diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 14e5712d..4fceaf6e 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,8 +9,8 @@ { "name": "codeman", "source": "./plugins/codeman", - "description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.", - "version": "1.45.0", + "description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.", + "version": "1.48.0", "author": { "name": "Ark0N", "url": "https://github.com/Ark0N" diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e08fd25..a41256ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,102 @@ # aicodeman +## 1.48.0 + +### Minor Changes + +- 4960bee: ### Thanks + - @opticon454 for a big round: the two-way Discord bridge (#741), custom endpoint context limits, Codex and DeepSeek model selection and the model picker (#752, #755, #757), Clone Repo into a custom folder (#744), the New folder button (#745), and the wrapped `.conf` file links (#735). + - @Randalix for the Edit history panel (#693). + - @aakhter for relative links that follow your `cd` (#740). + - @JDProfresh for settings saves that say when they failed (#736), a visible press on the phone keys (#737), and two test harness fixes (#738, #742). + - @timkjr for a tab strip that keeps its shape whichever tab is selected (#748). + - @Hugo-DDT for the parallel fix to the same wrapped file link bug (#734), whose `Bash(` test rows landed with the guard below. + + **Talk to your agents from Discord (#741).** An opt-in two-way bridge gives each tab its own thread: alerts go out, and a reply from an allowlisted account is typed into that tab. An agent that needs a person can run `codeman notify "" [--urgent] [--file ]`, and files travel both ways. Claude learns the command through its system prompt. Setup is in `docs/discord-bridge.md`. + + **Edit history panel (#693).** An opt-in header button (per device, off by default, never on phones) shows every Edit and Write a local Claude session made, subagents included, with each diff inline, plus the images a Bash call produced. It reads transcripts only when the panel asks, and keeps its 0600 per-session store only as long as the session's record. + + **Custom endpoints go further (#752, #755, #757).** OpenCode and Codex now receive the endpoint's context window, Codex launches on the model you picked, and endpoint discovery reads each model's context size from `/v1/models`, with a per-endpoint default for gateways that state none. DeepSeek Harness moves to a per-session home with a generated patch, so it runs the exact model you chose. The model picker gained a search box and marks the model one of your running sessions is using on an endpoint that cannot report it, or says plainly that nothing is known. + + **Clone into a custom folder, and make a folder from the picker (#744, #745).** Clone Repo can clone straight into a folder you pick, empty or not yet created, and registers it as a linked case. The path picker in Link Existing gained a New folder button, so a brand-new project folder no longer means a trip to a shell. + + **Relative links follow your `cd` (#740).** A relative path printed in the terminal now resolves against the directory the session's pane is in, not the folder the session started in. Only the base changes: what can be opened is the same as before. Session state carries it as the new optional `paneCwd` field. + + **Fixes.** + - **File links keep the whole name (#735).** `.conf` files and parenthesised paths with spaces link as one file in the terminal and the Response Viewer, and a path wrapped over several rows stays clickable from every row. An ambiguous hard wrap tries the unspaced name first and looks up the spaced one only after a 404, on click, never on hover. + - **A failed settings save says so (#736).** Any write the server did not store now reports the reason and keeps App Settings open, where only two kinds of refusal did before. Run mode and respawn preset writes warn too, and a failed tunnel stop no longer claims the tunnel stopped. + - **Phone keys show a press (#737).** Every toolbar button, the voice, attach and settings icons and the accessory keys now react on all seven skins. + - **The L-shaped tab strip holds its shape (#748).** A too-wide first tab is trimmed to the room beside the toolbar, so the layout no longer changes with the selected tab. + - **Credential files stay out of the attachment routes.** `webhook.json`, `custom-model-hosts.json`, the generated custom-model configs and everything under `backups/` could still be read through the attachment routes, which in multi-user mode means by any user. A config backup was the widest gap, since it holds copies of `users.json` and `settings.json`. A custom `configBackupDir` is outside what the blocklist can name, so keep that folder out of reach of other users. + - **The OpenCode Console breach notice is retired.** A new install has no record of having seen it, so every new OpenCode user was still being warned about an October incident. + + **Fixes applied while landing.** `codeman notify --file` refuses a credential file such as `.env` or a key under `.ssh`, even inside the tab's own folder, and the Discord bot token cannot be read through the attachment routes (#741). A very wide table cell in an agent's last message no longer stalls the server, and a file sent from Discord is no longer reported as something the agent produced (#741). A `Bash(` row keeps its links, because it holds a command rather than a path, while `Read(` and `Update(` rows link a spaced name whole (#735). A model id the CLI cannot launch is refused before anyone is asked to evict a loaded model, on both the swap route and quick-start (#752). A cases-folder refusal in Clone Repo now points at Clone Repo, and OpenSSL's `SSL_connect` errors classify as unreachable again (#744). Abandoning a tunnel enable records the settings baseline (#736). A session's unreachable `DSH_HOME` is logged when the server's home is linked instead (#755). Clone Repo and New folder no longer risk a hang on a mount that stopped answering (#744, #745). + + **Docs and tests.** Discord setup guide and wiki page, the Edit history and `paneCwd` API sections, the custom endpoint guide, and the architecture notes for the new subsystems. Two Docker and TUI test harnesses now pass under a root runner and on tmux 3.2a (#738, #742). + +## 1.47.0 + +### Minor Changes + +- 1ab01bb: ### Thanks + - @timkjr for five PRs: a wrapped tab strip that uses the space under the toolbar (#721), three fixes that stop Codeman reading and watching a remote session's folder on the local disk (#722, #727, #728), and a quieter build (#729). + - @kdxcxs for bodyless requests that work through Cloudflare Tunnel (#725), traced through a real Access + Tunnel hostname. + - @Hugo-DDT for a desktop dead key typed once and a Ctrl+V that stays a paste after a replay (#726), and @hedburgaren for reporting both in #716. + - @opticon454 for rendered Markdown that fills a resized File Viewer (#730). + - @JDProfresh for a middle click on a file path that opens the file (#731). + + **The wrapped tab strip uses the room under the toolbar (#721).** When the classic header strip wraps onto more rows (automatically, or with the two-rows setting), only the rows beside the toolbar stop short of it now; every row below runs the full header width, so long tab names no longer end up one per row. A strip that fits on one row looks exactly as before, and a strip tall enough to scroll keeps the plain wrap. + + **Fixes.** + - **Bodyless requests work through Cloudflare Tunnel (#725).** cloudflared forwards a POST with no body as an empty chunked request with no Content-Type, which the server refused with `415` before the route ran, so actions such as opening a saved web tab failed through a tunnel. An empty, untyped POST under `/api/` now reaches its route exactly like a `Content-Length: 0` one; anything with a body or another method is still refused as before. + - **A desktop dead key is typed once, and Ctrl+V stays a paste after a replay (#726, #716).** The soft-keyboard recovery for Android IMEs now stays off on a desktop (a fine pointer that is not Android), so a dead-key backtick no longer arrives twice. Plain-text Ctrl+V is now framed as a bracketed paste by Codeman itself, because xterm stops adding the markers once a terminal replay clears its paste mode, and a copied newline then submitted the prompt. The paste still scrolls to the bottom and clears the selection, in the main pane, grid tiles and the split pane. + - **Remote sessions no longer touch the local disk at their path (#722, #727, #728).** A remote (SSH) session's folder is a path on the remote host, but the Ralph `@fix_plan.md` watcher and the image watcher watched it on this machine (logging `ENOENT` at every start, or watching an unrelated local folder of the same name), and the Ralph file routes read and wrote it locally. Both watchers now skip remote sessions (the Session Options image-watcher switch is hidden there), the Ralph menu's **Import from File** reads `@fix_plan.md` on the remote host over the same ssh path the File Viewer uses, and **Write to File** (and the `@ralph_prompt.md` write) is refused for a remote session instead of landing in a local folder. + - **Rendered Markdown fills a resized File Viewer (#730).** The document kept a fixed 960px column from before the window could be resized, so it stopped reflowing once you dragged or expanded the window past it. + - **A middle click on a file path opens the file (#731).** In the Response Viewer and the File Viewer's rendered Markdown, a middle click on a file path or an in-document link opened the dashboard root in a new tab. It now does what a left click does; real web links keep the browser's own middle click. + - **No more duplicate-key warning on build (#729).** The Chinese translation table carried `Idle` twice. + + **Fixes applied while landing.** A remote session's fix plan stays authoritative as it is for a local one, so a prose line such as "All tasks completed" cannot mark every todo done and end a remote Ralph loop when it cannot end a local one (#722). The Session Options image-watcher switch flips back and says so when the server refuses the change, instead of reporting success (#727). The remote `@fix_plan.md` read uses the File Viewer's own containment check rather than a copy of it (#728). ASCII-diagram blocks in the File Viewer's rendered Markdown follow the window instead of staying 720px wide (#730). A paste marker hidden inside another one (`ESC[20` + `ESC[201~` + `1~`) can no longer survive the strip and end the bracketed paste early, in Ctrl+V and in the phone composer alike, and an Android device with a mouse, a trackpad cover or a stylus keeps the soft-keyboard fixes, which turned off there because Chromium reports such a pointer as fine (#726). + + **Docs and tests.** The L-shaped strip in the frontend notes and the architecture notes, the remote-session rows for the Ralph file routes and the two watchers in the remote-sessions guide and the architecture notes, and an overview of the empty-POST parser in its module. + +## 1.46.0 + +### Minor Changes + +- de1aab9: ### Thanks + - @JDProfresh for five PRs: a phone keyboard that no longer costs Claude its transcript (#711, measured in #672), a + on each session tab that starts a session beside it (#712, shaped in Discussion #700), a tunnel that needs its own opt-in instead of the LAN one (#718, from their report #706), a settings.json that two writers can no longer tear (#719), and a DeepSeek test that passes on current Node (#710). + - @aakhter for six PRs: `codeman agent spawn` in a project directory (#708), worker ids kept when the readiness check fails (#709), the offline Codex history reader (#707), experimental bounded inspection and guarded submission for agents (#694, from Discussion #695), links that follow URLs and paths across wrapped rows and table cells (#686), and file paths with spaces linked as one path (#687). + - @opticon454 for the Fetch button and fetch interval in the Git status window (#714). + - @johoja12 for Cmd+K arrows that switch the CLI both ways after you type a case name (#717). + + **Fetch from the Git status window (#714).** The Git status window has a Fetch button next to Refresh. It runs `git fetch` for the repositories it lists and shows each one's result. An optional interval runs it for you: off by default, app-wide in App Settings, Bottom bar, with a per-session override in Session Options, Git. A fetch only moves remote-tracking refs (HEAD, the index and the working tree stay as they are), it never runs for a Docker or remote session, and it is bounded in time, output and concurrency. + + **Start a session beside any tab (#712, opt-in).** Turn on **New Session Button on Tabs** (App Settings, Tabs) and each session tab gets a +: pick any CLI its case can run (a Docker case offers what its container has) and the new session starts in that case or worktree and that tab's group, without touching the toolbar's case or Run mode. The row menu offers the same as "New session here…" for the keyboard. + + **The phone keyboard no longer costs Claude its transcript (#711).** Every keyboard open and close used to resize the PTY, and Claude Code answers each height change by reprinting only the tail that fits, so lines above it were lost from tmux for good (25 of 100 in five keyboard cycles). A local Claude session now keeps its rows while the keyboard is up and slides the screen so the composer stays above the keyboard. On by default as **Keep Size Under Keyboard** (App Settings, per device); off restores the old resize. Fullscreen Claude (mouse tracking on) still resizes as before. + + **Typing stays smooth in a long prompt (#713).** The local echo overlay (the instant preview of what you type) rebuilt every character of the prompt on each keystroke, so on a phone-class CPU a 4,000-character prompt took most of a second per key. It now rebuilds only the line that changed, with one paint layer per line, so a keystroke costs the same however long the prompt is. Every character still sits on its terminal cell. This ships in `xterm-zerolag-input` too. + + **Links survive wrapping and spaces (#686, #687).** A URL or path a CLI wrapped inside a table cell or an indented paragraph now links whole instead of in fragments, URLs keep balanced parentheses (`…/Team-Space(QA)Archive/…`), a full-width table divider no longer glues onto the row below, and OSC 8 hyperlinks open through Codeman's own opener after a confirmation. A file path with spaces (`/mnt/c/OneDrive - Contoso/report.docx`, `Saved to: ~/My Docs/plan.md`) links as one path in the terminal and the response viewer when the line bounds it, and a shell prompt that prints its working directory no longer turns the whole command line into one link. + + **`codeman agent spawn` in a project directory (#708).** Without a case name, `spawn` starts the worker in the current directory, or in an existing directory given with `--working-dir`, through `POST /api/v1/sessions` and the mode's start route. Named cases still use quick-start. `--json` prints `{ sessionId, workingDir, created: true }` plus `ready`, `readinessError` or `startupError`. An empty `--working-dir`, a bare spawn from your home directory (it would install Codeman's hooks into `~/.claude`; pass `--working-dir ~` to mean it) and an unknown `--mode` are refused with exit 4 before any request. + + **Read saved Codex conversations offline (#707).** `codeman history read` reads Codex's saved rollouts without a Codeman server: by file, by Codex session id, or a whole conversation with its sub-agents and forks, filtered by topic or UTC date with whole turns kept. Reads and output have separate, raisable budgets, and anything it could not read or attribute (a compressed `.jsonl.zst` rollout, a file still being written) is reported through `complete: false` instead of failing the lookup. The codeman skill routes to it. + + **Experimental: bounded inspection and guarded submission for agents (#694, opt-in).** `codeman agent inspect` reads bounded, cursor-paged evidence from another session (a local Codex session's attributed rollout records, otherwise a labelled pane snapshot), and `agent stage` / `agent submit` type one line into a verified local Codex composer and later send exactly one Enter against a hash-confirmed receipt. Off by default behind **Experimental agent coordination** (App Settings, Cross-session features); while off the three routes answer `403 FORBIDDEN` naming the setting. These routes and verbs are experimental under the versioning policy and may change in a minor or patch release. During the few milliseconds of a guarded submit, other input is held and written in order afterwards; a tagged input POST in that window answers `409 CONFLICT` so its sender retries, and an untagged one (the phone keys, the Stop button) is held and answered once written. + + **Cmd+K arrows switch the CLI both ways (#717).** After typing a case name in Cmd+K, plain Left and Right now both change the New session row's CLI whenever the caret is at either end of the query (only Right did before). Inside the query the arrows move the caret as usual. + + **Fixes.** + - **Security: a LAN install's acknowledgement no longer opens a public tunnel (#718).** `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` (and `--allow-unauthenticated-network`), which the installer writes for every LAN install without a password, also let the Cloudflare tunnel start, so one click on the welcome screen's tunnel link published full terminal control to a public URL with no login and no confirm. It now covers the network bind only. An unauthenticated tunnel has its own opt-in, `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `codeman web --allow-unauthenticated-tunnel` (carried by `codeman web -d` and `codeman service install`), which the installer never writes; if you relied on the network variable to start the tunnel without a password, set one of those. With no password and no multi-user mode, the browser now asks before every tunnel enable, using the new `loginConfigured` field of `GET /api/tunnel/status`. Both acknowledgement variables and their flags are now listed as stable in the versioning policy. + - **settings.json can no longer be torn (#719).** A quick-start's `lastUsedCase` write was not awaited and not atomic, so it could overlap the next settings save and leave one write's bytes over the tail of the other, after which every reader saw `{}`. Every write to settings.json now goes through one serialized writer that writes a temp file and renames it into place, and a file that does not parse is refused with `409 CONFLICT` instead of being overwritten with only the writer's own keys. A case rename checks this before it ends any session. + - **A worker id is never lost (#709).** When `codeman agent spawn` created a worker but its readiness request failed, it exited without the new session id. It now prints the id (and `--json` carries it) and exits 1, so the caller can inspect or delete the worker. `codeman agent` and `codeman mcp` also name the error code (such as `ECONNREFUSED`) when a request fails with an empty message. + - **The DeepSeek transcript test passes on Node 24.22+ and 26.10+ (#710).** It pinned Node's old zstd decoder, which stopped at the first frame until nodejs/node#65865. + + **Fixes applied while landing.** The Git fetch interval field is no longer reset by a session update while you type in it, a session holding only half an override shows the app's value for the other half, reading a repository's `core.sshCommand` no longer doubles the fetch timeout, a `GIT_SSH` in the server's environment is honoured, and the Fetch button's disabled and busy styles no longer reach other icon buttons and stop pulsing under reduced motion (#714). A + launch keeps the toolbar on its own case, and pressing the + again closes its picker (#712). The fetch scheduler no longer logs the text of a malformed settings.json on every 15-second tick (the parser's message could quote an API key; it now says the file is not valid JSON, once), and a fetch is skipped while the Docker case list cannot be read, so it can never run inside a Docker workspace (#714). A case rename answers `409 CONFLICT` for a settings.json it cannot write before it ends any session (#719). The unauthenticated-tunnel warning no longer fires in multi-user mode, where the tunnel has a login in front of it (#718). Untagged input during a guarded submit is held instead of refused, and the skill's staging guide says so (#694). A shell prompt line (a spaced working directory followed by `$` included) and a labelled `../` spaced path link as they should, and a bold label or inline code before a path in the response viewer splits it as the terminal does (#687); a URL whose row soft-wraps no longer glues a trailing column onto the next path, and in a table a link in the column right of a wrapped URL stays clickable on the URL's first row (#686). The keyboard hold reads the terminal's height only while rows are held (#711). The README, the plugin's README and its marketplace description now say the codeman skill reads saved Codex conversations outside a Codeman session and refuses everything else there (#707). + + **Docs and tests.** New sections for the keyboard hold (next to the terminal geometry rule it is an exception to), the tab +, Git fetch, the settings writer, the tunnel opt-in, the directory spawn, the offline history reader and the experimental agent routes, in CLAUDE.md, the API reference, the architecture notes, the wiki and the codeman skill. + ## 1.45.0 ### Minor Changes diff --git a/CLAUDE.md b/CLAUDE.md index bd2594ac..4f350fdb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ Uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`) via `@chan When the user says **COM** (`COM` = patch, `COM minor`, `COM major`): **Read [`docs/releasing.md`](docs/releasing.md) and follow every step in order**; never improvise a release from memory. It covers the changeset, `npm run version-packages`, the `**Version**` line below, the branch check and explicit-path commit, deploy, the getcodeman.com badge, watching BOTH CI workflows, the MCP registry publish, and the Discussions announcement. -**Version**: 1.45.0 (must match `package.json`) +**Version**: 1.48.0 (must match `package.json`) ## Project Overview @@ -129,9 +129,11 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md` -**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is a CLIENT: no new route, no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` +**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked `agent-session-routes.ts` module (experimental, behind `agentCoordinationEnabled`, SYNCED, default OFF: all three routes answer `403 FORBIDDEN` naming it while off, and the CLI shows that verbatim); the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), while `spawn` without a case (cwd or `--working-dir`) is `POST /api/v1/sessions` plus `/interactive` or `/shell` without that header, so no agent-case marker, and refuses (exit 4, before any request) an empty `--working-dir` and a bare spawn from a HOME cwd, since it always sends an explicit `workingDir` and the server's no-`workingDir` hooks skip cannot apply (test/cli-agent-project-spawn.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` -**MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; no new route). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`. +**Discord bridge (`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`)**: opt-in two-way Discord (a bot, not the one-way webhook), one thread per tab. Split like `webhook-notify.ts`: pure helpers, a 0600 store (`discord.json` holds the bot token, is never returned by these routes and is on the sensitive-path list; `discord-bridge.json` maps sessionId to thread id), then IO (`DiscordApi` REST with injected fetch, `DiscordGateway` inbound WebSocket with injected socket, `DiscordBridge` rules). Outbound hangs off `WebServer.sendPushNotifications` beside the webhook (so it works with zero push subscriptions) and tab events off `WebServer.broadcast` (`bridgeSessionEvent`); a finished turn (Claude's idle and stop hooks, DeepSeek's stop; the other CLIs raise no turn end, so theirs arrive with an error, a blocked respawn, a crash loop or `codeman notify`) or an error posts the CLI's WHOLE last message as markdown, read from its transcript through the response viewer's reader (`WebServer.lastResponseFor`, set by the session routes: the CLI's own transcript, or the pane segmented into turns for the CLIs that keep none; never for a shell), split over posts of at most 1900 characters and not posted twice (keyed on the text plus its turn, recorded only after the first post succeeds), while a dialog and every CLI without a transcript post the RENDERED tmux screen (`screenTail`, with the composer row and the status lines under it cut), never the raw stream, which a full-screen UI repaints in place; `staleThreads` (default `keep`) archives or deletes threads the bot owns that no tab maps to, after boot and then daily, never one under an hour old, never with an empty map and at most 25 per run. Inbound replies are typed through `session.writeViaMux`. ⚠️ Guards that live in code and tests pin: only `allowedUserIds`, in `guildId`, inside a thread the bridge created, are acted on, everything else is dropped unread; a reply is one printable line plus Enter (a lone digit while a permission or question is pending goes as the key alone, since the dialog acts on it), capped and rate limited, and `discord-notify` is a per-session token bucket (6 a minute, 429 `RATE_LIMITED`; `fileProblem` probes the folder with the bounded probe first); posts send `allowed_mentions` limited to the allowlisted ids; received files go only from Discord's own HTTPS hosts (max 5 x 8 MB, names reduced to `[A-Za-z0-9._-]`) into `/.codeman-discord/`, and a sent file's REAL path must lie inside the tab's folder (`fileProblem`, symlinks followed); a Discord failure never reaches a session; the alert tail is a function the bridge calls only once it will post (no tmux exec per event while off); a delete reaches the bridge from the cleanup path with `killMux` true, never from `session:deleted`, which a detach also fires; `.codeman-discord/` is probed with the bounded probe, refused if a symlink, and ignores itself with a `.gitignore` of `*`; a config restore of `discord.json` reloads the running bridge (`afterRestore`). The startup hint ("how to reach the user") rides the CLI registry as the engine value `appendSystemPrompt` (Claude's `--append-system-prompt`, undefined unless the bridge and `startupHint` are on, so every other launch is byte-identical) No other CLI is told. `codeman notify "" [--urgent] [--file ]` wraps `POST /api/sessions/:id/discord-notify`, which accepts a unique 8-character id prefix (a Docker case exports it cut). → `docs/discord-bridge.md`, `docs/wiki/Discord-Bridge.md`, `docs/api-reference.md` + +**MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; the MCP server adds no routes). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`. **Agent-created case marker** (`src/agent-case-marker.ts`): a case dir that `POST /api/quick-start` CREATES for an agent-driven spawn (signal: the preamble's `X-Codeman-Agent-Origin` header / `agentOrigin` field, else a resolved `parentSessionId`) gets `.codeman-agent-case.json`, published as `agentCreated` on `GET /api/cases`; `GET /api/cases/agent-created` is the cleanup listing (`inUse`, `modifiedAt`) behind Add Case → Manage. ⚠️ Only the branch that CREATES the directory may write it: never label a linked case, cloned repo or pre-existing path (it drives a recursive delete). ⚠️ Reading is total: anything but a well-formed v1 marker reads as not agent-created. ⚠️ Removal stays on `DELETE /api/cases/:name` (the ONE recursive-delete path), and the sweep excludes `inUse` cases. ⚠️ Changing the preamble's headers requires bumping `CODEMAN_PREAMBLE`. → [architecture-invariants#agent-created-case-marker](docs/architecture-invariants.md#agent-created-case-marker) @@ -175,7 +177,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md` -**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md` +**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.workDetect.composerPopupLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md` **External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP, Copilot)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All nine **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. ⚠️ **Copilot**: `allowAll` (`--yolo`) is a `privilegedParam` and `COPILOT_HOME`/`COPILOT_ALLOW_ALL`/`COPILOT_PROVIDER_*`/`COPILOT_MODEL` are privileged env keys, and so is every other `COPILOT_*` variable the CLI reads that switches approvals, runs code with no approval row or sends its sign-in token elsewhere (the 1.0.98 list is in `stock.ts`; the prefix admits whatever a later release adds, so re-read the bundle whenever the measured version moves); the resume id must start with a letter or digit (`path-segment`), since `--resume`'s value is optional and `--resume --yolo` would otherwise make `--yolo` its own flag; `--name` is dropped next to `--resume`/`--continue` (Copilot refuses the pair); its mouse DECSETs are stripped (`strip-mux-and-mouse`) so a drag selects and the wheel is forwarded by `_shouldForwardWheelToApp` while `cliMouseTracking` is on; `composerReadyMark` is the footer hint `/ commands · ? help`, NOT `❯` (the folder-trust dialog draws `❯ 1. Yes`); `last-response` reads the pane's visible screen (`transcriptNeedsScreenCapture`) through a per-CLI `PANE_DIALECTS` row, because its stream is cursor moves, not lines (a prompt row scrolled off by a tall answer reads as the answer's tail unless a banner/trust dialog is on screen, and typed composer text is not a prompt); Docker seeds no Copilot credential (`config.json` holds a plain-text token without a keyring). → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp) @@ -183,13 +185,13 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `/projects/` is renamed too, or merged entry by entry, never overwriting, into one already at the new key (case delete never removes history, so an earlier case of the new name leaves one); other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, and both are restored if the registry write fails. -**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key). ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles) +**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config; the one exception is a recipe's `linkFromRealHome` (deepseek's `profiles`, `sessions`, `settings.yaml`), which symlinks those named entries back out of the real home so they survive the redirect. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key). ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles) ⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. The floor also divides that context by the model's parallel slot count (`modelSlotCounts`: `--parallel`/`-np` from `cmd`, else `/props` `total_slots`; `perSlotContext()`), and `/props` only ever RAISES that warning: it supplies the context only when `cmd` states none and the count only when `cmd` has no `--parallel`, never a larger pool. `/props` is probed once per launch command (it refreshes llama-swap's last-use time, so a `ttl` would never expire), and a `/props` context beside an explicit `--parallel N` is already per slot, so it is not divided again. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles) **MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md` -**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `: ` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path and `POST /api/sessions/:id/input` ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: by default only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title and a pinned one stops Claude generating its own; the opt-in synced `pinCliSessionName` (default OFF, re-read at every spawn and respawn) pins every tab name, after which a Codeman rename holds only until the next turn; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title) +**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path, `POST /api/sessions/:id/input`, the experimental guarded stage (`stageGuardedInput()`, `POST /api/sessions/:id/stage`, typed on an agent's behalf like the input route) and a Discord reply (`DiscordBridge.onGatewayMessage`, through the bridge's `sendInput`) ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: by default only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title and a pinned one stops Claude generating its own; the opt-in synced `pinCliSessionName` (default OFF, re-read at every spawn and respawn) pins every tab name, after which a Codeman rename holds only until the next turn; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title) **Maintainer bot (external)**: the Telegram bot that reviews open PRs and triages discussion threads in Codeman sessions used to live at `scripts/pr-bot/`. It moved OUT of this repository on 2026-09-14, to `~/codeman-cases/prbot/` (its own private git repo, systemd unit `codeman-pr-bot`, guide + agent rules in its own `README.md` and `CLAUDE.md`). It is a CLIENT of Codeman's HTTP API like any other, so nothing here depends on it and it is not part of the server, the CLI or the npm package. ⚠️ It spawns real sessions named `prbot-<n>` / `dscbot-<n>` on the local Codeman and holds clones under `~/.codeman/pr-bot/`, so those session names and that data dir are taken; it also fetches PR heads into `refs/pr-bot/*` of this checkout and must never check out, reset or clean it. The CHANGELOG entries for 1.25.0 and earlier still describe it, which is history rather than drift. @@ -226,21 +228,27 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Prompt uploads** (`POST /api/sessions/:id/paste-image`: clipboard paste, drop, the Compose Attach key and the phone toolbar's Attach button; client wiring in `image-input.js` and `keyboard-accessory.js`): a pick is written to `<workspace>/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `<workspace>/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. Images are buffered in memory under `MAX_PASTE_IMAGE_BYTES` (50MB) after a client-side canvas re-encode; a DOCUMENT (pdf, docx, pptx, xlsx, zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml; the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`, which carry sh/py/js/html) is admitted by its name's extension alone, buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (`%PDF-`, `PK\x03\x04` for the OOXML trio and zip, UTF-8 without a NUL byte for text: a SIGNATURE or ENCODING check, never a structure or content check, so generic zip bytes pass as `.docx` and a script renamed `.txt` is text; the sh/py/js/html exclusion is a SCOPE choice by extension, keeping the server from storing what it would serve or run, not a content filter: the agent can already write any of those) and saved as `paste-<ts>-<hex>-<stem><ext>` with a sanitized stem (`[A-Za-z0-9._-]`, 40 chars, a wholly non-ASCII name leaves no stem; the `paste-` prefix is what the GC matches). A VIDEO is never buffered: `?kind=video` streams it to disk under `MAX_PASTE_VIDEO_BYTES` (1GB, env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), judges the container on the first 12 bytes as they arrive (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv; a mislabeled body is drained unwritten from there, never parked on disk first) and unlinks on any failure, a dead transfer included. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced: mp4/webm/mov/m4v/ogv, so an uploaded video is one the file viewer can also play; 3gp/mkv are out, since a phone's camera roll produces neither and neither previews), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. Busboy takes its size limit before the part is seen, which is why the choice rides the URL and not the file. ⚠️ A video draws on its OWN per-(IP, session) rate bucket (`consumeVideoToken`, 3/min) beside the image bucket (`consumePasteToken`, 30/min): at 1GB each, 30/min would be 30GB of disk a minute, so the lower rate bounds it to 3GB; the image path keeps the 30/min bucket and is unchanged without the flag. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it (Docker is fine, the workspace is bind-mounted at the same absolute path). ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (`probePathKind(session.workingDir, { pastCap: true })`, the #516 rule) before the first disk touch and refuses an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). ⚠️ Dispatch mirrors the client: an `image/*` MIME takes the IMAGE path whatever the name says (a GIF named `animation.txt` stays a GIF), a document extension takes the document branch, and anything else takes the image path exactly as before, where the HEIC sniff still recovers a HEIC under a generic name; a foreign extension with no image MIME gets its `Unsupported file type` 400 only AFTER that sniff fails, never before, so nothing the image path ever accepted by its bytes is refused. A text mismatch is refused as not UTF-8 text (UTF-16 when the file opens with a UTF-16 BOM, as Excel's "Unicode Text" and Notepad's "Unicode" do), telling the user to save it as UTF-8, and its log line names the failure (`utf16-bom`/`nul-byte`/`not-utf8`) and never prints the bytes, since the head of a text file is user content. ⚠️ The client classifies a pick through ONE helper, `_promptAttachKind()` (MIME type first, then the name's extension, because some Android file managers hand over a File with none; returns `image`, `video` or `document`); every pick and paste filter (Attach, drop, the tile grid's drop in `tile-grid.js`, the Compose and paste-dialog pickers, the clipboard handlers), the flag and the re-encode gate read it, never `file.type` directly. Those surfaces ADMIT through `_admitPromptAttachments()` (image-input.js), which calls it and toasts what a pick skipped (how many files of a mixed pick, `Unsupported file type` when none pass, nothing for a text paste); a new surface admits through that helper too, never by filtering on `_promptAttachKind()` itself, or it loses the toasts. ⚠️ A pick that lands while the tile grid owns the terminal goes to the PTY, never the main terminal's overlay: that terminal is parked and hidden then, and a tile's Enter cannot flush it (`_tilesOwnTerminal()` in the `_attachFromToolbar` guard). Tests: `test/toolbar-attach.test.ts`, `test/mobile-prompt-composer.test.ts`, `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, `test/upload-document-list-parity.test.ts` (the browser's copies of `ALLOWED_DOCUMENT_EXTS`: `_promptAttachKind()`, the three pickers' `accept=` and the two file-path link patterns), the paste-image block of `test/routes/session-routes.test.ts`. → [architecture-invariants#buffers-uploads-and-terminal-history](docs/architecture-invariants.md#buffers-uploads-and-terminal-history) -**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which adds `~/`, relative and folder paths without overlapping a rooted match or a URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view/<cap>/...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) +**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which reserves spaced-path spans first (an unlinkable one only while its last word holds a `/`; any label, before an absolute or relative path, must be an explicit path label, prose after that label stays out, and an earlier connective or verb such as `and` or `has` (`SPACED_PATH_PROSE_WORD`) makes any candidate prose; rules and trade-offs in the architecture-invariants paragraph linked below), then adds rooted, `~/`, relative and folder paths without overlapping a reservation, earlier match or URL; a relative path resolves against the pane's live directory `SessionState.paneCwd` when known, else `workingDir` (`relativeResolutionBase()` + `resolveLinkedFilePath`; `paneCwd` is only a BASE, never a confinement root: routing and the file routes still judge against `workingDir`, and remote/docker/direct-PTY/rediscovered sessions never get one, scoped by `paneExitApplies`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. An ambiguous parenthesised hard wrap is checked only on activation, original first: only a 404 permits a spaced-path lookup, and only a registered alternative is opened; every other original result uses the normal opener, never an attachment-policy gate on workspace files, and hover performs no lookup. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view/<cap>/...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) -**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) +The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens, whole email addresses, labels ending in `:`, measurements and clock times (`45%`, `12.3ms`, `10:00:00`) and a `name=value` field under a URL ending in `/` never continue a URL; a token with only `-`, `_` or `.` is also refused when it carries a call paren (`client.get()`) or ends a sentence (`Done.`). A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. -**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md` +**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping**: it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing has it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > New Case makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) + +**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md` **Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search) -**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline (it never fetches and never writes). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate). +**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline on its own (it never fetches; fetching is the separate path below). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate). + +**Git fetch** (`src/git-fetch.ts`, `src/git-fetch-scheduler.ts`, `routes/git-status-routes.ts`, the panel's **Fetch** button): `POST /api/sessions/:id/git-fetch` is the ONLY git command the Git PANEL runs that WRITES (cloning and worktree management write too, but only when the user asked for them at that moment), and it is the only one that can run later by itself, on a timer, and the only one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config commands such as `credential.helper`), which is why it is opt-in and never runs on a timer until the user turns it on. ⚠️ Fetch updates remote-tracking refs only; it must never commit, check out, merge or touch the working tree. ⚠️ It is not as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref`, hence one folder is fetched by at most one run at a time (`fetchWorkspaceOnce` joins a run already going), and `FETCH_CONFIG_ARGS` (`-c core.fsmonitor=false -c maintenance.auto=false -c gc.auto=0`) keeps an unattended fetch from starting a gc in a repository the agent is using. ⚠️ `buildFetchArgs()` is `['fetch', '--quiet']` and must stay verb-invariant (`test/git-fetch.test.ts`); those `-c` overrides are added by the runner, not by `buildFetchArgs()`. ⚠️ The git timeout must reach the whole process TREE, not just the `git` parent: `execFile` signals the parent only, so `runFetch` spawns `detached` and signals the negative pid (SIGTERM, then SIGKILL `FETCH_KILL_ESCALATION_MS` later), and the caller is answered AT the timeout while the group is still dying: that is the point. `test/git-fetch.test.ts` proves it against a local server that never answers, by group membership rather than parentage. A non-zero exit must still reject the way `execFile` would, or a real failure reads as a clean run with no reason. ⚠️ The same Docker rule as above applies BEFORE any git runs (a repository at or inside a Docker case workspace is refused), and remote sessions answer `unsupported`. ⚠️ Keep it non-interactive (`gitNonInteractiveEnv()`: `GIT_TERMINAL_PROMPT=0` + BatchMode ssh) or a fetch hangs on an invisible login prompt, but that BatchMode ssh is a DEFAULT, never an override: `fetchEnvFor` must not overwrite the repository's `core.sshCommand`, the user's own `GIT_SSH_COMMAND` or a `GIT_SSH` (`readRepoSshCommand` reads it inside the same limiter slot and the same wall-clock bound as the fetch it serves, and the spawn gets only what the read left), and `StrictHostKeyChecking=accept-new` writes an unknown host key into `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. Keep it bounded: `MAX_REPOS`/`MAX_REPOS_LIMIT` (12/50), `MAX_FETCH_OUTPUT_BYTES` (2 MB), the git timeout per spawn, and `FETCH_CONCURRENCY` (4) as ONE `Limiter` shared by the fetches of the whole tick: `createLimiter` keeps its count in the closure, so a limiter that delegated to `mapLimited` per call bounds each call, and 4 folders × 4 repositories would start 16 git processes. ⚠️ One semaphore cannot bound both the folders and the fetches: a folder holding a slot would then wait for a slot its own fetch needs, so the tick bounds its folders with `mapLimited` and hands the shared bound to every run. ⚠️ Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`) and the browser writes it with `textContent`. Scheduler semantics: settings are read fresh each tick (`GIT_FETCH_TICK_INTERVAL_MS`, 15 s), a per-session override wins even while the app-wide switch is off, the interval is timed from the run START, state is keyed by FOLDER (sessions sharing one folder are the same clone, so one run serves them all, the schedule is stamped at selection, and every session on a folder is remembered, a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins (both orders are tested), and the folder is probed once for the whole group rather than once per session on it: closing the one that holds the override must not stop the folder's fetching while another session is still open on it), a tick is skipped while the previous one is still going, and a user-chosen folder is probed BOUNDED with the default options before anything runs (rule #516: `unknown` is never read as `absent`). ⚠️ The two settings (`gitFetchEnabled`, `gitFetchIntervalSeconds`, off/300 by default, clamped 30–3600) are **synced**, not per-device: they must stay out of `displayKeys` and out of the strip destructure in settings-ui.js, or the scheduler reads nobody's value. Per-session state rides `SessionUpdated` (no new SSE event) as `gitFetch`, and the click route publishes the run's trigger, not the request's: `trigger: 'click'` only when the click was the run, because a click that joined a tick already going fetched nothing and cannot report itself as the fetch (`GitFetchRun` carries it, and the scheduler publishes the run's trigger for the same reason: its tick can join a click too). The UI's "by click" text depends on it. Tests: `test/git-fetch.test.ts`, `test/git-fetch-scheduler.test.ts`, `test/routes/session-routes-git-fetch-settings.test.ts`, `test/git-fetch-i18n.test.ts`, `test/app-settings-structure.test.ts`, `test/session-options-structure.test.ts`, `test/git-fetch-session-options-ui.test.ts`, and the Fetch block of `test/git-status.browser.test.ts` (browser suite, not in the gate). + +**Edit history panel** (`showEditHistory`, per-device, default OFF; issue #643): which files a LOCAL claude session edited, with diffs and the images a Bash call produced, read from its transcripts. ⚠️ **Reads only on request:** only the open panel triggers a read (`?sync=1`); the server never syncs on its own (no hook, timer or second watcher; wiring-guard test). ⚠️ **The store holds secrets** (every diff): 0600 under `dataPath('edit-history')`, bounded per session, deleted with the session's record, blocked in `isSensitivePath`, never fed to `/api/search`. ⚠️ **Images only through the existing file surfaces** (`file-raw` in the workspace, the attachment route outside it); no route here serves a transcript-supplied path, and such paths are probed only through `probePathKind()`. Remote, Docker and non-claude sessions answer `unsupported`. → [architecture-invariants#edit-history-panel](docs/architecture-invariants.md#edit-history-panel) **Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route, `GET /api/download` and the rendered-HTML route `GET /html-view/<cap>/...` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware) **Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode-and-workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization) -**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case) +**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). ⚠️ A `path` is the folder cloned INTO (no case-name subfolder) and registers a linked case: admin-only in multi-user mode, judged by `prepareNewCasePath()`, empty or new only; an empty one is cloned into directly and never removed, and emptied after a failure only when git really ran and did not refuse it. ⚠️ A destination at or inside a Docker case workspace is refused `400` on both branches: git would run the new repository's hooks and filters on the host. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case) **Git worktrees of a case** (case picker → **Git worktrees…**; `src/git-worktrees.ts`, `routes/worktree-routes.ts`): a local repository case keeps worktrees at `<repo>/.claude/worktrees/<name>` on `worktree-<name>`, Claude Code's own `--worktree` layout. ⚠️ Only that managed path is ever created or removed, every component `lstat`ed and realpath-checked (a symlinked `.claude` is refused). ⚠️ git never runs for a Docker or remote case, nor at or inside a Docker workspace (`worktree add` runs hooks and filters on the host). ⚠️ Remove is `git worktree remove` WITHOUT `--force`, refused while a live session is inside; branch delete is `-d`, never `-D`. ⚠️ Sessions start through the existing create paths (quick-start `worktree`, or `POST /api/sessions` `workingDir`), never a new spawn path. → [architecture-invariants#git-worktrees-of-a-case](docs/architecture-invariants.md#git-worktrees-of-a-case) @@ -262,7 +270,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package) -**PTY and browser terminal geometry** (#464): a browser terminal whose width differs from the PTY's garbles Claude's redraws, so `syncTerminalGeometry()` (terminal-ui.js) is the ONE function that may resize the main terminal (never a bare `fitAddon.fit()`), a font change is a geometry change, and the fit is withheld wherever the SIGWINCH is. ⚠️ Resize is answered with `Session.ptyGeometry`, and a client adopts its COLUMNS only (never rows); `ptyGeometry` is null without a live pane. → [architecture-invariants#pty-and-browser-terminal-geometry](docs/architecture-invariants.md#pty-and-browser-terminal-geometry) +**PTY and browser terminal geometry** (#464): a browser terminal whose width differs from the PTY's garbles Claude's redraws, so `syncTerminalGeometry()` (terminal-ui.js) is the ONE function that may resize the main terminal (never a bare `fitAddon.fit()`), a font change is a geometry change, and the fit is withheld wherever the SIGWINCH is. ⚠️ Resize is answered with `Session.ptyGeometry`, and a client adopts its COLUMNS only (never rows); `ptyGeometry` is null without a live pane. ⚠️ The one sanctioned screen taller than its box is the phone keyboard row hold (#672, `keyboardHoldsRows`, default ON, inline renderers only via `capabilities.keyboardHold`): the PTY keeps its rows while the keyboard is up (each height change makes Claude's inline renderer drop transcript lines from tmux) and `--kb-clip` keeps the last content row at the bottom of the visible box. → [architecture-invariants#pty-and-browser-terminal-geometry](docs/architecture-invariants.md#pty-and-browser-terminal-geometry) **Service worker precache** (`sw.js` + `scripts/build.mjs`): `BUILD_ID` and `HASHED_ASSETS` are build-generated and the build THROWS unless each declaration appears exactly once; `caches.match` must pass `ignoreSearch: true` because `cacheBustAssets` appends `?v=` to hashed names. → [architecture-invariants#service-worker-precache-and-cache-key](docs/architecture-invariants.md#service-worker-precache-and-cache-key) @@ -281,7 +289,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | **Rate limit** | 10 failed auth/IP → 429 (15min decay). Multi-user mode instead checks 50/IP (`AUTH_IP_FAILURE_MAX`) and 10 per (username, IP) BEFORE the password, so past either even a correct password from that IP waits (the account still logs in from another IP), plus 10 per username AFTER it (429 to wrong guesses from any IP, never to a correct password). QR and hook-secret have separate buckets, so neither can lock out login | | **Hook bypass** | `/api/hook-event` + `/api/status-telemetry` skip Basic auth (localhost-only, schema-validated), but when auth is active the loopback bypass requires `X-Codeman-Hook-Secret` **unconditionally** (Codeman cannot detect a user's own loopback reverse proxy) | | **Lost-frame page** | The THIRD unauthenticated 200, beside the two hook routes, and the only one decided by request headers alone: a `GET`/`HEAD` carrying `Sec-Fetch-Dest: iframe\|frame`, `Accept: text/html` and mode `navigate` (or none), for a path that is NOT a registered route (never `/api/`, `/ws/`, `/q/`), is answered BEFORE the credential checks with the static web-tab recovery page (`lostWebviewFramePage`: no reflected input, `default-src 'none'` plus its own script hash, `no-store`). `/` is the one registered route also admitted, only when the request carries neither `codeman_session` nor `Authorization` (nothing in Codeman frames its own root; a sandboxed frame has neither), since the landing page masks to exactly `/` and its reload otherwise rendered Codeman inside the web tab. ⚠️ A non-browser client can set those headers, so an unauthenticated caller can tell a registered route (401) from a non-route (200) and enumerate the route table; accepted, the routes are public in `docs/api-reference.md`. Pinned by `test/webview-auth-exemption.test.ts` + `test/webview-lost-root-frame.test.ts` | -| **Tunnel** | Enabling a tunnel **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` or the per-request `acknowledgeUnauthTunnel:true` action field (never persisted) | +| **Tunnel** | Enabling a tunnel **refuses** without a login (`CODEMAN_PASSWORD` or multi-user mode) unless exposure is acknowledged via `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` / `--allow-unauthenticated-tunnel` or the per-request `acknowledgeUnauthTunnel:true` action field (never persisted). `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` acknowledges the BIND only and never satisfies this guard (#706: `install.sh` writes it for every passwordless LAN install). The authorization is decided ONCE per request, running or not, and required again at the `start()` site: the settings-file awaits between them let a concurrent stop land, and an unauthorized enable never writes `tunnelEnabled:true`. With no login the browser asks before every enable (welcome link, tunnel panel, App Settings save that turns it on) through ONE helper, `_confirmTunnelEnable()` (settings-ui.js), which reads `loginConfigured` from `GET /api/tunnel/status` (computed in the route by the guard's own test, never in `TunnelManager`); the 403 handler is the fallback when that read fails | | **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`/`GROK_*`/`XAI_*`/`DSH_*`/`DEEPSEEK_*`/`OMP_*`/`COPILOT_*`) | | **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS | @@ -302,7 +310,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/` - **API endpoint**: Types in `src/types/` domain file, route in `src/web/routes/*-routes.ts`. Return the `ApiResponse` envelope (`{ success: true, data }`; errors via `createErrorResponse()` with proper status code). Validate with Zod schemas in `schemas.ts`. - **SSE event**: Add to `src/web/sse-events.ts` + `SSE_EVENTS` in `constants.js`, emit via `broadcast()`, handle in `app.js` (`addListener(`) - **Session setting**: Add to `SessionState`, include in `session.toState()`, call `persistSessionState()` -- **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`). ⚠️ Anything in `PUT /api/settings` that acts on a setting (the `toggleService` watcher calls) must resolve from **`merged`** (persisted + incoming), never from the raw request body: a partial PUT omits keys it doesn't intend to change, and `body.x ?? default` turns every omission into "apply the default" and silently resets live services. Pinned by `test/routes/system-routes-settings-partial-put.test.ts`. ⚠️ A synced setting is admin-only to write in multi-user mode (the PUT 403s a non-admin, whose browser does not send it): a new route that writes `settings.json` takes the same `isMultiUserMode() && !requireAdmin(req, reply)` gate. +- **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`). ⚠️ A setting the server ACTS on must be synced, never a display key and never stripped by `saveAppSettings`: a per-device value never reaches the server, so a scheduler or watcher reads nobody's number (the Git fetch rows are pinned by `test/app-settings-structure.test.ts`). ⚠️ Anything in `PUT /api/settings` that acts on a setting (the `toggleService` watcher calls) must resolve from **`merged`** (persisted + incoming), never from the raw request body: a partial PUT omits keys it doesn't intend to change, and `body.x ?? default` turns every omission into "apply the default" and silently resets live services. Pinned by `test/routes/system-routes-settings-partial-put.test.ts`. ⚠️ A synced setting is admin-only to write in multi-user mode (the PUT 403s a non-admin, whose browser does not send it): a new route that writes `settings.json` takes the same `isMultiUserMode() && !requireAdmin(req, reply)` gate. ⚠️ Every write to the server's `settings.json` goes through `mutateSettingsFile()` (`src/web/settings-writer.ts`: one at a time, tmp+rename, and a file that does not parse is refused, never read as `{}`), and a route awaits it before answering: two unserialized plain `writeFile`s tore the file, and the `{}` fallback then erased every other setting. The one direct writer is config-backup restore (`restoreConfigBackup()`, as for `clis.json`): it must work on a file that does not parse, which the writer refuses. Pinned by `test/settings-json-write-serialization.test.ts`. - **Hook event**: Add to `HookEventType`, add hook in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema` - **Mobile feature**: Add to relevant singleton, guard with `MobileDetection.isMobile()`. New header buttons must stay off phones (`test/mobile-header-buttons-policy.test.ts`). - **New test**: a test that constructs `WebServer` itself binds port 0 (`new WebServer(0, …)`, then `server.boundPort`), and a raw `http`/`net`/Fastify/`ws` server listens on `0` and reads `address().port`; mobile tests get theirs from `createTestServer()` and read `boundPort` too. `test/test-ports-guard.test.ts` refuses a fixed port in either. Route tests use `app.inject()` (no port needed); see `test/routes/_route-test-utils.ts`. @@ -311,7 +319,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/` ## State Files -All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`). +All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/<id>.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`). **Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored. diff --git a/README.md b/README.md index bb3c142c..33fb90ee 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ <a href="https://github.com/Ark0N/Codeman/stargazers"><img src="https://img.shields.io/github/stars/Ark0N/Codeman?style=flat-square&color=eab308" alt="GitHub stars"></a> <a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></a> <a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a> + <a href="https://github.com/NipunaRanasinghe/awesome-ai-agents"><img src="https://img.shields.io/badge/Featured_in-Awesome_AI_Agents-2ea44f?style=flat-square" alt="Featured in Awesome AI Agents"></a> </p> <p align="center"> @@ -460,7 +461,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt - **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files - **Diagnostics in Settings** — **App Settings → System → Diagnostics → Run checks** runs `codeman doctor` on the server and lists Node, tmux, every agent CLI and the optional office tools with versions, paths and install hints. Besides the `PATH`, it also looks in each CLI's usual install directories (`~/.local/bin`, `~/.npm-global/bin` and the like), so most installs are found under a service with a minimal `PATH`. Admin only in multi-user mode. - **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable) -- **Git status in the bottom bar** — off by default (**App Settings → Header & Panels → Bottom bar → Git status**, per device). A small indicator at the right of the bottom bar shows the active session's repository at a glance: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge conflicts, `✓` all committed and pushed. Click it for a draggable window listing the staged, not-staged, untracked and conflicted files (grouped under collapsed folders, or as a flat list if you turn that setting off) and the unpushed commits; **click a file to see its diff** (new files as all additions, deleted files as all removals), with **Open file** to jump to the viewer. A folder that holds several projects gets one collapsible section per repository found up to two levels down, all collapsed until you open them. Read-only and offline (Codeman never fetches or changes the repo); not shown for Docker or remote sessions. +- **Git status in the bottom bar** — off by default (**App Settings → Header & Panels → Bottom bar → Git status**, per device). A small indicator at the right of the bottom bar shows the active session's repository at a glance: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge conflicts, `✓` all committed and pushed. Click it for a draggable window listing the staged, not-staged, untracked and conflicted files (grouped under collapsed folders, or as a flat list if you turn that setting off) and the unpushed commits; **click a file to see its diff** (new files as all additions, deleted files as all removals), with **Open file** to jump to the viewer. A folder that holds several projects gets one collapsible section per repository found up to two levels down, all collapsed until you open them. **Fetch** in that window's header runs `git fetch` for the repositories it lists, on your click or on an interval you turn on (**App Settings → Bottom bar**, off by default; **Session Options → Git** overrides both the switch and the interval per session). Sessions open on the same folder share one run and each sees its result. Apart from that fetch Codeman never fetches or changes the repo, so "behind" is as of the last fetch; not shown for Docker or remote sessions. - **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials - **Create a case in a custom folder** — tick **Create in a custom folder** in **Add Case → Create New**, pick a parent folder (Browse included) and a name, and Codeman scaffolds the new case there instead of `~/codeman-cases`. The target must be a new or empty folder; system directories, your home folder itself, credential trees such as `~/.ssh`, and Codeman's own data folder are refused. Admin only in multi-user mode. - **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, **OMP**, or **GitHub Copilot CLI** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*` vs `COPILOT_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) [`docs/omp-integration.md`](docs/omp-integration.md) and [`docs/copilot-integration.md`](docs/copilot-integration.md) @@ -479,6 +480,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt - **OSC 52 clipboard**: a program in a session that sets the clipboard with OSC 52 (Claude Code's `/copy`, a tmux yank, vim) puts the text on your browser's clipboard, in the main terminal and in every tile. Clipboard read requests are refused, and replaying a buffer never copies - **MCP server sync** _(opt-in)_: copies the user-level MCP servers each installed CLI has (Claude, Codex, Gemini, OpenCode, Antigravity, and GitHub Copilot CLI) into the others' own config files. Additive only, a server you switched off is never copied, **Preview** shows the changes first, and the previous file is kept as `.codeman-bak`. App Settings → Agents & CLIs; admin only in multi-user mode - **CJK / IME input** — full composition support for Chinese / Japanese / Korean, with Ctrl- and Alt-modified navigation keys passed through to the CLI +- **Discord bridge**: opt-in and two-way: Codeman posts to a private Discord server when an agent needs you, in a thread per tab with what is on its screen, and types your reply (and any file you attach) into that tab, so a blocked agent can be answered from your phone off-network. Agents can ask for you with `codeman notify`. Outbound connections only; least-privilege bot setup in [docs/discord-bridge.md](docs/discord-bridge.md). See the [wiki page](docs/wiki/Discord-Bridge.md) - **Config backups** — timestamped copies of the files that hold your customization (settings, CLI overrides, custom endpoints, webhook, linked cases, remote and Docker hosts, saved dashboards and, in multi-user mode, accounts), made at startup and whenever one changes. On by default into `~/.codeman/backups/config`; the folder, how many to keep (2–500) and how long (days, 0 = no limit) are settings, with **Back up now** and a verified **Restore** in Settings → System. Retention only ever deletes folders Codeman made and never the newest. See the [wiki page](docs/wiki/Config-Backups.md) - **Plan usage in the header** — live Claude subscription usage (the 5-hour and weekly windows) from a statusline exporter Codeman hands to `claude` at spawn and never writes into your settings files, plus Codex limits from its own app-server and the GitHub Copilot monthly premium-request quota (the server asks `api.github.com` for it every 10 minutes with Copilot's own sign-in token: on by default while Copilot CLI is installed and signed in, and turning off Plan Usage Limits stops it); per device, on for desktops and off for phones - **Prompt-cache readout**: each Claude session shows whether its prompt cache is warm (`cache:until HH:MM`) or cold, in the footer (when you have no statusline of your own) and in Session Options → Respawn, with the cost of a re-cache and a note after a compaction. Display only; it survives a restart @@ -584,6 +586,8 @@ loginctl enable-linger $USER **Always set `CODEMAN_PASSWORD`** before exposing via tunnel — without it, anyone with the URL has full access to your sessions. +The tunnel Codeman manages (App Settings → System → Remote access) refuses to start without a login (`CODEMAN_PASSWORD` or multi-user mode) unless `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `codeman web --allow-unauthenticated-tunnel` acknowledges the exposure, or you accept the browser's confirm, which it asks before every enable while no login is set. `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` covers only the network bind: it does not let the tunnel start. + </details> ### QR Code Authentication @@ -825,12 +829,14 @@ Those `DONE_<task>_<random>` strings are the skill's **split marker** trick, and | [`reference/recipes.md`](skills/codeman/reference/recipes.md) | 8 worked flows: claude, DeepSeek Harness and shell workers, fan-out, blocked-worker watch, messaging fan-out. On demand. | | [`reference/endpoints.md`](skills/codeman/reference/endpoints.md) | Full endpoint tables, error codes, per-mode signal table, capacity limits. On demand. | | [`reference/messaging.md`](skills/codeman/reference/messaging.md) | Talking to claude workers directly via Claude Code cross-session messaging. On demand. | +| [`reference/history.md`](skills/codeman/reference/history.md) | Reading saved Codex conversations offline with `codeman history read`: selectors, filters, forks, budgets. On demand. | +| [`reference/inspection-and-staging.md`](skills/codeman/reference/inspection-and-staging.md) | Experimental and opt-in: bounded pane inspection and guarded submission with `codeman agent inspect`, `stage` and `submit`. On demand. | Every recipe in there was verified against a live server, and the comments record the failure modes that were measured rather than guessed. #### Two things worth knowing -- **It self-gates.** Outside a Codeman session (`CODEMAN_MUX` unset) the skill refuses to act and does not guess an API URL, so a global install costs an unrelated Claude Code session nothing. +- **It self-gates.** It acts inside a Codeman-managed session. Outside one (`CODEMAN_MUX` unset) it only reads saved Codex conversations offline (`codeman history read`) and refuses everything else, and it never guesses an API URL, so a global install costs an unrelated Claude Code session nothing. - **It is deliberately conservative.** Unprompted, it may only spawn sessions, prompt them, and delete ones **it created in that same conversation, by exact id**, through a fail-closed guard that refuses to delete the agent's own session. Deleting a case (which erases a real directory of your code), bulk kills, respawn/ralph/cron/orchestrator changes and settings writes all require you to ask, naming the target. ⚠️ Turning `agentSkillEnabled` back off **does not remove already-injected copies** (a create-time sweep would yank the skill out from under other live sessions sharing that workspace). Remove them per case with `codeman skill uninstall --case <name>`. @@ -971,20 +977,25 @@ codeman session list # list sessions codeman session logs <id> # tail output codeman task add "fix the failing test" # (t) queue a task codeman attach <path> # show an attachment card for a local file +codeman notify "<message>" [--urgent] [--file <path>] # post to this tab's Discord thread (see the Discord bridge) codeman tui --list # numbered session list (plain text when piped) codeman tui 3 # attach to session 3 of that list ``` ### `codeman agent` — session-to-session verbs in every CLI mode -The skill above drives the API with `curl` and `jq` from a preamble Codeman seeds per session. Where it does not reach (a `pi` or `shell` session, a machine without `jq`), the same environment is still there (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane), so `codeman agent` packages the same verbs as commands. It is a thin client over the endpoints listed under [API](#api): no new route, no new transport, auth and ownership unchanged. One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*. +The skill above drives the API with `curl` and `jq` from a preamble Codeman seeds per session. Where it does not reach (a `pi` or `shell` session, a machine without `jq`), the same environment is still there (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane), so `codeman agent` packages the same verbs as commands. It is a thin client over the endpoints listed under [API](#api): auth and ownership apply to every endpoint, including bounded inspection and guarded input. The `inspect`, `stage` and `submit` commands and their HTTP routes are **experimental** and **opt-in**: turn on **Experimental agent coordination** in App Settings (`agentCoordinationEnabled`, synced, off by default; until then the routes answer `403 FORBIDDEN` naming it). Their commands, flags, paths, payloads and behavior may change or be removed in minor or patch releases, so pin a compatible version when automating them; see [Experimental agent coordination](docs/versioning-policy.md#experimental-agent-coordination). One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*. ```bash codeman agent ls # sessions; * marks this one SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait for the composer where the mode has a ready mark +SID=$(codeman agent spawn --working-dir ~/src/app --mode codex) # no case: an existing directory (cwd by default) codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 # --wait = default signal set codeman agent read "$SID" # last answer (as the server reads it for that mode) codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode) +codeman agent inspect "$SID" --max-bytes 8192 --json # recent evidence + opaque cursor (pane snapshots are labeled) +codeman agent stage "$SID" --message-file /tmp/prompt.txt --json # no Enter; known local Codex composer only +codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json # one conditional Enter codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes (opencode, pi, …): ask for the marker in halves … codeman agent wait "$SID" --match WORKDONE_4711 # … and wait on the joined form, which the prompt's echo never contains codeman agent interrupt "$SID" # a bare ESC, conversation intact @@ -994,11 +1005,23 @@ codeman agent ack <msg-id> # remove what you hand codeman agent rm "$SID" # any session except this one ``` -Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `--json` prints the envelope's `data` for every verb. `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill. +Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `spawn` with no case, or with `--working-dir`, starts the worker in that existing directory through `POST /api/v1/sessions` plus `POST /api/v1/sessions/:id/interactive` (or `/shell`) instead of quick-start, so no case is created and the directory is never labelled agent scratch; an empty `--working-dir` refuses (exit `4`) rather than falling back to cwd, and so does a cwd that is your home directory (Codeman's hooks would land in `~/.claude`), which needs an explicit `--working-dir ~`; if the start fails it still prints the created id and exits `1`. `--json` prints the envelope's `data` for every verb, except a directory `spawn`, which prints `{ sessionId, workingDir, created: true }`, plus `ready` when a readiness wait ran, `readinessError` when that call failed, and `startupError` when the start failed. `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill. + +### `codeman history read`: saved Codex conversations, offline + +`codeman history read` reads Codex's own saved rollouts (`$CODEX_HOME/sessions`, else `~/.codex/sessions`) straight from disk: no server, no managed session, nothing written. Unlike `codeman agent` it works anywhere, including on a machine where Codeman is not running. + +```bash +codeman history read --session-id 019fc604 # one conversation, by native id or 8+ character prefix +codeman history read --root-session-id <uuid> --topic auth # a conversation and every sub-agent or fork under it +codeman history read --file /path/to/rollout.jsonl --format text +``` + +Filters keep whole user/assistant turns (`--topic` is a literal, case-insensitive match; `--after`/`--before` are inclusive UTC dates). A forked or spawned thread's copy of its parent's history is dropped, so every record belongs to the thread that wrote it. Output is JSON by default and carries `complete: false` whenever the evidence is partial (a file that could not be attributed, a compressed `.jsonl.zst` rollout, a record a running Codex is still writing, an unknown ancestor). Every bound fails closed and is raisable: `--metadata-bytes` (attributing the store, default 1 GiB), `--max-entries` (default 65536), `--scan-bytes` (the selected transcripts, default 16 MiB) and `--max-bytes` (output, default 8192). The full reference is [`skills/codeman/reference/history.md`](skills/codeman/reference/history.md). ### MCP server (`codeman mcp`): drive Codeman from any MCP client -`codeman mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio, so any MCP client (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, ...) can start Codeman sessions in any installed agent CLI, prompt them, wait for them, read their answers and clean up, from inside Codeman or from outside it. Like `codeman agent`, it is a client of the HTTP API: no new route, auth and ownership unchanged. +`codeman mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio, so any MCP client (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, ...) can start Codeman sessions in any installed agent CLI, prompt them, wait for them, read their answers and clean up, from inside Codeman or from outside it. Like `codeman agent`, it is a client of the HTTP API. The MCP server adds no routes of its own, and auth and ownership are unchanged. ```bash # Claude Code @@ -1227,7 +1250,7 @@ Full details: [`docs/archive/code-structure-findings.md`](docs/archive/code-stru [![npm](https://img.shields.io/npm/v/xterm-zerolag-input?style=flat-square&color=22c55e)](https://www.npmjs.com/package/xterm-zerolag-input) -Instant keystroke feedback overlay for xterm.js. Eliminates perceived input latency over high-RTT connections by rendering typed characters immediately as a pixel-perfect DOM overlay. Zero dependencies, 6.1 kB gzipped, configurable prompt detection, CJK/emoji wide-character support, full state machine with 238 tests. +Instant keystroke feedback overlay for xterm.js. Eliminates perceived input latency over high-RTT connections by rendering typed characters immediately as a pixel-perfect DOM overlay. Zero dependencies, 5.7 kB minified and gzipped, configurable prompt detection, CJK/emoji wide-character support, full state machine with 276 tests. Typing cost does not grow with prompt length. ```bash npm install xterm-zerolag-input diff --git a/README.zh-CN.md b/README.zh-CN.md index 39f858eb..e8fd544e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -21,6 +21,7 @@ <a href="https://github.com/Ark0N/Codeman/stargazers"><img src="https://img.shields.io/github/stars/Ark0N/Codeman?style=flat-square&color=eab308" alt="GitHub stars"></a> <a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></a> <a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a> + <a href="https://github.com/NipunaRanasinghe/awesome-ai-agents"><img src="https://img.shields.io/badge/Featured_in-Awesome_AI_Agents-2ea44f?style=flat-square" alt="Featured in Awesome AI Agents"></a> </p> <p align="center"> @@ -585,6 +586,8 @@ loginctl enable-linger $USER 通过隧道暴露前**务必设置 `CODEMAN_PASSWORD`** —— 否则任何拿到 URL 的人都能完全访问你的会话。 +Codeman 自己管理的隧道(App Settings → System → Remote access)在没有登录(`CODEMAN_PASSWORD` 或多用户模式)时拒绝启动,除非用 `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` 或 `codeman web --allow-unauthenticated-tunnel` 确认这种暴露,或者你在浏览器的确认框里点了同意;没有登录时,每次开启前浏览器都会先问。`CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` 只管网络绑定,并不能让隧道启动。 + </details> ### 二维码认证 @@ -826,12 +829,14 @@ deleted 9f2d8e5f, aff9c691, be9f1f15 标签消失 | [`reference/recipes.md`](skills/codeman/reference/recipes.md) | 8 个完整流程:claude、DeepSeek Harness 与 shell 工作会话、扇出、盯住被卡住的工作会话、消息扇出。按需加载。 | | [`reference/endpoints.md`](skills/codeman/reference/endpoints.md) | 完整端点表、错误码、各模式的信号表、容量限制。按需加载。 | | [`reference/messaging.md`](skills/codeman/reference/messaging.md) | 通过 Claude Code 跨会话消息直接和 claude 工作会话对话。按需加载。 | +| [`reference/history.md`](skills/codeman/reference/history.md) | 用 `codeman history read` 离线读取已保存的 Codex 对话:选择器、过滤条件、分叉、预算。按需加载。 | +| [`reference/inspection-and-staging.md`](skills/codeman/reference/inspection-and-staging.md) | 实验性功能,需要手动开启:用 `codeman agent inspect`、`stage` 和 `submit` 做有界的窗格检查和受保护的提交。按需加载。 | 里面的每一个配方都在真实服务器上验证过,注释记录的是实测出来而不是猜出来的失败模式。 #### 两件值得知道的事 -- **它会自我门禁。** 不在 Codeman 会话里(`CODEMAN_MUX` 未设置)时,技能拒绝动作,也不去猜 API 地址,所以全局安装对无关的 Claude Code 会话没有任何代价。 +- **它会自我门禁。** 它在 Codeman 管理的会话里工作。不在这样的会话里(`CODEMAN_MUX` 未设置)时,它只会离线读取已保存的 Codex 对话(`codeman history read`),其他一律拒绝,也从不去猜 API 地址,所以全局安装对无关的 Claude Code 会话没有任何代价。 - **它刻意保守。** 未经提示,它只会拉起会话、给它们发提示,并删除**它在同一段对话里自己创建的**会话(按精确 id,经由一个拒绝删除智能体自身会话的失败即关闭守卫)。删除 case(会抹掉一个真实的代码目录)、批量杀会话、改动 respawn/ralph/cron/orchestrator 以及写设置,都需要你开口并指名目标。 ⚠️ 把 `agentSkillEnabled` 关回去**不会删掉已经注入的副本**(在创建时做清扫,会把技能从共用同一个工作区的其他活动会话脚下抽走)。要删就按 case 删:`codeman skill uninstall --case <name>`。 @@ -993,7 +998,7 @@ codeman agent rm "$SID" # 除当前会话外 ### MCP 服务器(`codeman mcp`):从任意 MCP 客户端驱动 Codeman -`codeman mcp` 是一个走 stdio 的 [Model Context Protocol](https://modelcontextprotocol.io) 服务器,因此任何 MCP 客户端(Claude Code、Claude Desktop、Codex、Gemini CLI、Cursor 等)都能在任意已安装的智能体 CLI 里启动 Codeman 会话、给它们发提示、等它们完成、读取回答并清理,无论是在 Codeman 内部还是外部。和 `codeman agent` 一样,它是 HTTP API 的客户端:没有新路由,认证和归属规则不变。 +`codeman mcp` 是一个走 stdio 的 [Model Context Protocol](https://modelcontextprotocol.io) 服务器,因此任何 MCP 客户端(Claude Code、Claude Desktop、Codex、Gemini CLI、Cursor 等)都能在任意已安装的智能体 CLI 里启动 Codeman 会话、给它们发提示、等它们完成、读取回答并清理,无论是在 Codeman 内部还是外部。和 `codeman agent` 一样,它是 HTTP API 的客户端。MCP 服务器自己不新增任何路由,认证和归属规则不变。 ```bash # Claude Code @@ -1221,7 +1226,7 @@ npm test # 运行测试(与 CI 相同;浏览器/移动端 [![npm](https://img.shields.io/npm/v/xterm-zerolag-input?style=flat-square&color=22c55e)](https://www.npmjs.com/package/xterm-zerolag-input) -为 xterm.js 提供即时按键反馈的叠加层。通过把输入的字符立即渲染为像素级精准的 DOM 叠加层,消除高 RTT 连接下的感知输入延迟。零依赖、gzip 后 6.1 kB、可配置的提示符检测、CJK/emoji 宽字符支持、带 238 个测试的完整状态机。 +为 xterm.js 提供即时按键反馈的叠加层。通过把输入的字符立即渲染为像素级精准的 DOM 叠加层,消除高 RTT 连接下的感知输入延迟。零依赖、压缩并 gzip 后 5.7 kB、可配置的提示符检测、CJK/emoji 宽字符支持、带 276 个测试的完整状态机。输入开销不随提示词长度增长。 ```bash npm install xterm-zerolag-input diff --git a/config/mcp-registry/server.json b/config/mcp-registry/server.json index 5ce1fc24..6442f4c0 100644 --- a/config/mcp-registry/server.json +++ b/config/mcp-registry/server.json @@ -3,7 +3,7 @@ "name": "io.github.Ark0N/codeman", "title": "Codeman", "description": "Start, prompt and read AI coding agent sessions (Claude Code, Codex, Gemini CLI and more) in Codeman", - "version": "1.45.0", + "version": "1.48.0", "websiteUrl": "https://getcodeman.com", "repository": { "url": "https://github.com/Ark0N/Codeman", @@ -13,7 +13,7 @@ { "registryType": "npm", "identifier": "aicodeman", - "version": "1.45.0", + "version": "1.48.0", "transport": { "type": "stdio" }, diff --git a/config/test-suites.ts b/config/test-suites.ts index d66abd58..49812b3a 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -19,6 +19,8 @@ * why these are a runnable suite (`npm run test:browser`) rather than skipped. */ export const BROWSER_TEST_GLOBS = [ + 'test/wrapped-file-paths.browser.test.ts', + 'test/command-palette-agent-arrows.browser.test.ts', 'test/tmux-capture-sgr.browser.test.ts', 'test/tab-rail-resize.browser.test.ts', 'test/tab-activation.browser.test.ts', @@ -27,6 +29,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/tab-focus.browser.test.ts', 'test/session-sidebar-ux.browser.test.ts', 'test/header-divider.browser.test.ts', + 'test/tab-strip-corner.browser.test.ts', 'test/header-host-sensors.browser.test.ts', 'test/session-options-responsive.browser.test.ts', 'test/inline-rename.test.ts', @@ -47,6 +50,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/case-custom-path.browser.test.ts', 'test/doctor-settings.browser.test.ts', 'test/git-status.browser.test.ts', + 'test/edit-history.browser.test.ts', 'test/split-pane-orchestration.browser.test.ts', 'test/split-pane-auto-collapse.browser.test.ts', 'test/spreadsheet-preview.browser.test.ts', @@ -55,6 +59,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/markdown-anchor-links.browser.test.ts', 'test/fullscreen-tab-switch-capture.browser.test.ts', 'test/terminal-touch-swipe-axis.browser.test.ts', + 'test/phone-press-feedback.browser.test.ts', ]; /** diff --git a/docs/api-reference.md b/docs/api-reference.md index 95a28b17..9a269ad3 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -426,6 +426,13 @@ worker's prompt but never submitted, and the wait then runs its full timeout on turn that never started. Verified live; this is the most common silent failure on this endpoint. +Input tagged with `clientId` and `seq`, waiting or not, answers `409 CONFLICT` +with nothing written while an experimental guarded submit holds the session's +input lease, or input held behind it is still being written (a few milliseconds; +see [guarded input](#experimental-bounded-agent-inspection-and-guarded-input)). +Retry it. Untagged input is held instead and written in arrival order once the +lease is released. + A **plain prompt** (printable text followed by exactly one `\r`, nothing else) is delivered through tmux even without `useMux`: the text is typed, Enter is pressed as a separate key, and the server re-presses Enter while the prompt is still visibly @@ -589,9 +596,167 @@ request returns. The capture runs synchronous tmux calls on the server; the | `lines=<n>` | With `full=1` only: read at most `<n>` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. When a bound below the configured limit left older lines in tmux (its `history_size` is deeper), `truncated` is true with `truncationReason: 'tail'`; a byte cap still reports `'capped'`. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. | | `alt=recording` | With `full=1` only: if the pane is in the alternate screen (a nested tmux, where tmux keeps no history, or any full-screen app such as vim or less while it is open), answer with the visible frame over the byte recording instead (`source: 'mux-visible'`, `tail` applied as without `full=1`). Shells' automatic loads send it. | +## Experimental bounded agent inspection and guarded input + +**Experimental:** `codeman agent inspect`, `stage` and `submit`, and their +`/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases), +are labeled experimental, so the versioning policy's rule for experimental +surfaces applies (`docs/versioning-policy.md`, "Experimental agent +coordination"): they are not part of the stable contract. Their commands, flags, paths, +payloads and behavior may change or be removed in a minor or patch release. +Pin a compatible Codeman version when automating them. Authentication and +session ownership checks still apply. + +**Opt-in:** all three routes answer `403 FORBIDDEN` while the synced +`agentCoordinationEnabled` setting is off (the default; App Settings, +Cross-session features, **Experimental agent coordination**), with a message +naming it. The check runs before session lookup and body validation, and reads +the saved setting on every request, so turning it on needs no restart. +`codeman agent inspect|stage|submit` print that refusal verbatim (exit 1). + +These authenticated routes use the standard envelope and session ownership checks. +`/api/v1` aliases `/api`; an inaccessible session returns `404 NOT_FOUND` before +any transcript, pane or receipt lookup. + +| Endpoint | Parameters/body | Result | +| --- | --- | --- | +| `GET /api/v1/sessions/:id/inspect` | `maxBytes=8192` (integer 1–65536), optional opaque `since` UUID | `source`, `identity`, observed `activity`, bounded UTF8 `evidence`, `truncated`, `incremental`, `cursor`, `maxBytes`; native results also include `omittedBeforeBytes`, `scannedBytes`, optional `blockedReason` | +| `POST /api/v1/sessions/:id/stage` | `{ "message": "printable single line" }` | `{ stageId, sha256, expiresAt, sessionId, submitted:false }`; types without Enter | +| `POST /api/v1/sessions/:id/submit` | `{ "stageId": "UUID", "confirmSha": "64 lowercase hex characters" }` | `{ stageId, sessionId, submitted:true }`; sends one conditional Enter | + +Inspection initially supports native local Codex JSONL with strict conversation +attribution, using the session's effective `CODEX_HOME`, explicit resume identity +or unique managed originator. It never selects an arbitrary newest cwd match. +The initial read selects recent complete records; `omittedBeforeBytes` explicitly +labels its tail omission. Cursor reads consume only complete appended records, +keeping UTF8/JSONL boundaries. Evidence bytes exclude response metadata and JSON +escaping. Record scans cap at 256 KiB plus bounded identity anchors; attribution +separately caps at 8192 directory entries, eight directory levels, 8 MiB of +negative-filter prefix reads and 1 MiB of candidate metadata reads (including +final identity verification). The negative filter reads 1 KiB of each rollout +first and 4 KiB only when that window holds no complete `originator` field. Once +a rollout is attributed, later calls re-verify only that file (same file identity, +same strict metadata) and rescan only the `sessions/YYYY/MM/DD` directories not +older than its own, for a second candidate such as a codex `/new`. A file that no +longer verifies falls back to one full walk; a verified file whose incremental scan +finds a second candidate or runs out of budget answers unattributed without one, +since the full walk reads a superset of that scan. So the caps bound the first call, +not every call. +An unexpected read error is `422 OPERATION_FAILED` (logged with the session id +and error code only), not a retryable `409`. Unreachable workspace/home/metadata directories +are probed with a bounded past-cap probe before canonical reads and remain +unattributed. A full incremental page can be continued with its cursor; only a +first pending record too large for the budget reports an output-budget block. +Oversized/unfinished records report `blockedReason`; incremental +reads do not silently skip them. Other modes, remote/container sessions, and +unprovable native attribution return `source:"pane-snapshot"`, `incremental:false`, +`cursor:null`; these snapshots can repeat evidence. + +Attribution identifies a file, not an authoritative current-thread signal. A +configured launch resume and a new owned rollout conflict and force fallback. +An in-CLI `/resume` of an existing foreign-originator thread may leave no new +owned rollout; launch metadata cannot detect that switch. Check the returned +conversation identity and do not use these observations as proof of the active +thread, task completion or authorization. + +Opaque server-side cursors bind caller, session, pane generation, conversation, +opened-file identity, byte offset and bounded content anchors. Terminal resizes +do not change this inspection scope. Each owner and session keeps its newest 8 +cursors (a retry of the last call still works; older ones are retired), at most +1024 live cursors are retained in all, each for 15 minutes; restart, eviction or +expiry requires a fresh tail baseline. Replacement, truncation, detected rewrites, reassociation, foreign +cursors or loss of the attributed source return `409 CONFLICT`. No transcript +filesystem path is returned as cursor/identity metadata; evidence retains the +owner's native record content, which can itself contain paths. + +Guarded input initially requires a local characterized Codex composer, one +unwrapped visible line, a recognized footer and a cursor at its exact end. The +composer glyph and footer are the CLI registry's +`capabilities.workDetect.promptGlyph` and `composerFooterLine`; a CLI that +declares no `composerFooterLine` is refused. +Unknown/working/modal/shell/remote/container composers fail closed. Stage accepts +at most 8192 UTF8 bytes, no C0/C1/DEL controls, multiline text, or trailing +whitespace (the existing mux transport trims it). Before typing, it checks the +actual captured pane width and rendered composer indentation, refusing any +message that would reach the right edge or wrap. Every declared Codex effort +word plus `default` is recognized. Its two-minute receipt stores +only hashes/ephemeral identities in a bounded in-memory store (1024 live receipts), +never persisted prompt content. + +Stage refuses, before typing anything, a message that would change what Enter +does rather than what it sends, from the registry's +`composerCommandPrefixes` and `composerCompletionSigils`. For codex: a message +starting with `/` (a slash command; codex runs it, or with its command list open +runs the highlighted one) or `!` (a shell command), checked after any leading +whitespace, and a message whose last word starts with `@` (file and mention +search) or `$` (skills; every `$` word, since whether codex opens that list +depends on its skill catalog). Those popups follow the caret, which stage leaves +at the end of the message, so a `@` or `$` word earlier in the message is +accepted. Send slash and shell commands through `POST .../input` +(`codeman agent send`). Independently, a frame with a suggestion popup in the +block directly above the composer (the registry's `composerPopupLine`) is refused +at stage and submit, since codex draws every popup there and leaves the footer +below the composer unchanged. + +A receipt is issued only for a capture that shows exactly the message with the +caret after it. Codex repaints its composer 30 to 60 ms after the keys arrive, +so after typing stage re-reads the pane every 50 ms for up to one second and +takes the first frame with the same pane generation, the input revision its own +write moved and a composer whose SHA-256 equals the message's. Each capture +reads the screen and the caret in one tmux invocation. A frame showing another +writer's input, a new pane lifecycle or a new geometry ends the wait at once. +Every refusal after the keys went out (typing failed or was ambiguous, the +pane changed, or no frame verified in time) is a `409` that says the message +may already be in the composer: inspect the pane and follow the stranded-draft +recovery (Ctrl+U) in the skill reference before staging again. + +While a guarded submit holds the input lease (the few milliseconds of its +conditional Enter), input that reaches the session is held, not refused, and is +written in arrival order as soon as the lease is released, whatever the submit's +outcome. That covers terminal WebSocket keystrokes (each frame is acknowledged +only once it is written, so a frame typed during the lease lands before the one +typed after it), Shift+Enter and Ctrl+Enter from `POST .../send-key`, approval +answers and Codeman's own managed writes. A held write that is still being sent +holds everything behind it, with one exception: when the hold queue is full (256 +inputs or 1 MiB), a managed write (`writeViaMux`, which `POST .../input` uses for a plain +prompt or `useMux`) is not held and that route falls back to a direct write, which +is refused during the lease itself but can land ahead of input still being written +after it. A tagged `POST /api/v1/sessions/:id/input` (one +carrying `clientId` and `seq`) instead answers `409 CONFLICT` ("A guarded submit +is in progress on this session; nothing was written") for that span, the lease +plus the held input's drain, rather than a `200` for bytes that were never +written; it keeps its seq, so the browser's durable queue, whose POST fallback +sends one frame at a time and keeps it on a `409`, delivers it on the next retry. +Untagged input to that route is held like a WebSocket frame, because its senders +(the phone accessory keys, the Stop button) never read the answer. A lease holds +at most 256 inputs or 1 MiB; past that a WebSocket is closed with code `1013` and +nothing later from it is applied, so the browser reconnects and resends every +unacknowledged frame in order, and `send-key` and an untagged raw input answer +`409 CONFLICT`. + +Direct auxiliary key operations participate in the same pending-write and input +lease checks. Complete allowlisted focus/mouse reports and empty writes preserve +input revision; mixed or unknown sequences remain keyboard-bearing. Exact +composer/caret checks also reject a mouse-moved caret. + +Submit consumes an owned receipt before awaited delivery, verifies the caller, +confirmation hash and expiry, then waits until at least 250 ms have passed since +the staged keys went out (codex reads keys typed in one burst as a paste and, for +120 ms after one, turns Enter into a newline instead of a submit), then verifies +lifecycle/input/geometry revisions and exact composer/caret, +then uses a session-level conditional Enter dispatch with a temporary input lease. +Competing Codeman writes cannot enter during that dispatch. Managed restarts and +intervening browser/API/auxiliary input invalidate receipts independently of pid +reuse or restored visible text. Direct tmux edits remain outside Codeman's +serialization. Failed, ambiguous, expired, changed or repeated submits return +`409 CONFLICT`; every owned attempt consumes the receipt. Neither submit nor its +error recovery automatically replays Enter. Success acknowledges delivery of one +Enter, not native acceptance or task completion. Invalid schema input returns +`400 INVALID_INPUT`. + ## The `codeman agent` CLI (client over these endpoints) -`codeman agent ls|spawn|send|wait|read|interrupt|rm|post|inbox|ack` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until <signals>`; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`, and the inbox verbs map one to one onto the [agent inbox](#agent-inbox) routes (`post` → `POST …/inbox`, `inbox [id] [--wait]` → `GET …/inbox[?wait=]`, own inbox by default, `ack <ids…>` → `POST …/inbox/ack`); `ls` adds an `INBOX` column from `GET /api/v1/agent-inbox/summary` and drops it when that route does not answer. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard (for `inbox --wait`, `3` only when `GET /api/v1/sessions` no longer lists the session; a wait the server ended otherwise, on a restart or a detach, is `2`). `post` takes text that starts with `-` as it is (a markdown file often does); text that is exactly an option (`--json`, `-h`) goes after `--`. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour. +`codeman agent ls|spawn|send|wait|read|inspect|stage|submit|interrupt|rm|post|inbox|ack` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It uses the guarded endpoints above for `inspect`/`stage`/`submit`, and adds no route for the other verbs: `spawn <case>` is `POST /api/v1/quick-start`, and `spawn` with no case (the caller's cwd) or with `--working-dir <dir>` (an existing local directory; combining it with a case, or an empty or whitespace-only value, refuses with exit 4 before any request; so does a cwd that resolves to the home directory, since the server would install its hooks there, so HOME needs an explicit `--working-dir ~`) is `POST /api/v1/sessions` with `workingDir`, `mode`, `name` and `parentSessionId` followed by `POST /api/v1/sessions/:id/interactive` or `/shell` (the mode's `capabilities.startMode`), never retried; either form then runs `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry where it declares one. `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until <signals>`; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`, and the inbox verbs map one to one onto the [agent inbox](#agent-inbox) routes (`post` → `POST …/inbox`, `inbox [id] [--wait]` → `GET …/inbox[?wait=]`, own inbox by default, `ack <ids…>` → `POST …/inbox/ack`); `ls` adds an `INBOX` column from `GET /api/v1/agent-inbox/summary` and drops it when that route does not answer. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. `--json` prints the envelope's `data`, except a directory `spawn`, which prints `{ sessionId, workingDir, created: true }`, plus `ready` when a readiness wait ran, `readinessError` when that call failed, and `startupError` when the start failed (with `ready: false`, exit 1, the created id kept so the caller can inspect it). An unknown `--mode` refuses (exit 4) before any request, for a named case too. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard (for `inbox --wait`, `3` only when `GET /api/v1/sessions` no longer lists the session; a wait the server ended otherwise, on a restart or a detach, is `2`). `post` takes text that starts with `-` as it is (a markdown file often does); text that is exactly an option (`--json`, `-h`) goes after `--`. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour. `codeman mcp` (`src/mcp-server.ts`) is a Model Context Protocol server on stdio over the same endpoints, for MCP clients: `list_sessions` (`GET /api/v1/sessions`), `start_session` (`POST /api/v1/quick-start` with `X-Codeman-Agent-Origin: codeman-mcp`, then `wait-output` on the mode's ready mark), `send_prompt` (`POST …/input` with `wait`/`waitTimeout`), `wait_for_session` (`GET …/wait` or `…/wait-output`), `read_output` (`GET …/last-response` or `…/terminal?tail=`), `interrupt_session` and `delete_session`. It adds no route. The URL comes from `CODEMAN_API_URL` or `--url`, never a guess; prompting, interrupting and deleting are limited to sessions the process started (or the calling session's own children) unless it runs with `--allow-any-session`. See the README section "MCP server" and `test/mcp-server.test.ts`. @@ -735,6 +900,16 @@ already serves non-media text inert (`text/plain`, `nosniff`), so this just keeps the server from storing what it would serve or an agent could run, and html and svg are the ones deliberately kept out. +## New folder in the path picker + +`POST /api/filesystem/mkdir` with `{ path, name, sessionId?, showHidden? }` makes one +folder directly inside `path`, which must be a folder the path picker may browse, and +answers `{ path }`. `name` is one path segment (letters, numbers, spaces and `_ - . ~`, not +starting with a dot or `~`). `400` for a cases folder (use Add Case → New Case) or a `path` +that is not a directory, `403` for a blocked tree or a `path` outside the browse roots, +`404` for a `path` that does not exist, `409` when the folder already exists, and `422` +when the mount under `path` does not answer. + ## Rendered HTML files An `.html`/`.htm` file renders as a page in a sandbox, in two steps @@ -804,6 +979,18 @@ also pure decoration: it confers no permission, and a child is unaffected by its parent exiting. It appears on session state as `parentSessionId` (absent when unresolved) and survives a server restart. +## Pane directory (`paneCwd`) + +Session state (`GET /api/v1/sessions`, the `session:updated` event) may carry `paneCwd`, +the directory the session's local tmux pane is in now (`#{pane_current_path}`), which +the web UI uses as the base for a relative path printed in the terminal. It is +never restored after a restart (it is written to `state.json` with the rest of the +session state but not read back), absent for remote, +Docker, direct-PTY and rediscovered sessions and for a session split into several panes, +and never a confinement root: file routes stay confined to `workingDir`. +tmux reports the resolved directory, so for a workspace reached through a symlink a +directory at or under the resolved workspace is reported in `workingDir`'s spelling. + ## Session model (`displayModel`) Session state (`GET /api/v1/sessions`, the `session:updated` event) carries the model a @@ -1040,24 +1227,47 @@ data}` envelope on the wire — unwrap it the same way). Answers `[]` for a boolean` reports whether one is stored, so a client can render "unchanged if left blank" without ever holding the real value. - `POST /api/v1/model-endpoints` with `{ id, label, baseUrl, apiKey?, -authStyle?, defaultModelId? }` creates one. `id` must match +authStyle?, defaultModelId?, defaultContextLength? }` creates one. `id` + must match `^[a-zA-Z0-9_-]+$`; `authStyle` is `bearer` (default) or `api-key`, never both (a real server hung indefinitely when sent both headers on one - request); `baseUrl` must be `http(s)`, carry no embedded credentials, and + request); `defaultContextLength` is a user-set fallback used when + assigning a model that discovery has no context length for (discovery + itself never writes it); `baseUrl` must be `http(s)`, carry no embedded + credentials, and is refused if it points at (or resolves to) a link-local or cloud-metadata address. `409 ALREADY_EXISTS` on a duplicate id. -- `PUT /api/v1/model-endpoints/:id` updates one. An **absent** `apiKey` +- `PUT /api/v1/model-endpoints/:id` updates one, also accepting + `defaultContextLength`. An **absent** `apiKey` keeps the stored one rather than clearing it — the client never receives the real value to resend deliberately unchanged, so omission is the only way to say "leave it alone"; there is no way to clear a key back to unset - this way. `defaultModelId`, when set, must be one of that endpoint's own + this way. `defaultContextLength` behaves the opposite way: a PUT without + it clears the stored value, so send the field back to keep it. + `defaultModelId`, when set, must be one of that endpoint's own `models` (`400 INVALID_INPUT` otherwise). - `DELETE /api/v1/model-endpoints/:id` removes one. - `POST /api/v1/model-endpoints/:id/discover-models` fetches the endpoint's own `GET /v1/models` and stores the result as `models`, updating - `lastDiscoveredAt`, plus (best-effort, only for a model llama-swap's own - response already reports loaded) `modelContextLengths` and `modelSizesGB`. - A `defaultModelId` that no longer appears in the fresh list is dropped + `lastDiscoveredAt`, plus (best-effort) `modelContextLengths`, + `modelSlotCounts` and `modelSizesGB`: every listing entry is read for a + stated context figure (`context_length`, then `context_window`) and a + size, BEFORE any loaded check; only the launch-command/`/props` + enrichment needs a model llama-swap's own response reports loaded. A + loaded model's context figure comes from llama-swap's own launch command + (`--fit-ctx` / `-c` / `--ctx-size`), falling back to `/props` (`n_ctx`, + `total_slots`) for whatever the command did not answer, and OVERWRITES the + listed figure for that model; the model's own listing + (`context_length`, then `context_window`) only ever fills the gap when + neither probe does, and is recorded as a single-slot figure: + llama-swap lists the PER-SLOT window, so whenever the listed figure is + the one taken the stored slot count is 1. A server with no `status` + field on any entry (plain llama.cpp, a non-llama-swap gateway) gets no + launch-command/`/props` enrichment at all, only whatever its listings + state. `modelContextLengths` falls back to the + endpoint's `defaultContextLength` when a session is assigned a model + discovery never learned a window for. A `defaultModelId` that no longer + appears in the fresh list is dropped rather than carried forward invalid. Failures answer `422 OPERATION_FAILED` with the underlying connection error, or a named egress refusal if the resolved address turned out to be blocked. The same refresh also runs @@ -1150,9 +1360,11 @@ The target is judged before anything is written: Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes outside the cases directory and into the shared, ownerless registry. If anything fails after the first write, what this call created is removed (the whole folder if it created it, otherwise only the scaffold inside the empty folder you picked) and the response is `500`. +`POST /api/cases/clone` takes the same optional `path`, with `{ name, repository, ref?, shallow?, description?, path? }`. Without `path` the repository is cloned to `<cases dir>/<name>` as always. With `path` the repository is cloned **into that exact folder** (no `<name>` subfolder is added) and registered as a linked case, judged by the same rules as above: `400` for an invalid or blocked path (or a symlink or file at the target), `404` for a missing parent, `422` for an unreachable parent, and `409 ALREADY_EXISTS` for a folder that has files, a name already in use or a folder that is already a case. Admin only in multi-user mode (`403`). An existing empty folder is cloned into directly and is kept, emptied again, if the clone fails; a second clone into the same folder while one is running is refused with `409`. A destination at or inside a Docker case workspace is refused `400 INVALID_INPUT` (git would run the new repository's hooks and filters on the host, and a container can write there), and `503` when the Docker case list cannot be read. If the clone succeeds but the registry write fails (or the name was linked while git ran, which is `409`), the cloned folder is kept and the error names it: `500 OPERATION_FAILED`, then use Link Existing. + ## Git status -`GET /api/sessions/:id/git-status` is what the bottom-bar Git indicator and its panel read (Settings → Header & Panels → Bottom bar, per-device, default off). It reports what the session's workspace has not committed or pushed. **Read-only and offline:** it never fetches, pulls, commits or writes (it runs `git status` with `--no-optional-locks`, so it does not even refresh the index), which is why `behind` is as of the last `git fetch`. The session is resolved like every session route (ownership via `findSessionOrFail`; another user's session is `404`). A repository whose root is, or is inside, a Docker case workspace is dropped (from the walk-up, the scan below a folder, and the diff route): a container can write there, and a repository's own clean filter or signature program would run on the host. When a branch's upstream does not exist on the remote (deleted and pruned, or never pushed, as after cloning an empty repository and committing), `upstreamGone` is `true` and the unpushed list falls back to commits on no remote-tracking ref at all. +`GET /api/sessions/:id/git-status` is what the bottom-bar Git indicator and its panel read (Settings → Header & Panels → Bottom bar, per-device, default off). It reports what the session's workspace has not committed or pushed. **Read-only and offline:** it never fetches, pulls, commits or writes (it runs `git status` with `--no-optional-locks`, so it does not even refresh the index), which is why `behind` is as of the last `git fetch` (the panel's Fetch button is a separate route, below). The session is resolved like every session route (ownership via `findSessionOrFail`; another user's session is `404`). A repository whose root is, or is inside, a Docker case workspace is dropped (from the walk-up, the scan below a folder, and the diff route): a container can write there, and a repository's own clean filter or signature program would run on the host. When a branch's upstream does not exist on the remote (deleted and pruned, or never pushed, as after cloning an empty repository and committing), `upstreamGone` is `true` and the unpushed list falls back to commits on no remote-tracking ref at all. `GET /api/sessions/:id/git-diff?repo=<repoRoot>&path=<path>&kind=staged|unstaged|untracked|conflicted` returns the unified diff of one file the panel lists (`{ diff, truncated, binary }`; staged is index vs HEAD, unstaged is working tree vs index, untracked is the whole file as additions). It is what opens when you click a file in the Git panel. `repo` and `path` are matched against the current status rather than trusted, so anything the status does not list is `404`. Read-only: it passes `--no-ext-diff --no-textconv` (no external diff or textconv driver runs), but a repository's clean filters still run, as they do for any `git diff`, which is why a repository a container can write to is never inspected (below). Capped at 400 KB, and refused (`400`) for remote and Docker sessions; a repository at or inside a Docker case workspace is not in the status, so it is `404` here. @@ -1175,6 +1387,25 @@ Both routes accept two optional query parameters, which the UI sends from its pe Lists are capped (300 files and 50 commits per repository) while the counts stay exact. A branch with no upstream reports the commits no remote has (`HEAD --not --remotes`); a repository with no remote reports `unpushedCount: 0`, since there is nothing to push to. Concurrent polls of one folder share a single git invocation; `?fresh=1` (what the panel's Refresh button and opening the panel send) skips the short-lived caches, though it still joins a computation already running. +`POST /api/sessions/:id/git-fetch` is the panel's **Fetch** button: one `git fetch --quiet` per repository the panel would show, through the same resolver so the two cannot disagree about which repositories belong to a workspace, with the same `maxRepos` and `timeout` clamps. It is the only git command the Git panel runs that writes (cloning and worktree management write too, but only when you asked for them at the moment you asked), and it writes remote-tracking refs only: nothing commits, checks out, merges or touches the working tree. It is not quite as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref` while a fetch is running, which is why one folder is fetched by one run at a time. It runs on that click, or on the interval below and never otherwise, and `git fetch` is exactly the command that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, and config commands such as `credential.helper`), which is why nothing runs on a timer until you opt in, and why a repository at or inside a Docker case workspace is refused before any git runs. Remote and Docker sessions answer `state: 'unsupported'` like the status route. Bounded: 4 repositories at a time across the whole run (one shared bound, not one per repository group; a scheduled tick keeps that one bound for all its folders, so 4 folders do not mean 4 × 4, and works at most 4 folders at a time), one spawn per repository with the same per-command timeout, which reaches the whole process group rather than only the `git` parent (a fetch over http spawns helper processes, and a timeout that signals the parent alone can leave one running after the answer goes out), `GIT_TERMINAL_PROMPT=0` plus BatchMode ssh so a repository that needs a login fails fast instead of hanging the request on an invisible prompt. Codeman supplies no credentials, so a repository your own git is already signed in to works because that is your existing setup. That BatchMode ssh is a default, never an override: your own `GIT_SSH_COMMAND` and the repository's `core.sshCommand` are honoured, and ⚠️ the default's `StrictHostKeyChecking=accept-new` records an unknown host key in `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. A click joins a run already going for the same folder, and the click publishes the trigger of the run it actually did (`click` when the click was the run, `interval` when it joined a tick already going, because a click that fetched nothing cannot report itself as the fetch) as the session's `gitFetch` state (below), so another tab open on the same folder sees the same run. + +`data` is `{ state, repos, reposTruncated, repoLimit, fetchedAt }`, `repos[]` each `{ name, path, ok }` and, when it failed, `failure` (`{ code, message, stderr }`: the codes as in the clone failures, plus `NO_REMOTE`). A repository that could not be fetched keeps its row rather than being dropped. The panel shows the result under its list and then re-reads, so `behind` reflects what this fetch brought. + +The interval is two **synced** settings in `PUT /api/settings` (`src/git-fetch-scheduler.ts`): `gitFetchEnabled` (default `false`) and `gitFetchIntervalSeconds` (30 to 3600, default 300, the gap between runs timed from the start of a run so a slow repository does not push the next one out). Synced, not per-device, because the server is what acts on them; the scheduler reads them fresh on each 15 s tick, so a change works without a restart. A session can override both, and the override wins even while the app-wide switch is off: `POST /api/sessions/:id/git-fetch-settings` takes `{ "enabled": true | false | null, "intervalSeconds": number | null }` (`null` on a field clears that half so the app value applies again, a field left out of the body leaves the other half as it was), is `400 INVALID_INPUT` for a remote or Docker session, and answers `200` with `{ gitFetchSettings: { enabled, intervalSeconds } }` where `null` means the session follows the app. What the last run did rides the session state as `gitFetch` (`{ ranAt, trigger, state, repos, failed, firstFailure? }`, `trigger` is `click` or `interval`), on the existing `SessionUpdated` event: no new event. Sessions on the same folder share one run and both see its result, the schedule is keyed by folder, and a folder is fetched by at most one run at a time; a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins, and the folder is probed once for the whole group rather than once per session on it; every session open on a folder is remembered, so closing the one that holds the override does not stop that folder's fetching while another session is still open on it. The tick skips anything its bounded probe does not confirm is a directory, stamps each folder's schedule when it picks it, and does not run a second tick over a selection still going. Remote and Docker sessions are never fetched on a timer, and the Docker rule is re-checked against the CURRENT Docker roots every tick. Both fetch paths fail closed on a Docker case list (`docker-cases.json`) that exists but cannot be read or parsed: a click answers `200` with `state: 'error'` and publishes that as its `gitFetch`, and a tick fetches nothing and leaves its folders due for the next tick, since without the list nothing tells which folders a container can write to (the read-only status and diff routes keep reading such a list as no Docker workspaces). + + +## Edit history + +What the Edit history panel reads (Settings → Header & Panels → Header, per-device, default off): the edits a **local claude session** made, main agent and subagents, read from its transcripts. Read-only. Every route resolves the session through `findSessionOrFail` (another user's session is `404`). Remote, Docker and non-claude sessions answer `state: 'unsupported'` and are never read. + +`GET /api/sessions/:id/edits[?sync=1]` lists the stored edits, newest first, without diffs: `{ state, reason?, backfillTruncated, agents, entries }`. With `sync=1` it first reads what the transcripts gained since the last read (after `/clear` or a resume, the previous conversation's transcripts to their end first); without it the server reads nothing. `state` is `ok`, `unavailable` (stored entries only; `reason` is `no-transcript` before the session has a transcript Codeman can find, `removed` while the session's history is being deleted, or the read error; the transcript is the one the session's hooks report, else the newest `<id>.jsonl` under the session's `CLAUDE_CONFIG_DIR` override or the server's config dir) or `unsupported` (`reason`: `remote`, `docker`, `mode`). Each entry is `{ id, ts, path, relPath, kind: 'code'|'image', tool, created, added, removed, truncated, agentId? }`, where `tool` is the edit tool, `bash` for an image a Bash call produced or `sent` for one the agent sent; `relPath` is relative to the workspace (a subagent's worktree for its edits). `agents` maps a subagent id to `{ description, agentType, worktreeBranch? }`. `backfillTruncated: true` means older edits can still be loaded. + +`GET /api/sessions/:id/edits/:entryId` returns one edit's diff as unified text: `{ diff, truncated }` (each edit's diff is capped at 64 KiB). `404` for an edit the session does not have. + +`POST /api/sessions/:id/edits/older` reads the next older block, within the current conversation, of up to three transcripts, a full read budget each (with more transcripts, main and subagents take turns), and answers like the list. + +The edits are stored per session in `~/.codeman/edit-history/<id>.json` (mode 0600, at most 500 edits and 4 MiB of diff text, the oldest dropped), deleted when the session's record is removed (a killed pinned session keeps it; at startup, a file whose session no longer exists is deleted too) and never fed into `/api/v1/search`. No route here serves a file: the panel shows thumbnails through `file-raw` inside the workspace only; an image outside it opens in the file preview, which registers it through the attachment route. + ## Git worktrees of a case A local case whose folder is the top of a git repository can keep **worktrees** at `<case>/.claude/worktrees/<name>`, each on its own branch, so several agents on one repository never share a checkout. This is the layout Claude Code's own `claude --worktree <name>` uses (branch `worktree-<name>`), so either tool finds the other's worktrees. Remote (SSH) and Docker cases are refused with `400 INVALID_INPUT` before git runs, and so is a local case whose folder is at or inside a Docker case workspace: `git worktree add` runs the repository's post-checkout hook and checkout filters on this machine, and a container can write there. A case folder that is not a repository, is a bare repository, or sits below the repository's top level is `400` too. The case resolves the way quick-start resolves it (a linked case is admin-only in multi-user mode, everything else is the caller's own case space; another user's case is `404`), and a case folder on a mount that does not answer is `422 OPERATION_FAILED`, never `404`. @@ -1213,12 +1444,13 @@ A local case whose folder is the top of a git repository can keep **worktrees** ## Settings -`~/.codeman/settings.json` is one file for the whole server: the synced App Settings (feature switches, the plan-usage collection switch `showPlanUsageLimits`, `runMode`, `respawnPresets`, `tunnelEnabled`, ...), plus `modelConfig`, `caseOrder` and `lastUsedCase`. Reads are open to every signed-in user; in multi-user mode a non-admin reads it without what is the admin's (`voiceSettings.apiKey`, the Deepgram key an admin saved, and the admin's case names in `caseOrder` and `lastUsedCase`). **In multi-user mode every write is admin only:** for a non-admin the routes below answer `403 FORBIDDEN` before the body is even validated, so nothing is written, no watcher is toggled and no tunnel is started. Single-user mode is unaffected. +`~/.codeman/settings.json` is one file for the whole server: the synced App Settings (feature switches, the plan-usage collection switch `showPlanUsageLimits`, `runMode`, `respawnPresets`, `tunnelEnabled`, ...), plus `modelConfig`, `caseOrder` and `lastUsedCase`. Reads are open to every signed-in user; in multi-user mode a non-admin reads it without what is the admin's (`voiceSettings.apiKey`, the Deepgram key an admin saved, and the admin's case names in `caseOrder` and `lastUsedCase`). **In multi-user mode every write is admin only:** for a non-admin the routes below answer `403 FORBIDDEN` before the body is even validated, so nothing is written, no watcher is toggled and no tunnel is started. Single-user mode is unaffected. Every write to the file (these routes, and the `lastUsedCase` that `POST /api/quick-start` and `POST /api/ralph-loop/start` record before they answer) runs one at a time and replaces the file atomically (mode `0600`); the one write outside that queue is a config-backup restore, which must work on a file that does not parse. A write to a `settings.json` that exists but is not a JSON object is refused rather than starting from `{}`, which would erase every other setting: the routes below answer `409 CONFLICT` with a message naming the fix and leave the file untouched, the two starts skip their `lastUsedCase`, and an admin's `POST /api/cases/:name/rename` answers the same `409 CONFLICT`, checked before it ends any session or moves the folder. A missing or empty file still starts from `{}`. | Method | Path | Body | Notes | | ------ | ----------------------------- | ------------------------------------ | ----- | | `GET` | `/api/settings` | none | The stored settings object. Never writes. For a non-admin in multi-user mode, without `voiceSettings.apiKey`, `caseOrder` and `lastUsedCase`. | -| `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `403 FORBIDDEN` for a non-admin in multi-user mode. A tunnel enable without `CODEMAN_PASSWORD` is refused separately (`403 OPERATION_FAILED`, resend with `acknowledgeUnauthTunnel: true`). | +| `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `403 FORBIDDEN` for a non-admin in multi-user mode. A tunnel enable with no login (no `CODEMAN_PASSWORD`, not multi-user) is refused separately (`403 OPERATION_FAILED`, resend with `acknowledgeUnauthTunnel: true`) unless the server runs with `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `--allow-unauthenticated-tunnel`. `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` does not count; when it is set, the refusal says so. An enable without that authorization is accepted only while the tunnel is already running and never stores `tunnelEnabled: true`; if the tunnel stops before the request would start it, it is not restarted and the answer is the same `403 OPERATION_FAILED` (the request's other settings are saved). | +| `GET` | `/api/tunnel/status` | none | `{ running, url, loginConfigured }`. `loginConfigured` is `true` when a login protects the server (`CODEMAN_PASSWORD` set or multi-user mode on), the same test the tunnel guard above applies; with `false` the browser asks before every tunnel enable. Neither opt-in variable makes it `true`. | | `GET` | `/api/execution/model-config` | none | `modelConfig` (cron's and the plan orchestrator's default model). | | `PUT` | `/api/execution/model-config` | `{ defaultModel?, agentTypeOverrides?, ... }` | Replaces `modelConfig`. `403 FORBIDDEN` for a non-admin in multi-user mode. | | `PUT` | `/api/cases/order` | `{ order: string[] }` | The one case order every user's `GET /api/cases` is sorted by. `403 FORBIDDEN` for a non-admin in multi-user mode. | @@ -1314,6 +1546,19 @@ Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Setting Delivery goes through the same egress guard as web tabs (refused on the resolved address too), does not follow redirects, times out after 5 s, sends the same event for the same session at most once per 3 s, and has at most 5 requests in flight. Error text never contains the URL. +## Discord bridge + +One thread per tab in a private Discord channel, alerts out and replies typed back in. Setup (bot, least-privilege permissions, IDs) is in [discord-bridge.md](discord-bridge.md). The bot token is a bearer secret: stored in `~/.codeman/discord.json` (0600), never returned, and the three settings routes are admin only in multi-user mode. + +| Method | Path | Body | Notes | +| ------ | ------ | ------ | ------ | +| `GET` | `/api/discord` | none | Config without the token, plus `hasToken`, `tokenMasked`, `gateway` (`off` \| `connecting` \| `connected` \| `error`), `gatewayError` and `lastResult`. | +| `PUT` | `/api/discord` | `{ enabled?, token?, guildId?, parentChannelId?, allowedUserIds?, threadCreation?, onSessionDelete?, staleThreads?, pingMode?, scope?, startupHint? }` (strict) | `threadCreation`: `lazy` \| `onSessionStart`. `onSessionDelete`: `keep` \| `archive` \| `delete`. `pingMode`: `off` \| `critical` \| `all`. `scope`: `attention` \| `all`. An absent `token` keeps the saved one; `""` clears it. `400` for a non-numeric id or enabling without a token, guild, channel and at least one allowed user. | +| `POST` | `/api/discord/test` | none | Posts one message to the parent channel with the saved config, even while disabled. `200` with `data.ok`; `400` if no token or channel is saved. | +| `POST` | `/api/sessions/:id/discord-notify` | `{ message, urgent?, file? }` (strict, message 1 to 1500 characters) | A CLI asks for the user: posts into that tab's thread, pinging when `urgent`. `file` is an absolute path inside the tab's working folder (real path, regular file, at most 8 MB, not on the attachment blocklist, local sessions only) uploaded with the post; `400` otherwise. A `200` carries the delivery result: `data.ok` is `false` (with `data.error`) when Discord refused or failed the post. `409` while the bridge is disabled, `429` past 6 posts a minute per tab. The `codeman notify "message" [--urgent] [--file path]` command wraps this route. | + +Only replies from `allowedUserIds`, in `guildId`, inside a thread the bridge created are typed into a session, as one printable line plus Enter (a lone digit while a permission or question is pending goes as the key alone), at most one per second per tab. Attached files are saved under `.codeman-discord/` in the tab's folder and their paths appended to that line (see [discord-bridge.md](discord-bridge.md)). + ## Diagnostics `GET /api/doctor[?category=core|office|other]` returns the `codeman doctor --json` report (`platform`, `summary`, `tools[]` with `status` `ok` \| `missing` \| `outdated` \| `skipped` \| `error`, `version`, `path`, `installHint`). The probe engine is synchronous, so it runs in a child process of the same entry script, never on the server's event loop (30 s timeout). It names install paths and versions, so it is admin only in multi-user mode (`403`). `400` for an unknown category, `500` if the child produces no report. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 7a743bb6..479d279b 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -53,7 +53,7 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test. ⚠️ **`agent_working` is a hook event with no Claude Code hook behind it** (157th SSE constant). It exists because a harness turn cannot run while one of its own modal approvals is on screen, so "the agent started working" proves a dialog was answered in the terminal. It joins `APPROVAL_RESOLVING_EVENTS`; without it a dsh session's red alert would survive until the next `stop`, the exact stuck-alert bug the claude path already had to fix once — and the pane-capture staleness sweep that fixed it there is Claude-dialog-shaped and cannot help here. -⚠️ **Answers are READ FROM DISK for this mode, not scraped off the pane** (`src/deepseek-transcript.ts`, behind `GET /api/sessions/:id/last-response`). dsh writes a structured JSONL transcript at `$DSH_HOME/sessions/<mangled-cwd>/<dsh-session-id>/session.jsonl.zstd`, so it belongs with claude and codex rather than with the pane-segmented modes — and for dsh specifically the segmenter was not merely coarse but WRONG: dsh-TUI paints a full-screen splash, so a `last-response` call on a fresh dsh session returned its ASCII-art logo, which anything polling for a worker's first answer reads as an answer. Four mechanisms in that file are load-bearing. **(1)** dsh appends **one zstd FRAME per write**, and Node's `zlib` zstd decoder — one-shot AND streaming — stops at the first frame end: a real 56-line transcript decoded as 1 line / 158 bytes, i.e. the session header alone, so every call would have reported "nothing said yet" forever. `zstdFrameRanges()` walks frame and block headers (no decompression) to find exact boundaries and decompresses each frame; splitting on the 4-byte magic instead would corrupt everything after a magic sequence that happens to occur inside compressed data. zstd itself is resolved at RUNTIME (`zstdSupported()`), because it landed in Node 22.15 while the project floor is 22.0 and `@types/node` still does not declare it; without it the mode falls back to the pane exactly as before, which is why `readDeepSeekLastResponse()` distinguishes `null` ("this reader cannot run here") from an empty result ("read fine, nothing said yet"). **(2)** Every turn also records a plugin-sourced `user/message` (dsh's runtime-context snapshot: sandbox policy, approval policy, cwd), so only `source.kind === 'user'` is a real prompt. **(3)** A turn that ends in `reason.kind: 'error'` is surfaced as `Turn error: …` (and a non-error early stop such as `max-tokens` as `Turn ended: …`) rather than as an empty string, which an agent reads as "still thinking" through fifteen polls. **(4)** Reply text is assembled per (turn, step): a finalized `assistant/message` wins, and the streamed `assistant/chunk` / `text-chunks` deltas are consulted ONLY for a step that never finalized (so a partial answer is readable mid-turn without ever being appended twice) — ⚠️ and "finalized" is tracked as a SET of steps, not as non-empty text, because a step whose whole reply was reasoning strips to `''` at the `</think>` boundary and would otherwise resurrect the raw, unstripped deltas in its place (measured on a real conversation). ⚠️ Session→transcript pairing is by the transcript's own header `cwd` plus a ±60 s boot window against the Codeman session's `createdAt`, never by reproducing dsh's directory mangling (which already has two forms on disk, `<uuid>` and `session-<uuid>`) and never by newest-mtime alone: mtime alone handed a freshly spawned worker its PREDECESSOR's answer in the same case directory, which is worse than saying nothing because an agent cannot tell a stale answer from a fresh one. The `DSH_HOME` override reaches the reader through the narrow `Session.deepSeekHomeOverride` getter rather than an `envOverrides` accessor, since that map can hold provider credentials; it is ephemeral by design (never persisted), so a session that overrode it and outlived a server restart resolves the default tree and reads as "nothing said yet". Tests: `test/deepseek-transcript.test.ts`. +⚠️ **Answers are READ FROM DISK for this mode, not scraped off the pane** (`src/deepseek-transcript.ts`, behind `GET /api/sessions/:id/last-response`). dsh writes a structured JSONL transcript at `$DSH_HOME/sessions/<mangled-cwd>/<dsh-session-id>/session.jsonl.zstd`, so it belongs with claude and codex rather than with the pane-segmented modes — and for dsh specifically the segmenter was not merely coarse but WRONG: dsh-TUI paints a full-screen splash, so a `last-response` call on a fresh dsh session returned its ASCII-art logo, which anything polling for a worker's first answer reads as an answer. Four mechanisms in that file are load-bearing. **(1)** dsh appends **one zstd FRAME per write**, and Node's `zlib` zstd decoder (one-shot AND streaming) before 24.22 and 26.10 (nodejs/node#65865) stops at the first frame end: a real 56-line transcript decoded as 1 line / 158 bytes, i.e. the session header alone, so every call would have reported "nothing said yet" forever. `zstdFrameRanges()` walks frame and block headers (no decompression) to find exact boundaries and decompresses each frame; splitting on the 4-byte magic instead would corrupt everything after a magic sequence that happens to occur inside compressed data. zstd itself is resolved at RUNTIME (`zstdSupported()`), because it landed in Node 22.15 while the project floor is 22.0 and `@types/node` still does not declare it; without it the mode falls back to the pane exactly as before, which is why `readDeepSeekLastResponse()` distinguishes `null` ("this reader cannot run here") from an empty result ("read fine, nothing said yet"). **(2)** Every turn also records a plugin-sourced `user/message` (dsh's runtime-context snapshot: sandbox policy, approval policy, cwd), so only `source.kind === 'user'` is a real prompt. **(3)** A turn that ends in `reason.kind: 'error'` is surfaced as `Turn error: …` (and a non-error early stop such as `max-tokens` as `Turn ended: …`) rather than as an empty string, which an agent reads as "still thinking" through fifteen polls. **(4)** Reply text is assembled per (turn, step): a finalized `assistant/message` wins, and the streamed `assistant/chunk` / `text-chunks` deltas are consulted ONLY for a step that never finalized (so a partial answer is readable mid-turn without ever being appended twice) — ⚠️ and "finalized" is tracked as a SET of steps, not as non-empty text, because a step whose whole reply was reasoning strips to `''` at the `</think>` boundary and would otherwise resurrect the raw, unstripped deltas in its place (measured on a real conversation). ⚠️ Session→transcript pairing is by the transcript's own header `cwd` plus a ±60 s boot window against the Codeman session's `createdAt`, never by reproducing dsh's directory mangling (which already has two forms on disk, `<uuid>` and `session-<uuid>`) and never by newest-mtime alone: mtime alone handed a freshly spawned worker its PREDECESSOR's answer in the same case directory, which is worse than saying nothing because an agent cannot tell a stale answer from a fresh one. The `DSH_HOME` override reaches the reader through the narrow `Session.deepSeekHomeOverride` getter rather than an `envOverrides` accessor, since that map can hold provider credentials; it is ephemeral by design (never persisted), so a session that overrode it and outlived a server restart resolves the default tree and reads as "nothing said yet". Tests: `test/deepseek-transcript.test.ts`. ⚠️ **This is also what makes dsh the one non-claude mode the `codeman` agent skill drives like claude** (`skills/codeman/preamble.sh`, preamble 1.20.0): with a real end-of-turn signal AND a real transcript, `spawn_workers alpha beta:deepseek` is a mixed fleet in one call and `sendwait`/`last_text` need no per-mode variant. Two traps are handled in the preamble rather than left to the agent. **Readiness is not the stop signal**: the harness reports `idle` at BOOT roughly 300 ms before its composer paints (measured 2.26 s vs 2.56 s after spawn, twice), so a send-and-wait fired straight after `quick-start` resolves on the boot edge, reports a turn that never ran, and strands the prompt in a pane that was not yet accepting input — `spawn_worker`'s dsh branch gates on the composer glyph (`❯`, overridable via `DSH_READY_MARK`) instead, after which the boot edge is spent and unobservable. And `sendwait` asks for `wait:"stop,exit"` rather than the `wait:true` default set, because that set also carries `idle`, which for any external CLI is inferred from output stabilization: on a dsh worker whose TUI repaints rarely, a re-wait resolved in 0 ms with `signal:"idle"` on a turn that had three minutes left to run. The skill also sends `deepSeekConfig.permissionMode: 'danger-full-access'` for its own workers, matching what the Run button sends, because the harness default still asks and a worker parked on an approval row cannot finish a fan-out (the multi-user clamp still applies). @@ -81,13 +81,13 @@ OMP's registry entry declares `privilegedParams: []` and a launch spec that only Further detail: ⚠️ **Auto-reconnect revives ONLY when the durable remote tmux session is verifiably still alive** (`remoteTmuxSessionAlive()`, a `has-session` probe over ssh, #355): a clean agent exit (Ctrl-C, Ctrl-D, `exit`) tears that session down, and `isPaneDead()` cannot tell it from a transport drop, so the watcher used to relaunch a FRESH agent after every clean exit (claude only looked fine because its `|| --resume` fallback masked it). An unreachable host answers `undefined`, which also means do not revive. ⚠️ `has-session` prints NOTHING on success, so the probe is classified by EXIT STATUS (`classifyRemoteAliveExit`: 0 alive, ssh's 255 or a timeout unknown, anything else gone); reading stdout classified every live session as gone and silently disabled transport-drop reconnects. The answer is cached per session and forgotten whenever the pane is seen alive again, or a stale `true` from one transport drop would revive the next clean exit. -File reads in a remote case are the second ssh surface (see Remote SSH cases below). The reason writes, office previews and thumbnails are deliberately unsupported over ssh is that no remote file is ever copied onto the server's disk. ssh children are bounded because terminal output in a remote session is written on the remote host, so a prompt-injected agent there can print hundreds of `codeman://attach` links. The third surface is a remote session's full-history load, which reads the scrollback from the remote tmux (`src/remote-pane-history.ts`) through the same limiter, with a bounded slot wait (`maxWaitMs`) since the local capture is its fallback. +⚠️ The image watcher never watches a remote session's `workingDir` (a path on the remote host, which it would watch on the LOCAL disk): every call site that starts a watch skips `session.remote` (`setupSessionListeners()`, the app-wide toggle in `PUT /api/settings`, and `POST /api/sessions/:id/image-watcher`, which refuses `enabled: true` with `INVALID_INPUT`), so a new call site must too. File reads in a remote case are the second ssh surface (see Remote SSH cases below). The reason writes, office previews and thumbnails are deliberately unsupported over ssh is that no remote file is ever copied onto the server's disk. ssh children are bounded because terminal output in a remote session is written on the remote host, so a prompt-injected agent there can print hundreds of `codeman://attach` links. The third surface is a remote session's full-history load, which reads the scrollback from the remote tmux (`src/remote-pane-history.ts`) through the same limiter, with a bounded slot wait (`maxWaitMs`) since the local capture is its fallback. ### Remote SSH cases **Remote host wake-on-LAN from user input**: an optional `RemoteHost.wakeMac` (magic packet built and broadcast by Codeman) or `RemoteHost.wakeCommand` (a single executable path, run WITHOUT a shell, and the explicit override) lets the input route — and an explicit `POST /api/sessions/:id/wake` — wake a SLEEPING host instead of writing into a stalled ssh pane; `tmux send-keys` succeeds against a stalled pane, so the bytes used to vanish silently. The wake flow lives in `src/remote-wake.ts` and is reachable **only** from an EXPLICIT user request: `POST /api/sessions/:id/input`, that explicit wake route, and the create/attach path (`POST /api/quick-start` for a remote case, `POST /api/sessions` with `attachRemoteSession`, via `ensureHostAwake`), because "the user pressed Run on a sleeping host" is the same kind of request and the tmux probe would otherwise fail with a misleading "needs tmux installed". Everything TIMER-driven must never wake a host: the COD-108 auto-reconnect watcher, `Server.handleRemoteSessionDropped` and boot recovery have no access to the registry, or a host would be re-woken seconds after each suspend and could never stay asleep (asserted by wiring guards in `test/remote-wake.test.ts`, not just documented — including that `ensureHostAwake` is called from the HTTP route only, since `cron-service.ts` builds sessions through the shared service with nobody waiting on the answer). `GET /api/sessions/:id/reachability` only ASKS — it never wakes — and feeds the amber "host unreachable" banner (`host-wake-ui.js`) whose action is either Wake or, with no target configured, "Configure WoL" → `#wakeConfigModal` (saved via `PUT /api/remote-hosts/:id`). Detection is a throttled bare TCP probe (no ssh, no `ServerAliveInterval` — keepalives would move bytes into an idle connection every interval; and a host behind a jump host/SOCKS proxy is reachability-UNKNOWN, never "asleep": `isProbeable()` keeps the registry from buffering, gating or bannering on a probe that cannot reach it), input is buffered and flushed in order after `reattachRemote()` (the send-and-wait path blocks instead, as does the create path, with a shorter request budget), and the wake fields are re-read from `remote-hosts.json` on recovery AND (throttled, cached) live for a running session, because the persisted `remote` snapshot would never see a field added later (`rehydrateRemoteHostFields` + `RemoteWakeDeps.resolveRemote`). Design + invariants: `docs/remote-sessions.md` §Wake-on-LAN from user input. -**Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to an idempotent `claude --session-id <id> || claude --resume <id>` pair under a login shell, so a respawn or reattach continues the SAME conversation rather than starting a fresh one (remote omp gets the same treatment via `--continue`; ⚠️ because the claude arm is an `a || b` pair under `-c`, that pane's PID is the login shell, not the agent); per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`. ⚠️ **Reading a file in a remote case goes over ssh too** (#415): `src/remote-files.ts` is the single remote-READ layer (`buildRemoteFileCommand` = `buildSshConnectionArgs` + one shellescaped remote command; `remoteProbePaths` returns remote realpath + stat; `remoteCreateReadStream` streams a `Range` via `tail -c +N | head -c L` and its `close()` must be wired to the response's `close` or the ssh child outlives an aborted download). The guard order matches the local path exactly (`validateSessionFilePathLexical` → remote realpath of BOTH file and workspace root → containment → sensitive-path → size cap on the REMOTE size), a request path arrives from the browser and is only ever interpolated as a `shellescape`d token, and an unreachable host answers **502**, never a 404. ⚠️ The probe's symlink resolution FAILS CLOSED: `readlink -f` where it exists, otherwise a `cd -P`/`pwd -P` directory walk plus a bounded plain-`readlink` loop over the last component, and anything it cannot fully resolve is reported unresolvable (404), never as the unresolved string — the first version resolved the directory chain only, so on a host without `readlink -f` a `ws/notes.txt -> ~/.ssh/id_rsa` link passed containment under its own path while `cat` served the key. Records are NUL-separated and index-keyed so a newline in a filename cannot shift the mapping. ⚠️ ssh children are BOUNDED: probes and buffered reads go through `src/remote-ssh-limiter.ts` (a `document-conversion-limiter`-shaped semaphore, default 4), the attachment-history list probes its whole history in ONE batched call (`probeRemoteAttachmentHistory`, threaded into `registerExternalAttachment({remoteProbes})`), and probes chunk at 40 paths — a prompt-injected agent printing `codeman://attach` links in a remote session used to fork one `ssh` per link. `describeExecError` never returns Node's `Command failed: <ssh line>` message (identity path + probe script in a 502 body). The `PUT /file-content` guard sits AHEAD of `validateSessionFilePath`, which resolves LOCALLY, or a same-named local directory (an sshfs mount) takes the write. Under `VITEST` the three IO functions refuse rather than connect. This covers the ATTACHMENT routes too, which is the half a clicked path needs when the file is OUTSIDE the case directory (`_isExternalPreviewPath` sends it to `POST …/attachments`): registration, by-id `raw`, metadata and the history list all resolve over ssh (`registerExternalAttachment({remote})`, `resolveServableRemoteAttachment`), and what decides the host is the SESSION, never the path string — the same absolute path means a different file on each host. Deliberately NOT supported over ssh: writes (`edit=1`/`PUT` answer 400, `editable` is always false), office previews/thumbnails, the file tree/picker, `tail-file`. Tests: `test/remote-files.test.ts`, `test/routes/file-routes-remote.test.ts`. +**Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to an idempotent `claude --session-id <id> || claude --resume <id>` pair under a login shell, so a respawn or reattach continues the SAME conversation rather than starting a fresh one (remote omp gets the same treatment via `--continue`; ⚠️ because the claude arm is an `a || b` pair under `-c`, that pane's PID is the login shell, not the agent); per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`. ⚠️ **Reading a file in a remote case goes over ssh too** (#415): `src/remote-files.ts` is the single remote-READ layer (`buildRemoteFileCommand` = `buildSshConnectionArgs` + one shellescaped remote command; `remoteProbePaths` returns remote realpath + stat; `remoteCreateReadStream` streams a `Range` via `tail -c +N | head -c L` and its `close()` must be wired to the response's `close` or the ssh child outlives an aborted download). The guard order matches the local path exactly (`validateSessionFilePathLexical` → remote realpath of BOTH file and workspace root → containment → sensitive-path → size cap on the REMOTE size), a request path arrives from the browser and is only ever interpolated as a `shellescape`d token, and an unreachable host answers **502**, never a 404. ⚠️ The probe's symlink resolution FAILS CLOSED: `readlink -f` where it exists, otherwise a `cd -P`/`pwd -P` directory walk plus a bounded plain-`readlink` loop over the last component, and anything it cannot fully resolve is reported unresolvable (404), never as the unresolved string — the first version resolved the directory chain only, so on a host without `readlink -f` a `ws/notes.txt -> ~/.ssh/id_rsa` link passed containment under its own path while `cat` served the key. Records are NUL-separated and index-keyed so a newline in a filename cannot shift the mapping. ⚠️ ssh children are BOUNDED: probes and buffered reads go through `src/remote-ssh-limiter.ts` (a `document-conversion-limiter`-shaped semaphore, default 4), the attachment-history list probes its whole history in ONE batched call (`probeRemoteAttachmentHistory`, threaded into `registerExternalAttachment({remoteProbes})`), and probes chunk at 40 paths — a prompt-injected agent printing `codeman://attach` links in a remote session used to fork one `ssh` per link. `describeExecError` never returns Node's `Command failed: <ssh line>` message (identity path + probe script in a 502 body). The `PUT /file-content` guard sits AHEAD of `validateSessionFilePath`, which resolves LOCALLY, or a same-named local directory (an sshfs mount) takes the write. Under `VITEST` the three IO functions refuse rather than connect. This covers the ATTACHMENT routes too, which is the half a clicked path needs when the file is OUTSIDE the case directory (`_isExternalPreviewPath` sends it to `POST …/attachments`): registration, by-id `raw`, metadata and the history list all resolve over ssh (`registerExternalAttachment({remote})`, `resolveServableRemoteAttachment`), and what decides the host is the SESSION, never the path string — the same absolute path means a different file on each host. Deliberately NOT supported over ssh: writes (`edit=1`/`PUT` answer 400, `editable` is always false), office previews/thumbnails, the file tree/picker, `tail-file`. The Ralph file routes follow the same rules (`ralph-routes.ts`): `POST .../fix-plan/read` reads `@fix_plan.md` on the remote host through `remote-files.ts` (one probe of the file and the workspace, confined with the shared `isPathWithinRoot`, capped at 1 MB; missing or outside the workspace is `NOT_FOUND`, anything else `OPERATION_FAILED`, never a local fallback), and `fix-plan/write` and `ralph-prompt/write` refuse a remote session (`INVALID_INPUT`) before touching the local disk. Tests: `test/remote-files.test.ts`, `test/routes/file-routes-remote.test.ts`, `test/routes/ralph-routes-remote.test.ts`. **Remote tmux PATH**: every SSH command that invokes or locates tmux uses `withRemoteTmuxPath()` (`remote-hosts.ts`) before the complete remote script is shell-escaped: link preflight, launch/attach, alive probes, discovery, history/screen capture, and cleanup. Non-interactive SSH shells can omit Homebrew and per-user binaries even when the user's interactive shell finds them. The wrapper double-quotes the exported PATH value (including inherited PATH) so homes with spaces work under dash; it leaves the existing SSH options, socket/target and inner CLI login-shell handling intact. tmux gives a new pane the PATH of the client that created it (measured on tmux 3.4), so every new remote pane, agent or shell, now starts on the widened PATH before its login shell runs. Keep new remote tmux operations on this same path. `test/remote-tmux-path.test.ts` exercises all builders under real sh/dash with a sparse PATH and a home containing spaces, using offline SSH/tmux stubs. Local and Docker tmux commands keep their own setup. @@ -111,7 +111,7 @@ Further detail: ⚠️ An ADOPTED container may back SEVERAL cases at different ⚠️ `external`, `hooks` and `altScreen` are three INDEPENDENT capabilities on purpose; deriving one from another shipped the `until=stop`-hangs-on-shell bug. -⚠️ Four capability fields carry a REGEX from config (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine` and `capabilities.workDetect.awaitingLine`) and all four must compile through `compileVersionRegex()`, which caps length and refuses nested quantifiers; `workingLine` is the one that runs on the PTY hot path. +⚠️ Several capability fields carry a REGEX from config (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `watchingLine`, `awaitingLine`, `blockedLine`, `composerFooterLine`, `composerPopupLine` and `capabilities.modelDetect.screenLine`) and all of them must compile through `compileVersionRegex()`, which caps length and refuses nested quantifiers; `workingLine` is the one that runs on the PTY hot path. ⚠️ **`param` is TWO namespaces.** `launch.params` keys, `env.configSetenv[].fromParam` and `capabilities.privilegedParams[].param` all name a LAUNCH PARAM; the legacy `<Mode>Config` wire field is a separate namespace, bridged only by `launch.legacyConfigAliases`. Getting `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass clamp's only handle on a CLI's privilege switch, and a wrong name clamps nothing with no load error and no failing test — so `schema.ts` rejects an entry naming a param it never declared. Codex is the entry where the two names differ (`bypassApprovals` vs `dangerouslyBypassApprovals`) and therefore the one that catches a regression. @@ -205,7 +205,7 @@ History: before the tree, every pair got a U-bridge bezier hanging below the str **Three owners, one setter.** `SessionState.nameSource` is `placeholder` | `auto` | `manual`. The constructor infers a missing value from the name (`isGeneratedSessionName()` = `w<n>-<case>` / `s<n>-<case>`, or no name at all, is a placeholder; anything else was a person's), the create routes pass none, the boot restore passes the persisted one. The `name` setter is the manual path and the ONLY thing that produces `manual` after construction; `applyAutoName()` is the only thing that produces `auto`, and it does so whether or not the string changed, which is what makes "first prompt" mean the first. A prompt whose title is null (`/clear`, `! npm test`, blank) never reaches it, so the session stays eligible: the first REAL prompt names the tab. -**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path and `POST /api/sessions/:id/input` set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` newline (always a line feed for Ctrl+Enter; for Shift+Enter a line feed unless the CLI declares another `capabilities.newline` chord) never passes through the session. +**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path, `POST /api/sessions/:id/input`, the experimental guarded stage (`stageGuardedInput()`, behind `POST /api/sessions/:id/stage`) and a Discord reply set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` newline (always a line feed for Ctrl+Enter; for Shift+Enter a line feed unless the CLI declares another `capabilities.newline` chord) never passes through the session. **The tracker is a best-effort transcript with explicit per-key rules**, not a byte filter. Mirrored: printable text, backspace, Ctrl+W, Ctrl+U/Ctrl+C (composer emptied), `\n` and Alt+Enter (a newline IN the composer, joined with a space), bracketed paste (newlines inside it likewise). Ignored: cursor keys, Home/End/Delete, Shift+Tab, Tab, SGR mouse and focus reports, Alt chords, OSC/DCS, the rest of C0. Tainting: Up/Down (CSI and SS3), Ctrl+P/N/R, Ctrl+_, because the composer then holds a history line the tracker never saw and Enter must submit nothing rather than a fragment. A bare Esc is resolved at the END of the chunk it arrives in, since xterm hands each key's whole sequence to one write and the programmatic senders send Esc alone; a CSI split across chunks still resumes. The draft keeps its HEAD past 8192 code points (the title is the first sentence, so keeping the tail would title a long paste by its last line) and an escape sequence is abandoned past 64 bytes. @@ -345,8 +345,8 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t - **Lineage arcs to a collapse-hidden session anchor to its group header** (`lineage-line--proxied`); two endpoints proxied to one header draw nothing. - **The upstream HTML5 drag stays off in the grouped rail**: a flat-order drop cannot express a group move, and the server re-ranks within the old group. The grouped rail has its OWN pointer drag instead (`_bindTabLayoutPointerDrag`, mouse/pen only, bound once on the container): rows move before/after a row or into a group, a header drag reorders groups, and the drop maps to ONE operation through the pure `dropOperation()`. Escape cancels a drag, and the click that ends one is swallowed. ⚠️ A press is only captured once it moves 6 px, so its release can land outside the rail: `pointerup`/`pointercancel` are heard on `window` while a press is pending, a move with the primary button up cancels it, and a new press cancels any previous one. Without that a stale press became a phantom drag on the next hover, and a replaced drag left its capture-phase Escape listener behind, swallowing every Escape before the terminal saw it. The flat rail and the header strip keep the HTML5 drag untouched. For the same reason Ctrl+Shift+{ / } only swaps with a neighbour in the active session's own section (`_canSwapActiveTabWith`, reading the projection's `sectionByRef`): a cross-group swap moves nothing on the server, gets no `session:orderChanged` back, and would leave this client's `sessionOrder` and Alt+N targets out of step with every other device. - **Edits are named operations, saved serially.** `createEditCoordinator` applies `createGroup` / `renameGroup` / `deleteGroup` / `reorderGroup` / `moveRef` to the rail at once, then sends ONE `PUT /api/tab-layout {baseVersion, layout}` at a time; edits made meanwhile wait and go out on the version that write returns. A 409 carries the server's layout: the in-flight operations are replayed onto it (an operation that no longer applies is dropped and reported) and re-sent, at most `maxAttempts` times; a 400 re-reads once (a second 400 is reported as a failed save, not as a race); anything else reports and re-reads. A `moveRef` moves the session together with the sessions that still follow it and marks a hand-moved child `placement: 'manual'`, mirroring the server's `moveRef`, and `normalizeLayout` keeps `placement` because whole layouts are written back. `_onTabLayoutChanged` / `_applyTabLayout` defer a read while a write is in flight and rebase unsaved edits onto a read otherwise. ⚠️ A FAILED read (`_applyTabLayout(null)`) while edits are pending keeps the held layout and the editor and re-reads after the write settles: disposing there would orphan the in-flight write, whose 409 then never gets its rebase. Any path that does drop unsaved work says so in a toast. On `pagehide`, unconfirmed operations go out in a `keepalive` PUT AND into sessionStorage as `{ owner, baseVersion, savedAt, operations }`; after reload they replay onto the fresh layout (a no-op when the keepalive landed), but only for the same owner, within `TAB_LAYOUT_PENDING_MAX_AGE_MS` (60 s), and never onto a layout older than the copy's base. A "Move to <group>" with no anchor carries no `index`, so a replay still puts the row last. -- **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to <group>, Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline (Enter commits and refocuses the header; a commit by BLUR leaves focus where it went, since refocusing from inside the blur handler overrides the user's click). The glyph stays visible under `@media (hover: none)`: a touch tablet has no hover and no long-press `contextmenu`. Group names in "Move to" labels are quoted, so a group named "New group" or "ungrouped" cannot read (or translate, case-insensitively) like the fixed entries. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus leaving it, a resize, a second open and any full re-render. It borrows the `.tab-rail-action-menu` class for its look only: `closeTabRailActionMenu()` excludes `.tab-layout-group-action-menu`, so closing the row menu (every `session:deleted` does) cannot strand the group menu's listeners. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`. -- **New, child and web tabs land in groups.** A group menu's "New session" (`createSessionInTabGroup`) runs ONE launch with `run({ count: 1, tabGroupId })`; every launch path puts `tabGroupId` on its create request (`POST /api/sessions` or `/api/quick-start`), and `TabLayoutService.sessionCreated(owner, id, { tabGroupId }, register)` places the session at the end of that group in the SAME versioned write that first records it. The session enters the live map inside the owner lock (`register`, rolled back if the write fails), so no queued layout edit can commit it first. The group is looked up in the creating owner's own layout, so an unknown, deleted or foreign id is ignored rather than failing the spawn, and an explicit group is a hand placement (a child placed this way is `manual`). The create response carries the committed `tabLayout`, adopted before the tab is drawn. A session spawned by another one (`parentSessionId`) and placed by nobody lands after its parent: the server's normalization puts it after the parent's rows in the parent's container, and `project({ parents })` draws a not-yet-stored child there at once. Whether it then FOLLOWS its parent is the synced `spawnedTabsFollowParent` setting (default OFF), which `sessionCreated()` reads fresh through the `childrenFollowParent` dep: off, the child is marked `manual` in that same creating write; on, it follows until moved by hand. Deciding at creation is enough, so flipping the setting leaves existing tabs alone. A hand move makes a following child `manual`; with the setting on, the row menu notes where a child sits (following, placed by hand, parent closed, parent loop; plain text, never a focusable menu item) and offers "Follow parent again" only to a `manual` child, which clears `placement` and is re-derived by the server on save. With the setting off the row menu says nothing about placement. A legacy `PUT /api/session-order` (strip or phone drag, a same-section Ctrl+Shift+{/}, a device without a layout, an admin's cross-owner order) marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`, `childrenSplitFromParent`); a PUT of the current order, or one that moves a family together, leaves every stored placement alone. The browser never echoes a create or delete through that PUT, with or without groups: the service already re-projects the order and broadcasts it (`session:orderChanged`), and `syncSessionOrder` adopts it. A child whose parent is gone stands on its own and is never re-adopted. Saved web tabs are layout refs like sessions, but OPENNESS stays per device: a grouped web tab renders (and takes an Alt+N slot) only while it is open on this device, and reopens where the layout keeps it. +- **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to <group>, Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). With **New Session Button on Tabs** on (`showTabNewButton`, per device, `html.tabs-show-new`), every session row menu (the rail, compact or full, the expanded sidebar, the tree; never a tile's, which shares the function) also offers "New session here…", which opens the row's "+" picker, and the compact rail hides it. The "+" itself opens its picker on Enter or Space through its own inline `onkeydown` (the strip's key handler acts only on a focused tab), which is the keyboard path on the header strip, where no row menu exists. The menu cancels a REPEATED Enter or Space, so a held key that opened it cannot also pick the entry it focuses. A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline (Enter commits and refocuses the header; a commit by BLUR leaves focus where it went, since refocusing from inside the blur handler overrides the user's click). The glyph stays visible under `@media (hover: none)`: a touch tablet has no hover and no long-press `contextmenu`. Group names in "Move to" labels are quoted, so a group named "New group" or "ungrouped" cannot read (or translate, case-insensitively) like the fixed entries. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus moving to an element outside it, a resize, a second open and any full re-render. ⚠️ A `focusout` with no `relatedTarget` is not a departure: Safari never focuses a clicked button, so pressing an item blurs the focused one with focus going nowhere, and closing on that dropped the item's click (the tile count menu's rule). The tab "+" picker is this same menu (`_openTabLayoutMenu` with `anyLayout`, so it opens on every layout, not only the editable grouped rail). It borrows the `.tab-rail-action-menu` class for its look only: `closeTabRailActionMenu()` excludes `.tab-layout-group-action-menu`, so closing the row menu (every `session:deleted` does) cannot strand the group menu's listeners. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`. +- **New, child and web tabs land in groups.** A group menu's "New session" (`createSessionInTabGroup`) runs ONE launch with `run({ count: 1, tabGroupId })`; a tab's "+" (`openTabNewSessionMenu`) runs ONE launch in that tab's case or managed worktree through `_launchOnce` (the Git worktrees modal's launcher too), `run({ count: 1, mode, caseName | worktree, tabGroupId? })`, carrying the tab's named group if it has one. ⚠️ The mode rides the call and is never swapped into the Run button's `_runMode`, so a mode picked while the launch is pending is kept, and the case-list reload after every launch keeps the toolbar's own case (`_reloadCasesAfterRun`): the no-argument reload would re-select the server's `lastUsedCase`, which a "+" launching through quick-start moves to the tab's case while the toolbar stays put, so a later worktree launch, plain Run or group "New session" would carry the toolbar there. ⚠️ The tab's case is found by what the session runs on (`_tabCaseOf`), never by path alone: `GET /api/cases` gives remote and Docker cases a DISPLAY path with the raw values under `remote`/`docker`, so a Docker session belongs to its container's case on its own engine host, a remote one to the case on its own host whose path encloses its own, and a local one to the deepest case enclosing its folder (a display path never encloses one). A Docker tab's picker starts that container's probe (`_probeDockerCaseModes`) when nothing is cached for its case, since the toolbar probes only its own selected case: that open lists what is already known, the next one the container's answer. From an ungrouped tab the "+" sends no `tabGroupId` on purpose (the create schema cannot say "Ungrouped"), so the case default group below applies, as for a toolbar Run. Every launch path puts `tabGroupId` on its create request (`POST /api/sessions` or `/api/quick-start`), and `TabLayoutService.sessionCreated(owner, id, { tabGroupId }, register)` places the session at the end of that group in the SAME versioned write that first records it. The session enters the live map inside the owner lock (`register`, rolled back if the write fails), so no queued layout edit can commit it first. The group is looked up in the creating owner's own layout, so an unknown, deleted or foreign id is ignored rather than failing the spawn, and an explicit group is a hand placement (a child placed this way is `manual`). The create response carries the committed `tabLayout`, adopted before the tab is drawn. A session spawned by another one (`parentSessionId`) and placed by nobody lands after its parent: the server's normalization puts it after the parent's rows in the parent's container, and `project({ parents })` draws a not-yet-stored child there at once. Whether it then FOLLOWS its parent is the synced `spawnedTabsFollowParent` setting (default OFF), which `sessionCreated()` reads fresh through the `childrenFollowParent` dep: off, the child is marked `manual` in that same creating write; on, it follows until moved by hand. Deciding at creation is enough, so flipping the setting leaves existing tabs alone. A hand move makes a following child `manual`; with the setting on, the row menu notes where a child sits (following, placed by hand, parent closed, parent loop; plain text, never a focusable menu item) and offers "Follow parent again" only to a `manual` child, which clears `placement` and is re-derived by the server on save. With the setting off the row menu says nothing about placement. A legacy `PUT /api/session-order` (strip or phone drag, a same-section Ctrl+Shift+{/}, a device without a layout, an admin's cross-owner order) marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`, `childrenSplitFromParent`); a PUT of the current order, or one that moves a family together, leaves every stored placement alone. The browser never echoes a create or delete through that PUT, with or without groups: the service already re-projects the order and broadcasts it (`session:orderChanged`), and `syncSessionOrder` adopts it. A child whose parent is gone stands on its own and is never re-adopted. Saved web tabs are layout refs like sessions, but OPENNESS stays per device: a grouped web tab renders (and takes an Alt+N slot) only while it is open on this device, and reopens where the layout keeps it. - **A case's default session group** (`caseDefaultGroups` in state.json, keyed by the layout owner key then case name; a group id or `null` for Ungrouped) is the fallback placement for a new session of that case. It is read and written ONLY through `TabLayoutService` (`caseDefaultGroup`, `setCaseDefaultGroup`, `forgetCaseDefaultGroup`, and `sessionCreated`'s `caseName` hint), under the owner lock, never by a route or the store directly. Precedence inside `sessionCreated`'s single write: an explicit `tabGroupId` found in the owner's layout, then a parent that places the child (`hasPlacingParent`, the same conditions as `pinCreatedChild`), then the case default, else normal placement. The id is looked up in the creating owner's own layout, so a deleted or foreign id never fails a spawn; a stale id is forgotten only AFTER the creating write lands (a failed write rolls back and changes nothing). `POST /api/quick-start` and `POST /api/ralph-loop/start` pass their `caseName`; `POST /api/sessions` infers it LEXICALLY from `workingDir` (`inferLocalCaseName`: a direct child of the caller's case space, or an exact linked-case path for an admin or single-user, or a Claude worktree of either, `<case>/.claude/worktrees/<name>`, which is how the Git worktrees modal launches Claude while every other CLI goes through quick-start), only for a named `workingDir` (never the server's own cwd), never for a remote attach and never touching the filesystem. Cron jobs, the one-shot run route and reboot restore keep normal placement. The Case settings selector (`onCaseDefaultGroupChanged`, session-ui.js) reads it on popover open and is redrawn from an adopted layout whose groups changed (`_refreshCaseDefaultGroupOptions` in `_applyTabLayout`; an unchanged group list is skipped, since every create, delete and reorder broadcasts a layout and a rebuild would reset an open dropdown). - **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, a group with no open rows is a leaf (no `aria-expanded`, no owned group), and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's. - **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk. `aria-posinset`/`aria-setsize` follow painted order too, so the incremental render path re-runs `_applyTabTreePositions` after it re-sorts rows in place. ⚠️ `_handleTabTreeKeydown` acts only when the key lands on the treeitem ITSELF: a key on a focused in-row control (close, overflow, the rename input) is that control's, or Enter on the overflow button re-selects the row instead of reopening its menu. ⚠️ The roving `tabindex=-1` also hides every item but the stop from the keyboard-dismiss selector's `[tabindex]` arm, which is why `MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR` lists `[role="treeitem"]` (see Dismissing the on-screen keyboard). @@ -409,7 +409,17 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli ⚠️ **The terminal routes an out-of-workspace path to the preview, not the log viewer.** The log viewer spawns `tail -f` and allows only the workspace, `/var/log` and `~/logs`, so an external `.log`/`.json`/code path answered `Path must be within working directory or allowed log directories` while the SAME path clicked in the response viewer previewed fine. `activate()` now checks `_isExternalPreviewPath` alongside `previewsInFileViewer`. In-workspace text keeps the tail viewer, which is the point of it (live follow), except markdown (`.md`/`.markdown`, in `FILE_PREVIEW_EXTENSIONS` since the owner's 2026-10-13 decision, rendered as a document) and `.html`/`.htm` (`rendersAsPage()`, rendered as a page, see below), which always go to the preview; nothing widened `file-stream-manager`'s allowlist, so no `tail -f` is spawned on an arbitrary host path. -**Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it returns the rooted matches first, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. +**Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it reserves spaced candidates first (including unlinkable spans), then detects rooted matches, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against a base (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. + +**The relative base is the pane's directory.** The base is `relativeResolutionBase(session)`: the pane's live directory `SessionState.paneCwd` when known, else `workingDir`, which is fixed at create time and never follows a `cd`. `paneCwd` is `#{pane_current_path}` from the pane-exit watcher's existing batched `list-panes -a` read (`derivePaneCurrentPaths()`, single-pane sessions and absolute paths only; no new tmux call and no new SSE event: the server applies it on the `paneExitsUpdated` tick and broadcasts `session:updated` only on a change). tmux reports the cwd of the pane's FOREGROUND process-group leader, so a shell's `cd` moves it, while an agent CLI's tool subprocesses (Claude's Bash tool, codex's exec) do not: for those sessions it stays the CLI's own directory. ⚠️ `paneCwd` is a resolution BASE only, never a confinement root: `_isExternalPreviewPath`, `file-content`/`file-raw` and the log viewer's `tail -f` allowlist all still judge against `workingDir`, so a pane that `cd`s outside the workspace sends its relative links through the attachment routes like any other external path, and what can be opened is unchanged (an absolute path to the same file already opened). ⚠️ `Session.setPaneCwd()` uses the `paneExitApplies` scoping rule and fails closed the same way: a remote session's local pane runs the ssh client and a docker case's runs `docker exec`, so their pane directory is a host directory the agent was never in; direct-PTY and rediscovered sessions get none either, and the browser re-checks `remote`/`docker`. ⚠️ tmux reads the directory from `/proc/<pgrp>/cwd`, always the RESOLVED path, while `workingDir` keeps the spelling the case was created with (a symlinked `~/codeman-cases`, a case linked through a symlink, macOS `/tmp`), and `_isExternalPreviewPath` compares lexically, so `setPaneCwd()` writes a reading at or under the workspace's resolved path back in `workingDir`'s spelling. The resolution runs once per session in the background, bounded (`probePathKind()` first, then a `realpath()` raced against the same timeout, never on the watcher tick and never `realpathSync`); an `unknown` probe or a failed or slow `realpath()` leaves the reading untranslated and retries after `WORKSPACE_REALPATH_RETRY_MS`. Until it settles, only a reading lexically inside `workingDir` is taken, so the first tick cannot mis-route links. ⚠️ Never restored after a restart: written to `state.json` by `toState()` like the rest of the session state but never read back, and a directory change alone never persists. A failed or empty read keeps every last directory, and a blank `#{pane_current_path}` on a single-pane session keeps that session's (`derivePaneCurrentPaths()` carries it forward); an authoritative read that lists the session as split into several panes, or not at all, clears it, so a hand-split session falls back to `workingDir`. The server applies the manager's answer as given, `undefined` included, and `setPaneCwd()` reports a clear once. Trade-off: the base is the pane's directory at CLICK time, so a relative path printed before a `cd` resolves against the new directory. Tests: `test/pane-cwd-tracking.test.ts`. + +Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths keep the own-line and marker forms plus the same path labels; any other label (`Error:`, `Note:`, `Saved the report to:`) defers an absolute path too. A rooted candidate needs a directory before its first space, so `/review the changes in src/a.ts` is prose. An earlier word holding a known extension anywhere (`a.ts#L12 foo.ts`) ends the path at that word, a `:` followed by a digit inside an earlier word is a line suffix (`a.ts:42:See b.md`, grep and compiler output) and ends the path there, a run of spaces is a column gap rather than a name, and a leading run of `./` or `../` is not a segment (`Saved to: ../My Docs/plan.md` links whole; an interior `..` is still refused). An unlinkable candidate reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` keeps its ordinary links while `/etc/secret files/passwords.txt` links nothing. A path label vouches that a path follows, not for the words before it: a relative candidate whose only `/` word is its last is prose after the label (`Wrote: 120 lines to src/foo.ts`, `Path: see src/foo.ts`) and defers to the ordinary detectors. The cost is a labelled relative path whose only space is in its top directory, `Saved to: My Docs/plan.md` (and `Saved to: 03 Attachments/plan.pptx`), which splits as on master; `03 Attachments/Build SIPOC Design/Build-SIPOC.pptx`, `notes/a b/c.md`, `~/`, `./` and `../` forms and absolute paths still link whole. An earlier word that is a lowercase connective or verb (`SPACED_PATH_PROSE_WORD`: `and`, `or`, `to`, `in`, `the`, `is`, `has`, `contains`, `then`, `as` and a few more) or a bare `=` makes the candidate a sentence, in every shape: `/home/me/proj contains a.md`, `- /tmp/out has the logs in run.log`, `Path: /home/me/proj/out contains a.md`, `Files: src/components and utils/x.ts`, `Wrote: 3 files to docs/design notes/overview.md`. The cost is a name that uses one of those words (`Pros and Cons.md`, `How to deploy.md`, `Documents and Settings`), which splits as on master; `at` (`Screenshot 2026-10-24 at 10.00.00.png`) and `of` (`Copy of report.docx`) are left out of the list on purpose. What remains ambiguous is an own-line absolute path followed by words that hold none of them (`/home/me/proj/out report.pdf` reads like `/home/me/docs/Read Me.txt`), which links as one path. A command line is refused too: a first word that is an executable under `bin/`, `.bin/`, `scripts/` or `tools/` (`/opt/homebrew/bin/node server.js`, `/home/me/proj/scripts/deploy prod.yml`) or any `-x`/`--x` flag word (`/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`); a bare `-` (`Docs - Copy`, `OneDrive - Contoso`) is not a flag. So is a shell prompt that prints an absolute working directory (WSL's `/mnt/c/...` is outside `$HOME`, so every earlier command in a shell's scrollback has one): a first word ending in `$` (`/home/me/proj$ cat notes.md`) and any earlier word with no letter or digit other than a bare `-` (`/mnt/c/proj ❯ code notes.md`, `/home/me/proj $ cat notes.md`, `/home/me/proj/out ✓ report.pdf`) refuse the candidate, while `Program Files (x86)` and `Docs - Copy` still link whole. The response viewer carries any leading `<strong>` label in a paragraph or list item, or one starting a later line of a paragraph (after a `<br>`, since chat renders with `breaks: true`), into detection without changing the label or displayed text, and `spacedFilePathOnLine()` judges it as it judges the same line in the terminal (`**Note:** /home/me/proj/out report.pdf` splits). A text node that follows any other inline element starts mid-line, so a placeholder word stands in for what precedes it (``Use `--config` /home/me/proj/cfg data.json`` splits as in the terminal), while a path inside inline code still links whole; paths split across inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap. + +Parentheses also bound an inline spaced path, such as `Updated (/mnt/c/My Docs/report.conf)`. The whole candidate is reserved before fragment detectors run; existing root, prose and traversal guards still apply. Inside parentheses only, a comma at the end of an earlier word marks a list, not one filename. Own-line, list-marker and labelled paths retain commas in filenames and directory names. ⚠️ `Bash(` is the one call shape left out (`opensBashCall()`, both here and for the hard-wrap alternative below): it holds a command, so `Bash(/tmp/a.out input.txt)` is a program and its argument and keeps master's links, while the path tools (`Read(`, `Update(`, `Write(`) link a spaced name whole. + +Hard-wrapped filenames keep the existing concatenation without inserted spaces. A parenthesised final filename broken between words also carries a spaced alternative without changing its cell mapping. On activation only, the terminal checks the original path through the existing guarded attachment registration route (`notify: false`), tries the alternative only after HTTP 404, and retains the clicked session throughout. If both exist, the original wins. Every original result other than HTTP 404 goes to the normal opener, preserving workspace previews and log viewing even for extensions the attachment registry rejects; for an external path that opener retains the same attachment guards and errors. After an original 404, a spaced alternative opens only if it registers successfully; if it also returns 404, the original goes to the normal opener. Path labels, list markers and leading indentation inside parentheses are excluded from the alternative’s lookup key. Hover performs no lookup. Text alone cannot distinguish the two filenames, so this uses the filesystem result rather than a spacing guess. + +The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A row end counts as a cell end, so `⏺ Docs live at https://example.com/guide/` followed by ` self-hosted` still links `.../guide/self-hosted`, and an indented `and/or the API reference covers it.` still links `.../guide/and/or`; both are pinned as known trade-offs. Such a token is still refused when it is code with a call paren (`client.get()`, `expect(x).toBe(y)`; a wiki title like `Mercury_(planet)` keeps its paren) or a word ending a sentence (`ready.`, `Done.`, `self-hosted.`; a file name keeps its period, so `install.html.` still joins). Whatever its punctuation, a token never continues a URL when it ends with a colon (a label such as `Failure/Error:` or `prometheus.io/scrape:`), when it is a measurement or clock time (`45%`, `45%|####`, `12.3MB/45.0MB`, `12.3ms`, `10:00:00`; percent-encoding such as `2026%20Plan` still joins), or when it is a `name=value` field (`API_KEY=...`, `level=info`) under a URL ending in `/`, since a query needs a `?` first (after `?`, `&` or `_` it is a query parameter and joins). A continuation must be indented (a column-0 row is new output, which keeps `status=200`, `24/7` and `really?` out), and a shell prompt (`user@host:`), a home path (`~/`), a whole email address or an HTTP status line never continues a URL. A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. When other columns follow a wrapped URL on its first row (a codex table's path or date column), the URL is stitched without them, and they still link on that row: `terminalLinkLine()` scans them after the stitched rows as a segment of their own, mapped back to their cells. A URL in such a column is the row's last URL, and only the last URL on a row is continued, so that row is never stitched. A divider row never continues the row above it, so a URL that reaches the right edge does not absorb a full-width `────` row below. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. **Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`. @@ -425,7 +435,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli ### Filesystem path picker -**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command. +**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing has it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > New Case makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. ⚠️ **This is a second file-serving surface, so it carries the same confinement burden as [Attachments](#attachments) and does not inherit it automatically.** Traversal is allowlisted to Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured via `CODEMAN_FILE_PICKER_ROOTS`; sensitive trees are blocked and symlink escapes are rejected after `realpath` resolution rather than before. Without the realpath step a symlink inside an allowed root would walk straight out of it. `preview` reuses the shared conversion cache and the **global** `document-conversion-limiter`, which is what stops N concurrent large-document previews from forking N multi-minute converter processes. Content types are pinned: images and PDF inline, DOCX/PPTX through the converters, and Markdown/TXT/JSON as inert `text/plain` (never `text/html`, which would be stored XSS on our own origin). Size caps are 2MB for text and 50MB for binary/document previews. @@ -464,11 +474,13 @@ Tests: `test/file-preview-markdown.test.ts` (jsdom-in-vm, pins every rule above) ### Clone a repository as a case -**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`). +**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`). An optional `path` (the **Clone into a custom folder** checkbox) is the folder the repository is cloned INTO, exactly as given (the case name adds no subfolder), and it registers a LINKED case, the same way `POST /api/cases` with a `path` does: it is judged by `prepareNewCasePath()` (`web/case-path.ts`: blocked trees, symlinks, parent must exist), is admin-only in multi-user mode (it writes an absolute path into the shared linked-cases registry), and must be empty or not exist yet. An existing empty folder is cloned into directly (`CloneOptions.intoEmptyDir`: no temporary sibling and no rename, so a mount point works; a failed attempt empties it again but never removes it); a folder with anything in it is refused. Deleting such a case unlinks it and never removes the files. If the registry write fails after git finished, the folder is kept and the error says where it is. - ⚠️ **The URL is a code-execution surface, which is why it is parsed rather than forwarded.** `ext::sh -c <cmd>` makes git run an arbitrary command as its transport, and ANY `<name>::<payload>` dispatches to a `git-remote-<name>` helper, so every `::` form is refused outright. A repository starting with `-` is read by git as a flag; that is rejected AND every spawn puts `--` before the operands, because either defence alone is one edit away from being a hole. Spawns are argv arrays, never a shell (unlike `remote-hosts.ts`, which does build a shell line and must `shellescape`). The Zod schema deliberately only length-bounds `repository` — a weaker regex duplicate of `parseGitRepositoryUrl` would be the copy that drifts. - ⚠️ **Non-interactive or it hangs the request.** The clone is synchronous by design (no job store, no polling, no cancellation surface), so an invisible credential prompt would pin an open HTTP request until the timeout. `gitNonInteractiveEnv()` closes all four prompt paths at once: `GIT_TERMINAL_PROMPT=0`, empty `GIT_ASKPASS`/`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=never` + empty `DISPLAY`, `GCM_INTERACTIVE=never`, and `ssh -oBatchMode=yes`. `HOME`/`PATH` are inherited on purpose — a user whose own agent or credential helper already works keeps working (so a private repo may well clone; Codeman just never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ In multi-user mode a NON-ADMIN's clone and preflight run with `git -c credential.helper=` (`cloneWithoutCredentialHelpers`), because the helpers belong to the one server account every user shares; admins and single-user mode keep them. The Docker server image relies on exactly this: when built with the opt-in `CODEMAN_INSTALL_GH`/`CODEMAN_INSTALL_AZ` args it configures `gh` (github.com) and an `az`-backed helper (`docker/git-credential-azure-cli`, Azure DevOps) in its SYSTEM gitconfig, both silent until the user signs the CLI in, so a private clone there still fails fast rather than prompting. - ⚠️ **Bounded in time, output and concurrency.** Timeout → SIGTERM → SIGKILL, signalled to the whole process GROUP (`detached: true`, negative pid) because `git clone` fans out into `git-remote-https`/`index-pack` children that a polite signal to the parent leaves running. stderr is kept as a bounded, credential-redacted, control-stripped TAIL; `ls-remote` stdout is capped and refs are capped at 500 each. A small global pool (default 2, `CODEMAN_MAX_GIT_OPERATIONS`) caps concurrent git network ops, same reasoning as `document-conversion-limiter.ts`. +- ⚠️ **A direct clone's cleanup has a premise.** One direct (`intoEmptyDir`) clone per folder runs at a time, and a failed one empties the folder only when git actually ran (`GitRun.started`: a full queue or a slot wait that timed out wrote nothing) and did not refuse the folder. That refusal is read from git's own `already exists and is not an empty directory` text, tested directly and never through the classifier's code: the message quotes the path, and a folder named `openssl` once classified as unreachable. A folder whose mount stopped answering is left alone too (`probePathKind()`), so no libuv worker is held on it. +- ⚠️ **No clone at or inside a Docker case workspace, with or without a `path`.** Git would run the new repository's hooks and filters on the host. Refused `400` on both branches. - **Repository contents beat scaffolding.** An existing `CLAUDE.md` is kept (a generated one is written only when absent) and hooks are MERGED into whatever `.claude/settings.local.json` the repo shipped. A repo that ships its own `.claude/settings*.json` is reported back as a warning, because repo-supplied hooks run on the user's machine as soon as a session starts there. - **Failure leaves nothing behind.** The destination is removed only when it did not exist before the attempt, and a pre-existing directory is refused rather than cloned into, so a failed clone never squats on a case name and never touches an existing tree. - ⚠️ **Error detail comes from the LAST diagnostic line, not the first.** `git clone` opens with `Cloning into '<dest>'…`, so a first-line pick reported the destination path as the reason a bad branch failed (observed against a real remote). `NOT_FOUND` wording must also say "or private": GitHub answers "Repository not found" for a private repo and a typo alike when unauthenticated. @@ -624,17 +636,17 @@ Adding the header changed the preamble, so `CODEMAN_PREAMBLE` was bumped (1.22.0 **Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`, design doc `docs/custom-model-endpoints-plan.md`; full stack — settings-panel CRUD + the Run-menu picker, on top of the backend below): points a session at a user-configured custom OpenAI-compatible endpoint — local (llama.cpp, DGX Spark, Strix Halo) or cloud (Azure AI Foundry, OpenRouter) — instead of its harness's native cloud backend. Endpoints are a read/write-array store (`custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`) discovered via `GET <baseUrl>/v1/models`; `CustomModelHost.authStyle` is `'bearer'` (default, `Authorization: Bearer`) or `'api-key'` (Azure's convention) — **never both**, live-tested against a real server: sending both headers on one request reliably hangs it indefinitely, reproduced 3×. -⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/deepseek, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp — writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config — and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source — the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.<name>]` block redirected via `GROK_HOME` — its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified. +⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/copilot, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp/deepseek, writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config/dsh home; deepseek's redirect is a per-session `DSH_HOME` holding only the generated `cordis.patch.yml`, whose `linkFromRealHome` recipe field symlinks `profiles`/`sessions`/`settings.yaml` back out of the real dsh home (resolved session-`DSH_HOME`-first, lstat-guarded so a dangling link survives re-apply) instead of stranding them, and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source, the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.<name>]` block redirected via `GROK_HOME`, its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified. -⚠️ **Two launch paths, chosen by mechanism, not preference**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()` — a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share — because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` relaunches a CLI in an existing pane, so a CLI whose launch declares a `fallback` chain (claude) gets a conversation pinned as `resumeSessionId` for that one respawn: `--session-id <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <id>` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. The dead-pane respawn shares this pin, see [Dead-pane respawn: the resume pin](#dead-pane-respawn-the-resume-pin). ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/<id>` for pi/omp, grok's `[model.codeman-custom]` block name): that is the registry's `customModelInjection.launchModel` template, applied onto the respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id, and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model; never the values, which carry the API key and are re-derived from the endpoint store on recovery), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600. +⚠️ **Two launch paths, chosen by mechanism, not preference**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()`, a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share, because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` relaunches a CLI in an existing pane, so a CLI whose launch declares a `fallback` chain (claude) gets a conversation pinned as `resumeSessionId` for that one respawn: `--session-id <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <id>` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. The dead-pane respawn shares this pin, see [Dead-pane respawn: the resume pin](#dead-pane-respawn-the-resume-pin). ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/<id>` for pi/omp, grok's `[model.codeman-custom]` block name), and codex gets one too (`--model <bare modelId>`, its pattern changed to `model-plain-colon`, which admits qwen3-style colon ids and requires a letter or digit first, for the native App Settings Codex model too): that is the registry's `customModelInjection.launchModel` template, applied onto the respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id, and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model; never the values, which carry the API key and are re-derived from the endpoint store on recovery), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600. ⚠️ **Security**: every env var this feature can redirect (`ANTHROPIC_BASE_URL`, `GOOGLE_GEMINI_BASE_URL`, `CODEX_HOME`, `GROK_HOME`, `HOME` for pi/omp, `OPENCODE_CONFIG_CONTENT`, etc.) is in that CLI's `privilegedEnvKeys` — several of these were reachable via the generic `envOverrides` field's prefix allowlist BEFORE this feature existed (the env allowlist is global and prefix-based, not per-CLI-scoped), so building this surfaced and closed a pre-existing gap rather than opening a new one. `ANTHROPIC_*` is deliberately NOT in claude's `allowedPrefixes` at all — Anthropic-traffic redirection can only happen through this feature's own admin-configured, SSRF-guarded route, never a plain client-supplied `envOverrides`. -**Confidence, verified end-to-end against a real llama-swap server via the DYNAMIC `scripts/test-local-llm-harnesses.ts`** (reads the live CLI registry, so a registry change needs zero script edits): claude/opencode/pi/grok/omp **PASS**; codex config structure is correct, and codex only speaks the Responses API since Feb 2026 (`wire_api = "responses"`) — re-verified live against a llama-swap deployment that DOES answer `/v1/responses` (an earlier test's harder failure against a different deployment does not reproduce everywhere): a plain, no-tool-call chat turn gets a real reply, but a real tool-call attempt came back as `agent_message` TEXT (the tool-call JSON printed as the answer) rather than an executable `function_call` item — confirmed via `codex exec --json`'s raw event stream. Tool execution is what makes codex a coding agent, so it remains not usable for real work either way, just with a more precise failure mode than a flat protocol break; gemini fails with `Invalid auth method selected` (an undocumented `GATEWAY` AuthType gemini-cli selects once `GOOGLE_GEMINI_BASE_URL` is set — unresolved after real investigation); deepseek's originally-reported `HTTP_404` is root-caused and fixed — its bundled `@deepseek-ai/dsh-llm-deepseek` module builds `${DEEPSEEK_BASE_URL}/chat/completions` with no `/v1` of its own (confirmed by installing the real package and reading its source), so a new `appendV1Suffix` flag on its registry entry (alone — claude/gemini must not get it) runs `endpoint.baseUrl` through `withV1Suffix()` before writing it, live-confirmed against llama-swap (`.../chat/completions` 404s, `.../v1/chat/completions` succeeds) though not yet re-run through an actual `dsh` binary, which isn't installable in this environment; antigravity has no known mechanism at all. See the confidence table in `docs/custom-model-endpoints-plan.md` for the full detail on each. +**Confidence, verified end-to-end against a real llama-swap server via the DYNAMIC `scripts/test-local-llm-harnesses.ts`** (reads the live CLI registry, so a registry change needs zero script edits): claude/opencode/pi/grok/omp **PASS**; codex config structure is correct, and codex only speaks the Responses API since Feb 2026 (`wire_api = "responses"`), re-verified live against a llama-swap deployment that DOES answer `/v1/responses` (an earlier test's harder failure against a different deployment does not reproduce everywhere): a plain, no-tool-call chat turn gets a real reply, but a real tool-call attempt came back as `agent_message` TEXT (the tool-call JSON printed as the answer) rather than an executable `function_call` item, confirmed via `codex exec --json`'s raw event stream. Tool execution is what makes codex a coding agent, so it remains not usable for real work either way, just with a more precise failure mode than a flat protocol break; gemini fails with `Invalid auth method selected` (an undocumented `GATEWAY` AuthType gemini-cli selects once `GOOGLE_GEMINI_BASE_URL` is set, unresolved after real investigation); deepseek's originally-reported `HTTP_404` is root-caused and fixed, its bundled `@deepseek-ai/dsh-llm-deepseek` module builds `${DEEPSEEK_BASE_URL}/chat/completions` with no `/v1` of its own (confirmed by installing the real package and reading its source), live against llama-swap (`.../chat/completions` 404s, `.../v1/chat/completions` succeeds), and the env-var redirect that first fixed it has since been REPLACED by a `configDir` recipe: a per-session `DSH_HOME` carrying only a generated `cordis.patch.yml` that declares a `codeman-custom` `openai-completions` provider over the installed profile, with the `/v1` fix living in `renderConfigFile`'s shared `withV1Suffix()` (every file template applies it; the `env` kind's `appendV1Suffix` flag now belongs to copilot alone) and the session's state symlinked back from the real dsh home; a real "hello world" reply through an actual `dsh` binary has since been verified live by the maintainer (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1: `--dump-config` shows the patched rows, the TUI boots on the chosen model, and a prompt reaches the endpoint as `POST /v1/chat/completions` with the reply rendering in the TUI), so this is verified end-to-end like the harnesses above; antigravity has no known mechanism at all. See the confidence table in `docs/custom-model-endpoints-plan.md` for the full detail on each. ⚠️ **The Run-menu picker generates entries from `window.__codemanCustomModelClis`** (`server.ts`, injected at page render from `enabledClis().filter(kind==='agent' && customModelInjection.kind!=='unsupported')`, JSON-escaped against a literal `</script>` via the exported `escapeScriptJson()` since `label` is a user-`clis.json`-settable string unlike the neighbouring booleans-only `__codemanCliAvailable`), never a hardcoded per-CLI id list in the frontend — the same "no branching on CLI id outside stock.ts" discipline the registry itself enforces. One entry per (capable, INSTALLED CLI, saved endpoint) pair, e.g. "Claude Code (llama.cpp)", filtered through `isCliAvailable()` like the stock entries. Clicking one calls `selectCustomModelEntry(mode, endpointId)` (`session-ui.js`), which re-fetches the endpoint (never trusts anything cached from the dropdown's render — the 5-minute sweep below or a settings edit may have changed it since) and decides the model: exactly one discovered model launches straight away, two or more open `#customModelPickModal` to ask, with `defaultModelId` marked but never auto-chosen (asking exists so ONE launch can deliberately differ from the saved default). -⚠️ **The picker promotes exactly one row to the top**: whichever model llama-swap reports `ready` right now (tagged "Currently loaded", queried via `GET /api/model-endpoints/:id/running-status`, client-side bounded to ~800ms via `Promise.race` so a sleeping/firewalled endpoint cannot leave the modal invisible for the route's own 5s server-side timeout) beats a merely remembered choice, and — only when nothing is currently loaded — the model actually launched last for this exact (harness, endpoint) pair (tagged "Last used", read from the per-device `codeman:customModelLastUsed:<mode>:<endpointId>` localStorage key). Neither tag reorders past the top, and the "Default" pill is a SEPARATE span rather than a third value of the same slot, so a promoted row that is also the endpoint's `defaultModelId` shows both (on a single-purpose GPU box that is the common case; an exclusive slot silently dropped the Default marking for exactly that row). "Last used" is written by `_runCustomModelEntryViaRestart` (claude) and `_quickStartWithCustomModelConfirm` (every one-shot launch; `runCustomModelEntry` itself only dispatches between the two) only once the model is actually applied — never on the mere click — because a context-window-warning decline means this exact model cannot work with this CLI at all, and promoting a model that cannot launch would be actively wrong, not just premature. +⚠️ **The picker promotes exactly one row to the top**: whichever model llama-swap reports `ready` right now (tagged "Currently loaded", queried via `GET /api/model-endpoints/:id/running-status`, client-side bounded to ~800ms via `Promise.race` so a sleeping/firewalled endpoint cannot leave the modal invisible for the route's own 5s server-side timeout) beats a live session's pin (tagged "In use by a session": `_getCustomModelInUseBySession` walks `this.sessions` for one of your own sessions whose `customModel.endpointId` is that host, newest `createdAt` wins), which applies only when nothing is currently loaded and beats a merely remembered choice, the model actually launched last for this exact (harness, endpoint) pair (tagged "Last used", read from the per-device `codeman:customModelLastUsed:<mode>:<endpointId>` localStorage key). ⚠️ The pin reads only `customModel`, never `displayModel` (screen-scraped display text: a terminal that merely printed a matching string is not evidence), and never counts a session with `status: 'stopped'`, a null `pid` (its PTY exited) or a recorded `paneExit`, the same live-pane test keep-warm applies. ⚠️ When nothing was promoted and the probe answered `isLlamaSwap: false` (the shape `getLlamaSwapStatus` gives for EVERY failure it catches, a down or refused endpoint included, so the line claims only that nothing was reported), the static `#customModelPickUnknown` line says so out loud; its `[hidden]` rule must keep `display: none !important` scoped to `#customModelPickModal`, because `.form-hint`'s `display: block` outranks the UA `[hidden]` rule. Neither tag reorders past the top, and the "Default" pill is a SEPARATE span rather than a third value of the same slot, so a promoted row that is also the endpoint's `defaultModelId` shows both (on a single-purpose GPU box that is the common case; an exclusive slot silently dropped the Default marking for exactly that row). "Last used" is written by `_runCustomModelEntryViaRestart` (claude) and `_quickStartWithCustomModelConfirm` (every one-shot launch; `runCustomModelEntry` itself only dispatches between the two) only once the model is actually applied, never on the mere click, because a context-window-warning decline means this exact model cannot work with this CLI at all, and promoting a model that cannot launch would be actively wrong, not just premature. Either way the actual launch (`runCustomModelEntry`) routes through `run()` itself via a temporary `_runMode` swap — never `setRunMode()`, which would persist it as the user's new default — rather than a parallel dispatch table, which is what gives a custom-model launch the same `_runInFlight` lock every other Run click gets and means a CLI whose injection recipe lands later needs no update here. It then GETs `/api/sessions/:id/wait?until=idle&timeout=20000` on that session BEFORE applying — measured live, a freshly launched CLI reports itself `busy` for its own startup (boot spinner, workspace-trust check) well before the apply call would otherwise reach it, and the apply route's `isBusy()` guard correctly can't tell that apart from a real turn in progress, so every fresh launch failed with `SESSION_BUSY` until this wait was added. A timeout there is a normal 200 per the wait endpoint's own contract, never an error, so a session still busy after 20s just reaches the apply call anyway and gets that route's own honest error. It then calls `POST /api/sessions/:id/custom-model` on the session `run()` produced, guarded by snapshotting `activeSessionId` before the call and requiring it to have actually changed after — every `run*()` handles its own failure internally and returns normally rather than throwing, so a declined/failed launch must not silently re-point and restart whatever session was already open. @@ -738,6 +750,24 @@ Matching semantics: a query containing `/` matches the relative path, otherwise ⚠️ Closing the preview must **pause and unload** the media (`_stopFilePreviewMedia` in panels-ui.js): dropping the overlay's `visible` class is `display:none` and nothing else, and a DETACHED `HTMLMediaElement` keeps playing, which is how the X button used to leave a video audible with no player to pause. +### Edit history panel + +**Edit history panel** (issue #643; `showEditHistory`, per-device, default OFF): which files a local claude session edited (main agent and subagents), each edit's diff, and images a Bash call produced, read from the session's transcripts by byte offset, never from git. Parser `src/edit-history-parse.ts` (pure), store `src/edit-history-store.ts`, reader `src/edit-history-local-source.ts`, sync `src/web/edit-history-sync.ts`, routes `src/web/routes/edit-history-routes.ts` (`GET /api/sessions/:id/edits[?sync=1]`, `GET …/edits/:entryId`, `POST …/edits/older`, all through `findSessionOrFail` WITH `req`), panel `edit-history-ui.js`. User guide: `docs/wiki/Working-With-Files.md` § Edit history. + +⚠️ **Reads only on request, opt-in all the way down.** `server.ts` builds the `EditHistorySync` and hands it to the routes; its only own calls are `remove` (through `forgetEditHistory`, when the session's persisted record goes), `sweep` (startup) and `flushAll` (shutdown). No hook, no timer, no second watcher. The panel drives every read: `?sync=1` when it opens, and while it is open on that session a debounced (600 ms) re-read after a `transcript:tool_end` of an edit-capable tool (Edit, Write, MultiEdit, NotebookEdit, Bash, Agent, Task, SendUserFile), which the existing `TranscriptWatcher` already broadcasts, or the session's `session:idle` (the end of a turn, via `_onSessionIdle`; it also catches a background subagent that writes after the tool end that started it). Not `transcript:complete`: it fires on a `result` line, which claude's interactive transcript never has. A closed panel reads nothing and keeps no badge. Guarded by the "reads only on request" wiring test in `test/edit-history-sync.test.ts`. + +⚠️ **Which transcript** (`resolveClaudeTranscript`). The main transcript is the path the session's `TranscriptWatcher` follows (the hook-reported `transcript_path`), so a session is read from where its claude really writes, whatever its `CLAUDE_CONFIG_DIR`. Without a watcher (server restarted, no hook since), `findClaudeTranscript()` looks for `<claudeSessionId|id>.jsonl` under `claudeProjectsDir(session.claudeConfigDir())`: the session's own override (#255, the same dir the relaunch pin checks), else the server's. A config dir set outside Codeman (a shell profile, a wrapper) is never guessed: until the next hook such a session answers `unavailable` / `no-transcript`, and the panel says so. Subagents are `<main path without .jsonl>/subagents/agent-<id>.jsonl`. A different main file (`/clear`, resume): the previous one, next to it in the same directory, is first read to its end with its subagents (over as many syncs as that takes), then the new one from its end with fresh cursors; the stored entries stay. "Load older" reaches back within the current conversation only, and reads the stored conversation's transcript while the switch is still pending. + +⚠️ **Bounded reads.** A sync is at most `MAX_FORWARD_PASSES` (3) reads, each at most `READ_BUDGET_BYTES` (8 MiB) shared by the main transcript and all subagents (main first), plus up to 8 KiB of `meta.json` per subagent seen for the first time; a first read starts that far before the end. A file whose chunk the shared budget cut short is not applied but read in a later pass (the store would take a chunk with no line end for one line longer than the budget). "Load older" reads a full-budget block of up to `MAX_OLDER_SOURCES` (3) transcripts per click; with more, they take turns. A line longer than the budget is skipped, going forward and going back, and the line after (before) it is read whole. A subagent transcript that vanished loses its cursor, not its entries. Parsing happens in the request, line by line; nothing runs on the PTY data path. One sync per session at a time, and requests during a run fold into one more run. At most `MAX_LOADED_STORES` (3) stores stay in memory: past that the least recently used one without a run in flight is written (only if it changed) and dropped, and a later request loads it again after that write. + +⚠️ **The store holds secrets.** Every diff an agent wrote ends up in `dataPath('edit-history')/<sessionId>.json`, API keys and `.env` contents included. It is written 0600 via tmp + rename (a fresh file each time, so an existing store never keeps wider bits; a new directory is created 0700) and bounded per session: `MAX_ENTRIES` (500) and `MAX_STORE_BYTES` (4 MiB of diff text, paths and ids), oldest dropped, also on load. Each edit's diff is capped at `MAX_HUNK_BYTES` (64 KiB); the file also holds one cursor per transcript file of the current conversation, at most `MAX_PENDING_BASH` (200) unanswered Bash calls of at most 8,192 characters of command text each (a call with an id, cwd or agent id over 4,096 characters is not kept, and dropped on load) and `MAX_AGENTS` (501) subagent labels of at most 1,024 characters per string. `load()` takes the file as untrusted: one over `MAX_FILE_BYTES` (32 MiB) is not parsed, and the rest is rebuilt from its known fields under the same bounds (unknown fields and non-string diff lines dropped, the per-edit cap applied again, a transcript id with anything but `[A-Za-z0-9._-]` dropped, since the previous transcript's path is built from it). Writes are serialized. The store lives as long as the session's persisted record: `forgetEditHistory` runs when `cleanupSession` with `killMux` removes the record (a pinned session's record is only demoted and keeps its history), on the persisted-only `DELETE /api/sessions/:id`, for every record `cleanupStaleSessions` drops at startup and every one `POST /api/cleanup-state` drops. After that startup cleanup, `sweep` deletes the `edit-history/*.json` files whose session is neither live nor recorded (left behind by a crash between removing the record and deleting the file, or by an older build) and tmp files of earlier runs (named per process start, so a pid used again, a container's pid 1, does not keep one). While `remove()` runs, nothing writes the file again: a sync in flight stops after its current read, and a request meanwhile reads nothing (`unavailable` / `removed`). The routes start a sync in the same tick as their session check, so a request that passed the check before the deletion cannot start one afterwards. Then the session's slot goes, and a session that comes back with the same id (reboot restore re-creates it) starts afresh. A store that is not loaded is deleted without being parsed. The store is blocked in `isSensitivePath` (`/.codeman*/edit-history/`, same reasoning as `intents.json`) and never fed to `/api/search`. + +⚠️ **Images only through existing file surfaces.** A Bash image candidate counts only when the file's mtime lies in the call's window (start − 2 s … end + 2 s) and no entry already covers that file version; `SendUserFile` images need no window. The panel shows a thumbnail only inside the workspace, through the confined `file-raw` route. An image outside it gets no thumbnail (each would mint an attachment id per render); a click opens the shared file preview, which registers it once through `POST /api/sessions/:id/attachments` with `notify: false` (its extension allowlist and `isSensitivePath` apply). SVGs get no thumbnail. No route of this feature serves a file, least of all a transcript path. + +⚠️ **Transcript paths are agent-printed, so they are probed BOUNDED and scanned in linear time** (#516; #693 review). `probeLocalPaths()` sends each candidate through `probePathKind()` (no `pastCap`: a bulk scan), one at a time, and only a path that answered `'file'` gets `realpath` + `stat`; a path on a mount that stopped answering costs one stalled stat, never a sync, a queued sync or `remove()` that cannot finish (`test/edit-history-probe-stall.test.ts`). `PATH_TOKEN_RE` runs synchronously in the request, over Bash output of up to 30,000 characters: an unquoted token may start only at a token boundary (line start or a token-ending character, `:` excluded so URLs stay ignored), and the `file://` branch stops at the next `file://` (`:` stays allowed for timestamped names). A lookbehind that merely excludes some characters lets a match start at every character of a long token and rescan it, quadratic in the line (2.7 s for 30,000 `#`). Time-budget regression tests in `test/edit-history-parse.test.ts`. Forward reads and "Load older" yield to the event loop between the files they parse (up to 8 MiB each); a store removed meanwhile ends the read before its images are probed. Remote, Docker and non-claude sessions never load a store on any route. + +Paths, agent descriptions and diff lines render via `textContent` only (browser test with a hostile path). The header button uses the marker-class pattern (`btn-edit-history--hidden`) and stays off phones (`mobile.css`, locked in `test/mobile-header-buttons-policy.test.ts`). Remote, Docker and non-claude sessions answer `unsupported` and are never read. + ### Config backups **Config backups** (`src/config-backup.ts`, pure over the directories it is handed; `routes/config-backup-routes.ts`; the Config backups group in App Settings → System, `settings-ui.js`; contributed in #661, user guide `docs/wiki/Config-Backups.md`, endpoints in `docs/api-reference.md`): timestamped snapshots of the files that hold what a user customized, so a bad write can be undone. Settings (SYNCED): `configBackupEnabled` (absent = ON, since it protects against an event nobody schedules), `configBackupDir` (absolute, `~` expanded; empty, relative or the data dir itself = `<data dir>/backups/config`), `configBackupKeepCount` (2 to 500, default 20), `configBackupKeepDays` (0 to 3650, default 30, `0` = no age limit). A snapshot is a `cfg-YYYYMMDD-HHMMSS` folder (server local time, `-2`, `-3`… on a same-second collision; mode 0700) holding the tracked files under their own names plus a `manifest.json` (all 0600): `id`, `createdAt`, `reason` (`startup`/`auto`/`manual`/`pre-restore`), `appVersion`, the change-check `hash`, the `origin`, the `keepCount`/`keepDays` in force, and a SHA-256 and size per file. It is built in a `.tmp-cfg-*` folder and renamed into place; a pass sweeps temp folders older than an hour. Passes run at startup and every 5 minutes (`CONFIG_BACKUP_INTERVAL_MS` in `server.ts`, not in test mode), and on **Back up now** (forced). @@ -750,7 +780,7 @@ Matching semantics: a query containing `/` matches the relative path, otherwise ⚠️ **The origin is a random id in `<data dir>/config-backup-id`, never the hostname.** Instances that share a folder (two machines on one synced folder, prod and a beta) must keep to their own snapshots, so the change check, list, prune, Compare and restore are all scoped to it; without that, each instance snapshotted on every tick and Restore offered the other machine's `users.json` and `.env`. The hostname was tried first and was wrong: a Docker Compose container gets a new one on every recreate, which unlisted every earlier snapshot, and since prune is origin-scoped they were never deleted either. The id is not the hook secret and is never tracked or restored. Listing never creates it (`readConfigBackupOrigin()`; only a pass or a restore does). A fresh or reinstalled data dir is a new owner; copying an `origin` from an old manifest into the file makes those snapshots visible again. -⚠️ **Retention survives a bad `settings.json`.** Each manifest records the retention in force. `runConfigBackup()` reads `settings.json` itself, because `readJsonConfig` answers `{}` for any failure, including a read that lands inside the non-atomic write of `PUT /api/settings`: a file that does not parse still gets its snapshot but prunes nothing, and a retention key ABSENT from the file (a reset) means what the newest manifest that recorded one holds (`withRecordedRetention()`, per key, in the folder the file names, so re-entering a custom folder after a reset finds that folder's own history). `GET /api/config-backups` reports the same values the pass uses, the form fills from them, and Save sends a retention key only when it was saved before or edited there (`_configBackupRetentionPayload()`), so merely displaying the recorded retention never writes it back. Restoring `settings.json` keeps the four `configBackup*` keys at their live values (an old `configBackupDir` would point the list at another folder and hide the undo copy just made), except a retention key the live file lacks, which keeps the backup's. While `settings.json` does not parse a pass reads it as `{}`, so its snapshot goes to the default folder; nothing is widened. +⚠️ **Retention survives a bad `settings.json`.** Each manifest records the retention in force. `runConfigBackup()` reads `settings.json` itself, because `readJsonConfig` answers `{}` for any failure, including a file that does not parse: a file that does not parse still gets its snapshot but prunes nothing, and a retention key ABSENT from the file (a reset) means what the newest manifest that recorded one holds (`withRecordedRetention()`, per key, in the folder the file names, so re-entering a custom folder after a reset finds that folder's own history). `GET /api/config-backups` reports the same values the pass uses, the form fills from them, and Save sends a retention key only when it was saved before or edited there (`_configBackupRetentionPayload()`), so merely displaying the recorded retention never writes it back. Restoring `settings.json` keeps the four `configBackup*` keys at their live values (an old `configBackupDir` would point the list at another folder and hide the undo copy just made), except a retention key the live file lacks, which keeps the backup's. While `settings.json` does not parse a pass reads it as `{}`, so its snapshot goes to the default folder; nothing is widened. ⚠️ **A custom folder is probed BOUNDED before anything reads it** (#516: it may be a network mount that stopped answering, and the GET runs every time App Settings opens). `probeBackupDir()` runs `probePathKind(dir, { pastCap: true })` before every pass and in every route, and `readEffectiveBackupSettings()` probes BEFORE the recorded-retention fallback lists the folder (that fallback once did a `readdirSync` first whenever only `configBackupDir` was set). `unknown` answers 503 `OPERATION_FAILED` through `describeUnknownPath()` and skips the pass; a missing custom folder is created only when its parent is present. The default folder needs no probe. `test/routes/config-backup-unreachable.test.ts` pins all four routes with a recording `node:fs` mock. @@ -813,7 +843,7 @@ Feedback is silent on success except ONCE per page load (a feature that works by ### Terminal paste (Ctrl+V) -**The Ctrl+V paste trap consumes exactly ONE paste event.** `_handleImagePaste()` (image-input.js) appends a hidden `contenteditable` div, focuses it, and reads the clipboard out of the paste event that lands there. An image becomes an upload whose saved path is typed into the session; text goes through `terminal.paste()`, so the bracketed-paste markers survive. Two independent routes deliver that event for one keypress, and a browser may fire both: +**The Ctrl+V paste trap consumes exactly ONE paste event.** `_handleImagePaste()` (image-input.js) appends a hidden `contenteditable` div, focuses it, and reads the clipboard out of the paste event that lands there. An image becomes an upload whose saved path is typed into the session; plain text is a Codeman-built bracketed paste (`\x1b[200~` … `\x1b[201~`, nested markers stripped, newlines as `\r`) handed to that pane's terminal via `terminal.input(framed)`. `terminal.paste()` only emits those markers while xterm's DECSET 2004 mirror is on, and a terminal replay clears that mirror (#716), so Ctrl+V builds the frame itself. `input()` fires onData as user input, which scrolls to the bottom and clears the selection. The main pane's onData bracketed-paste branch and a tile's `_onTerminalData` deliver the frame, which is where the local-echo flush and the delayed write live (#219). A frame wrapped in CSI 200~/201~ does not match the query-reply or mouse-report filters. The terminal is refocused; the paste is not written into the xterm buffer. Two independent routes deliver that event for one keypress, and a browser may fire both: 1. **`document.execCommand('paste')`**, which the function calls itself. ⚠️ Its return value proves nothing about whether it fired. Firefox dispatches a trusted `paste` event carrying the real clipboard and still returns `false`, because the trap cancels the event and the command therefore never completes. Chromium refuses the command outright and dispatches nothing. 2. **The keydown's own default action.** The `Ctrl+V` branch in `attachCustomKeyEventHandler` returns `false`, which stops xterm from evaluating the key into `^V` but does not cancel the DOM event, for the same reason rule 1 of smart copy above spells out. `trap.focus()` has already run by then, so the browser sends its own paste to the trap as well. @@ -1025,6 +1055,8 @@ Tests: `test/terminal-touch-tap.test.ts`, `test/session-swipe-gesture.test.ts` ( ⚠️ **The floor is set by the 10th tab onward, not by the tabs you can see**: `.tab-number` renders only for `_tabIdx < 9`, so tab 10 loses 16px + a gap off its left and its centre sits 10px further right. The centre clears the icons when `reserved > icons + rightEdge - leftRunUp - gap` (= 50 + 9 - 17 - 4 = **38px**), hit-testing snaps to whole pixels so 39px still lands on the gear, and the practical floor is 40px — a NUMBERED tab clears it at 20px, which is exactly why reasoning from the tabs on screen would put the centre back on the gear. `test/mobile-tab-tap-zones.test.ts` recomputes that inequality from the stylesheet, so widening the gear or the padding fails there rather than on a phone. The guarantee is centre-off-the-ICONS, not centre-inside-the-label (on a numberless tab it lands in the gap between them, which still switches). Non-active tabs keep their icons hidden and stay tappable end to end. +⚠️ **The opt-in tab "+" is a third icon in that cluster**: with **New Session Button on Tabs** on (`showTabNewButton`, per device, off by default; `html.tabs-show-new`) the active phone tab shows a 28px "+" between the gear and close, and `html.tabs-show-new .session-tab.active .tab-name` grows the reserve to 68px (gear 32 + "+" 28 + close 20 less close's 2px margin needs a 64px label, 66 with the test's rounding allowance), still under the 80px `max-width`. Growing the reserve while the setting is on is fine; shrinking the base one never is. The same test recomputes it with the "+" counted. + ⚠️ Mobile no longer hoists the active session to the front of the strip: that reordering ran on full renders only, so tab order flipped depending on which render path fired, and it renumbered the Alt+N badges. Scroll-into-view replaces it; do not reintroduce it. ### Tab layouts (by state, by case, ledger) @@ -1041,6 +1073,8 @@ Tests: `test/terminal-touch-tap.test.ts`, `test/session-swipe-gesture.test.ts` ( **Ledger.** CSS only, on `.tabs-ledger` inside `@media (min-width: 768px)` and the header host: the flat list on an auto-fill column grid with equal cells, mono type and a 3px status bar (`--ledger-bar`: yellow for a waiting alert, red for an action alert or an error, muted for stopped, exited and web tabs) instead of the dot. ⚠️ It must not change the markup (pinned: the ledger's innerHTML equals classic's); the rail, the sidebar and narrower strips show the plain list. +**Classic, wrapped: the L-shaped strip.** Once a classic desktop strip in the header host wraps (`tabs-auto-wrap`, or the manual `tabs-two-rows`), `_syncTabStripCorner()` (app.js) adds `.tabs-corner`: `#headerRight` goes `position: absolute` over the strip's top-right corner and the strip runs the header's full width, so only the row(s) beside the toolbar stop short of it and every later row uses the width under it. A flex line cannot be shortened on its own, so in that state the strip is `display: block` with `inline-flex` tabs flowing around a `::before` float sized from the toolbar's box (`--tab-corner-width` / `--tab-corner-height`); the old `row-gap` (lineage's `--lineage-row-gap` included) becomes the tabs' bottom margin and the bottom padding shrinks by the same amount, so row spacing and lineage routing room are unchanged. ⚠️ `updateTabOverflowMode()` removes `.tabs-corner` BEFORE it measures the wrap (the corner takes the toolbar out of the header's flow, which widens the strip), so the wrap is still judged against the one-row layout and an unwrapped strip looks exactly as before. ⚠️ Flex-only rules do not apply to the strip in that state. ⚠️ A strip that already scrolls (past `--tab-strip-max-height`) keeps the plain wrap, checked on the plain strip before the class goes on: the float scrolls with the tabs while the toolbar stays put, and toggling the class on a scrolled strip lets scroll anchoring move it; a strip pushed past the max-height by the float itself falls back too. A first tab too wide for the room beside the toolbar (the whole first line would drop below it) is trimmed to the room instead (`tab-corner-fit`, `max-width: var(--tab-corner-first-max)`, its name ellipsized): a selected tab is wider (gear and close show), so a fallback here made the whole layout follow the selection. Only a room under `TAB_CORNER_MIN_FIRST_TAB` keeps the plain wrap. ⚠️ The cap goes on the first tab on every pass, never only once it measures too wide: `selectSession()` widens a newly selected tab at once and this sync is debounced, so a cap set afterwards leaves the first row below the toolbar until it runs. It changes nothing on a tab narrower than the room. A `ResizeObserver` on the toolbar re-decides wrap and corner and redraws the connection lines when a reading or the header divider resizes it without a tab render. Scope: classic arrangement, desktop, header host only; state/case/ledger, the sidebar and the vertical rail never get the class (`applySessionListLayout()` drops it when the strip moves). Tests: `test/tab-strip-corner.browser.test.ts` (browser suite). + With `classic` nothing is left behind: no headings, no breaks, no inline order, no boxes, no name split, no arrangement class. A stale cached mobile-overview.js degrades every grouping to the flat strip. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. ### Header stats styles @@ -1151,7 +1185,9 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u ### PTY and browser terminal geometry -**The PTY and the browser terminal must never disagree about size** (issue #464, `syncTerminalGeometry()` in terminal-ui.js): Claude Code's TUI wraps its frame at the width the PTY reported and erases the previous frame by walking the cursor up the number of rows it BELIEVES that frame occupied. A browser terminal of a different width makes each logical line take more physical rows than Ink counted, so `eraseLines(n)` clears too few and the new frame paints over rows nothing erased — the doubled lines and half-overwritten prose in #464, **measured** against this repo's xterm (a 120-column PTY against a 62-column terminal renders every wrapped line twice; `test/terminal-pty-geometry.test.ts` pins it, and pins the clean render at matching widths so the assertion cannot pass against code that fixes nothing). ⚠️ **`fitAddon.fit()` is NOT the way to resize this terminal.** It resizes xterm to `proposeDimensions()` RAW while every server-facing path reports those floored at 40x10, so whenever the floor bit the two diverged silently — **measured in Chrome at 430px**: font size 44 proposed 13 columns, the server was told 40, and xterm stayed at 13. `syncTerminalGeometry()` fits, floors and applies as one step and is the ONE function that may change the size; `test/terminal-pty-geometry.test.ts` sweeps the seven modules that touch the main terminal (terminal-ui, mobile-handlers, app, ralph-panel, settings-ui, tab-rail-resize, notification-manager) for a bare fit; the split pane and teammate terminals own their own sizing and are out of scope. ⚠️ **Withhold the fit wherever you withhold the SIGWINCH.** `throttledResize` (virtual keyboard up) and `sendResize` (session detached into its own window) used to reflow locally and skip only the server write, which is the one combination that cannot be right. ⚠️ **A font change is a geometry change**: `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size and told the server nothing, so raising the font on a phone left the CLI wrapping at the old column count (`_refitAfterCellSizeChange`). ⚠️ **Resize is no longer write-only.** `Session.resize` DECLINES a small-viewport request while a desktop connection holds an active sizing claim and says nothing, so both transports now answer with `Session.ptyGeometry` (`{"t":"zc"}` on the socket, the body of the resize POST) and `_onPtyGeometryReport` adopts it — a terminal that keeps a WIDTH the PTY refused renders GARBLED, not merely wrong-sized. ⚠️ While that refusal stands (`_paneWidthRefused`), a resize ASKS for the container's width without applying it (`_geometryForResizeRequest`: rows follow the container, columns stay at the PTY's): fitting first re-wrapped the whole buffer to the container and back on every 30s mobile retry, and ran the scrollback clear for a resize that brings no redraw. `selectSession` clears the flag, since it belongs to the previous pane. ⚠️ **`ptyGeometry` is null without a live pane**, never the field values: they are seeded at spawn (`_notePtySpawnGeometry`, so a reattached pane reports its tmux window's real size) and moved by `resize()`, but a dead-pane session still holds the constructor defaults of 120x40 or a gone pane's size — reporting those made a client adopt a size no process was ever told and claim another device owned the pane when none existed. ⚠️ **COLUMNS ONLY.** Adopting the PTY's ROWS was a regression: a phone taking a desktop's 43 rows into a viewport with room for 18 painted an `.xterm-screen` far taller than its container, xterm's own viewport then had nothing to scroll, and the CLI's input line sat below the container with no gesture able to reach it — output visible, typing invisible, for as long as the claim stayed hot. Width is the axis the wrap arithmetic depends on; rows only decide how much is on screen, and keeping the local count keeps the composer at the bottom of a viewport that scrolls. ⚠️ **`.term-overflows-x` keys on what does not FIT, not on a PTY mismatch**, because the 40-column floor widens the terminal past a narrow container with the PTY agreeing throughout (measured at 360px: font 18 paints 433px, font 24 paints 578px — 38% unreachable, and `increaseFontSize` reaches 24 in two taps). `_syncTerminalOverflowAffordance()` MEASURES `.xterm-screen` against the container on the next frame rather than deriving it from cell arithmetic. ⚠️ That rule sets **both** overflow axes — mobile.css loads later with `.terminal-container { overflow: visible }`, and a bare `overflow-x` would leave overflow-y computing to `auto`, handing the browser a vertical scroll container the terminal's touch handler does not know about — and sets **no `touch-action`**: the terminal's own touchmove handler pans it (`canPanHorizontally`), because `touchstart` preventDefault()s every 'content' tap and that cancels a native `pan-x` before it starts (measured: a 140px swipe reached scrollLeft 141 without that preventDefault and 0 with it). Removing the class returns `scrollLeft` to 0, so a resolved mismatch cannot leave the pane parked off-screen. +**The PTY and the browser terminal must never disagree about size** (issue #464, `syncTerminalGeometry()` in terminal-ui.js): Claude Code's TUI wraps its frame at the width the PTY reported and erases the previous frame by walking the cursor up the number of rows it BELIEVES that frame occupied. A browser terminal of a different width makes each logical line take more physical rows than Ink counted, so `eraseLines(n)` clears too few and the new frame paints over rows nothing erased — the doubled lines and half-overwritten prose in #464, **measured** against this repo's xterm (a 120-column PTY against a 62-column terminal renders every wrapped line twice; `test/terminal-pty-geometry.test.ts` pins it, and pins the clean render at matching widths so the assertion cannot pass against code that fixes nothing). ⚠️ **`fitAddon.fit()` is NOT the way to resize this terminal.** It resizes xterm to `proposeDimensions()` RAW while every server-facing path reports those floored at 40x10, so whenever the floor bit the two diverged silently — **measured in Chrome at 430px**: font size 44 proposed 13 columns, the server was told 40, and xterm stayed at 13. `syncTerminalGeometry()` fits, floors and applies as one step and is the ONE function that may change the size; `test/terminal-pty-geometry.test.ts` sweeps the seven modules that touch the main terminal (terminal-ui, mobile-handlers, app, ralph-panel, settings-ui, tab-rail-resize, notification-manager) for a bare fit; the split pane and teammate terminals own their own sizing and are out of scope. ⚠️ **Withhold the fit wherever you withhold the SIGWINCH.** `throttledResize` (virtual keyboard up) and `sendResize` (session detached into its own window) used to reflow locally and skip only the server write, which is the one combination that cannot be right. ⚠️ **A font change is a geometry change**: `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size and told the server nothing, so raising the font on a phone left the CLI wrapping at the old column count (`_refitAfterCellSizeChange`). ⚠️ **Resize is no longer write-only.** `Session.resize` DECLINES a small-viewport request while a desktop connection holds an active sizing claim and says nothing, so both transports now answer with `Session.ptyGeometry` (`{"t":"zc"}` on the socket, the body of the resize POST) and `_onPtyGeometryReport` adopts it — a terminal that keeps a WIDTH the PTY refused renders GARBLED, not merely wrong-sized. ⚠️ While that refusal stands (`_paneWidthRefused`), a resize ASKS for the container's width without applying it (`_geometryForResizeRequest`: rows follow the container, columns stay at the PTY's): fitting first re-wrapped the whole buffer to the container and back on every 30s mobile retry, and ran the scrollback clear for a resize that brings no redraw. `selectSession` clears the flag, since it belongs to the previous pane. ⚠️ **`ptyGeometry` is null without a live pane**, never the field values: they are seeded at spawn (`_notePtySpawnGeometry`, so a reattached pane reports its tmux window's real size) and moved by `resize()`, but a dead-pane session still holds the constructor defaults of 120x40 or a gone pane's size — reporting those made a client adopt a size no process was ever told and claim another device owned the pane when none existed. ⚠️ **COLUMNS ONLY** (one documented exception, the keyboard row hold in the next paragraph). Adopting the PTY's ROWS was a regression: a phone taking a desktop's 43 rows into a viewport with room for 18 painted an `.xterm-screen` far taller than its container, xterm's own viewport then had nothing to scroll, and the CLI's input line sat below the container with no gesture able to reach it — output visible, typing invisible, for as long as the claim stayed hot. Width is the axis the wrap arithmetic depends on; rows only decide how much is on screen, and keeping the local count keeps the composer at the bottom of a viewport that scrolls. ⚠️ **`.term-overflows-x` keys on what does not FIT, not on a PTY mismatch**, because the 40-column floor widens the terminal past a narrow container with the PTY agreeing throughout (measured at 360px: font 18 paints 433px, font 24 paints 578px — 38% unreachable, and `increaseFontSize` reaches 24 in two taps). `_syncTerminalOverflowAffordance()` MEASURES `.xterm-screen` against the container on the next frame rather than deriving it from cell arithmetic. ⚠️ That rule sets **both** overflow axes — mobile.css loads later with `.terminal-container { overflow: visible }`, and a bare `overflow-x` would leave overflow-y computing to `auto`, handing the browser a vertical scroll container the terminal's touch handler does not know about — and sets **no `touch-action`**: the terminal's own touchmove handler pans it (`canPanHorizontally`), because `touchstart` preventDefault()s every 'content' tap and that cancels a native `pan-x` before it starts (measured: a 140px swipe reached scrollLeft 141 without that preventDefault and 0 with it). Removing the class returns `scrollLeft` to 0, so a resolved mismatch cannot leave the pane parked off-screen. + +**The one exception to COLUMNS ONLY: rows held under the on-screen keyboard** (#672, `keyboardHoldsRows`, per-device, default ON): every PTY height change makes Claude Code's inline renderer clear the screen and reprint only the transcript tail that fits the new height, so the rows above that tail are gone from tmux and older ones stay in history as stale copies, and the phone keyboard opening and closing was such a change each time. **Measured** on Claude Code 2.1.306 with tmux 3.2a, a fresh session already at the phone's size printing 100 numbered lines: five keyboard cycles (PTY 62x38 to 62x15 and back, one SIGWINCH per transition) left 75 of the 100 in `tmux capture-pane` history; with the rows held the PTY stayed 62x38, no SIGWINCH reached the pane and all 100 remained. So `KeyboardHandler` captures `terminal.rows` on the show transition (`app._keyboardHeldRows`) and `getTerminalDimensions()` never reports fewer rows while the keyboard is visible; columns still follow the container, so the #464 width rule is untouched. ⚠️ **Why this is not the regression the COLUMNS ONLY note describes.** There the extra rows came from ANOTHER device's sizing claim, adopted from `ptyGeometry`, and nothing kept the composer in view: an `.xterm-screen` taller than its container with the input line below it and no gesture able to reach it. Here the rows are this device's own keyboard-free rows, held on purpose only while the keyboard is up, and the screen is translated up by `--kb-clip` (`_applyKeyboardClip()`, pure math in `keyboardClipPx()` in constants.js) so the last content row sits at the bottom of the visible box: following live output, the cursor counts only within `KEYBOARD_CLIP_CURSOR_REACH` (2) rows below the last content (a TUI that parks its cursor far below what it drew must not scroll its content away); reading history, the offset follows the scroll position so the top of the scrollback stays reachable. The local-echo overlay is bounded to the visible rows through `setRowLimit()`, which narrows the overlay renderer's existing `totalRows` bound rather than adding a second one, so a long draft grows upward from the last row the user can see. `reconcilePtyGeometry` still adopts columns only: nothing here takes rows from the PTY's report. `test/keyboard-held-rows.test.ts` pins the composer row inside the visible box with the rows held, against a real headless xterm and the shipped `_applyKeyboardClip()`. ⚠️ Holding goes through `getTerminalDimensions()`, never one caller, so the fit, the PTY resize and the capture-geometry comparisons agree; `syncTerminalGeometry()` skips `fitAddon.fit()` while held, so xterm never visits the short size. ⚠️ A shape change (rotation, fold) releases the hold AND arms the viewport settle (`_scheduleViewportSettle()`), which refits and tells the PTY: with the keyboard still up `throttledResize` stands aside and a rotation alone arms nothing, so without it xterm kept the held rows with no clip and the composer sat under the keyboard until it closed. A rotation with no hold arms nothing, as before. ⚠️ ONLY inline renderers qualify (`_keyboardHoldEligible()`): the registry's `capabilities.keyboardHold: 'inline'` (Claude alone; read through `window.__codemanKeyboardHoldModes`, never an id literal), never while `cliMouseTracking` reports Claude's fullscreen mode (it lays out the full height and has not been measured losing lines, so it resizes as before), never remote or docker (their local pane hosts a nested client). A full-height layout (codex: banner at the top, composer pinned to the bottom) cannot fit both ends above the keyboard, which is what the first, unscoped version got wrong. ⚠️ The hold lives on the shared terminal, so `_reconcileKeyboardHold()` releases it whenever the active session stops qualifying (tab switch, a `session:updated`, a reconnect, the setting turned off), and keyboard close releases it too; it never re-holds with the keyboard already up, so a session that becomes eligible mid-keyboard keeps the short rows until the keyboard closes. ### Terminal resilience: replay clears, renderer liveness, fetch deadlines @@ -1182,7 +1218,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u | **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` | | **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit | | **Rate limit** | 10 failed logins/IP → 429 (15min decay). Multi-user mode instead has two gates checked before scrypt, so past either even a correct password from that IP gets 429: 50/IP (`AUTH_IP_FAILURE_MAX`) and 10 per (username, IP) (`AUTH_FAILURE_MAX`; the same account still logs in from another IP). A 10-per-username bucket (multi-user only) is checked after the verify: 429 to failed guesses from the 10th on, from any IP, never to a correct password. QR has separate limiter | -| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) skip Basic auth (localhost-only, schema-validated). When auth is active (`CODEMAN_PASSWORD` set), the loopback bypass requires the per-instance `X-Codeman-Hook-Secret` header **unconditionally** — COD-54 introduced it tunnel-gated; COD-91 (PR #127) made it always-on because Codeman can't detect a user's own loopback reverse proxy (own cloudflared/`tailscale serve`/nginx → 127.0.0.1), closing that residual plain-bypass gap. Hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env, `config/hook-secret.ts`); a missing/wrong secret gets 401 and rate-limits in a dedicated bucket (never locks out login). Tunnel enable **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (env, COD-55) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9): the welcome/settings tunnel toggle pops a security confirm dialog and, on confirm, resends with that flag (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted | +| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) skip Basic auth (localhost-only, schema-validated). When auth is active (`CODEMAN_PASSWORD` set), the loopback bypass requires the per-instance `X-Codeman-Hook-Secret` header **unconditionally** — COD-54 introduced it tunnel-gated; COD-91 (PR #127) made it always-on because Codeman can't detect a user's own loopback reverse proxy (own cloudflared/`tailscale serve`/nginx → 127.0.0.1), closing that residual plain-bypass gap. Hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env, `config/hook-secret.ts`); a missing/wrong secret gets 401 and rate-limits in a dedicated bucket (never locks out login). Tunnel enable **refuses** without a login (`CODEMAN_PASSWORD` or multi-user mode) unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` / `--allow-unauthenticated-tunnel` (#706; until then the guard took `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1`, COD-55, which `install.sh` writes for every passwordless LAN install, so the bind acknowledgement silently published the tunnel too) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9). With no login the browser asks BEFORE every enable (welcome link, tunnel panel, an App Settings save that turns it on) through `_confirmTunnelEnable()`, reading `loginConfigured` from `GET /api/tunnel/status`, and on confirm sends that flag; the 403 handler stays the fallback when the status read fails (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted | | **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS`=1 (opt-in hooks-only listener on the docker bridge gateway so in-container hooks reach a loopback-bound server; bind IP from `CODEMAN_DOCKER_BRIDGE_HOST` or auto-detect) | | **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`) | | **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS | @@ -1260,3 +1296,110 @@ Its CLI knowledge is a GENERATED block (`npm run generate:cli-catalog`, markers ⚠️ It must stay **bash 3.2** clean — macOS ships it and the documented install is `curl | bash` under `set -euo pipefail`, so `declare -A`, `mapfile`, namerefs, `${x,,}` and here-strings are all fatal there; CI runs `bash -n` plus a real `bash:3.2` container, since expanding an EMPTY array under `set -u` is a runtime abort `bash -n` cannot see. ⚠️ It executes ONLY commands from the embedded block (`CLI_INSTALL_CMD_TRUSTED`) — there is no network fetch of the catalogue at install time to worry about at all. + +## Agent inspection and guarded submission + +These three operations are experimental across both CLI and HTTP, including the +`/api/v1` paths and unversioned aliases: a case of the "labeled experimental" rule +in `docs/versioning-policy.md`, so do not infer stability from the URL prefix. + +⚠️ They are opt-in. All three routes answer `403 FORBIDDEN` with a message naming +`agentCoordinationEnabled` (synced, default OFF, App Settings, Cross-session +features) until it is on, the `cliManagementEnabled` / `mcpSyncEnabled` shape. The +gate runs first, before session lookup or body validation, and reads +`settings.json` fresh on every request, so a toggle applies without a restart. +`codeman agent inspect|stage|submit` pass the 403 through verbatim. + +`agent-session-routes.ts` is registered by `WebServer` with the other route +modules. Ownership is checked before transcript/pane/receipt lookup. Inspection +uses strict native metadata attribution, bounded traversal, separate prefix and +candidate-metadata budgets, bounded path probes before canonical reads, and +complete UTF8 JSONL records. A full page advances its cursor without claiming a +blocked record. Opaque cursors bind caller/session/lifecycle/file identity; pane +geometry is excluded so changing devices does not invalidate transcript paging. +Cursors are capped per owner and session (`INSPECT_CURSORS_PER_SCOPE`, the newest 8, +under the global 1,024), so one polling agent retires its own oldest cursors instead +of evicting every other caller's from the shared FIFO. + +⚠️ Codex never prunes `~/.codex/sessions`, so attribution must not cost a store +walk per call. The first call walks (negative-filter prefixes read 1 KiB first, +4 KiB only when that window holds no complete `originator` field; non-UUID file +names are skipped unread), then the attributed `{path, dev:ino}` and its strict +metadata are remembered per session (`attributionMemo`, bounded LRU). A later call +re-verifies only that file's head, then scans just the date directories not older +than its own (`sessions/YYYY/MM/DD`, compared numerically; any non-numeric name is +never pruned) for a second candidate, which is how a codex `/new` or a new owned +rollout beside a configured resume is still caught. A head that no longer verifies +drops the memo and falls back to one full walk; a verified head whose incremental +scan fails (a second candidate, an exhausted budget, an unreadable entry) drops it +and answers unattributed without one, because the full walk reads a superset of that +scan under the same budget and could only fail the same way. Non-`InspectionConflict` errors are `422 +OPERATION_FAILED` (never a retryable `409`) and are logged with the session id and +error code only, never the path. + +Staging requires a fresh, known composer/caret capture. The composer glyph and +footer are registry data (`capabilities.workDetect.promptGlyph` and +`composerFooterLine`, compiled through `compileVersionRegex()`), and only a CLI +declaring `composerFooterLine` takes guarded input (codex today). Codex's footer +words come from the same `CODEX_EFFORT_WORDS` as its `modelDetect.screenLine`. Before any write, available +columns are calculated from actual captured geometry and the rendered prefix, +including indentation; reaching the last column or wrapping is refused. A message +starting with a `composerCommandPrefixes` character or whose last word starts with a +`composerCompletionSigils` one is refused before typing (codex `/!` and `@$`), and a +frame with a `composerPopupLine` row in the block just above the composer is refused at +stage and submit: codex draws its popups there and keeps its footer, so the footer +alone read `› /stat` with the command list open as idle, and Enter ran `/status`. +⚠️ After typing, stage polls (`STAGE_SETTLE_TIMEOUT_MS` 1000 / `STAGE_SETTLE_INTERVAL_MS` +50) for the first frame with the same generation, `revision === before + 1` and +`composerSha === promptSha256(message)`, since codex repaints 30 to 60 ms after the keys +and the first capture is often from before them; another writer or a lifecycle change +ends the wait at once, and every refusal after typing says the message may be in the +composer. `capturePaneBuffer()` reads the visible frame and its caret in ONE tmux call +(`capture-pane \; display-message`, split by `splitFrameAndCursor()`), falling back to +the two calls only for output it cannot split; a combined call that THREW goes to the +capture's catch and is never rerun (a hung tmux would block the event loop for a second +timeout on every `GET .../terminal`). Keep the poll anyway. ⚠️ Input that arrives +while a guarded submit holds the lease is HELD, never refused (`holdForGuardedLease()`, +`_inGuardedTurn()`), and written in arrival order when the lease is released: the +browser's WebSocket path does not wait for ACKs, so a refused seq N was overtaken by +N+1 and its redelivery dropped as a duplicate. A WS frame is ACKed only once written; +a held async write (mux write, named key) holds everything behind it until it settles +(`guardedInputHeld`); a full hold queue (256 inputs / 1 MiB) closes the socket with +`1013` and applies nothing later from it, so the browser resends in order. `POST +.../input` answers `409` (seq returned) to TAGGED input for the lease plus the drain, +since its browser drainer is serialized and retries; untagged input there (the phone +accessory keys and the Stop button's Ctrl+C, fire-and-forget POSTs that never read the +answer) is held instead, the raw write through `holdForGuardedLease()` and a mux write +by `writeViaMux`'s own turn (never wrap `writeViaMux` in a hold: the drain would queue +it again behind later input and wait on itself). `write()` refuses during the lease +only, since the drain writes held input through it, so a direct `write()` caller such +as cron's `paste` mode (`cron-service.ts`) can land ahead of input still draining. +⚠️ Submit waits `SUBMIT_QUIET_MS` (250) after the staged keys before its +checks: stage types plain keys, codex's paste-burst detector reads them as a paste, and +an Enter within 120 ms of one becomes a newline while submit would report it +delivered. Stage cancels older verifier retries and sends no Enter. Receipts contain hashes and +bounded ephemeral identities only, expire, and are consumed before awaited +submission, including failures. + +Submission additionally binds input and geometry revisions and rechecks the +exact composer/caret at the conditional Enter boundary. The route must retain +the Session snapshot's staging generation rather than replacing it with the +inspection generation. Managed input and auxiliary named keys participate in the +same pending/lease boundary: they wait for the lease, and only a full hold queue +refuses them (`GuardedInputBusyError`, which `send-key` answers as `409 CONFLICT`). Complete focus/mouse reports and empty writes are +not edits. Never retry ambiguous Enter delivery or clear an unverified composer. +The ordinary SubmitVerifier uses the shared write boundary, so each re-press +updates `lastSubmitAt` just like any other Enter. + +## Discord bridge + +`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`. Opt-in, two-way, one thread per tab. User docs: [discord-bridge](discord-bridge.md). + +- **Who can type into a tab.** Only `allowedUserIds`, in `guildId`, inside a thread the bridge created. Everything else is dropped unread. A reply is one printable line plus Enter (a lone digit while a permission or question is pending goes as the key alone, since the dialog acts on it), capped and rate limited, and typed with `fromUser: true`. The bridge's own startup hint is typed with `fromUser: false`. +- **A Discord failure never reaches a session.** `notify()` never throws; a failed thread creation is returned by `threadFor` as a `DiscordResult`, not read back from shared state. +- **`null` means the bridge is off.** A deduped automatic event answers `{ ok: true, deduped: true }`; a deliberate `codeman notify` is never deduped. A 200 from `discord-notify` carries the delivery result in `data.ok`, and `codeman notify` exits 1 when it is false. +- **Threads belong to a parent channel.** `discord-bridge.json` is `{ parentChannelId, threads }`; `reload()` drops the map when the channel changes, and a creation that finishes after the change is discarded. +- **Deletes come from the cleanup path** (`killMux` true), never from `session:deleted`, which a detach also fires. +- **Files.** Received files come only from Discord's HTTPS hosts into `<tab folder>/.codeman-discord/` (bounded probe, refused if a symlink, self-ignoring). A sent file's real path must lie inside the tab's folder; both the folder and the file are probed with the bounded probe first. +- **The whole last message.** A finished turn or an error posts the CLI's whole last message, not the screen: `WebServer.lastResponseFor` is the response viewer's reader (the body of `GET /api/sessions/:id/last-response`, registered on the route context by `registerSessionRoutes`), so the source is the same per-CLI transcript, or the pane split into turns where a CLI keeps none; a dialog and a shell keep the screen tail (its composer row and the status lines under it cut, never a `❯ 1.` option row). The read is bounded to 4 s and any failure or empty read falls back to the screen. It is split into at most 8 posts of 1,900 characters, a cut code fence closed and reopened, and Markdown tables become aligned code blocks (the separator test strips whitespace first: a regex over a run of spaces is quadratic, and the text is steerable by whatever the agent read). The repeat check is keyed on the text plus the turn it came from (the transcript stamp, else the last submit) and is recorded only after the first post succeeds, so a failed post is sent again and a later turn ending on the same words is not dropped. Only some CLIs raise a turn end (`/api/hook-event`: Claude's `stop` and `idle_prompt`, DeepSeek's `stop`), so the others reach this path through errors, a blocked respawn or a crash loop. +- **Cost guards.** The alert tail is a function called only once a post will happen (no tmux exec while off), and `discord-notify` is a per-session token bucket (6 a minute, `429 RATE_LIMITED`). diff --git a/docs/cli-registry.md b/docs/cli-registry.md index 72c28a2d..a2a029e8 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -47,9 +47,12 @@ interface CliEntry { env: CliEnv; // exports, tmux setenv keys, the env-override allowlist capabilities: CliCapabilities; // what every call site reads instead of the id // .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine?, - // blockedLine?, blockedLines? } + // blockedLine?, blockedLines?, composerFooterLine?, composerPopupLine?, + // composerCommandPrefixes?, composerCompletionSigils? } // (how this CLI's pane shows work, work it started in the background, a turn - // that ended waiting for workers it will resume from, and a dialog it is blocked on) + // that ended waiting for workers it will resume from, a dialog it is blocked on, + // the rows around its idle composer that guarded input relies on, and what a + // draft can trigger) // .modelDetect?: { screenLine, screenLines? } // (where this CLI's own chrome names the model it runs: SessionState.displayModel) // .launchDefaults?: { [launchParam]: settingsKey } @@ -63,7 +66,7 @@ interface CliEntry { ### Regexes that come from config -Six capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine` and `capabilities.modelDetect.screenLine`. All six go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing. +Eight capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.workDetect.composerPopupLine` and `capabilities.modelDetect.screenLine`. All eight go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing. `workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused. @@ -124,6 +127,33 @@ below, open PERMISSION items, which a watching label never acknowledges), so a f costs a wrong badge and cannot silence an alert. Before giving a CLI idle signals and a pattern, make sure its row is one the agent cannot write. +### The idle composer guarded input reads (`composerFooterLine`) + +The experimental guarded `stage`/`submit` routes (`src/agent-staging.ts`, opt-in through +`agentCoordinationEnabled`) type into a composer only after proving, from a fresh capture, +that the newest `promptGlyph` row is the CLI's own composer at rest, with the caret at the +exact end of its text. `composerFooterLine` is the second half of that proof: every +non-blank row below the composer (at most three) must match it, one row at a time. A CLI +that declares none never takes guarded input; only codex declares one, built from the same +effort-word list as its `modelDetect.screenLine` (`CODEX_EFFORT_WORDS` in `stock.ts`), so a +codex footer change is a one-place edit. Like `blockedLine`, the rows it sees are near the +foot of the screen, so a pattern must anchor on chrome only that CLI draws there. + +Three more fields cover what a draft can do to Enter. Codex draws every suggestion popup +(its slash-command list, file search, skill and mention lists) ABOVE the composer and +leaves the footer below it alone, so the footer check reads `› /stat` with the command +list open as an idle draft, and Enter would run the highlighted `/status`. +`composerPopupLine` matches a popup row; it is tested against the contiguous block of +non-blank rows directly above the composer (past at most three blank rows, at most twelve +rows), never further up, and any match refuses the frame. `composerCommandPrefixes` lists +the characters that, first in a draft, make Enter run a command (codex: `/!`), and +`composerCompletionSigils` the ones that, starting the word under the caret, open a popup +(codex: `@$`); stage refuses such a message before typing, the sigils only on the last +word since the caret ends there. Both are 1 to 8 ASCII punctuation characters. Codex's +values were read from `codex-rs/tui/src/bottom_pane` at `rust-v0.162.0`; a file-search +popup with results shows bare path rows no pattern can tell from prose, which is why the +trigger refusal is there as well as the frame check. + ### Dialogs a CLI blocks on (`blockedLine`) `blockedLine` is the screen-read source of the `blocked` state for a CLI with no hook to diff --git a/docs/custom-model-endpoints-plan.md b/docs/custom-model-endpoints-plan.md index 5078f17b..aec0b910 100644 --- a/docs/custom-model-endpoints-plan.md +++ b/docs/custom-model-endpoints-plan.md @@ -8,8 +8,10 @@ against a **cloud** OpenAI-compatible endpoint too (e.g. Azure AI Foundry's OpenAI-compatible inference endpoint, OpenRouter, a self-hosted gateway). Right now every Codeman session mode defaults to its native cloud backend with no way to redirect a session at any other endpoint from the UI — the -closest existing precedent is DeepSeek's server-env-sourced -`DEEPSEEK_BASE_URL`, which isn't user-facing. +closest existing precedent was DeepSeek's server-env-sourced +`DEEPSEEK_BASE_URL`, which wasn't user-facing (that env redirect has since +been superseded by deepseek's per-session patch recipe, see the table +below). **Scope note**: this plan originally said "local LLM." It now covers any OpenAI-compatible endpoint the user configures — local (llama.cpp, Ollama, @@ -112,7 +114,7 @@ declared capability, never an `if (mode === 'claude')` branch. | `gemini` | Env vars `GOOGLE_GEMINI_BASE_URL` + `GEMINI_API_KEY` + `GEMINI_MODEL`; CLI needs a restart to pick them up | **Confirmed BROKEN against llama.cpp/llama-swap, unresolved after real investigation.** Setting `GOOGLE_GEMINI_BASE_URL` makes gemini-cli internally select an `AuthType.GATEWAY` auth path (undocumented — inferred from behaviour) with validation requirements distinct from every normal auth mode; a real run against llama-swap fails with `Invalid auth method selected` regardless of what key/format is supplied. Tried and all failed: a Google-format dummy API key, `GOOGLE_GENAI_USE_VERTEXAI=false`, a `GEMINI_DEFAULT_AUTH_TYPE` override, and hand-writing `settings.json` directly. `--skip-trust` was a real, separate fix (without it a trust-folder check silently overrides `--approval-mode yolo` back to `default`) but does not touch this auth failure. Documented as an open gap, not shipped as working — the registry entry and injection code exist and are exercised by the test script, but end-to-end gemini support needs upstream investigation of `GATEWAY` AuthType before it can be called done | | `pi` | Config file `~/.pi/agent/models.json` with a custom provider whose `models` is an **array** of `{id}` objects (not an object keyed by id) plus `authHeader: true`. Redirected via the child process's own `HOME` env var, isolated per test/session — **not** `PI_CONFIG_DIR`, which does nothing for pi (grepped pi's entire bundled JS source: the string appears nowhere) | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back. Two real bugs found and fixed before this worked: (1) `PI_CONFIG_DIR` is not read by pi at all — pi hardcodes `~/.pi/agent/models.json` with no dedicated override, so the actual redirect has to be the child process's `HOME`; (2) `models` must be an array of `{id}` objects per pi's own bundled `docs/models.md`, not an object keyed by model id (silently loaded zero models). Also requires an explicit `--model custom/<id>` on invocation — without it pi falls back to its own default provider and fails with "No API key found for the selected model" | | `grok` | TOML `config.toml`: a fixed `[model.codeman-custom]` block (`base_url`, `env_key` naming an env var the key rides in, never a literal TOML field) written to an isolated dir via `GROK_HOME`. Invoked with `-m codeman-custom` | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back. The ORIGINAL recipe in this table (env vars `GROK_BASE_URL`/`XAI_API_KEY`/`GROK_MODEL`) was flat-out **wrong**, not just unverified: it produced "Not signed in" against a real binary. Grok's real mechanism, confirmed against xAI's own docs and a live binary, is a `config.toml` with a `[model.<name>]` block, redirected via `GROK_HOME`; the key still rides as an env var (`XAI_API_KEY` via `env_key`), just referenced from the TOML rather than read directly | -| `deepseek` | Reuse the **existing** `DEEPSEEK_BASE_URL` + `DEEPSEEK_API_KEY` keys (already declared in `stock.ts`), now with `appendV1Suffix: true` (see confidence). Only `DEEPSEEK_BASE_URL` is in `privilegedEnvKeys` — `DEEPSEEK_API_KEY` deliberately stays clamp-exempt, since a non-granted owner supplying their OWN key removes privilege rather than granting it (adding it to the clamp list was a real regression, caught by `test/deepseek-mode.test.ts` and fixed before merge). No model-selection var — dsh model is a profile composition entry, not a flag/env var | **Root cause of the original `HTTP_404` found and fixed, by reading dsh's own bundled source — the same bar pi/grok's fixes were held to.** Installed `@deepseek-ai/dsh` (all its real published dependencies) into a scratch directory purely to read `@deepseek-ai/dsh-llm-deepseek/lib/index.js`: it builds its request as `fetch(\`${connection.baseURL}/chat/completions\`, ...)`with`baseURL`read straight from`DEEPSEEK_BASE_URL`(or defaulting to DeepSeek's real public API root,`https://api.deepseek.com`, which also carries no `/v1`) — no `/v1` insertion of dsh's own, unlike the OpenAI-SDK convention this recipe originally assumed. llama-swap/llama.cpp only ever serves the OpenAI-conventional `/v1/chat/completions`. Confirmed live: `POST <baseUrl>/chat/completions` → `404`, `POST <baseUrl>/v1/chat/completions` → `200`, on the exact same endpoint — and dsh's own error-message template, `DeepSeek API error (HTTP ${status})`, reproduces the originally reported `dsh: HTTP_404: DeepSeek API error (HTTP 404)` precisely. Fixed by adding `appendV1Suffix` (env kind only, deepseek's entry alone — claude/gemini must NOT get it, since claude was already confirmed working against the unmodified `baseUrl`), which runs `endpoint.baseUrl` through the same `withV1Suffix()` helper `configDir`-kind CLIs already use. ⚠️ Not yet re-run end-to-end with a real `dsh` binary — no install available in this environment (no npm-installed CLI binary in `PATH`, and the `codeman-test-picker` container doesn't bundle it either); the fix is source-confirmed and live-verified at the HTTP level, but a genuine "hello world" reply through `dsh` itself is the remaining step before promoting this to **verified** alongside claude/opencode/pi/grok/omp | +| `deepseek` | Per-session **`DSH_HOME` configDir recipe** (supersedes the original `DEEPSEEK_BASE_URL` + `appendV1Suffix` env redirect): an isolated dir holding only a generated `cordis.patch.yml`, a `codeman-custom` `openai-completions` provider (the base URL gets the `/v1` suffix from the shared `renderConfigFile` step), plus home-level `dsh-tui`/`agent-default-model` rows that select the exact model; the key is referenced by name via `apiKeyEnv: CODEMAN_CUSTOM_MODEL_API_KEY`, a dedicated var, because dsh's own `llm-deepseek`/`web-search-deepseek` patch rows read `DEEPSEEK_API_KEY`, and an in-session `/model` switch back to the official route must not carry the custom key to api.deepseek.com. `linkFromRealHome` + `linkFromRealHomeDirs` symlink `profiles`/`sessions` (directories, created if the real home never ran a turn) and `settings.yaml` back out of the real dsh home. `DSH_HOME` and `CODEMAN_CUSTOM_MODEL_API_KEY` are in `privilegedEnvKeys`; the operator's own `DEEPSEEK_API_KEY` handling (clamp-exempt, server-key forwarding) is unchanged | **Verified end-to-end against a real dsh install by the maintainer** (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1): `--dump-config` shows the three patched rows, the TUI boots on the chosen model, and a prompt reaches the endpoint as `POST /v1/chat/completions` with the right model, the bearer key and `max_tokens: 8192`, the reply renders and the context window shows in the footer. The original `HTTP_404` root cause stays documented in docs/custom-model-endpoints.md: dsh concatenates `/chat/completions` onto the raw base URL, which is why the env recipe needed `/v1` | | `omp` | Config file `~/.omp/agent/models.yml` with the same array-shaped `models` + `authHeader: true` fix as pi. Redirected via `HOME`, same reasoning as pi (`PI_CONFIG_DIR` does not relocate omp's config either, despite an earlier CLAUDE.md note claiming it does) | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back, after applying the same two fixes as pi (array-shaped `models`, `HOME`-redirect instead of `PI_CONFIG_DIR`) plus an explicit `--model custom/<id>` on invocation. Unverified against omp's own official docs (none are bundled in the install), but empirically confirmed working live | | `antigravity` | No CLI/env/config mechanism found — Antigravity's docs describe only a GUI settings panel, and explicitly say a custom endpoint "cannot currently" become the core reasoning model. **Not implemented**; toolbar entry stays disabled for this mode with an explanatory tooltip | No known mechanism | diff --git a/docs/custom-model-endpoints.md b/docs/custom-model-endpoints.md index f76c21a1..c5f0f427 100644 --- a/docs/custom-model-endpoints.md +++ b/docs/custom-model-endpoints.md @@ -67,15 +67,23 @@ configured, `PUT`/`DELETE /api/model-endpoints/:id` update or remove one. Endpoint management is admin-only in multi-user mode, same as remote/docker hosts — these are machine-level infra, not per-user settings. -**Context length is discovered too, opportunistically and safely.** The plain -`GET /v1/models` response has no context-window field. Discovery only ever -looks for one for a model llama-swap's own response already reports +**Context length is discovered too, opportunistically and safely.** Older +servers state no context-window field in the plain `GET /v1/models` +response at all; current llama-swap states one per model (both +`context_length` and `context_window`) and gateways like Bifrost pass +`context_window` through: that listed figure is the *lowest*-precedence +source, used only when neither probe below answers, and because llama-swap +documents it as the PER-SLOT window, a model whose figure comes from the +listing is recorded with a slot count of 1 so the slot check never divides a +per-slot figure a second time. The probes are only ever looked for on a model +llama-swap's own response already reports `status.value === "loaded"` for — never for an unloaded one, because llama-swap treats `?model=` as a routing hint and asking about a model that isn't loaded risks triggering an actual (slow, GPU-swapping) load as a side effect of what should be read-only discovery. A server with no `status` field -on any entry at all (not llama-swap) gets no context-length enrichment, -rather than guessing. A model's previously-learned context length survives a +on any entry at all (not llama-swap) gets no launch-command/`/props` +enrichment, only whatever its listings state, rather than guessing. A +model's previously-learned context length survives a later cycle where it wasn't the loaded one; it's dropped only once the model disappears from the endpoint's list entirely. Stored per model in `modelContextLengths` and applied automatically (see "Applying a model to a @@ -192,32 +200,66 @@ saved default, not just confirming it. The modal promotes exactly one row to the top of the list rather than always showing raw discovery order, so the zero-wait choice is the one -under your thumb: +under your thumb. Three kinds of knowledge feed that promotion, best +evidence first, and the first one that answers wins: -- **"Currently loaded"** — a model from this host's own list that +- **"Currently loaded"** is a model from this host's own list that llama-swap reports `ready` right now, queried via `GET /api/model-endpoints/:id/running-status`. Bounded client-side to ~800ms (`Promise.race`), on top of the route's own 5s server-side timeout, so an endpoint that is asleep or firewalled cannot leave the modal invisible for the full 5s after the Run menu has already closed. -- **"Last used"** — shown only when nothing is currently loaded: the model - actually launched last for this exact (harness, endpoint) pair, read - from the per-device `codeman:customModelLastUsed:<mode>:<endpointId>` - localStorage key. Written by `_runCustomModelEntryViaRestart` (claude) - and `_quickStartWithCustomModelConfirm` (every one-shot launch; the +- **"In use by a session"** is the fallback for every endpoint that + cannot report its own loaded model, which is every server except + llama-swap: a bare llama.cpp, Ollama, vLLM, and a gateway such as + Bifrost fronting a whole fleet all answer `isLlamaSwap: false`. + `_getCustomModelInUseBySession` walks `this.sessions` for one of your own + sessions whose `customModel.endpointId` is this host, newest + `createdAt` wins, and promotes its model. A live session is first-hand + evidence of a model in use on the endpoint, because Codeman put it + there, so it earns the promotion and the tag says where the knowledge + came from. Two fences keep it honest: it reads only `customModel`, + never `displayModel` (display text scraped off a CLI footer, so a + terminal that merely printed a similar string must not mark a row), + and a session with `status: 'stopped'`, a null `pid` (its PTY exited, + the same live-pane test keep-warm applies) or a recorded `paneExit` + does not count, since nothing is on the endpoint any more. Sessions on other + endpoints, and models the host no longer discovers, are skipped. +- **"Last used"** appears only when neither of the above answers: the + model actually launched last for this exact (harness, endpoint) pair, + read from the per-device localStorage key + `codeman:customModelLastUsed:<mode>:<endpointId>`. That key is per CLI + on purpose, so it can only + ever promote the CLI that launched there before, which is why a gateway + box with no session running shows plain discovery order for the other + CLIs. Written by `_runCustomModelEntryViaRestart` (claude) and + `_quickStartWithCustomModelConfirm` (every one-shot launch; the `runCustomModelEntry` entry point itself only dispatches between the - two) only once the model is actually applied, never on the mere click — + two) only once the model is actually applied, never on the mere click: declining the context-window warning means this exact model cannot work - with this CLI at all, so promoting it next time would be actively wrong, - not just premature. + with this CLI at all, so promoting it next time would be actively + wrong, not just premature. -Neither tag reorders anything past that one promoted row. The "Default" +No promotion reorders anything past that one promoted row. The "Default" pill is a separate span, not a third value of the same slot: a promoted row that is also the endpoint's `defaultModelId` shows both tags (on a single-purpose GPU box that is the common case, and an exclusive slot silently dropped the Default marking for exactly that row), and a row with neither promotion nor default shows no tag at all. +When nothing is known about any row, `#customModelPickUnknown` says so +out loud instead of leaving an alphabetical wall that reads as the app +ignoring the question. It shows when the probe answered +`isLlamaSwap: false`, which the route gives for every failure it +catches, so one answer covers a server that cannot report and an +endpoint that is down or refused alike; the line's wording therefore +claims only that nothing was reported. A probe that never got any +answer (`_getCustomModelRunningStatus` resolving `null`, Codeman's own +request failed or the client-side bound won) keeps the line hidden. +Like its sibling no-matches line it needs an +explicit `[hidden]` rule scoped to `#customModelPickModal`, because +`.form-hint`'s `display: block` outranks the UA `[hidden]` rule. + **How the launch itself applies the endpoint depends on the harness.** For opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP (`runCustomModelEntry` → `_runCustomModelEntryOneShot`), the endpoint/model is folded into the SAME @@ -286,6 +328,15 @@ hot-swap. A Claude session is relaunched with `--resume <conversation> || grok are relaunched with the `--model` value that selects the injected provider (`custom/<modelId>` for pi and omp, `codeman-custom` for grok), since for those three the config file alone does not switch the model. +**Codex is relaunched with `--model <modelId>` too**, the bare selected id +(not a provider selector): the launch param forces the session onto the +model its generated `config.toml` already selected, so a launch-time model +carried over from App Settings can never override the injected selection. +That launch arg must satisfy the codex entry's `model-plain-colon` token +pattern (a leading letter or digit, then letters, digits and `._-:/`, per +`stock.ts`), so an id outside that shape, e.g. LM Studio's +`org/model@q4_k_m`, is refused with a 400 at apply time now, where a +`config.toml` alone used to carry it. **Remote (SSH) and Docker sessions are refused** (400) for now: their restart reattaches the durable remote/in-container tmux rather than relaunching the agent, so the selection would report success and change nothing. @@ -293,14 +344,16 @@ agent, so the selection would report success and change nothing. **Claude gets two more env vars when known/applicable, both declared on its registry entry (`contextLengthVar`/`configDirVar`), not hardcoded here:** -- `CLAUDE_CODE_MAX_CONTEXT_TOKENS` is set to `modelId`'s discovered context - length (see the discovery section above) whenever one is known. Without +- `CLAUDE_CODE_MAX_CONTEXT_TOKENS` is set to `modelId`'s context length: + the discovered figure (see the discovery section above), falling back to + the endpoint's user-set `defaultContextLength`, whenever one is known. + Without it, Claude Code assumes a large (200k) window for any unrecognized custom model id and never compacts, which reliably overflows a much smaller real local context — confirmed live: a stock ~33.7K-token system prompt against a 16384-token llama-swap model failed with `exceeds the available context -size`. No entry for the model in `modelContextLengths` means the var is - simply omitted, never a guess. ⚠️ **This var only affects when Claude +size`. Neither a `modelContextLengths` entry nor a `defaultContextLength` + for the model means the var is simply omitted, never a guess. ⚠️ **This var only affects when Claude Code compacts conversation _history_ — it cannot fix a model whose real context is smaller than Claude Code's own fixed per-turn overhead** (system prompt + tool schemas, empirically ~36.4K tokens, confirmed live @@ -441,15 +494,17 @@ Both apply routes (the restart route and the one-shot `POST /api/quick-start`) now check for this **before** launching or restarting anything, gated on the CLI's registry entry declaring a `contextLengthVar` (currently only claude — the check is a no-op for every other CLI by -construction, never a hardcoded mode check). If the model's discovered -context (`modelContextLengths`, from discovery above) is below -`CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000, comfortably above the measured +construction, never a hardcoded mode check). If the model's context (the +discovered figure from `modelContextLengths`, or the endpoint's +`defaultContextLength` when discovery never learned one for that model) is +below `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000, comfortably above the measured ~36.4K overhead), the response is `{requiresContextWarning: true, modelId, contextLength, minSafeContextTokens}` (plus `slotCount` when the model runs more than one parallel slot) instead of applying — nothing is -restarted or created yet. A context length that was never discovered at -all skips the check entirely (nothing to compare, so it fails open rather -than warning on every model an endpoint hasn't reported a size for). -The same warning fires when the context divided across the model's parallel slots (`contextLength / slotCount`) is below the floor, because Claude Code sends several 40K-token requests at once and the slots share one pool. Retrying with `confirmedContext: true` launches anyway (the legacy `confirmed: true` still means both questions). +restarted or created yet. A model with neither a discovered figure nor a +`defaultContextLength` skips the check entirely (nothing to compare, so it +fails open rather than warning on every model an endpoint hasn't reported a +size for). +The same warning fires when the context divided across the model's parallel slots (`contextLength / slotCount`) is below the floor, because Claude Code sends several 40K-token requests at once and the slots share one pool. ⚠️ `defaultContextLength` is the model's whole window, never a per-slot figure, so a default small enough to split under the slot count trips that slot division exactly like a discovered figure does. Retrying with `confirmedContext: true` launches anyway (the legacy `confirmed: true` still means both questions). The Run-menu picker shows this as an in-app modal (`#customModelContextWarningModal`, matching the llama-swap conflict @@ -526,7 +581,12 @@ for '<id>' not found. Defaulting to fallback metadata...` on launch — and fabricating a fake catalog entry would mean copying the _shape_ of OpenAI's own proprietary schema (their per-model system-prompt content included) for a warning that doesn't otherwise affect behavior — not - something to build into discovery. + something to build into discovery. ⚠️ The fallback-metadata warning is + NOT about the context window going un-overridden. Codex does now get a + real context-window override: when a figure is known the generated + `config.toml` sets the top-level `model_context_window` to it and + `model_auto_compact_token_limit` to 75% of it (floored), so Codex + compacts at that point instead of trusting its fallback guess. - **Gemini** — fails with `Invalid auth method selected`, traced to an undocumented `GATEWAY` auth path gemini-cli selects once `GOOGLE_GEMINI_BASE_URL` is set. Unresolved after real investigation @@ -541,14 +601,40 @@ for '<id>' not found. Defaulting to fallback metadata...` on launch — `POST <baseUrl>/chat/completions` 404s against llama-swap while `POST <baseUrl>/v1/chat/completions` succeeds; the harness's own error template (`DeepSeek API error (HTTP ${status})`) matches the originally - reported symptom exactly. `customModelInjection`'s new `appendV1Suffix` - (deepseek's entry only — claude/gemini must NOT get it, since claude was - already confirmed working against the raw `baseUrl`) fixes it by writing - `DEEPSEEK_BASE_URL` with `/v1` appended. Not yet re-run end-to-end with a - real `dsh` binary (no install available in this environment) — the fix - is source-confirmed and live-verified at the HTTP level, but a real - "hello world" reply through `dsh` itself is still outstanding before - calling this fully verified like the harnesses above. + reported symptom exactly. The fix is no longer the original env redirect: + deepseek's `customModelInjection` is a `configDir` recipe: a per-session + `DSH_HOME` holding only a generated `cordis.patch.yml` that declares a + `codeman-custom` `openai-completions` provider (the base URL gets the `/v1` + suffix from the shared `renderConfigFile` step every file template goes + through), so the installed profile keeps supplying the transport; the + recipe's `linkFromRealHome` field symlinks `profiles`, `sessions` and + `settings.yaml` back out of the real dsh home so installed bundles, + transcripts and user settings all survive the redirect (missing + directory entries are created first: a home that has never run a + turn would otherwise get a dangling link whose every write through + it failed with ENOENT). **Verified end-to-end against a real dsh + install by the maintainer** (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1): + `--dump-config` shows the three patched rows, the TUI boots on the + chosen model, and a prompt reaches the endpoint as + `POST /v1/chat/completions` with the right model, the bearer key and + `max_tokens: 8192`; the reply renders and the context window shows in + the footer. What such a session SHARES with the real home is + exactly the linked entries: `profiles/`, `sessions/`, + `settings.yaml`. The last is shared only until the session writes + it: dsh saves settings with a temp file and a rename + (`writeFileAtomic` in `@deepseek-ai/dsh-atomic-write`), which replaces + the symlink, so the first `/settings` change inside such a session + never reaches `~/.dsh/settings.yaml` and is deleted with the session's + directory on clear or close. Everything else in the generated `DSH_HOME` is + isolated: `storages/` (dsh-TUI's persisted `/model` choice and + workspace state), `.agent-presets` and `.env`. Two consequences of + the generated home-level file follow: the user's own home-level + `cordis.patch.yml` does NOT apply in such a session (the generated + file replaces it; profile-level patch files reached through the + linked `profiles/` still compose), and the generated patch REPLACES + the `llm-pi-ai` provider row's whole `config` for the session. Note + too that it likewise REPLACES the `dsh-tui` row's whole `config`, so + a profile-level `effort`/`preset` does not apply. - **Antigravity** — no known custom-endpoint mechanism at all; unsupported. See the confidence table in `custom-model-endpoints-plan.md` for the full detail behind diff --git a/docs/deepseek-integration.md b/docs/deepseek-integration.md index 874f6fd1..02af1002 100644 --- a/docs/deepseek-integration.md +++ b/docs/deepseek-integration.md @@ -235,9 +235,11 @@ worker's first answer reads as an answer. Three things about the file shaped the reader (`src/deepseek-transcript.ts`): - **It is one zstd FRAME per append, not one zstd stream.** `zstd -dc` decodes all - of them, Node's `zlib` zstd decoder stops at the first: a real 56-line - transcript came back as 1 line. The reader walks frame headers itself. On a Node - older than 22.15 (no zstd at all) the mode falls back to the pane, as before. + of them, Node's `zlib` zstd decoder before 24.22 and 26.10 stops at the first: + a real 56-line transcript came back as 1 line. The reader walks frame headers + itself, which stays correct on a Node that decodes every frame + (nodejs/node#65865). On a Node older than 22.15 (no zstd at all) the mode + falls back to the pane, as before. - **Not every `user/message` is the user.** Each turn also records a plugin-sourced runtime-context snapshot; only `source.kind === 'user'` is a prompt. diff --git a/docs/discord-bridge.md b/docs/discord-bridge.md new file mode 100644 index 00000000..86210d3e --- /dev/null +++ b/docs/discord-bridge.md @@ -0,0 +1,137 @@ +# Discord bridge + +Codeman can post to a private Discord server when a session needs you, give every tab its own thread, and type your thread reply into that tab. It only makes outbound connections, so Codeman is not exposed to the internet and it works when you are away from your network. + +> [!WARNING] +> A reply from an allowed Discord account is typed into agents that can run shell commands. Treat that Discord account like a login to the machine: turn on 2FA and use a strong, unique password. + +## What it does + +- Posts when a session asks for permission, asks a question, goes idle or errors. Which of these a CLI can raise depends on the hooks it has: Claude Code sends a turn end (`stop`, an `info` event that posts only when **Which events** is set to Everything, and `idle_prompt`, which posts by default after the turn has waited a minute). DeepSeek Harness sends only `stop`. Codex, OMP, OpenCode, Gemini, Antigravity, Pi, Grok and Copilot send no turn end, so they reach Discord through an error, a blocked respawn, a stopped crash loop or their own `codeman notify`; Codex's permission and question dialogs are also read off its screen. +- Posts a finished turn, and an error or blocked alert, as the CLI's whole last message, with its bold, lists and numbered questions kept (a Markdown table, which Discord cannot draw, becomes an aligned code block), split over several posts if it is long. It reads the CLI's transcript where it keeps one (Claude Code, Codex, DeepSeek Harness, OMP) and otherwise splits the screen into turns (OpenCode, Gemini, Antigravity, Pi, Grok, Copilot), which can start mid-message when a long answer has scrolled off. A shell, and a permission or question dialog, show the last lines of the screen instead, without the prompt row and status lines under them. +- Creates one thread per tab in a single parent channel, so a reply always reaches exactly one session. +- Types your reply into that session as a single line followed by Enter. A single digit sent while a permission or question is waiting goes as the key alone, without Enter, because the dialog acts on the keypress. +- Lets a CLI post a deliberate message (for example "press the reset button on the case") with the `codeman notify` command, which calls `POST /api/sessions/:id/discord-notify`. Each tab may post 6 a minute; past that the command says "too many messages", so a looping agent cannot ping your phone without end. +- Saves files you attach in a thread into that tab's working folder and tells the CLI the path. A CLI can send a file back with `codeman notify "message" --file <path>`. +- Tells each CLI about `codeman notify` (see [Telling the CLIs](#telling-the-clis)). + +## Setup + +Do these once, in order. Never paste the bot token into a chat or a session. + +### 1. Create a private server + +1. In Discord choose **Add a Server**, **Create My Own**, **For me and my friends**. +2. Invite nobody and create no invite links. +3. Create one text channel, for example `#codeman-cli`. + +### 2. Copy the IDs + +1. Open **User Settings**, **Advanced** and turn on **Developer Mode**. +2. Right-click the server, the channel and your own avatar, and choose **Copy ID** for each. You need the guild ID, the parent channel ID and your user ID. + +### 3. Create the application + +1. Go to <https://discord.com/developers/applications> and choose **New Application**. +2. Name it `Codeman`. + +### 4. Configure the bot + +On the **Bot** tab: + +1. Choose **Reset Token** and copy it. It is shown once. +2. Turn **Public Bot** off, so nobody else can add it. Discord refuses this while the app has a default install link, so first open the **Installation** tab, set **Install Link** to **None** (untick Guild Install and User Install if it will not save) and save, then come back here. +3. Leave **Requires OAuth2 Code Grant** off. +4. Under **Privileged Gateway Intents** turn **Message Content Intent** on. Leave **Server Members Intent** and **Presence Intent** off. + +### 5. Invite the bot with minimal permissions + +On **OAuth2**, **URL Generator**: + +1. Tick the scope `bot` only. Do not tick `applications.commands` or anything else. +2. Tick only these permissions (Attach Files and Embed Links are optional: without them, files cannot be sent from a CLI and links show no preview): + + | Permission | Why | + | ------------------------ | ------------------------------------ | + | View Channels | See the parent channel | + | Send Messages | Post in the parent channel | + | Send Messages in Threads | Post in each tab's thread | + | Create Public Threads | Make a thread per tab | + | Read Message History | Read your replies | + | Add Reactions | Acknowledge a reply that was typed | + | Attach Files | Send a file from a CLI to the thread | + | Embed Links | Show previews for links in posts | + +3. Open the generated URL, choose your private server and authorise. +4. Check the **Installation** tab still shows **Install Link: None**, so there is no public way to add the bot. + +The ticked boxes are the source of truth. As a cross-check, these eight permissions add up to the permissions integer `309237763136`. + +### 6. Optional: allow deleting threads + +Only needed for `onSessionDelete: delete`. Give the bot role **Manage Threads** on the parent channel only, as a channel permission override and not server-wide. Skip this if you are happy to keep or archive threads. + +### 7. Never grant + +Administrator, Manage Channels, Manage Roles, Manage Messages, Mention Everyone, or any voice permission. + +### 8. Configure Codeman + +1. Open **Settings**, **Notifications**, then the **Discord** group. +2. Enter the bot token, guild ID, parent channel ID and your user ID in the allowlist. +3. Choose the options below and press **Send test**. A test message should appear in the channel. + +The token is stored in `~/.codeman/discord.json` with mode 0600. It is never returned by the API, written to logs or sent over SSE. + +### 9. Phone alerts + +In the Discord mobile app set the server to **All Messages**. Discord's own push notification is what reaches you off-network. + +## Options + +| Option | Values | Default | Effect | +| ----------------- | --------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `threadCreation` | `lazy`, `onSessionStart` | `lazy` | `lazy` makes a thread on the first notification. `onSessionStart` makes one per new tab | +| `onSessionDelete` | `keep`, `archive`, `delete` | `archive` | What happens to the thread when its tab is deleted. `delete` needs Manage Threads | +| `staleThreads` | `keep`, `archive`, `delete` | `keep` | What happens to threads this bot made that no tab uses any more, checked after startup and then daily. A deleted tab's thread leaves the map, so this also archives or deletes threads that `onSessionDelete` kept or archived. Skips threads under an hour old, other people's threads and other channels, does nothing while the thread map is empty, and closes at most 25 per run. `delete` needs Manage Threads. Use one channel per Codeman | +| `pingMode` | `off`, `critical`, `all` | `critical` | `critical` pings you for permission prompts, questions, errors and urgent posts | +| `scope` | `attention`, `all` | `attention` | `attention` skips the quiet "response complete" event. `all` sends it too | +| `startupHint` | on, off | on | Tell Claude about `codeman notify` in its system prompt at launch | + +## Telling the CLIs + +A CLI only posts deliberately if it knows it can. While the bridge is on: + +- **Claude** (`startupHint`, on by default) is launched with `--append-system-prompt`, so nothing shows in the conversation. This reaches local tabs only: remote (ssh) and Docker launches are built outside the CLI registry, and have no `codeman` command anyway. +- **Every other CLI** is sent nothing: there is no silent way to tell it, and typing the note as a message would cost a model turn per tab. It can still run `codeman notify` if you tell it to. + +The text explains `codeman notify "message" --urgent`. `codeman` is on the PATH of every session from the installer or an npm install (a git checkout run without the installer has no `codeman` command) and reads `CODEMAN_SESSION_ID` and `CODEMAN_API_URL`, so no other setup is needed. Without a bridge it prints that the bridge is off and exits cleanly. Tabs started before the bridge was enabled are not told; use the skill or `CLAUDE.md` for those. + +## Files + +- **Received:** files you attach to a reply from an allowed user are saved as `<working folder>/.codeman-discord/<message id>-<name>` (mode 0600) and the one-line reply the CLI sees ends with `[attached: <paths>]`. At most 5 files of 8 MB each; names are reduced to letters, digits, `.`, `_` and `-`; only Discord's own HTTPS addresses are fetched. Anything skipped is named in the thread. The folder ignores itself (it holds a `.gitignore` of `*`), so nothing in it is committed. Remote (ssh) tabs have no local folder, so no file is saved for them. +- **Sent:** `codeman notify "message" --file <path>` uploads a regular file of at most 8 MB. The real path (symlinks followed) must be inside the tab's working folder, and a file on the attachment blocklist (`.env`, keys under `.ssh`, Codeman's own state) is refused even there, which stops an upload of something sensitive by mistake. The agent runs as your user, so this is a guard against accidents, not a sandbox. Remote tabs cannot send files. +- A downloaded file is untrusted content: only allowed users can send one, but a CLI that opens it is exposed to anything written inside it. + +## Security model + +- Only messages from IDs in `allowedUserIds`, in the configured guild, inside a thread Codeman created, are acted on. Everything else is dropped. +- A reply is reduced to one line, stripped of control characters and capped in length before it is typed. +- Replies are rate limited per session. +- A Discord outage or a bad token never affects sessions. The bridge backs off, and the last error shows in Settings. + +## Rotating and revoking + +- **Rotate:** in the Developer Portal choose **Bot**, **Reset Token**, paste the new token into Codeman and press **Send test**. +- **Revoke:** kick the bot from the server or delete the application, then choose **Remove token** in Settings. Deleting `~/.codeman/discord.json` alone does not reach a running bridge, which keeps the token it loaded until Codeman restarts. +- If the token is ever pasted somewhere it should not be, reset it straight away. + +## Troubleshooting + +| Symptom | Check | +| ----------------------------------- | ------------------------------------------------------------------------ | +| Test fails with 401 | The token is wrong or was reset. Paste the current one | +| Test fails with 403 or 50001 | The bot cannot see the parent channel. Check the role and the channel ID | +| Posts arrive but replies do nothing | Message Content Intent is off, or your user ID is not in the allowlist | +| Thread is not deleted | The bot lacks Manage Threads on the parent channel | +| Nothing arrives on the phone | Check the Discord app's notification setting for the server | diff --git a/docs/releasing.md b/docs/releasing.md index fc1f7fe5..097a03a9 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -59,4 +59,4 @@ A security notice is a sticky warning card about something outside Codeman that - `title` (optional) is the card's bold line; keep it short. `link.url` must be https. `i18n` is optional; a missing string falls back to the English one. - Run `npx prettier --write src/web/public/security-notices.json` and `npm test -- test/security-notices.test.ts`, which validates the file (unique ids, real dates, https links, every `cli` a stock registry id, a zh-CN title wherever there is an English one). -**Retiring one**: let it expire, or delete the entry once it has. Removing it early stops it on the next release only; browsers already showing it keep the card until they close it or reload. +**Retiring one**: let it expire, or delete the entry once it has. Removing it early stops it on the next release only; browsers already showing it keep the card until they close it or reload. Until then a fresh install gets it too, because a new browser has no seen list, so give an `expires` no later than the warning stays useful. An empty list (`{ "notices": [] }`) is a valid file. diff --git a/docs/remote-sessions.md b/docs/remote-sessions.md index 2d80397b..1c36a9ff 100644 --- a/docs/remote-sessions.md +++ b/docs/remote-sessions.md @@ -348,6 +348,8 @@ and it follows the same rule as the launch path: every ssh command line comes fr | `POST /api/sessions/:id/folders` | Lists a folder (absolute or `~/`) on the **remote** host in one ssh round trip (`remoteListFolder`): resolved with `cd -P`, missing → `404`, a file → `400 Not a folder` | | `GET /api/sessions/:id/folders/:folderId/files/:name/raw` | Streams one file of a granted remote folder over ssh after the same re-probe + guard as an attachment; `preview` (office) and `thumbnail` answer `400` | | `GET /api/sessions/:id/attachments/:attachmentId`, `GET …/attachments` (history) | Size/mtime/existence resolved over ssh, so a remote entry is not reported `missing`; the history list resolves EVERY entry in one batched probe, never one connection per entry | +| `POST /api/sessions/:id/fix-plan/read` | Reads `@fix_plan.md` on the **remote** host (one probe of the file and the workspace, the same confinement, a 1 MB cap); missing or resolving outside the workspace → `NOT_FOUND`, not a regular file, over the cap or an ssh failure → `OPERATION_FAILED`; never a local fallback | +| `POST /api/sessions/:id/fix-plan/write`, `POST /api/sessions/:id/ralph-prompt/write` | `400` (`INVALID_INPUT`) before any disk is touched: remote writes are out of scope, and a local write would land in a same-named local folder | ⚠️ The attachment route is the one a clicked path takes when it is **outside** the case directory (a remote `/tmp` scratchpad capture, a screenshot elsewhere in the home dir): @@ -429,6 +431,14 @@ path but the SESSION (`session.remote`): a remote session never falls back to lo `fs`, and a local session never opens an ssh connection — including for attachment records, which are keyed to the session that registered them. +**Local watchers skip a remote session.** The Ralph tracker's `@fix_plan.md` auto-load +(and the `fs.watch` behind it) never runs for a remote session, because the watcher reads +the LOCAL disk, where that path is missing or a different tree: `setupSessionListeners()` +(`server.ts`) calls `setRemoteWorkingDir()` instead, which only records the path. The fix +plan stays authoritative exactly as for a local session, so the tracker's output-based +completion guessers stay off there too; the file reaches the tracker only through +`fix-plan/read`, over ssh. The image watcher skips remote sessions the same way. + ## Wake-on-LAN from user input A durable remote session survives an SSH drop (COD-104/108), but nothing brought the diff --git a/docs/security-architecture.md b/docs/security-architecture.md index 21f0b9e5..72c2dcf0 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -105,7 +105,9 @@ The warning points at three ways to secure the instance: 2. `--host 127.0.0.1` + an authenticated tunnel (`cloudflared` / `tailscale serve`). 3. `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` — explicitly accept the risk (downgrades the warning to a one‑line note). This - flag is **only** an acknowledgement; it does not change reachability. + flag is **only** an acknowledgement; it does not change reachability. It covers + the bind and nothing else: it does **not** let the managed Cloudflare tunnel + start without a password, which has its own opt‑in (§2, the tunnel caveat). `CODEMAN_API_URL` (used by hooks/child processes) is always derived as a loopback address (`0.0.0.0`/`localhost`/`::1` → `127.0.0.1`) so in‑process hooks reach the @@ -209,8 +211,15 @@ requests as local: **Mitigation:** set `CODEMAN_PASSWORD` whenever a loopback‑connecting tunnel is up — it gates everything except the (secret‑gated) hook exemption and is the documented practice; since COD‑55 enabling the managed tunnel **refuses** to start -without it unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` explicitly -acknowledges the exposure. Prefer `tailscale serve` (below), which authenticates +without a login (`CODEMAN_PASSWORD` or multi‑user mode) unless +`CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` / `--allow-unauthenticated-tunnel` +acknowledges the exposure, or the request carries `acknowledgeUnauthTunnel: true` +(what the browser sends after its confirm). The network acknowledgement +(`CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK`) does **not** count: the installer writes +it for every LAN install without a password, so it once turned a click on the +welcome screen's tunnel link into a public, login‑free URL with no confirm. With +no login the browser asks before every enable, whatever the server would accept, +reading `loginConfigured` from `GET /api/tunnel/status`. Prefer `tailscale serve` (below), which authenticates at the tailnet layer so untrusted clients never reach the loopback port at all. ### Host‑header & Origin allowlist (DNS‑rebinding & CSRF defense) @@ -573,6 +582,7 @@ Opt-in and off by default: the server POSTs the Web Push events (permission prom - **A second server-side outbound channel through the web-tab egress guard (§10b).** Delivery goes through `webviewFetch`, so link-local and cloud-metadata targets are refused at save time and again on the RESOLVED address at connect time; redirects are not followed (`redirect: 'manual'`) and each send is bounded by a 5 s timeout. Loopback and RFC1918 stay allowed on purpose (a self-hosted ntfy is the point), so **Send test** works as a blind reachability probe (status, refused or timed out, never a response body) for whoever may call it. Web tabs already give that caller full LAN reach with bodies, so nothing new is exposed. - **The URL is a bearer secret** (anyone holding a Slack or Discord webhook URL can post as it). It lives in `~/.codeman/webhook.json` (0600, tmp+rename), is kept out of `settings.json` (which every logged-in user reads through `GET /api/settings`), is never returned (`GET /api/webhook` gives scheme + host only), and never appears in a log line, a delivery result or an error message. +- **Discord bridge** (`src/discord-bridge.ts`, [setup](discord-bridge.md)): the bot token lives in `~/.codeman/discord.json` (0600, tmp+rename) and is never returned by the Discord routes, logged or put in an error, and the attachment, folder and download routes refuse the file. A session whose folder is the home directory can still read it through its own file viewer, which serves that workspace without the blocklist. Inbound replies are typed into a session only from `allowedUserIds`, in the configured guild, inside a thread the bridge created; anything else is dropped unread. A reply becomes one printable line plus Enter, capped at 1000 characters and one per second per tab. Posts send `allowed_mentions` limited to the allowlisted ids, so agent output cannot ping a server. **Files:** an attachment is fetched only from Discord's own HTTPS hosts (no redirects followed), at most 5 per reply and 8 MB each, saved 0600 under `<tab folder>/.codeman-discord/` with the name reduced to `[A-Za-z0-9._-]`; a file a CLI sends must be a regular file whose real path (symlinks followed) lies inside its own tab's folder and is not on the attachment blocklist (`isBlockedAttachmentPath`, since a tab's folder can be the home directory), which stops a sensitive file being uploaded by mistake, and remote sessions send and receive none. A received file is untrusted content to whatever opens it. **What the checks do not stop:** the agent runs as the same OS user, so the path check prevents accidental uploads, not a determined agent that pastes a file's contents into the message. **What leaves the machine:** a permission or question alert sends the rendered screen (whatever the agent printed), and a finished turn or an error sends the CLI's whole last message, read from its transcript (up to 8 posts of 1,900 characters). `idle_prompt` is a warning, so with the default scope every Claude turn left unanswered for a minute is posted, and with **Which events** set to Everything every turn end is; treat the Discord server as holding that output. **Multi-user:** the bridge is one allowlist for the whole server, so an allowlisted Discord account can type into every user's sessions; only an admin can configure it. **Startup hint:** a fixed text (no user input) is added to Claude's system prompt; no other CLI is sent it. The bridge only dials out (REST and the gateway WebSocket), so it needs no inbound exposure. A Discord account on the allowlist can drive agents that run shell commands: it needs 2FA. - **It carries session data to a third party.** Titles and bodies include session names, tool names and error text, all agent- or user-controlled, so Discord gets `allowed_mentions: { parse: [] }` and Slack's `& < >` are escaped: agent output cannot ping a channel. In multi-user mode all three routes are admin-only and the channel is instance-wide: it receives every user's session events, the same reach an admin's own Web Push has, which means non-admins' session details leave the box at the admin's choice. --- @@ -585,7 +595,8 @@ Opt-in and off by default: the server POSTs the Web Push events (permission prom | `--host` / `CODEMAN_HOST` | Bind host (default `127.0.0.1`) | | `CODEMAN_ALLOWED_HOSTS` | Extra `Host`/`Origin` allowlist entries for reverse proxies (comma‑separated; exact host, or leading‑dot `.suffix` for subdomains) — see §3 | | `--base-url` / `CODEMAN_BASE_URL` | Sub‑path prefix Codeman is mounted under behind a reverse proxy, e.g. `/codeman` (default `/`); the proxy must forward the prefix unchanged. Independent of `CODEMAN_ALLOWED_HOSTS` | -| `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledge an unauthenticated non‑loopback bind (downgrades the warning) | +| `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledge an unauthenticated non‑loopback bind (downgrades the warning). Does not cover the tunnel | +| `--allow-unauthenticated-tunnel` / `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL` | Let the managed Cloudflare tunnel start without a login (the browser still asks first). Never written by the installer | | `--https` | Enable TLS (adds HSTS) | | `CODEMAN_INSTANCE` | Scope tmux socket + data dir for isolation | | `CODEMAN_GESTURE=1` | Make the gesture overlay available (widens CSP) | diff --git a/docs/tailscale-installer-plan.md b/docs/tailscale-installer-plan.md index aac7d4df..2322ed09 100644 --- a/docs/tailscale-installer-plan.md +++ b/docs/tailscale-installer-plan.md @@ -192,7 +192,7 @@ start/stop wrapper to manage; tailscaled owns the lifecycle). | First cert issuance latency | Verify step retries ~30s and says why the first load may be slow. | | `tailscale up` needs auth | Print the auth URL prominently, poll with timeout, skip gracefully. Works headless. | | Custom `CODEMAN_PORT` | Serve target uses the actual port; `install.sh tailscale` re-reads it from the service file. | -| Funnel (public internet) | OUT OF SCOPE for v1. If ever added it must mirror the tunnel guard: refuse without `CODEMAN_PASSWORD` (`isUnauthenticatedNetworkAcknowledged`). Funnel exposes to the whole internet and is a different risk class than tailnet-only serve. Mention `tailscale funnel` in docs only, with the password warning. | +| Funnel (public internet) | OUT OF SCOPE for v1. If ever added it must mirror the tunnel guard: refuse without `CODEMAN_PASSWORD` (`isUnauthenticatedTunnelAcknowledged` and the login check in the `PUT /api/settings` tunnel guard). Funnel exposes to the whole internet and is a different risk class than tailnet-only serve. Mention `tailscale funnel` in docs only, with the password warning. | | Uninstall | Best effort: if `serve status --json` shows 443 proxying to our port, run the targeted `tailscale serve --https=443 off` (still accepted by current CLIs); if the CLI rejects it, print manual instructions. Never touch other mappings, never uninstall tailscale itself. | | User already fronting Codeman some other way (reverse proxy etc.) | The serve check only looks at tailscale state; other proxies are invisible and unaffected (same stance as the loopback-exemption note in security-architecture). | diff --git a/docs/versioning-policy.md b/docs/versioning-policy.md index 6ec92a2f..d049ae5f 100644 --- a/docs/versioning-policy.md +++ b/docs/versioning-policy.md @@ -30,8 +30,13 @@ A **MAJOR** bump is required to break any of these after 1.0: 3. **Documented environment variables** that configure deployment: `CODEMAN_PASSWORD`, `CODEMAN_USERNAME`, `CODEMAN_HOST`, `CODEMAN_PORT`, `CODEMAN_INSTANCE`, `CODEMAN_ALLOWED_HOSTS`, `CODEMAN_DATA_DIR`, - `CODEMAN_TMUX_SOCKET`, and the `--host` / `--port` / `--https` CLI flags. - Removing or changing the meaning of one of these is breaking. + `CODEMAN_TMUX_SOCKET`, `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK`, + `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL`, and the `--host` / `--port` / + `--https` / `--allow-unauthenticated-network` / + `--allow-unauthenticated-tunnel` CLI flags. Removing or changing the meaning + of one of these is breaking. The two acknowledgements are listed from 1.46.0, + the release that split them: the network one covers the non-loopback bind + only, and an unauthenticated tunnel needs the tunnel one. 4. **The HTTP API and SSE event channel**, served under **`/api/v1`** with the uniform `{success:true,data}` / `{success:false,error,errorCode}` envelope and conventional HTTP status codes. Endpoint paths, the response envelope, error @@ -62,6 +67,25 @@ These may change in a **MINOR** (or even PATCH) release without a MAJOR bump: anything labeled experimental in the UI or docs. These may change or be removed at any time. +### Experimental agent coordination + +`codeman agent inspect`, `codeman agent stage` and `codeman agent submit`, and +the matching `GET /api/v1/sessions/:id/inspect`, `POST /api/v1/sessions/:id/stage` +and `POST /api/v1/sessions/:id/submit` routes (with their `/api` aliases, since +every `/api` route is also served under `/api/v1`), are labeled experimental, so +item 3 above applies to them: their commands, flags, endpoint paths, payloads and +behavior may change or be removed in a MINOR or PATCH release. They are also +opt-in: the routes answer `403 FORBIDDEN` with a message naming the synced +`agentCoordinationEnabled` setting (default OFF) until it is turned on, so +automation cannot come to depend on them without choosing to. Pin a compatible +Codeman version when automating them. Existing authentication, ownership and +shared response-envelope rules still apply. The session behavior underneath them +(input revision tracking in the session's write paths, the SubmitVerifier +re-press going through the shared write boundary) runs for every session and is +covered like any other stable behavior. Other agent commands and API routes keep +their existing guarantees. Promotion to stable requires an explicit documented +decision. + ## Deprecation policy When we need to change a covered surface: diff --git a/docs/wiki/Codeman-Skill.md b/docs/wiki/Codeman-Skill.md index dcd57f08..0033abd5 100644 --- a/docs/wiki/Codeman-Skill.md +++ b/docs/wiki/Codeman-Skill.md @@ -123,8 +123,11 @@ message a worker directly instead of typing into its terminal. ## What it will and will not do -- **It self-gates.** Outside a Codeman session (`CODEMAN_MUX` unset) it refuses to act and - never guesses an API URL, so a global install costs an unrelated agent session nothing. +- **It self-gates.** Outside a Codeman session (`CODEMAN_MUX` unset) it refuses every live + API operation and never guesses an API URL, so a global install cannot drive a server it + is not part of. The one exception is reading saved Codex conversations with + `codeman history read` (see [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent#reading-saved-codex-conversations)), + a local, read-only command that needs no server or session. - **Unprompted, it may only** start sessions, prompt them, and delete the ones **it created in that conversation, by exact id**, behind a guard that refuses to delete the agent's own session. @@ -153,6 +156,8 @@ The skill checks for both and says so instead of acting. | [`reference/recipes.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/recipes.md) | Worked flows: claude, DeepSeek Harness and shell workers, fan-out, watching a blocked worker, messaging. Loaded on demand. | | [`reference/endpoints.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/endpoints.md) | Endpoint tables, error codes, the signals each mode emits, capacity limits. Loaded on demand. | | [`reference/messaging.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/messaging.md) | Talking to claude workers directly through Claude Code's cross-session messaging. Loaded on demand. | +| [`reference/history.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/history.md) | Reading saved Codex conversations offline with `codeman history read`: selectors, filters, forks, budgets. Loaded on demand. | +| [`reference/inspection-and-staging.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/inspection-and-staging.md) | Experimental and opt-in (App Settings, Experimental agent coordination): bounded pane inspection and guarded submission with `codeman agent inspect`, `stage` and `submit`. Loaded on demand. | Only `SKILL.md` sits in the agent's context; the references load when a job needs them, so a quick run stays quick. diff --git a/docs/wiki/Config-Backups.md b/docs/wiki/Config-Backups.md index 8ca07216..e50d0739 100644 --- a/docs/wiki/Config-Backups.md +++ b/docs/wiki/Config-Backups.md @@ -15,6 +15,7 @@ A fixed list of files from the data directory (`~/.codeman/`, or the directory o | `clis.json` | CLI enable/disable and custom CLI entries. | | `custom-model-hosts.json` | Saved custom model endpoints. **Holds API keys.** | | `webhook.json` | Webhook notification target. | +| `discord.json` | Discord bridge settings. **Holds a bot token.** | | `intents.json` | Read My Mind per-case profiles. | | `linked-cases.json` | Registered cases. | | `remote-hosts.json`, `remote-cases.json`, `docker-hosts.json`, `docker-cases.json` | Remote SSH and Docker overlay configuration. | @@ -188,15 +189,15 @@ that are the same are listed on one line. A file that is not JSON shows only tha a file with more than 200 changes is cut short, so use the files themselves for a full diff. The self-rewritten fields listed under "When a backup is made" are not counted as changes. -**Credential values are never shown.** `.env`, `users.json`, `push-keys.json`, `webhook.json` (the -URL is a bearer secret) and `intents.json` (captured prompts) list only the names that changed, and in every other file a key that looks like a token, secret, password, +**Credential values are never shown.** `.env`, `users.json`, `push-keys.json`, `webhook.json` (the URL is a bearer secret), `discord.json` (the +bot token) and `intents.json` (captured prompts) list only the names that changed, and in every other file a key that looks like a token, secret, password, credential or key is shown as "changed" without its value. URL values lose any `user:password@`, query and fragment. ## Security - Some tracked files carry credentials (`.env`, `users.json`, `custom-model-hosts.json`, - `push-keys.json`, `webhook.json`). Each backup folder is mode `0700` and its files `0600`, so the default + `push-keys.json`, `webhook.json`, `discord.json`). Each backup folder is mode `0700` and its files `0600`, so the default location exposes nothing the data directory does not already. - **A custom folder is your decision.** Putting it on a shared drive, a synced folder or a network share copies those credentials there, with whatever permissions that location has. diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md index c5762d60..1b2428a0 100644 --- a/docs/wiki/Core-Concepts.md +++ b/docs/wiki/Core-Concepts.md @@ -22,11 +22,11 @@ Codeman-side configuration: Three ways to get one, all under **New or link a case…** at the bottom of the case picker (the case dropdown in the bottom toolbar; on a phone, the case sheet's **Create New Case**): -| How | Result | -| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. | -| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` and registered as a case. Private repos need this machine's own git credentials (see below). | -| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. | +| How | Result | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. | +| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` (or straight into a folder you choose, which must be empty or not exist yet) and registered as a case. Private repos need this machine's own git credentials (see below). | +| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. | Linked cases keep living where they are. Deleting a case in Codeman removes the registration, and for a linked case that is all it removes. diff --git a/docs/wiki/Custom-Model-Endpoints.md b/docs/wiki/Custom-Model-Endpoints.md index 9d2b6a30..89b51068 100644 --- a/docs/wiki/Custom-Model-Endpoints.md +++ b/docs/wiki/Custom-Model-Endpoints.md @@ -27,14 +27,20 @@ hosts — these are machine-level infra, not a per-user setting. shows up without another manual click of **Discover**. One endpoint being unreachable on a given cycle (powered off, wrong network) never blocks the others from refreshing. -**Context length is picked up automatically where it can be, safely.** Against a -llama.cpp/llama-swap server, discovery also learns each _currently loaded_ model's real -context window and applies it to the launched session (Claude Code today — see below), so -the harness stops assuming a large default window for a model name it doesn't recognise and -overflowing a much smaller real one. It's deliberately never probed for a model that isn't -already loaded, since asking a llama-swap server about an unloaded model can trigger an -actual, slow model swap as a side effect — a model just not currently loaded keeps whatever -context length an earlier cycle already learned for it instead. +**Context length is picked up automatically where it can be, safely.** Every model's own +listing entry is read for a stated context figure (`context_length`, then `context_window`, +which current llama.cpp, llama-swap and gateway-style servers state to varying degrees); +against a llama-swap server the stronger figures from its launch command and from llama.cpp's +`/props` probe win where they exist, and those probes only ever run for a model the server +already reports loaded, since asking about an unloaded model can trigger an actual, slow model +swap as a side effect. A model that states no figure keeps whatever an earlier cycle learned: +the stored figure always beats the endpoint's **Default context length** field +(`modelContextLengths[id] ?? defaultContextLength`), which only applies where no figure was +ever learned (that number is the model's whole window, never a per-slot figure). A model +neither covers simply gets no override, never a guess. The window is applied to the launched session by the harnesses +that can take it: Claude Code today (see below), OpenCode as the model's context limit, and +Codex as a `model_context_window` entry in its generated config file, which also sets Codex's +own auto-compact point via `model_auto_compact_token_limit` at 75% of the window. ## Running a session against one @@ -48,15 +54,20 @@ pointed at. **Which model it uses depends on how many the endpoint has discovered.** With exactly one, the session launches straight away on that model — nothing to choose. With two or more, a -small dialog asks which one to use for this launch before starting the session; the +small dialog asks which one to use for this launch before starting the session; a search box +at the top narrows a long list with a case-insensitive substring match, so a server hosting +hundreds of models stays usable. The endpoint's default model, if set, is marked but not auto-picked, so a launch can deliberately use a different one without changing the saved default. The list is not raw discovery order either: the model llama-swap reports loaded and ready is moved to the top and tagged -**Currently loaded**, and when nothing is loaded, the model you last launched on this harness -and endpoint pair is moved up instead and tagged **Last used** (a per-device browser value, so -another device starts from its own history). The default model keeps its own **Default** pill -in both cases, and nothing is ever auto-chosen: the promoted row is simply the one under your -thumb. +**Currently loaded**. When nothing is reported loaded, the model one of your own running +sessions is using on that endpoint is moved up instead and tagged **In use by a session** (it +beats **Last used**, never **Currently loaded**), and failing that the model you last launched +on this harness and endpoint pair is moved up and tagged **Last used** (a per-device browser +value, so another device starts from its own history). With none of those, a line under the +search box says nothing is marked, so the plain list is not mistaken for an answer. The default +model keeps its own **Default** pill in every case, and nothing is ever auto-chosen: the +promoted row is simply the one under your thumb. **For opencode, Codex, Gemini, Pi, Grok, DeepSeek, OMP and GitHub Copilot, picking an entry launches straight onto the endpoint** — no restart, because the endpoint is applied before the @@ -186,7 +197,7 @@ read is treated as one slot, never guessed. | **Claude Code, opencode, Pi, Grok, OMP** | Verified end-to-end against a real local server. | | **Codex** | Config is correct, and plain chat can work against a server that speaks the Responses API — but a real tool-call attempt comes back as inert text instead of running, so it's still not usable for real coding work. | | **Gemini** | Fails with an auth error gemini-cli raises once redirected. Unresolved; don't rely on it yet. | -| **DeepSeek** | The original 404 is root-caused and fixed (DeepSeek Harness's own code was missing a `/v1` most local servers require) — not yet re-run against a real `dsh` install to confirm end-to-end. | +| **DeepSeek** | Verified end-to-end against a real `dsh` install (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1): the original 404 is root-caused and fixed, and the redirect is now a generated per-session patch file whose prompt reaches the endpoint as `POST /v1/chat/completions`. Such a session does not carry over your home-level `cordis.patch.yml`, your own `llm-pi-ai` providers, or a profile-level `effort`/`preset`. | | **Antigravity** | No known custom-endpoint mechanism at all. Not offered. | Which harnesses show up in the Run-menu picker is read live off Codeman's own CLI registry, diff --git a/docs/wiki/Discord-Bridge.md b/docs/wiki/Discord-Bridge.md new file mode 100644 index 00000000..1694a04f --- /dev/null +++ b/docs/wiki/Discord-Bridge.md @@ -0,0 +1,105 @@ +# Discord Bridge + +Answer a blocked agent from your phone, from anywhere. Codeman posts to a private Discord +server when a session needs you, gives every tab its own thread, and types your reply in that +thread into that tab. It only makes outbound connections, so nothing is exposed to the internet +and it works when you are away from your network. + +**Opt-in, off by default. One server, one channel, threads per tab.** + +> **Treat the Discord account like a login to the machine.** A reply from an allowed account is +> typed into agents that can run shell commands. Turn on 2FA and use a strong, unique password. + +## What it does + +- **Alerts** for permission prompts, questions, idle sessions, errors and a stopped crash loop. A + question or permission prompt shows what is on the tab's screen right now, so you can see what is + being asked; a finished turn or an error carries the CLI's whole last message. +- **One thread per tab** in a parent channel, so a reply always reaches exactly one session. +- **Replies** from an allowed user are typed into that tab as one line plus Enter (a single digit answering a waiting permission or question goes as the key alone, without Enter). +- **Files** you attach are saved in the tab's folder and the agent is given the path. An agent can + send a file back. +- **Agents can ask for you.** `codeman notify "Press the reset button on the case" --urgent` + posts to the tab's thread. Sessions from the installer or an npm install have the command (a git checkout run without the installer does not); see [below](#telling-the-agents). + +## Set it up + +The full least-privilege walkthrough (private server, bot, the exact permissions, IDs, token +handling) is in [docs/discord-bridge.md](https://github.com/Ark0N/Codeman/blob/master/docs/discord-bridge.md). +In short: + +1. Make a private Discord server with one text channel, and copy the server, channel and your user IDs. +2. Create an application with a bot. Turn **Public Bot** off, turn **Message Content Intent** on, and + invite it with the `bot` scope and only the permissions the guide lists (no Administrator). +3. In **App Settings → Notifications → Discord**, paste the bot token and the three IDs, press **Save**, + then **Send test**, then turn **Talk to your sessions from Discord** on and **Save** again. + +The token is a secret: it is kept in its own file (`~/.codeman/discord.json`, readable by its owner +only), never in the shared settings, and never shown again once saved. + +## Options + +| Option | Choices | Default | +| ---------------------------------------- | ------------------------------------------- | --------------- | +| Create threads | On the first alert, or for every new tab | First alert | +| When a tab is deleted | Keep, archive or delete the thread | Archive | +| Stale threads | Keep, archive or delete threads no tab uses | Keep | +| @ ping | Never, critical events only, every post | Critical only | +| Which events | Needs attention, or everything | Needs attention | +| Tell Claude how to reach you | On or off | On | + +A finished turn, and an error, is posted as the CLI's whole last message, with its formatting and questions +kept, split over several posts if it is long. A shell, and permission or question dialogs, show the last lines +of the screen instead. + +Only some CLIs raise a turn end. Claude Code does (an idle alert by default, and a stop alert when **Which +events** is Everything), and DeepSeek Harness sends a stop. The others (Codex, OMP, OpenCode, Gemini, +Antigravity, Pi, Grok, Copilot) send none, so their messages reach Discord with an error or `codeman notify`; +Codex's dialogs are read off its screen. + +The stale-thread setting also archives or deletes threads that **When a tab is deleted** kept or archived, because a +deleted tab's thread leaves the bridge's map. + +Deleting a thread needs the **Manage Threads** permission on the channel. "Critical" means a +permission prompt, a question, an error, or a post an agent marked urgent. + +## Telling the agents + +An agent only posts deliberately if it knows it can: + +- **Claude** is launched with the instruction as part of its system prompt (**Tell Claude how to reach you**, on by default), so nothing shows in the chat. This reaches local tabs only (remote and Docker tabs have no `codeman` command). +- **Every other CLI** is not told. It can still run `codeman notify` if you tell it to. + +Tabs that were already open when you turned the bridge on are not told. The alerts still reach +Discord for them; only the agent-initiated `codeman notify` habit is missing. + +## Files + +- **You to the agent:** attach a file to a reply. It is saved as + `<tab folder>/.codeman-discord/<message id>-<name>` and the line the agent sees ends with + `[attached: <path>]`. At most 5 files of 8 MB each. The folder ignores itself, so nothing in it is committed. +- **Agent to you:** `codeman notify "message" --file <path>` uploads a file of at most 8 MB that lies + inside the tab's folder. Credential files such as `.env` are refused. +- Remote (ssh) tabs have no local folder, so they neither send nor receive files. +- A file you send is untrusted content to the agent that opens it. + +## Safety + +- Only user IDs on the allowlist, in your server, inside a thread Codeman made, are acted on. + Everything else is dropped. +- A reply is cut to one printable line (1000 characters) and limited to one per second per tab. +- Posts can ping only the allowlisted users, so agent output cannot ping the whole server. +- A Discord outage or a bad token never affects a session; it backs off, and the last error shows in Settings. + +## Troubleshooting + +| Symptom | Check | +| ------------------------------------------------ | -------------------------------------------------------------- | +| Send test says 401 | The token is wrong or was reset. Paste the current one. | +| Send test says the bot cannot see it | Check the bot's role and the channel ID. | +| Posts arrive but replies do nothing | Message Content Intent is off, or your user ID is not allowed. | +| Status line says "Message Content Intent is off" | Turn it on in the Developer Portal, Bot tab. | +| A thread is not deleted | The bot lacks Manage Threads on the channel. | + +See also: [Notifications And Approvals](Notifications-And-Approvals) for the other ways to hear +from an agent, and [Security](Security) for how secrets are handled. diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 6b2b337f..cc00cd1d 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -41,9 +41,13 @@ act outside a Codeman session. One line in a case's `AGENTS.md` is enough: *"oth ```bash codeman agent ls # sessions; * marks this one SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait for the composer where the mode has a ready mark +SID=$(codeman agent spawn --working-dir ~/src/app --mode codex) # no case: an existing directory (cwd by default) codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 codeman agent read "$SID" # last answer (as the server reads it for that mode) codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode) +codeman agent inspect "$SID" --max-bytes 8192 --json # experimental, opt-in: recent evidence + opaque cursor +codeman agent stage "$SID" --message-file /tmp/prompt.txt --json # experimental, opt-in: no Enter; local Codex composer only +codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json # experimental, opt-in: one conditional Enter codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes: the marker in halves … codeman agent wait "$SID" --match WORKDONE_4711 # … and the wait on the joined form codeman agent interrupt "$SID" # a bare ESC, conversation intact @@ -53,6 +57,21 @@ codeman agent ack <msg-id> # remove what you hand codeman agent rm "$SID" # any session except this one ``` +- **`spawn [case]`** uses the current directory when no case is supplied. Use + `codeman agent spawn --working-dir /path/to/project --mode codex` for another existing + local directory. A case and `--working-dir` cannot be combined. An empty case name + (`spawn "$CASE"` with `CASE` unset) is never read as "no case": it goes to quick-start, + which refuses it (exit 1). An empty `--working-dir` is refused before any request + (exit 4), so it never falls back to cwd. + A cwd that is your home directory is refused the same way, because Codeman's hooks would + land in `~/.claude`; pass `--working-dir ~` to start a worker there on purpose. + Explicit case names retain quick-start behavior (creating a scratch case if missing). + A directory launch is `POST /api/v1/sessions` followed by + `POST /api/v1/sessions/:id/interactive` (or `/shell`); it preserves the project and + parent lineage, and never labels it as agent scratch. Choose a separate worktree for + each worker that will edit code. If startup fails or its response is lost, the CLI + reports the exact created session ID and exits `1`; inspect that session before + deciding what to do next. - **Ids** may be the 8-character form `ls` prints. Anything shorter refuses, and so does an ambiguous prefix. - **`send`** takes ONE quoted argument of printable text and presses Enter. A prompt that @@ -67,8 +86,49 @@ codeman agent rm "$SID" # any session except t `ls` shows each session's mail in an INBOX column. Mail lives in memory unless **Keep agent inbox across restarts** is on (see [Settings Reference](Settings-Reference)), and goes when its session is deleted. +- **`inspect`, `stage`, `submit`** are experimental and opt-in: they answer `403 FORBIDDEN` + naming the setting until **Experimental agent coordination** is on (see + [Settings Reference](Settings-Reference); `agentCoordinationEnabled`, synced, off by + default), and they may change in any release. `inspect` returns bounded evidence with an + opaque cursor. `stage` types one printable UTF8 line without trailing whitespace into a + local Codex composer, with no Enter, and returns a receipt that expires after two minutes + once the pane shows exactly that text; it refuses slash (`/`) and shell (`!`) commands + and a message whose last word starts with `@` or `$` (those open a Codex popup that Enter + would accept), so send commands with `send`. `submit` sends one Enter only if the + composer still shows exactly that text, and never replays it. `send --no-enter` alone has no receipt. Details: + [API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md#experimental-bounded-agent-inspection-and-guarded-input). - **Exit codes** are the same for every verb: `0` done, `1` error, `2` timeout, `3` the - worker exited, `4` refused. `--json` prints the response's `data`. + worker exited, `4` refused. `--json` prints the response's `data`, except a directory + `spawn`, which prints `{ sessionId, workingDir, created: true }`, plus `ready` when a + readiness wait ran, `readinessError` when that call failed, and `startupError` when the + start failed. + +## Reading saved Codex conversations + +`codeman history read` is the one command here that needs no server and no Codeman +session: it reads Codex's own saved rollouts (`$CODEX_HOME/sessions`, else +`~/.codex/sessions`) from disk and writes nothing. + +```bash +codeman history read --session-id 019fc604 # one conversation, by id or 8+ character prefix +codeman history read --root-session-id <uuid> --topic auth # it and every sub-agent or fork under it +codeman history read --file /path/to/rollout.jsonl --format text +``` + +- **Filters** keep whole user/assistant turns: `--topic` is a literal, case-insensitive + match, and `--after`/`--before` are inclusive UTC dates. +- **Forks and sub-agents**: a child's copy of its parent's history is dropped, so every + record belongs to the thread that wrote it. +- **`complete: false`** in the JSON means the evidence is partial: a store file that could + not be attributed, a compressed `.jsonl.zst` rollout (not read), a record a running + Codex is still writing, an ancestor not found. +- **Budgets** fail closed and are raisable: `--metadata-bytes` (attributing the store, + default 1 GiB), `--max-entries` (65536), `--scan-bytes` (the selected transcripts, + 16 MiB) and `--max-bytes` (output, 8192). `--json` and `--format text` together are an + error. + +The full reference ships with the skill as +[`reference/history.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/history.md). ## The MCP server diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 50c108bb..0efad3c4 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -42,7 +42,7 @@ codeman web # then open http://localhost:3000 **Driving it from code** - [The Codeman Skill](Codeman-Skill) - one prompt, a whole crew: an agent starting and supervising worker sessions. -- [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) - the `codeman agent` commands, the MCP server, wait primitives. +- [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) - the `codeman agent` commands, the MCP server, wait primitives, reading saved Codex conversations offline. - [HTTP API](HTTP-API) - the envelope, auth, the endpoint map, SSE events. - [Hooks And Integrations](Hooks-And-Integrations) - events flowing back into Codeman. @@ -79,6 +79,7 @@ codeman web # then open http://localhost:3000 | ------------------------------------------------------------- | --------------------------------------------------- | | [Keeping Agents Running](Keeping-Agents-Running) | How does it run unattended overnight? | | [Notifications And Approvals](Notifications-And-Approvals) | How do I know an agent needs me, and answer from my phone? | +| [Discord Bridge](Discord-Bridge) | How do I answer a blocked agent from Discord, in a thread per tab? | | [Cron Jobs](Cron-Jobs) | How do I run an agent on a schedule? | | [Autonomous Loops](Autonomous-Loops) | What are the Ralph and Orchestrator loops for? | | [Watching Agents Work](Watching-Agents-Work) | How do I see what the subagents are doing? | diff --git a/docs/wiki/Mobile-Guide.md b/docs/wiki/Mobile-Guide.md index a6171460..c090c6bf 100644 --- a/docs/wiki/Mobile-Guide.md +++ b/docs/wiki/Mobile-Guide.md @@ -153,6 +153,12 @@ already writing in the editor. - The terminal and toolbar shift up when the keyboard opens, tracked through the browser's visual viewport rather than guessed. +- **Opening the keyboard does not resize a Claude session.** Each resize made Claude reprint + its screen at the new height and drop transcript lines from the session's history, so for a + local Claude session the terminal keeps its size while the keyboard is up and the view + scrolls instead, with the prompt and the line under it just above the keyboard. Codex, + Claude's fullscreen mode, remote and Docker sessions still resize. **Keep Size Under + Keyboard** in App Settings turns it off on this device. - **Two ways to dismiss the keyboard**: tap outside the terminal on inert space, or tap twice on inert terminal content. Tapping a control never dismisses it, and tapping the prompt row keeps focus so you can place the caret. diff --git a/docs/wiki/Notifications-And-Approvals.md b/docs/wiki/Notifications-And-Approvals.md index fcdd7f93..50dad790 100644 --- a/docs/wiki/Notifications-And-Approvals.md +++ b/docs/wiki/Notifications-And-Approvals.md @@ -13,6 +13,7 @@ opening the session. | Desktop notification | Another window on the same machine | Opt-in | | Push notification | Anywhere, even with no tab open | Opt-in | | Webhook (ntfy, Slack, Discord) | Anywhere, with no browser or subscription at all | Opt-in | +| Discord bridge (two-way) | Anywhere, and you can answer in a thread per tab | Opt-in | | Approvals Inbox | One queue for Claude, Codex and DeepSeek prompts | Opt-in | | Phone overview | Phone home screen, NEEDS YOU section | On | | Away Digest | Afterwards, as a summary | Opt-in | @@ -66,6 +67,11 @@ Setup: Once subscribed, a blocking prompt reaches your phone even from a locked screen. +## Discord bridge: answer from your phone + +A webhook is one-way. To also **reply** to a blocked agent, use the [Discord Bridge](Discord-Bridge): a +thread per tab, your reply typed into that tab, files in both directions. + ## Webhooks: ntfy, Slack, Discord **Opt-in, off by default. One channel for the whole server.** diff --git a/docs/wiki/Remote-Access.md b/docs/wiki/Remote-Access.md index 4cba2a6c..49e577cb 100644 --- a/docs/wiki/Remote-Access.md +++ b/docs/wiki/Remote-Access.md @@ -115,8 +115,10 @@ There is also a toggle in **App Settings → System → Remote access**. **The tunnel refuses to start without a password.** That is on purpose: a public URL with no authentication is a terminal on your machine handed to the internet. Acknowledging the risk -explicitly is possible from the UI toggle, and only from there; the API will not do it for -you. +explicitly is possible from the UI toggle, which asks you every time, or for the whole server +with `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` (or `codeman web --allow-unauthenticated-tunnel`). +`CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK`, which the installer sets for a LAN install without a +password, does not count. ## LAN plus password diff --git a/docs/wiki/Security.md b/docs/wiki/Security.md index c3686143..678e1ff6 100644 --- a/docs/wiki/Security.md +++ b/docs/wiki/Security.md @@ -20,7 +20,8 @@ In order of how much they matter: 1. **Do not expose it without `CODEMAN_PASSWORD`.** Binding a non-loopback host without one starts, but warns loudly. A tunnel refuses outright unless you acknowledge the exposure - in the UI. + in the UI, which asks every time, or set `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL`. The + installer's `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` covers the LAN bind only. 2. **Prefer Tailscale over a public tunnel.** Keeping the loopback bind and putting a private network in front of it removes the public attack surface entirely, and gives you real HTTPS. See [Remote Access](Remote-Access). diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 19d1676b..0ba55368 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -21,7 +21,7 @@ desktop. | Category | Examples | | ----------------------- | ------------------------------------------------------------------------------- | -| **Per-device, local** | Skin, WebGL renderer, local echo, CJK input, extended keyboard bar, File Viewer and Cron header buttons. Never sent to the server at all. | +| **Per-device, local** | Skin, WebGL renderer, local echo, keep size under keyboard, CJK input, extended keyboard bar, File Viewer and Cron header buttons. Never sent to the server at all. | | **Per-device policy** | Most `show*` toggles, plan usage chip, language. Stored server-side, but a device only takes the server value when it has no local one of its own. | | **Synced** | Models, effort, CLI options, notification preferences, voice settings, display name, the agent skill and approvals toggles. | @@ -46,6 +46,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See | Setting | Default | Notes | | ----------------------------- | -------------------- | --------------------------------------------------------------------- | | Local Echo | On for touch devices | Paints keystrokes locally and flushes on Enter. See [Input And Voice](Input-And-Voice). | +| Keep Size Under Keyboard | On | For local Claude sessions, the on-screen keyboard no longer resizes the session: the terminal keeps its rows and the view scrolls so the prompt sits just above the keyboard. Resizing made Claude reprint its screen and drop transcript lines from history. Codex, Claude's fullscreen mode, remote and Docker sessions resize as before. See [Mobile Guide](Mobile-Guide). | | CJK Input | Off | IME composition through a dedicated text field. | | Extended Keyboard Bar | Per device | Which accessory bar phones get. Shell sessions override it while they are active. | | Wheel Scrolls Local History | Off | Keeps the wheel on the local buffer instead of forwarding it to the CLI. | @@ -61,7 +62,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See Chips for every optional header control, with a live preview of the resulting header: Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Away Digest, Session -Manager, Attachments, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor, +Manager, Attachments, Edit history, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor, Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents, Ultracode Windows, Cron. @@ -84,8 +85,32 @@ by folder** (per device, on by default) shows changed files under collapsed fold window; off lists every file by its full path. **Git status: max repositories** (per device, 1 to 50, default 12) is how many repositories the window lists when a session's folder holds several projects. **Git status: git timeout** (per device, 5 to 120 seconds, default 30) is how -long one git command may run before that repository is reported as unreadable; raise it for -repositories on a slow network share. +long one git command may run before that repository is reported as unreadable, and the same value +bounds the Git window's Fetch button; raise it for repositories on a slow network share. + +**Git fetch on an interval** (synced, off by default) runs `git fetch` on a timer for the repositories the +Git window lists, so the `behind` number is current without a click, and **Git fetch: how often** (synced, +30 to 3600 seconds, default 300) is the gap between runs for a session that has no override of its own, timed from +the start of a run, so a slow repository does not push the next one out. These are the only Git settings +that are synced rather than per device, because the server's scheduler is what acts on them: kept per +device, the scheduler would read nobody's number. **Session Options → Git** overrides both the switch and +the interval for one session, and that override wins even while the app switch is off; a session that follows +the app shows the app's values and its rows go inert until you turn **Follow the app setting** off. Nothing is +ever fetched for a Docker or remote (SSH) session, and a repository a container can write to is refused +before any git runs. Sessions open on the same folder share one run, and a folder is fetched when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins. Both default to off because `git fetch` +is the one git command the Git window runs that writes (cloning and worktree management write too, but only +when you asked for them at the moment you asked), and the one that can run a repository's own hooks +(`reference-transaction`, `pre-auto-gc` through the maintenance a fetch triggers, and config commands such as +`credential.helper`), and the one that can run later by itself, on a timer. The ssh it runs is BatchMode so +nothing waits for a login, and that is a default, never an override: your own `GIT_SSH_COMMAND`, or the +repository's `core.sshCommand`, is used when either is set. ⚠️ The default's `StrictHostKeyChecking=accept-new` +records an unknown host key in `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. + +**Edit history** (per device, off by default, never on phones) adds a header button for the +active session's edit history: every edit the agent and its subagents made, with its diff, and +images it produced. Local Claude sessions only. Nothing is read until you open the window, and it +stores those diffs (secrets included) under `~/.codeman/edit-history/`; see +[Working With Files](Working-With-Files#edit-history). Most default to off. The stock desktop header is system stats, File Viewer, Tiles, and the gear. **Header Stats Style** picks how the system stats and plan usage are drawn: *Compact* @@ -106,6 +131,14 @@ memory and a restart empties it; switching it off deletes the file and keeps wha memory. Messages expire 12 hours after they were posted either way. See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent#the-codeman-agent-commands). +**Experimental agent coordination** (under **Cross-session features**, synced, off by +default) turns on the `codeman agent inspect`, `stage` and `submit` commands: bounded, +paged reads of another session's output, and typing a prompt into a local Codex composer +that is sent with one Enter only after Codeman checks it is unchanged. Off, their HTTP +routes answer `403 FORBIDDEN` naming the setting (`agentCoordinationEnabled`). They are +experimental and may change in any release. See +[Driving Codeman From An Agent](Driving-Codeman-From-An-Agent#the-codeman-agent-commands). + ### Appearance | Setting | Notes | @@ -121,6 +154,7 @@ memory. Messages expire 12 hours after they were posted either way. See | Tall Tabs | Taller tab strip. | | CLI Logos on Tabs | Each agent tab, and its row on the desktop home rail, shows the CLI's logo before the name. Off hides those logos on this device; the status dot and the shell's SH badge stay, and tiles, split headers and the Run menus keep their logos. On by default. | | Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. | +| New Session Button on Tabs | Adds a **+** beside each tab's gear: pick a CLI the tab's case can start and one session starts in that case (or worktree) and named group, leaving the Run button alone. The row menu offers it as *New session here…*. Per device, off by default. See [The Dashboard](The-Dashboard#session-tabs). | | Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. Desktop only, on by default. | | Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). | | Spawned Tabs Follow Their Parent | A session started by another one (an agent's worker, a `parentSessionId` spawn) follows its parent's tab into its group and moves with it until you move it by hand; the row menu then says where it sits and offers *Follow parent again*. Off, it starts right after its parent and stays put. Decided when the tab is created, so flipping it leaves existing tabs alone. Synced, off by default. | @@ -185,8 +219,9 @@ how long a desktop notification stays up before Codeman closes it (**Browser notification display time**, default 8; both per device, and your OS may close a desktop notification sooner), the idle threshold that decides when a quiet session counts as needing you, and the server-wide -webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See -[Notifications And Approvals](Notifications-And-Approvals). +webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode), and the +Discord group for the two-way bot (token, IDs, thread and ping options; see +[Discord Bridge](Discord-Bridge)). See [Notifications And Approvals](Notifications-And-Approvals). ### Voice @@ -244,7 +279,8 @@ Some things are configured before the server starts, not in the UI: | `CODEMAN_GESTURE` | Makes gesture control available to be enabled. | | `CODEMAN_DOCKER_BRIDGE_HOOKS` | Lets in-container hooks reach the host on a loopback bind. | | `CODEMAN_FILE_PICKER_ROOTS` | Extra roots for the path picker. | -| `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledges exposing the server with no password. | +| `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledges binding a non-loopback address with no password. Does not cover the tunnel. | +| `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL` | Lets the Cloudflare tunnel start with no password (`--allow-unauthenticated-tunnel` on `codeman web`). The browser still asks first. | | `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). | | `CODEMAN_MAX_SESSIONS` | Cap on concurrent sessions. 50 by default, 1 to 462 (a larger value is clamped to 462, anything that is not a positive integer falls back to 50). Read at startup. The 462 assumes the default `CODEMAN_MAX_WEBVIEWS` and no pinned closed sessions, which share the same 512-entry tab layout. In multi-user mode the per-user cap defaults to half of it unless `CODEMAN_MAX_SESSIONS_PER_USER` is set. | | `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. | diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index 47854b09..1a67c412 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -77,6 +77,14 @@ An agent tab shows its CLI's logo before the name, and a shell tab an `SH` badge on Tabs** (App Settings → Appearance → Tabs) hides the logos on that device; the tile and split headers and the Run menus keep theirs. +**New Session Button on Tabs** (same place, per device, off by default) adds a **+** beside +the tab's gear. It lists the CLIs that tab's case can start, each with its logo and name (a +Docker case lists what its container has), and a pick starts one session in that tab's case, +or the worktree it runs in, and in its named group. The Run button keeps its own mode and case. +Wherever a tab has a row menu (the vertical rail, the sidebar, `Shift+F10` on a grouped row), +the same list is its **New session here…** entry. From a tab outside any named group, the new +session lands in the case's default group if the case has one, as a Run from the toolbar does. + **Status is carried by the dot and the tab's own styling:** | Look | Meaning | diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md index 0be064d7..9486ac69 100644 --- a/docs/wiki/Working-With-Files.md +++ b/docs/wiki/Working-With-Files.md @@ -87,7 +87,10 @@ detected at once would fork ten multi-minute converter processes. File paths in a session are links. That works in two places: -- **In the terminal**, on any absolute path an agent prints. +- **In the terminal**, on any path an agent or a command prints. A relative path (`src/app.ts`, + `../notes.md`) resolves against the directory the session's pane is in now, so after a `cd` + in a shell it opens the file there; until the pane reports a directory, and always for remote + SSH and Docker cases, it resolves against the session's working directory. - **In the response viewer**, where paths are usually written as prose or in backticks. They render as underlined monospace links. A Markdown link whose target is a path (`[report](/tmp/run/report.json)`, the way Codex cites files) opens the same way. @@ -168,6 +171,11 @@ choice is remembered per device. The picker inserts the path into your prompt **without** pressing Enter, so nothing is submitted by accident. Its sibling **⌫ All** key clears the unsent prompt, and never sends the agent's `/clear` command. +In **Link Existing**'s picker, **+📁** (New folder) makes a folder inside the folder you +are viewing and selects it. Names use letters, numbers, spaces and `_ - . ~` (not starting +with a dot or `~`). The cases folder is refused, since every folder there is already a +case: use **Add Case → New Case** instead. + This is a separate file-serving surface from the viewer, with its own rules: it allowlists your home directory, the cases directory, and anything in `CODEMAN_FILE_PICKER_ROOTS`, and blocks sensitive trees. In multi-user mode a non-admin gets only their own user space as a @@ -245,14 +253,87 @@ Click it for a draggable window, in the style of the File Viewer: left out; the **git timeout** setting raises how long it waits. They all start collapsed (each summary line shows its branch and what is outstanding), and the ones you open stay open when the window refreshes; an unrelated repository above the workspace (a dotfiles repo in your home folder) is ignored. +- **Fetch** in the header runs `git fetch` for the repositories the window lists, on your click; the + window then re-reads, so "behind" is as of that fetch. A repository that could not be fetched keeps + its row and shows why. **Git fetch on an interval** (Settings → Bottom bar, off by default) runs the + same fetch on a timer for every session that is on, and **Session Options → Git** overrides both the + switch and the interval for one session: a session that turned itself on runs even while the app-wide + switch is off, sessions open on the same folder share one run, and nothing is ever fetched for Docker or + remote (SSH) sessions. -It is read-only and offline: Codeman never fetches, commits or changes the repository, so -"behind" is as of your last fetch. It is not shown for Docker or remote (SSH) sessions, and a repository at or inside a Docker case -workspace is skipped even from a local session (a container can write there, and git would run -that repository's own configuration on the host). The -data comes from `GET /api/sessions/:id/git-status` and `GET /api/sessions/:id/git-diff` +It is read-only apart from that fetch: Codeman never commits, checks out or changes the +repository, and `git fetch` writes remote-tracking refs only; it is the only git command the Git window +runs that writes, the only one that can run a repository's own hooks (`reference-transaction`, +`pre-auto-gc` through the maintenance a fetch triggers, and config commands such as `credential.helper`), +and the only one that can run later by itself, on a timer: cloning and worktree management write too, but +only when you asked for them at the moment you asked, which is why nothing runs on a timer until you opt in. A fetch holds git's ref lock, so an agent's own `git pull` in the same repository +can hit the same `cannot lock ref`, and a folder is fetched by one run at a time. It is not shown for Docker +or remote (SSH) sessions, and a repository at or inside a Docker case +workspace is skipped even from a local session, before any git runs (a container can write there, +and git would run that repository's own configuration on the host). Fetch asks nothing of you: it +uses the sign-in your own git already has, and it gives up quickly rather than waiting on an +invisible login prompt. That BatchMode ssh is a default, never an override: your own +`GIT_SSH_COMMAND`, or the repository's `core.sshCommand`, is used when either is set, and ⚠️ the default's +`StrictHostKeyChecking=accept-new` records an unknown host key in `~/.ssh/known_hosts` without asking: +BatchMode stops a prompt, not a write. The +data comes from `GET /api/sessions/:id/git-status`, `GET /api/sessions/:id/git-diff` and, on that +click or the interval you turned on, `POST /api/sessions/:id/git-fetch` (see the [API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)). +## Edit history + +The Git panel shows what is different now. The edit history shows what the agent did, one edit +at a time, including edits that were later undone or committed. Turn on **App Settings → Header & +Panels → Header → Edit history** (per device, off by default) and a clock button appears in the +header next to Attachments. It is not shown on phones. + +Click it for a draggable window listing the active session's edits, newest first: + +- One row per Edit or Write, with `new` for a file the agent created, `edit` otherwise, + and the lines added and removed. **Click a row** to see that edit's diff inline, coloured like the + Git panel; a diff over 64 KB is cut short. **Click the path** to open the file in the preview. +- Edits made by subagents carry a chip with the subagent's task; hover it for the agent type and + its worktree branch. **Main agent only** hides them. **Group by file** puts the edits under + their file, with the totals on the file's row. +- Images an agent produced with a shell command (a render, a plot, a screenshot) appear as rows with + a thumbnail: a `.png`, `.jpg`, `.gif`, `.webp`, `.avif` or `.svg` path named in the command or + its output counts when the file's modification time falls within that command's run. Images the + agent sent you count as well. Images outside the workspace and SVGs show no thumbnail. Click one + to open it in the preview. +- **Load older** reads further back in the conversation, in the main agent's transcript and the + subagents' alike (up to three transcripts per click; with more, they take turns). +- After `/clear` or a resume, the edits of the earlier conversation stay listed, and the window + first catches up on the ones it had not read yet. **Load older** then goes back within the new + conversation. + +It works for local Claude sessions only. Remote (SSH), Docker and other CLIs' sessions say so instead. + +How it reads: + +- **Only while you look.** The history is read from the session's Claude transcript when you open + the window, and again after each tool call that can change files and at the end of each turn, + while it stays open on that session. A closed window reads nothing, and Codeman does no work in + the background for sessions nobody looks at. +- **The transcript is found through the session's hooks.** Each prompt tells Codeman where the + session's transcript is. Right after a server restart, before the next prompt, Codeman looks in + the session's `CLAUDE_CONFIG_DIR` if you set one for it in Codeman, else in the default config + directory. A `CLAUDE_CONFIG_DIR` set outside Codeman (in a shell profile or a wrapper script) + is only known after the next prompt; until then the window says "No transcript found for this + session yet". +- **The history is stored, and it holds secrets.** Every diff an agent wrote is copied to + `~/.codeman/edit-history/<session-id>.json`, including API keys or `.env` contents it edited. The file is + readable by your user only, capped at 500 edits and 4 MiB of diff text per session (the oldest + are dropped), never part of search, blocked from every file route, and deleted when you delete + the session (a file left behind, by a crash for instance, goes at the next start). A pinned session you kill keeps its record and its history until you unpin and + delete it. +- **Images go through the existing file routes.** A thumbnail inside the workspace uses the File + Viewer's route. An image outside it opens like any clicked path outside the workspace (no + notification, same file-type allowlist and sensitive-file blocklist). No new route serves a file. + +The data comes from `GET /api/sessions/:id/edits`, `GET /api/sessions/:id/edits/:entryId` and +`POST /api/sessions/:id/edits/older` (see the +[API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)). + ## Gotchas - **The viewer follows the active session's workspace.** Switching tabs changes what you are diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 0b85b925..36a04909 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -25,6 +25,7 @@ - [Unattended Runs](Keeping-Agents-Running) - [Notifications & Approvals](Notifications-And-Approvals) +- [Discord Bridge](Discord-Bridge) - [Cron Jobs](Cron-Jobs) - [Autonomous Loops](Autonomous-Loops) - [Watching Agents Work](Watching-Agents-Work) diff --git a/package-lock.json b/package-lock.json index 89c76245..ff1b96da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aicodeman", - "version": "1.45.0", + "version": "1.48.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aicodeman", - "version": "1.45.0", + "version": "1.48.0", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -63,7 +63,7 @@ "exceljs": "4.4.0", "fflate": "0.8.3", "pixelmatch": "^6.0.0", - "playwright": "^1.58.0", + "playwright": "^1.64.1", "pngjs": "^7.0.0", "postcss": "^8.5.15", "prettier": "^3.4.0", @@ -9736,50 +9736,32 @@ } }, "node_modules/playwright": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", - "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.60.0" + "playwright-core": "1.64.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" + "node": ">=20" } }, "node_modules/playwright-core": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", - "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" - } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=20" } }, "node_modules/pngjs": { @@ -13389,7 +13371,7 @@ } }, "packages/xterm-zerolag-input": { - "version": "0.4.1", + "version": "0.4.2", "license": "MIT", "devDependencies": { "@xterm/headless": "^6.0.0", diff --git a/package.json b/package.json index ec697e96..d468ef30 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "1.45.0", + "version": "1.48.0", "description": "Self-hosted mission control for AI coding agents: run Claude Code, Codex, OpenCode, Gemini, DeepSeek, Grok and more 24/7 in tmux, from any device.", "mcpName": "io.github.Ark0N/codeman", "type": "module", @@ -133,7 +133,7 @@ "exceljs": "4.4.0", "fflate": "0.8.3", "pixelmatch": "^6.0.0", - "playwright": "^1.58.0", + "playwright": "^1.64.1", "pngjs": "^7.0.0", "postcss": "^8.5.15", "prettier": "^3.4.0", diff --git a/packages/xterm-zerolag-input/CHANGELOG.md b/packages/xterm-zerolag-input/CHANGELOG.md index 016f936f..627449b9 100644 --- a/packages/xterm-zerolag-input/CHANGELOG.md +++ b/packages/xterm-zerolag-input/CHANGELOG.md @@ -1,5 +1,43 @@ # xterm-zerolag-input +## 0.4.2 + +### Patch Changes + +- de1aab9: ### Thanks + - @JDProfresh for five PRs: a phone keyboard that no longer costs Claude its transcript (#711, measured in #672), a + on each session tab that starts a session beside it (#712, shaped in Discussion #700), a tunnel that needs its own opt-in instead of the LAN one (#718, from their report #706), a settings.json that two writers can no longer tear (#719), and a DeepSeek test that passes on current Node (#710). + - @aakhter for six PRs: `codeman agent spawn` in a project directory (#708), worker ids kept when the readiness check fails (#709), the offline Codex history reader (#707), experimental bounded inspection and guarded submission for agents (#694, from Discussion #695), links that follow URLs and paths across wrapped rows and table cells (#686), and file paths with spaces linked as one path (#687). + - @opticon454 for the Fetch button and fetch interval in the Git status window (#714). + - @johoja12 for Cmd+K arrows that switch the CLI both ways after you type a case name (#717). + + **Fetch from the Git status window (#714).** The Git status window has a Fetch button next to Refresh. It runs `git fetch` for the repositories it lists and shows each one's result. An optional interval runs it for you: off by default, app-wide in App Settings, Bottom bar, with a per-session override in Session Options, Git. A fetch only moves remote-tracking refs (HEAD, the index and the working tree stay as they are), it never runs for a Docker or remote session, and it is bounded in time, output and concurrency. + + **Start a session beside any tab (#712, opt-in).** Turn on **New Session Button on Tabs** (App Settings, Tabs) and each session tab gets a +: pick any CLI its case can run (a Docker case offers what its container has) and the new session starts in that case or worktree and that tab's group, without touching the toolbar's case or Run mode. The row menu offers the same as "New session here…" for the keyboard. + + **The phone keyboard no longer costs Claude its transcript (#711).** Every keyboard open and close used to resize the PTY, and Claude Code answers each height change by reprinting only the tail that fits, so lines above it were lost from tmux for good (25 of 100 in five keyboard cycles). A local Claude session now keeps its rows while the keyboard is up and slides the screen so the composer stays above the keyboard. On by default as **Keep Size Under Keyboard** (App Settings, per device); off restores the old resize. Fullscreen Claude (mouse tracking on) still resizes as before. + + **Typing stays smooth in a long prompt (#713).** The local echo overlay (the instant preview of what you type) rebuilt every character of the prompt on each keystroke, so on a phone-class CPU a 4,000-character prompt took most of a second per key. It now rebuilds only the line that changed, with one paint layer per line, so a keystroke costs the same however long the prompt is. Every character still sits on its terminal cell. This ships in `xterm-zerolag-input` too. + + **Links survive wrapping and spaces (#686, #687).** A URL or path a CLI wrapped inside a table cell or an indented paragraph now links whole instead of in fragments, URLs keep balanced parentheses (`…/Team-Space(QA)Archive/…`), a full-width table divider no longer glues onto the row below, and OSC 8 hyperlinks open through Codeman's own opener after a confirmation. A file path with spaces (`/mnt/c/OneDrive - Contoso/report.docx`, `Saved to: ~/My Docs/plan.md`) links as one path in the terminal and the response viewer when the line bounds it, and a shell prompt that prints its working directory no longer turns the whole command line into one link. + + **`codeman agent spawn` in a project directory (#708).** Without a case name, `spawn` starts the worker in the current directory, or in an existing directory given with `--working-dir`, through `POST /api/v1/sessions` and the mode's start route. Named cases still use quick-start. `--json` prints `{ sessionId, workingDir, created: true }` plus `ready`, `readinessError` or `startupError`. An empty `--working-dir`, a bare spawn from your home directory (it would install Codeman's hooks into `~/.claude`; pass `--working-dir ~` to mean it) and an unknown `--mode` are refused with exit 4 before any request. + + **Read saved Codex conversations offline (#707).** `codeman history read` reads Codex's saved rollouts without a Codeman server: by file, by Codex session id, or a whole conversation with its sub-agents and forks, filtered by topic or UTC date with whole turns kept. Reads and output have separate, raisable budgets, and anything it could not read or attribute (a compressed `.jsonl.zst` rollout, a file still being written) is reported through `complete: false` instead of failing the lookup. The codeman skill routes to it. + + **Experimental: bounded inspection and guarded submission for agents (#694, opt-in).** `codeman agent inspect` reads bounded, cursor-paged evidence from another session (a local Codex session's attributed rollout records, otherwise a labelled pane snapshot), and `agent stage` / `agent submit` type one line into a verified local Codex composer and later send exactly one Enter against a hash-confirmed receipt. Off by default behind **Experimental agent coordination** (App Settings, Cross-session features); while off the three routes answer `403 FORBIDDEN` naming the setting. These routes and verbs are experimental under the versioning policy and may change in a minor or patch release. During the few milliseconds of a guarded submit, other input is held and written in order afterwards; a tagged input POST in that window answers `409 CONFLICT` so its sender retries, and an untagged one (the phone keys, the Stop button) is held and answered once written. + + **Cmd+K arrows switch the CLI both ways (#717).** After typing a case name in Cmd+K, plain Left and Right now both change the New session row's CLI whenever the caret is at either end of the query (only Right did before). Inside the query the arrows move the caret as usual. + + **Fixes.** + - **Security: a LAN install's acknowledgement no longer opens a public tunnel (#718).** `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` (and `--allow-unauthenticated-network`), which the installer writes for every LAN install without a password, also let the Cloudflare tunnel start, so one click on the welcome screen's tunnel link published full terminal control to a public URL with no login and no confirm. It now covers the network bind only. An unauthenticated tunnel has its own opt-in, `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `codeman web --allow-unauthenticated-tunnel` (carried by `codeman web -d` and `codeman service install`), which the installer never writes; if you relied on the network variable to start the tunnel without a password, set one of those. With no password and no multi-user mode, the browser now asks before every tunnel enable, using the new `loginConfigured` field of `GET /api/tunnel/status`. Both acknowledgement variables and their flags are now listed as stable in the versioning policy. + - **settings.json can no longer be torn (#719).** A quick-start's `lastUsedCase` write was not awaited and not atomic, so it could overlap the next settings save and leave one write's bytes over the tail of the other, after which every reader saw `{}`. Every write to settings.json now goes through one serialized writer that writes a temp file and renames it into place, and a file that does not parse is refused with `409 CONFLICT` instead of being overwritten with only the writer's own keys. A case rename checks this before it ends any session. + - **A worker id is never lost (#709).** When `codeman agent spawn` created a worker but its readiness request failed, it exited without the new session id. It now prints the id (and `--json` carries it) and exits 1, so the caller can inspect or delete the worker. `codeman agent` and `codeman mcp` also name the error code (such as `ECONNREFUSED`) when a request fails with an empty message. + - **The DeepSeek transcript test passes on Node 24.22+ and 26.10+ (#710).** It pinned Node's old zstd decoder, which stopped at the first frame until nodejs/node#65865. + + **Fixes applied while landing.** The Git fetch interval field is no longer reset by a session update while you type in it, a session holding only half an override shows the app's value for the other half, reading a repository's `core.sshCommand` no longer doubles the fetch timeout, a `GIT_SSH` in the server's environment is honoured, and the Fetch button's disabled and busy styles no longer reach other icon buttons and stop pulsing under reduced motion (#714). A + launch keeps the toolbar on its own case, and pressing the + again closes its picker (#712). The fetch scheduler no longer logs the text of a malformed settings.json on every 15-second tick (the parser's message could quote an API key; it now says the file is not valid JSON, once), and a fetch is skipped while the Docker case list cannot be read, so it can never run inside a Docker workspace (#714). A case rename answers `409 CONFLICT` for a settings.json it cannot write before it ends any session (#719). The unauthenticated-tunnel warning no longer fires in multi-user mode, where the tunnel has a login in front of it (#718). Untagged input during a guarded submit is held instead of refused, and the skill's staging guide says so (#694). A shell prompt line (a spaced working directory followed by `$` included) and a labelled `../` spaced path link as they should, and a bold label or inline code before a path in the response viewer splits it as the terminal does (#687); a URL whose row soft-wraps no longer glues a trailing column onto the next path, and in a table a link in the column right of a wrapped URL stays clickable on the URL's first row (#686). The keyboard hold reads the terminal's height only while rows are held (#711). The README, the plugin's README and its marketplace description now say the codeman skill reads saved Codex conversations outside a Codeman session and refuses everything else there (#707). + + **Docs and tests.** New sections for the keyboard hold (next to the terminal geometry rule it is an exception to), the tab +, Git fetch, the settings writer, the tunnel opt-in, the directory spawn, the offline history reader and the experimental agent routes, in CLAUDE.md, the API reference, the architecture notes, the wiki and the codeman skill. + ## 0.4.1 ### Patch Changes diff --git a/packages/xterm-zerolag-input/README.md b/packages/xterm-zerolag-input/README.md index 1129a8a8..135bfa09 100644 --- a/packages/xterm-zerolag-input/README.md +++ b/packages/xterm-zerolag-input/README.md @@ -8,8 +8,8 @@ <a href="https://www.npmjs.com/package/xterm-zerolag-input"><img src="https://img.shields.io/npm/v/xterm-zerolag-input?style=flat-square&color=22c55e" alt="npm"></a> <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-227-22c55e?style=flat-square" alt="175 tests"> + <img src="https://img.shields.io/badge/Size-5.7%20kB%20gzip-22c55e?style=flat-square" alt="5.7 kB minified and gzipped"> + <img src="https://img.shields.io/badge/Tests-276-22c55e?style=flat-square" alt="276 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> @@ -60,12 +60,13 @@ Since 0.2.0 the package ships **two addons for two kinds of TUIs**: | | | |---|---| | **Survives full-screen TUIs** | Ink, blessed, and friends repaint the whole screen constantly. The overlay is a separate DOM layer they cannot reach, so it does not get clobbered. | -| **Pixel-matched to the canvas** | Each character is its own absolutely-positioned `<span>` at exact cell coordinates, so it does not drift out of the grid like normal DOM text flow. | +| **Pixel-matched to the canvas** | Each character is its own `<span>`, exactly as wide as its cells and snapped to the grid, so it does not drift out of the grid like normal DOM text flow. | +| **Cheap per keystroke** | Only the line that changed is rebuilt, and each line is one paint layer, so a keystroke costs the same in a 4,000-character prompt as in a short one. | | **Wide characters included** | CJK, fullwidth forms and emoji render double-width and position by visual column, using the terminal's Unicode addon when one is loaded. | | **Backspace that actually works** | A three-layer cascade (unsent, in-flight, already on screen) tells you exactly what to forward to the PTY, so editing works through any mix of typed, flushed and tab-completed text. | | **You keep control of input** | The addon never hooks `onData` for you. You decide what gets echoed and what gets forwarded, which is what makes char-at-a-time, buffered, and multi-session tab switching all possible. | -| **Small and self-contained** | 6.1 kB gzipped, zero runtime dependencies, dual CJS/ESM with full type declarations. | -| **Proven under load** | Extracted from [Codeman](https://getcodeman.com), hardened over thousands of hours of real remote and mobile usage, 175 tests over every state transition. | +| **Small and self-contained** | 5.7 kB minified and gzipped, zero runtime dependencies, dual CJS/ESM with full type declarations. | +| **Proven under load** | Extracted from [Codeman](https://getcodeman.com), hardened over thousands of hours of real remote and mobile usage, 276 tests over every state transition. | Built for anything that puts a terminal behind a network hop: SSH web clients, cloud IDEs, mobile terminals, Kubernetes and container consoles, remote agent dashboards, browser-based dev environments. @@ -128,7 +129,7 @@ This library answers all three: - a **DOM overlay** on its own z-index layer, which Ink cannot touch - **bottom-up buffer scanning** for the prompt instead of trusting the cursor -- **one absolutely-positioned `<span>` per character** at exact cell-grid coordinates +- **one `<span>` per character**, sized to its cells and snapped to the cell grid --- @@ -205,6 +206,7 @@ Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not** | `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character and drop any IME composition. See [backspace handling](#backspace-handling). | | `clear()` | `void` | Clear all state, the composition included, and hide the overlay. Call on Enter, Ctrl+C, Escape. | | `setComposition(text)` | `void` | Show text an IME is still composing as an underlined tail after the typed text. Pass `''` to remove it. See [IME composition](#ime-composition). | +| `setRowLimit(limit)` | `void` | Narrow the row bound the overlay keeps itself on screen with (normally `terminal.rows`) to the first `limit` screen rows, so it grows upward from the last of them; `null` restores all rows, and a limit above `terminal.rows` changes nothing. For hosts that keep the terminal taller than the box showing it, e.g. while an on-screen keyboard covers the bottom. | ### Backspace handling @@ -508,15 +510,23 @@ div.xterm-screen (position: relative) ### Per-character grid alignment -Each character is an absolutely-positioned `<span>`: +Each line is an absolutely-positioned `<div>` at its row, and each character is a `<span>` in a flex row inside it, as wide as the cells it covers: ``` -left = visualColumn * cellWidth (CSS pixels) -top = lineIndex * cellHeight (CSS pixels) -width = cellWidth * charCellWidth (1 cell, or 2 for wide characters) +line top = lineIndex * cellHeight (CSS pixels) +edge(col) = round(col * cellWidth * 4) / 4 (snapped to a quarter pixel) +span width = edge(col + charCellWidth) - edge(col) (1 cell, or 2 for wide characters) ``` -Positioning by visual column instead of letting the browser lay out text is what removes sub-pixel drift. +A span starts where the widths before it add up to. Normal DOM text drifts because glyph advances that the layout engine cannot store exactly carry their rounding into every later cell. A quarter pixel is stored exactly by Blink and WebKit (1/64 px layout units) and Gecko (1/60 px app units), so the snapped widths always add up to the snapped edge: no drift at any column, and every edge stays within 1/8 px of `col * cellWidth`. + +The spans are deliberately not absolutely positioned. A positioned element gets its own paint layer, and with one per character the browser had to re-layerize and repaint thousands of them on every keystroke of a long prompt. The font, colour, line height, centring and ligature settings are set once per line and inherited. + +### Rendering cost + +Each render compares every line with what the previous render left in the overlay and rebuilds only the lines whose text or position changed. Typing a character changes only the last line, so a keystroke costs one line of DOM work, layout and paint, however long the prompt. A font, colour, cell size or Unicode version change rebuilds everything, and so does anything else emptying the overlay element. + +Measured in headless Chromium with a 6x CPU slowdown standing in for a phone, typing into a prompt of about 4,100 characters: 0.4.x took a median 813 ms per frame (513 ms of script, style and layout per keystroke), this version 17 ms (8.6 ms). At about 300 characters, 0.4.x already missed 60 fps (54 ms frames). ### Font matching @@ -543,7 +553,7 @@ The overlay hides while the cursor row is scrolled out of the viewport and re-re ## Known limitations -- **Canvas and WebGL font mismatch**: minor sub-pixel differences are still possible. Per-character absolute positioning keeps them small. +- **Canvas and WebGL font mismatch**: minor sub-pixel differences are still possible. Per-character cells snapped to a quarter pixel keep them under 1/8 px. - **Grapheme clusters**: widths are summed per code point, so ZWJ emoji sequences (for example 👨‍👩‍👧) and combining marks can be over-counted. Single-code-point emoji and CJK are correct. - **Password prompts**: the overlay will happily show characters the server is not echoing. Call `clear()` when you detect a no-echo prompt. - **Prompt characters in output**: if your prompt marker also appears in command output, detection can latch onto the wrong line. Use a regex or a custom finder. diff --git a/packages/xterm-zerolag-input/package.json b/packages/xterm-zerolag-input/package.json index 955a1be8..ac77f48e 100644 --- a/packages/xterm-zerolag-input/package.json +++ b/packages/xterm-zerolag-input/package.json @@ -1,6 +1,6 @@ { "name": "xterm-zerolag-input", - "version": "0.4.1", + "version": "0.4.2", "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", diff --git a/packages/xterm-zerolag-input/src/overlay-renderer.ts b/packages/xterm-zerolag-input/src/overlay-renderer.ts index 7e8d7a07..baa1bab2 100644 --- a/packages/xterm-zerolag-input/src/overlay-renderer.ts +++ b/packages/xterm-zerolag-input/src/overlay-renderer.ts @@ -46,14 +46,61 @@ export function stringCellWidth(terminal: XtermTerminal | null | undefined, str: // ─── Overlay rendering ──────────────────────────────────────────────── +/** + * Cell edges are snapped to this fraction of a CSS pixel. + * + * The glyph spans sit in normal flow, so each one starts where the widths + * before it add up to, and a width the layout engine cannot store exactly + * would carry its rounding error into every later cell (the drift that plain + * DOM text shows against the canvas grid). A quarter pixel is exact in + * Blink and WebKit (1/64 px layout units) and in Gecko (1/60 px app units), + * so the widths always add up to the snapped edge: no drift at any column, + * and every edge is within 1/8 px of `col * cellW`. + */ +const EDGE_SNAP = 4; + +function cellEdge(col: number, cellW: number): number { + return Math.round(col * cellW * EDGE_SNAP) / EDGE_SNAP; +} + +/** What the last render left in a container, so the next one rebuilds only the lines that changed. */ +interface RenderedLines { + /** Everything a line's markup depends on besides its own text and position. */ + styleKey: string; + lines: HTMLDivElement[]; + lineKeys: string[]; + cursor: HTMLSpanElement | null; +} + +const rendered = new WeakMap<HTMLDivElement, RenderedLines>(); + +/** + * False once anything else has emptied or edited the container (the addon's + * hide path clears it): the children must be exactly the recorded lines, in + * order, then the cursor. + */ +function holdsOnlyRendered(container: HTMLDivElement, state: RenderedLines): boolean { + const children = container.children; + if (children.length !== state.lines.length + (state.cursor ? 1 : 0)) return false; + for (let i = 0; i < state.lines.length; i++) { + if (children[i] !== state.lines[i]) return false; + } + return !state.cursor || children[state.lines.length] === state.cursor; +} + /** * Render the overlay content into the container element. * - * Creates per-character `<span>` elements positioned on an exact grid - * matching xterm.js's canvas renderer. This avoids sub-pixel drift that - * occurs with normal DOM text flow. + * Each line is a `<div>` holding one `<span>` per character on the cell grid + * of xterm.js's canvas renderer (see makeLine). CJK wide characters get + * double-width spans. * - * CJK wide characters are rendered with double-width spans. + * Only lines whose content or position changed since the previous render + * into the same container are rebuilt. Typing a character changes just the + * last line, so a keystroke costs one line of DOM work however long the + * prompt is; rebuilding everything made each keystroke O(prompt length) in + * DOM work, layout and paint, which a long prompt on a phone paid for in + * dropped frames. */ export function renderOverlay(container: HTMLDivElement, params: RenderParams): void { const { @@ -94,11 +141,14 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams): // Code-point offset of each line in the whole text, so the composition // styling survives the tail slice below. const lineOffsets: number[] = []; + const lineLengths: number[] = []; { let offset = 0; for (const line of lines) { + const length = [...line].length; lineOffsets.push(offset); - offset += [...line].length; + lineLengths.push(length); + offset += length; } } let visibleLines = lines; @@ -120,8 +170,29 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams): container.style.left = '0px'; container.style.top = topRow * cellH + 'px'; - // Clear and rebuild (typically 1-3 line divs, negligible cost) - container.innerHTML = ''; + // Reuse the previous render's lines when nothing they depend on but their + // own text and position changed; otherwise start from an empty container. + const styleKey = [ + cellW, + cellH, + font.fontFamily, + font.fontSize, + font.fontWeight, + font.color, + font.backgroundColor, + font.letterSpacing, + terminal?.unicode?.activeVersion ?? '', + ].join('\n'); + let state = rendered.get(container); + if (!state || state.styleKey !== styleKey || !holdsOnlyRendered(container, state)) { + container.innerHTML = ''; + state = { styleKey, lines: [], lineKeys: [], cursor: null }; + rendered.set(container, state); + } + // Taken out while the lines change, so it is appended last again below. + state.cursor?.remove(); + state.cursor = null; + const fullWidthPx = totalCols * cellW; for (let i = 0; i < visibleLines.length; i++) { @@ -129,8 +200,16 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams): const leftPx = indents ? startCol * cellW : 0; const widthPx = indents ? fullWidthPx - leftPx : fullWidthPx; const topPx = i * cellH; - const lineCompositionFrom = - compositionStart === undefined ? undefined : compositionStart - lineOffsets[firstVisible + i]; + // Clamped to the line, so a line the composition does not reach keeps + // the same key (and its element) while the composition moves along. + let lineCompositionFrom: number | undefined; + if (compositionStart !== undefined) { + const from = compositionStart - lineOffsets[firstVisible + i]; + if (from < lineLengths[firstVisible + i]) lineCompositionFrom = Math.max(0, from); + } + // Text goes last, so no character in it can make two different lines collide. + const key = `${leftPx}|${topPx}|${widthPx}|${lineCompositionFrom ?? ''}|${visibleLines[i]}`; + if (state.lineKeys[i] === key) continue; const lineEl = makeLine( visibleLines[i], leftPx, @@ -144,8 +223,15 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams): terminal, lineCompositionFrom ); - container.appendChild(lineEl); + const previous = state.lines[i]; + if (previous) container.replaceChild(lineEl, previous); + else container.appendChild(lineEl); + state.lines[i] = lineEl; + state.lineKeys[i] = key; } + for (let i = state.lines.length - 1; i >= visibleLines.length; i--) state.lines[i].remove(); + state.lines.length = visibleLines.length; + state.lineKeys.length = visibleLines.length; // Block cursor at end of last line (use visual width for CJK support) if (showCursor) { @@ -161,6 +247,7 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams): cursor.style.height = cellH + 'px'; cursor.style.backgroundColor = cursorColor; container.appendChild(cursor); + state.cursor = cursor; } } @@ -168,13 +255,20 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams): } /** - * Create a styled line `<div>` with per-character grid positioning. + * Create a styled line `<div>` with one `<span>` per character on the cell grid. * - * Each character gets its own `<span>` positioned by visual column offset. - * CJK wide characters occupy 2 cell widths. Characters at or after - * `compositionFrom` (a code-point index into `text`, may be negative) are IME - * composition text: underlined, like xterm's own composition view, and marked - * `data-zerolag-composition` + `aria-hidden` since they are provisional. + * The spans sit side by side in a flex row, each exactly as wide as its cells + * (CJK wide characters occupy 2), with every edge snapped by cellEdge() so the + * widths add up to the grid without drifting. They are deliberately not + * absolutely positioned: a positioned element gets its own paint layer, and + * with one per character a long prompt left the browser re-layerizing and + * repainting thousands of them on every keystroke. Everything the spans share + * (font, colour, line height, centring, ligatures off) is set once on the + * line and inherited. + * + * Characters at or after `compositionFrom` (a code-point index into `text`) + * are IME composition text: underlined, like xterm's own composition view, and + * marked `data-zerolag-composition` + `aria-hidden` since they are provisional. */ function makeLine( text: string, @@ -190,7 +284,9 @@ function makeLine( compositionFrom?: number ): HTMLDivElement { const el = document.createElement('div'); - el.style.cssText = 'position:absolute;pointer-events:none'; + // No ligatures: the canvas renders each glyph independently. + el.style.cssText = + "position:absolute;display:flex;pointer-events:none;text-align:center;font-feature-settings:'liga' 0,'calt' 0"; el.style.backgroundColor = font.backgroundColor; el.style.left = leftPx + 'px'; el.style.top = topPx + 'px'; @@ -200,27 +296,24 @@ function makeLine( // The extra 1px lands in the next row's charTop gap (empty area before // text rendering starts), so no canvas content is obscured. el.style.height = cellH + 1 + 'px'; + el.style.lineHeight = cellH + 'px'; + el.style.fontFamily = font.fontFamily; + el.style.fontSize = font.fontSize; + el.style.fontWeight = font.fontWeight; + el.style.color = font.color; + if (font.letterSpacing) el.style.letterSpacing = font.letterSpacing; - // CJK wide chars occupy 2 cells — position by visual column offset - let colOffset = 0; + // Each span runs from the previous snapped edge to its own, so the spans + // land on the grid by visual column (CJK wide chars span 2 cells). + const spanHeight = ';height:' + cellH + 'px'; + let col = 0; + let edge = 0; let index = 0; for (const ch of text) { - const cw = charCellWidth(terminal, ch); + col += charCellWidth(terminal, ch); + const nextEdge = cellEdge(col, cellW); const span = document.createElement('span'); - // No ligatures — canvas renders each glyph independently. - span.style.cssText = - 'position:absolute;display:inline-block;text-align:center;pointer-events:none;' + - "font-feature-settings:'liga' 0,'calt' 0"; - span.style.left = colOffset * cellW + 'px'; - span.style.top = '0px'; - span.style.width = cw * cellW + 'px'; - span.style.height = cellH + 'px'; - span.style.lineHeight = cellH + 'px'; - span.style.fontFamily = font.fontFamily; - span.style.fontSize = font.fontSize; - span.style.fontWeight = font.fontWeight; - span.style.color = font.color; - if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing; + span.style.cssText = 'flex:none;width:' + (nextEdge - edge) + 'px' + spanHeight; if (compositionFrom !== undefined && index >= compositionFrom) { span.style.textDecoration = 'underline'; span.setAttribute('data-zerolag-composition', ''); @@ -228,7 +321,7 @@ function makeLine( } span.textContent = ch; el.appendChild(span); - colOffset += cw; + edge = nextEdge; index++; } diff --git a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts index 3993de8b..e1320a98 100644 --- a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts +++ b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts @@ -69,6 +69,9 @@ export class ZerolagInputAddon implements XtermAddon { private _bufferDetectDone = false; // IME text still being composed: drawn after the pending text, never sent. private _composition = ''; + // Rows from the top of the screen that are actually visible (see setRowLimit); + // null means all of them. + private _rowLimit: number | null = null; // Render cache private _lastRenderKey = ''; @@ -329,6 +332,35 @@ export class ZerolagInputAddon implements XtermAddon { } } + /** + * Bound the layout to the first `limit` rows of the screen, or to all of them + * with `null`. It narrows the bound the overlay already keeps itself on + * screen with (`totalRows`, see _layoutRows), never adds a second one. + * + * For a host that keeps the terminal taller than the box showing it and + * scrolls the screen element instead (e.g. holding the PTY's rows while an + * on-screen keyboard covers the bottom): the composer must grow upward from + * the last row the user can SEE, not from the terminal's last row, which is + * under the keyboard. + */ + setRowLimit(limit: number | null): void { + const next = limit !== null && Number.isFinite(limit) && limit > 0 ? Math.trunc(limit) : null; + if (next === this._rowLimit) return; + this._rowLimit = next; + this.rerender(); + } + + /** + * The `totalRows` bound handed to renderOverlay: the one place that decides + * where the block stops growing downward. The terminal's rows, narrowed by + * setRowLimit when the host shows fewer; a limit above the terminal's rows + * changes nothing. + */ + private _layoutRows(): number { + const rows = this._terminal!.rows; + return this._rowLimit !== null ? Math.min(this._rowLimit, rows) : rows; + } + /** * Re-read font properties from the terminal and re-render. * Call after font size changes, theme changes, etc. @@ -621,9 +653,11 @@ export class ZerolagInputAddon implements XtermAddon { // Skip redundant re-renders — include text content to detect // same-length changes (e.g., setFlushed with different text) // `rows` is part of the key: the layout is clamped to the visible rows - // (see renderOverlay), so a keyboard opening — which changes rows without - // changing the text — must not be skipped as a redundant render. - const renderKey = `${displayText}:${compositionStart}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`; + // (see renderOverlay), so a keyboard opening, which changes rows or the + // row limit without changing the text, must not be skipped as a + // redundant render. + const totalRows = this._layoutRows(); + const renderKey = `${displayText}:${compositionStart}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${totalRows}:${this._flushedOffset}`; if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return; this._lastRenderKey = renderKey; @@ -671,7 +705,7 @@ export class ZerolagInputAddon implements XtermAddon { charTop, charHeight, promptRow: activePrompt.row, - totalRows: this._terminal.rows, + totalRows, font: this._font, showCursor: this._options.showCursor, cursorColor, diff --git a/packages/xterm-zerolag-input/test/cjk-testplan.test.ts b/packages/xterm-zerolag-input/test/cjk-testplan.test.ts index 57b815e9..d13fa789 100644 --- a/packages/xterm-zerolag-input/test/cjk-testplan.test.ts +++ b/packages/xterm-zerolag-input/test/cjk-testplan.test.ts @@ -14,7 +14,7 @@ import { describe, it, expect, afterEach } from 'vitest'; import { renderOverlay, charCellWidth, stringCellWidth } from '../src/overlay-renderer.js'; import type { RenderParams, FontStyle } from '../src/types.js'; -import { createMockTerminal } from './helpers.js'; +import { createMockTerminal, lineSpans } from './helpers.js'; import { ZerolagInputAddon } from '../src/zerolag-input-addon.js'; // ─── Shared fixtures ───────────────────────────────────────────────── @@ -47,16 +47,7 @@ function makeParams(overrides: Partial<RenderParams> = {}): RenderParams { /** Extract span data from a rendered line div */ function getSpans(lineDiv: HTMLDivElement) { - const spans: { text: string; left: number; width: number }[] = []; - for (let i = 0; i < lineDiv.children.length; i++) { - const span = lineDiv.children[i] as HTMLSpanElement; - spans.push({ - text: span.textContent || '', - left: parseFloat(span.style.left), - width: parseFloat(span.style.width), - }); - } - return spans; + return lineSpans(lineDiv).map(({ text, left, width }) => ({ text, left, width })); } // ─── Addon setup helpers ───────────────────────────────────────────── @@ -199,18 +190,27 @@ describe('Test 2: CJK double-width spacing', () => { it('CJK span widths are exactly 2 * cellW pixels', () => { const container = document.createElement('div'); - renderOverlay(container, makeParams({ lines: ['你'], cellW: 8.4 })); + renderOverlay(container, makeParams({ lines: ['你'], cellW: 8.5 })); const lineDiv = container.children[0] as HTMLDivElement; const span = lineDiv.children[0] as HTMLSpanElement; - expect(span.style.width).toBe('16.8px'); // 2 * 8.4 + expect(span.style.width).toBe('17px'); // 2 * 8.5 }); it('ASCII span widths remain 1 * cellW pixels', () => { const container = document.createElement('div'); - renderOverlay(container, makeParams({ lines: ['a'], cellW: 8.4 })); + renderOverlay(container, makeParams({ lines: ['a'], cellW: 8.5 })); const lineDiv = container.children[0] as HTMLDivElement; const span = lineDiv.children[0] as HTMLSpanElement; - expect(span.style.width).toBe('8.4px'); // 1 * 8.4 + expect(span.style.width).toBe('8.5px'); // 1 * 8.5 + }); + + it('at a fractional cell width, CJK and ASCII spans still cover 2 and 1 cells to within 1/8 px', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['你a'], cellW: 8.4 })); + const [wide, narrow] = getSpans(container.children[0] as HTMLDivElement); + expect(Math.abs(wide.width - 2 * 8.4)).toBeLessThanOrEqual(0.125); + expect(Math.abs(narrow.left - 2 * 8.4)).toBeLessThanOrEqual(0.125); + expect(Math.abs(narrow.left + narrow.width - 3 * 8.4)).toBeLessThanOrEqual(0.125); }); it('terminal unicode addon is preferred over fallback when available', () => { @@ -644,8 +644,9 @@ describe('Test 6: ASCII input — no regression', () => { const spans = getSpans(lineDiv); expect(spans[0].left).toBe(0); - expect(spans[1].left).toBeCloseTo(8.4, 5); - expect(spans[2].left).toBeCloseTo(16.8, 5); + // Edges are snapped to a quarter pixel, so within 1/8 px of the grid. + expect(Math.abs(spans[1].left - 8.4)).toBeLessThanOrEqual(0.125); + expect(Math.abs(spans[2].left - 16.8)).toBeLessThanOrEqual(0.125); }); it('ASCII cursor positions at correct column', () => { diff --git a/packages/xterm-zerolag-input/test/composition.test.ts b/packages/xterm-zerolag-input/test/composition.test.ts index ceacfc46..9a06b708 100644 --- a/packages/xterm-zerolag-input/test/composition.test.ts +++ b/packages/xterm-zerolag-input/test/composition.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach } from 'vitest'; -import { createMockTerminal } from './helpers.js'; +import { createMockTerminal, lineSpans } from './helpers.js'; import { ZerolagInputAddon } from '../src/zerolag-input-addon.js'; // setComposition(): IME text still being composed, drawn as an underlined tail @@ -68,8 +68,9 @@ describe('setComposition', () => { expect(span.style.textDecoration).toBe('underline'); } // Grid positions continue straight on from the pending text. - expect(spans[3].style.left).toBe(3 * CELL_W + 'px'); - expect(spans[4].style.left).toBe(4 * CELL_W + 'px'); + const cells = lineSpans(line); + expect(cells[3].left).toBe(3 * CELL_W); + expect(cells[4].left).toBe(4 * CELL_W); expect(overlay.style.display).toBe(''); }); @@ -79,9 +80,10 @@ describe('setComposition', () => { addon.setComposition('天気'); const spans = Array.from(lineDivs(overlay)[0].children) as HTMLSpanElement[]; expect(spans.map((s) => s.textContent).join('')).toBe('今日は天気'); - expect(spans[3].style.left).toBe(6 * CELL_W + 'px'); - expect(spans[3].style.width).toBe(2 * CELL_W + 'px'); - expect(spans[4].style.left).toBe(8 * CELL_W + 'px'); + const cells = lineSpans(lineDivs(overlay)[0]); + expect(cells[3].left).toBe(6 * CELL_W); + expect(cells[3].width).toBe(2 * CELL_W); + expect(cells[4].left).toBe(8 * CELL_W); }); it('does not touch pendingText, hasPending, flushed state or the state snapshot', () => { @@ -115,7 +117,8 @@ describe('setComposition', () => { expect(compositionText(overlay)).toBe('WXYZ'); const second = Array.from(lines[1].children) as HTMLSpanElement[]; expect(second.every((s) => s.hasAttribute('data-zerolag-composition'))).toBe(true); - expect(second[0].style.left).toBe('0px'); + expect(lineSpans(lines[1])[0].left).toBe(0); + expect(lines[1].style.left).toBe('0px'); }); it('keeps the composition styling when only the tail of a tall prompt fits', () => { diff --git a/packages/xterm-zerolag-input/test/helpers.ts b/packages/xterm-zerolag-input/test/helpers.ts index d2229576..878dffb9 100644 --- a/packages/xterm-zerolag-input/test/helpers.ts +++ b/packages/xterm-zerolag-input/test/helpers.ts @@ -204,3 +204,21 @@ export function createMockTerminal(opts: MockTerminalOptions = {}) { }, }; } + +/** + * The glyph spans of a rendered overlay line, with each one's left edge and + * width in px. The spans sit side by side in a flex row, so a span's left + * edge is the sum of the widths before it (what the browser lays out), not a + * style property of its own; jsdom does no layout, so this adds them up. + */ +export function lineSpans(lineDiv: Element): { text: string; left: number; width: number; el: HTMLSpanElement }[] { + const spans: { text: string; left: number; width: number; el: HTMLSpanElement }[] = []; + let left = 0; + for (const child of Array.from(lineDiv.children)) { + const el = child as HTMLSpanElement; + const width = parseFloat(el.style.width); + spans.push({ text: el.textContent || '', left, width, el }); + left += width; + } + return spans; +} diff --git a/packages/xterm-zerolag-input/test/overlay-renderer.test.ts b/packages/xterm-zerolag-input/test/overlay-renderer.test.ts index a95846a8..a83d0901 100644 --- a/packages/xterm-zerolag-input/test/overlay-renderer.test.ts +++ b/packages/xterm-zerolag-input/test/overlay-renderer.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { renderOverlay, charCellWidth, stringCellWidth } from '../src/overlay-renderer.js'; import type { RenderParams, FontStyle } from '../src/types.js'; +import { lineSpans } from './helpers.js'; const FONT: FontStyle = { fontFamily: 'monospace', @@ -38,7 +39,7 @@ describe('renderOverlay', () => { it('creates per-character spans in a line div', () => { const container = document.createElement('div'); - renderOverlay(container, makeParams({ lines: ['abc'] })); + renderOverlay(container, makeParams({ lines: ['abc'], cellW: 10 })); // Line div + cursor span expect(container.children.length).toBe(2); @@ -46,17 +47,43 @@ describe('renderOverlay', () => { const lineDiv = container.children[0] as HTMLDivElement; expect(lineDiv.children.length).toBe(3); // a, b, c - const spanA = lineDiv.children[0] as HTMLSpanElement; - expect(spanA.textContent).toBe('a'); - expect(spanA.style.left).toBe('0px'); + const [a, b, c] = lineSpans(lineDiv); + expect([a.text, a.left, a.width]).toEqual(['a', 0, 10]); + expect([b.text, b.left, b.width]).toEqual(['b', 10, 10]); + expect([c.text, c.left, c.width]).toEqual(['c', 20, 10]); + }); - const spanB = lineDiv.children[1] as HTMLSpanElement; - expect(spanB.textContent).toBe('b'); - expect(spanB.style.left).toBe('8.4px'); + it('lays the spans out in a flex row, not absolutely positioned (one paint layer per line)', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['ab'] })); + const lineDiv = container.children[0] as HTMLDivElement; + expect(lineDiv.style.position).toBe('absolute'); + expect(lineDiv.style.display).toBe('flex'); + for (const { el } of lineSpans(lineDiv)) { + expect(el.style.position).toBe(''); + expect(el.style.left).toBe(''); + // flex: none, so a span is never stretched or squeezed off its cells. + expect(el.style.flexGrow).toBe('0'); + expect(el.style.flexShrink).toBe('0'); + } + }); - const spanC = lineDiv.children[2] as HTMLSpanElement; - expect(spanC.textContent).toBe('c'); - expect(spanC.style.left).toBe('16.8px'); + it('snaps cell edges to a quarter pixel so a fractional cell width never drifts', () => { + const container = document.createElement('div'); + const cellW = 8.4; + renderOverlay(container, makeParams({ lines: ['x'.repeat(78) + '你'], startCol: 0, totalCols: 80, cellW })); + const spans = lineSpans(container.children[0]); + let col = 0; + for (const span of spans) { + // Every width is a whole number of quarter pixels, which Blink, WebKit + // and Gecko all store exactly, so the browser adds them up exactly too. + expect(Number.isInteger(span.width * 4)).toBe(true); + expect(Math.abs(span.left - col * cellW)).toBeLessThanOrEqual(0.125); + col += span.text === '你' ? 2 : 1; + } + // The far edge is still on the grid after 80 columns. + const last = spans[spans.length - 1]; + expect(Math.abs(last.left + last.width - 80 * cellW)).toBeLessThanOrEqual(0.125); }); it('sets span width to cellW', () => { @@ -67,7 +94,7 @@ describe('renderOverlay', () => { expect(span.style.width).toBe('9.5px'); }); - it('applies font styles to spans', () => { + it('applies font styles once on the line, for its spans to inherit', () => { const font: FontStyle = { fontFamily: 'Fira Code', fontSize: '16px', @@ -83,12 +110,16 @@ describe('renderOverlay', () => { // jsdom normalizes hex to rgb() expect(lineDiv.style.backgroundColor).toBe('rgb(0, 0, 0)'); + expect(lineDiv.style.fontFamily).toBe('Fira Code'); + expect(lineDiv.style.fontSize).toBe('16px'); + expect(lineDiv.style.fontWeight).toBe('bold'); + expect(lineDiv.style.color).toBe('rgb(255, 0, 0)'); + expect(lineDiv.style.letterSpacing).toBe('0.5px'); + + // Inherited, so a span sets none of them itself. const span = lineDiv.children[0] as HTMLSpanElement; - expect(span.style.fontFamily).toBe('Fira Code'); - expect(span.style.fontSize).toBe('16px'); - expect(span.style.fontWeight).toBe('bold'); - expect(span.style.color).toBe('rgb(255, 0, 0)'); - expect(span.style.letterSpacing).toBe('0.5px'); + expect(span.style.fontFamily).toBe(''); + expect(span.style.color).toBe(''); }); it('offsets first line by startCol', () => { @@ -217,47 +248,49 @@ describe('renderOverlay', () => { const lineDiv = container.children[0] as HTMLDivElement; const span = lineDiv.children[0] as HTMLSpanElement; expect(span.style.height).toBe('19px'); - expect(span.style.lineHeight).toBe('19px'); + // Set on the line and inherited by the span. + expect(lineDiv.style.lineHeight).toBe('19px'); }); - it('span top is 0px (no vertical offset / no transform)', () => { + it('span has no vertical offset and no transform', () => { const container = document.createElement('div'); renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 })); const lineDiv = container.children[0] as HTMLDivElement; const span = lineDiv.children[0] as HTMLSpanElement; - expect(span.style.top).toBe('0px'); + // A flex item starts at the top of its line; nothing shifts it down. + expect(span.style.top).toBe(''); + expect(span.style.marginTop).toBe(''); // No translateY transform — sub-pixel overhang causes artifacts expect(span.style.transform).toBe(''); }); // ─── Font rendering tests ───────────────────────────────────── - it('span disables ligatures via font-feature-settings', () => { + // These are inherited properties, set once on the line for every span. + + it('line disables ligatures via font-feature-settings', () => { const container = document.createElement('div'); renderOverlay(container, makeParams({ lines: ['fi'] })); const lineDiv = container.children[0] as HTMLDivElement; - const span = lineDiv.children[0] as HTMLSpanElement; // Check cssText includes the ligature-disabling settings // jsdom may normalize whitespace; check that both liga and calt are disabled - expect(span.style.cssText).toContain('font-feature-settings:'); - expect(span.style.cssText).toContain("'liga' 0"); - expect(span.style.cssText).toContain("'calt' 0"); + expect(lineDiv.style.cssText).toContain('font-feature-settings:'); + expect(lineDiv.style.cssText).toContain("'liga' 0"); + expect(lineDiv.style.cssText).toContain("'calt' 0"); }); - it('span has text-align: center for glyph centering', () => { + it('line has text-align: center for glyph centering', () => { const container = document.createElement('div'); renderOverlay(container, makeParams({ lines: ['m'] })); const lineDiv = container.children[0] as HTMLDivElement; - const span = lineDiv.children[0] as HTMLSpanElement; - expect(span.style.textAlign).toBe('center'); + expect(lineDiv.style.textAlign).toBe('center'); }); - it('span has pointer-events: none', () => { + it('line has pointer-events: none', () => { const container = document.createElement('div'); renderOverlay(container, makeParams({ lines: ['a'] })); const lineDiv = container.children[0] as HTMLDivElement; - const span = lineDiv.children[0] as HTMLSpanElement; - expect(span.style.pointerEvents).toBe('none'); + expect(lineDiv.style.pointerEvents).toBe('none'); }); // ─── Multi-line cursor positioning ──────────────────────────── @@ -323,20 +356,18 @@ describe('renderOverlay', () => { const lineDiv = container.children[0] as HTMLDivElement; expect(lineDiv.children.length).toBe(3); - const spanA = lineDiv.children[0] as HTMLSpanElement; - expect(spanA.textContent).toBe('a'); - expect(spanA.style.left).toBe('0px'); - expect(spanA.style.width).toBe('10px'); // 1 cell + const [spanA, spanCJK, spanB] = lineSpans(lineDiv); + expect(spanA.text).toBe('a'); + expect(spanA.left).toBe(0); + expect(spanA.width).toBe(10); // 1 cell - const spanCJK = lineDiv.children[1] as HTMLSpanElement; - expect(spanCJK.textContent).toBe('你'); - expect(spanCJK.style.left).toBe('10px'); // col 1 - expect(spanCJK.style.width).toBe('20px'); // 2 cells + expect(spanCJK.text).toBe('你'); + expect(spanCJK.left).toBe(10); // col 1 + expect(spanCJK.width).toBe(20); // 2 cells - const spanB = lineDiv.children[2] as HTMLSpanElement; - expect(spanB.textContent).toBe('b'); - expect(spanB.style.left).toBe('30px'); // col 3 - expect(spanB.style.width).toBe('10px'); // 1 cell + expect(spanB.text).toBe('b'); + expect(spanB.left).toBe(30); // col 3 + expect(spanB.width).toBe(10); // 1 cell }); it('cursor position accounts for CJK width', () => { @@ -360,13 +391,11 @@ describe('renderOverlay', () => { renderOverlay(container, makeParams({ lines: ['hi你'], cellW: 8 })); const lineDiv = container.children[0] as HTMLDivElement; // h(col 0), i(col 1), 你(col 2, width 2) - const spanH = lineDiv.children[0] as HTMLSpanElement; - expect(spanH.style.left).toBe('0px'); - const spanI = lineDiv.children[1] as HTMLSpanElement; - expect(spanI.style.left).toBe('8px'); - const spanCJK = lineDiv.children[2] as HTMLSpanElement; - expect(spanCJK.style.left).toBe('16px'); - expect(spanCJK.style.width).toBe('16px'); + const [spanH, spanI, spanCJK] = lineSpans(lineDiv); + expect(spanH.left).toBe(0); + expect(spanI.left).toBe(8); + expect(spanCJK.left).toBe(16); + expect(spanCJK.width).toBe(16); }); }); @@ -503,3 +532,147 @@ describe('renderOverlay — staying on screen (totalRows)', () => { expect(container.style.top).toBe(6 * 17 + 'px'); }); }); + +describe('renderOverlay: rebuilding only the lines that changed', () => { + const lineEls = (container: HTMLDivElement) => + Array.from(container.children).filter((el) => el.tagName === 'DIV') as HTMLDivElement[]; + const text = (line: HTMLDivElement) => Array.from(line.children, (s) => s.textContent).join(''); + + it('appending to the last line keeps every earlier line element', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['first', 'second', 'th'] })); + const [first, second, third] = lineEls(container); + + renderOverlay(container, makeParams({ lines: ['first', 'second', 'thi'] })); + const after = lineEls(container); + expect(after[0]).toBe(first); + expect(after[1]).toBe(second); + expect(after[2]).not.toBe(third); + expect(after.map(text)).toEqual(['first', 'second', 'thi']); + }); + + it('keeps the cursor as the last child and moves it with the text', () => { + const container = document.createElement('div'); + const params = { startCol: 0, cellW: 10 }; + renderOverlay(container, makeParams({ ...params, lines: ['ab'] })); + renderOverlay(container, makeParams({ ...params, lines: ['ab', 'c'] })); + const children = Array.from(container.children) as HTMLElement[]; + expect(children.length).toBe(3); + const cursor = children[2]; + expect(cursor.tagName).toBe('SPAN'); + expect(cursor.style.left).toBe('10px'); + expect(cursor.style.top).toBe(17 + 'px'); + expect(container.querySelectorAll(':scope > span').length).toBe(1); + }); + + it('a wrapped line coming back (backspace) removes the extra line', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['abc', 'd'] })); + const [first] = lineEls(container); + renderOverlay(container, makeParams({ lines: ['abc'] })); + expect(lineEls(container)).toEqual([first]); + expect(container.children.length).toBe(2); // line + cursor + }); + + it('removes the cursor when the next render does not show one', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['ab'] })); + renderOverlay(container, makeParams({ lines: ['ab'], showCursor: false })); + expect(container.children.length).toBe(1); + expect(container.children[0].tagName).toBe('DIV'); + }); + + it('a font, colour or cell size change rebuilds every line', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['one', 'two'] })); + const before = lineEls(container); + + renderOverlay(container, makeParams({ lines: ['one', 'two'], font: { ...FONT, color: '#ff0000' } })); + const recoloured = lineEls(container); + expect(recoloured[0]).not.toBe(before[0]); + expect(recoloured[0].style.color).toBe('rgb(255, 0, 0)'); + + renderOverlay(container, makeParams({ lines: ['one', 'two'], font: { ...FONT, color: '#ff0000' }, cellW: 12 })); + const resized = lineEls(container); + expect(resized[1]).not.toBe(recoloured[1]); + expect(lineSpans(resized[1])[1].left).toBe(12); + }); + + it('a Unicode version switch rebuilds lines whose text did not change', () => { + const container = document.createElement('div'); + const unicode = { activeVersion: '6', getStringCellWidth: (s: string) => (s === 'W' ? 1 : 1) }; + const terminal = { unicode } as any; + renderOverlay(container, makeParams({ lines: ['aW'], cellW: 10, terminal })); + const before = lineEls(container)[0]; + + unicode.activeVersion = '11'; + unicode.getStringCellWidth = (s: string) => (s === 'W' ? 2 : 1); + renderOverlay(container, makeParams({ lines: ['aW'], cellW: 10, terminal })); + const after = lineEls(container)[0]; + expect(after).not.toBe(before); + expect(lineSpans(after)[1].width).toBe(20); + }); + + it('starts over when something else emptied the container', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['one', 'two'] })); + container.innerHTML = ''; + renderOverlay(container, makeParams({ lines: ['one', 'two'] })); + expect(lineEls(container).map(text)).toEqual(['one', 'two']); + expect(container.children.length).toBe(3); + }); + + it('starts over when something else reordered the lines', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['one', 'two'] })); + const [one, two] = lineEls(container); + container.insertBefore(two, one); + renderOverlay(container, makeParams({ lines: ['one', 'two'] })); + expect(lineEls(container).map(text)).toEqual(['one', 'two']); + expect(container.children.length).toBe(3); + }); + + it('starts over when something else added to the container', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['one'] })); + container.appendChild(document.createElement('div')); + renderOverlay(container, makeParams({ lines: ['one'] })); + expect(container.children.length).toBe(2); + expect(text(lineEls(container)[0])).toBe('one'); + }); + + it('a composition growing on the last line leaves the lines it does not reach alone', () => { + const container = document.createElement('div'); + // 'first' (5) + 'abc' pending, composition starts at code point 8. + renderOverlay(container, makeParams({ lines: ['first', 'abcx'], compositionStart: 8 })); + const [first] = lineEls(container); + renderOverlay(container, makeParams({ lines: ['first', 'abcxy'], compositionStart: 8 })); + const after = lineEls(container); + expect(after[0]).toBe(first); + const marked = Array.from(after[1].children).filter((s) => s.hasAttribute('data-zerolag-composition')); + expect(marked.map((s) => s.textContent).join('')).toBe('xy'); + }); + + it('a composition that starts on an earlier line marks the whole later line', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['abXY', 'ZW'], compositionStart: 2 })); + const [first, second] = lineEls(container); + const marked = (line: HTMLDivElement) => + Array.from(line.children) + .filter((s) => s.hasAttribute('data-zerolag-composition')) + .map((s) => s.textContent) + .join(''); + expect(marked(first)).toBe('XY'); + expect(marked(second)).toBe('ZW'); + }); + + it('two containers keep separate records', () => { + const a = document.createElement('div'); + const b = document.createElement('div'); + renderOverlay(a, makeParams({ lines: ['same'] })); + renderOverlay(b, makeParams({ lines: ['same'] })); + expect(lineEls(a)[0]).not.toBe(lineEls(b)[0]); + expect(text(lineEls(a)[0])).toBe('same'); + expect(text(lineEls(b)[0])).toBe('same'); + }); +}); diff --git a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts index e4653868..4718cb25 100644 --- a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts +++ b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts @@ -756,3 +756,78 @@ describe('viewport scrolled away from the bottom', () => { expect(overlay.style.display).toBe('none'); }); }); + +describe('setRowLimit', () => { + function rowLimitSetup(promptRow = 10) { + const lines = Array.from({ length: 24 }, (_, i) => (i === promptRow ? '$ ' : '')); + const mock = createMockTerminal({ buffer: { lines }, cols: 20, rows: 24, cellHeight: 17 }); + const addon = new ZerolagInputAddon({ prompt: { type: 'character', char: '$', offset: 2 } }); + mock.terminal.loadAddon(addon); + cleanups.push(() => { + addon.dispose(); + mock.cleanup(); + }); + const overlay = () => mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement; + return { addon, overlay }; + } + + it('grows the composer upward from the last VISIBLE row, not the terminal last row', () => { + const { addon, overlay } = rowLimitSetup(); + addon.appendText('x'.repeat(50)); // 3 lines at 20 cols from the prompt on row 10 + expect(overlay().style.top).toBe(`${10 * 17}px`); + addon.setRowLimit(12); + expect(overlay().style.top).toBe(`${9 * 17}px`); + }); + + it('null restores the full terminal height, and junk reads as null', () => { + const { addon, overlay } = rowLimitSetup(); + addon.appendText('x'.repeat(50)); + addon.setRowLimit(12); + addon.setRowLimit(null); + expect(overlay().style.top).toBe(`${10 * 17}px`); + addon.setRowLimit(12); + addon.setRowLimit(Number.NaN); + expect(overlay().style.top).toBe(`${10 * 17}px`); + }); + + // setRowLimit narrows the SAME bound renderOverlay already keeps the block on + // screen with (RenderParams.totalRows, filled from terminal.rows), so there is + // one place that decides where the block stops growing downward. + it('narrows the terminal-rows bound and never widens it', () => { + const { addon, overlay } = rowLimitSetup(22); + addon.appendText('x'.repeat(50)); // 3 lines from row 22 of 24: the terminal bound lifts it to row 21 + expect(overlay().style.top).toBe(`${21 * 17}px`); + addon.setRowLimit(24); + expect(overlay().style.top).toBe(`${21 * 17}px`); + addon.setRowLimit(100); + expect(overlay().style.top).toBe(`${21 * 17}px`); + addon.setRowLimit(23); + expect(overlay().style.top).toBe(`${20 * 17}px`); + }); + + it('leaves a block that fits above the limit where it is', () => { + const { addon, overlay } = rowLimitSetup(); + addon.appendText('x'.repeat(50)); + addon.setRowLimit(13); // rows 10..12 are exactly the three lines + expect(overlay().style.top).toBe(`${10 * 17}px`); + }); + + it('keeps the TAIL of a block taller than the limit', () => { + const { addon, overlay } = rowLimitSetup(); + addon.appendText('x'.repeat(50)); // lines of 18, 20 and 12 characters + addon.setRowLimit(2); + expect(overlay().style.top).toBe('0px'); + const xs = [...overlay().querySelectorAll(':scope > div')].map( + (line) => (line.textContent ?? '').replace(/[^x]/g, '').length + ); + expect(xs).toEqual([20, 12]); + }); + + it('holds across later keystrokes, not only the re-render it triggers', () => { + const { addon, overlay } = rowLimitSetup(); + addon.appendText('x'.repeat(50)); + addon.setRowLimit(12); + addon.appendText('y'); + expect(overlay().style.top).toBe(`${9 * 17}px`); + }); +}); diff --git a/plugins/codeman/.claude-plugin/plugin.json b/plugins/codeman/.claude-plugin/plugin.json index 8b0133e3..69089733 100644 --- a/plugins/codeman/.claude-plugin/plugin.json +++ b/plugins/codeman/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "codeman", - "description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.", - "version": "1.45.0", + "description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.", + "version": "1.48.0", "author": { "name": "Ark0N", "url": "https://github.com/Ark0N" diff --git a/plugins/codeman/README.md b/plugins/codeman/README.md index 9ea8f05e..06d50222 100644 --- a/plugins/codeman/README.md +++ b/plugins/codeman/README.md @@ -7,7 +7,7 @@ The agent skill for [Codeman](https://getcodeman.com), the self-hosted mission c /plugin install codeman@codeman ``` -The skill acts only inside a Codeman-managed session (`CODEMAN_MUX=1`) and refuses everywhere else, so installing it globally costs nothing for unrelated sessions. +The skill acts inside a Codeman-managed session (`CODEMAN_MUX=1`). Outside one it only reads saved Codex conversations offline (`codeman history read`) and refuses everything else, so installing it globally costs nothing for unrelated sessions. Pick one install route. Codeman can inject the skill into each case itself (App Settings, Agent Skill), and `codeman skill install` writes a user-level copy; a Claude Code that has one of those AND this plugin lists the skill twice, as `codeman` and `codeman:codeman`. Both work, the second is just noise. diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index 50133e8c..755ade18 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -8,12 +8,19 @@ description: >- read their output, and clean up; where available, message claude workers directly (Claude Code cross-session messaging). Use when asked to orchestrate or parallelize work across Codeman sessions, get a second opinion from another CLI or model, watch - another session, or start and manage workers. Only usable inside a Codeman-managed - session (CODEMAN_MUX=1); refuse to act otherwise. + another session, start and manage workers, or retrieve saved local Codex history + by file/native ID or root and descendants with topic/date filters. Live API + operations require a Codeman-managed session (CODEMAN_MUX=1). Offline history + reads do not require a server or managed session. --- # Driving Codeman from inside a session +For **saved Codex conversation history**, use `codeman history read` and +[reference/history.md](reference/history.md). This is a read-only local command: +route to it **before §0**, including outside a managed session. It needs no API +URL, credentials or running server. All live API operations below retain §0. + 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. @@ -30,21 +37,33 @@ through: the verbs in detail (the old §5) in [reference/verbs.md](reference/ver worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpoint tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and direct messaging to claude workers in [reference/messaging.md](reference/messaging.md). +Experimental bounded inspection and guarded stage/submit load only when needed from +[reference/inspection-and-staging.md](reference/inspection-and-staging.md). This skill works the same whichever CLI you are: Codeman puts it where each CLI looks for skills (`.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP). A **pi** or **shell** worker never gets it, but has the same environment: tell it *"other sessions: `codeman agent --help`"*. -That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`, and the inbox -verbs `post`, `inbox`, `ack`) is the same +That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `inspect`, `stage`, `submit`, +`interrupt`, `rm`, and the inbox verbs `post`, `inbox`, `ack`) is the same verbs over the same endpoints, with the guards below (no control bytes, no self-delete, no guessed URL) enforced in code, and it is also your own way through when this machine has no `jq` for the preamble below. +For an existing project, use one command: +`codeman agent spawn --working-dir /path/to/project --mode codex`. With no case and +no `--working-dir`, `spawn` uses cwd. Give editing workers separate worktrees. A +positional case keeps quick-start behavior and cannot be combined with +`--working-dir`; an empty `--working-dir` (`"$WT"` unset) refuses with exit 4 +rather than falling back to cwd, and a cwd that is HOME refuses too (hooks would +land in `~/.claude`; pass `--working-dir ~` to mean it). Directory launches keep parent lineage and never +mark the project as agent scratch. If startup fails or is ambiguous, the CLI +reports the exact created ID: inspect it before retrying or cleaning up. + ## 0. Guard and bootstrap -If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server -you are not part of is not yours to drive. +For live API operations, if `CODEMAN_MUX` is not `1`, **stop and say so**. +Do not guess an API URL; a server you are not part of is not yours to drive. Two failures come from your CLI, not from Codeman: name them to the user instead of retrying. diff --git a/plugins/codeman/skills/codeman/reference/history.md b/plugins/codeman/skills/codeman/reference/history.md new file mode 100644 index 00000000..ae087fb6 --- /dev/null +++ b/plugins/codeman/skills/codeman/reference/history.md @@ -0,0 +1,84 @@ +# Saved Codex history (offline) + +Use `codeman history read` for retained local Codex JSONL transcripts. This command +works outside a managed session and never calls the HTTP API. Do not bootstrap +API credentials or require `CODEMAN_MUX` for this read-only job. + +Choose exactly one selector: + +```bash +codeman history read --file /path/to/rollout.jsonl +codeman history read --session-id aaaaaaaa --sessions-root /path/to/sessions +codeman history read --root-session-id aaaaaaaa-1111-4111-8111-111111111111 \ + --topic 'authentication' --after 2026-10-01 --before 2026-10-24 \ + --max-bytes 65536 +``` + +`--session-id` accepts a native conversation UUID or unique prefix of at least +8 characters; identity comes from metadata, not filenames. `--root-session-id` +requires a full native UUID and includes transitive descendants found in metadata +(spawned sub-agents and forks). The default store is `$CODEX_HOME/sessions`, or +`~/.codex/sessions` when unset; a store directory that is itself a symlink is +followed, links inside it are skipped. `--file` selects one file and does not +search for its ancestors or children. + +Topic matching is a case-insensitive literal substring. Dates are inclusive +`YYYY-MM-DD` bounds compared as UTC calendar dates. Each filter selects whole +user/assistant turns; a turn is retained if it has a date match and a topic match, +which may be different messages in that turn. Tool calls/results and injected +context (rows Codex adds that start with `#` or `<`, such as AGENTS.md and +`<environment_context>`) are omitted. Adjacent duplicate native representations +are collapsed; repeated messages from the same representation are retained. + +A forked or spawned thread's file starts with a copy of its parent's history. That +copy is dropped (it is in the parent's own file), so each record belongs to the +thread that wrote it. A later `session_meta` for any thread that is not a declared +ancestor still refuses the file. + +JSON is the default (`--json` or `--format json`); `--format text` prints one block +per record. `--json` together with `--format text` is an error. +Results identify source paths, native session/root IDs, timestamps, roles and phases, +plus scan counters and `complete`. `phase` is Codex's own (`commentary` or +`final_answer`) on assistant records and `null` on user records or when none was +recorded. A root ID is `null` when ancestry cannot be proven. +Report that uncertainty; never relabel the queried child as its own root. +`complete: false` reports malformed or partial records (a running Codex may be +mid-write), skipped links, unattributed files, compressed rollouts, unreadable +directories, missing ancestors, or an inherited copy whose end could not be found +or proven (including when the parent's file is present but cannot be read). It +does not prove the store includes every conversation ever created, and a filtered +result is only evidence for its selected scope. + +Reads require regular UTF8 files and reject final-component symlinks, conflicting +metadata, ambiguous IDs, cyclic ancestry and invalid selectors. A store file that +cannot be attributed (invalid UTF8 in its head, no metadata in its first 128 KiB, +changing while its head is read) is counted in `unattributedFiles` instead of +failing the lookup. Codex's optional rollout compression rewrites cold rollouts to +`*.jsonl.zst`; those are not read. Each is counted in `compressedFiles` (one whose +plain `.jsonl` sibling exists is skipped, as Codex skips it), and a lookup that +misses names them. A compressed file passed to `--file` is refused; read a +decompressed copy instead (`zstd -dc <file> > copy.jsonl`). A selected rollout +that a running Codex is appending to is read as a snapshot up to its last complete +line; one that was replaced or truncated fails. Parent directories of an explicitly supplied path must be trusted; this is +not a filesystem sandbox. + +Budgets, each failing with an error rather than a partial answer. Every flag is +validated before the first file is read, and only plain decimal integers are +accepted: + +| Flag | Bounds | Default | Maximum | +| ---- | ------ | ------- | ------- | +| `--metadata-bytes` | head reads attributing every rollout in the store | 1 GiB | 16 GiB | +| `--max-entries` | directory entries visited | 65536 | 1048576 | +| `--scan-bytes` | the selected transcripts, plus any parent file read to prove where a copy ends (each file charged once) | 16 MiB | 256 MiB | +| `--max-bytes` | output, including the final newline | 8192 | 1048576 | + +Each rollout's `session_meta` carries Codex's base instructions (about 20 KiB), so +attributing a store costs about that much per rollout, charged to +`--metadata-bytes`, never to `--scan-bytes`. Directory depth is capped at 8 (Codex +writes `YYYY/MM/DD`). Output is never silently truncated or split into partial +JSON or turns. Narrow the store/filters or explicitly raise the relevant budget. +Missing evidence is an error, not an empty success. + +This command does not inspect live panes, stage prompts, submit input or manage +sessions. For live operations return to the managed-session guard in the skill. diff --git a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md new file mode 100644 index 00000000..8f454748 --- /dev/null +++ b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md @@ -0,0 +1,152 @@ +# Experimental bounded inspection and guarded submission + +**Experimental:** `codeman agent inspect`, `stage` and `submit`, and their +`/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases), +are labeled experimental and so are not part of the stable CLI/HTTP contract. Their commands, flags, paths, +payloads and behavior may change or be removed in a minor or patch release. +Pin a compatible Codeman version when automating them. Authentication and +session ownership checks still apply. + +They are also opt-in: until the user turns on **Experimental agent coordination** +in App Settings (`agentCoordinationEnabled`, off by default), every one of them +fails with `FORBIDDEN: ... (HTTP 403)` naming that setting. That is not something +to retry or work around: tell the user, or use `send`/`read` instead. + +Use these commands inside a Codeman session. They use its authenticated HTTP API. +They supplement `send`, `wait` and `read` over the same HTTP transport; no direct +tmux access is required. + +## Inspect recent evidence + +```bash +codeman agent inspect "$SID" --max-bytes 8192 --json +codeman agent inspect "$SID" --max-bytes 8192 --since "$CURSOR" --json +``` + +Read `source`, `identity`, `activity`, `evidence`, `truncated` and `cursor` before +interpreting the result. `maxBytes` bounds UTF8 evidence bytes, excluding the JSON +metadata/envelope. The default is 8192, maximum 65536. + +- `codex-jsonl`: strictly attributed local Codex JSONL records. The first call + selects recent complete records within a bounded tail window; + `omittedBeforeBytes` labels older bytes deliberately omitted. A cursor continues + after the last consumed complete record. Partial final records remain pending. +- `pane-snapshot`: UTF8-safe tail of a visible frame or the session's buffered + output. It has `incremental:false` and `cursor:null`. This is the fallback for + other modes, remote/container sessions, ambiguous attribution, and exhausted + attribution budgets. Repeating a snapshot can repeat evidence. + +Attribution proves which file supplied the evidence, not which thread is now +active in the TUI. A launch resume that conflicts with a newly owned rollout +falls back to a snapshot. An in-CLI `/resume` of an existing foreign-originator +thread may leave launch metadata unchanged. Check `identity.conversationId`; +these reads do not provide authoritative current-thread tracking. + +Terminal resizes preserve inspection cursors. Cursors are opaque, scoped to the +requesting identity, session, pane lifecycle generation +and attributed transcript, expire after 15 minutes, and disappear on server +restart. Each caller and session keeps its newest 8 (so retrying the last call +works), at most 1024 in all; an older cursor can be retired. A replaced/truncated/rewritten source, reassociated session, foreign cursor +or unavailable native source returns `409 CONFLICT`. Inspect again without +`--since` to establish a new tail baseline; this may omit older evidence. + +`blockedReason` explains an unfinished or oversized record. Incremental reads +never split or silently skip one. Increase the byte budget for +`record-exceeds-output-budget`; when the record exceeds the maximum or scan cap, +use `read --tail` for a labeled terminal view or start a fresh tail baseline. +Inspect scans at most 256 KiB of record data plus bounded generation anchors. +Attribution separately caps directory entries (8192), depth (8), negative-filter +prefix reads (8 MiB) and candidate metadata including final verification (1 MiB). Evidence and +activity are observations, not proof of task completion or authorization. + +## Stage, inspect, then submit once + +```bash +printf '%s' 'Review src/ and report the failing tests' > /tmp/codeman-message.txt +codeman agent stage "$SID" --message-file /tmp/codeman-message.txt --json +# Review the returned stageId, sha256, expiresAt; inspect the target if needed. +codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json +``` + +Stage types the file contents **without Enter**. The file must be regular, UTF8, +one printable line, at most 8192 bytes, with no control characters or trailing +whitespace/newline. It initially supports a local Codex composer with a recognized +footer and an exact, unwrapped line/caret capture. Before typing, stage checks +the actual captured width and composer indentation; a message that would reach +the right edge or wrap is refused without writing anything. Unknown, working, modal, +wrapped, remote/container and shell composers are refused. A customized footer or +unsupported character-width rendering can also make verification unavailable. +Self-targets are refused by the CLI. + +Stage also refuses, before typing, a message that would change what Enter does: +one starting with `/` (a Codex slash command; with its command list open, Enter +would run the highlighted command instead) or `!` (a shell command), and one whose +**last** word starts with `@` (file/mention search) or `$` (skills). Those popups +follow the caret, which stage leaves at the end, so `look at @src/app.ts please` +is fine. Send slash and shell commands with `codeman agent send`, and end a +message with an ordinary word when it mentions a file. A frame showing a +suggestion popup above the composer is refused at stage and submit too. + +The receipt stores a hash and ephemeral identities, never prompt content. It +expires after two minutes; capacity is 1024 live receipts. After typing, stage +re-reads the pane (every 50 ms, up to one second, since Codex repaints a little +after the keys) and issues a receipt only for a frame that shows exactly the +message with the caret after it. Any refusal after the keys went out (typing +failed or was ambiguous, the pane or input changed, or no frame verified in time) +says the message may already be in the composer: no receipt was issued. Do not +stage again on top of it; follow [Recover a stranded draft](#recover-a-stranded-draft) below first. + +While a submit's Enter is in flight (milliseconds, plus the moment it takes to +write input held behind it), TAGGED input to that session (`clientId` and `seq`, +which `codeman agent send` and `interrupt` always carry) answers `409 CONFLICT` +with nothing written; retry it. Untagged input, terminal keystrokes, `send-key` +and Codeman's own managed writes are held instead and written in arrival order +once the Enter is out. + +Submit checks the caller, session, hash, expiry, pane lifecycle, input revision, +geometry, exact composer and caret, then sends one conditional Enter at the session's input +boundary. It first waits until 250 ms have passed since stage typed (Codex turns an +Enter that follows typed text within 120 ms into a newline), so submitting right +after stage is fine. Competing Codeman writes are held during dispatch and written +after it, except tagged input, which is refused with `409`. Browser/API/key +input and managed restarts invalidate receipts even when the visible text or pid +is restored. Direct tmux edits are outside that input serialization; captures +remain evidence at the time observed, rather than terminal isolation. + +Every owned submit attempt consumes its receipt, including hash mismatch, +verification failure and delivery failure. Concurrent/repeated submits cannot +replay Enter, and there is no autonomous retry. A successful response means one +Enter was delivered; use inspection or a suitable wait signal to observe native +acceptance. A `409` or broken connection means inspect first. Do not automatically +stage/submit again or send an unguarded Enter to recover. + +## Recover a stranded draft + +A consumed receipt cannot be reused, and a stage refused after typing issued +none. First observe whether the prompt is still in the composer or was accepted: use transcript evidence/wait signals and a fresh terminal capture +(`GET /api/v1/sessions/:id/terminal?tail=8192`). If the target is working, has +accepted the prompt, shows a dialog, or delivery is ambiguous, keep observing; +do not clear or resend anything. A transcript prompt row alone is not proof +that text remains unsent in the current composer. + +Only recover an unambiguously unsent draft in an input-ready local Codex +composer: verify the complete draft, recognized footer and caret at its end in +the current frame, and ensure no other writer can edit this target during +recovery. If the frame is wrapped, incomplete or unclear, ask the user to clear +it in the terminal instead of guessing keys. Never use `send` to overwrite a +draft: that appends and submits both prompts together. + +After those checks, the known Codex line-clear chord may be sent once through +the existing raw input route, with **no Enter**. This conditional example uses +the authenticated `CURL`/`API` values loaded by §0 and the exact target id: + +```bash +"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \ + -H 'Content-Type: application/json' -d '{"input":"\u0015","useMux":false}' +``` + +Do not retry a failed/ambiguous clear blindly. Read a fresh terminal frame and +verify the composer is empty and input-ready. Only then create a **fresh** stage +receipt for the intended prompt and submit that receipt once. If the draft did +not clear, the target began working, or verification remains uncertain, stop and +ask the user; do not send an unguarded Enter or replay the old prompt. diff --git a/plugins/codeman/skills/codeman/reference/messaging.md b/plugins/codeman/skills/codeman/reference/messaging.md index 3b99f200..99db822e 100644 --- a/plugins/codeman/skills/codeman/reference/messaging.md +++ b/plugins/codeman/skills/codeman/reference/messaging.md @@ -48,7 +48,7 @@ own head: the worker enforcing the cap is the one who has to be told about it. | Job | Channel | | --- | --- | -| spawn a worker, create its case | HTTP `quick-start` (the only path) | +| spawn a worker, create its case | HTTP `quick-start` (the only path that creates a case); an existing directory is `POST sessions` + `interactive` (`codeman agent spawn --working-dir`) | | readiness, incl. the trust dialog | HTTP, Flow 1 (a message cannot answer a dialog) | | deliver a task to a READY claude worker | **messaging** (preferred) or HTTP input | | steer a BUSY claude worker mid-turn | **messaging** (read between the worker's tool calls; the HTTP path can only type into the composer, where text waits for the turn to end) | diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index 65af5c65..d72d46bc 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -31,7 +31,7 @@ wrong directory.** `quick-start` with a new `caseName` does not find your repo: |-------------------|------|------------------------------| | a fresh scratch dir (throwaway experiments) | `POST /api/v1/quick-start {"caseName":"scratch-1","mode":"claude"}` with a **new** case name | Codeman creates the directory and **writes hooks**: `stop` and `blocked` fire, send-and-wait is trustworthy | | a linked case (a real repo in the linked-cases registry) | same call with the linked name | **hooks installed at session create**, so `stop` fires here too. Not guaranteed: the operator can turn it off. Check | -| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` | same: **hooks installed at session create**, subject to the same setting. Check | +| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` (what `codeman agent spawn --working-dir /abs/path` sends) | same: **hooks installed at session create**, subject to the same setting. Check | Read `.data.casePath` back from the `quick-start` response and check it is where you meant. `caseName` accepts letters, digits, `-` and `_` only, and it resolves through @@ -550,8 +550,10 @@ this (`src/web/routes/approval-routes.ts:43`). ESC is then claude's own interrup that half is the CLI's behavior, not something this API guarantees. - **This is not the composer-clearing tool.** Esc (and Ctrl+U) do **not** clear a - typed-but-unsubmitted prompt, verified live. The only recovery there is to submit it - with `{"input":"\r"}` and let the worker read the junk line. + typed-but-unsubmitted Claude prompt, verified live. Do not submit an unverified + draft merely to clear it. For a refused guarded local Codex stage or submit, use the + verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-a-stranded-draft); + when the composer is unclear, ask the user to clear it in the terminal. - The interrupted turn already burned its tokens. Interrupting early saves the rest. - `POST /api/sessions/:id/send-key` is a different endpoint and cannot do this: its allowlist is S-Enter / C-Enter only. @@ -811,3 +813,16 @@ Rules that matter: not parked, `send` it one line ("read your inbox: `codeman agent inbox`"). - **Lifetime.** An inbox goes with its session on delete. A server restart empties all inboxes unless the user turned on *Keep agent inbox across restarts* in Settings. + +## Experimental bounded inspection and guarded submission + +These three commands and their HTTP routes are labeled experimental, so they may +change or be removed in minor or patch releases. They are also opt-in +(`agentCoordinationEnabled`, off by default: a 403 naming it until the user turns +it on). Use `codeman agent inspect` for bounded evidence and incremental local +Codex cursors. +Use `codeman agent stage --message-file` followed by one hash-confirmed +`codeman agent submit --stage-id --confirm-sha` when the exact local Codex +composer and caret can be verified. Other targets provide labeled snapshots or +refuse guarded input. See [inspection and staging](inspection-and-staging.md) +for budgets, receipt expiry, conflict handling and supported composers. diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index 50133e8c..755ade18 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -8,12 +8,19 @@ description: >- read their output, and clean up; where available, message claude workers directly (Claude Code cross-session messaging). Use when asked to orchestrate or parallelize work across Codeman sessions, get a second opinion from another CLI or model, watch - another session, or start and manage workers. Only usable inside a Codeman-managed - session (CODEMAN_MUX=1); refuse to act otherwise. + another session, start and manage workers, or retrieve saved local Codex history + by file/native ID or root and descendants with topic/date filters. Live API + operations require a Codeman-managed session (CODEMAN_MUX=1). Offline history + reads do not require a server or managed session. --- # Driving Codeman from inside a session +For **saved Codex conversation history**, use `codeman history read` and +[reference/history.md](reference/history.md). This is a read-only local command: +route to it **before §0**, including outside a managed session. It needs no API +URL, credentials or running server. All live API operations below retain §0. + 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. @@ -30,21 +37,33 @@ through: the verbs in detail (the old §5) in [reference/verbs.md](reference/ver worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpoint tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and direct messaging to claude workers in [reference/messaging.md](reference/messaging.md). +Experimental bounded inspection and guarded stage/submit load only when needed from +[reference/inspection-and-staging.md](reference/inspection-and-staging.md). This skill works the same whichever CLI you are: Codeman puts it where each CLI looks for skills (`.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP). A **pi** or **shell** worker never gets it, but has the same environment: tell it *"other sessions: `codeman agent --help`"*. -That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`, and the inbox -verbs `post`, `inbox`, `ack`) is the same +That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `inspect`, `stage`, `submit`, +`interrupt`, `rm`, and the inbox verbs `post`, `inbox`, `ack`) is the same verbs over the same endpoints, with the guards below (no control bytes, no self-delete, no guessed URL) enforced in code, and it is also your own way through when this machine has no `jq` for the preamble below. +For an existing project, use one command: +`codeman agent spawn --working-dir /path/to/project --mode codex`. With no case and +no `--working-dir`, `spawn` uses cwd. Give editing workers separate worktrees. A +positional case keeps quick-start behavior and cannot be combined with +`--working-dir`; an empty `--working-dir` (`"$WT"` unset) refuses with exit 4 +rather than falling back to cwd, and a cwd that is HOME refuses too (hooks would +land in `~/.claude`; pass `--working-dir ~` to mean it). Directory launches keep parent lineage and never +mark the project as agent scratch. If startup fails or is ambiguous, the CLI +reports the exact created ID: inspect it before retrying or cleaning up. + ## 0. Guard and bootstrap -If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server -you are not part of is not yours to drive. +For live API operations, if `CODEMAN_MUX` is not `1`, **stop and say so**. +Do not guess an API URL; a server you are not part of is not yours to drive. Two failures come from your CLI, not from Codeman: name them to the user instead of retrying. diff --git a/skills/codeman/reference/history.md b/skills/codeman/reference/history.md new file mode 100644 index 00000000..ae087fb6 --- /dev/null +++ b/skills/codeman/reference/history.md @@ -0,0 +1,84 @@ +# Saved Codex history (offline) + +Use `codeman history read` for retained local Codex JSONL transcripts. This command +works outside a managed session and never calls the HTTP API. Do not bootstrap +API credentials or require `CODEMAN_MUX` for this read-only job. + +Choose exactly one selector: + +```bash +codeman history read --file /path/to/rollout.jsonl +codeman history read --session-id aaaaaaaa --sessions-root /path/to/sessions +codeman history read --root-session-id aaaaaaaa-1111-4111-8111-111111111111 \ + --topic 'authentication' --after 2026-10-01 --before 2026-10-24 \ + --max-bytes 65536 +``` + +`--session-id` accepts a native conversation UUID or unique prefix of at least +8 characters; identity comes from metadata, not filenames. `--root-session-id` +requires a full native UUID and includes transitive descendants found in metadata +(spawned sub-agents and forks). The default store is `$CODEX_HOME/sessions`, or +`~/.codex/sessions` when unset; a store directory that is itself a symlink is +followed, links inside it are skipped. `--file` selects one file and does not +search for its ancestors or children. + +Topic matching is a case-insensitive literal substring. Dates are inclusive +`YYYY-MM-DD` bounds compared as UTC calendar dates. Each filter selects whole +user/assistant turns; a turn is retained if it has a date match and a topic match, +which may be different messages in that turn. Tool calls/results and injected +context (rows Codex adds that start with `#` or `<`, such as AGENTS.md and +`<environment_context>`) are omitted. Adjacent duplicate native representations +are collapsed; repeated messages from the same representation are retained. + +A forked or spawned thread's file starts with a copy of its parent's history. That +copy is dropped (it is in the parent's own file), so each record belongs to the +thread that wrote it. A later `session_meta` for any thread that is not a declared +ancestor still refuses the file. + +JSON is the default (`--json` or `--format json`); `--format text` prints one block +per record. `--json` together with `--format text` is an error. +Results identify source paths, native session/root IDs, timestamps, roles and phases, +plus scan counters and `complete`. `phase` is Codex's own (`commentary` or +`final_answer`) on assistant records and `null` on user records or when none was +recorded. A root ID is `null` when ancestry cannot be proven. +Report that uncertainty; never relabel the queried child as its own root. +`complete: false` reports malformed or partial records (a running Codex may be +mid-write), skipped links, unattributed files, compressed rollouts, unreadable +directories, missing ancestors, or an inherited copy whose end could not be found +or proven (including when the parent's file is present but cannot be read). It +does not prove the store includes every conversation ever created, and a filtered +result is only evidence for its selected scope. + +Reads require regular UTF8 files and reject final-component symlinks, conflicting +metadata, ambiguous IDs, cyclic ancestry and invalid selectors. A store file that +cannot be attributed (invalid UTF8 in its head, no metadata in its first 128 KiB, +changing while its head is read) is counted in `unattributedFiles` instead of +failing the lookup. Codex's optional rollout compression rewrites cold rollouts to +`*.jsonl.zst`; those are not read. Each is counted in `compressedFiles` (one whose +plain `.jsonl` sibling exists is skipped, as Codex skips it), and a lookup that +misses names them. A compressed file passed to `--file` is refused; read a +decompressed copy instead (`zstd -dc <file> > copy.jsonl`). A selected rollout +that a running Codex is appending to is read as a snapshot up to its last complete +line; one that was replaced or truncated fails. Parent directories of an explicitly supplied path must be trusted; this is +not a filesystem sandbox. + +Budgets, each failing with an error rather than a partial answer. Every flag is +validated before the first file is read, and only plain decimal integers are +accepted: + +| Flag | Bounds | Default | Maximum | +| ---- | ------ | ------- | ------- | +| `--metadata-bytes` | head reads attributing every rollout in the store | 1 GiB | 16 GiB | +| `--max-entries` | directory entries visited | 65536 | 1048576 | +| `--scan-bytes` | the selected transcripts, plus any parent file read to prove where a copy ends (each file charged once) | 16 MiB | 256 MiB | +| `--max-bytes` | output, including the final newline | 8192 | 1048576 | + +Each rollout's `session_meta` carries Codex's base instructions (about 20 KiB), so +attributing a store costs about that much per rollout, charged to +`--metadata-bytes`, never to `--scan-bytes`. Directory depth is capped at 8 (Codex +writes `YYYY/MM/DD`). Output is never silently truncated or split into partial +JSON or turns. Narrow the store/filters or explicitly raise the relevant budget. +Missing evidence is an error, not an empty success. + +This command does not inspect live panes, stage prompts, submit input or manage +sessions. For live operations return to the managed-session guard in the skill. diff --git a/skills/codeman/reference/inspection-and-staging.md b/skills/codeman/reference/inspection-and-staging.md new file mode 100644 index 00000000..8f454748 --- /dev/null +++ b/skills/codeman/reference/inspection-and-staging.md @@ -0,0 +1,152 @@ +# Experimental bounded inspection and guarded submission + +**Experimental:** `codeman agent inspect`, `stage` and `submit`, and their +`/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases), +are labeled experimental and so are not part of the stable CLI/HTTP contract. Their commands, flags, paths, +payloads and behavior may change or be removed in a minor or patch release. +Pin a compatible Codeman version when automating them. Authentication and +session ownership checks still apply. + +They are also opt-in: until the user turns on **Experimental agent coordination** +in App Settings (`agentCoordinationEnabled`, off by default), every one of them +fails with `FORBIDDEN: ... (HTTP 403)` naming that setting. That is not something +to retry or work around: tell the user, or use `send`/`read` instead. + +Use these commands inside a Codeman session. They use its authenticated HTTP API. +They supplement `send`, `wait` and `read` over the same HTTP transport; no direct +tmux access is required. + +## Inspect recent evidence + +```bash +codeman agent inspect "$SID" --max-bytes 8192 --json +codeman agent inspect "$SID" --max-bytes 8192 --since "$CURSOR" --json +``` + +Read `source`, `identity`, `activity`, `evidence`, `truncated` and `cursor` before +interpreting the result. `maxBytes` bounds UTF8 evidence bytes, excluding the JSON +metadata/envelope. The default is 8192, maximum 65536. + +- `codex-jsonl`: strictly attributed local Codex JSONL records. The first call + selects recent complete records within a bounded tail window; + `omittedBeforeBytes` labels older bytes deliberately omitted. A cursor continues + after the last consumed complete record. Partial final records remain pending. +- `pane-snapshot`: UTF8-safe tail of a visible frame or the session's buffered + output. It has `incremental:false` and `cursor:null`. This is the fallback for + other modes, remote/container sessions, ambiguous attribution, and exhausted + attribution budgets. Repeating a snapshot can repeat evidence. + +Attribution proves which file supplied the evidence, not which thread is now +active in the TUI. A launch resume that conflicts with a newly owned rollout +falls back to a snapshot. An in-CLI `/resume` of an existing foreign-originator +thread may leave launch metadata unchanged. Check `identity.conversationId`; +these reads do not provide authoritative current-thread tracking. + +Terminal resizes preserve inspection cursors. Cursors are opaque, scoped to the +requesting identity, session, pane lifecycle generation +and attributed transcript, expire after 15 minutes, and disappear on server +restart. Each caller and session keeps its newest 8 (so retrying the last call +works), at most 1024 in all; an older cursor can be retired. A replaced/truncated/rewritten source, reassociated session, foreign cursor +or unavailable native source returns `409 CONFLICT`. Inspect again without +`--since` to establish a new tail baseline; this may omit older evidence. + +`blockedReason` explains an unfinished or oversized record. Incremental reads +never split or silently skip one. Increase the byte budget for +`record-exceeds-output-budget`; when the record exceeds the maximum or scan cap, +use `read --tail` for a labeled terminal view or start a fresh tail baseline. +Inspect scans at most 256 KiB of record data plus bounded generation anchors. +Attribution separately caps directory entries (8192), depth (8), negative-filter +prefix reads (8 MiB) and candidate metadata including final verification (1 MiB). Evidence and +activity are observations, not proof of task completion or authorization. + +## Stage, inspect, then submit once + +```bash +printf '%s' 'Review src/ and report the failing tests' > /tmp/codeman-message.txt +codeman agent stage "$SID" --message-file /tmp/codeman-message.txt --json +# Review the returned stageId, sha256, expiresAt; inspect the target if needed. +codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json +``` + +Stage types the file contents **without Enter**. The file must be regular, UTF8, +one printable line, at most 8192 bytes, with no control characters or trailing +whitespace/newline. It initially supports a local Codex composer with a recognized +footer and an exact, unwrapped line/caret capture. Before typing, stage checks +the actual captured width and composer indentation; a message that would reach +the right edge or wrap is refused without writing anything. Unknown, working, modal, +wrapped, remote/container and shell composers are refused. A customized footer or +unsupported character-width rendering can also make verification unavailable. +Self-targets are refused by the CLI. + +Stage also refuses, before typing, a message that would change what Enter does: +one starting with `/` (a Codex slash command; with its command list open, Enter +would run the highlighted command instead) or `!` (a shell command), and one whose +**last** word starts with `@` (file/mention search) or `$` (skills). Those popups +follow the caret, which stage leaves at the end, so `look at @src/app.ts please` +is fine. Send slash and shell commands with `codeman agent send`, and end a +message with an ordinary word when it mentions a file. A frame showing a +suggestion popup above the composer is refused at stage and submit too. + +The receipt stores a hash and ephemeral identities, never prompt content. It +expires after two minutes; capacity is 1024 live receipts. After typing, stage +re-reads the pane (every 50 ms, up to one second, since Codex repaints a little +after the keys) and issues a receipt only for a frame that shows exactly the +message with the caret after it. Any refusal after the keys went out (typing +failed or was ambiguous, the pane or input changed, or no frame verified in time) +says the message may already be in the composer: no receipt was issued. Do not +stage again on top of it; follow [Recover a stranded draft](#recover-a-stranded-draft) below first. + +While a submit's Enter is in flight (milliseconds, plus the moment it takes to +write input held behind it), TAGGED input to that session (`clientId` and `seq`, +which `codeman agent send` and `interrupt` always carry) answers `409 CONFLICT` +with nothing written; retry it. Untagged input, terminal keystrokes, `send-key` +and Codeman's own managed writes are held instead and written in arrival order +once the Enter is out. + +Submit checks the caller, session, hash, expiry, pane lifecycle, input revision, +geometry, exact composer and caret, then sends one conditional Enter at the session's input +boundary. It first waits until 250 ms have passed since stage typed (Codex turns an +Enter that follows typed text within 120 ms into a newline), so submitting right +after stage is fine. Competing Codeman writes are held during dispatch and written +after it, except tagged input, which is refused with `409`. Browser/API/key +input and managed restarts invalidate receipts even when the visible text or pid +is restored. Direct tmux edits are outside that input serialization; captures +remain evidence at the time observed, rather than terminal isolation. + +Every owned submit attempt consumes its receipt, including hash mismatch, +verification failure and delivery failure. Concurrent/repeated submits cannot +replay Enter, and there is no autonomous retry. A successful response means one +Enter was delivered; use inspection or a suitable wait signal to observe native +acceptance. A `409` or broken connection means inspect first. Do not automatically +stage/submit again or send an unguarded Enter to recover. + +## Recover a stranded draft + +A consumed receipt cannot be reused, and a stage refused after typing issued +none. First observe whether the prompt is still in the composer or was accepted: use transcript evidence/wait signals and a fresh terminal capture +(`GET /api/v1/sessions/:id/terminal?tail=8192`). If the target is working, has +accepted the prompt, shows a dialog, or delivery is ambiguous, keep observing; +do not clear or resend anything. A transcript prompt row alone is not proof +that text remains unsent in the current composer. + +Only recover an unambiguously unsent draft in an input-ready local Codex +composer: verify the complete draft, recognized footer and caret at its end in +the current frame, and ensure no other writer can edit this target during +recovery. If the frame is wrapped, incomplete or unclear, ask the user to clear +it in the terminal instead of guessing keys. Never use `send` to overwrite a +draft: that appends and submits both prompts together. + +After those checks, the known Codex line-clear chord may be sent once through +the existing raw input route, with **no Enter**. This conditional example uses +the authenticated `CURL`/`API` values loaded by §0 and the exact target id: + +```bash +"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \ + -H 'Content-Type: application/json' -d '{"input":"\u0015","useMux":false}' +``` + +Do not retry a failed/ambiguous clear blindly. Read a fresh terminal frame and +verify the composer is empty and input-ready. Only then create a **fresh** stage +receipt for the intended prompt and submit that receipt once. If the draft did +not clear, the target began working, or verification remains uncertain, stop and +ask the user; do not send an unguarded Enter or replay the old prompt. diff --git a/skills/codeman/reference/messaging.md b/skills/codeman/reference/messaging.md index 3b99f200..99db822e 100644 --- a/skills/codeman/reference/messaging.md +++ b/skills/codeman/reference/messaging.md @@ -48,7 +48,7 @@ own head: the worker enforcing the cap is the one who has to be told about it. | Job | Channel | | --- | --- | -| spawn a worker, create its case | HTTP `quick-start` (the only path) | +| spawn a worker, create its case | HTTP `quick-start` (the only path that creates a case); an existing directory is `POST sessions` + `interactive` (`codeman agent spawn --working-dir`) | | readiness, incl. the trust dialog | HTTP, Flow 1 (a message cannot answer a dialog) | | deliver a task to a READY claude worker | **messaging** (preferred) or HTTP input | | steer a BUSY claude worker mid-turn | **messaging** (read between the worker's tool calls; the HTTP path can only type into the composer, where text waits for the turn to end) | diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 65af5c65..d72d46bc 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -31,7 +31,7 @@ wrong directory.** `quick-start` with a new `caseName` does not find your repo: |-------------------|------|------------------------------| | a fresh scratch dir (throwaway experiments) | `POST /api/v1/quick-start {"caseName":"scratch-1","mode":"claude"}` with a **new** case name | Codeman creates the directory and **writes hooks**: `stop` and `blocked` fire, send-and-wait is trustworthy | | a linked case (a real repo in the linked-cases registry) | same call with the linked name | **hooks installed at session create**, so `stop` fires here too. Not guaranteed: the operator can turn it off. Check | -| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` | same: **hooks installed at session create**, subject to the same setting. Check | +| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` (what `codeman agent spawn --working-dir /abs/path` sends) | same: **hooks installed at session create**, subject to the same setting. Check | Read `.data.casePath` back from the `quick-start` response and check it is where you meant. `caseName` accepts letters, digits, `-` and `_` only, and it resolves through @@ -550,8 +550,10 @@ this (`src/web/routes/approval-routes.ts:43`). ESC is then claude's own interrup that half is the CLI's behavior, not something this API guarantees. - **This is not the composer-clearing tool.** Esc (and Ctrl+U) do **not** clear a - typed-but-unsubmitted prompt, verified live. The only recovery there is to submit it - with `{"input":"\r"}` and let the worker read the junk line. + typed-but-unsubmitted Claude prompt, verified live. Do not submit an unverified + draft merely to clear it. For a refused guarded local Codex stage or submit, use the + verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-a-stranded-draft); + when the composer is unclear, ask the user to clear it in the terminal. - The interrupted turn already burned its tokens. Interrupting early saves the rest. - `POST /api/sessions/:id/send-key` is a different endpoint and cannot do this: its allowlist is S-Enter / C-Enter only. @@ -811,3 +813,16 @@ Rules that matter: not parked, `send` it one line ("read your inbox: `codeman agent inbox`"). - **Lifetime.** An inbox goes with its session on delete. A server restart empties all inboxes unless the user turned on *Keep agent inbox across restarts* in Settings. + +## Experimental bounded inspection and guarded submission + +These three commands and their HTTP routes are labeled experimental, so they may +change or be removed in minor or patch releases. They are also opt-in +(`agentCoordinationEnabled`, off by default: a 403 naming it until the user turns +it on). Use `codeman agent inspect` for bounded evidence and incremental local +Codex cursors. +Use `codeman agent stage --message-file` followed by one hash-confirmed +`codeman agent submit --stage-id --confirm-sha` when the exact local Codex +composer and caret can be verified. Other targets provide labeled snapshots or +refuse guarded input. See [inspection and staging](inspection-and-staging.md) +for budgets, receipt expiry, conflict handling and supported composers. diff --git a/src/agent-codex-identity.ts b/src/agent-codex-identity.ts new file mode 100644 index 00000000..1ff2f280 --- /dev/null +++ b/src/agent-codex-identity.ts @@ -0,0 +1,275 @@ +/** + * @fileoverview Bounded Codex rollout attribution for agent evidence. Never uses + * newest-mtime, prompt timestamps or cwd alone as proof of session ownership. + * The first call walks the store under explicit budgets; the attributed file is + * then remembered per session and only re-verified (plus a scan of the date + * directories not older than it) on later calls, since codex never prunes + * `~/.codex/sessions`. + */ +import { open, opendir, realpath } from 'node:fs/promises'; +import { probePathKind } from './utils/bounded-path-probe.js'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { LRUMap } from './utils/lru-map.js'; + +const UUID_AT_END = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; +const HEAD_BYTES = 131072; +const PREFIX_SCAN_BYTES = 8 * 1024 * 1024; +export interface CodexAttributionOptions { + codexHome: string; + sessionId: string; + workingDir: string; + resumeSessionId?: string; +} +export interface CodexAttributionBudget { + maxEntries: number; + maxBytes: number; + /** Separate bound for cheap negative prefixes; full candidate metadata has its own budget. */ + maxPrefixBytes?: number; +} +interface Metadata { + id?: string; + cwd?: string; + originator?: string; +} +async function canonical(path: string): Promise<string | undefined> { + if ((await probePathKind(path, { pastCap: true })) !== 'directory') return undefined; + try { + return await realpath(path); + } catch { + return undefined; + } +} +/** Match the CLI's relative CODEX_HOME interpretation without exposing env credentials. */ +export function resolveEffectiveCodexHome( + sessionHome: string | undefined, + workingDir: string, + env: Readonly<Record<string, string | undefined>> = process.env +): string { + const path = resolve(workingDir, sessionHome || env.CODEX_HOME || join(env.HOME || tmpdir(), '.codex')); + return path; +} +/** Complete metadata only: a prefix or a partially flushed JSONL row proves nothing. */ +function strictMetadata(bytes: Buffer): Metadata | undefined { + const rows = bytes.toString('utf8').split('\n'); + rows.pop(); + let found: Metadata | undefined; + for (const line of rows) { + try { + const row = JSON.parse(line) as { type?: string; payload?: Metadata }; + if (row.type !== 'session_meta') continue; + if ( + found || + typeof row.payload?.id !== 'string' || + typeof row.payload.cwd !== 'string' || + (row.payload.originator !== undefined && typeof row.payload.originator !== 'string') + ) + return undefined; + found = row.payload; + } catch { + /* Incomplete or malformed rows are not identity evidence. */ + } + } + return found; +} +/** + * The rollout a previous call attributed, per session. A later call re-verifies only + * this file's head (same `dev:ino`, same strict metadata) and then scans just the + * directories not older than its own for a second candidate (codex `/new`, or a new + * owned rollout beside a configured resume), instead of re-reading the whole store, + * which codex never prunes. A head that no longer verifies drops the entry and falls + * back to the full walk; a failed incremental scan drops it and answers unattributed, + * since the full walk reads a superset of that scan. Bounded: one entry per live + * session is the expected size. + */ +interface AttributionMemo { + id: string; + path: string; + dev: number; + ino: number; + meta: Metadata; + /** Directory components of `path` below `sessions/`, used to prune older siblings. */ + dirParts: string[]; +} +const attributionMemo = new LRUMap<string, AttributionMemo>({ maxSize: 1024 }); +/** Test-only: forget every remembered attribution. */ +export function __clearCodexAttributionMemo(): void { + attributionMemo.clear(); +} +/** Short first read: `originator` sits right after `id`, `timestamp` and `cwd`. */ +const PREFIX_FIRST_BYTES = 1024; +/** Fallback read when the first window holds no complete `originator` field. */ +const PREFIX_MAX_BYTES = 4096; +const ORIGINATOR_FIELD = /"originator"\s*:\s*"(?:[^"\\]|\\.)*"/; +/** + * Is a date-shaped directory name older than the remembered one at the same depth? + * Only all-digit names compare (codex writes `sessions/YYYY/MM/DD/`); anything else + * is never pruned, so an unexpected layout costs a wider scan, not a missed rollout. + */ +function olderDatePart(name: string, memo: string | undefined): boolean { + if (memo === undefined || !/^\d+$/.test(name) || !/^\d+$/.test(memo)) return false; + return Number(name) < Number(memo); +} +/** + * A configured resume and a newly owned rollout conflict rather than pinning an + * old launch forever after /new. TUI resumes of foreign-originator threads cannot + * be authoritatively tracked from this metadata; this returns attributed evidence. + */ +export async function findExactCodexConversation( + options: CodexAttributionOptions, + budget: CodexAttributionBudget = { maxEntries: 8192, maxBytes: 1048576 } +): Promise<{ id: string; path: string } | undefined> { + const workspace = await canonical(options.workingDir); + const home = await canonical(options.codexHome); + if (!workspace || !home) return undefined; + const sessions = join(home, 'sessions'); + if ((await probePathKind(sessions, { pastCap: true })) !== 'directory') return undefined; + const key = [home, options.sessionId, options.resumeSessionId ?? '', workspace].join('\0'); + const remembered = attributionMemo.get(key); + if (remembered) { + const verified = await verifyRemembered(remembered, workspace, budget); + if (verified) { + const others = await scanCandidates(sessions, options, budget, remembered.dirParts); + if (others && others.length === 1 && others[0].path === remembered.path) { + return { id: remembered.id, path: remembered.path }; + } + } + attributionMemo.delete(key); + // A file that no longer verifies (replaced, rewritten, gone) gets one full walk + // now. A verified file whose incremental scan failed does not: that scan reads a + // subset of what a full walk reads under the same budget, so a second candidate, + // an exhausted budget or an unreadable entry it hit is one the full walk would hit + // too, and walking the whole never-pruned store could only end the same way. + if (verified) return undefined; + } + const candidates = await scanCandidates(sessions, options, budget); + if (!candidates || candidates.length !== 1) return undefined; + const candidate = candidates[0]; + if (!candidate.meta.cwd || (await canonical(candidate.meta.cwd)) !== workspace) return undefined; + const handle = await open(candidate.path, 'r').catch(() => undefined); + if (!handle) return undefined; + try { + const info = await handle.stat(); + const bytes = Buffer.alloc(HEAD_BYTES); + const read = await handle.read(bytes, 0, bytes.length, 0); + const meta = strictMetadata(bytes.subarray(0, read.bytesRead)); + if ( + !meta || + meta.id !== candidate.id || + meta.cwd !== candidate.meta.cwd || + meta.originator !== candidate.meta.originator + ) + return undefined; + attributionMemo.set(key, { + id: candidate.id, + path: candidate.path, + dev: info.dev, + ino: info.ino, + meta, + dirParts: relative(sessions, dirname(candidate.path)).split(sep).filter(Boolean), + }); + return { id: candidate.id, path: candidate.path }; + } finally { + await handle.close(); + } +} +/** Same file (`dev:ino`), same strict metadata, same canonical workspace: nothing else. */ +async function verifyRemembered( + memo: AttributionMemo, + workspace: string, + budget: CodexAttributionBudget +): Promise<boolean> { + if (HEAD_BYTES > budget.maxBytes) return false; + if (!memo.meta.cwd || (await canonical(memo.meta.cwd)) !== workspace) return false; + const handle = await open(memo.path, 'r').catch(() => undefined); + if (!handle) return false; + try { + const info = await handle.stat(); + if (!info.isFile() || info.dev !== memo.dev || info.ino !== memo.ino) return false; + const bytes = Buffer.alloc(HEAD_BYTES); + const read = await handle.read(bytes, 0, bytes.length, 0); + const meta = strictMetadata(bytes.subarray(0, read.bytesRead)); + return !!meta && meta.id === memo.id && meta.cwd === memo.meta.cwd && meta.originator === memo.meta.originator; + } catch { + return false; + } finally { + await handle.close(); + } +} +/** + * Every rollout under `sessions` whose strict metadata names this session (or the + * configured resume id), or undefined when the walk hit a bound or an unreadable + * entry (an incomplete inventory proves nothing). With `notOlderThan`, date-shaped + * directories older than those components are skipped. + */ +async function scanCandidates( + sessions: string, + options: CodexAttributionOptions, + budget: CodexAttributionBudget, + notOlderThan?: string[] +): Promise<Array<{ id: string; path: string; meta: Metadata }> | undefined> { + // `onBoundary`: every ancestor so far equals the remembered path, so pruning applies. + const directories = [{ path: sessions, depth: 0, onBoundary: notOlderThan !== undefined }]; + const candidates: Array<{ id: string; path: string; meta: Metadata }> = []; + const originator = `codeman_${options.sessionId}`; + const originatorJson = JSON.stringify(originator); + const explicit = options.resumeSessionId; + let entries = 0; + let prefixes = 0; + const prefixLimit = Math.min(budget.maxPrefixBytes ?? PREFIX_SCAN_BYTES, PREFIX_SCAN_BYTES); + let scanned = HEAD_BYTES; // Reserve an independent final verification. + while (directories.length) { + const dir = directories.pop()!; + const handle = await opendir(dir.path).catch(() => undefined); + if (!handle) return undefined; + for await (const entry of handle) { + if (++entries > budget.maxEntries) return undefined; + const path = join(dir.path, entry.name); + if (entry.isDirectory()) { + if (dir.depth >= 8) return undefined; + const part = notOlderThan?.[dir.depth]; + if (dir.onBoundary && olderDatePart(entry.name, part)) continue; + directories.push({ path, depth: dir.depth + 1, onBoundary: dir.onBoundary && entry.name === part }); + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + const id = entry.name.match(UUID_AT_END)?.[1]; + if (!id) continue; // no rollout id in the name: never a candidate, read nothing + const file = await open(path, 'r').catch(() => undefined); + if (!file) return undefined; + try { + const info = await file.stat(); + if (!info.isFile()) return undefined; + if (id !== explicit) { + // Cheap negative filter only; a match still needs full parsed metadata. + // A short first window decides when it already holds the whole + // `originator` field; only otherwise is the wider window read. + let window = Math.min(info.size, PREFIX_FIRST_BYTES); + let prefix = ''; + for (;;) { + if (prefixes + window > prefixLimit) return undefined; + prefixes += window; + const buffer = Buffer.alloc(window); + const first = await file.read(buffer, 0, window, 0); + prefix = buffer.subarray(0, first.bytesRead).toString('utf8'); + if (prefix.includes(originatorJson) || ORIGINATOR_FIELD.test(prefix)) break; + const wider = Math.min(info.size, PREFIX_MAX_BYTES); + if (wider <= window) break; + window = wider; + } + if (!prefix.includes(originatorJson)) continue; + } + const size = Math.min(info.size, HEAD_BYTES); + if (scanned + size > budget.maxBytes) return undefined; + scanned += size; + const bytes = Buffer.alloc(size); + const read = await file.read(bytes, 0, size, 0); + const meta = strictMetadata(bytes.subarray(0, read.bytesRead)); + if (!meta || meta.id !== id) continue; + if (id === explicit || meta.originator === originator) candidates.push({ id, path, meta }); + } finally { + await file.close(); + } + } + } + } + return candidates; +} diff --git a/src/agent-inspection.ts b/src/agent-inspection.ts new file mode 100644 index 00000000..eec6c159 --- /dev/null +++ b/src/agent-inspection.ts @@ -0,0 +1,208 @@ +/** + * @fileoverview Bounded append-only JSONL evidence reads for agent inspection. + * Opaque, expiring server-side cursors contain no public filesystem paths. A cursor + * binds the caller, pane generation, attributed conversation and opened file identity. + * Complete records only; an oversized row stalls explicitly rather than losing evidence. + */ +import { open, type FileHandle } from 'node:fs/promises'; +import { createHash, randomUUID } from 'node:crypto'; +import { isUtf8 } from 'node:buffer'; + +export const INSPECT_MAX_BYTES = 65536; +export const INSPECT_SCAN_BYTES = 262144; +export interface InspectionScope { + owner: string; + sessionId: string; + generation: string; + conversationId: string; +} +interface CursorState { + scope: string; + /** Owner and session only: the unit the per-scope cap counts. */ + holder: string; + path: string; + identity: string; + offset: number; + observedSize: number; + modifiedAt: number; + changedAt: number; + anchor: string; + expiresAt: number; +} +export class InspectionConflict extends Error {} + +/** UTF8-safe suffix, also used for explicitly non-incremental pane evidence. */ +export function boundedUtf8Tail(text: string, maxBytes: number): string { + // A UTF8 suffix fits within this many UTF16 units. Include a pair of extra + // units so slicing cannot introduce a surrogate replacement inside the result. + const bytes = Buffer.from(text.slice(-maxBytes - 2)); + let start = Math.max(0, bytes.length - maxBytes); + while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++; + return bytes.subarray(start).toString('utf8'); +} +async function anchor(handle: FileHandle, offset: number): Promise<string> { + const count = Math.min(offset, 4096); + const head = Buffer.alloc(count); + const tail = Buffer.alloc(count); + await handle.read(head, 0, count, 0); + await handle.read(tail, 0, count, Math.max(0, offset - count)); + return createHash('sha256').update(head).update(tail).digest('hex'); +} + +/** Cursors kept per owner and session; older ones are retired first. */ +export const INSPECT_CURSORS_PER_SCOPE = 8; + +export class InspectionReader { + private readonly cursors = new Map<string, CursorState>(); + constructor( + private readonly now = Date.now, + private readonly capacity = 1024, + private readonly ttlMs = 900000, + private readonly perScope = INSPECT_CURSORS_PER_SCOPE + ) {} + + async read(path: string, scope: InspectionScope, maxBytes: number, since?: string) { + if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > INSPECT_MAX_BYTES) + throw new Error('Invalid byte budget'); + const scopeKey = JSON.stringify(scope); + const previous = since ? this.cursors.get(since) : undefined; + if ( + since && + (!previous || previous.expiresAt <= this.now() || previous.scope !== scopeKey || previous.path !== path) + ) { + throw new InspectionConflict('Invalid or expired inspection cursor; inspect without since to start again'); + } + const handle = await open(path, 'r'); + try { + const before = await handle.stat(); + if (!before.isFile()) throw new InspectionConflict('Inspection source is not a regular file'); + const identity = `${before.dev}:${before.ino}:${before.birthtimeMs}`; + let offset = previous?.offset ?? Math.max(0, before.size - INSPECT_SCAN_BYTES + 1); + if ( + previous && + (identity !== previous.identity || + before.size < previous.observedSize || + (before.size === previous.observedSize && + (before.mtimeMs !== previous.modifiedAt || before.ctimeMs !== previous.changedAt)) || + (await anchor(handle, offset)) !== previous.anchor) + ) { + throw new InspectionConflict('Inspection cursor source changed; inspect without since to start again'); + } + let startsAtBoundary = offset === 0; + if (!previous && offset > 0) { + const preceding = Buffer.alloc(1); + await handle.read(preceding, 0, 1, offset - 1); + startsAtBoundary = preceding[0] === 10; + } + const scan = Buffer.alloc( + Math.min(INSPECT_SCAN_BYTES - (!previous && offset > 0 ? 1 : 0), Math.max(0, before.size - offset)) + ); + const { bytesRead } = await handle.read(scan, 0, scan.length, offset); + let selectedStart = 0; + let consumed = 0; + let blockedReason: string | undefined; + let omittedBeforeBytes = 0; + if (!previous) { + const leading = startsAtBoundary ? 0 : scan.indexOf(10) + 1; + const completeEnd = scan.lastIndexOf(10, bytesRead - 1) + 1; + if (!startsAtBoundary && leading === 0) { + // The tail lies wholly inside one unfinished oversized record. Its + // beginning is not known: retain the known file boundary rather than + // manufacture a cursor pointing into a JSON/UTF8 fragment. + offset = 0; + blockedReason = 'record-exceeds-scan-limit'; + } else { + selectedStart = completeEnd; + consumed = completeEnd; + while (selectedStart > leading) { + const recordStart = Math.max(leading, scan.lastIndexOf(10, selectedStart - 2) + 1); + if (completeEnd - recordStart > maxBytes) { + if (selectedStart === completeEnd) { + selectedStart = recordStart; + consumed = recordStart; + blockedReason = 'record-exceeds-output-budget'; + } + break; + } + selectedStart = recordStart; + } + if (completeEnd < bytesRead && !blockedReason) blockedReason = 'partial-record'; + // If the only known boundary is the end of a skipped leading row, + // the remainder is a partial record and stays pending there. + if (completeEnd < leading) selectedStart = consumed = leading; + omittedBeforeBytes = offset + selectedStart; + } + } else { + while (consumed < bytesRead) { + const end = scan.indexOf(10, consumed); + if (end < 0) { + blockedReason = bytesRead === INSPECT_SCAN_BYTES ? 'record-exceeds-scan-limit' : 'partial-record'; + break; + } + if (end + 1 > maxBytes) { + if (consumed === 0) blockedReason = 'record-exceeds-output-budget'; + break; + } + consumed = end + 1; + } + } + const evidenceBytes = scan.subarray(selectedStart, consumed); + if (!isUtf8(evidenceBytes)) throw new InspectionConflict('Inspection source contains invalid UTF8'); + for (const row of evidenceBytes.toString('utf8').split('\n').slice(0, -1)) { + try { + JSON.parse(row); + } catch { + throw new InspectionConflict('Inspection source contains invalid JSONL'); + } + } + const after = await handle.stat(); + if ( + after.size < before.size || + after.ino !== before.ino || + (after.size === before.size && (after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs)) + ) + throw new InspectionConflict('Inspection source changed during read'); + const nextAnchor = await anchor(handle, offset + consumed); + const cursor = randomUUID(); + // No await between capacity enforcement and insertion: concurrent reads + // must not all reserve the same remaining slot while hashing anchors. + // Each owner and session keeps only its newest cursors, so one agent polling in + // a loop retires its own oldest ones instead of pushing every other caller's out + // of the shared map. The cursor just used stays among the newest, so a retry of + // the last call still works. The global capacity is only a backstop. + const holder = JSON.stringify([scope.owner, scope.sessionId]); + const mine: string[] = []; + for (const [key, value] of this.cursors) { + if (value.expiresAt <= this.now()) this.cursors.delete(key); + else if (value.holder === holder) mine.push(key); + } + while (mine.length >= this.perScope) this.cursors.delete(mine.shift()!); + while (this.cursors.size >= this.capacity) this.cursors.delete(this.cursors.keys().next().value!); + this.cursors.set(cursor, { + scope: scopeKey, + holder, + path, + identity, + offset: offset + consumed, + observedSize: before.size, + modifiedAt: before.mtimeMs, + changedAt: before.ctimeMs, + anchor: nextAnchor, + expiresAt: this.now() + this.ttlMs, + }); + return { + evidence: evidenceBytes.toString('utf8'), + cursor, + omittedBeforeBytes, + truncated: omittedBeforeBytes > 0 || offset + consumed < before.size, + blockedReason, + scannedBytes: bytesRead, + }; + } finally { + await handle.close(); + } + } + clear(): void { + this.cursors.clear(); + } +} diff --git a/src/agent-staging.ts b/src/agent-staging.ts new file mode 100644 index 00000000..f79e1234 --- /dev/null +++ b/src/agent-staging.ts @@ -0,0 +1,356 @@ +/** + * @fileoverview Short-lived, bounded guarded prompt receipts. No prompt content is + * retained: only caller/session identity, lifecycle/input revisions and SHA-256. + * A receipt is consumed synchronously before verification/delivery, including failures. + */ +import { createHash, randomUUID } from 'node:crypto'; +import { stripAnsi } from './utils/regex-patterns.js'; +import { visibleWidth } from './tui/tui-ansi.js'; +import { compileVersionRegex } from './config/cli-registry/patterns.js'; +import type { CliEntry } from './config/cli-registry/types.js'; + +/** + * The screen chrome a guarded composer read relies on, all from the CLI registry + * (`capabilities.workDetect`): the composer glyph, the pattern every row below an + * idle composer must match, and what opens a popup or turns Enter into a command. + * Nothing here names a CLI; a CLI takes part in guarded input by declaring + * `composerFooterLine`. + */ +export interface GuardedComposerChrome { + promptGlyph: string; + footerLine: RegExp; + /** A row of a suggestion popup drawn directly above the composer (`composerPopupLine`). */ + popupLine?: RegExp; + /** First characters of a draft that make Enter run a command (`composerCommandPrefixes`). */ + commandPrefixes?: string; + /** First characters of the word at the caret that open a completion popup (`composerCompletionSigils`). */ + completionSigils?: string; +} +/** The chrome a registry entry declares, or null (no guarded input for that CLI). */ +export function guardedComposerChrome(entry: CliEntry | undefined): GuardedComposerChrome | null { + const detect = entry?.capabilities.workDetect; + if (!detect?.promptGlyph || !detect.composerFooterLine) return null; + // Config-supplied (~/.codeman/clis.json): compiled through the ReDoS guard, null on refusal. + const footerLine = compileVersionRegex(detect.composerFooterLine); + if (!footerLine) return null; + // A declared popup pattern the guard refuses is no chrome at all: dropping it would + // silently read a popup frame as an idle composer. + const popupLine = detect.composerPopupLine ? compileVersionRegex(detect.composerPopupLine) : undefined; + if (popupLine === null) return null; + return { + promptGlyph: detect.promptGlyph, + footerLine, + ...(popupLine ? { popupLine } : {}), + ...(detect.composerCommandPrefixes ? { commandPrefixes: detect.composerCommandPrefixes } : {}), + ...(detect.composerCompletionSigils ? { completionSigils: detect.composerCompletionSigils } : {}), + }; +} +/** + * Why a message cannot be staged into this composer, or null. Typing it would change + * what Enter does rather than what it sends: a draft starting with a command prefix + * (codex: `/` for a slash command, `!` for a shell command) is run, not sent as a + * prompt, and a last word starting with a completion sigil (codex: `@` file and + * mention search, `$` skills) opens a popup at the caret whose highlighted entry Enter + * would accept. Checked before anything is typed. Only the LAST word matters for the + * sigils because the caret is at the end of the message and the popup follows the + * caret; a sigil word earlier in the message opens nothing. + */ +export function composerTriggerRefusal(message: string, chrome: GuardedComposerChrome): string | null { + const first = message.trimStart().charAt(0); + if (first && chrome.commandPrefixes?.includes(first)) + return `Stage refuses a message starting with "${first}": the CLI runs it as a command instead of sending it as a prompt. Send commands with \`codeman agent send\` (POST /api/v1/sessions/:id/input) instead`; + const last = message.split(/\s/).at(-1)?.charAt(0); + if (last && chrome.completionSigils?.includes(last)) + return `Stage refuses a message whose last word starts with "${last}": it opens a completion popup at the caret and Enter would accept the highlighted entry instead of sending the message. End the message with another word, or use \`codeman agent send\``; + return null; +} +/** How many rows above the composer a popup check reads: the popup cap (8) plus header and hint rows. */ +const POPUP_WINDOW_ROWS = 12; +/** Blank rows a popup can leave between itself and the composer row (the composer's own top padding, plus a popup's). */ +const POPUP_GAP_ROWS = 3; +const escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const composerRow = (chrome: GuardedComposerChrome) => + new RegExp(String.raw`^\s*${escapeRegex(chrome.promptGlyph)}(?:\s|$)`); +export const STAGE_MAX_BYTES = 8192; +export const promptSha256 = (text: string): string => createHash('sha256').update(text).digest('hex'); +export interface GuardedInputSnapshot { + generation: string; + revision: number; + composerSha: string; + /** Actual captured pane width minus the rendered composer prefix (including indentation). */ + availableColumns: number; +} +export interface GuardedInputTarget { + id: string; + /** The session's composer chrome, read before typing for what a message would trigger. */ + chrome(): GuardedComposerChrome | null; + snapshot(): GuardedInputSnapshot | null; + write(text: string): Promise<boolean>; + submit(expected: GuardedInputSnapshot): Promise<boolean>; +} +interface Receipt extends GuardedInputSnapshot { + owner: string; + sessionId: string; + expiresAt: number; + sha256: string; + /** When the staged keys went out, for `SUBMIT_QUIET_MS`. */ + typedAt: number; +} +export class GuardedInputConflict extends Error {} + +/** + * How long stage keeps reading the pane after typing for a frame that shows the + * message, and how often. A capture taken right after the keys land is often a + * frame from before them: codex repaints its composer 30 to 60 ms after input + * arrives (measured on a live 0.162.0 pane), so the first read can show the old, + * empty composer, or nothing provable at all. A second of 50 ms reads covers that + * many times over and still answers the caller quickly. + */ +export const STAGE_SETTLE_TIMEOUT_MS = 1000; +export const STAGE_SETTLE_INTERVAL_MS = 50; +/** + * The least time between the staged keys and the conditional Enter. Stage types its + * text as plain keys (`send-keys -l`), which codex's paste-burst detector reads as a + * paste, and for 120 ms after such a burst codex turns Enter into a NEWLINE rather + * than a submit (`PASTE_ENTER_SUPPRESS_WINDOW`, codex-rs/tui/src/bottom_pane/ + * paste_burst.rs at rust-v0.162.0). A caller that submits the moment stage answers + * would land inside it and be told one Enter was delivered while codex only broke + * the line. Submit waits out the rest of this window first; a keystroke in that + * time still invalidates the receipt. + */ +export const SUBMIT_QUIET_MS = 250; +/** Settle-window overrides for tests: a fake clock comes from the store's `now`. */ +export interface StageSettleOptions { + timeoutMs?: number; + intervalMs?: number; + sleep?: (ms: number) => Promise<void>; +} +/** + * Every refusal after the keys went out says so: stage cannot tell whether the text + * landed, so the caller must look before staging again (the recovery steps clear a + * stranded draft with Ctrl+U). + */ +export const MAY_BE_TYPED = + 'no receipt was issued. The message may already be in the composer: inspect the pane and follow the stranded-draft recovery steps (Ctrl+U) before staging again'; + +/** + * Conservatively read a composer: exactly one visible, unwrapped row and a known + * footer, both as the registry describes them. Never remove internal spaces, infer + * an unknown shell prompt, or interpret dialog options as authority. An unsupported + * frame yields no proof. + */ +export function readGuardedComposer(screen: string, chrome: GuardedComposerChrome): string | null { + const isComposer = composerRow(chrome); + const lines = stripAnsi(screen) + .split('\n') + .map((line) => line.trimEnd()); + const rows = lines.map((line, index) => ({ line, index })).filter((row) => isComposer.test(row.line)); + const last = rows.at(-1); + if (!last) return null; + const below = lines.slice(last.index + 1).filter((line) => line.trim()); + if (below.length === 0 || below.length > 3 || below.some((line) => !chrome.footerLine.test(line))) return null; + if (chrome.popupLine && popupAbove(lines, last.index, chrome.popupLine)) return null; + // Preserve exact printable content; only SGR-dim placeholder/suggestion text is excluded. + const raw = screen.split('\n')[last.index]; + let dim = false; + let visible = ''; + // eslint-disable-next-line no-control-regex + for (const token of raw.split(/(\x1b\[[0-9;]*m)/)) { + // eslint-disable-next-line no-control-regex + if (/^\x1b\[/.test(token)) { + const codes = token.slice(2, -1).split(';').map(Number); + for (let i = 0; i < codes.length; i++) { + // Extended color payloads may contain 0, 2 or 22; those are not SGR modes. + if (codes[i] === 38 || codes[i] === 48 || codes[i] === 58) { + i += codes[i + 1] === 2 ? 4 : codes[i + 1] === 5 ? 2 : 0; + } else if (codes[i] === 0 || codes[i] === 22) dim = false; + else if (codes[i] === 2) dim = true; + } + } else if (!dim) visible += token; + } + // The glyph and the one space after it are the prefix, not input. + return stripAnsi(visible) + .trimStart() + .slice(chrome.promptGlyph.length + 1) + .trimEnd(); +} + +/** + * Does a suggestion popup sit directly above the composer row? A popup draws over + * the rows above the composer and leaves the footer below it alone, so the footer + * check alone reads a popup frame as an idle composer. Read the contiguous block of + * rows just above (past at most `POPUP_GAP_ROWS` blank rows), never the whole pane: + * the transcript further up is the agent's own and may print anything. A transcript + * row that happens to match costs a refusal, never a receipt. + */ +function popupAbove(lines: string[], composerIndex: number, popupLine: RegExp): boolean { + let index = composerIndex - 1; + for (let gap = 0; gap < POPUP_GAP_ROWS && index >= 0 && !lines[index].trim(); gap++) index--; + for (let seen = 0; seen < POPUP_WINDOW_ROWS && index >= 0 && lines[index].trim(); seen++, index--) { + if (popupLine.test(lines[index])) return true; + } + return false; +} + +/** Require the capture's caret at the exact end of the unwrapped composer. */ +export function readGuardedSnapshot(snapshot: string, chrome: GuardedComposerChrome): string | null { + return readGuardedSnapshotDetails(snapshot, chrome)?.composer ?? null; +} +/** Exact input-row prefix width, shared by proof and preflight wrapping checks. */ +export function readGuardedSnapshotDetails( + snapshot: string, + chrome: GuardedComposerChrome +): { composer: string; prefixWidth: number } | null { + // formatPaneSnapshot emits one absolute row address per line, then the caret. + // Plain captures lack caret evidence and cannot prove invisible trailing edits. + // eslint-disable-next-line no-control-regex + const positions = [...snapshot.matchAll(/\x1b\[(\d+);(\d+)H/g)]; + if (positions.length < 2) return null; + const caret = positions.at(-1)!; + if (caret.index! + caret[0].length !== snapshot.length) return null; + const rows: string[] = []; + for (let i = 0; i < positions.length - 1; i++) { + const position = positions[i]; + if (Number(position[1]) !== i + 1 || position[2] !== '1') return null; + rows.push(snapshot.slice(position.index! + position[0].length, positions[i + 1].index)); + } + const composer = readGuardedComposer(rows.join('\n'), chrome); + if (composer === null) return null; + const isComposer = composerRow(chrome); + let row = -1; + for (let i = rows.length - 1; i >= 0; i--) + if (isComposer.test(stripAnsi(rows[i]))) { + row = i; + break; + } + const prefix = stripAnsi(rows[row]).match(new RegExp(String.raw`^(\s*${escapeRegex(chrome.promptGlyph)}[ \u00a0])`)); + if (!prefix || Number(caret[1]) !== row + 1 || Number(caret[2]) !== visibleWidth(prefix[1] + composer) + 1) + return null; + return { composer, prefixWidth: visibleWidth(prefix[1]) }; +} + +export class GuardedStageStore { + private readonly receipts = new Map<string, Receipt>(); + private readonly busy = new Set<string>(); + private readonly settleTimeoutMs: number; + private readonly settleIntervalMs: number; + private readonly sleep: (ms: number) => Promise<void>; + constructor( + private readonly now = Date.now, + private readonly capacity = 1024, + private readonly ttlMs = 120000, + settle: StageSettleOptions = {} + ) { + this.settleTimeoutMs = settle.timeoutMs ?? STAGE_SETTLE_TIMEOUT_MS; + this.settleIntervalMs = settle.intervalMs ?? STAGE_SETTLE_INTERVAL_MS; + this.sleep = settle.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + } + async stage(target: GuardedInputTarget, owner: string, message: string) { + // eslint-disable-next-line no-control-regex + const hasControls = /[\x00-\x1f\x7f-\x9f]/.test(message); + if ( + !message || + message !== message.trimEnd() || + Buffer.byteLength(message) > STAGE_MAX_BYTES || + Buffer.from(message).toString('utf8') !== message || + hasControls + ) + throw new GuardedInputConflict( + 'Stage requires printable single-line input within 8192 UTF8 bytes, without trailing whitespace' + ); + const chrome = target.chrome(); + const refusal = chrome && composerTriggerRefusal(message, chrome); + if (refusal) throw new GuardedInputConflict(refusal); + const key = target.id; + if (this.busy.has(key)) throw new GuardedInputConflict('Another guarded input operation is in progress'); + this.busy.add(key); + try { + const before = target.snapshot(); + if (!before || before.composerSha !== promptSha256('')) + throw new GuardedInputConflict('An empty, verifiable composer is required'); + if (!Number.isFinite(before.availableColumns) || visibleWidth(message) >= before.availableColumns) + throw new GuardedInputConflict('Message would reach the pane edge or wrap; shorten it or widen the pane'); + for (const [id, receipt] of this.receipts) + if (receipt.expiresAt <= this.now() || receipt.sessionId === target.id) this.receipts.delete(id); + // Refuse capacity before typing. Do not evict a caller's still-live receipt. + // Include in-flight stages (this operation already owns one busy slot), + // so different sessions cannot type concurrently into the same last slot. + if (this.receipts.size + this.busy.size > this.capacity) + throw new GuardedInputConflict('Guarded stage receipt capacity reached'); + if (!(await target.write(message))) + throw new GuardedInputConflict(`Staging delivery failed or ambiguous; ${MAY_BE_TYPED}`); + const typedAt = this.now(); + const after = await this.settle(target, before, promptSha256(message)); + const stageId = randomUUID(); + const expiresAt = this.now() + this.ttlMs; + const sha256 = promptSha256(message); + this.receipts.set(stageId, { ...after, owner, sessionId: target.id, expiresAt, sha256, typedAt }); + return { stageId, sha256, expiresAt, sessionId: target.id, submitted: false as const }; + } finally { + this.busy.delete(key); + } + } + /** + * The first frame after the write that shows exactly this message, with the input + * revision the write itself moved and nothing else changed. A null or stale frame + * (the old composer, a half-painted one) is read again until the settle window + * ends; a frame showing another writer's keystroke, a new pane lifecycle or a new + * geometry is refused at once, since waiting cannot undo that. + */ + private async settle( + target: GuardedInputTarget, + before: GuardedInputSnapshot, + expectedSha: string + ): Promise<GuardedInputSnapshot> { + const deadline = this.now() + this.settleTimeoutMs; + for (;;) { + const frame = target.snapshot(); + if ( + frame && + (frame.generation !== before.generation || + frame.revision !== before.revision + 1 || + frame.availableColumns !== before.availableColumns) + ) + throw new GuardedInputConflict(`Input or pane changed while staging; ${MAY_BE_TYPED}`); + if (frame?.composerSha === expectedSha) return frame; + if (this.now() >= deadline) + throw new GuardedInputConflict( + `The composer did not show the staged message within ${this.settleTimeoutMs} ms; ${MAY_BE_TYPED}` + ); + await this.sleep(this.settleIntervalMs); + } + } + async submit(target: GuardedInputTarget, owner: string, stageId: string, confirmSha: string) { + const receipt = this.receipts.get(stageId); + if (!receipt || receipt.owner !== owner || receipt.sessionId !== target.id) + throw new GuardedInputConflict('Unknown, used, or foreign stage receipt'); + // One shot, even a wrong hash or unprovable composer. No retry of ambiguous Enter. + this.receipts.delete(stageId); + if (this.busy.has(target.id)) throw new GuardedInputConflict('Another guarded input operation is in progress'); + this.busy.add(target.id); + try { + if (receipt.expiresAt <= this.now() || receipt.sha256 !== confirmSha) + throw new GuardedInputConflict('Expired stage receipt or confirmation hash mismatch'); + const quiet = receipt.typedAt + SUBMIT_QUIET_MS - this.now(); + if (quiet > 0) await this.sleep(quiet); + const current = target.snapshot(); + if ( + !current || + current.generation !== receipt.generation || + current.revision !== receipt.revision || + current.availableColumns !== receipt.availableColumns || + current.composerSha !== receipt.sha256 + ) + throw new GuardedInputConflict('Staged composer, input, or pane identity changed; nothing submitted'); + if (!(await target.submit(current))) + throw new GuardedInputConflict('Submission delivery failed or ambiguous; receipt consumed, inspect the pane'); + return { stageId, sessionId: target.id, submitted: true as const }; + } finally { + this.busy.delete(target.id); + } + } + clear(): void { + this.receipts.clear(); + this.busy.clear(); + } +} diff --git a/src/cli-agent.ts b/src/cli-agent.ts index 6a5fad47..bc8625be 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -2,16 +2,21 @@ * @fileoverview `codeman agent …` — session-to-session verbs for the agent running * inside a Codeman session, in every CLI mode. * - * A thin HTTP client over endpoints that already exist (`quick-start`, `input`, - * `wait`, `wait-output`, `last-response`, `terminal`, `DELETE sessions/:id`, and the - * agent inbox: `inbox`, `inbox/ack`, `agent-inbox/summary`). It - * invents no route and no transport: everything goes through `CODEMAN_API_URL`, so - * auth, ownership and the per-session waiter cap apply unchanged. The behaviour is + * A thin HTTP client over the server's endpoints (`quick-start` for a named + * case, `POST sessions` + `sessions/:id/interactive|shell` for a worker in an + * existing directory, `input`, `wait`, `wait-output`, `last-response`, `terminal`, + * `DELETE sessions/:id`, the agent inbox: `inbox`, `inbox/ack`, + * `agent-inbox/summary`, and the experimental, opt-in + * `inspect`/`stage`/`submit` of `web/routes/agent-session-routes.ts`, which answer 403 + * until `agentCoordinationEnabled` is on; that refusal is printed verbatim). It uses no + * transport of its own: everything goes through `CODEMAN_API_URL`, so auth, ownership + * and the per-session waiter cap apply unchanged. The behaviour is * the packaged agent skill's (`skills/codeman`), ported from shell prose into code * with tests, so a `codex`/`opencode`/`pi` agent — which never gets the claude-only * preamble — has the same verbs from one line in its AGENTS.md. * - * Invariants (each asserted in `test/cli-agent.test.ts`): + * Invariants (each asserted in `test/cli-agent.test.ts` or + * `test/cli-agent-project-spawn.test.ts`): * 1. Refuses outside a Codeman session (`CODEMAN_MUX=1` + `CODEMAN_API_URL`); it * never guesses a URL — a server you are not part of is not yours to drive. * 2. `send` transmits printable text plus `\r` only. ESC exists solely as @@ -23,14 +28,26 @@ * 4. An id shorter than 8 characters refuses (exit 4) before any request, on every * verb. `9` would resolve to whichever session is alone with that first * character, the user's own interactive tab included. + * 5. `spawn` without a case starts the worker in an existing directory (cwd, or + * `--working-dir`) via `POST sessions` + `interactive|shell`, without the + * `X-Codeman-Agent-Origin` header, so no agent-case marker is written. It refuses + * (exit 4, before any request) an empty `--working-dir` and a bare spawn from a + * HOME cwd: it always sends an explicit `workingDir`, so the server's skip of + * workspace hooks for a request that names none cannot protect `~/.claude`. * * Commands live here as functions returning an exit code, not calling * `process.exit`, so the whole surface is unit-testable against a fake server. * * @module cli-agent */ +import { open, realpath, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { resolve } from 'node:path'; import http from 'node:http'; import https from 'node:https'; +import { isUtf8 } from 'node:buffer'; +import { STAGE_MAX_BYTES } from './agent-staging.js'; +import { INSPECT_MAX_BYTES } from './agent-inspection.js'; import type { Command } from 'commander'; import { basicAuthHeader, @@ -343,6 +360,18 @@ export const httpRequest: ApiRequest = (ctx, options) => { }); }; +/** + * One line describing a thrown request error. Node 22 rejects a refused connection to a host + * that resolves to both ::1 and 127.0.0.1 with an AggregateError whose `message` is empty, so + * fall back to its `code` (ECONNREFUSED) before a generic line rather than printing nothing. + */ +export function describeRequestError(error: unknown): string { + const message = getErrorMessage(error); + if (message) return message; + const code = (error as { code?: unknown } | null | undefined)?.code; + return code ? String(code) : 'request failed without a message'; +} + /** One line describing a failed response, for humans. Plain-text guards (401/403/429) have no envelope. */ export function describeFailure(res: ApiResponse): string { if (res.json && !res.json.success) { @@ -372,7 +401,13 @@ export interface AgentDeps { now?: () => number; } -/** Print `data` as JSON (the `--json` path) — always the envelope's `data`, never a reshaped copy. */ +/** + * Print `data` as JSON (the `--json` path): the envelope's `data`, never a reshaped copy, + * except a directory spawn, which prints `{ sessionId, workingDir, created: true }` plus + * `ready` when a readiness wait ran, `readinessError` when that call failed, and + * `startupError` when its start fails, because `POST /api/sessions` answers with the + * whole session row. + */ function emitJson(deps: AgentDeps, data: unknown): void { deps.io.out(JSON.stringify(data, null, 2)); } @@ -509,7 +544,8 @@ export async function agentLs(deps: AgentDeps): Promise<number> { } export interface SpawnOptions { - caseName: string; + caseName?: string; + workingDir?: string; mode: string; name?: string; /** Wait for the composer before returning, where the registry gives the mode a ready mark. */ @@ -532,44 +568,135 @@ export const AGENT_ORIGIN_HEADER = { 'X-Codeman-Agent-Origin': 'codeman-agent-cl export function composerReadyMark(mode: string): string | undefined { return getCli(mode)?.capabilities.composerReadyMark; } -/** `agent spawn` — quick-start with lineage, then the readiness ladder where the mode has one. */ + +/** realpath, or the plain resolved path when it cannot be resolved; never throws. */ +async function canonicalPath(path: string): Promise<string> { + try { + return await realpath(path); + } catch { + return resolve(path); + } +} + +/** + * Whether `directory` is the user's home directory, comparing realpaths so a symlinked + * HOME (or a cwd reached through a symlink) still matches. False when HOME is unknown. + */ +export async function isHomeDirectory(directory: string): Promise<boolean> { + let home: string; + try { + home = homedir(); + } catch { + return false; + } + if (!home) return false; + const [a, b] = await Promise.all([canonicalPath(directory), canonicalPath(home)]); + return a === b; +} + +/** `agent spawn`: start in a project directory or named case, preserving lineage and mode readiness. */ export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promise<number> { + const cli = getCli(options.mode); + if (!cli) return fail(deps, `Unknown mode: ${options.mode}`, EXIT.refused); + if (options.caseName !== undefined && options.workingDir !== undefined) + return fail(deps, 'Use a case OR --working-dir, not both', EXIT.refused); + // Same rule for the flag: `--working-dir "$WT"` with WT empty (a failed + // `git worktree add`) must not fall through to the caller's own checkout, which + // `resolve('')` would make it. + if (options.workingDir !== undefined && options.workingDir.trim() === '') + return fail(deps, 'refusing: --working-dir is empty; pass an existing directory or omit the flag', EXIT.refused); + // Only an OMITTED case means "use a directory": an empty one (`spawn "$CASE"` with + // CASE unset) still goes to quick-start, whose schema refuses it. + const workingDir = options.caseName !== undefined ? undefined : resolve(options.workingDir ?? process.cwd()); + if (workingDir) { + // eslint-disable-next-line no-control-regex + if (/[\x00-\x1f\x7f]/.test(workingDir)) + return fail(deps, 'Working directory contains control characters', EXIT.refused); + try { + if (!(await stat(workingDir)).isDirectory()) throw new Error('not a directory'); + } catch { + return fail(deps, 'Working directory must be an existing local directory', EXIT.refused); + } + // The server installs workspace hooks (and, with agent skill injection on, the + // skill) into the directory it is given, and it skips that only when the caller + // names no workingDir, because the cwd fallback is $HOME under installer-created + // services. This CLI always sends its directory explicitly, so an implicit cwd of + // HOME must be refused here or it would write ~/.claude/settings.local.json and + // ~/.claude/skills. An explicit `--working-dir ~` is the caller's choice and stays. + if (options.workingDir === undefined && (await isHomeDirectory(workingDir))) + return fail( + deps, + 'refusing: the current directory is your home directory; pass --working-dir ~ to start a worker there on purpose', + EXIT.refused + ); + } const body: Record<string, unknown> = { - caseName: options.caseName, + ...(workingDir ? { workingDir } : { caseName: options.caseName }), mode: options.mode, parentSessionId: deps.ctx.selfId, }; - if (options.name) body.sessionName = options.name; + if (options.name) body[workingDir ? 'name' : 'sessionName'] = options.name; const res = await deps.request(deps.ctx, { method: 'POST', - path: '/api/v1/quick-start', + path: workingDir ? '/api/v1/sessions' : '/api/v1/quick-start', body, - headers: { ...AGENT_ORIGIN_HEADER }, + headers: workingDir ? undefined : { ...AGENT_ORIGIN_HEADER }, }); - const data = res.json?.data as { sessionId?: string; caseName?: string; casePath?: string } | undefined; + const created = res.json?.data as + | { session?: { id?: string }; sessionId?: string; caseName?: string; casePath?: string } + | undefined; + // `POST /api/sessions` answers `{ session: { id, ... } }`; quick-start answers `{ sessionId, ... }`. + const createdId = created?.session?.id; + const data: + | { sessionId?: string; caseName?: string; casePath?: string; workingDir?: string; created?: boolean } + | undefined = workingDir && createdId ? { sessionId: createdId, workingDir, created: true } : created; if (!res.json?.success || !data?.sessionId) return fail(deps, describeFailure(res)); const sid = data.sessionId; + if (workingDir) { + // Creation alone leaves a session without a pane. Report the exact created id + // on an ambiguous or rejected start; never retry a start or hide a leaked tab. + let startupError: string | undefined; + try { + const started = await deps.request(deps.ctx, { + method: 'POST', + path: `/api/v1/sessions/${encodeURIComponent(sid)}/${cli.capabilities.startMode}`, + }); + if (!started.json?.success) startupError = describeFailure(started); + } catch (error) { + startupError = getErrorMessage(error) || 'Startup request failed'; + } + if (startupError) { + if (deps.json) emitJson(deps, { ...data, ready: false, startupError }); + else deps.io.out(sid); + deps.io.err(`Session ${sid} was created but startup failed or is ambiguous: ${startupError}`); + return EXIT.error; + } + } let ready: boolean | undefined; let readinessError: string | undefined; let dead = false; const mark = composerReadyMark(options.mode); if (options.ready && mark) { - const wait = await deps.request(deps.ctx, { - method: 'GET', - path: `/api/v1/sessions/${encodeURIComponent(sid)}/wait-output`, - query: { match: mark, from: 'buffer', timeout: options.timeoutMs }, - timeoutMs: options.timeoutMs + 10_000, - }); - // A failed readiness call (waiter cap, 400, network) is its own error, not "the - // composer never showed up": report the real reason instead of the trust-dialog hint. - if (!wait.json?.success) readinessError = describeFailure(wait); - else { - const result = (wait.json.data as { wait?: WaitResult } | undefined)?.wait; - // A worker that died while we waited is exit 3 like every other wait, not a - // "composer not seen" timeout that sends the caller looking for a dialog. - dead = waitExitCode(result) === EXIT.dead; - ready = !dead && Boolean(result?.matched); + try { + const wait = await deps.request(deps.ctx, { + method: 'GET', + path: `/api/v1/sessions/${encodeURIComponent(sid)}/wait-output`, + query: { match: mark, from: 'buffer', timeout: options.timeoutMs }, + timeoutMs: options.timeoutMs + 10_000, + }); + // A failed readiness call (waiter cap, 400, network) is its own error, not "the + // composer never showed up": report the real reason instead of the trust-dialog hint. + if (!wait.json?.success) readinessError = describeFailure(wait); + else { + const result = (wait.json.data as { wait?: WaitResult } | undefined)?.wait; + // A worker that died while we waited is exit 3 like every other wait, not a + // "composer not seen" timeout that sends the caller looking for a dialog. + dead = waitExitCode(result) === EXIT.dead; + ready = !dead && Boolean(result?.matched); + } + } catch (error) { + readinessError = describeRequestError(error); } } @@ -578,7 +705,7 @@ export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promis } else { // Human lines go to stderr so `SID=$(codeman agent spawn …)` captures the id alone. const say = (line: string) => deps.io.err(line); - say(palette.ok(`${GLYPH.ok} spawned ${sid} (${options.mode}, case ${data.caseName ?? options.caseName})`)); + say(palette.ok(`${GLYPH.ok} spawned ${sid} (${options.mode}, ${workingDir ?? data.caseName ?? options.caseName})`)); if (ready === true) say(palette.muted(' composer up: the worker can take a prompt')); if (dead) say(palette.err(`${GLYPH.fail} the worker exited during the readiness wait`)); if (ready === false && !dead) { @@ -757,6 +884,103 @@ export async function agentWait(deps: AgentDeps, options: WaitOptions): Promise< return waitExitCode(data?.wait); } +/** Bounded incremental evidence, with an opaque server-owned cursor. */ +export async function agentInspect( + deps: AgentDeps, + options: { id: string; maxBytes: number; since?: string } +): Promise<number> { + if (!Number.isInteger(options.maxBytes) || options.maxBytes < 1 || options.maxBytes > INSPECT_MAX_BYTES) + return fail(deps, '--max-bytes must be between 1 and 65536', EXIT.refused); + const target = await resolveSessionId(deps, options.id); + if ('error' in target) return fail(deps, target.error, target.code); + const response = await deps.request(deps.ctx, { + method: 'GET', + path: `/api/v1/sessions/${encodeURIComponent(target.id)}/inspect`, + query: { maxBytes: options.maxBytes, since: options.since }, + }); + if (!response.json?.success) return fail(deps, describeFailure(response)); + if (deps.json) emitJson(deps, response.json.data); + else { + const data = response.json.data as { + source?: string; + evidence?: string; + truncated?: boolean; + cursor?: string; + blockedReason?: string; + }; + deps.io.out( + `[${data.source ?? 'unknown'}]${data.truncated ? ' truncated' : ''}${data.blockedReason ? ` (${data.blockedReason})` : ''}` + ); + deps.io.out(data.evidence ?? ''); + if (data.cursor) deps.io.out(`cursor: ${data.cursor}`); + } + return EXIT.ok; +} + +/** Stage exactly the printable UTF8 file contents; never sends Enter. */ +export async function agentStage(deps: AgentDeps, options: { id: string; messageFile: string }): Promise<number> { + if (isSelfSession(deps.ctx.selfId, options.id)) return fail(deps, `refusing: ${options.id} is me`, EXIT.refused); + let message: string; + try { + const info = await stat(options.messageFile); + if (!info.isFile() || info.size > STAGE_MAX_BYTES) + return fail(deps, 'message file must be a regular file within 8192 UTF8 bytes', EXIT.refused); + const handle = await open(options.messageFile, 'r'); + try { + const buffer = Buffer.alloc(STAGE_MAX_BYTES + 1); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + const bytes = buffer.subarray(0, bytesRead); + if (bytesRead > STAGE_MAX_BYTES || !isUtf8(bytes)) + return fail(deps, 'message file must contain at most 8192 bytes of UTF8', EXIT.refused); + message = bytes.toString('utf8'); + } finally { + await handle.close(); + } + } catch (error) { + return fail(deps, `cannot read message file: ${getErrorMessage(error)}`, EXIT.refused); + } + const refusal = inputRefusal(message); + if (refusal || message !== message.trimEnd()) + return fail(deps, refusal ?? 'stage refuses trailing whitespace', EXIT.refused); + const target = await resolveSessionId(deps, options.id); + if ('error' in target) return fail(deps, target.error, target.code); + const response = await deps.request(deps.ctx, { + method: 'POST', + path: `/api/v1/sessions/${encodeURIComponent(target.id)}/stage`, + body: { message }, + }); + if (!response.json?.success) return fail(deps, describeFailure(response)); + if (deps.json) emitJson(deps, response.json.data); + else { + const data = response.json.data as { stageId: string; sha256: string; expiresAt: number }; + deps.io.out( + `staged (no Enter): ${data.stageId}\nsha256: ${data.sha256}\nexpires: ${new Date(data.expiresAt).toISOString()}` + ); + } + return EXIT.ok; +} + +/** Submit one verified staged composer; failure never automatically replays Enter. */ +export async function agentSubmit( + deps: AgentDeps, + options: { id: string; stageId: string; confirmSha: string } +): Promise<number> { + if (isSelfSession(deps.ctx.selfId, options.id)) return fail(deps, `refusing: ${options.id} is me`, EXIT.refused); + if (!/^[a-f0-9]{64}$/.test(options.confirmSha) || !/^[a-f0-9-]{36}$/.test(options.stageId)) + return fail(deps, 'submit requires a stage UUID and its exact SHA-256', EXIT.refused); + const target = await resolveSessionId(deps, options.id); + if ('error' in target) return fail(deps, target.error, target.code); + const response = await deps.request(deps.ctx, { + method: 'POST', + path: `/api/v1/sessions/${encodeURIComponent(target.id)}/submit`, + body: { stageId: options.stageId, confirmSha: options.confirmSha }, + }); + if (!response.json?.success) return fail(deps, describeFailure(response)); + if (deps.json) emitJson(deps, response.json.data); + else deps.io.out('Enter delivered once; inspect or wait for acceptance (no automatic retry).'); + return EXIT.ok; +} + export interface ReadOptions { id: string; /** Bytes of raw terminal to fetch; ANSI is stripped for humans. */ @@ -1038,7 +1262,7 @@ async function run(json: boolean, verb: (deps: AgentDeps) => Promise<number>): P try { process.exitCode = await verb(deps); } catch (err) { - console.error(palette.err(`${GLYPH.fail} ${getErrorMessage(err)}`)); + console.error(palette.err(`${GLYPH.fail} ${describeRequestError(err)}`)); process.exitCode = EXIT.error; } } @@ -1048,7 +1272,7 @@ export function registerAgentCommands(program: Command): Command { const agent = program .command('agent') .description( - 'Talk to other sessions from inside one (any CLI mode): list, spawn, send, wait, read, interrupt, rm, and the inbox: post, inbox, ack' + 'Talk to other sessions from inside one (any CLI mode): list, spawn, send, wait, read, interrupt, rm, the inbox: post, inbox, ack, and the experimental, opt-in inspect, stage, submit' ); agent @@ -1059,20 +1283,26 @@ export function registerAgentCommands(program: Command): Command { .action((options: { json?: boolean }) => run(Boolean(options.json), agentLs)); agent - .command('spawn <case>') - .description( - 'Start a worker session in a case (created if missing) and wait for its composer where the mode draws one' - ) + .command('spawn [case]') + .description('Start a worker in cwd, an existing directory, or a named case; wait for its composer where supported') .option('-m, --mode <mode>', 'Run mode id, as the Run menu names it', 'claude') .option('-n, --name <name>', 'Session name shown in the UI') + .option( + '--working-dir <directory>', + 'Existing project directory (defaults to cwd when no case is given; a cwd of HOME is refused, pass it explicitly)' + ) .option('--no-ready', 'Return as soon as the session exists, without the readiness wait') .option('-t, --timeout <ms>', 'Readiness budget in ms', String(DEFAULT_WAIT_MS)) .option('--json', 'Machine-readable output') .action( - (caseName: string, options: { mode: string; name?: string; ready: boolean; timeout?: string; json?: boolean }) => + ( + caseName: string | undefined, + options: { mode: string; name?: string; workingDir?: string; ready: boolean; timeout?: string; json?: boolean } + ) => run(Boolean(options.json), (deps) => agentSpawn(deps, { caseName, + workingDir: options.workingDir, mode: options.mode, name: options.name, ready: options.ready, @@ -1169,6 +1399,49 @@ export function registerAgentCommands(program: Command): Command { ) ); + agent + .command('inspect <id>') + .description( + 'Experimental, opt-in (agentCoordinationEnabled): bounded transcript evidence or a labeled pane snapshot; opaque incremental cursors' + ) + .option('--max-bytes <n>', 'UTF8 evidence budget (1–65536)', '8192') + .option('--since <cursor>', 'Resume a cursor from the same owner, pane and transcript') + .option('--json', 'Machine-readable output') + .action((id: string, options: { maxBytes: string; since?: string; json?: boolean }) => + run(Boolean(options.json), (deps) => + agentInspect(deps, { + id, + maxBytes: parsePositiveInt(options.maxBytes, 8192, '--max-bytes'), + since: options.since, + }) + ) + ); + + agent + .command('stage <id>') + .description( + 'Experimental, opt-in (agentCoordinationEnabled): type a printable single-line UTF8 file without Enter; requires a verifiable local Codex composer, and refuses slash or shell commands and a last word starting with @ or $ (use send)' + ) + .requiredOption('--message-file <path>', 'Regular UTF8 file, at most 8192 bytes, no trailing newline') + .option('--json', 'Machine-readable output') + .action((id: string, options: { messageFile: string; json?: boolean }) => + run(Boolean(options.json), (deps) => agentStage(deps, { id, messageFile: options.messageFile })) + ); + + agent + .command('submit <id>') + .description( + 'Experimental, opt-in (agentCoordinationEnabled): send Enter once only when the staged composer, pane and hash still match' + ) + .requiredOption('--stage-id <receipt>', 'One-shot stage receipt') + .requiredOption('--confirm-sha <hash>', 'Exact SHA-256 returned by stage') + .option('--json', 'Machine-readable output') + .action((id: string, options: { stageId: string; confirmSha: string; json?: boolean }) => + run(Boolean(options.json), (deps) => + agentSubmit(deps, { id, stageId: options.stageId, confirmSha: options.confirmSha }) + ) + ); + agent .command('read <id>') .description("Print a session's last answer (as the server reads it for that mode) or, with --tail, its terminal") diff --git a/src/cli-history.ts b/src/cli-history.ts new file mode 100644 index 00000000..238224a2 --- /dev/null +++ b/src/cli-history.ts @@ -0,0 +1,109 @@ +/** + * @fileoverview `codeman history read`: the command-line front of the offline + * Codex history reader (`local-history.ts`). + * + * Deliberately separate from `codeman agent` (`cli-agent.ts`): that command is + * a client of a running Codeman and refuses outside a managed session, while + * this one reads rollout files from disk and needs neither a server nor + * `CODEMAN_MUX`. It never writes, never inspects a live pane and never sends + * input. + * + * Every bound the reader enforces has a flag here with the reader's own + * maximum, so a large store is a matter of raising a number rather than an + * impossible lookup. Output is JSON unless `--format text` is given; `--json` + * is the JSON spelling and refuses to combine with `--format text`. + * + * @module cli-history + */ +import type { Command } from 'commander'; +import { + formatLocalHistory, + HISTORY_DEFAULT_MAX_ENTRIES, + HISTORY_DEFAULT_METADATA_BYTES, + HISTORY_DEFAULT_OUTPUT_BYTES, + HISTORY_DEFAULT_SCAN_BYTES, + HISTORY_MAX_ENTRIES, + HISTORY_MAX_METADATA_BYTES, + HISTORY_MAX_OUTPUT_BYTES, + HISTORY_MAX_SCAN_BYTES, + readLocalHistory, + validateOutputBytes, +} from './local-history.js'; + +/** + * A budget flag's value as a number. Only plain decimal digits count, so `0x10`, + * `1e3` and ` 5 ` are refused rather than read as 16, 1000 and 5; anything else + * becomes NaN, which the reader's range check rejects with the flag's name. + */ +function budgetFlag(value: string | boolean | undefined): number { + return typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : NaN; +} + +export function registerHistoryCommands(program: Command): Command { + const history = program + .command('history') + .description('Read local saved native transcripts without a running server'); + history + .command('read') + .description('Read Codex history by file, native id, or root conversation and descendants') + .option('--file <path>', 'Explicit local JSONL transcript (regular file only)') + .option('--session-id <id>', 'Native Codex conversation UUID or unique prefix (at least 8 characters)') + .option('--root-session-id <uuid>', 'Native root UUID, including transitive child conversations') + .option( + '--sessions-root <path>', + 'Local Codex sessions directory (default: $CODEX_HOME/sessions, else ~/.codex/sessions)' + ) + .option('--topic <text>', 'Case-insensitive literal match, preserving whole matching turns') + .option('--after <date>', 'Inclusive YYYY-MM-DD lower bound, as a UTC date; preserves whole matching turns') + .option('--before <date>', 'Inclusive YYYY-MM-DD upper bound, as a UTC date; preserves whole matching turns') + .option( + '--max-bytes <n>', + `Maximum complete output bytes (1-${HISTORY_MAX_OUTPUT_BYTES})`, + String(HISTORY_DEFAULT_OUTPUT_BYTES) + ) + .option( + '--scan-bytes <n>', + `Read budget for the selected transcripts (1-${HISTORY_MAX_SCAN_BYTES})`, + String(HISTORY_DEFAULT_SCAN_BYTES) + ) + .option( + '--metadata-bytes <n>', + `Read budget for attributing every rollout in the store (1-${HISTORY_MAX_METADATA_BYTES})`, + String(HISTORY_DEFAULT_METADATA_BYTES) + ) + .option( + '--max-entries <n>', + `Directory entries visited in the store (1-${HISTORY_MAX_ENTRIES})`, + String(HISTORY_DEFAULT_MAX_ENTRIES) + ) + .option('--format <format>', 'json or text', 'json') + .option('--json', 'JSON output (the default; conflicts with --format text)') + .action(async (options: Record<string, string | boolean | undefined>, command: Command) => { + try { + if (options.format !== 'json' && options.format !== 'text') throw new Error('--format must be json or text'); + if (options.json && options.format === 'text' && command.getOptionValueSource('format') === 'cli') + throw new Error('--json conflicts with --format text; pass one of them'); + const format = options.format; + // Every flag is checked before the first file is read: --max-bytes would otherwise + // only be judged after the whole store had been scanned. + const maxBytes = budgetFlag(options.maxBytes); + validateOutputBytes(maxBytes); + const result = await readLocalHistory({ + file: options.file as string | undefined, + sessionId: options.sessionId as string | undefined, + rootSessionId: options.rootSessionId as string | undefined, + sessionsRoot: options.sessionsRoot as string | undefined, + topic: options.topic as string | undefined, + after: options.after as string | undefined, + before: options.before as string | undefined, + scanBytes: budgetFlag(options.scanBytes), + metadataBytes: budgetFlag(options.metadataBytes), + maxEntries: budgetFlag(options.maxEntries), + }); + program.configureOutput().writeOut?.(`${formatLocalHistory(result, format, maxBytes)}\n`); + } catch (error) { + program.error(error instanceof Error ? error.message : String(error), { exitCode: 1, code: 'history.read' }); + } + }); + return history; +} diff --git a/src/cli-notify.ts b/src/cli-notify.ts new file mode 100644 index 00000000..5cdae57a --- /dev/null +++ b/src/cli-notify.ts @@ -0,0 +1,129 @@ +/** + * @fileoverview `codeman notify`: post to this tab's Discord thread (POST /api/sessions/:id/discord-notify). + * + * A CLI that is blocked on something only the user can do (a physical step, a blocker, a timed-out + * wait) runs this. It reads CODEMAN_SESSION_ID and CODEMAN_API_URL, which Codeman sets in every + * session, and the shared credentials, so the caller handles no auth. `runNotify` returns what to + * print and the exit code instead of exiting, so a test can drive it. + * + * @module cli-notify + */ + +import http from 'node:http'; +import https from 'node:https'; +import { isAbsolute, join } from 'node:path'; +import { readCodemanCredentials } from './codeman-credentials.js'; + +const REQUEST_TIMEOUT_MS = 30_000; + +export interface NotifyOptions { + message: string; + urgent?: boolean; + file?: string; + session?: string; + url?: string; +} + +export interface NotifyResult { + code: number; + out?: string; + err?: string; +} + +export type JsonPost = ( + apiUrl: string, + apiPath: string, + payload: unknown, + timeoutMs?: number +) => Promise<{ status: number; error?: string; delivery?: { ok: boolean; error?: string } }>; + +/** POST JSON to the Codeman API with the same credentials `codeman attach` uses. Status 0 means no answer. */ +export const postJson: JsonPost = (apiUrl, apiPath, payload, timeoutMs = REQUEST_TIMEOUT_MS) => { + const { username, password } = readCodemanCredentials(); + const url = new URL(apiPath, apiUrl); + const body = JSON.stringify(payload); + const transport = url.protocol === 'https:' ? https : http; + return new Promise((resolveOnce) => { + // Settle once: a response that stalls after its headers ends in 'close' without 'end'. + let settled = false; + const resolve: typeof resolveOnce = (v) => { + if (settled) return; + settled = true; + resolveOnce(v); + }; + const headers: Record<string, string | number> = { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }; + if (password) headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; + const req = transport.request( + { + protocol: url.protocol, + hostname: url.hostname, + port: url.port, + method: 'POST', + path: `${url.pathname}${url.search}`, + rejectUnauthorized: false, + headers, + timeout: timeoutMs, + }, + (res) => { + let text = ''; + res.on('data', (c) => (text += c)); + res.on('error', () => resolve({ status: 0 })); + res.on('close', () => resolve({ status: 0 })); + res.on('end', () => { + let error: string | undefined; + let delivery: { ok: boolean; error?: string } | undefined; + try { + const body = JSON.parse(text) as { error?: string; data?: { ok?: unknown; error?: string } }; + error = body.error; + // A 200 only says Codeman took the request; `data.ok` says whether Discord took the post. + if (typeof body.data?.ok === 'boolean') delivery = { ok: body.data.ok, error: body.data.error }; + } catch { + /* not JSON */ + } + resolve({ status: res.statusCode ?? 0, error, delivery }); + }); + } + ); + // A server that takes the connection and never answers must not hang the agent: status 0 is "no answer". + req.on('timeout', () => req.destroy()); + req.on('error', () => resolve({ status: 0 })); + req.write(body); + req.end(); + }); +}; + +export async function runNotify( + opts: NotifyOptions, + env: NodeJS.ProcessEnv, + cwd: string, + post: JsonPost = postJson +): Promise<NotifyResult> { + const sessionId = opts.session || env.CODEMAN_SESSION_ID; + const apiUrl = opts.url || env.CODEMAN_API_URL; + if (!sessionId || !apiUrl) { + return { code: 1, err: 'notify must run inside a Codeman session (CODEMAN_SESSION_ID and CODEMAN_API_URL)' }; + } + const payload: { message: string; urgent?: boolean; file?: string } = { message: opts.message }; + if (opts.urgent) payload.urgent = true; + if (opts.file) payload.file = isAbsolute(opts.file) ? opts.file : join(cwd, opts.file); + const { status, error, delivery } = await post( + apiUrl, + `/api/sessions/${encodeURIComponent(sessionId)}/discord-notify`, + payload + ); + if (status >= 200 && status < 300) { + if (delivery?.ok === false) { + return { code: 1, err: `Could not post to Discord${delivery.error ? `: ${delivery.error}` : ''}` }; + } + return { code: 0, out: 'Posted to Discord' }; + } + // The bridge being off is not a failure: the caller just says it in the terminal. + if (status === 409) return { code: 0, out: 'The Discord bridge is off. Say it in the terminal instead.' }; + if (status === 429) + return { code: 1, err: 'Too many messages to Discord from this tab. Wait a minute, then send one.' }; + return { code: 1, err: `Could not post to Discord${error ? `: ${error}` : status ? ` (HTTP ${status})` : ''}` }; +} diff --git a/src/cli.ts b/src/cli.ts index ba07793b..b28ee6a0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,6 +16,7 @@ import { isAbsolute, join } from 'node:path'; import { homedir } from 'node:os'; import { dataPath } from './config/instance.js'; import { readCodemanCredentials } from './codeman-credentials.js'; +import { runNotify } from './cli-notify.js'; import { casePath } from './config/cases-dir.js'; import { assertValidBasePath } from './config/base-path.js'; import { @@ -29,6 +30,7 @@ import { import type { AgentSkillDir } from './config/cli-registry/types.js'; import { getCli, listClis } from './config/cli-registry/registry.js'; import { registerAgentCommands } from './cli-agent.js'; +import { registerHistoryCommands } from './cli-history.js'; import { resolveMcpConfig, runMcpStdio } from './mcp-server.js'; import { getSessionManager } from './session-manager.js'; import { getTaskQueue } from './task-queue.js'; @@ -91,6 +93,22 @@ async function postAttachment(apiUrl: string, sessionId: string, filePath: strin }); } +program + .command('notify <message>') + .description( + "Post a message to this tab's Discord thread (needs the user: a physical step, a blocker, a timed-out wait)" + ) + .option('--urgent', 'Ping the user') + .option('--file <path>', 'Send a file from the session folder with the message') + .option('-s, --session <id>', 'Codeman session ID (defaults to CODEMAN_SESSION_ID)') + .option('--url <url>', 'Codeman API URL (defaults to CODEMAN_API_URL)') + .action(async (message: string, options) => { + const { code, out, err } = await runNotify({ message, ...options }, process.env, process.cwd()); + if (out) console.log(code === 0 && out.startsWith('Posted') ? palette.ok(`✓ ${out}`) : out); + if (err) console.error(palette.err(`✗ ${err}`)); + process.exit(code); + }); + program .command('attach <path>') .description('Show an attachment card for a local file') @@ -324,6 +342,7 @@ skillCmd // ============ Agent Commands (session-to-session, any CLI mode) ============ registerAgentCommands(program); +registerHistoryCommands(program); // ============ MCP server (any MCP client drives Codeman) ============ @@ -950,6 +969,10 @@ function addWebLaunchOptions(cmd: Command): Command { '--allow-unauthenticated-network', 'Allow non-loopback web access without CODEMAN_PASSWORD (dangerous; terminal control is exposed)' ) + .option( + '--allow-unauthenticated-tunnel', + 'Allow starting the Cloudflare tunnel without CODEMAN_PASSWORD (dangerous; publishes terminal control to a public URL; env: CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL)' + ) .option( '--multiuser', 'Enable opt-in multi-user mode (named users in ~/.codeman/users.json; env: CODEMAN_MULTIUSER)' @@ -964,6 +987,7 @@ function toWebLaunchOptions(options: { baseUrl?: string; titleHostname?: string; allowUnauthenticatedNetwork?: boolean; + allowUnauthenticatedTunnel?: boolean; multiuser?: boolean; }): WebLaunchOptions { const port = parseInt(options.port, 10); @@ -985,6 +1009,7 @@ function toWebLaunchOptions(options: { basePath, titleHostname: options.titleHostname, allowUnauthenticatedNetwork: !!options.allowUnauthenticatedNetwork, + allowUnauthenticatedTunnel: !!options.allowUnauthenticatedTunnel, multiuser: !!options.multiuser, }; } @@ -1072,6 +1097,7 @@ webCmd.action(async (options) => { const https = launch.https; const titleHostname = options.titleHostname; const allowUnauthenticatedNetwork = launch.allowUnauthenticatedNetwork ?? false; + const allowUnauthenticatedTunnel = launch.allowUnauthenticatedTunnel ?? false; const basePath = launch.basePath ?? ''; // Single source of truth for subsystems that read it directly (e.g. renderers). if (basePath) process.env.CODEMAN_BASE_URL = basePath; @@ -1086,7 +1112,16 @@ webCmd.action(async (options) => { try { // The server prints its own "running at" line (it also covers the daemon and // service launch paths), so this one used to be a duplicate of it. - const server = await startWebServer(port, https, false, host, titleHostname, allowUnauthenticatedNetwork, basePath); + const server = await startWebServer( + port, + https, + false, + host, + titleHostname, + allowUnauthenticatedNetwork, + basePath, + allowUnauthenticatedTunnel + ); if (https) { console.log(palette.warn(' Note: Accept the self-signed certificate in your browser on first visit')); } diff --git a/src/codex-rollout-format.ts b/src/codex-rollout-format.ts new file mode 100644 index 00000000..068e68bf --- /dev/null +++ b/src/codex-rollout-format.ts @@ -0,0 +1,128 @@ +/** + * @fileoverview Shape helpers for Codex rollout JSONL lines, shared by the two + * readers of `~/.codex/sessions`: the Past Sessions scanner + * (`codex-transcript.ts`, previews only) and the offline history reader + * (`local-history.ts`, whole conversations). + * + * Both readers must agree on three facts, so they live here once: + * + * - where the store is (`$CODEX_HOME/sessions`, else `~/.codex/sessions`); + * - which line shapes carry a user or assistant message; + * - which `response_item` user rows are injected context rather than input. + * + * ## The message shapes + * + * Codex has written conversation text under three shapes: + * + * - `event_msg` / `user_message` and `agent_message`, written by every version + * seen so far. `agent_message` carries a `phase` (`commentary` or + * `final_answer`); `user_message` carries none. + * - `event_msg` / `item_completed` with an `item.type` of `UserMessage` or + * `AgentMessage`, written by newer versions (0.152.1 and later). + * - `response_item` rows with `role: 'user'` or `'assistant'`. These are what + * the model saw, so the user rows mix real input with injected context. + * + * ## The injected-context rule + * + * Codex injects context as `response_item` user rows that start with a Markdown + * heading (`# AGENTS.md instructions for ...`) or an XML-style tag + * (`<environment_context>`, `<recommended_plugins>`, `<subagent_notification>`, + * `<skill>`, `<turn_aborted>`, `<image ...>`, `<codex_internal_context ...>`). + * Measured on a store of 2,916 rollouts (codex 0.120 to 0.163), every + * `response_item` user row with either prefix and no event twin was injected; + * the real prompts that start with `#` all have a `user_message` or + * `UserMessage` event twin, which this rule never filters. A fixed list of tag + * names would leak every wrapper Codex adds later (`<recommended_plugins>` + * alone was 1,355 rows on that store), so the rule is the prefix. + * + * @module codex-rollout-format + */ + +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** Codex's home directory: `$CODEX_HOME`, else `~/.codex`, exactly as codex resolves it. */ +export function codexHomeDir(): string { + return process.env.CODEX_HOME || join(homedir(), '.codex'); +} + +/** The rollout store under {@link codexHomeDir}. */ +export function codexSessionsRoot(): string { + return join(codexHomeDir(), 'sessions'); +} + +/** + * Flatten Codex message content, which is a string or an array of blocks. + * Only blocks carrying a string `text` contribute (images and skills do not). + * Not trimmed: callers that want a preview trim. + */ +export function codexContentText(content: unknown): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content + .filter( + (b): b is { text: string } => !!b && typeof b === 'object' && typeof (b as { text?: unknown }).text === 'string' + ) + .map((b) => b.text) + .filter(Boolean) + .join('\n'); +} + +/** Injected context rather than typed input. Applies to `response_item` user rows only. */ +export function isCodexInjectedContext(text: string): boolean { + const start = text.trimStart(); + return start.startsWith('#') || start.startsWith('<'); +} + +/** One rollout line's user or assistant message, whichever shape carried it. */ +export interface CodexLineMessage { + role: 'user' | 'assistant'; + /** Untrimmed text, never empty. */ + text: string; + /** `event` = user_message/agent_message, `item` = item_completed, `response` = response_item. */ + shape: 'event' | 'item' | 'response'; + /** Codex's own phase (`commentary` / `final_answer`) when the line carries one. */ + phase?: string; + /** A `response_item` user row: may be injected context, see {@link isCodexInjectedContext}. */ + injectionProne: boolean; +} + +function record(value: unknown): Record<string, unknown> { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {}; +} + +function phaseOf(value: unknown): string | undefined { + return typeof value === 'string' && value ? value : undefined; +} + +/** The message a parsed rollout line carries, or null for every other line. */ +export function codexMessageFromLine(entry: unknown): CodexLineMessage | null { + const e = record(entry); + const p = record(e.payload); + let role: CodexLineMessage['role'] | undefined; + let text = ''; + let shape: CodexLineMessage['shape'] = 'event'; + let phase: string | undefined; + if (e.type === 'event_msg') { + if (p.type === 'user_message' || p.type === 'agent_message') { + role = p.type === 'user_message' ? 'user' : 'assistant'; + text = codexContentText(p.message); + phase = phaseOf(p.phase); + } else if (p.type === 'item_completed') { + const item = record(p.item); + if (item.type === 'UserMessage' || item.type === 'AgentMessage') { + role = item.type === 'UserMessage' ? 'user' : 'assistant'; + shape = 'item'; + text = codexContentText(item.content); + phase = phaseOf(item.phase); + } + } + } else if (e.type === 'response_item' && (p.role === 'user' || p.role === 'assistant')) { + role = p.role; + shape = 'response'; + text = codexContentText(p.content); + phase = phaseOf(p.phase); + } + if (!role || !text) return null; + return { role, text, shape, phase, injectionProne: shape === 'response' && role === 'user' }; +} diff --git a/src/codex-transcript.ts b/src/codex-transcript.ts index 9f360ee3..aabc0624 100644 --- a/src/codex-transcript.ts +++ b/src/codex-transcript.ts @@ -37,13 +37,20 @@ * summaries), so they are read only when neither shape above appears, and * the obvious injections are dropped. * + * The shapes, the injected-context rule and the store location live in + * `codex-rollout-format.ts`, shared with the offline reader `local-history.ts`. + * * @module codex-transcript */ import { open, readdir, stat } from 'node:fs/promises'; -import { homedir } from 'node:os'; import { join } from 'node:path'; +import { + codexMessageFromLine, + codexSessionsRoot, + isCodexInjectedContext as isInjectedContext, +} from './codex-rollout-format.js'; import { LRUMap } from './utils/lru-map.js'; /** Covers `session_meta` (~19 KiB) plus the first user message (~69 KiB behind it). */ @@ -134,11 +141,6 @@ function shouldCacheIdentity(identity: RolloutIdentity, fileSize: number): boole return identity.firstPrompt !== undefined || fileSize >= HEAD_BYTES; } -function codexSessionsRoot(): string { - const home = process.env.CODEX_HOME || join(homedir(), '.codex'); - return join(home, 'sessions'); -} - /** Read at most `bytes` from the front of a file. Returns '' when unreadable. */ async function readHead(path: string, bytes: number): Promise<string> { const fh = await open(path, 'r').catch(() => null); @@ -176,55 +178,16 @@ async function readTail(path: string, size: number, bytes: number): Promise<stri } } -/** Flatten codex's message content, which is a string or an array of text blocks. */ -function contentText(content: unknown): string { - if (typeof content === 'string') return content.trim(); - if (!Array.isArray(content)) return ''; - return content - .filter( - (b): b is { text: string } => !!b && typeof b === 'object' && typeof (b as { text?: unknown }).text === 'string' - ) - .map((b) => b.text) - .join('\n') - .trim(); -} - -/** One line's user-prompt text, whichever of the three shapes it is. */ -function userPromptFromLine(entry: { - type?: string; - payload?: { - type?: string; - role?: string; - content?: unknown; - message?: unknown; - item?: { type?: string; content?: unknown }; - }; -}): { text: string; injectionProne: boolean } | null { - const p = entry.payload; - if (!p) return null; - - if (entry.type === 'event_msg' && p.type === 'item_completed' && p.item?.type === 'UserMessage') { - const text = contentText(p.item.content); - return text ? { text, injectionProne: false } : null; - } - if (entry.type === 'event_msg' && p.type === 'user_message') { - const text = typeof p.message === 'string' ? p.message.trim() : contentText(p.message); - return text ? { text, injectionProne: false } : null; - } - if (entry.type === 'response_item' && p.role === 'user') { - const text = contentText(p.content); - return text ? { text, injectionProne: true } : null; - } - return null; -} - /** - * Injected context rather than something the user typed. Codex prepends the - * repository's AGENTS.md and wraps environment context in a tag, and both arrive - * as `response_item` user rows. + * One line's user-prompt text, whichever of the three shapes it is. The shapes + * and the injected-context rule are shared with the offline history reader + * (`codex-rollout-format.ts`), so the two never disagree about what a prompt is. */ -function isInjectedContext(text: string): boolean { - return text.startsWith('#') || text.startsWith('<'); +function userPromptFromLine(entry: unknown): { text: string; injectionProne: boolean } | null { + const message = codexMessageFromLine(entry); + if (!message || message.role !== 'user') return null; + const text = message.text.trim(); + return text ? { text, injectionProne: message.injectionProne } : null; } /** Collapse to one line and cap, so a row carries a title rather than an essay. */ diff --git a/src/config-backup.ts b/src/config-backup.ts index f9e315aa..e3425dc9 100644 --- a/src/config-backup.ts +++ b/src/config-backup.ts @@ -53,6 +53,7 @@ export const CONFIG_BACKUP_FILES = [ 'clis.json', 'custom-model-hosts.json', 'webhook.json', + 'discord.json', 'intents.json', 'linked-cases.json', 'remote-hosts.json', @@ -794,6 +795,7 @@ const REDACT_ALL_FILES: ReadonlySet<string> = new Set([ 'users.json', 'push-keys.json', 'webhook.json', + 'discord.json', 'intents.json', ]); /** Elsewhere a value is hidden when its own key looks like a credential. */ diff --git a/src/config/cli-registry/patterns.ts b/src/config/cli-registry/patterns.ts index 9cd8f303..612d3783 100644 --- a/src/config/cli-registry/patterns.ts +++ b/src/config/cli-registry/patterns.ts @@ -21,6 +21,7 @@ export type TokenPattern = | 'model' | 'model-claude' | 'model-plain' + | 'model-plain-colon' | 'model-pi' | 'id' | 'id-dotted' @@ -47,6 +48,10 @@ const PATTERNS: Record<TokenPattern, RegExp> = { // copilot --model. Same characters as `model`, but a leading alphanumeric: the value follows a // flag, so `--yolo` must not pass as a model id and become its own flag (the allowAll clamp bypass). 'model-plain': /^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/, + // codex --model, and the value a custom-model `launchModel` is forced onto it. Same + // shape as `model-plain` (leading alphanumeric, so a flag cannot ride the flag) plus + // `:`, since Ollama-style ids like `qwen3:32b` must survive onto the command line. + 'model-plain-colon': /^[a-zA-Z0-9][a-zA-Z0-9._\-/:]*$/, // buildPiCommand — `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id` 'model-pi': /^[a-zA-Z0-9._\-/:]+$/, // opencode --session, codex resume diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index de4a5542..ba9acae3 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -80,6 +80,7 @@ const paramSpecSchema = z.union([ 'muxName', 'effortLevel', 'effortSettingsJson', + 'appendSystemPrompt', 'codemanPrefixedSessionId', 'launcherDefaultTarget', ]), @@ -224,6 +225,7 @@ const envExportSchema = z 'muxName', 'effortLevel', 'effortSettingsJson', + 'appendSystemPrompt', 'codemanPrefixedSessionId', 'launcherDefaultTarget', ]), @@ -286,6 +288,14 @@ const launchModelTemplate = z .regex(/^[a-zA-Z0-9._\-/:{}]+$/) .optional(); +/** One entry name under a CLI's real home, for `linkFromRealHome`: single path segment, + * leading alphanumeric, no traversal or hidden-dot escape. */ +const homeSegmentName = z + .string() + .min(1) + .max(40) + .regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/); + const capabilitiesSchema = z .object({ external: z.boolean(), @@ -301,6 +311,9 @@ const capabilitiesSchema = z // the CLI to page its own transcript. Distinct from wheelForward (SGR reports). // Optional: absent means no paging, the safe default for an unmeasured CLI. transcriptPageKeys: z.boolean().optional(), + // Keep the PTY's rows while a phone keyboard is up (inline renderers only). + // Optional: absent means the keyboard resizes, the safe default. + keyboardHold: z.literal('inline').optional(), keyboardAccessory: z.enum(['agent', 'shell']), privilegedCommandGate: z.boolean(), startMode: z.enum(['interactive', 'shell']), @@ -384,6 +397,35 @@ const capabilitiesSchema = z // marker but never the footer. Still bounded, since it is also how much text the // pattern runs over. blockedLines: z.number().int().min(1).max(24).optional(), + // Same guard again: run over the rows under the composer each time guarded + // input reads the pane, and ~/.codeman/clis.json can set it. + composerFooterLine: z + .string() + .min(1) + .refine( + (src) => compileVersionRegex(src) !== null, + 'composerFooterLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers' + ) + .optional(), + // Same guard again: run over the rows just above the composer by the same reads. + composerPopupLine: z + .string() + .min(1) + .refine( + (src) => compileVersionRegex(src) !== null, + 'composerPopupLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers' + ) + .optional(), + // Single ASCII punctuation characters, never letters, digits or whitespace: each one + // refuses every message that starts (or whose last word starts) with it. + composerCommandPrefixes: z + .string() + .regex(/^[!-/:-@[-`{-~]{1,8}$/, 'composerCommandPrefixes must be 1 to 8 ASCII punctuation characters') + .optional(), + composerCompletionSigils: z + .string() + .regex(/^[!-/:-@[-`{-~]{1,8}$/, 'composerCompletionSigils must be 1 to 8 ASCII punctuation characters') + .optional(), }) .strict() // A window with nothing to search is a typo, not a configuration. Refused at LOAD @@ -548,10 +590,61 @@ const capabilitiesSchema = z kind: z.literal('configDir'), dirEnvVar: envName, fileName: z.string().min(1).max(80), - template: z.enum(['codex-toml', 'pi-models-json', 'omp-models-yml', 'grok-toml']), + template: z.enum(['codex-toml', 'pi-models-json', 'omp-models-yml', 'grok-toml', 'deepseek-patch-json']), launchModel: launchModelTemplate, + // State that must survive the isolated-directory redirect, symlinked by name from + // the CLI's real home (deepseek: profiles/sessions/settings.yaml). Single path + // segments only: the consumer joins them, so a traversal here would escape the + // real home. See the field's comment in cli-registry/types.ts. + linkFromRealHome: z.array(homeSegmentName).max(8).optional(), + // Which of `linkFromRealHome` are directories (missing ones are created before + // linking; missing files are skipped). Same segment pattern, see types.ts. + linkFromRealHomeDirs: z.array(homeSegmentName).max(8).optional(), + // The real home's fallback location, relative to the user's home, ONE segment: + // a leading dot is legitimate (`.dsh` is what ships) but separators, `..` and + // bare `.` are not; the consumer joins it under the user's home, a traversal + // here escapes that home and `.` IS that home (linking THROUGH it unshielded). + realHomeFallback: z + .string() + .min(1) + .max(80) + .regex(/^(?!\.\.?$)[^/\\]+$/) + .optional(), }) - .strict(), + .strict() + .superRefine((recipe, ctx) => { + // Both rules were comments before; the schema is the security boundary, so + // enforce them at load time (see the header of this file). + if (recipe.linkFromRealHome?.length && !recipe.realHomeFallback) { + ctx.addIssue({ + code: 'custom', + message: 'realHomeFallback is required whenever linkFromRealHome is declared', + path: ['realHomeFallback'], + }); + } + for (const name of recipe.linkFromRealHome ?? []) { + if (name === recipe.fileName) { + ctx.addIssue({ + code: 'custom', + message: `linkFromRealHome must never include fileName (${JSON.stringify(name)}): the generated file owns that name in the isolated directory, a link there would write THROUGH into the user's real file`, + path: ['linkFromRealHome'], + }); + } + } + // types.ts documents the dirs list as "must be a subset of linkFromRealHome"; + // a name absent from it names nothing (missing-file entries are skipped as + // files), so the recipe would silently stop creating a directory it declares. + const linkedNames = new Set(recipe.linkFromRealHome ?? []); + for (const dir of recipe.linkFromRealHomeDirs ?? []) { + if (!linkedNames.has(dir)) { + ctx.addIssue({ + code: 'custom', + message: `linkFromRealHomeDirs must be a subset of linkFromRealHome: ${JSON.stringify(dir)} is not linked, so the directory rule for it can never apply`, + path: ['linkFromRealHomeDirs'], + }); + } + } + }), z.object({ kind: z.literal('unsupported') }).strict(), ]), }) diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 1b0e00ea..031c3a9f 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -13,6 +13,14 @@ import type { CliEntry } from './types.js'; import { CODEX_REASONING_EFFORTS } from '../../types/session.js'; +/** + * Codex's footer effort words: every CODEX_REASONING_EFFORTS level plus `default` (what + * codex prints when no effort is configured), as one regex alternation. Shared by the two + * patterns that read that footer (`modelDetect.screenLine` and + * `workDetect.composerFooterLine`), so a new level is a one-place edit. + */ +const CODEX_EFFORT_WORDS = [...CODEX_REASONING_EFFORTS, 'default'].join('|'); + const HOME_DIRS = { local: '~/.local/bin', usrLocal: '/usr/local/bin', @@ -144,6 +152,7 @@ const CLAUDE: CliEntry = { // from the EFFORT_LEVELS allowlist upstream, same as every other engine value). effortLevel: { type: 'engine', source: 'effortLevel' }, effortJson: { type: 'engine', source: 'effortSettingsJson' }, + discordHint: { type: 'engine', source: 'appendSystemPrompt' }, sessionId: { type: 'engine', source: 'sessionId' }, sessionName: { type: 'engine', source: 'sessionName' }, }, @@ -171,6 +180,12 @@ const CLAUDE: CliEntry = { { flag: '--effort', valueFrom: 'effortLevel', quote: 'single', when: { param: 'effortLevel', state: 'set' } }, { flag: '--settings', valueFrom: 'effortJson', quote: 'single', when: { param: 'effortJson', state: 'set' } }, { flag: '--name', valueFrom: 'sessionName', quote: 'double', when: { capabilityGate: 'nameFlag' } }, + { + flag: '--append-system-prompt', + valueFrom: 'discordHint', + quote: 'single', + when: { param: 'discordHint', state: 'set' }, + }, ], }, { @@ -195,6 +210,12 @@ const CLAUDE: CliEntry = { { flag: '--effort', valueFrom: 'effortLevel', quote: 'single', when: { param: 'effortLevel', state: 'set' } }, { flag: '--settings', valueFrom: 'effortJson', quote: 'single', when: { param: 'effortJson', state: 'set' } }, { flag: '--name', valueFrom: 'sessionName', quote: 'double', when: { capabilityGate: 'nameFlag' } }, + { + flag: '--append-system-prompt', + valueFrom: 'discordHint', + quote: 'single', + when: { param: 'discordHint', state: 'set' }, + }, ], }, ], @@ -289,6 +310,10 @@ const CLAUDE: CliEntry = { // forwarded (older CLI, inline renderer, the local-scrollback opt-out) a scroll is // turned into PageUp/PageDown, which Claude pages its transcript on (#205). transcriptPageKeys: true, + // Claude's default renderer is inline, and each keyboard-driven PTY height change made + // it reprint only the tail that fits, erasing transcript rows from tmux (measured on + // 2.1.306: 25 of 100 lines over five keyboard cycles). See CliCapabilities.keyboardHold. + keyboardHold: 'inline', keyboardAccessory: 'agent', privilegedCommandGate: false, startMode: 'interactive', @@ -616,7 +641,10 @@ const CODEX: CliEntry = { params: { bypassApprovals: { type: 'bool' }, animations: { type: 'bool' }, - model: { type: 'token', pattern: 'model' }, + // `model-plain-colon`, not `model`: the custom-model feature forces the selected id + // onto this param (`launchModel: '{modelId}'` below), and Ollama-style ids carry a + // `:` (`qwen3:32b`). A leading alphanumeric still keeps `--yolo` from riding a flag. + model: { type: 'token', pattern: 'model-plain-colon' }, reasoningEffort: { type: 'enum', values: [...CODEX_REASONING_EFFORTS] }, resumeId: { type: 'token', pattern: 'id' }, }, @@ -734,6 +762,34 @@ const CODEX: CliEntry = { watchingLines: 4, blockedLine: String.raw`(?:^|\n) {0,4}› \d\. [\s\S]*\n {2}(?:Press enter to confirm|tab (?:to add|or esc to clear) notes|enter continue)(?:[^\n]|\n {2}(?=[a-z←])){0,120}(?:cancel|interrupt|quit|back|answer|all)$`, blockedLines: 20, + // The rows codex draws under an idle composer, tested one at a time by guarded + // stage/submit (agent-staging.ts): the model footer (` gpt-5.6-terra default · + // ~/case`, effort words from CODEX_EFFORT_WORDS as in modelDetect below), the + // `? for shortcuts` / `← for agents` hint row 0.162.0 adds at rest, and the older + // `NN% context left` footer. Indented, so the `›` composer row itself never matches. + composerFooterLine: String.raw`^\s+(?:\d+% context left|\? for shortcuts|← for agents|\S+ (?:${CODEX_EFFORT_WORDS}) · )`, + // Read from codex-rs/tui/src/bottom_pane at rust-v0.162.0 (chat_composer/ + // popup_state.rs, composer_layout.rs and the popups' render code and snapshots): + // every suggestion popup renders ABOVE the composer and keeps the footer below it, + // so the footer alone cannot tell `› /stat` with the command list open from an + // idle draft, and Enter then runs the highlighted `/status`. What sits in the + // block just above the composer (past its blank top padding row): + // command list (`/`) `› /status show current session ...` + // ` /statusline configure which items ...` + // skill and mention lists ` enter insert · esc close` (key-hint row) + // (`$`, `@` with mentions on) ` enter/tab insert · esc close · ↑/↓ select · ←/→ filter` + // file search (`@`) ` loading...` / ` no matches`, else bare path + // rows, which no pattern can tell from prose + // The last case is why stage also refuses the triggers themselves (below). + composerPopupLine: String.raw`^ {0,2}(?:enter(?:/tab)? insert · esc close|loading\.\.\.$|no matches$)|^[› ] /[\w:-]+ {2,}\S`, + // Same source. A draft starting with `/` is a slash command (the popup opens while + // the caret is in its name, and Enter runs a command either way), and one starting + // with `!` is a shell command (codex switches its prompt to `!` and runs it). Popups + // open for the word under the caret only: `@` (file search, or the unified mention + // list) and `$` (skills and mentions; whether a given `$word` opens it depends on + // codex's skill catalog, so every `$` word is refused). + composerCommandPrefixes: '/!', + composerCompletionSigils: '@$', }, // The footer under the composer, measured on a live 0.147.0 pane: // ` gpt-5.6-terra default · ~/codeman-cases/th-scratch` (model, reasoning effort, @@ -757,7 +813,7 @@ const CODEX: CliEntry = { // and the `›` composer, and a forged footer-shaped transcript line is not followed by // an indented row, so it is not read. modelDetect: { - screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:${[...CODEX_REASONING_EFFORTS, 'default'].join('|')}) · [^\n]*(?:\n {2}[^\n]*)?$`, + screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:${CODEX_EFFORT_WORDS}) · [^\n]*(?:\n {2}[^\n]*)?$`, screenLines: 2, }, // App Settings → Codex model / reasoning effort (synced), filled into a LOCAL launch's @@ -801,6 +857,7 @@ const CODEX: CliEntry = { dirEnvVar: 'CODEX_HOME', fileName: 'config.toml', template: 'codex-toml', + launchModel: '{modelId}', }, // CODEX_HOME already matches the CODEX_ allowedPrefix above, so it was ALREADY // reachable via plain envOverrides before this feature existed. It is arguably @@ -1505,30 +1562,30 @@ const DEEPSEEK: CliEntry = { // SERVER's own key into every dsh pane, so DEEPSEEK_BASE_URL is the exfiltration // vector, not the key itself — a non-granted owner supplying THEIR OWN key removes // privilege rather than granting it, and clamping it here was a real regression - // (test/deepseek-mode.test.ts) fixed before this shipped. - privilegedEnvKeys: ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL'], - // Reuses the already-existing DEEPSEEK_BASE_URL/DEEPSEEK_API_KEY keys above. No - // modelVars — dsh's model is a profile-composition entry (see `model: { source: 'none' - // }` above), not an env var, so forcing a specific model name may not fully work; - // verify against a real profile before shipping. - // - // ⚠️ appendV1Suffix is REQUIRED, not optional-nice-to-have: without it every request - // 404s. Confirmed live and by reading dsh's own bundled source - // (@deepseek-ai/dsh-llm-deepseek): it builds the request URL as - // `${DEEPSEEK_BASE_URL}/chat/completions` with no "/v1" of its own (its real public - // API, https://api.deepseek.com, expects the caller's base URL to already carry any - // needed prefix), while llama-swap/llama.cpp only serves the OpenAI-conventional - // "/v1/chat/completions" — a bare POST to ".../chat/completions" 404s live, and the - // 404 reported here originally ("dsh: HTTP_404: DeepSeek API error (HTTP 404)") - // matches dsh's own error-message template for exactly this failure. See the - // customModelInjection doc comment in cli-registry/types.ts for the full reasoning, - // including why claude/gemini must NOT get this. + // (test/deepseek-mode.test.ts) fixed before this shipped. CODEMAN_CUSTOM_MODEL_API_KEY + // joins it (like codex's entry): the dedicated custom-endpoint key rides the same + // allowlisted-envOverrides path as the redirect itself. + privilegedEnvKeys: ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL', 'CODEMAN_CUSTOM_MODEL_API_KEY'], + // The installed profile supplies the transport; a per-session patch selects its exact model. customModelInjection: { - kind: 'env', - baseUrlVar: 'DEEPSEEK_BASE_URL', - apiKeyVar: 'DEEPSEEK_API_KEY', - modelVars: [], - appendV1Suffix: true, + kind: 'configDir', + dirEnvVar: 'DSH_HOME', + fileName: 'cordis.patch.yml', + template: 'deepseek-patch-json', + // dsh keeps state in its home that the redirect must NOT swallow: `sessions` is the + // session-log root (deleting it would take the user's transcripts with it on clear/ + // close), `profiles` are the installed bundles the patch composes over, and + // `settings.yaml` must keep applying (read through the link only until the session's + // first settings write: dsh saves by rename, which replaces the link with a local file + // that goes away with the session). The generated home-level `cordis.patch.yml` + // deliberately replaces the user's own for the session rather than linking it. + // `sessions` and `profiles` are directories: dsh creates them itself on a first + // turn, and a fresh/relocated $DSH_HOME that never ran one would otherwise get a + // dangling link whose every write fails ENOENT: the consumer creates missing + // directory targets and skips missing file targets like `settings.yaml`. + linkFromRealHome: ['profiles', 'sessions', 'settings.yaml'], + linkFromRealHomeDirs: ['profiles', 'sessions'], + realHomeFallback: '.dsh', }, }, overlays: { @@ -1881,8 +1938,8 @@ const COPILOT: CliEntry = { // custom provider and drops the GitHub sign-in requirement; COPILOT_PROVIDER_TYPE defaults to // "openai", which is what a Codeman custom endpoint is (llama.cpp, vLLM, Ollama). // COPILOT_MODEL names the model and COPILOT_PROVIDER_MODEL_ID / _WIRE_MODEL default to it. - // The OpenAI-style route is `<base>/v1/chat/completions`, so the base gets the `/v1` suffix - // the same way deepseek's does. All four already match the COPILOT_ prefix allowlist, so + // The OpenAI-style route is `<base>/v1/chat/completions`, so the base gets the `/v1` + // suffix via appendV1Suffix below. All four already match the COPILOT_ prefix allowlist, so // they are privileged env keys below: a non-granted multi-user owner must not be able to // redirect the session's endpoint or credentials through plain envOverrides. customModelInjection: { diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 81fd48b4..ebad4977 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -29,6 +29,8 @@ export type EngineValue = | 'muxName' | 'effortLevel' | 'effortSettingsJson' + /** The Discord bridge's startup hint, passed to Claude as `--append-system-prompt`. */ + | 'appendSystemPrompt' /** `sessionId` prefixed `codeman_<id>` — codex's unique per-pane rollout originator. */ | 'codemanPrefixedSessionId' /** @@ -432,6 +434,39 @@ export interface CliCapabilities { * this is sized for the narrowest pane the CLI was measured at. Default 1. */ blockedLines?: number; + /** + * Source of a regex every non-blank row BELOW an idle composer must match (at most + * three rows): the footer and hint rows this CLI draws there and nowhere else. It is + * what lets the experimental guarded stage/submit routes (`agent-staging.ts`) prove + * that the `promptGlyph` row they read is this CLI's own composer, at rest, and not + * a dialog option or a transcript line the agent printed. A CLI that omits it never + * takes guarded input: stage and submit refuse it. Tested one row at a time. + */ + composerFooterLine?: string; + /** + * Source of a regex matching a row of a suggestion popup this CLI draws directly + * ABOVE its composer (a slash-command list, a file or mention search, its key-hint + * row). The footer below such a composer is unchanged, so without this guarded + * stage/submit would read the popup frame as an idle composer, and Enter would + * accept the highlighted entry instead of sending the draft. Tested one row at a + * time against the contiguous block just above the composer row, never further + * up the pane: a match there refuses the frame. See `readGuardedComposer()`. + */ + composerPopupLine?: string; + /** + * Characters that, as the first character of a draft, make Enter run a command + * rather than send a prompt (codex: `/` slash commands, `!` shell commands). + * Guarded stage refuses such a message before typing; commands go through the + * ordinary input route. + */ + composerCommandPrefixes?: string; + /** + * Characters that, starting the word under the caret, open a completion popup + * whose highlighted entry Enter accepts (codex: `@` file and mention search, `$` + * skills). Guarded stage refuses a message whose LAST word starts with one, + * before typing: the caret ends there, and the popup follows the caret. + */ + composerCompletionSigils?: string; }; /** * How many columns this CLI indents its transcript body by, so a copy taken from its @@ -563,6 +598,30 @@ export interface CliCapabilities { * Absent means no paging, so a CLI nobody has measured keeps plain local scrolling. */ transcriptPageKeys?: boolean; + /** + * `'inline'`: the CLI renders inline (its content stacks at the bottom of the + * main screen), so while a phone's on-screen keyboard is up the browser keeps + * the PTY's rows and scrolls the view instead of resizing (`keyboardHoldsRows`, + * terminal-ui.js `_keyboardHoldEligible`). Read by the frontend through + * `window.__codemanKeyboardHoldModes`, which the server builds from this field. + * + * Why it exists: every PTY height change makes Claude Code's inline renderer + * clear the screen and reprint only the tail that fits (#672). Measured on + * Claude Code 2.1.306 with tmux 3.2a, a fresh session already at the phone's + * size printing 100 numbered lines: five keyboard open/close cycles (PTY + * 62x38 to 62x15 and back, one SIGWINCH per transition) left 75 of the 100 in + * `tmux capture-pane` history, the other 25 gone for good; with the rows held + * the PTY stayed 62x38, no SIGWINCH reached the pane and all 100 remained. + * + * ⚠ Only for a renderer that is inline. A CLI that lays out the FULL height + * (codex: banner at the top, composer pinned to the bottom) cannot fit both + * ends above the keyboard and must be resized instead, so it stays absent. + * Absent means resize, the safe default for an unmeasured CLI. Even when set, + * the hold stands aside while the CLI reports mouse tracking (Claude's + * fullscreen mode, which lays out the full height and has not been measured + * losing lines, so it resizes as before), and for remote and docker sessions. + */ + keyboardHold?: 'inline'; keyboardAccessory: 'agent' | 'shell'; /** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */ privilegedCommandGate: boolean; @@ -755,9 +814,11 @@ export interface CliCapabilities { * for pi and omp (`--model custom/<id>`, or the CLI stays on its own default * provider and reports "No API key found for the selected model") or for * grok (`--model codeman-custom`, the `[model.<name>]` block the config - * declares). Absent = the config alone selects the model (claude's env vars, - * opencode's blob, codex's top-level `model` key). Applied by the session's - * respawn options through the entry's `legacyConfigField`, never by id. + * declares), and codex carries its bare selected id (`{modelId}`, an id the + * entry's `model` token pattern must be able to carry). Absent = the config + * alone selects the model (claude's env vars, opencode's blob). Applied by + * the session's respawn options through the entry's `legacyConfigField`, + * never by id. * * `contextLengthVar` (env kind only): the env var a discovered per-model context-window * size is written to when known (claude's `CLAUDE_CODE_MAX_CONTEXT_TOKENS`) — without it, @@ -799,19 +860,13 @@ export interface CliCapabilities { * reuses that file. * * `appendV1Suffix` (env kind only): the raw `endpoint.baseUrl` gets `withV1Suffix()` - * applied before being written to `baseUrlVar`, instead of being used verbatim. - * DeepSeek needs this and claude/gemini must NOT get it — a per-CLI asymmetry confirmed - * by reading each SDK's own request-building source, not assumed: DeepSeek Harness's - * bundled `@deepseek-ai/dsh-llm-deepseek` concatenates `${connection.baseURL}/chat/ - * completions` with no `/v1` insertion of its own (its real public API base, - * `https://api.deepseek.com`, expects the caller's base URL to already carry any - * needed prefix), while llama-swap/llama.cpp only ever serves the OpenAI-conventional - * `/v1/chat/completions` — confirmed live: a bare `POST <baseUrl>/chat/completions` - * 404s, `POST <baseUrl>/v1/chat/completions` succeeds, and the harness's own error - * message template (`DeepSeek API error (HTTP ${status})`) reproduces the exact - * `HTTP_404` this feature originally shipped with unexplained. Claude Code's own SDK, - * by contrast, was already confirmed working end-to-end against the RAW `baseUrl` with - * no suffix — appending one there would be wrong, not just redundant. + * applied before being written to `baseUrlVar`, instead of being used verbatim. Only + * copilot declares it: its OpenAI-style route is `<base>/v1/chat/completions`, and a + * gateway saves the bare root. claude/gemini must NOT get it: Claude Code's own SDK was + * confirmed working end-to-end against the RAW `baseUrl`, so appending a suffix there + * would be wrong, not just redundant. The per-session config-file recipes do not need + * this flag at all: `renderConfigFile()` applies the suffix inside every template + * (deepseek's patch included), so no configDir entry carries a raw base URL. */ customModelInjection: | { @@ -831,8 +886,40 @@ export interface CliCapabilities { kind: 'configDir'; dirEnvVar: string; fileName: string; - template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml'; + template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml' | 'deepseek-patch-json'; launchModel?: string; + /** + * Names of entries under this CLI's REAL home that must be symlinked into the + * isolated directory after the generated file is written: the same trick + * `linkSharedProjectsDir()` plays for claude's `projects`. Declared for CLIs whose + * home carries state that must survive the redirect (deepseek's `sessions` transcript + * root and `profiles` bundles, plus `settings.yaml`, which would otherwise stop + * applying and, for `sessions`, get deleted by `removeConfigDir` on clear/close). + * A CLI that saves a linked file by rename replaces the link: dsh does, so the first + * in-session settings write turns `settings.yaml` into a local file. + * Never include `fileName` itself: the generated file owns that name in the isolated + * directory, and a link there would write THROUGH into the user's real file. + * Consumed generically by custom-model-injection-apply.ts. + */ + linkFromRealHome?: string[]; + /** + * Which of `linkFromRealHome` are DIRECTORIES (`sessions`, `profiles`) rather than + * files (`settings.yaml`). A missing directory target is created (mode 0700, what + * dsh does itself on a first turn) before linking: linking a nonexistent directory + * leaves a dangling symlink the CLI's own `mkdir` under it fails on with ENOENT on + * EVERY turn. A missing FILE target is skipped instead of linked (dangling-file + * links serve no purpose the CLI could not do itself). Must be a subset of + * `linkFromRealHome`; entries absent from it are treated as files. + * Consumed generically by custom-model-injection-apply.ts. + */ + linkFromRealHomeDirs?: string[]; + /** + * Fallback location of the CLI's real home, relative to the user's home directory + * (deepseek's `.dsh`), used only when neither the session's own `dirEnvVar` override + * nor the server's environment sets it. Required whenever `linkFromRealHome` is + * declared, so no consumer has to hardcode a per-CLI default of its own. + */ + realHomeFallback?: string; } | { kind: 'unsupported' }; } diff --git a/src/config/path-probe.ts b/src/config/path-probe.ts index e69e9642..c5a79141 100644 --- a/src/config/path-probe.ts +++ b/src/config/path-probe.ts @@ -46,3 +46,10 @@ export const MAX_STALLED_PATH_PROBES = Math.min( PATH_PROBE_STALL_CEILING, envInt('CODEMAN_PATH_PROBE_MAX_STALLED', Math.max(1, PATH_PROBE_STALL_CEILING - 1), 1, 64) ); + +/** + * How long a session waits before trying again to resolve its workspace's symlinks + * after the folder did not answer, so a mount that recovers is picked up (the pane + * directory reading stays in the resolved spelling meanwhile). + */ +export const WORKSPACE_REALPATH_RETRY_MS = 60_000; diff --git a/src/config/server-timing.ts b/src/config/server-timing.ts index 7a9c1858..0db258b2 100644 --- a/src/config/server-timing.ts +++ b/src/config/server-timing.ts @@ -70,6 +70,17 @@ export const SESSION_LIMIT_WAIT_MS = 5000; /** Pause between scheduled run iterations (ms) */ export const ITERATION_PAUSE_MS = 2000; +// ============================================================================ +// Git Fetch Interval +// ============================================================================ + +/** + * How often the fetch scheduler (src/git-fetch-scheduler.ts) wakes to check which sessions are due (ms). A + * run lands within one tick of the interval the user set, because the interval is timed from the start of + * a run; a tick is a settings read and a due check, not a spawn, so a quiet install costs nothing. + */ +export const GIT_FETCH_TICK_INTERVAL_MS = 15 * 1000; + // ============================================================================ // Mux Stats // ============================================================================ diff --git a/src/custom-model-hosts.ts b/src/custom-model-hosts.ts index 8ad2c17d..b6e6b7e7 100644 --- a/src/custom-model-hosts.ts +++ b/src/custom-model-hosts.ts @@ -52,18 +52,22 @@ export interface CustomModelHost { defaultModelId?: string; /** * Discovered context-window size (tokens) per model id, keyed by the same strings as - * `models`. Populated opportunistically during discovery (`custom-model-routes.ts`) from - * llama.cpp/llama-swap's `GET /props?model=<id>` — the plain OpenAI-shaped `/v1/models` - * response has no such field. Only ever probed for a model the server already reports as - * loaded (llama-swap's `status.value === 'loaded'`); an unloaded one is deliberately never - * probed, since llama-swap treats `/props?model=` as a routing hint that can trigger an - * actual (slow, GPU-swapping) model load as a side effect of merely asking. A model this - * has no entry for simply gets no context-length env override applied — never a guess. + * `models`. Populated only by discovery (`custom-model-routes.ts`), from the strongest + * source the server offers: the llama-swap launch command reported by `GET /running` + * (the source of truth for a loaded model), then llama.cpp's `GET /props?model=<id>` + * `n_ctx` (only ever probed for a model the server already reports as loaded, since + * llama-swap treats `/props?model=` as a routing hint that can trigger an actual + * (slow, GPU-swapping) model load as a side effect of merely asking), then the model's + * own `GET /v1/models` entry (`context_length`, else `context_window`, which current + * llama-swap, Bifrost and OpenRouter-style servers each state to varying degrees). A + * model with no entry here falls back to the endpoint's `defaultContextLength`; with + * neither, it simply gets no context-length env override applied, never a guess. */ modelContextLengths?: Record<string, number>; /** * Discovered number of parallel server slots per model id, keyed like `modelContextLengths` - * and populated on the same loaded-models-only basis. Read from the launch command's + * but populated only for models the server reports loaded (a listing never states a slot + * count, unlike `contextLengths`, which every entry can supply). Read from the launch command's * `--parallel`/`-np` when it states one, otherwise from llama.cpp's own `/props` * `total_slots` (a llama-server started without `--parallel` picks the count itself, so * the command cannot say). llama-server splits its context across these slots (by default @@ -88,6 +92,29 @@ export interface CustomModelHost { * server-side check relies on. */ modelSizesGB?: Record<string, number>; + /** + * User-set fallback context length (tokens), applied via + * `resolveCustomModelContextLength()` at every read site (the swap route, the + * quick-start route, boot recovery) only when `modelContextLengths` has no entry for + * the selected model (a gateway that states no per-model figure, a model never loaded + * yet). It is the model's WHOLE window, never a per-slot figure: the Claude floor + * check divides it by any stored slot count exactly like a discovered figure. Never + * written by discovery, so a value set in the endpoint editor survives every + * re-discovery sweep; unset keeps the long-standing behaviour of letting each CLI + * assume its own default. + */ + defaultContextLength?: number; +} + +/** + * The context window to apply for `modelId` on `host`: the discovered per-model figure when + * discovery has one, else the endpoint's user-set `defaultContextLength`, else undefined + * (no override applied, never a guess). ONE helper for every read site: the swap route, + * the quickstart route and boot recovery each re-write the CLI's config files, and a site + * that forgot the fallback silently loses the endpoint default on the next respawn. + */ +export function resolveCustomModelContextLength(host: CustomModelHost, modelId: string): number | undefined { + return host.modelContextLengths?.[modelId] ?? host.defaultContextLength; } export function customModelHostsPath(configDir: string): string { diff --git a/src/custom-model-injection-apply.ts b/src/custom-model-injection-apply.ts index 43cfd5ad..aa9deaf4 100644 --- a/src/custom-model-injection-apply.ts +++ b/src/custom-model-injection-apply.ts @@ -10,9 +10,9 @@ * cli-registry changes" requirement it was written against. */ -import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs'; +import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs'; import { homedir, platform } from 'node:os'; -import { join, dirname } from 'node:path'; +import { join, dirname, isAbsolute } from 'node:path'; import { dataPath } from './config/instance.js'; import type { CliEntry } from './config/cli-registry/types.js'; import { @@ -30,25 +30,99 @@ export function customModelConfigDir(sessionId: string): string { * Writes a `ConfigDirInjection`'s files under `baseDir` and returns the full * envOverrides object a caller should merge into the session/process env * (the dir-redirect var plus any `extraEnv` the config file references by - * name). Never touches anything outside `baseDir` — the caller is - * responsible for choosing an isolated directory (never the user's real - * `~/.codex`, `~/.pi`, etc.). + * name). The generated files stay under `baseDir`, but a recipe that declares + * `linkFromRealHome` additionally links (and, for missing directory targets, + * creates) entries in the CLI's REAL home: the caller is still responsible + * for choosing an isolated `baseDir` (never the user's real `~/.codex`, + * `~/.pi`, etc.), it is just no longer the only directory touched. * * pi and omp embed the API key literally in the file, so the tree is written * 0700/0600 like every other secret-bearing file under `~/.codeman`; the chmod * covers a re-apply onto a file that already exists (`mode` only applies at * creation). */ -export function applyConfigDirInjection(baseDir: string, injection: ConfigDirInjection): Record<string, string> { +export function applyConfigDirInjection( + baseDir: string, + injection: ConfigDirInjection, + /** + * The value this SESSION's own env overrides set for `injection.dirEnvVar`, when it + * set one: a user who relocated the CLI's real home (an allowlisted key like + * DSH_HOME) must have their state linked from THAT directory, not the server's. + */ + sessionHomeOverride?: string +): Record<string, string> { for (const file of injection.files) { const filePath = join(baseDir, file.relPath); mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 }); writeFileSync(filePath, file.content, { encoding: 'utf8', mode: 0o600 }); chmodSync(filePath, 0o600); } + linkRealHomeEntries(baseDir, injection, sessionHomeOverride); return { [injection.dirEnvVar]: baseDir, ...injection.extraEnv }; } +/** + * Symlinks the registry recipe's `linkFromRealHome` entries out of the CLI's real home + * into the otherwise-isolated `baseDir`, so redirecting `dirEnvVar` moves only the + * generated config file and not the state that has to survive the session (deepseek: + * the `sessions` transcript root `removeConfigDir` would otherwise delete, the `profiles` + * bundles the patch composes over, and `settings.yaml`). Declared per entry in stock.ts: + * this consumer is recipe-driven and names no CLI itself. + * + * Home resolution: the session's own override first (unless it IS `baseDir`, a re-apply + * onto a session already holding a previous injection's value must not self-link), then + * the server's environment, then the recipe's `realHomeFallback`. Only ABSOLUTE values + * are considered: a set-but-relative `DSH_HOME`-style home resolves against the CLI's + * cwd, and joining it here would mkdir/symlink under the SERVER's working directory and + * leave relative links that dangle. No home resolvable = + * no links, same best-effort posture as `linkSharedProjectsDir()`. + */ +function linkRealHomeEntries(baseDir: string, injection: ConfigDirInjection, sessionHomeOverride?: string): void { + const names = injection.linkFromRealHome; + if (!names || names.length === 0) return; + const absoluteOnly = (value: string | undefined): string | undefined => + value && isAbsolute(value) ? value : undefined; + const fromSession = sessionHomeOverride?.trim(); + const realHome = + absoluteOnly(fromSession && fromSession !== baseDir ? fromSession : undefined) || + absoluteOnly(process.env[injection.dirEnvVar]?.trim()) || + (injection.realHomeFallback ? join(homedir(), injection.realHomeFallback) : undefined); + if (!realHome) return; + const dirs = new Set(injection.linkFromRealHomeDirs ?? []); + for (const name of names) { + linkRealHomeEntry(join(realHome, name), join(baseDir, name), dirs.has(name)); + } +} + +function linkRealHomeEntry(target: string, link: string, isDir: boolean): void { + // lstat, never existsSync: existsSync is false for a DANGLING link, and recovery + // re-applies on every restart, so the second pass would try to recreate the link and + // throw EEXIST. lstat answers for real entries, good links and dangling links alike. + try { + if (lstatSync(link)) return; // already linked (idempotent re-apply) or a real dir wrote one + } catch { + // ENOENT: nothing is here yet, the only state in which a link gets created + } + if (!existsSync(target)) { + if (!isDir) return; // missing FILE target: skip, never plant a dangling file link + // Missing DIRECTORY target: a dangling directory link is not inert, since the CLI's own + // recursive mkdir THROUGH it fails ENOENT on every turn (dsh writing under + // `$DSH_HOME/sessions` on a fresh/relocated home). Create it first, 0700, exactly + // what dsh itself does on a first turn. + try { + mkdirSync(target, { recursive: true, mode: 0o700 }); + } catch { + return; // e.g. the target's parent refuses writes: no link rather than a dangling one + } + } + try { + symlinkSync(target, link, platform() === 'win32' ? 'junction' : 'dir'); + } catch { + // best-effort only: a platform that refuses symlinks keeps the documented + // side effect for this session rather than failing the whole apply over it + } +} + /** * Real, shared Claude config directory Codeman's own host process runs under — honors * `CLAUDE_CONFIG_DIR` the same way `claude-credentials.ts`'s `claudeCredentialsPath()` @@ -250,7 +324,13 @@ export function applyCustomModelInjection( * trust-dialog seed, and only when provided (boot recovery, which has no reason to * re-answer a dialog that already fired once, omits it rather than re-deriving it). */ - workingDir?: string + workingDir?: string, + /** + * Reads one env var from the SESSION's own env overrides (single-key on purpose: the + * map carries credentials). Only the `configDir` kind uses it, to resolve the CLI's real + * home for `linkFromRealHome` from the session's value before the server's. + */ + sessionEnvLookup?: (envVar: string) => string | undefined ): AppliedCustomModel | undefined { const injection = buildCustomModelInjection(entry, endpoint, modelId, contextLength); if (injection.kind === 'unsupported') return undefined; @@ -283,6 +363,6 @@ export function applyCustomModelInjection( }; } const configDir = customModelConfigDir(sessionId); - const envOverrides = applyConfigDirInjection(configDir, injection); + const envOverrides = applyConfigDirInjection(configDir, injection, sessionEnvLookup?.(injection.dirEnvVar)); return { envOverrides, envKeys: Object.keys(envOverrides), configDir, launchModel: injection.launchModel }; } diff --git a/src/custom-model-injection.ts b/src/custom-model-injection.ts index 9aab9422..5bee035c 100644 --- a/src/custom-model-injection.ts +++ b/src/custom-model-injection.ts @@ -87,6 +87,12 @@ export interface ConfigDirInjection { * `customModelInjection.launchModel` template, never hand-built per CLI. */ launchModel?: string; + /** Carried from the registry recipe, see `cli-registry/types.ts` `linkFromRealHome`. */ + linkFromRealHome?: string[]; + /** Carried from the registry recipe, see `cli-registry/types.ts` `linkFromRealHomeDirs`. */ + linkFromRealHomeDirs?: string[]; + /** Carried from the registry recipe, see `cli-registry/types.ts` `realHomeFallback`. */ + realHomeFallback?: string; } export interface UnsupportedInjection { @@ -136,14 +142,22 @@ export function buildCustomModelInjection( } case 'configContentEnv': { - const content = renderConfigContent(cap.template, endpoint, modelId, apiKey); + const content = renderConfigContent(cap.template, endpoint, modelId, apiKey, contextLength); return withLaunchModel({ kind: 'env', envOverrides: { [cap.envVar]: content } }, cap.launchModel, modelId); } case 'configDir': { - const { content, extraEnv } = renderConfigFile(cap.template, endpoint, modelId, apiKey); + const { content, extraEnv } = renderConfigFile(cap.template, endpoint, modelId, apiKey, contextLength); return withLaunchModel( - { kind: 'configDir', dirEnvVar: cap.dirEnvVar, files: [{ relPath: cap.fileName, content }], extraEnv }, + { + kind: 'configDir', + dirEnvVar: cap.dirEnvVar, + files: [{ relPath: cap.fileName, content }], + extraEnv, + ...(cap.linkFromRealHome ? { linkFromRealHome: cap.linkFromRealHome } : {}), + ...(cap.linkFromRealHomeDirs ? { linkFromRealHomeDirs: cap.linkFromRealHomeDirs } : {}), + ...(cap.realHomeFallback ? { realHomeFallback: cap.realHomeFallback } : {}), + }, cap.launchModel, modelId ); @@ -164,41 +178,114 @@ function withLaunchModel<T extends EnvInjection | ConfigDirInjection>( return { ...result, launchModel: template.split('{modelId}').join(modelId) }; } +/** + * One quarter of the window is held back for the reply, capped at 8192 tokens (a small local + * model's window must not lose a disproportionate slice to the reservation). Returns undefined + * for an unknown or non-finite size: opencode then keeps its own default guess, which is the + * pre-fix behaviour; never invent a limit here. + */ +function opencodeModelLimit(contextLength: number | undefined): { context: number; output: number } | undefined { + if (contextLength === undefined || !Number.isFinite(contextLength)) return undefined; + const total = Math.trunc(contextLength); + if (total <= 0) return undefined; + const output = Math.max(1, Math.min(8192, Math.floor(total / 4))); + // `limit.context` is the FULL window as far as opencode is concerned: it reserves for + // the reply itself, so the discovered total is passed through, never a shrunk figure. + return { context: total, output }; +} + function renderConfigContent( template: 'opencode-json', endpoint: CustomModelEndpoint, modelId: string, - apiKey: string + apiKey: string, + contextLength?: number ): string { switch (template) { - case 'opencode-json': + case 'opencode-json': { + // `limit` carries the discovered context window (issue #749): without it opencode assumes + // a large default window for an unrecognized model id and eventually overflows a much + // smaller local server. `context` is the FULL window and opencode reserves for the reply + // inside it itself, so `opencodeModelLimit` passes the real total through and only + // `output` gets the capped reservation. + const limit = opencodeModelLimit(contextLength); return JSON.stringify({ $schema: 'https://opencode.ai/config.json', provider: { custom: { options: { baseURL: withV1Suffix(endpoint.baseUrl), apiKey }, - models: { [modelId]: {} }, + models: { [modelId]: limit ? { limit } : {} }, }, }, model: `custom/${modelId}`, }); + } } } -const CODEX_API_KEY_ENV_VAR = 'CODEMAN_CUSTOM_MODEL_API_KEY'; +/** The DEDICATED env var carrying a custom endpoint's API key for config-file recipes that + * reference keys by name (codex's `env_key`, deepseek's `apiKeyEnv`). Never a CLI-native + * name: dsh's own `llm-deepseek`/`web-search-deepseek` rows read `DEEPSEEK_API_KEY` too, + * so an in-session `/model` switch back to `deepseek-official` (or a profile with web + * search on) would otherwise send the custom endpoint's key to api.deepseek.com. Both + * entries list it in `privilegedEnvKeys` (stock.ts) like each other's redirect vars. + */ +const CUSTOM_MODEL_API_KEY_ENV_VAR = 'CODEMAN_CUSTOM_MODEL_API_KEY'; /** The `[model.<name>]` block name grok's config.toml uses for the injected model — also * what `-m <name>` in the standalone script's ONE_SHOT argv must reference to select it. */ export const GROK_CUSTOM_MODEL_NAME = 'codeman-custom'; function renderConfigFile( - template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml', + template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml' | 'deepseek-patch-json', endpoint: CustomModelEndpoint, modelId: string, - apiKey: string + apiKey: string, + contextLength?: number ): { content: string; extraEnv?: Record<string, string> } { const baseUrl = withV1Suffix(endpoint.baseUrl); switch (template) { + case 'deepseek-patch-json': { + const route = { provider: 'codeman-custom', model: modelId }; + return { + content: JSON.stringify( + [ + { + id: 'llm-pi-ai', + name: '@deepseek-ai/dsh-llm-pi-ai', + config: { + providers: { + 'codeman-custom': { + // By NAME only (never the literal key, see CUSTOM_MODEL_API_KEY_ENV_VAR). + apiKeyEnv: CUSTOM_MODEL_API_KEY_ENV_VAR, + api: 'openai-completions', + baseURL: baseUrl, + models: [ + { + id: modelId, + name: modelId, + ...(contextLength !== undefined && Number.isFinite(contextLength) && contextLength > 0 + ? { + contextWindow: Math.trunc(contextLength), + // A quarter of the window keeps the reply inside the prompt's share; 8192 caps it on a large window. + maxTokens: Math.max(1, Math.min(8192, Math.floor(contextLength / 4))), + } + : {}), + }, + ], + }, + }, + }, + }, + { id: 'dsh-tui', name: '@deepseek-harness-tui/dsh-tui', config: route }, + { id: 'agent-default-model', name: '@deepseek-ai/dsh-agent-default-model', config: route }, + ], + null, + 2 + ), + extraEnv: { [CUSTOM_MODEL_API_KEY_ENV_VAR]: apiKey }, + }; + } case 'codex-toml': { // Verified against real codex (>= Feb 2026): `model` is a top-level STRING, never // a `[model].default` table — codex rejects that with "invalid type: map, expected @@ -217,15 +304,21 @@ function renderConfigFile( const content = [ `model = ${quoted(modelId)}`, `model_provider = "custom"`, + ...(contextLength !== undefined && Number.isFinite(contextLength) && contextLength > 0 + ? [ + `model_context_window = ${Math.trunc(contextLength)}`, + `model_auto_compact_token_limit = ${Math.max(1, Math.floor(contextLength * 0.75))}`, + ] + : []), '', '[model_providers.custom]', `name = "Custom Endpoint"`, `base_url = ${quoted(baseUrl)}`, - `env_key = ${quoted(CODEX_API_KEY_ENV_VAR)}`, + `env_key = ${quoted(CUSTOM_MODEL_API_KEY_ENV_VAR)}`, `wire_api = "responses"`, '', ].join('\n'); - return { content, extraEnv: { [CODEX_API_KEY_ENV_VAR]: apiKey } }; + return { content, extraEnv: { [CUSTOM_MODEL_API_KEY_ENV_VAR]: apiKey } }; } case 'pi-models-json': // Verified against pi's OWN bundled docs (models.md): `models` is an ARRAY of diff --git a/src/daemon-control.ts b/src/daemon-control.ts index cb09f3d7..c52efd5e 100644 --- a/src/daemon-control.ts +++ b/src/daemon-control.ts @@ -49,6 +49,7 @@ export interface WebLaunchOptions { basePath?: string; titleHostname?: string; allowUnauthenticatedNetwork?: boolean; + allowUnauthenticatedTunnel?: boolean; multiuser?: boolean; } @@ -92,6 +93,7 @@ export function buildWebArgs(options: WebLaunchOptions): string[] { if (options.basePath) args.push('--base-url', options.basePath); if (options.titleHostname) args.push('--title-hostname', options.titleHostname); if (options.allowUnauthenticatedNetwork) args.push('--allow-unauthenticated-network'); + if (options.allowUnauthenticatedTunnel) args.push('--allow-unauthenticated-tunnel'); if (options.multiuser) args.push('--multiuser'); return args; } diff --git a/src/deepseek-transcript.ts b/src/deepseek-transcript.ts index dc374f10..f32139cc 100644 --- a/src/deepseek-transcript.ts +++ b/src/deepseek-transcript.ts @@ -26,13 +26,15 @@ * **1. One zstd FRAME per append, not one zstd stream.** The file is * `session.jsonl.zstd`, and dsh appends by compressing each batch of lines into * its own frame and writing it at the end. `zstd -dc` handles that (frames - * concatenate by definition), but Node's `zlib.zstdDecompress()` and - * `createZstdDecompress()` both stop at the first frame end: measured on a real - * 56-line transcript, Node returned 158 bytes / 1 line where the CLI returned - * 43,747 bytes / 56 lines. That is a silent truncation to the session header — - * every call would have reported "no answer yet" forever. `decodeZstdFrames()` - * below walks the frame headers itself and decompresses each frame, and - * `test/deepseek-transcript.test.ts` pins it against multi-frame fixtures. + * concatenate by definition), but before Node 24.22 and 26.10 + * (nodejs/node#65865) `zlib.zstdDecompress()` and `createZstdDecompress()` both + * stop at the first frame end, and the supported range still includes those + * versions: measured on a real 56-line transcript, Node returned 158 bytes / + * 1 line where the CLI returned 43,747 bytes / 56 lines. That is a silent + * truncation to the session header: every call would have reported "no answer + * yet" forever. `decodeZstdFrames()` below walks the frame headers itself and + * decompresses each frame, and `test/deepseek-transcript.test.ts` pins it + * against multi-frame fixtures. * * **2. The user's prompts are mixed with injected context.** Every turn also * writes a `user/message` whose source is a plugin (the runtime-context diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts new file mode 100644 index 00000000..a8703371 --- /dev/null +++ b/src/discord-bridge.ts @@ -0,0 +1,1334 @@ +/** + * @fileoverview Two-way Discord bridge: one thread per tab, alerts out, replies typed back in. + * + * Split like webhook-notify.ts, so the parts that matter are testable without a network: + * - pure: `sanitiseReply`, `threadNameFor`, `buildPost`, `discordConfigProblem`, `maskToken` + * - store: `~/.codeman/discord.json` (bot token, 0600 via tmp+rename) and + * `~/.codeman/discord-bridge.json` (sessionId -> threadId, not secret) + * - IO: `DiscordApi` (REST, injected fetch), `DiscordGateway` (inbound WebSocket, injected + * socket factory) and `DiscordBridge` (the rules that tie them to sessions) + * + * Rules the code keeps and the tests pin: + * - The bot token lives only in `discord.json`; it is never returned, logged, or put in an error. + * - Only messages from `allowedUserIds`, in `guildId`, inside a thread this bridge created, are + * acted on. Everything else is dropped without being read further. + * - A reply becomes one printable line plus Enter. Embedded newlines would submit early. A lone digit + * while a permission or question is pending goes as the key alone (see onGatewayMessage). + * - Posts never ping unless `pingMode` says so, and then only the allowlisted user ids. + * - A Discord outage or a bad token never reaches a session: every IO path catches and backs off. + * + * @module discord-bridge + */ + +import { existsSync, mkdirSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import { probePathKind } from './utils/bounded-path-probe.js'; +import { DISCORD_RECEIVED_DIR } from './web/paste-image-gc.js'; +import WebSocket from 'ws'; +import { WEBHOOK_SCOPES, type WebhookUrgency } from './types/push.js'; +import { + DISCORD_ON_DELETE, + DISCORD_PING_MODES, + DISCORD_THREAD_CREATION, + type DiscordConfig, + type DiscordGatewayState, + type DiscordPingMode, + type DiscordResult, +} from './types/discord.js'; + +const CONFIG_FILE = 'discord.json'; +const THREADS_FILE = 'discord-bridge.json'; +const API = 'https://discord.com/api/v10'; +const GATEWAY_URL = 'wss://gateway.discord.gg/?v=10&encoding=json'; +const SEND_TIMEOUT_MS = 8000; +const MAX_CONTENT = 1900; +const MAX_THREAD_NAME = 100; +/** Longest reply typed into a session; Discord allows 2000, a CLI prompt rarely needs more than this. */ +export const MAX_REPLY_CHARS = 1000; +const REPLY_MIN_INTERVAL_MS = 1000; +const DEDUPE_WINDOW_MS = 3000; +/** GUILDS | GUILD_MESSAGES | MESSAGE_CONTENT (privileged, switched on in the Developer Portal). */ +const GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 15); +/** Gateway close codes that retrying cannot fix: bad token, bad intents, bad shard. */ +const FATAL_CLOSE_CODES = new Set([4004, 4010, 4011, 4012, 4013, 4014]); +const SNOWFLAKE = /^\d{15,25}$/; +/** Stale-thread sweep: a thread younger than this may still be between creation and its map entry. */ +const STALE_MIN_AGE_MS = 60 * 60 * 1000; +const STALE_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000; +/** At most this many threads closed in one sweep, so a damaged map cannot empty the channel in one go. */ +const STALE_MAX_PER_SWEEP = 25; +const DISCORD_EPOCH_MS = 1420070400000; +/** A Markdown table wider or longer than this is posted as it was written, not padded into a code block. */ +const TABLE_MAX_COLS = 24; +const TABLE_MAX_ROWS = 200; +/** + * The most of a last message worth converting: what 8 posts can carry, twice over, since a table can + * shrink when formatted. The rest could never be posted, and a padded table costs its widest cell + * times its rows, so an agent-steerable message must not reach the conversion whole. + */ +const MAX_CONVERTED_TEXT = MAX_CONTENT * 8 * 2; +/** Reading a CLI's transcript must not hold an alert back for long. */ +const FULL_TEXT_TIMEOUT_MS = 4000; + +export const DEFAULT_DISCORD_CONFIG: DiscordConfig = { + enabled: false, + token: '', + guildId: '', + parentChannelId: '', + allowedUserIds: [], + threadCreation: 'lazy', + onSessionDelete: 'archive', + staleThreads: 'keep', + pingMode: 'critical', + scope: 'attention', + startupHint: true, +}; + +export interface DiscordMessage { + event: string; + title: string; + body: string; + urgency: WebhookUrgency; + sessionId?: string; + sessionName?: string; + /** The tail of what the CLI last printed, so the reader can see what it is asking. */ + tail?: string | (() => string); + /** + * The CLI's whole last message, read from its transcript. When it yields text the post carries that + * (split over as many posts as it needs, markdown kept) instead of the screen tail. + */ + fullText?: () => Promise<{ text: string; version?: string } | undefined>; + host?: string; + /** A file to upload with the post (already read and size-checked by the caller). */ + file?: DiscordFile; +} + +export interface DiscordFile { + name: string; + data: Uint8Array; +} + +// --------------------------------------------------------------------------- +// Pure +// --------------------------------------------------------------------------- + +/** Why `cfg` cannot be saved as an enabled config, or null. */ +export function discordConfigProblem(cfg: DiscordConfig): string | null { + if (cfg.token && (/\s/.test(cfg.token) || cfg.token.length > 200)) return 'The bot token looks wrong'; + if (cfg.guildId && !SNOWFLAKE.test(cfg.guildId)) return 'Guild ID must be a number (Copy ID in Discord)'; + if (cfg.parentChannelId && !SNOWFLAKE.test(cfg.parentChannelId)) return 'Channel ID must be a number'; + if (cfg.allowedUserIds.some((id) => !SNOWFLAKE.test(id))) return 'Each allowed user ID must be a number'; + if (cfg.allowedUserIds.length > 20) return 'At most 20 allowed user IDs'; + if (cfg.enabled) { + if (!cfg.token) return 'Add the bot token before enabling'; + if (!cfg.guildId || !cfg.parentChannelId) return 'Add the guild ID and parent channel ID before enabling'; + if (cfg.allowedUserIds.length === 0) return 'Add at least one allowed user ID: nobody could reply otherwise'; + } + return null; +} + +/** First four and last two characters, so the user can tell which token is saved. */ +export function maskToken(token: string): string { + return token.length > 12 ? `${token.slice(0, 4)}•••${token.slice(-2)}` : token ? '•••' : ''; +} + +const clip = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s); + +/** + * A Discord reply as one printable line plus Enter, or null when nothing printable is left. The caller + * drops the Enter for a lone digit answering a pending dialog. + */ +export function sanitiseReply(raw: string, files: string[] = []): string | null { + // eslint-disable-next-line no-control-regex + const line = raw.replace(/[\r\n\t]+/g, ' ').replace(/[\u0000-\u001f\u007f-\u009f]/g, ''); + const trimmed = clip(line.trim(), MAX_REPLY_CHARS); + // Paths are ours (a working folder plus a sanitised name), appended after the cap so they are never cut. + const attached = files.length ? `${trimmed ? ' ' : ''}[attached: ${files.join(', ')}]` : ''; + // eslint-disable-next-line no-control-regex + const out = `${trimmed}${attached}`.replace(/[\u0000-\u001f\u007f-\u009f]/g, ''); + return out ? `${out}\r` : null; +} + +export function threadNameFor(sessionName: string | undefined, sessionId: string): string { + const name = (sessionName ?? '').replace(/[\r\n]+/g, ' ').trim(); + return clip(name || sessionId.slice(0, 8), MAX_THREAD_NAME); +} + +/** Whether `urgency` earns a ping under `mode`. */ +export function shouldPing(mode: DiscordPingMode, urgency: WebhookUrgency, explicitUrgent = false): boolean { + if (mode === 'off') return false; + return mode === 'all' || explicitUrgent || urgency === 'critical'; +} + +export function shouldSendDiscord(cfg: DiscordConfig, urgency: WebhookUrgency): boolean { + if (!cfg.enabled || !cfg.token) return false; + return cfg.scope === 'all' || urgency !== 'info'; +} + +/** Largest single file taken from Discord, or sent to it. Discord's own free limit is higher. */ +export const MAX_FILE_BYTES = 8 * 1024 * 1024; +const MAX_FILES_PER_REPLY = 5; +const DOWNLOAD_TIMEOUT_MS = 30_000; +/** Where a session's received files go, relative to its working folder. */ +export const RECEIVED_DIR = DISCORD_RECEIVED_DIR; + +/** + * What a session is told about the bridge. Claude gets it as `--append-system-prompt`; no other CLI + * is told. No apostrophes: it is rendered into a shell command. + */ +export const DISCORD_STARTUP_HINT = + 'Codeman note: if you are blocked on something only the user can do (a physical step such as ' + + 'pressing a button, an answer you cannot work out, or a wait that timed out), run: ' + + 'codeman notify "message that leads with the action" --urgent (lead with the action). The user reads a Discord thread for ' + + 'this tab and their reply is typed to you as one line; a file they attach is saved and its path is ' + + 'given in that line. Add --file <path> to send a file from your working folder. Not for routine progress.'; + +let activeStartupHint: string | undefined; +/** The hint to launch Claude with, or undefined while the bridge or the setting is off. */ +export function getDiscordStartupHint(): string | undefined { + return activeStartupHint; +} + +/** + * The last lines of a rendered screen for a post: colour and charset codes removed, blank lines and + * pure rule lines (box drawing, dashes) dropped, the last 14 lines, at most 900 characters, cut on a line. + * With `composerGlyph` (the CLI's own prompt mark), the composer row and everything under it (the status + * and mode hints) is cut first, but only within the last 8 rows and never a dialog's selected option + * (`❯ 1. Yes`), so a question's choices stay. + */ +export function screenTail(screen: string, maxLines = 14, maxChars = 900, composerGlyph?: string): string { + let lines = screen + // eslint-disable-next-line no-control-regex + .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b[()][A-Za-z0-9]|\u001b[@-Z\\-_]/g, '') + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '') + .split(/\r?\n/) + .map((l) => l.trimEnd()) + .filter((l) => l.trim() !== '' && !/^[\s\-_=\u2500-\u257f]+$/.test(l)); + if (composerGlyph) { + for (let i = lines.length - 1; i >= Math.max(0, lines.length - 8); i--) { + const row = lines[i].trimStart(); + if (row.startsWith(composerGlyph) && !/^\S+\s+\d+[.)]/.test(row)) { + lines = lines.slice(0, i); + break; + } + } + } + let out = lines.slice(-maxLines); + while (out.length > 1 && out.join('\n').length > maxChars) out = out.slice(1); + return out.join('\n').slice(-maxChars); +} + +/** + * Discord does not render Markdown tables: a `| a | b |` block shows as raw pipes. Each table outside a + * code fence becomes a code block with the columns padded to their widest cell (right-aligned where the + * separator row says `---:`), which a monospace font lines up. Bold and code marks inside a cell are + * dropped, since a code block would show them literally. + */ +export function tablesToCodeBlocks(text: string): string { + const cells = (row: string): string[] => { + let r = row.trim(); + if (r.startsWith('|')) r = r.slice(1); + if (r.endsWith('|') && !r.endsWith('\\|')) r = r.slice(0, -1); + return r.split(/(?<!\\)\|/).map((c) => + c + .replace(/\\\|/g, '|') + .replace(/\*\*|`/g, '') + .trim() + ); + }; + // Whitespace stripped first, so the pattern has none to backtrack over (a run of spaces made the old one quadratic). + const isSeparator = (row: string) => { + const r = row.replace(/\s+/g, ''); + return r.includes('|') && /^\|?:?-+:?(\|:?-+:?)*\|?$/.test(r); + }; + const lines = text.split('\n'); + const out: string[] = []; + let fence = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (/^\s*```/.test(line)) fence = !fence; + if (fence || !line.includes('|') || i + 1 >= lines.length || !isSeparator(lines[i + 1])) { + out.push(line); + continue; + } + // Rows belong to the table while they carry a pipe, and start with one when the header does, so a + // sentence with a pipe under a table stays prose. + const piped = line.trim().startsWith('|'); + let j = i + 2; + while ( + j < lines.length && + lines[j].trim() !== '' && + lines[j].includes('|') && + (!piped || lines[j].trim().startsWith('|')) + ) { + j++; + } + const header = cells(line); + // A table past these bounds is left as it was (raw pipes): formatting costs columns times rows, and the + // text can be steered by whatever the agent read. + if (header.length > TABLE_MAX_COLS || j - i - 1 > TABLE_MAX_ROWS) { + for (let k = i; k < j; k++) out.push(lines[k]); // a loop: a spread of 200,000 lines overflows the stack + i = j - 1; + continue; + } + const align = cells(lines[i + 1]).map((c) => (c.endsWith(':') ? 'right' : 'left')); + const rows = [header]; + for (let k = i + 2; k < j; k++) rows.push(cells(lines[k])); + let cols = 0; + for (const r of rows) if (r.length > cols) cols = r.length; + if (cols > TABLE_MAX_COLS) { + for (let k = i; k < j; k++) out.push(lines[k]); // a loop: a spread of 200,000 lines overflows the stack + i = j - 1; + continue; + } + const width = Array.from({ length: cols }, () => 1); + for (const r of rows) for (let c = 0; c < r.length; c++) width[c] = Math.max(width[c], [...r[c]].length); + const pad = (v: string, c: number) => (align[c] === 'right' ? v.padStart(width[c]) : v.padEnd(width[c])); + const fmt = (r: string[]) => + Array.from({ length: cols }, (_, c) => pad((r[c] ?? '').replace(/```/g, '\u2019\u2019\u2019'), c)) + .join(' ') + .trimEnd(); + out.push('```', fmt(header), width.map((w) => '-'.repeat(w)).join(' '), ...rows.slice(1).map(fmt), '```'); + i = j - 1; + } + return out.join('\n'); +} + +/** + * Split a long message into posts of at most `max` characters, on blank lines first, then lines, then + * (for one huge line) by length. A code fence the cut lands inside is closed and reopened, so each post + * renders on its own. At most `maxPosts`; the last carries a note when text is left over. + */ +export function splitForDiscord(text: string, max = 1900, maxPosts = 8): string[] { + const posts: string[] = []; + let cur = ''; + let fence = false; + let cut = false; + const flush = () => { + if (cur.trim()) posts.push(fence ? `${cur.trimEnd()}\n\`\`\`` : cur.trimEnd()); + cur = fence ? '```\n' : ''; + }; + const lines = text.replace(/\r\n/g, '\n').split('\n'); + for (let line of lines) { + if (posts.length >= maxPosts) { + cut = true; + break; + } + while (line.length > max - 8) { + if (cur.trim()) flush(); + cur += line.slice(0, max - 8) + '\n'; + line = line.slice(max - 8); + flush(); + if (posts.length >= maxPosts) { + cut = true; + break; + } + } + if (cut) break; + if (cur.length + line.length + 1 > max - 8) flush(); + cur += `${line}\n`; + if (/^\s*```/.test(line)) fence = !fence; + } + if (!cut && cur.trim()) posts.push(cur.trimEnd()); + if (cut && posts.length > 0) posts[posts.length - 1] += '\n*(The message continues in the tab.)*'; + return posts.slice(0, maxPosts); +} + +/** Final path segment only, `[A-Za-z0-9._-]`, no leading dot, at most 80 characters. */ +export function safeAttachmentName(name: string): string { + const base = (name.split(/[\\/]/).pop() ?? '').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[._]+/, ''); + return clip(base, 80) || 'file'; +} + +/** Discord's own CDN over https: the only place an attachment URL may point. */ +export function isDiscordCdnUrl(raw: string): boolean { + try { + const u = new URL(raw); + return u.protocol === 'https:' && /(^|\.)(discordapp\.com|discordapp\.net|discord\.com)$/.test(u.hostname); + } catch { + return false; + } +} + +export interface DiscordPost { + content: string; + allowed_mentions: { parse: string[]; users: string[] }; +} + +export function buildPost(msg: DiscordMessage, cfg: DiscordConfig, explicitUrgent = false): DiscordPost { + const ping = shouldPing(cfg.pingMode, msg.urgency, explicitUrgent) ? cfg.allowedUserIds : []; + const mentions = ping.map((id) => `<@${id}>`).join(' '); + const head = `${mentions ? `${mentions} ` : ''}**${clip(msg.title, 120)}**`; + // Built here, not by the caller, so the screen is only read for a post that is really going out. + let tailText: string | undefined; + try { + tailText = typeof msg.tail === 'function' ? msg.tail() : msg.tail; + } catch { + tailText = undefined; + } + const fence = tailText?.trim() ? `\n\`\`\`\n${tailText.trim().replace(/```/g, "'''")}\n\`\`\`` : ''; + const host = msg.host ? ` (${clip(msg.host, 60)})` : ''; + const content = clip(`${head}${host}${msg.body ? `\n${msg.body}` : ''}${fence}`, MAX_CONTENT); + // Agent output and session names must not be able to @everyone the server; only the allowlisted ids ping. + return { content, allowed_mentions: { parse: [], users: ping } }; +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +export function discordConfigPath(configDir: string): string { + return join(configDir, CONFIG_FILE); +} + +const oneOf = <T extends string>(list: readonly T[], v: unknown, fallback: T): T => + (list as readonly unknown[]).includes(v) ? (v as T) : fallback; + +function coerce(raw: unknown): DiscordConfig { + const r = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>; + const d = DEFAULT_DISCORD_CONFIG; + return { + enabled: r.enabled === true, + token: typeof r.token === 'string' ? r.token : '', + guildId: typeof r.guildId === 'string' ? r.guildId : '', + parentChannelId: typeof r.parentChannelId === 'string' ? r.parentChannelId : '', + allowedUserIds: Array.isArray(r.allowedUserIds) + ? r.allowedUserIds.filter((x): x is string => typeof x === 'string') + : [], + threadCreation: oneOf(DISCORD_THREAD_CREATION, r.threadCreation, d.threadCreation), + onSessionDelete: oneOf(DISCORD_ON_DELETE, r.onSessionDelete, d.onSessionDelete), + staleThreads: oneOf(DISCORD_ON_DELETE, r.staleThreads, d.staleThreads), + pingMode: oneOf(DISCORD_PING_MODES, r.pingMode, d.pingMode), + scope: oneOf(WEBHOOK_SCOPES, r.scope, d.scope), + startupHint: r.startupHint === undefined ? d.startupHint : r.startupHint === true, + }; +} + +function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> { + let timer: ReturnType<typeof setTimeout>; + return Promise.race([ + p, + new Promise<never>((_, reject) => { + timer = setTimeout(() => reject(new Error('timed out')), ms); + timer.unref?.(); + }), + ]).finally(() => clearTimeout(timer)); +} + +/** A failure with a reason worth showing as it is (unlike a network error, whose text is not). */ +class BridgeProblem extends Error {} + +export async function readDiscordConfig(configDir: string): Promise<DiscordConfig> { + try { + return coerce(JSON.parse(await fs.readFile(discordConfigPath(configDir), 'utf-8'))); + } catch { + return { ...DEFAULT_DISCORD_CONFIG }; + } +} + +async function writePrivate(target: string, data: unknown, mode: number): Promise<void> { + // A unique name: two writes at once (two admin tabs) must not share one tmp file. + const tmp = `${target}.${randomUUID()}.tmp`; + try { + await fs.writeFile(tmp, JSON.stringify(data, null, 2), { mode }); + await fs.rename(tmp, target); + } catch (err) { + await fs.unlink(tmp).catch(() => undefined); + throw err; + } +} + +/** 0600 via tmp+rename: `mode` on writeFile only applies to a file being created. */ +export async function writeDiscordConfig(configDir: string, cfg: DiscordConfig): Promise<void> { + if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true }); + await writePrivate(discordConfigPath(configDir), coerce(cfg), 0o600); +} + +/** The thread map plus the parent channel it was built under. */ +async function readThreadFile(configDir: string): Promise<{ parent: string; threads: Record<string, string> }> { + try { + const raw = JSON.parse(await fs.readFile(join(configDir, THREADS_FILE), 'utf-8')) as Record<string, unknown>; + const threads: Record<string, string> = {}; + const saved = typeof raw.threads === 'object' && raw.threads !== null ? raw.threads : {}; + for (const [k, v] of Object.entries(saved)) if (typeof v === 'string' && SNOWFLAKE.test(v)) threads[k] = v; + return { parent: typeof raw.parentChannelId === 'string' ? raw.parentChannelId : '', threads }; + } catch { + return { parent: '', threads: {} }; + } +} + +export async function readThreadMap(configDir: string): Promise<Record<string, string>> { + return (await readThreadFile(configDir)).threads; +} + +async function writeThreadMap( + configDir: string, + parentChannelId: string, + threads: Record<string, string> +): Promise<void> { + if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true }); + await writePrivate(join(configDir, THREADS_FILE), { parentChannelId, threads }, 0o600); +} + +// --------------------------------------------------------------------------- +// IO: REST +// --------------------------------------------------------------------------- + +export interface DiscordThreadInfo { + id: string; + parent_id?: string; + owner_id?: string; + thread_metadata?: { archived?: boolean }; +} + +export type DiscordFetch = (target: URL, init: RequestInit) => Promise<Response>; + +export class DiscordHttpError extends Error { + constructor( + readonly status: number, + /** Discord's own error code (10003 unknown channel, 50001 missing access ...). */ + readonly code?: number + ) { + super(`Discord HTTP ${status}${code ? ` (code ${code})` : ''}`); + } +} + +/** Thin REST client. The token goes only in the Authorization header and never into a message. */ +export class DiscordApi { + constructor( + private readonly token: () => string, + private readonly fetchImpl: DiscordFetch, + private readonly sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)) + ) {} + + async request<T = unknown>(method: string, path: string, body?: unknown, retried = false): Promise<T | null> { + const isForm = typeof FormData !== 'undefined' && body instanceof FormData; + const res = await this.fetchImpl(new URL(`${API}${path}`), { + method, + headers: { + Authorization: `Bot ${this.token()}`, + // A form body sets its own multipart Content-Type (with the boundary). + ...(isForm ? {} : { 'Content-Type': 'application/json' }), + 'User-Agent': 'DiscordBot (https://github.com/Ark0N/Codeman, 1)', + }, + body: body === undefined ? undefined : isForm ? (body as FormData) : JSON.stringify(body), + redirect: 'manual', + signal: AbortSignal.timeout(SEND_TIMEOUT_MS), + }); + if (res.status === 429 && !retried) { + const j = (await res.json().catch(() => ({}))) as { retry_after?: number }; + await this.sleep(Math.min(Math.max((j.retry_after ?? 1) * 1000, 100), 10_000)); + return this.request<T>(method, path, body, true); + } + if (!res.ok) { + const j = (await res.json().catch(() => ({}))) as { code?: number }; + throw new DiscordHttpError(res.status, j.code); + } + if (res.status === 204) return null; + return (await res.json().catch(() => null)) as T | null; + } + + createThread(parentId: string, name: string): Promise<{ id: string } | null> { + // type 11 = public thread. A week of inactivity before Discord archives it. + return this.request('POST', `/channels/${parentId}/threads`, { name, type: 11, auto_archive_duration: 10080 }); + } + post(channelId: string, p: DiscordPost): Promise<{ id: string } | null> { + return this.request('POST', `/channels/${channelId}/messages`, p); + } + postWithFile(channelId: string, p: DiscordPost, file: DiscordFile): Promise<{ id: string } | null> { + const form = new FormData(); + form.append('payload_json', JSON.stringify(p)); + form.append('files[0]', new Blob([Buffer.from(file.data)]), safeAttachmentName(file.name)); + return this.request('POST', `/channels/${channelId}/messages`, form); + } + rename(threadId: string, name: string): Promise<unknown> { + return this.request('PATCH', `/channels/${threadId}`, { name }); + } + archive(threadId: string): Promise<unknown> { + return this.request('PATCH', `/channels/${threadId}`, { archived: true }); + } + remove(threadId: string): Promise<unknown> { + return this.request('DELETE', `/channels/${threadId}`); + } + botUserId(): Promise<{ id: string } | null> { + return this.request('GET', '/users/@me'); + } + /** Active threads of the guild (all channels: the caller filters by parent). */ + activeThreads(guildId: string): Promise<{ threads: DiscordThreadInfo[] } | null> { + return this.request('GET', `/guilds/${guildId}/threads/active`); + } + /** The most recent archived public threads of a channel (one page). */ + archivedThreads(parentId: string): Promise<{ threads: DiscordThreadInfo[] } | null> { + return this.request('GET', `/channels/${parentId}/threads/archived/public?limit=100`); + } + react(channelId: string, messageId: string, emoji: string): Promise<unknown> { + return this.request( + 'PUT', + `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me` + ); + } +} + +// --------------------------------------------------------------------------- +// IO: gateway +// --------------------------------------------------------------------------- + +export interface GatewayMessage { + id: string; + channel_id: string; + guild_id?: string; + content: string; + author: { id: string; bot?: boolean }; + attachments?: Array<{ id: string; filename: string; size: number; url: string }>; +} + +/** The subset of `ws` the gateway uses, so tests can drive it without a socket. */ +export interface GatewaySocket { + send(data: string): void; + close(code?: number): void; + on(event: 'message', cb: (data: { toString(): string }) => void): void; + on(event: 'close', cb: (code: number) => void): void; + on(event: 'error', cb: (err: Error) => void): void; +} +export type SocketFactory = (url: string) => GatewaySocket; + +const defaultSocket: SocketFactory = (url) => new WebSocket(url) as unknown as GatewaySocket; + +/** + * Inbound half: identify, heartbeat, resume and reconnect with backoff. Only MESSAGE_CREATE is + * surfaced. A close code that retrying cannot fix (bad token, intent not enabled) stops it and + * records `lastError` for the Settings status line. + */ +export class DiscordGateway { + state: DiscordGatewayState = 'off'; + lastError: string | null = null; + private sock: GatewaySocket | null = null; + private seq: number | null = null; + private sessionId: string | null = null; + private resumeUrl: string | null = null; + private hb: ReturnType<typeof setInterval> | null = null; + private retry: ReturnType<typeof setTimeout> | null = null; + private acked = true; + private attempts = 0; + private stopped = true; + + constructor( + private readonly token: () => string, + private readonly onMessage: (m: GatewayMessage) => void, + private readonly socketFactory: SocketFactory = defaultSocket, + private readonly backoffMs: (attempt: number) => number = (n) => Math.min(1000 * 2 ** n, 60_000) + ) {} + + start(): void { + this.stopped = false; + this.connect(); + } + + stop(): void { + this.stopped = true; + this.state = 'off'; + this.clearTimers(); + const s = this.sock; + this.sock = null; + try { + s?.close(1000); + } catch { + /* already closed */ + } + } + + private clearTimers(): void { + if (this.hb) clearInterval(this.hb); + if (this.retry) clearTimeout(this.retry); + this.hb = this.retry = null; + } + + private connect(): void { + if (this.stopped) return; + this.state = 'connecting'; + let sock: GatewaySocket; + try { + sock = this.socketFactory(this.resumeUrl ?? GATEWAY_URL); + } catch { + this.scheduleReconnect(); + return; + } + this.sock = sock; + sock.on('message', (data) => this.handle(sock, data.toString())); + sock.on('error', () => undefined); // 'close' follows and drives the reconnect + sock.on('close', (code) => { + if (this.sock !== sock) return; // a replaced socket's late close + this.sock = null; + this.clearTimers(); + if (this.stopped) return; + if (FATAL_CLOSE_CODES.has(code)) { + this.state = 'error'; + this.lastError = + code === 4004 + ? 'Discord refused the bot token' + : code === 4014 + ? 'Message Content Intent is off in the Developer Portal' + : `Discord closed the connection (${code})`; + return; + } + this.scheduleReconnect(); + }); + } + + private scheduleReconnect(): void { + if (this.stopped) return; + this.state = 'connecting'; + this.retry = setTimeout(() => this.connect(), this.backoffMs(this.attempts++)); + } + + private handle(sock: GatewaySocket, raw: string): void { + // `ws` keeps emitting while a socket closes: a frame after stop() or from a replaced socket must start nothing. + if (this.stopped || sock !== this.sock) return; + let p: { op: number; d?: unknown; s?: number | null; t?: string | null }; + try { + p = JSON.parse(raw); + } catch { + return; + } + if (typeof p.s === 'number') this.seq = p.s; + switch (p.op) { + case 10: { + const interval = Number((p.d as { heartbeat_interval?: number })?.heartbeat_interval) || 41_250; + this.acked = true; + this.hb = setInterval(() => { + if (!this.acked) { + // Zombie connection: no ack since the last beat. Resume on a fresh socket. + try { + sock.close(4000); + } catch { + /* already closed */ + } + return; + } + this.acked = false; + sock.send(JSON.stringify({ op: 1, d: this.seq })); + }, interval); + if (this.sessionId && this.seq !== null) { + sock.send(JSON.stringify({ op: 6, d: { token: this.token(), session_id: this.sessionId, seq: this.seq } })); + } else { + sock.send( + JSON.stringify({ + op: 2, + d: { + token: this.token(), + intents: GATEWAY_INTENTS, + properties: { os: 'linux', browser: 'codeman', device: 'codeman' }, + }, + }) + ); + } + break; + } + case 11: + this.acked = true; + break; + case 1: + sock.send(JSON.stringify({ op: 1, d: this.seq })); + break; + case 7: // server asked us to reconnect (resumable) + sock.close(4000); + break; + case 9: // invalid session: resumable only if d === true + if (p.d !== true) { + this.sessionId = null; + this.seq = null; + this.resumeUrl = null; + } + sock.close(4000); + break; + case 0: + if (p.t === 'READY') { + const d = p.d as { session_id?: string; resume_gateway_url?: string }; + this.sessionId = d.session_id ?? null; + this.resumeUrl = d.resume_gateway_url ? `${d.resume_gateway_url}/?v=10&encoding=json` : null; + this.state = 'connected'; + this.lastError = null; + this.attempts = 0; + } else if (p.t === 'RESUMED') { + this.state = 'connected'; + this.attempts = 0; + } else if (p.t === 'MESSAGE_CREATE') { + this.onMessage(p.d as GatewayMessage); + } + break; + } + } +} + +// --------------------------------------------------------------------------- +// The bridge +// --------------------------------------------------------------------------- + +/** + * Create or re-verify `<workingDir>/.codeman-discord`, or null when it is not safe to write there. + * Same safeguards as the prompt uploads folder: the workspace is probed with the bounded probe before + * the first disk touch (a dead mount must not hang the event loop), a symlink or a non-directory at + * the folder is refused (an agent could plant `.codeman-discord -> ~/.ssh`), and the folder ignores + * itself with a `.gitignore` of `*`, written once with O_EXCL so an `git add -A` never commits what was + * sent and a file already there stays the user's. + */ +export async function ensureReceivedDir(workingDir: string): Promise<string | null> { + if ((await probePathKind(workingDir, { pastCap: true })) !== 'directory') return null; + const dir = join(workingDir, RECEIVED_DIR); + try { + const st = await fs.lstat(dir); + if (st.isSymbolicLink() || !st.isDirectory()) return null; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return null; + try { + await fs.mkdir(dir); + } catch (mkErr) { + if ((mkErr as NodeJS.ErrnoException).code !== 'EEXIST') return null; + const again = await fs.lstat(dir).catch(() => null); + if (!again || again.isSymbolicLink() || !again.isDirectory()) return null; + } + } + const ignore = join(dir, '.gitignore'); + try { + await fs.writeFile(ignore, '*\n', { flag: 'wx' }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EEXIST') return dir; + // An empty ignore file would read as the user's next time, so take it back. + await fs.rm(ignore, { force: true }).catch(() => undefined); + return null; + } + return dir; +} + +export interface DiscordBridgeDeps { + configDir: string; + fetchImpl: DiscordFetch; + /** + * Types a person's Discord reply into the session (a line ending in Enter, or a lone digit for a + * dialog). False when the session is gone. + */ + sendInput: (sessionId: string, text: string) => Promise<boolean>; + /** True once the tab's agent has exited: tmux still accepts keys for a dead pane, so a tick would lie. */ + agentExited?: (sessionId: string) => boolean; + /** True while the session shows a permission or question dialog, which acts on a bare keypress. */ + awaitingChoice?: (sessionId: string) => boolean; + /** A session's working folder, where received files are saved. Undefined when it is gone or remote. */ + sessionDir?: (sessionId: string) => string | undefined; + socketFactory?: SocketFactory; + now?: () => number; + sleep?: (ms: number) => Promise<void>; + backoffMs?: (attempt: number) => number; +} + +export class DiscordBridge { + private cfg: DiscordConfig = { ...DEFAULT_DISCORD_CONFIG }; + private threads: Record<string, string> = {}; + private loaded = false; + private gateway: DiscordGateway | null = null; + /** The token the running gateway was started with ('' when none). */ + private gatewayToken = ''; + private lastSent = new Map<string, number>(); + private lastReply = new Map<string, number>(); + /** The last name seen per session, so only a real rename touches the thread. */ + private names = new Map<string, string>(); + private pending = new Map<string, Promise<string | DiscordResult>>(); + /** Tabs closed while their thread was still being created: the creation applies `onSessionDelete` to it. */ + private closedWhilePending = new Set<string>(); + /** The last whole message posted per tab, so the same text is not posted twice (idle, then stop). */ + private lastFull = new Map<string, string>(); + /** The parent channel the thread map was built under. */ + private threadsParent = ''; + /** True from boot until the restored tabs exist (see hold). */ + private held = false; + /** Thread-map writes run one after another: concurrent creations must not interleave on the tmp file. */ + private persistChain: Promise<void> = Promise.resolve(); + /** Per-thread chain of inbound messages (see onGatewayMessage). */ + private inbound = new Map<string, Promise<void>>(); + private mapLoad?: Promise<void>; + private sweepTimer?: ReturnType<typeof setInterval>; + /** The first full load (config, thread map, gateway), shared by every caller that arrives before it ends. */ + private firstLoad?: Promise<void>; + private last: DiscordResult | null = null; + private readonly api: DiscordApi; + private readonly now: () => number; + + constructor(private readonly deps: DiscordBridgeDeps) { + this.now = deps.now ?? Date.now; + this.api = new DiscordApi(() => this.cfg.token, deps.fetchImpl, deps.sleep); + } + + get lastResult(): DiscordResult | null { + return this.last; + } + get gatewayState(): DiscordGatewayState { + return this.gateway?.state ?? 'off'; + } + get gatewayError(): string | null { + return this.gateway?.lastError ?? null; + } + + async ensureLoaded(): Promise<void> { + if (this.loaded) return; + this.firstLoad ??= this.reload(); + await this.firstLoad; + } + + /** Forget thread entries for tabs that no longer exist (dropped without a delete, for example). Once, at boot. */ + async prune(liveIds: Iterable<string>): Promise<void> { + await this.ensureLoaded(); + const live = new Set(liveIds); + if (live.size === 0) return; // nothing restored: more likely a failed restore than an empty server + const stale = Object.keys(this.threads).filter((id) => !live.has(id)); + if (stale.length === 0) return; + for (const id of stale) delete this.threads[id]; + await this.persistThreads(); + } + + /** + * Close threads in the parent channel that this bot made and no tab maps to (`staleThreads`). Runs after + * the boot prune, then daily. Never touches a thread someone else made (owner is the bot), a young one + * (its map entry may not be written yet), or anything while the map is empty (a lost map would make + * every thread look stale). Returns how many it archived or deleted. Never throws. + */ + async sweepStaleThreads(): Promise<number> { + try { + await this.ensureLoaded(); + const c = this.cfg; + if (c.staleThreads === 'keep' || !c.enabled || !c.token || !c.guildId || !c.parentChannelId) return 0; + if (Object.keys(this.threads).length === 0 || this.pending.size > 0) return 0; + const me = (await this.api.botUserId())?.id; + if (!me) return 0; + const known = new Set(Object.values(this.threads)); + const cutoff = this.now() - STALE_MIN_AGE_MS; + const stale = (list: DiscordThreadInfo[] | undefined, archived: boolean) => + (list ?? []) + .filter( + (t) => + t.parent_id === c.parentChannelId && + t.owner_id === me && + SNOWFLAKE.test(t.id) && + !known.has(t.id) && + Number((BigInt(t.id) >> 22n) + BigInt(DISCORD_EPOCH_MS)) < cutoff && + // An archived thread has nothing left to archive. + !(archived && c.staleThreads === 'archive') + ) + .map((t) => t.id); + const ids = [ + ...stale((await this.api.activeThreads(c.guildId))?.threads, false), + ...(c.staleThreads === 'delete' + ? stale((await this.api.archivedThreads(c.parentChannelId))?.threads, true) + : []), + ].slice(0, STALE_MAX_PER_SWEEP); + let done = 0; + for (const id of ids) { + const ok = await (c.staleThreads === 'delete' ? this.api.remove(id) : this.api.archive(id)).then( + () => true, + () => false + ); + if (ok) done++; + } + return done; + } catch { + return 0; + } + } + + /** Sweep now, then daily. The timer does not keep the process alive and `stop()` clears it. */ + startStaleSweep(): void { + clearInterval(this.sweepTimer); + void this.sweepStaleThreads(); + this.sweepTimer = setInterval(() => void this.sweepStaleThreads(), STALE_SWEEP_INTERVAL_MS); + this.sweepTimer.unref?.(); + } + + /** Hold the gateway back (boot): a reply must not arrive before the restored tabs exist. */ + hold(): void { + this.held = true; + } + + /** Boot is done: start the gateway if the bridge is on. */ + async release(): Promise<void> { + if (!this.held) return; + this.held = false; + await this.reload(); + } + + /** (Re)read the config and start or stop the gateway to match. Call at boot and after a save. */ + async reload(): Promise<void> { + this.cfg = await readDiscordConfig(this.deps.configDir); + // The thread map is read once, by whoever gets here first; everyone else waits for that read. A second + // read finishing later would replace the map and drop threads made in between. + this.mapLoad ??= readThreadFile(this.deps.configDir).then((saved) => { + this.threads = { ...saved.threads, ...this.threads }; + this.threadsParent ||= saved.parent; + this.loaded = true; + }); + await this.mapLoad; + // Threads belong to the channel they were made under: a new parent channel (or guild) starts afresh, + // or every tab would keep posting into the old one. + const parent = this.cfg.parentChannelId; + if (parent && parent !== this.threadsParent) { + this.threads = {}; + this.pending.clear(); + this.closedWhilePending.clear(); + this.threadsParent = parent; + await this.persistThreads(); + } + activeStartupHint = this.cfg.enabled && this.cfg.token && this.cfg.startupHint ? DISCORD_STARTUP_HINT : undefined; + // A save that changed neither the switch nor the token keeps the gateway session, so it can resume. + // A gateway that gave up (a fatal close, such as a missing intent) is replaced, or a fix could never take. + const want = this.cfg.enabled && this.cfg.token && !this.held ? this.cfg.token : ''; + if (this.gateway && this.gateway.state !== 'error' && want && want === this.gatewayToken) return; + this.gateway?.stop(); + this.gateway = null; + this.gatewayToken = want; + if (want) { + this.gateway = new DiscordGateway( + () => this.cfg.token, + (m) => void this.onGatewayMessage(m).catch(() => undefined), + this.deps.socketFactory, + this.deps.backoffMs + ); + this.gateway.start(); + } + } + + stop(): void { + clearInterval(this.sweepTimer); + this.sweepTimer = undefined; + this.gateway?.stop(); + this.gateway = null; + this.gatewayToken = ''; + activeStartupHint = undefined; + this.lastSent.clear(); + this.lastReply.clear(); + this.names.clear(); + this.lastFull.clear(); + this.inbound.clear(); + } + + private fail(err: unknown): DiscordResult { + const status = err instanceof DiscordHttpError ? err.status : undefined; + const code = err instanceof DiscordHttpError ? err.code : undefined; + const error = + status === 401 + ? 'The bot token was refused' + : status === 403 || code === 50001 + ? 'The bot cannot see the parent channel (check its role and the channel ID)' + : status === 404 + ? 'Channel not found (check the IDs)' + : status + ? `HTTP ${status}` + : err instanceof BridgeProblem + ? err.message + : (err as { name?: string })?.name === 'TimeoutError' + ? 'Timed out' + : 'Network error'; + return { ok: false, status, error, at: this.now() }; + } + + private persistThreads(): Promise<void> { + this.persistChain = this.persistChain + .then(() => writeThreadMap(this.deps.configDir, this.threadsParent, { ...this.threads })) + .catch(() => undefined); + return this.persistChain; + } + + /** The thread for a session, creating it when absent. Concurrent callers share one creation. */ + private threadFor(sessionId: string, name: string | undefined): Promise<string | DiscordResult> { + const existing = this.threads[sessionId]; + if (existing) return Promise.resolve(existing); + const inflight = this.pending.get(sessionId); + if (inflight) return inflight; + const parent = this.cfg.parentChannelId; + const mine: { p?: Promise<string | DiscordResult> } = {}; + const p = (mine.p = (async () => { + try { + const t = await this.api.createThread(parent, threadNameFor(name, sessionId)); + if (!t?.id) return this.fail(new BridgeProblem('Discord did not return a thread')); + // A save moved the bridge to another channel meanwhile: this thread belongs to the old one. + if (parent !== this.cfg.parentChannelId) { + await this.closeThread(t.id); // made in the old channel: tidy it + return this.fail(new BridgeProblem('The parent channel changed')); + } + if (this.closedWhilePending.has(sessionId)) { + await this.closeThread(t.id); + return this.fail(new BridgeProblem('The tab was closed')); + } + this.threads[sessionId] = t.id; + await this.persistThreads(); + return t.id; + } catch (err) { + return this.fail(err); + } finally { + // Only this creation's own entry: a newer one for the same tab may have replaced it. + if (this.pending.get(sessionId) === mine.p) { + this.pending.delete(sessionId); + this.closedWhilePending.delete(sessionId); + } + } + })()); + this.pending.set(sessionId, p); + return p; + } + + /** Post an alert. Session-less messages go to the parent channel. Never throws. */ + async notify( + msg: DiscordMessage, + opts: { urgent?: boolean; deliberate?: boolean } = {} + ): Promise<DiscordResult | null> { + const explicitUrgent = opts.urgent === true; + await this.ensureLoaded(); + if (!shouldSendDiscord(this.cfg, msg.urgency)) return null; + const key = `${msg.event}:${msg.sessionId ?? ''}`; + const t = this.now(); + const prev = this.lastSent.get(key); + // A deliberate post (an agent's own `codeman notify`) is never deduped: a second, different message is real. + // Automatic events are, and say so, which is not the same answer as "the bridge is off" (null). + if (!opts.deliberate && prev !== undefined && t - prev < DEDUPE_WINDOW_MS) { + return { ok: true, deduped: true, at: t }; + } + this.lastSent.set(key, t); + if (this.lastSent.size > 256) { + for (const [k, v] of this.lastSent) if (t - v > DEDUPE_WINDOW_MS) this.lastSent.delete(k); + } + let chunks: string[] = []; + // Remembered only once the first post is out: a failed post must leave the message to be sent again. + let posted: string | undefined; + if (msg.fullText) { + try { + const got = await withTimeout(msg.fullText(), FULL_TEXT_TIMEOUT_MS); + const text = got?.text.trim(); + const sid = msg.sessionId; + if (text && sid) { + // Keyed on the text and the turn it came from, so a later turn that ends on the same words is posted. + const key = `${got?.version ?? ''}\u0000${text}`; + msg = { ...msg, tail: undefined }; + if (this.lastFull.get(sid) !== key) { + chunks = splitForDiscord(tablesToCodeBlocks(text.slice(0, MAX_CONVERTED_TEXT))); + posted = key; + } // else: said already, so only the header goes + } + } catch { + // no transcript to read: the screen tail stands in + } + } + const post = buildPost(msg, this.cfg, explicitUrgent); + // One post when the head and the first part fit together, the rest follow without a ping. + if (chunks.length > 0 && post.content.length + 1 + chunks[0].length <= MAX_CONTENT) { + post.content += `\n${chunks.shift()}`; + } + // Claimed just before the first post and given back if it fails, so an alert for the same tab that + // overlaps this one does not post the message too, and a failed post is still sent again. + let claimed: { sid: string; before: string | undefined } | undefined; + try { + let target = this.cfg.parentChannelId; + if (msg.sessionId) { + const thread = await this.threadFor(msg.sessionId, msg.sessionName); + if (typeof thread !== 'string') return (this.last = thread); + target = thread; + } + const send = (to: string) => (msg.file ? this.api.postWithFile(to, post, msg.file) : this.api.post(to, post)); + if (posted && msg.sessionId) { + claimed = { sid: msg.sessionId, before: this.lastFull.get(msg.sessionId) }; + this.lastFull.set(msg.sessionId, posted); + } + try { + await send(target); + } catch (err) { + // The thread was deleted by hand: forget it, make a new one, once. + if (msg.sessionId && err instanceof DiscordHttpError && err.status === 404) { + delete this.threads[msg.sessionId]; + const fresh = await this.threadFor(msg.sessionId, msg.sessionName); + if (typeof fresh !== 'string') return (this.last = fresh); + await send(fresh); + } else { + throw err; + } + } + // Parts 2 to 8 are best effort on purpose: the first post, which carries the title, is what is reported. + for (const part of chunks) await this.say(this.threads[msg.sessionId ?? ''] ?? target, part); + return (this.last = { ok: true, at: this.now() }); + } catch (err) { + if (claimed) { + if (claimed.before === undefined) this.lastFull.delete(claimed.sid); + else this.lastFull.set(claimed.sid, claimed.before); + } + return (this.last = this.fail(err)); + } + } + + /** A deliberate test send to the parent channel: bypasses `enabled`, scope and dedupe. */ + async sendTest(cfg: DiscordConfig, host?: string): Promise<DiscordResult> { + // Its own client: swapping this.cfg would undo a save that lands while the post is in flight. + const api = new DiscordApi(() => cfg.token, this.deps.fetchImpl, this.deps.sleep); + try { + await api.post( + cfg.parentChannelId, + buildPost( + { + event: 'discord:test', + title: 'Codeman test notification', + body: 'If you can read this, the Discord bridge can post. Replies in session threads are typed into that tab.', + urgency: 'info', + host, + }, + { ...cfg, pingMode: 'off' } + ) + ); + return (this.last = { ok: true, at: this.now() }); + } catch (err) { + return (this.last = this.fail(err)); + } + } + + /** Session tab created: make its thread now when `threadCreation` is `onSessionStart`. */ + async sessionCreated(sessionId: string, name: string | undefined): Promise<void> { + await this.ensureLoaded(); + if (!this.cfg.enabled || this.cfg.threadCreation !== 'onSessionStart') return; + const made = await this.threadFor(sessionId, name); + if (typeof made !== 'string') this.last = made; + } + + async sessionRenamed(sessionId: string, name: string | undefined): Promise<void> { + const seen = this.names.get(sessionId); + this.names.set(sessionId, name ?? ''); + // First sight only records the name: the thread was made with it, so a PATCH would spend a rename for nothing. + if (seen === undefined || seen === (name ?? '')) return; + const thread = this.threads[sessionId]; + if (!thread || !this.cfg.enabled) return; + await this.api.rename(thread, threadNameFor(name, sessionId)).catch(() => undefined); + } + + /** + * Drop the per-tab state only, keeping the thread and its map entry. For a session that is discarded + * but can come back under the same id (a failed reboot-restore rebuild stays on offer for a retry). + * Per-tab state goes whether or not the tab ever had a thread, so these maps cannot grow for the life of the server. + */ + forgetTab(sessionId: string): void { + this.lastReply.delete(sessionId); + this.names.delete(sessionId); + this.lastFull.delete(sessionId); + } + + /** Session tab deleted: apply `onSessionDelete` and forget the mapping. */ + async sessionDeleted(sessionId: string): Promise<void> { + this.forgetTab(sessionId); + // A thread still being created is closed by its creation, which finds no entry to remove here. + if (this.pending.has(sessionId)) this.closedWhilePending.add(sessionId); + const thread = this.threads[sessionId]; + if (!thread) return; + delete this.threads[sessionId]; + await this.persistThreads(); + await this.closeThread(thread); + } + + /** Apply `onSessionDelete` to a thread. */ + private async closeThread(thread: string): Promise<void> { + if (!this.cfg.enabled) return; + if (this.cfg.onSessionDelete === 'delete') await this.api.remove(thread).catch(() => undefined); + else if (this.cfg.onSessionDelete === 'archive') await this.api.archive(thread).catch(() => undefined); + } + + private say(channelId: string, content: string): Promise<unknown> { + return this.api.post(channelId, { content, allowed_mentions: { parse: [], users: [] } }).catch(() => undefined); + } + + /** + * Save a reply's attachments under the session's working folder and return the paths. Only Discord + * CDN URLs, at most 5 files of 8 MB each, names reduced to safe characters. Anything skipped is + * named in `refused`, so the person is not left guessing why a file did not arrive. + */ + private async saveAttachments(sessionId: string, m: GatewayMessage): Promise<{ paths: string[]; refused?: string }> { + const list = m.attachments ?? []; + if (list.length === 0) return { paths: [] }; + const dir = this.deps.sessionDir?.(sessionId); + if (!dir) return { paths: [], refused: 'That tab has no local folder to save files in, so no file was saved.' }; + // The message id goes into a file name, so it must be a snowflake and nothing else. + if (!SNOWFLAKE.test(m.id)) return { paths: [], refused: 'That message could not be saved.' }; + const target = await ensureReceivedDir(dir); + if (!target) return { paths: [], refused: 'The tab folder is not safe to write to, so no file was saved.' }; + const paths: string[] = []; + const skipped: string[] = []; + for (const [i, a] of list.entries()) { + const name = safeAttachmentName(a.filename ?? 'file'); + if (i >= MAX_FILES_PER_REPLY || !isDiscordCdnUrl(a.url) || !(a.size <= MAX_FILE_BYTES)) { + skipped.push(name); + continue; + } + try { + const res = await this.deps.fetchImpl(new URL(a.url), { + redirect: 'manual', + signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), + }); + const buf = res.ok ? new Uint8Array(await res.arrayBuffer()) : null; + if (!buf || buf.byteLength > MAX_FILE_BYTES) { + skipped.push(name); + continue; + } + const dest = join(target, `${m.id}-${name}`); + // 'wx' (O_EXCL) also refuses a symlink planted at the name. + await fs.writeFile(dest, buf, { mode: 0o600, flag: 'wx' }); + paths.push(dest); + } catch { + skipped.push(name); + } + } + return { + paths, + refused: skipped.length + ? `Not saved (over 8 MB, more than ${MAX_FILES_PER_REPLY} files, or not downloadable): ${skipped.join(', ')}` + : undefined, + }; + } + + /** Inbound reply. One chain per thread, so a reply that waits on a file download cannot be overtaken by a later one. */ + async onGatewayMessage(m: GatewayMessage): Promise<void> { + const key = String(m.channel_id); + const run = (this.inbound.get(key) ?? Promise.resolve()).then(() => this.handleMessage(m)).catch(() => undefined); + this.inbound.set(key, run); + await run; + if (this.inbound.get(key) === run) this.inbound.delete(key); + } + + /** Every gate here drops silently: a stranger learns nothing. */ + private async handleMessage(m: GatewayMessage): Promise<void> { + if (!this.cfg.enabled || m.author?.bot) return; + if (m.guild_id !== this.cfg.guildId) return; + if (!this.cfg.allowedUserIds.includes(m.author?.id)) return; + const sessionId = Object.keys(this.threads).find((k) => this.threads[k] === m.channel_id); + if (!sessionId) return; + const t = this.now(); + const prev = this.lastReply.get(sessionId); + if (prev !== undefined && t - prev < REPLY_MIN_INTERVAL_MS) { + // Too soon after the last reply: say it was not typed, instead of dropping it silently. + await this.api.react(m.channel_id, m.id, '⏳').catch(() => undefined); + return; + } + if (this.deps.agentExited?.(sessionId)) { + await this.say(m.channel_id, 'The agent in that tab has exited, so nothing was typed.'); + return; + } + this.lastReply.set(sessionId, t); + const saved = await this.saveAttachments(sessionId, m); + const text = sanitiseReply(m.content ?? '', saved.paths); + if (!text) { + if (saved.refused) await this.say(m.channel_id, saved.refused); + return; + } + if (saved.refused) await this.say(m.channel_id, saved.refused); + // A lone digit answers a dialog by itself: an Enter after it would land on whatever comes next. + const keys = /^\d\r$/.test(text) && this.deps.awaitingChoice?.(sessionId) ? text.slice(0, -1) : text; + const ok = await this.deps.sendInput(sessionId, keys).catch(() => false); + const reply = ok + ? this.api.react(m.channel_id, m.id, '✅') + : this.api.post(m.channel_id, { + content: 'That tab is gone, so nothing was typed.', + allowed_mentions: { parse: [], users: [] }, + }); + await reply.catch(() => undefined); + } +} diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index fbafb460..f2ce722d 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -130,6 +130,32 @@ export async function readDockerCases(configDir: string): Promise<DockerCase[]> return readJsonArray<DockerCase>(dockerCasesPath(configDir)); } +/** + * The Docker cases for a caller that must fail CLOSED (the git fetch paths): a missing file is no cases, + * but a file that cannot be read, does not parse or is not an array rejects, where {@link readDockerCases} + * reads all of those as `[]`. The writer truncates and rewrites the file in place, so a read can land on an + * empty or half-written file, and to a caller deciding where git may run that must never look like "no + * Docker workspaces". The parser's own message is not repeated (V8 quotes the text around the fault). + */ +export async function readDockerCasesOrThrow(configDir: string): Promise<DockerCase[]> { + const path = dockerCasesPath(configDir); + let raw: string; + try { + raw = await fs.readFile(path, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw err; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + parsed = undefined; + } + if (!Array.isArray(parsed)) throw new Error(`${path} is not a valid JSON array`); + return parsed as DockerCase[]; +} + export async function writeDockerCases(configDir: string, cases: DockerCase[]): Promise<void> { await writeJsonArray(configDir, dockerCasesPath(configDir), cases); } diff --git a/src/edit-history-local-source.ts b/src/edit-history-local-source.ts new file mode 100644 index 00000000..f5c93ea0 --- /dev/null +++ b/src/edit-history-local-source.ts @@ -0,0 +1,242 @@ +/** + * @fileoverview Reads a local claude session's transcripts for its edit history: the main + * transcript and its subagent transcripts, each from a byte offset. + * + * WHICH FILE (`resolveClaudeTranscript`). The transcript watcher's path, which the session's + * hooks report, so a session is read from wherever its claude really writes. Before the first + * hook after a server restart the watcher has none, and `findClaudeTranscript` looks for + * `<id>.jsonl` under the session's own `CLAUDE_CONFIG_DIR` override, else the server's + * (`claudeProjectsDir()`, as the relaunch pin does). A config dir set outside Codeman is not + * guessed; it is found with the next hook. Subagents live in + * `<main path without .jsonl>/subagents/agent-<id>.jsonl`. + * + * HOW MUCH. A requested file is read from its `offset` (clamped to its size), an unrequested + * (new) one from `budget` bytes before its end, and a new subagent's `.meta.json` (≤ 8 KB) + * comes along. `budgetBytes` is shared by all files, main first; once spent, files still + * report their size with an empty chunk. A request with `len` is a block that must end at + * `offset + len` (an "older" block ends at the floor): short of budget, its end is read. + * With `onlyRequested` ("older" reads) unrequested files are left out. + * + * Tests: `test/edit-history-local-source.test.ts`. + * + * @module edit-history-local-source + */ + +import { open, readdir, realpath, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { basename, isAbsolute, join } from 'node:path'; +import { probePathKind, type PathProbeKind } from './utils/bounded-path-probe.js'; +import { claudeProjectsDir } from './utils/claude-transcript.js'; + +const ID_PATTERN = /^[A-Za-z0-9._-]+$/; +const REL_PATH_PATTERN = /^(main|subagents\/agent-[A-Za-z0-9_-]+\.jsonl)$/; +const AGENT_FILE_PATTERN = /^agent-([A-Za-z0-9_-]+)\.jsonl$/; +const META_MAX_BYTES = 8192; + +/** One file to read from where the last read stopped (`len` only for an "older" block). */ +export interface EditSourceRequest { + /** `main` or `subagents/agent-<id>.jsonl`. */ + relPath: string; + offset: number; + len?: number; +} + +export interface EditSourceFile { + relPath: string; + /** The file's size when it was read. */ + size: number; + startOffset: number; + chunk: Buffer; +} + +export interface EditSources { + /** The conversation id: the main transcript's file name without `.jsonl`. */ + transcriptId: string; + files: EditSourceFile[]; + /** `agent-<id>.meta.json` text of each subagent read for the first time, by agent id. */ + metas: Record<string, string>; +} + +export interface EditSourceReadOptions { + /** Read only the requested files; the others are not listed. */ + onlyRequested?: boolean; +} + +/** What a path turned out to be (for Bash image candidates). */ +export interface PathProbe { + /** The path with symlinks resolved. */ + realPath: string; + kind: 'file' | 'directory' | 'other'; + /** Size in bytes (0 for anything that is not a regular file). */ + size: number; + mtimeMs: number; +} + +async function readRange(path: string, offset: number, len: number): Promise<Buffer> { + if (len <= 0) return Buffer.alloc(0); + const fh = await open(path, 'r'); + try { + const buf = Buffer.alloc(len); + const { bytesRead } = await fh.read(buf, 0, len, offset); + return buf.subarray(0, bytesRead); + } catch { + return Buffer.alloc(0); + } finally { + await fh.close(); + } +} + +async function fileSize(path: string): Promise<number | null> { + try { + const s = await stat(path); + return s.isFile() ? s.size : null; + } catch { + return null; + } +} + +/** The newest `<id>.jsonl` under any project dir, for the first id that has one; null when none does. */ +export async function findClaudeTranscript( + ids: readonly string[], + projectsDir: string = claudeProjectsDir() +): Promise<string | null> { + let dirs: string[]; + try { + dirs = await readdir(projectsDir); + } catch { + return null; + } + for (const id of new Set(ids)) { + if (!ID_PATTERN.test(id)) continue; + let best: { path: string; mtime: number } | null = null; + for (const dir of dirs) { + const path = join(projectsDir, dir, `${id}.jsonl`); + try { + const s = await stat(path); + if (s.isFile() && (!best || s.mtimeMs > best.mtime)) best = { path, mtime: s.mtimeMs }; + } catch { + // not in this project dir + } + } + if (best) return best.path; + } + return null; +} + +/** + * The session's main transcript: the transcript watcher's (hook-reported) path when there is + * one, else the newest `<id>.jsonl` under `configDir` (the session's `CLAUDE_CONFIG_DIR` + * override; the server's config dir when unset). Null when neither finds one. + */ +export async function resolveClaudeTranscript( + watcherPath: string | null | undefined, + ids: readonly string[], + configDir?: string +): Promise<string | null> { + return watcherPath || findClaudeTranscript(ids, claudeProjectsDir(configDir)); +} + +/** + * Read the main transcript at `mainPath` and its subagent transcripts. Null when `mainPath` + * is not an absolute `<id>.jsonl` path or the file does not exist. + */ +export async function readClaudeEditSources( + mainPath: string, + requests: readonly EditSourceRequest[], + budgetBytes: number, + opts: EditSourceReadOptions = {} +): Promise<EditSources | null> { + if (!isAbsolute(mainPath) || !mainPath.endsWith('.jsonl')) return null; + const transcriptId = basename(mainPath, '.jsonl'); + if (!ID_PATTERN.test(transcriptId)) return null; + + const byPath = new Map<string, EditSourceRequest>(); + for (const r of requests) { + if (!REL_PATH_PATTERN.test(r.relPath) || byPath.has(r.relPath)) continue; + if (!Number.isSafeInteger(r.offset) || r.offset < 0) continue; + if (r.len !== undefined && (!Number.isSafeInteger(r.len) || r.len < 0)) continue; + byPath.set(r.relPath, r); + } + + const subDir = join(mainPath.slice(0, -'.jsonl'.length), 'subagents'); + const sources: Array<{ relPath: string; path: string; agentId?: string }> = [{ relPath: 'main', path: mainPath }]; + try { + for (const name of (await readdir(subDir)).sort()) { + const m = AGENT_FILE_PATTERN.exec(name); + if (m) sources.push({ relPath: `subagents/${name}`, path: join(subDir, name), agentId: m[1] }); + } + } catch { + // no subagents yet + } + + let remaining = Math.max(0, Math.floor(budgetBytes)); + const files: EditSourceFile[] = []; + const metas: Record<string, string> = {}; + for (const src of sources) { + const size = await fileSize(src.path); + if (size === null) { + if (src.relPath === 'main') return null; + continue; + } + const req = byPath.get(src.relPath); + if (!req && opts.onlyRequested) continue; + let start: number; + let len: number; + if (req?.len !== undefined) { + const blockStart = Math.min(req.offset, size); + const blockEnd = Math.min(req.offset + req.len, size); + len = Math.min(blockEnd - blockStart, remaining); + start = blockEnd - len; + } else if (req) { + start = Math.min(req.offset, size); + len = Math.min(remaining, size - start); + } else { + len = Math.min(size, remaining); + start = size - len; + } + const chunk = await readRange(src.path, start, len); + remaining -= chunk.length; + files.push({ relPath: src.relPath, size, startOffset: start, chunk }); + if (!req && src.agentId) { + const metaPath = join(subDir, `agent-${src.agentId}.meta.json`); + const metaSize = await fileSize(metaPath); + if (metaSize !== null) { + metas[src.agentId] = (await readRange(metaPath, 0, Math.min(metaSize, META_MAX_BYTES))).toString('utf8'); + } + } + } + return { transcriptId, files, metas }; +} + +/** + * Per path its resolved path, kind, size and mtime, or null when it is not a regular file + * (missing, a directory, or on a filesystem that did not answer). `~/` means this user's home. + * + * The paths come from Bash output, so the agent chose them: each one first goes through the + * bounded probe (`probePathKind`, no `pastCap`, this is a bulk scan), and only a path it saw + * as a file is touched by `realpath` + `stat`. A path on a mount that stopped answering + * then costs one stalled stat at most, never a sync that cannot finish (#516 rule). + * Sequential, so one sync holds at most one threadpool worker. + */ +export async function probeLocalPaths( + paths: readonly string[], + home: string = homedir(), + probeKind: (path: string) => Promise<PathProbeKind> = probePathKind +): Promise<Array<PathProbe | null>> { + const out: Array<PathProbe | null> = []; + for (const p of paths) { + const path = p.startsWith('~/') ? join(home, p.slice(2)) : p; + if (!isAbsolute(path) || (await probeKind(path)) !== 'file') { + out.push(null); + continue; + } + try { + const real = await realpath(path); + const s = await stat(real); + out.push(s.isFile() ? { realPath: real, kind: 'file', size: s.size, mtimeMs: Math.floor(s.mtimeMs) } : null); + } catch { + out.push(null); + } + } + return out; +} diff --git a/src/edit-history-parse.ts b/src/edit-history-parse.ts new file mode 100644 index 00000000..409f7dd1 --- /dev/null +++ b/src/edit-history-parse.ts @@ -0,0 +1,352 @@ +/** + * @fileoverview Edit history parser: claude JSONL transcript lines → the files the agent + * edited (with their diff) and the images its Bash calls may have produced. + * + * Every `Edit`/`Write` result line carries `toolUseResult.filePath` and a + * `structuredPatch` (hunks with ` `/`+`/`-` prefixed lines), so a per-edit diff needs no + * git. The whole-file fields next to it (`originalFile`, `content`) are never kept. + * + * Images rarely come from `Write`; they come from scripts run through Bash. A Bash call + * yields a CANDIDATE: the image-looking paths in its command and output, with the call's + * start and end time. Whether a candidate file was actually produced by the call (its + * mtime falls inside that window) is decided later by whoever can stat it, locally or on + * the remote host: this module does no IO. + * + * Chunked input: a transcript is read in byte ranges, so a Bash `tool_use` and its result + * can land in different chunks. Unanswered calls are handed back as `pending` and passed + * into the next call. + * + * Tests: `test/edit-history-parse.test.ts`. + * + * @module edit-history-parse + */ + +import { posix } from 'node:path'; + +/** Per-record budget for kept hunk lines; a larger patch is cut and marked `truncated`. */ +export const MAX_HUNK_BYTES = 64 * 1024; +/** Image paths kept per Bash call. */ +export const MAX_IMAGE_CANDIDATES = 20; +/** Unanswered Bash calls carried between chunks (an interrupted call never gets a result). */ +export const MAX_PENDING_BASH = 200; +/** + * Command text kept per unanswered Bash call. Pending calls are persisted with the store, so + * this bounds it; an image path past this point of a command is still found in the output. + */ +export const MAX_PENDING_COMMAND_CHARS = 8192; +/** Longest id, cwd or agent id of an unanswered Bash call; a call with a longer one is not kept. */ +export const MAX_PENDING_FIELD_CHARS = 4096; + +export type EditKind = 'code' | 'image'; +/** `edit` = a result with a path and a patch from a tool other than Edit/Write. */ +export type EditTool = 'Edit' | 'Write' | 'edit'; + +export interface EditHunk { + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + lines: string[]; +} + +export interface EditRecord { + /** The `tool_use_id` of the edit: unique per edit, used to deduplicate. */ + id: string; + /** ms since epoch, from the result line's `timestamp` (0 when missing). */ + ts: number; + path: string; + kind: EditKind; + tool: EditTool; + /** A `Write` that created the file. */ + created: boolean; + /** Counted over the whole patch, also when `hunks` was cut. */ + added: number; + removed: number; + hunks: EditHunk[]; + truncated: boolean; + /** Set for lines of a subagent transcript. */ + agentId?: string; +} + +export interface PendingBash { + id: string; + ts: number; + command: string; + cwd: string; + agentId?: string; +} + +export interface ImageCandidate { + toolUseId: string; + start: number; + end: number; + paths: string[]; + agentId?: string; + /** + * Files claude handed to the user (`SendUserFile`): shown on purpose, so no mtime window + * applies; the file must still exist, and a version already listed is not listed again. + */ + sent?: boolean; +} + +export interface EditParseResult { + records: EditRecord[]; + candidates: ImageCandidate[]; + pending: PendingBash[]; +} + +const IMAGE_EXT = '(?:png|jpe?g|gif|webp|avif|svg)'; +const IMAGE_PATH_RE = new RegExp(`\\.${IMAGE_EXT}$`, 'i'); +// After the extension: no further name character, and no `.ext` (a.png.bak). +const EXT_END = '(?![\\w-])(?!\\.\\w)'; +// Characters that end an unquoted path token. +const TOKEN_CHAR = '[^\\s\'"`<>|;&()=,:*?]'; +const PATH_TOKEN_RE = new RegExp( + [ + // 1. file:// URL (Codex: `Saved to: file:///…`), which may contain spaces up to the extension. + // The lazy scan stops at the next `file://`: otherwise it runs to the line end from + // every `file://` of a line that repeats it (quadratic). `:` stays allowed, so a + // timestamped name (`plot-12:30.png`) is still found. + `file://(/(?:(?!file://)[^\\n])*?\\.${IMAGE_EXT})${EXT_END}`, + // 2./3. Quoted paths, spaces allowed. + `"([^"\\n]*\\.${IMAGE_EXT})"`, + `'([^'\\n]*\\.${IMAGE_EXT})'`, + // 4. Unquoted token. A match starts only at a token boundary (line start or a character + // that ends a token), never inside a URL (`:` is not a boundary), a glob or a longer + // name. Anchoring at boundaries keeps this linear: a lookbehind that only excluded + // some characters let a match start at every other character of a long token + // (`####…`, a base64 blob) and rescan the token from each, quadratic in the line + // length. The name before the extension must not be empty. + `(?<=^|[\\s'"\`<>|;&()=,*?])((?:${TOKEN_CHAR}*/)?[^\\s'"\`<>|;&()=,:*?/]+\\.${IMAGE_EXT})${EXT_END}`, + ].join('|'), + 'gi' +); + +/** `image` for the image extensions the panel can thumbnail, else `code`. */ +export function classifyEditPath(path: string): EditKind { + return IMAGE_PATH_RE.test(path) ? 'image' : 'code'; +} + +/** + * Image-looking paths in a Bash call's command and output, in order of appearance, + * deduplicated and capped at {@link MAX_IMAGE_CANDIDATES}. Absolute paths are normalised, + * `~/` paths stay literal (the host that stats them expands `~`), relative ones are + * resolved against `cwd` and dropped when `cwd` is not absolute. URLs and globs are + * skipped. + */ +export function extractImagePathCandidates(command: string, stdout: string, stderr: string, cwd: string): string[] { + const text = [command, stdout, stderr].join('\n'); + const out: string[] = []; + const seen = new Set<string>(); + for (const m of text.matchAll(PATH_TOKEN_RE)) { + const raw = m[1] ?? m[2] ?? m[3] ?? m[4]; + if (!raw || raw.includes('://') || /[*?]/.test(raw)) continue; + let resolved: string; + if (raw.startsWith('/')) resolved = posix.normalize(raw); + else if (raw.startsWith('~/')) resolved = raw; + else if (cwd.startsWith('/')) resolved = posix.resolve(cwd, raw); + else continue; + if (seen.has(resolved)) continue; + seen.add(resolved); + out.push(resolved); + if (out.length >= MAX_IMAGE_CANDIDATES) break; + } + return out; +} + +function isRecord(v: unknown): v is Record<string, unknown> { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/** + * The hunks of `patch` with their string lines, cut once the lines reach {@link MAX_HUNK_BYTES} + * (`truncated`). Also applied to hunks loaded from a store file. + */ +export function capHunks(patch: readonly unknown[]): { hunks: EditHunk[]; truncated: boolean } { + const hunks: EditHunk[] = []; + let budget = MAX_HUNK_BYTES; + let truncated = false; + for (const h of patch) { + if (!isRecord(h) || !Array.isArray(h.lines)) continue; + if (truncated) break; + const kept: string[] = []; + for (const l of h.lines) { + if (typeof l !== 'string') continue; + const cost = Buffer.byteLength(l) + 3; + if (cost > budget) { + truncated = true; + break; + } + budget -= cost; + kept.push(l); + } + hunks.push({ + oldStart: Number(h.oldStart) || 0, + oldLines: Number(h.oldLines) || 0, + newStart: Number(h.newStart) || 0, + newLines: Number(h.newLines) || 0, + lines: kept, + }); + } + return { hunks, truncated }; +} + +function toHunks(patch: unknown[]): { hunks: EditHunk[]; added: number; removed: number; truncated: boolean } { + let added = 0; + let removed = 0; + for (const h of patch) { + if (!isRecord(h) || !Array.isArray(h.lines)) continue; + for (const l of h.lines) { + if (typeof l !== 'string') continue; + if (l.startsWith('+')) added++; + else if (l.startsWith('-')) removed++; + } + } + return { ...capHunks(patch), added, removed }; +} + +/** A new file's content as one all-added hunk, in `structuredPatch` form. */ +function createdFileHunk(content: string): Record<string, unknown> { + const lines = content.split('\n'); + if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); + return { oldStart: 0, oldLines: 0, newStart: 1, newLines: lines.length, lines: lines.map((l) => `+${l}`) }; +} + +function toEditRecord(id: string, ts: number, result: unknown, agentId: string | undefined): EditRecord | null { + if (!isRecord(result)) return null; + const { filePath, structuredPatch } = result; + if (typeof filePath !== 'string' || !filePath || !Array.isArray(structuredPatch)) return null; + const tool: EditTool = typeof result.type === 'string' ? 'Write' : 'oldString' in result ? 'Edit' : 'edit'; + const kind = classifyEditPath(filePath); + // A Write that created a file reports an empty patch; its diff is the whole content. + const patch = + result.type === 'create' && structuredPatch.length === 0 && kind !== 'image' && typeof result.content === 'string' + ? [createdFileHunk(result.content)] + : structuredPatch; + const { hunks, added, removed, truncated } = toHunks(patch); + const record: EditRecord = { + id, + ts, + path: filePath, + kind, + tool, + created: result.type === 'create', + added, + removed, + hunks, + truncated, + }; + if (agentId) record.agentId = agentId; + return record; +} + +/** + * Image paths of a `SendUserFile` result (`{ caption, attachments: [{ path }] }`): the + * files claude delivered to the user, which is how a session shows generated images. + */ +function sentImagePaths(result: unknown): string[] { + if (!isRecord(result) || !Array.isArray(result.attachments)) return []; + const out: string[] = []; + for (const a of result.attachments) { + if (!isRecord(a) || typeof a.path !== 'string' || !a.path.startsWith('/')) continue; + if (IMAGE_PATH_RE.test(a.path) && !out.includes(a.path)) out.push(a.path); + } + return out; +} + +/** The text of a `tool_result` block's content (a string or a list of text blocks). */ +function blockText(content: unknown): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content + .map((b) => (isRecord(b) && typeof b.text === 'string' ? b.text : '')) + .filter(Boolean) + .join('\n'); +} + +/** + * Parse transcript lines into edit records and Bash image candidates. + * + * @param text complete JSONL lines; a first line cut mid-record (a byte range starting + * inside it) and anything else that does not parse is skipped. + * @param pending unanswered Bash calls from the previous chunk. + */ +export function parseClaudeEditLines(text: string, pending: readonly PendingBash[] = []): EditParseResult { + const open = new Map<string, PendingBash>(pending.map((p) => [p.id, p])); + const records: EditRecord[] = []; + const candidates: ImageCandidate[] = []; + + for (const rawLine of text.split('\n')) { + const line = rawLine.trim(); + if (!line) continue; + let rec: unknown; + try { + rec = JSON.parse(line); + } catch { + continue; + } + if (!isRecord(rec)) continue; + const message = rec.message; + const content = isRecord(message) ? message.content : undefined; + if (!Array.isArray(content)) continue; + const parsedTs = typeof rec.timestamp === 'string' ? Date.parse(rec.timestamp) : NaN; + const ts = Number.isFinite(parsedTs) ? parsedTs : 0; + const agentId = typeof rec.agentId === 'string' && rec.agentId ? rec.agentId : undefined; + const cwd = typeof rec.cwd === 'string' ? rec.cwd : ''; + + if (rec.type === 'assistant') { + for (const block of content) { + if (!isRecord(block) || block.type !== 'tool_use' || block.name !== 'Bash') continue; + if (typeof block.id !== 'string' || !isRecord(block.input)) continue; + const command = block.input.command; + if (typeof command !== 'string') continue; + if ([block.id, cwd, agentId ?? ''].some((f) => f.length > MAX_PENDING_FIELD_CHARS)) continue; + const entry: PendingBash = { id: block.id, ts, command: command.slice(0, MAX_PENDING_COMMAND_CHARS), cwd }; + if (agentId) entry.agentId = agentId; + open.delete(block.id); + open.set(block.id, entry); + while (open.size > MAX_PENDING_BASH) open.delete(open.keys().next().value as string); + } + continue; + } + + if (rec.type !== 'user') continue; + const results = content.filter( + (b): b is Record<string, unknown> => isRecord(b) && b.type === 'tool_result' && typeof b.tool_use_id === 'string' + ); + // `toolUseResult` describes the line's result; it is unambiguous only for one block. + const single = results.length === 1; + for (const block of results) { + const id = block.tool_use_id as string; + if (single) { + const edit = toEditRecord(id, ts, rec.toolUseResult, agentId); + if (edit) records.push(edit); + } + const sent = single ? sentImagePaths(rec.toolUseResult) : []; + if (sent.length > 0) { + const candidate: ImageCandidate = { toolUseId: id, start: ts, end: ts, paths: sent, sent: true }; + if (agentId) candidate.agentId = agentId; + candidates.push(candidate); + } + const call = open.get(id); + if (!call) continue; + open.delete(id); + const r = single ? rec.toolUseResult : undefined; + let stdout: string; + let stderr = ''; + if (isRecord(r) && (typeof r.stdout === 'string' || typeof r.stderr === 'string')) { + stdout = typeof r.stdout === 'string' ? r.stdout : ''; + stderr = typeof r.stderr === 'string' ? r.stderr : ''; + } else { + stdout = blockText(block.content); + } + const paths = extractImagePathCandidates(call.command, stdout, stderr, call.cwd); + if (paths.length === 0) continue; + const candidate: ImageCandidate = { toolUseId: id, start: call.ts, end: ts, paths }; + if (call.agentId) candidate.agentId = call.agentId; + candidates.push(candidate); + } + } + + return { records, candidates, pending: [...open.values()] }; +} diff --git a/src/edit-history-store.ts b/src/edit-history-store.ts new file mode 100644 index 00000000..de2c303a --- /dev/null +++ b/src/edit-history-store.ts @@ -0,0 +1,678 @@ +/** + * @fileoverview Per-session edit history: the parsed edits and Bash images of one claude + * session, plus where in each transcript file reading stopped. + * + * Sources are the main transcript (`main`) and each subagent transcript + * (`subagents/agent-<id>.jsonl`). Each has a cursor: + * - `offset`: the next byte to read forward; always a line boundary. + * - `floor`: the lowest byte already parsed; > 0 while older lines are unread + * ("ältere laden" reads `[floor − budget, floor)`, see `olderRange`). + * The reader (`edit-history-local-source.ts`) reads forward from each `offset` of the + * current conversation, a file without one from `budget` bytes before its end, and hands the + * bytes to `apply`. Cuts happen only at `\n`, which never occurs inside a UTF-8 sequence, so + * multibyte characters split by a chunk boundary are never decoded half. + * + * A first read of a file starts `budget` bytes before its end (transcripts reach 100 MB+), + * drops the cut first line and leaves `floor` there. A line longer than the budget is + * skipped instead of blocking the cursor, reading forward (`midLine`) and backward + * (`floorMidLine`); the line after it (forward) or before it (backward) is read whole. + * That needs every chunk to be a full budget unless it reaches the file's end (forward) or + * start (older): a chunk with no line end then means one line longer than the budget. The + * caller does not hand over a chunk that a budget shared by several files cut short. + * + * A new conversation (`/clear`, resume) arrives as a new `transcriptId`: cursors and + * pending Bash calls start over, entries stay. Entries are capped at {@link MAX_ENTRIES} + * and {@link MAX_STORE_BYTES} of diff text; the oldest by time are dropped. Besides them + * the file holds one cursor per transcript file of the current conversation, at most + * {@link MAX_PENDING_BASH} unanswered Bash calls and at most {@link MAX_AGENTS} subagent labels. + * + * ⚠️ The file holds secrets: a diff carries whatever the agent wrote, keys and `.env` + * contents included. So, like `intents.json`, it is written 0600 via tmp+rename, bounded, + * deleted with the session (`remove()`; `sweep()` at startup removes files no session owns), + * never fed to `/api/search`, and refused by the attachment, folder and download routes through + * `isSensitivePath` (`web/sensitive-path.ts`). `load()` takes the file as untrusted: past + * {@link MAX_FILE_BYTES} it is not parsed, and every part is rebuilt under the same bounds. + * + * Persisted as JSON under `dataPath('edit-history', '<sessionId>.json')`, written debounced. + * + * Tests: `test/edit-history-store.test.ts`. + * + * @module edit-history-store + */ + +import { randomBytes } from 'node:crypto'; +import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { dataPath } from './config/instance.js'; +import { + capHunks, + MAX_HUNK_BYTES, + MAX_PENDING_BASH, + MAX_PENDING_COMMAND_CHARS, + MAX_PENDING_FIELD_CHARS, + parseClaudeEditLines, + type EditRecord, + type EditTool, + type ImageCandidate, + type PendingBash, +} from './edit-history-parse.js'; + +export const MAX_ENTRIES = 500; +/** Diff text (and paths) kept per session; see {@link entryBytes}. */ +export const MAX_STORE_BYTES = 4 * 1024 * 1024; +/** Longest string kept from a subagent's `meta.json` field. */ +export const MAX_AGENT_META_CHARS = 1024; +/** + * Subagent labels kept; past it the oldest one no entry uses is dropped. One more than + * {@link MAX_ENTRIES}, so besides the newest label there always is one. + */ +export const MAX_AGENTS = MAX_ENTRIES + 1; +/** Bytes read per file and pass (first read, forward step, "older" step). */ +export const READ_BUDGET_BYTES = 8 * 1024 * 1024; +/** + * A store file larger than this is not parsed (starts empty). One written within the caps + * above stays far below it; JSON escaping at most doubles ordinary text. + */ +export const MAX_FILE_BYTES = 32 * 1024 * 1024; +export const MAIN_SOURCE = 'main'; +const SAVE_DEBOUNCE_MS = 1000; +const FILE_VERSION = 1; +const NL = 0x0a; +/** A transcript id as the reader takes it (`<id>.jsonl`): no path separators. */ +const TRANSCRIPT_ID = /^[A-Za-z0-9._-]+$/; +/** + * Names this process's tmp files (`<id>.json.<tag>.tmp`); any other tmp file is left over from + * an earlier run (`sweep`), even one whose pid this process has again (a container's pid 1). + */ +export const TMP_TAG = `${process.pid}-${randomBytes(4).toString('hex')}`; + +/** `bash`: an image a Bash call wrote; `sent`: an image claude delivered to the user. */ +export type EditHistoryTool = EditTool | 'bash' | 'sent'; + +export interface EditHistoryEntry extends Omit<EditRecord, 'tool'> { + tool: EditHistoryTool; +} + +export interface SourceCursor { + offset: number; + floor: number; + /** The next forward read starts inside a line longer than the budget; skip to its end. */ + midLine?: boolean; + /** `floor` lies inside a line longer than the budget; the next older read drops its tail. */ + floorMidLine?: boolean; +} + +export interface AgentMeta { + description: string; + agentType: string; + worktreePath?: string; + worktreeBranch?: string; +} + +export interface ByteRange { + offset: number; + len: number; +} + +export interface ApplyInput { + transcriptId: string; + /** `main` or `subagents/agent-<id>.jsonl`. */ + source: string; + startOffset: number; + chunk: Buffer; + /** Size of the file when the chunk was read. */ + size: number; +} + +export interface ApplyResult { + added: number; + candidates: ImageCandidate[]; +} + +export interface ConfirmedImage { + toolUseId: string; + path: string; + ts: number; + agentId?: string; + tool?: 'bash' | 'sent'; +} + +export interface EditHistoryStoreOptions { + /** Directory holding `<sessionId>.json`; default `dataPath('edit-history')`. */ + dir?: string; + debounceMs?: number; +} + +interface StoreFile { + version: number; + sessionId: string; + transcriptId: string | null; + cursors: Record<string, SourceCursor>; + pendingBash: PendingBash[]; + agents: Record<string, AgentMeta>; + entries: EditHistoryEntry[]; +} + +function isObj(v: unknown): v is Record<string, unknown> { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/** What an entry costs against {@link MAX_STORE_BYTES}: its strings and diff lines, plus a fixed overhead. */ +export function entryBytes(e: EditHistoryEntry): number { + let n = 128 + Buffer.byteLength(e.id) + Buffer.byteLength(e.path) + Buffer.byteLength(e.agentId ?? ''); + for (const h of e.hunks) for (const l of h.lines) n += Buffer.byteLength(l) + 3; + return n; +} + +function metaString(v: unknown): string | undefined { + return typeof v === 'string' && v ? v.slice(0, MAX_AGENT_META_CHARS) : undefined; +} + +/** Parse an `agent-<id>.meta.json` text; null when it is not an object. */ +export function parseAgentMeta(text: string): AgentMeta | null { + try { + return agentMetaFrom(JSON.parse(text)); + } catch { + return null; + } +} + +function isOffset(v: unknown): v is number { + return Number.isSafeInteger(v) && (v as number) >= 0; +} + +// The loaders below take the persisted file as untrusted: a hand-edited or damaged store +// loses the broken parts instead of failing every request or stalling a cursor. + +function agentMetaFrom(raw: unknown): AgentMeta | null { + if (!isObj(raw)) return null; + const meta: AgentMeta = { + description: metaString(raw.description) ?? '', + agentType: metaString(raw.agentType) ?? '', + }; + const worktreePath = metaString(raw.worktreePath); + const worktreeBranch = metaString(raw.worktreeBranch); + if (worktreePath) meta.worktreePath = worktreePath; + if (worktreeBranch) meta.worktreeBranch = worktreeBranch; + return meta; +} + +function cursorFrom(raw: unknown): SourceCursor | null { + if (!isObj(raw) || !isOffset(raw.offset) || !isOffset(raw.floor) || raw.floor > raw.offset) return null; + const cursor: SourceCursor = { offset: raw.offset, floor: raw.floor }; + if (raw.midLine === true) cursor.midLine = true; + if (raw.floorMidLine === true) cursor.floorMidLine = true; + return cursor; +} + +const loadedField = (v: unknown): v is string => typeof v === 'string' && v.length <= MAX_PENDING_FIELD_CHARS; + +function pendingBashFrom(raw: unknown): PendingBash | null { + if (!isObj(raw)) return null; + const { id, ts, command, cwd, agentId } = raw; + if (!loadedField(id) || !Number.isFinite(ts) || typeof command !== 'string' || !loadedField(cwd)) return null; + if (agentId !== undefined && !loadedField(agentId)) return null; + const pending: PendingBash = { id, ts: ts as number, command: command.slice(0, MAX_PENDING_COMMAND_CHARS), cwd }; + if (agentId) pending.agentId = agentId; + return pending; +} + +const ENTRY_KINDS = new Set(['code', 'image']); +const ENTRY_TOOLS = new Set(['Edit', 'Write', 'edit', 'bash', 'sent']); +const countFrom = (v: unknown): number => (Number.isSafeInteger(v) && (v as number) >= 0 ? (v as number) : 0); + +/** + * A loaded entry rebuilt from its known fields: unknown fields and non-string diff lines are + * dropped and the per-edit {@link MAX_HUNK_BYTES} is applied again; null when it is unusable. + */ +function entryFrom(raw: unknown): EditHistoryEntry | null { + if (!isObj(raw) || typeof raw.id !== 'string' || !raw.id || typeof raw.path !== 'string' || !raw.path) return null; + if (!Number.isFinite(raw.ts) || !ENTRY_KINDS.has(raw.kind as string) || !ENTRY_TOOLS.has(raw.tool as string)) { + return null; + } + if (!Array.isArray(raw.hunks)) return null; + const { hunks, truncated } = capHunks(raw.hunks); + const entry: EditHistoryEntry = { + id: raw.id, + ts: raw.ts as number, + path: raw.path, + kind: raw.kind as EditHistoryEntry['kind'], + tool: raw.tool as EditHistoryTool, + created: raw.created === true, + added: countFrom(raw.added), + removed: countFrom(raw.removed), + hunks, + truncated: truncated || raw.truncated === true, + }; + if (typeof raw.agentId === 'string' && raw.agentId) entry.agentId = raw.agentId; + return entry; +} + +export class EditHistoryStore { + readonly sessionId: string; + private readonly file: string; + private readonly debounceMs: number; + private transcriptId: string | null = null; + private cursors = new Map<string, SourceCursor>(); + private pendingBash: PendingBash[] = []; + private agents = new Map<string, AgentMeta>(); + private entries = new Map<string, EditHistoryEntry>(); + /** Sum of {@link entryBytes} over `entries`. */ + private bytes = 0; + private saveTimer: NodeJS.Timeout | null = null; + /** The last queued write; `remove()` waits for it. */ + private writing: Promise<void> | null = null; + private removed = false; + + constructor(sessionId: string, opts: EditHistoryStoreOptions = {}) { + this.sessionId = sessionId; + this.file = join(opts.dir ?? dataPath('edit-history'), `${sessionId}.json`); + this.debounceMs = opts.debounceMs ?? SAVE_DEBOUNCE_MS; + } + + /** + * A store with the persisted state of `sessionId`; empty when the file is missing, + * unreadable or larger than {@link MAX_FILE_BYTES}. + */ + static async load(sessionId: string, opts: EditHistoryStoreOptions = {}): Promise<EditHistoryStore> { + const store = new EditHistoryStore(sessionId, opts); + let data: unknown; + try { + if ((await stat(store.file)).size > MAX_FILE_BYTES) return store; + data = JSON.parse(await readFile(store.file, 'utf-8')); + } catch { + return store; + } + if (!isObj(data)) return store; + if (data.version !== FILE_VERSION) return store; + const f = data as unknown as StoreFile; + // The sync builds the previous transcript's path from it (`/clear`), so only a plain id counts. + store.transcriptId = + typeof f.transcriptId === 'string' && TRANSCRIPT_ID.test(f.transcriptId) ? f.transcriptId : null; + // Cursors are offsets into that conversation's files; without it they mean nothing. + const cursors = store.transcriptId !== null && isObj(f.cursors) ? f.cursors : {}; + for (const [k, raw] of Object.entries(cursors)) { + const cursor = cursorFrom(raw); + if (cursor) store.cursors.set(k, cursor); + } + if (Array.isArray(f.pendingBash)) { + store.pendingBash = f.pendingBash + .map(pendingBashFrom) + .filter((p): p is PendingBash => p !== null) + .slice(-MAX_PENDING_BASH); + } + if (Array.isArray(f.entries)) { + store.addRecords(f.entries.map(entryFrom).filter((e): e is EditHistoryEntry => e !== null)); + } + for (const [k, raw] of Object.entries(isObj(f.agents) ? f.agents : {})) { + const meta = k.length <= MAX_AGENT_META_CHARS ? agentMetaFrom(raw) : null; + if (meta) store.putAgent(k, meta); + } + return store; + } + + /** + * Delete the stored history of every session `keep` rejects, and tmp files other processes + * left behind (startup: a crash between removing a session's record and deleting its file, + * or mid-write; an older build). + * Returns how many sessions' files were deleted. + */ + static async sweep( + keep: (sessionId: string) => boolean, + opts: Pick<EditHistoryStoreOptions, 'dir'> = {} + ): Promise<number> { + const dir = opts.dir ?? dataPath('edit-history'); + let names: string[]; + try { + names = await readdir(dir); + } catch { + return 0; + } + let removed = 0; + for (const name of names) { + const tmp = /\.json\.[\w-]+\.tmp$/.test(name); + const stale = tmp + ? !name.endsWith(`.json.${TMP_TAG}.tmp`) + : name.endsWith('.json') && !keep(name.slice(0, -'.json'.length)); + if (!stale) continue; + await unlink(join(dir, name)).catch(() => undefined); + if (!tmp) removed++; + } + return removed; + } + + /** Delete the stored history of `sessionId` without loading it. */ + static async delete(sessionId: string, opts: Pick<EditHistoryStoreOptions, 'dir'> = {}): Promise<void> { + await new EditHistoryStore(sessionId, opts).remove(); + } + + getTranscriptId(): string | null { + return this.transcriptId; + } + + getCursor(source: string): SourceCursor | undefined { + const c = this.cursors.get(source); + return c ? { ...c } : undefined; + } + + /** + * True while any source has unread older lines that could still be shown. At a cap (no + * room left for another entry) an older edit would be dropped the moment it is read, so + * there is nothing more to load. + */ + get backfillTruncated(): boolean { + if (this.entries.size >= MAX_ENTRIES || this.bytes > MAX_STORE_BYTES - MAX_HUNK_BYTES) return false; + for (const c of this.cursors.values()) if (c.floor > 0) return true; + return false; + } + + /** Entries newest first. */ + list(): EditHistoryEntry[] { + return [...this.entries.values()].sort((a, b) => b.ts - a.ts); + } + + get(id: string): EditHistoryEntry | undefined { + return this.entries.get(id); + } + + getAgents(): Record<string, AgentMeta> { + return Object.fromEntries(this.agents); + } + + hasAgentMeta(agentId: string): boolean { + return this.agents.has(agentId); + } + + /** Record a subagent's `meta.json`; ignored when it does not parse. */ + setAgentMeta(agentId: string, metaText: string): void { + const meta = this.removed ? null : parseAgentMeta(metaText); + if (!meta) return; + this.putAgent(agentId, meta); + this.scheduleSave(); + } + + /** The next older range of `source` ("ältere laden"), or null when it is read to its start. */ + olderRange(source: string, budget = READ_BUDGET_BYTES): ByteRange | null { + const cursor = this.cursors.get(source); + if (!cursor || cursor.floor <= 0) return null; + const offset = Math.max(0, cursor.floor - budget); + return { offset, len: cursor.floor - offset }; + } + + /** Every source with a cursor in the current conversation. */ + knownSources(): string[] { + return [...this.cursors.keys()]; + } + + /** Sources with older lines left. */ + sourcesWithOlder(): string[] { + return [...this.cursors.entries()].filter(([, c]) => c.floor > 0).map(([s]) => s); + } + + /** + * A source whose file is gone (a deleted subagent transcript): its cursor goes, so it no + * longer offers older lines. Its entries stay. + */ + dropSource(source: string): void { + if (this.removed || !this.cursors.delete(source)) return; + this.scheduleSave(); + } + + /** + * Apply a forward chunk: read from the source's cursor, or, without one, its last `budget` + * bytes; a full budget unless it reaches the file's end. A file that shrank below its cursor + * (replaced) loses the cursor and is read from its end next time. Returns new entries and + * Bash image candidates. + */ + apply(input: ApplyInput): ApplyResult { + if (this.removed) return { added: 0, candidates: [] }; + const { transcriptId, source, startOffset, chunk, size } = input; + if (transcriptId !== this.transcriptId) { + this.transcriptId = transcriptId; + this.cursors.clear(); + this.pendingBash = []; + } + let cursor = this.cursors.get(source); + if (cursor && size < cursor.offset) { + this.cursors.delete(source); + cursor = undefined; + } + if (cursor && startOffset !== cursor.offset) return { added: 0, candidates: [] }; + + const atEof = startOffset + chunk.length >= size; + // Bytes before `from` belong to a line that started before this chunk. + let from = 0; + if (cursor ? cursor.midLine : startOffset > 0) { + const nl = chunk.indexOf(NL); + if (nl === -1) { + // Still inside that line: move past it unless it is the line being written. A first + // read began inside it, so the floor does too. + if (!atEof) this.setCursor(source, cursor, startOffset + chunk.length, startOffset, true, true); + return { added: 0, candidates: [] }; + } + from = nl + 1; + } + const lastNl = chunk.lastIndexOf(NL); + if (lastNl < from) { + if (from > 0) { + // The skipped (or cut first) line ended here; the next one is read from its start. + this.setCursor(source, cursor, startOffset + from, startOffset + from, false); + } else if (!atEof && chunk.length > 0) { + // One line longer than the budget: skip it instead of re-reading it forever. + this.setCursor(source, cursor, startOffset + chunk.length, startOffset, true); + } else if (!cursor) { + this.setCursor(source, undefined, startOffset, startOffset, false); + } + return { added: 0, candidates: [] }; + } + const end = lastNl + 1; + this.setCursor(source, cursor, startOffset + end, startOffset + from, false); + const parsed = parseClaudeEditLines(chunk.subarray(from, end).toString('utf8'), this.pendingBash); + this.pendingBash = parsed.pending; + const added = this.addRecords(parsed.records); + this.scheduleSave(); + return { added, candidates: parsed.candidates }; + } + + /** + * Apply a chunk read at `olderRange`: a full budget unless it reaches the file's start. It + * ends at the floor, which is a line start, or with `floorMidLine` lies inside a line longer + * than the budget (its part in this chunk is dropped). + */ + applyOlder(input: ApplyInput): ApplyResult { + const { transcriptId, source, startOffset, chunk } = input; + const cursor = this.removed ? undefined : this.cursors.get(source); + if (transcriptId !== this.transcriptId || !cursor || startOffset + chunk.length !== cursor.floor) { + return { added: 0, candidates: [] }; + } + const none = { added: 0, candidates: [] }; + let end = chunk.length; + if (cursor.floorMidLine) { + const lastNl = chunk.lastIndexOf(NL); + if (lastNl === -1 && startOffset > 0) { + // Still inside the long line. + cursor.floor = startOffset; + this.scheduleSave(); + return none; + } + // The long line starts at `end`: drop its part. + end = lastNl + 1; + delete cursor.floorMidLine; + } + let from = 0; + if (startOffset > 0) { + // The newline at `end - 1` closes a line that may have started before this chunk. + const nl = chunk.subarray(0, end).indexOf(NL); + if (nl === -1 || nl === end - 1) { + if (end < chunk.length) { + // It ends where the long line starts: the next block, ending there, holds it whole. + cursor.floor = startOffset + end; + } else { + // No line starts in this chunk: it lies inside one line longer than the budget. Skip it. + cursor.floor = startOffset; + cursor.floorMidLine = true; + } + this.scheduleSave(); + return none; + } + from = nl + 1; + } + cursor.floor = startOffset + from; + // Calls whose results lie in already-read newer lines cannot be paired here. + const parsed = parseClaudeEditLines(chunk.subarray(from, end).toString('utf8')); + const added = this.addRecords(parsed.records); + this.scheduleSave(); + return { added, candidates: parsed.candidates }; + } + + /** Add Bash images whose mtime was confirmed inside their call's window. */ + addImageEntries(images: readonly ConfirmedImage[]): number { + if (this.removed) return 0; + const records: EditHistoryEntry[] = images.map((img) => { + const entry: EditHistoryEntry = { + id: `${img.toolUseId}:${img.path}`, + ts: img.ts, + path: img.path, + kind: 'image', + tool: img.tool ?? 'bash', + created: false, + added: 0, + removed: 0, + hunks: [], + truncated: false, + }; + if (img.agentId) entry.agentId = img.agentId; + return entry; + }); + const added = this.addRecords(records); + if (added) this.scheduleSave(); + return added; + } + + /** + * Write pending changes, if any, and wait for a write under way (the store is dropped from + * memory and loaded again later). Unlike `flush()`, a store without changes writes nothing. + */ + async settle(): Promise<void> { + if (this.saveTimer) return this.flush(); + await this.writing; + } + + /** Write now (cancels a pending debounced write). Writes run one after another. */ + async flush(): Promise<void> { + if (this.saveTimer) { + clearTimeout(this.saveTimer); + this.saveTimer = null; + } + const run = (this.writing ?? Promise.resolve()).then(() => this.write()); + this.writing = run.catch(() => undefined); + await run; + } + + /** True once {@link remove} ran: the store ignores every later write. */ + isRemoved(): boolean { + return this.removed; + } + + /** + * Delete the persisted file (session deleted) and forget the content. The store takes no + * changes afterwards, so a read still in flight neither writes nor refills it. + */ + async remove(): Promise<void> { + this.removed = true; + if (this.saveTimer) { + clearTimeout(this.saveTimer); + this.saveTimer = null; + } + this.entries.clear(); + this.bytes = 0; + this.cursors.clear(); + this.pendingBash = []; + this.agents.clear(); + // A write already under way ends first (or sees `removed`), so it cannot rename its + // tmp file over the deleted store afterwards. + await this.writing; + await unlink(this.file).catch(() => undefined); + } + + private async write(): Promise<void> { + if (this.removed) return; + const data: StoreFile = { + version: FILE_VERSION, + sessionId: this.sessionId, + transcriptId: this.transcriptId, + cursors: Object.fromEntries(this.cursors), + pendingBash: this.pendingBash, + agents: Object.fromEntries(this.agents), + entries: this.list(), + }; + const dir = join(this.file, '..'); + await mkdir(dir, { recursive: true, mode: 0o700 }); + // `mode` applies only to a file being created: the fresh tmp file is 0600, and the rename + // makes it the store, so an existing store never keeps wider bits. + const tmp = `${this.file}.${TMP_TAG}.tmp`; + await writeFile(tmp, JSON.stringify(data), { mode: 0o600 }); + if (this.removed) { + await unlink(tmp).catch(() => undefined); + return; + } + await rename(tmp, this.file); + } + + private putAgent(agentId: string, meta: AgentMeta): void { + this.agents.delete(agentId); + this.agents.set(agentId, meta); + if (this.agents.size <= MAX_AGENTS) return; + const used = new Set<string>(); + for (const e of this.entries.values()) if (e.agentId) used.add(e.agentId); + for (const id of this.agents.keys()) { + if (this.agents.size <= MAX_AGENTS) break; + // The newest label has no entries yet: they come with its transcript. + if (id !== agentId && !used.has(id)) this.agents.delete(id); + } + } + + private setCursor( + source: string, + cursor: SourceCursor | undefined, + offset: number, + firstFloor: number, + midLine: boolean, + firstFloorMidLine = false + ): void { + const next: SourceCursor = { offset, floor: cursor ? cursor.floor : firstFloor }; + if (midLine) next.midLine = true; + if (cursor ? cursor.floorMidLine : firstFloorMidLine) next.floorMidLine = true; + this.cursors.set(source, next); + } + + private addRecords(records: readonly EditHistoryEntry[]): number { + const fresh: string[] = []; + for (const r of records) { + if (this.entries.has(r.id)) continue; + this.entries.set(r.id, r); + this.bytes += entryBytes(r); + fresh.push(r.id); + } + if (this.entries.size > MAX_ENTRIES || this.bytes > MAX_STORE_BYTES) { + for (const e of [...this.entries.values()].sort((a, b) => a.ts - b.ts)) { + if (this.entries.size <= MAX_ENTRIES && this.bytes <= MAX_STORE_BYTES) break; + this.entries.delete(e.id); + this.bytes -= entryBytes(e); + } + } + // Only what is still there counts: an "older" block past the cap adds nothing. + return fresh.filter((id) => this.entries.has(id)).length; + } + + private scheduleSave(): void { + if (this.removed || this.saveTimer) return; + this.saveTimer = setTimeout(() => { + this.saveTimer = null; + void this.flush().catch((err) => { + console.warn(`[EditHistory] Save failed for session ${this.sessionId}:`, err); + }); + }, this.debounceMs); + this.saveTimer.unref?.(); + } +} diff --git a/src/git-clone.ts b/src/git-clone.ts index ef3380e8..513d6a11 100644 --- a/src/git-clone.ts +++ b/src/git-clone.ts @@ -50,9 +50,10 @@ import { spawn, execFileSync } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { existsSync } from 'node:fs'; -import { rename, rm } from 'node:fs/promises'; +import { lstat, readdir, rename, rm } from 'node:fs/promises'; import { basename, dirname, join } from 'node:path'; import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; +import { probePathKind } from './utils/bounded-path-probe.js'; // ─── Tunables ──────────────────────────────────────────────────────────────── @@ -188,8 +189,13 @@ export interface GitFailure { export interface CloneOptions { /** Pre-validated operand from `parseGitRepositoryUrl`. */ repository: string; - /** Absolute destination directory. Must NOT exist; created by git. */ + /** Absolute destination directory. Must NOT exist (created by git), unless `intoEmptyDir` is set. */ destination: string; + /** + * Clone straight into `destination` when it already exists as an EMPTY directory (the folder a user + * picked), instead of refusing it. A folder with anything in it, or a symlink, is still refused. + */ + intoEmptyDir?: boolean; /** Optional branch or tag (`--branch <ref> --single-branch`). */ ref?: string; /** `--depth 1`: history-less but much faster on large repos. */ @@ -590,6 +596,11 @@ export function classifyGitFailure(stderr: string, timedOut: boolean, spawnError stderr: clean, }; } + // First: git's refusal quotes the destination path, so a folder named openssl, rustls or "not found" would + // otherwise match the host and repository patterns below, and a wrong code here decides a deletion. + if (/already exists and is not an empty directory|destination path .* already exists/.test(lower)) { + return { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: clean }; + } if (timedOut) { return { code: 'TIMEOUT', @@ -625,12 +636,13 @@ export function classifyGitFailure(stderr: string, timedOut: boolean, spawnError stderr: clean, }; } - if (/could not resolve host|connection refused|connection timed out|network is unreachable|ssl|tls/.test(lower)) { + if ( + /could not resolve host|connection refused|connection timed out|network is unreachable|\bssl(?:\b|_)|\btls\b|certificate|handshake/.test( + lower + ) + ) { return { code: 'HOST_UNREACHABLE', message: 'Could not reach that host from this machine.', stderr: clean }; } - if (/already exists and is not an empty directory|destination path .* already exists/.test(lower)) { - return { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: clean }; - } return { code: 'FAILED', message: clean ? `git failed: ${firstLine(clean)}` : 'git failed.', stderr: clean }; } @@ -701,6 +713,8 @@ interface GitRun { stderr: string; code: number | null; timedOut: boolean; + /** False when git never ran (queue full, slot wait timed out, spawn failed): nothing was written. */ + started: boolean; spawnError?: string; } @@ -720,10 +734,17 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number) const queuedAt = Date.now(); const slot = await acquireGitSlot(timeoutMs); if (slot === 'queue-full') { - return { stdout: '', stderr: '', code: null, timedOut: false, spawnError: 'EBUSY: git operation queue is full' }; + return { + stdout: '', + stderr: '', + code: null, + timedOut: false, + spawnError: 'EBUSY: git operation queue is full', + started: false, + }; } if (slot === 'timed-out') { - return { stdout: '', stderr: '', code: null, timedOut: true }; + return { stdout: '', stderr: '', code: null, timedOut: true, started: false }; } const remainingMs = Math.max(1, timeoutMs - (Date.now() - queuedAt)); try { @@ -736,7 +757,7 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number) detached: true, }); } catch (err) { - resolve({ stdout: '', stderr: '', code: null, timedOut: false, spawnError: String(err) }); + resolve({ stdout: '', stderr: '', code: null, timedOut: false, started: false, spawnError: String(err) }); return; } @@ -783,8 +804,10 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number) resolve(result); }; - child.on('error', (err) => finish({ stdout, stderr, code: null, timedOut, spawnError: String(err) })); - child.on('close', (code) => finish({ stdout, stderr, code, timedOut })); + child.on('error', (err) => + finish({ stdout, stderr, code: null, timedOut, started: false, spawnError: String(err) }) + ); + child.on('close', (code) => finish({ stdout, stderr, code, timedOut, started: true })); }); } finally { releaseGitSlot(); @@ -847,6 +870,35 @@ export async function probeGitRemote( }; } +/** Destinations with a direct (`intoEmptyDir`) clone in flight. */ +const directClones = new Set<string>(); + +function destinationExists(): CloneResult { + return { + ok: false, + failure: { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: '' }, + }; +} + +/** True for a real (not symlinked) directory with nothing in it. Any read error counts as not empty. */ +async function isEmptyDirectory(path: string): Promise<boolean> { + try { + const st = await lstat(path); + return st.isDirectory() && (await readdir(path)).length === 0; + } catch { + return false; + } +} + +/** Remove everything inside `path`, keeping `path` itself. Best effort. */ +async function emptyDirectory(path: string): Promise<void> { + try { + for (const entry of await readdir(path)) await rm(join(path, entry), { recursive: true, force: true }); + } catch { + /* nothing to clean, or it vanished */ + } +} + /** * Clone `repository` into `destination`. * @@ -872,10 +924,33 @@ export async function cloneRepository(opts: CloneOptions): Promise<CloneResult> }; } if (existsSync(opts.destination)) { - return { - ok: false, - failure: { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: '' }, - }; + // One direct clone per folder at a time: once two gits have written into the same folder their + // files cannot be told apart, so the second caller is refused before it spawns anything. Taken + // before the emptiness check (which can precede a long wait for a git slot). + if (!opts.intoEmptyDir || directClones.has(opts.destination)) return destinationExists(); + directClones.add(opts.destination); + try { + if (!(await isEmptyDirectory(opts.destination))) return destinationExists(); + // The folder is the user's own, so there is no temporary sibling to rename into place: git fills it. + const direct = await runGit(buildCloneArgs(opts), opts.timeoutMs ?? GIT_CLONE_TIMEOUT_MS, MAX_STDERR_BYTES); + if (direct.code === 0 && !direct.spawnError) return { ok: true, stderr: sanitizeGitOutput(direct.stderr) }; + const failure = classifyGitFailure(direct.stderr, direct.timedOut, direct.spawnError); + // The folder was empty when this attempt took it, so what is in it is this attempt's partial tree, but + // only if git actually ran: a refused folder, a full queue or a slot wait that timed out wrote nothing, + // and anything in the folder by then (files the user copied in, another case created there) is not ours. + // A mount that stopped answering is left alone too, so a libuv worker is not held on it. + // git's own refusal is tested on its text, not on the classifier's code, so the deletion never depends on + // which pattern matched first. + const refused = /already exists and is not an empty directory/i.test(direct.stderr); + if (direct.started && !refused && failure.code !== 'DESTINATION_EXISTS') { + if ((await probePathKind(opts.destination, { pastCap: true })) === 'directory') { + await emptyDirectory(opts.destination); + } + } + return { ok: false, failure }; + } finally { + directClones.delete(opts.destination); + } } // Sibling of the destination (same filesystem), so the rename is atomic. diff --git a/src/git-fetch-scheduler.ts b/src/git-fetch-scheduler.ts new file mode 100644 index 00000000..a563fd1b --- /dev/null +++ b/src/git-fetch-scheduler.ts @@ -0,0 +1,307 @@ +/** + * @fileoverview Git fetch on an interval: the sessions the user turned on, fetched by a tick the server owns + * (`src/git-fetch-scheduler.ts`, wired in `src/web/server.ts`). + * + * The fetch module (src/git-fetch.ts) is the only git command the Git panel runs that writes, and on a timer + * it is the only one that can run without being asked (cloning and worktree management write too, but those + * happen when the user asks for them). Which is why the whole feature is off until the user turns it on, in + * Settings → Bottom bar, per session in Session Options → Git. + * + * The scheduler holds no timers of its own. The server ticks it (`GIT_FETCH_TICK_INTERVAL_MS`) and every + * tick reads the app setting FRESH, so a live toggle takes effect on the next tick instead of at boot, and + * turning the feature off actually stops the fetching. A session can override both the switch and the + * interval; a session with no override follows the app. + * + * Rules the code keeps and the tests pin: + * - Nothing runs when nothing is on: no repository set is read and nothing is spawned, so a quiet + * install costs one settings read per tick. A session that turned itself on still runs even while the + * app-wide switch is off: that is what a per-session switch has to mean. + * - Remote and Docker sessions are never fetched, exactly as the Git panel excludes them, and the Docker + * workspace roots are checked again every tick, so a session whose case became a Docker case stops + * fetching at the next tick. A Docker case list that cannot be read fails CLOSED: the tick fetches + * nothing (a fetch runs a repository's hooks, and without the list nothing tells which folders a + * container can write to), the folders it picked stay due for the next tick, and it is logged once + * until a read succeeds again. + * - A folder is fetched by at most one run at a time, and the run belongs to the FOLDER, not to one + * session: two sessions open on the same folder are the same clone, so they share one run and both get + * its result. State is keyed by `resolve(workingDir)`, deliberately not `realpath`, because `realpath` + * is itself one of the unbounded fs calls rule #516 bounds; the bounded probe below is the fs read. + * Every session open on a folder is remembered, so closing the one holding the override does not stop + * the folder's fetching while another session is still on it. A folder is due when ANY session open on it + * is due, and the tick decides that per folder in a second pass: the order the sessions were read cannot + * change which interval wins, so a 30 s session on a folder a 3600 s session last fetched ten minutes ago + * is fetched now. The folder is probed once, not once per session on it. + * - Before anything is fetched, the folder is probed BOUNDED with the default options (rule #516): a case + * on a mount that stopped answering is skipped, never touched, and a probe that answers `unknown` is not + * read as "not a folder". + * - A schedule is stamped at SELECTION, when a session goes into `due`, not when its run starts, so a + * tick that is still running cannot pick the same folder again; and a tick is skipped while the previous + * one is still going, so the bound below is the bound for the whole tick, not per tick. + * - At most `FETCH_CONCURRENCY` git processes at a time across the whole tick, because each is a git + * process: ONE bound is shared by every run of the tick, so `FETCH_CONCURRENCY` is the tick's ceiling + * rather than each folder's own. The folders get their OWN bound (`mapLimited`), because one semaphore + * cannot serve both levels: a folder that holds a slot and then waits for one for its own fetch is + * waiting on itself, and the folders that fill the bound are exactly the ones blocking it. + * - The interval is timed from the START of a run, so a run that takes three minutes does not push the + * next one out by three minutes; it lands on the tick after its own interval elapses. + * - The per-repository limit and the git timeout stay the module's defaults (`MAX_REPOS`, + * `DEFAULT_GIT_TIMEOUT_MS`): both settings are per device and never reach the server, so the server + * cannot know what a device chose. + * - A failure is reported, not retried harder: the folder keeps its schedule and gets its next run at its + * own interval. A settings read that fails skips this tick only, rather than deciding from defaults. + * settings.json is replaced atomically, so such a failure is a file that does not parse (a hand edit) or + * a real read error, which lasts until someone fixes it: it is logged once, and again only after a read + * has succeeded in between, not every 15 s tick. + * + * @module git-fetch-scheduler + */ + +import { resolve } from 'node:path'; +import { getErrorMessage } from './types.js'; +import { + FETCH_CONCURRENCY, + MAX_GIT_FETCH_INTERVAL_SECONDS, + MIN_GIT_FETCH_INTERVAL_SECONDS, + gitFetchStatusFrom, + type GitFetchOptions, + type GitFetchRun, + type GitFetchSettings, + type GitFetchStatus, + type GitFetchTrigger, +} from './git-fetch.js'; +import { clampInt, DEFAULT_GIT_TIMEOUT_MS, MAX_REPOS, createLimiter, mapLimited } from './git-workspace-status.js'; +import type { PathProbeKind } from './utils/bounded-path-probe.js'; + +/** What the scheduler needs from a session: the facts a decision needs, and nothing else. */ +export interface GitFetchSessionView { + id: string; + workingDir: string; + /** `unknown`, not `boolean`: a session's remote/Docker markers are objects, and the only question here is whether either is set at all. */ + remote?: unknown; + docker?: unknown; + gitFetchEnabled?: boolean; + gitFetchIntervalSeconds?: number; +} + +/** A session open on a folder, with the interval that makes IT due. One folder's sessions are a group. */ +interface GroupMember { + view: GitFetchSessionView; + intervalSeconds: number; +} + +export interface GitFetchPorts { + sessionIds(): Iterable<string>; + getSession(id: string): GitFetchSessionView | undefined; + /** Read FRESH every tick; that is the whole point of the tick-driven shape. */ + readSettings(): Promise<GitFetchSettings>; + /** + * Host paths of every Docker case workspace. REJECTS when the list exists but cannot be read or parsed; the + * tick then fetches nothing and the folders it picked are due again on the next tick. + */ + dockerWorkspaces(): Promise<string[]>; + /** + * Bounded probe (rule #516), default options: a bulk scan keeps the stall cap, so this must not be called + * with `pastCap`. Anything that is not `directory` (including `unknown`, which is never `absent`) is + * skipped, not fetched. + */ + probePath(path: string): Promise<PathProbeKind>; + /** Called through `fetchWorkspaceOnce`, so a run already going for this folder is joined, not stacked. */ + fetch(cwd: string, opts: GitFetchOptions, trigger: GitFetchTrigger): Promise<GitFetchRun>; + publish(id: string, status: GitFetchStatus): void; + log?(message: string): void; +} + +/** + * What one session will actually do: its own override wins, otherwise the app-wide setting. The interval is + * clamped again here because a persisted value can predate the schema's bounds. + */ +export function effectiveGitFetch( + view: GitFetchSessionView, + settings: GitFetchSettings +): { enabled: boolean; intervalSeconds: number } { + return { + enabled: view.gitFetchEnabled ?? settings.enabled, + intervalSeconds: clampInt( + view.gitFetchIntervalSeconds ?? settings.intervalSeconds, + MIN_GIT_FETCH_INTERVAL_SECONDS, + MAX_GIT_FETCH_INTERVAL_SECONDS, + MIN_GIT_FETCH_INTERVAL_SECONDS + ), + }; +} + +export class GitFetchScheduler { + private readonly ports: GitFetchPorts; + /** When each FOLDER last started a run; timed from the start, not the end. */ + private readonly lastRunAt = new Map<string, number>(); + private readonly inFlight = new Set<string>(); + /** Session → folder, so `remove()` can keep the folder's schedule while other sessions are still on it. */ + private readonly folderOf = new Map<string, string>(); + private running = false; + private stopped = false; + /** The last settings read failed and was logged; cleared by the next read that succeeds. */ + private settingsUnreadable = false; + /** The same for the Docker case list. */ + private dockerCasesUnreadable = false; + + constructor(ports: GitFetchPorts) { + this.ports = ports; + } + + /** One tick: fetch every folder whose sessions are on and due. Returns how many folders ran. */ + async tick(now = Date.now()): Promise<number> { + if (this.stopped || this.running) return 0; + this.running = true; + try { + let settings: GitFetchSettings; + try { + settings = await this.ports.readSettings(); + } catch (err) { + // Writes are atomic, so this is a real read error or a file that does not parse (a hand edit), and it + // stays that way until it is fixed: logged once, not on every tick. The reader never puts the file's + // text in its error (readGitFetchSettings), since settings.json can hold an API key. + if (!this.settingsUnreadable) { + this.settingsUnreadable = true; + this.ports.log?.( + `git fetch: settings unreadable, ticks skipped until it reads again: ${getErrorMessage(err)}` + ); + } + return 0; + } + this.settingsUnreadable = false; + + // Pass 1: who is on what, before anything is decided. Every session open on a folder is recorded here, + // not only the one that would get picked, so `remove()` can still see who is open on it when the session + // holding the override is the one that closes. + const groups = new Map<string, GroupMember[]>(); + for (const id of this.ports.sessionIds()) { + const view = this.ports.getSession(id); + if (!view || view.remote || view.docker) continue; + const effective = effectiveGitFetch(view, settings); + if (!effective.enabled) continue; + const folder = resolve(view.workingDir); + this.folderOf.set(id, folder); + const group = groups.get(folder); + const member = { view, intervalSeconds: effective.intervalSeconds }; + if (group) group.push(member); + else groups.set(folder, [member]); + } + + // Pass 2: the folder's turn, decided from the whole group rather than from whichever session happened to + // be read first. A folder is due when ANY session open on it is due: a session on a 30 s interval on a + // folder a 3600 s session last fetched ten minutes ago has to be fetched now, because that same run + // satisfies the long interval too. Deciding in one pass means the session read first owns the clock: a + // group ordered [short, long] and a group ordered [long, short] would have behaved differently, and the + // run belongs to the folder, so no session in the group should. + const due = new Map<string, GroupMember[]>(); + // What each picked folder's clock said before this tick stamped it, so a tick that ends up fetching + // nothing (an unreadable Docker case list, below) can leave the folders due again. + const stampedOver = new Map<string, number | undefined>(); + for (const [folder, group] of groups) { + if (this.inFlight.has(folder)) continue; + const started = this.lastRunAt.get(folder); + const isDue = started === undefined || group.some((m) => now - started >= m.intervalSeconds * 1000); + if (!isDue) continue; + // Bounded probe (rule #516), once per folder rather than once per session on it. + const kind = await this.ports.probePath(folder); + if (kind !== 'directory') { + this.ports.log?.(`git fetch: ${folder} skipped, answered ${kind}`); + continue; + } + this.inFlight.add(folder); + // The clock starts at the selection, so a slow folder does not shift its own schedule. + stampedOver.set(folder, started); + this.lastRunAt.set(folder, now); + due.set(folder, group); + } + if (!due.size) return 0; + + // Only now, because nothing above needs it: a quiet tick does not read the Docker case list. + let dockerWorkspaces: string[]; + try { + dockerWorkspaces = await this.ports.dockerWorkspaces(); + } catch (err) { + // Fail CLOSED: a fetch runs the repository's hooks, and without the list nothing tells which folders a + // container can write to. Nothing ran, so the picks are undone (a stamp a `remove()` already cleared + // stays cleared) and the folders are due again on the next tick instead of after their interval. + for (const [folder, started] of stampedOver) { + this.inFlight.delete(folder); + if (this.lastRunAt.get(folder) !== now) continue; + if (started === undefined) this.lastRunAt.delete(folder); + else this.lastRunAt.set(folder, started); + } + if (!this.dockerCasesUnreadable) { + this.dockerCasesUnreadable = true; + this.ports.log?.( + `git fetch: Docker case list unreadable, nothing fetched until it reads again: ${getErrorMessage(err)}` + ); + } + return 0; + } + this.dockerCasesUnreadable = false; + // ONE bound for the git processes of the whole tick, handed to every run so no folder gets its own: + // each fetch is one git process, and a folder can hold up to `MAX_REPOS_LIMIT` of them, so a bound per + // run would let `FETCH_CONCURRENCY` folders × `MAX_REPOS_LIMIT` processes run at once. + const fetches = createLimiter(FETCH_CONCURRENCY); + // The folders get their OWN bound, because the same semaphore cannot serve both levels: a folder that + // holds a slot and then waits for a slot for its own fetch is waiting on itself: once + // `FETCH_CONCURRENCY` folders fill the bound, the ones holding slots are exactly the ones that can + // never get one back. Both bounds are `FETCH_CONCURRENCY`, so the tick still never runs more git + // processes than the module's ceiling. + await mapLimited([...due.entries()], FETCH_CONCURRENCY, async ([folder, group]) => { + try { + const run = await this.ports.fetch( + folder, + { + dockerWorkspaces, + maxRepos: MAX_REPOS, + timeoutMs: DEFAULT_GIT_TIMEOUT_MS, + limiter: fetches, + }, + 'interval' + ); + // Every session on the folder gets the SAME run, with the trigger of the run that actually happened: + // if this tick joined a run a click had already started, the row has to say it was a click, because + // that is what fetched it. + for (const member of group) this.ports.publish(member.view.id, gitFetchStatusFrom(run.report, run.trigger)); + } catch (err) { + // The run that failed keeps its reason in the panel's own row; here only the folder-level failure is + // worth a line, because nothing else would say a scheduled fetch could not run at all. + this.ports.log?.(`git fetch: ${folder} could not run: ${getErrorMessage(err)}`); + } finally { + this.inFlight.delete(folder); + } + }); + return due.size; + } finally { + this.running = false; + } + } + + /** + * A session that closed or was detached: its schedule goes with it, but the FOLDER's stays while another + * session is still open on it: the run belongs to the clone, not to the session that asked for it. + */ + remove(id: string): void { + const folder = this.folderOf.get(id); + this.folderOf.delete(id); + if (!folder) return; + const stillOpen = [...this.folderOf.values()].includes(folder); + if (!stillOpen) { + this.lastRunAt.delete(folder); + this.inFlight.delete(folder); + } + } + + stop(): void { + this.stopped = true; + this.lastRunAt.clear(); + this.inFlight.clear(); + this.folderOf.clear(); + } + + /** Folders the scheduler still remembers a run for, so a test can see a `remove()` actually took. */ + tracked(): number { + return new Set([...this.lastRunAt.keys(), ...this.inFlight]).size; + } +} diff --git a/src/git-fetch.ts b/src/git-fetch.ts new file mode 100644 index 00000000..a2f4b2ba --- /dev/null +++ b/src/git-fetch.ts @@ -0,0 +1,506 @@ +/** + * @fileoverview `git fetch` for the repositories the Git panel shows, on the user's click + * (`POST /api/sessions/:id/git-fetch`) or on an interval the user turned on + * (`src/git-fetch-scheduler.ts`). + * + * The status module (src/git-workspace-status.ts) is deliberately offline: "behind" is as of the last + * fetch, so a number that is stale can only be made current by fetching. This is the only git command the + * Git panel runs that writes, and the only git command Codeman runs on a timer. Cloning a repository and + * managing worktrees write too, but those are things the user asked for at the moment they asked; this is + * the one that can happen later, by itself. Nothing here commits, checks out, merges or touches the working + * tree: `git fetch` updates remote-tracking + * refs and nothing else, and `FETCH_CONFIG_ARGS` keeps it from starting a gc in a repository an agent is + * already using. It is not harmless in the way a read is: a fetch holds git's ref lock, so an agent's own + * `git pull` in the same clone can collide with it (`cannot lock ref`), which is why one folder is fetched + * by at most one run at a time (`fetchWorkspaceOnce`). + * + * Same split as git-clone.ts: the pure half (`buildFetchArgs`, `describeFetchFailure`, `fetchEnvFor`, + * `gitFetchSettingsFrom`, `gitFetchStatusFrom`) holds every decision and tests without spawning, and the IO + * half runs one bounded `git fetch --quiet` per repository. + * + * Rules the code keeps and the tests pin: + * - The repository set is `resolveWorkspaceRepos`, the single source the panel and the diff route + * already use, so Fetch cannot reach a repository the panel would not show. The Docker rule is + * checked before any git runs: a repository a container can write to is refused outright, because a + * planted config would run on the host, and `git fetch` is exactly the command that can run a + * repository's own hooks (`reference-transaction`, and `pre-auto-gc` through the automatic maintenance + * that follows a fetch) and its config commands such as `credential.helper`, which is why a timer only + * runs it after the user opts in. + * - One run per folder at a time: `fetchWorkspaceOnce` joins a run already going for the same folder + * instead of starting a second one, because two fetches in one clone collide on the ref lock. + * - Bounded: the git timeout from Settings → Bottom bar per repository, `MAX_REPOS_LIMIT` repositories at + * most, a hard wall-clock bound on each spawn, and `FETCH_CONCURRENCY` git processes at a time for the + * whole run: one `Limiter`, shared by the scheduler's tick, not one bound per folder. + * - Non-interactive: `GIT_TERMINAL_PROMPT=0` and BatchMode ssh, so a private repository that needs a + * login fails fast instead of hanging the request on an invisible username prompt. Codeman collects + * no credentials; a repository the user's own git is already signed in to works because that is the + * user's existing setup, so unlike a clone no credential helper is disabled here. That BatchMode ssh is + * a default, never an override: `fetchEnvFor` keeps whatever the repository's `core.sshCommand`, the + * user's own `GIT_SSH_COMMAND` or a `GIT_SSH` already says, because on a machine with several ssh setups + * that choice is usually the one that works. ⚠️ The default still leaves a record: `StrictHostKeyChecking=accept-new` + * writes an unknown host key into `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a + * write. + * - The timeout kills the PROCESS GROUP, not just the `git` parent. A fetch over http spawns + * `git-remote-http` children, and `execFile`'s `timeout` signals the parent only, so the answer can go + * out while a helper is still running, and on a timer that repeats every tick with nobody watching. + * `spawn` with `detached: true` puts the tree in its own group so the escalation (SIGTERM, then SIGKILL + * `FETCH_KILL_ESCALATION_MS` later) reaches the children too, which is why the negative pid is used + * rather than `child.kill()`. Same shape as `runGit` in git-clone.ts, without that module's slot queue: + * fetch is bounded by `FETCH_CONCURRENCY`, not by a clone queue. + * - Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`), + * and the browser writes it with `textContent` only. + * + * @module git-fetch + */ + +import { execFile, spawn } from 'node:child_process'; +import { basename, relative, resolve } from 'node:path'; +import { promisify } from 'node:util'; +import { classifyGitFailure, gitNonInteractiveEnv, sanitizeGitOutput, type GitFailureCode } from './git-clone.js'; +import { + createLimiter, + DEFAULT_GIT_TIMEOUT_MS, + type Limiter, + resolveOverviewLimits, + resolveWorkspaceRepos, + type GitRunner, +} from './git-workspace-status.js'; + +const execFileAsync = promisify(execFile); + +/** + * Git processes in flight at once, for one run, and for a whole scheduler tick, because the tick hands ONE + * bound to every run (see `createLimiter`): each fetch is one git process, and a folder can hold up to + * `MAX_REPOS_LIMIT`. The tick bounds its folders separately, because the same semaphore cannot serve both + * levels: a folder holding a slot would then wait for a slot its own fetch needs. + */ +export const FETCH_CONCURRENCY = 4; +/** A fetch on a busy remote can print a lot; a bound on what we will hold. */ +const MAX_FETCH_OUTPUT_BYTES = 2 * 1024 * 1024; +/** git's stderr is kept as a bounded tail (as in git-clone.ts): enough for the reason, not the whole stream. */ +const MAX_FETCH_STDERR_BYTES = 8_192; +/** A `git config --get` answer is one line; this is a sanity cap, not an expected size. */ +const MAX_SSH_COMMAND_BYTES = 4_096; +/** + * How long the process group gets to answer SIGTERM before the same escalation goes to SIGKILL. Kept as a + * const so a test can say how long it is willing to wait for the group to actually die. + */ +export const FETCH_KILL_ESCALATION_MS = 3_000; + +/** + * Config overrides set ahead of the argv, so an unattended fetch cannot do more than move refs: no + * fsmonitor hook, and no automatic maintenance or gc. A fetch is the git command that can run a + * repository's own hooks (`reference-transaction`, and `pre-auto-gc` through the maintenance that follows a + * fetch), and a gc started by a timer in a repository an agent is working in is exactly the kind of extra + * write this module must not do. Kept as a const so a test can pin it without spawning git. + */ +export const FETCH_CONFIG_ARGS = ['-c', 'core.fsmonitor=false', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0']; + +/** The interval the user can turn on (Settings → Bottom bar, and Session Options → Git): bounds and default. */ +export const DEFAULT_GIT_FETCH_INTERVAL_SECONDS = 300; +export const MIN_GIT_FETCH_INTERVAL_SECONDS = 30; +export const MAX_GIT_FETCH_INTERVAL_SECONDS = 3600; + +export interface GitFetchSettings { + enabled: boolean; + intervalSeconds: number; +} + +/** + * A settings object as the scheduler should read it: absent means OFF, because nothing runs on a timer + * until the user turns this on, and a number outside the schema's bounds is clamped rather than trusted. + * Pure, so the reading itself is testable without a settings file. + */ +export function gitFetchSettingsFrom(settings: Record<string, unknown> | undefined): GitFetchSettings { + const raw = settings?.gitFetchIntervalSeconds; + const seconds = + typeof raw === 'number' && Number.isFinite(raw) ? Math.round(raw) : DEFAULT_GIT_FETCH_INTERVAL_SECONDS; + return { + enabled: settings?.gitFetchEnabled === true, + intervalSeconds: Math.min(MAX_GIT_FETCH_INTERVAL_SECONDS, Math.max(MIN_GIT_FETCH_INTERVAL_SECONDS, seconds)), + }; +} + +/** + * The only git command this module runs. Kept as a function so a test can pin the verb: nothing here + * commits, checks out, merges or prunes, and no operand is interpolated: the repository is the process + * `cwd` and the argv is fixed. + */ +export function buildFetchArgs(): string[] { + return ['fetch', '--quiet']; +} + +/** + * The `git` process also gets the `FETCH_CONFIG_ARGS` overrides ahead of this argv; they are kept separate + * so pinning the verb here does not have to say anything about maintenance, and so both can be tested + * without spawning. + * + * A repository with no remote is a fact, not a fault. Git's wordings for it differ by version (2.47 + * exits quietly and this code is never reached, older git says "no default remote repository"), so the + * branch is pinned on literal fixtures and the code the panel shows is the code this writes. + */ +export type FetchFailureCode = GitFailureCode | 'NO_REMOTE'; + +export interface FetchFailure { + code: FetchFailureCode; + /** User-facing summary. */ + message: string; + /** Tail of git's own stderr, control-stripped and credential-redacted. */ + stderr: string; +} + +/** + * A thrown git error as the panel should show it. `classifyGitFailure` (git-clone.ts) already knows the + * network reasons (auth, an unreachable host, a missing git), so they are reused here; only its timeout + * wording is written for clones, so the fetch path replaces it with what the user can actually do. + */ +export function describeFetchFailure(err: unknown): FetchFailure { + const e = err as { code?: unknown; stderr?: unknown; message?: string; killed?: boolean }; + const stderr = typeof e.stderr === 'string' ? e.stderr : ''; + const spawnError = typeof e.message === 'string' ? e.message : undefined; + const clean = sanitizeGitOutput(stderr); + const lower = `${clean}\n${spawnError ?? ''}`.toLowerCase(); + if (/no default remote repository|no remote configured|nothing to fetch/.test(lower)) { + return { + code: 'NO_REMOTE', + message: 'This repository has no remote, so there is nothing to fetch.', + stderr: clean, + }; + } + const timedOut = e.code === 'ETIMEDOUT' || e.killed === true; + const failure = classifyGitFailure(stderr, timedOut, spawnError); + if (failure.code === 'TIMEOUT') + failure.message = 'Git timed out. Raise “Git status: git timeout” in Settings → Bottom bar, or fetch from a shell.'; + return failure; +} + +/** + * The environment a fetch gets, as a pure decision so it can be pinned without spawning git. + * + * `gitNonInteractiveEnv` supplies the BatchMode default, and the default is only a default: git reads + * `GIT_SSH_COMMAND` from the environment ahead of the repository's `core.sshCommand`, so setting it would + * override a repository that already chose its own ssh (an alternative host, an identity file, a jump box) + * and break a setup that works today. The server's own `GIT_SSH` is the same kind of answer: git reads it + * after both of those (`GIT_SSH_COMMAND`, then `core.sshCommand`, then `GIT_SSH`), so a written default would + * hide it too. That is the only case where this module makes an ssh choice on the user's behalf, so it is the + * whole decision here: the caller supplies the value it read, and this decides between the user's + * environment and the repository's own answer. + */ +export function fetchEnvFor(base: NodeJS.ProcessEnv, repoSshCommand: string | null): NodeJS.ProcessEnv { + const env = gitNonInteractiveEnv(base); + // `gitNonInteractiveEnv` only writes the default when the environment had nothing, so this is exactly the + // case where it was a choice rather than a pass-through, and the repository's own answer, or a `GIT_SSH` in + // the environment, wins over it. + if (!base.GIT_SSH_COMMAND && (repoSshCommand || base.GIT_SSH)) delete env.GIT_SSH_COMMAND; + return env; +} + +/** + * The repository's own `core.sshCommand`, or null. One bounded, read-only `git config --get` per fetch: + * git would honour that config itself if we did not write `GIT_SSH_COMMAND` over it, so honouring it means + * not writing one. `--get` reads local and global config, which is the value git would have used, and no + * hook or filter runs on the way to it. + * + * The read happens inside the same slot and the same wall-clock bound as the fetch it serves, so it cannot + * add a fifth git process or push a fetch past the user's timeout: it spends part of it. + */ +export async function readRepoSshCommand(cwd: string, timeoutMs: number): Promise<string | null> { + try { + const { stdout } = await execFileAsync('git', ['config', '--get', 'core.sshCommand'], { + cwd, + timeout: timeoutMs, + maxBuffer: MAX_SSH_COMMAND_BYTES, + env: gitNonInteractiveEnv(), + }); + return stdout.trim() || null; + } catch { + // A repository with no such setting, or a read that could not finish: the default is what git would have + // used anyway, and a fetch must not fail because the check for a config value failed. + return null; + } +} + +/** + * Fetch is allowed to write refs, so this runner does not carry `--no-optional-locks`: fetch takes a + * required lock anyway. + * + * `spawn`, not `execFile`, because the bound has to reach the whole tree (see the fileoverview): the + * caller is answered at the timeout, and the SIGTERM → SIGKILL escalation is deliberately NOT cancelled + * when the answer goes out, because the promise is a promise about the answer while the group still has to + * die. That is the difference from `runGit` in git-clone.ts, which disarms the escalation at `finish` and so + * can leave a `git-remote-http` child running after a timed-out call. + * + * The thrown error keeps the shape `describeFetchFailure` reads: `stderr` for the reason, `message` for a + * spawn failure (`spawn git ENOENT` → GIT_MISSING), `killed` and `code` for the timeout. A non-zero exit + * rejects the way `execFile` would (`code` is then the exit code), because resolving it would report a real + * failure as a clean run with an empty reason. + * + * `timeoutMs` bounds the `core.sshCommand` read and the fetch TOGETHER: the spawn is armed with what the read + * left, so a `git` that never answers costs one timeout, not two. A read that used the whole bound answers + * as a timeout without starting the fetch. + */ +export const runFetch: GitRunner = async (cwd, args, opts) => { + const timeoutMs = opts?.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS; + const startedAt = Date.now(); + const env = fetchEnvFor(process.env, await readRepoSshCommand(cwd, timeoutMs)); + const remainingMs = timeoutMs - (Date.now() - startedAt); + if (remainingMs <= 0) { + throw Object.assign(new Error(`Command failed: git ${args.join(' ')}`), { + stderr: '', + killed: true, + code: 'ETIMEDOUT', + }); + } + + return new Promise<string>((resolve, reject) => { + let child: ReturnType<typeof spawn>; + try { + child = spawn('git', [...FETCH_CONFIG_ARGS, ...args], { + cwd, + env, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + }); + } catch (err) { + reject(err); + return; + } + + let stdout = ''; + let stderr = ''; + let stdoutBytes = 0; + let timedOut = false; + let settled = false; + let killTimer: NodeJS.Timeout | undefined; + + const killTree = (signal: NodeJS.Signals) => { + try { + if (child.pid) process.kill(-child.pid, signal); + } catch { + try { + child.kill(signal); + } catch { + /* already gone */ + } + } + }; + + const fail = (spawnError?: string, exitCode?: number | null) => { + if (settled) return; + settled = true; + // Only the timeout timer is disarmed here. On a timeout the escalation is what the caller is waiting + // for, and cancelling it is the bug this runner exists to fix. + clearTimeout(timeoutTimer); + reject( + Object.assign(new Error(spawnError ?? `Command failed: git ${args.join(' ')}`), { + stderr, + killed: timedOut, + code: timedOut ? 'ETIMEDOUT' : (exitCode ?? undefined), + }) + ); + }; + + const timeoutTimer = setTimeout(() => { + timedOut = true; + killTree('SIGTERM'); + killTimer = setTimeout(() => killTree('SIGKILL'), FETCH_KILL_ESCALATION_MS); + fail(); + }, remainingMs); + + child.stdout?.on('data', (chunk: Buffer) => { + stdoutBytes += chunk.length; + if (stdoutBytes <= MAX_FETCH_OUTPUT_BYTES) stdout += chunk.toString('utf-8'); + }); + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf-8'); + if (stderr.length > MAX_FETCH_STDERR_BYTES * 2) stderr = stderr.slice(-MAX_FETCH_STDERR_BYTES); + }); + + const finish = (code: number | null) => { + if (settled) return; + // execFile's contract, which `describeFetchFailure` depends on: a non-zero exit rejects, so a real + // git failure keeps its reason in `stderr` instead of becoming a green row with an empty one. `null` + // means a signal, reachable only when something outside this runner killed the group: our own timeout + // has already answered by then, and `settled` above is what keeps that answer the answer. + if (code !== 0) return fail(undefined, code); + settled = true; + clearTimeout(timeoutTimer); + if (killTimer) clearTimeout(killTimer); + resolve(stdout); + }; + + child.on('error', (err) => fail(String(err))); + child.on('close', (code) => finish(code)); + }); +}; + +export interface GitFetchEntry { + /** Folder name of the repository (its root's basename), as the panel names it. */ + name: string; + /** The repository root relative to the working directory: `.`, `..`, `api`, `apps/web`. */ + path: string; + ok: boolean; + failure?: FetchFailure; +} + +export interface GitFetchReport { + /** `ok` when at least one repository was fetched (or attempted); the other states are as in the overview. */ + state: 'ok' | 'not-a-repo' | 'unsupported' | 'error'; + reason?: 'remote' | 'docker'; + error?: string; + /** One entry per repository fetched, in the same order the panel lists them. */ + repos: GitFetchEntry[]; + /** More than `repoLimit` repositories were found; only the first were fetched. */ + reposTruncated: boolean; + /** The most repositories this fetch would list (the caller's setting, or `MAX_REPOS`). */ + repoLimit?: number; + /** When the last repository finished, so the panel can say what the "behind" number is now based on. */ + fetchedAt: number; +} + +/** + * What one run did, in the shape a session keeps and the Session Options Git row shows. `trigger` says + * whether the user clicked or the interval fired, because a number the user did not ask for should read + * differently from one they did. + */ +/** What a run was, and why it happened. */ +export type GitFetchTrigger = 'click' | 'interval'; + +export interface GitFetchStatus { + ranAt: number; + trigger: GitFetchTrigger; + /** As in the report: `ok`, or the state that stopped the run. */ + state: GitFetchReport['state']; + /** Repositories this run covered. */ + repos: number; + /** How many of them could not be fetched. */ + failed: number; + /** The first failure, named: enough for one row without reprinting the whole list. */ + firstFailure?: { name: string; code: FetchFailureCode }; +} + +/** + * One run, with the trigger that caused it. A joiner gets the run already going, so what it reports is that + * run's trigger rather than the one that made it ask: a click that joins an interval run did not fetch + * anything itself, and the row the panel shows has to describe the fetch that actually happened. + */ +export interface GitFetchRun { + report: GitFetchReport; + trigger: GitFetchTrigger; +} + +export function gitFetchStatusFrom(report: GitFetchReport, trigger: GitFetchTrigger): GitFetchStatus { + const failed = report.repos.filter((r) => !r.ok); + const status: GitFetchStatus = { + ranAt: report.fetchedAt, + trigger, + state: report.state, + repos: report.repos.length, + failed: failed.length, + }; + const first = failed[0]; + if (first?.failure) status.firstFailure = { name: first.name, code: first.failure.code }; + return status; +} + +/** Options for the fetch path: the same repository rules and limits as an overview, without its cache clock. */ +export interface GitFetchOptions { + git?: GitRunner; + home?: string; + dockerWorkspaces?: string[]; + maxRepos?: number; + timeoutMs?: number; + /** + * The shared slot count for a whole scheduler tick. Passed in so `FETCH_CONCURRENCY` bounds the tick + * rather than each folder separately; without it, a tick over more than `FETCH_CONCURRENCY` folders could + * have that many git processes going at once inside each one. + */ + limiter?: Limiter; +} + +export const emptyReport = (state: GitFetchReport['state'], extra: Partial<GitFetchReport> = {}): GitFetchReport => ({ + state, + repos: [], + reposTruncated: false, + fetchedAt: Date.now(), + ...extra, +}); + +async function fetchOne(dir: string, git: GitRunner, timeoutMs: number): Promise<FetchFailure | null> { + try { + await git(dir, buildFetchArgs(), { timeoutMs }); + return null; + } catch (err) { + return describeFetchFailure(err); + } +} + +/** + * Fetch every repository the panel would show for `cwd`, through the same resolver so the two cannot + * disagree. A repository that failed keeps its row with the reason, exactly as an unreadable repository + * stays in the status list: the panel must show that something was not fetched rather than quietly skip it. + */ +export async function fetchGitWorkspaceOverview(cwd: string, opts: GitFetchOptions = {}): Promise<GitFetchReport> { + const where = await resolveWorkspaceRepos(cwd, opts); + if (where.kind === 'docker') return emptyReport('unsupported', { reason: 'docker' }); + if (where.kind === 'error') return emptyReport('error', { error: where.error }); + const dirs = where.kind === 'enclosing' ? [where.root] : where.dirs; + if (!dirs.length) return emptyReport('not-a-repo'); + const { timeoutMs } = resolveOverviewLimits(opts); + const runner = opts.git ?? runFetch; + const run = opts.limiter ?? createLimiter(FETCH_CONCURRENCY); + const failures = await run(dirs, (dir) => fetchOne(dir, runner, timeoutMs)); + const repos: GitFetchEntry[] = dirs.map((dir, i) => { + const base = { name: basename(dir), path: relative(cwd, dir) || '.' }; + const failure = failures[i]; + return failure ? { ...base, ok: false, failure } : { ...base, ok: true }; + }); + return { + state: 'ok', + repos, + reposTruncated: where.kind === 'children' ? where.truncated : false, + repoLimit: resolveOverviewLimits(opts).maxRepos, + fetchedAt: Date.now(), + }; +} + +/** + * Runs already going, keyed by the resolved folder. A request for a folder another run is already working on + * JOINS that run instead of starting a second `git fetch` in the same clone: two fetches there collide on + * git's ref lock (`cannot lock ref 'refs/remotes/origin/main'`), which is what a click during a tick would + * otherwise do. A joiner gets the run already going's answer, including its limits and its trigger: the point + * is not to fetch twice, not to fetch with the freshest options, and a click that fetched nothing cannot + * report itself as the fetch. + * + * Keyed by `resolve`, not `realpath`, because `realpath` is itself one of the unbounded fs calls rule #516 + * bounds for user-chosen paths; the scheduler probes the folder bounded and calls this. Two paths that + * resolve the same are the same folder here; a workspace reached through a symlink resolves to the link, so + * a link and its target can be fetched by two runs, the accepted trade for keeping the probe bounded. + */ +const lane = new Map<string, Promise<GitFetchRun>>(); + +export async function fetchWorkspaceOnce( + folder: string, + opts: GitFetchOptions = {}, + trigger: GitFetchTrigger +): Promise<GitFetchRun> { + const key = resolve(folder); + const going = lane.get(key); + // The joiner is handed the run already going as it is, trigger included: it did not cause that fetch, so it + // must not report itself as having. + if (going) return going; + const run: Promise<GitFetchRun> = fetchGitWorkspaceOverview(key, opts) + .then((report) => ({ report, trigger })) + .finally(() => { + // Only the run that is still the lane's own: a later run for the same folder must not be dropped by an + // earlier one finishing late. + if (lane.get(key) === run) lane.delete(key); + }); + lane.set(key, run); + return run; +} + +/** Test seam: an in-memory lane is not cleared by stopping the scheduler, so a test has to clear it itself. */ +export function clearGitFetchLane(): void { + lane.clear(); +} diff --git a/src/git-workspace-status.ts b/src/git-workspace-status.ts index 8850542a..2a5a8230 100644 --- a/src/git-workspace-status.ts +++ b/src/git-workspace-status.ts @@ -22,7 +22,8 @@ * * Rules the code keeps and the tests pin: * - READ-ONLY and OFFLINE. It never fetches, pulls, commits or writes. "Behind" therefore reflects - * the last fetch (the UI says so); "ahead" and the unpushed list are exact against the + * the last fetch (the UI says so, and the Fetch button is a separate user-triggered path in + * `src/git-fetch.ts`, never a poll); "ahead" and the unpushed list are exact against the * remote-tracking refs already on disk. `--no-optional-locks` keeps `git status` from even * refreshing the index, so polling cannot contend with the agent's own git commands. * - Every call is async (`execFile`), bounded by a timeout, and never interpolates a path into a @@ -596,8 +597,55 @@ export async function discoverChildRepos( const discoveryCache = new Map<string, { at: number; value: { dirs: string[]; truncated: boolean } }>(); -/** Run `fn` over `items` with at most `limit` in flight, keeping the input order. */ -async function mapLimited<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> { +/** + * A slot count fixed ONCE, so callers share it instead of each getting its own bound (see the fetch path): + * the count lives in the closure, so `limit` is the ceiling for everything going through THIS limiter at + * once, not for each call to it, which is what makes a scheduler tick's bound honest. + * ⚠️ One limiter cannot bound two levels of the same work: a caller that holds a slot and then waits for + * another slot for the work IT starts cannot get one, because the holders are exactly the ones waiting. The + * outer level needs its own bound (`mapLimited`), which is why the tick shares one for fetches and keeps a + * separate one for folders. + */ +export type Limiter = <T, R>(items: T[], fn: (item: T) => Promise<R>) => Promise<R[]>; + +export function createLimiter(limit: number): Limiter { + let inUse = 0; + const waiting: Array<() => void> = []; + const acquire = (): Promise<void> => { + if (inUse < limit) { + inUse++; + return Promise.resolve(); + } + return new Promise<void>((done) => { + waiting.push(() => { + inUse++; + done(); + }); + }); + }; + const release = () => { + inUse--; + const next = waiting.shift(); + if (next) next(); + }; + return async <T, R>(items: T[], fn: (item: T) => Promise<R>): Promise<R[]> => { + const out: R[] = new Array(items.length); + await Promise.all( + items.map(async (item, i) => { + await acquire(); + try { + out[i] = await fn(item); + } finally { + release(); + } + }) + ); + return out; + }; +} + +/** Run `fn` over `items` with at most `limit` in flight, keeping the input order. Per call; `createLimiter` shares one bound across calls. */ +export async function mapLimited<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> { const out: R[] = new Array(items.length); let next = 0; const worker = async () => { @@ -634,7 +682,7 @@ export function resolveOverviewLimits(opts: { maxRepos?: unknown; timeoutMs?: un }; } -type WorkspaceRepos = +export type WorkspaceRepos = | { kind: 'docker' } | { kind: 'error'; error: string } | { kind: 'enclosing'; root: string } @@ -643,9 +691,10 @@ type WorkspaceRepos = /** * WHICH repositories belong to the workspace (the module header has the rules), without a full * status of any of them: one cached `rev-parse` for the enclosing repository, else the cached scan - * below the folder. The overview and the diff route both go through here, so they cannot disagree. + * below the folder. The overview, the fetch route and the diff route all go through here, so they cannot + * disagree about which repositories belong to a workspace. */ -async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise<WorkspaceRepos> { +export async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise<WorkspaceRepos> { const now = opts.now ?? Date.now; const { maxRepos, timeoutMs } = resolveOverviewLimits(opts); const dockerRoots = await realAll(opts.dockerWorkspaces ?? []); diff --git a/src/hooks-config.ts b/src/hooks-config.ts index f1bd7ebc..3c995e13 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -42,6 +42,7 @@ import { dataPath } from './config/instance.js'; import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js'; import { isNearStalledPath, probePath } from './utils/index.js'; import { DEFAULT_KEEP_WARM_MAX_HOURS, type KeepWarmSettings } from './prompt-cache-keepwarm.js'; +import { gitFetchSettingsFrom, type GitFetchSettings } from './git-fetch.js'; /** * Existence check for a WRITER. Unlike the bounded read-side probe (`probePath`), @@ -1158,8 +1159,9 @@ export async function readKeepWarmSettings(): Promise<KeepWarmSettings> { /** * The same two keys for the keep-warm service, which keeps what it reads until the next * settings change. A missing file is the defaults, but any other read or parse failure rejects - * rather than falling back: settings.json is rewritten in place, so a read can land mid-write, - * and a fallback kept from it would hold keep-warm off until the next keep-warm settings change. + * rather than falling back: settings.json is replaced atomically, so such a failure is a real + * read error or a file that does not parse (a hand edit), never a write in progress, and a + * fallback kept from it would hold keep-warm off until the next keep-warm settings change. */ export async function readKeepWarmSettingsOrThrow(): Promise<KeepWarmSettings> { let text: string; @@ -1169,7 +1171,40 @@ export async function readKeepWarmSettingsOrThrow(): Promise<KeepWarmSettings> { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return keepWarmSettingsFrom({}); throw err; } - return keepWarmSettingsFrom(JSON.parse(text) as Record<string, unknown>); + return keepWarmSettingsFrom(parseSettingsText(text)); +} + +/** + * The Git fetch interval's app-wide default (`gitFetchEnabled`, absent = OFF) and its cap + * (`gitFetchIntervalSeconds`, seconds between runs). Read FRESH on every tick by the scheduler, so a live + * toggle takes effect on the next tick and turning it off actually stops the fetching. A missing file is + * the defaults (OFF), but any other read or parse failure rejects and the scheduler skips that tick rather + * than deciding from defaults. settings.json is replaced atomically (a temp file renamed into place, see + * settings-writer.ts), so such a failure is a real read error or a file that does not parse (a hand edit), + * never a write in progress, and the scheduler logs it once rather than every tick. + */ +export async function readGitFetchSettings(): Promise<GitFetchSettings> { + let text: string; + try { + text = await readFile(SETTINGS_PATH, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return gitFetchSettingsFrom({}); + throw err; + } + return gitFetchSettingsFrom(parseSettingsText(text)); +} + +/** + * settings.json's text for the two readers above, which reject a file that does not parse. The parser's + * own message is never repeated, as in settings-writer.ts: V8 quotes the text around the fault, settings.json + * can hold an API key (`voiceSettings.apiKey`), and both callers log the error they get. + */ +function parseSettingsText(text: string): Record<string, unknown> { + try { + return JSON.parse(text) as Record<string, unknown>; + } catch { + throw new Error(`${SETTINGS_PATH} is not valid JSON`); + } } function keepWarmSettingsFrom(settings: Record<string, unknown>): KeepWarmSettings { diff --git a/src/image-watcher.ts b/src/image-watcher.ts index 8f8dc560..072a0ee3 100644 --- a/src/image-watcher.ts +++ b/src/image-watcher.ts @@ -14,7 +14,7 @@ import { basename, extname, relative } from 'node:path'; import { statSync } from 'node:fs'; import type { AttachmentDetectedEvent, AttachmentDetectedType, ImageDetectedEvent } from './types.js'; import { KeyedDebouncer } from './utils/index.js'; -import { UPLOAD_DIR_NAMES } from './web/paste-image-gc.js'; +import { DISCORD_RECEIVED_DIR, UPLOAD_DIR_NAMES } from './web/paste-image-gc.js'; // ========== Types ========== @@ -159,14 +159,14 @@ export class ImageWatcher extends EventEmitter { // Ignore common heavy directories for performance ignored: (path: string) => { // Skip node_modules, .git, and other heavy directories, and Codeman's - // own upload folders: a pdf the user handed to the agent is not a file - // the agent produced. + // own upload folders: a pdf the user handed to the agent, by upload or + // from Discord, is not a file the agent produced. if ( path.includes('/node_modules/') || path.includes('/.git/') || path.includes('/dist/') || path.includes('/.next/') || - UPLOAD_DIR_NAMES.some((name) => path.includes(`/${name}/`)) + [...UPLOAD_DIR_NAMES, DISCORD_RECEIVED_DIR].some((name) => path.includes(`/${name}/`)) ) { return true; } diff --git a/src/local-history.ts b/src/local-history.ts new file mode 100644 index 00000000..4ff244bf --- /dev/null +++ b/src/local-history.ts @@ -0,0 +1,842 @@ +/** + * @fileoverview Offline reader for saved Codex conversations, behind + * `codeman history read` (`cli-history.ts`). It reads rollout JSONL files from + * disk directly: no HTTP route, no running server and no managed session. + * + * ## Selecting conversations + * + * Exactly one selector: `file` (one rollout, no store search), `sessionId` (a + * native thread id or unique prefix of at least 8 characters) or + * `rootSessionId` (a full id plus every transitive descendant). Identity always + * comes from each file's `session_meta`, never from its filename. + * + * ## Budgets + * + * Three separate bounds, each raisable from the CLI up to a fixed maximum, and + * each failing closed with an error rather than returning a partial answer: + * + * - `metadataBytes`: the head reads that attribute every rollout in the store. + * A `session_meta` line embeds Codex's base instructions (about 20 KiB on + * every rollout measured), so this scales with the store, not the query, + * and is kept apart from the transcript budget: a lookup on a store of + * thousands of rollouts must not spend the budget meant for the transcripts + * it returns. Each file's head read is also capped at 128 KiB. + * - `scanBytes`: the selected transcripts, read whole. + * - `maxEntries`: directory entries visited. + * + * ## Forks and sub-agents + * + * A thread Codex forks or spawns as a sub-agent can carry a copy of its + * parent's history in its own file (codex-rs `ForkPersistence::Copied`, written + * by `record_initial_history` in `core/src/session/mod.rs`; the copy keeps the + * parent's `session_meta`, see `keep_forked_rollout_item` in + * `core/src/agent/control/spawn.rs`). Such a file starts with its own + * `session_meta`, then the parent's, then the copied records, then one + * `thread_settings_applied` event, then the child's own records. The copy + * already lives in the parent's own file, so it is dropped here: + * + * - Paginated history (`history_mode: "paginated"`) numbers every record with + * an `ordinal` and declares `subagent_history_start_ordinal`; records below + * it are inherited, exactly as Codex's own projection treats them + * (`thread-store/src/local/thread_history_materialization.rs`). A paginated + * thread with a `history_base` references its prefix in another file and has + * nothing inherited to drop. + * - Legacy history has no ordinals. The prefix runs from the inherited + * `session_meta` to the `thread_settings_applied` event Codex appends after + * the copy. A legacy copy keeps the parent's own `thread_settings_applied` + * events too, so when more than one candidate follows, the boundary is the + * last one whose preceding messages are still a prefix of the parent's own + * messages, read from the parent's file (once: a family read that selects + * the parent reuses that same read, so no file is charged to `scanBytes` + * twice). Without a readable parent file that cannot be proven: the cut then + * falls at the LAST candidate, so parent text is never attributed to the + * child, and the result says so (`unverifiedPrefixes`, `complete: false`). + * Any failure of the parent's read other than a budget leads here, rather + * than failing the child whose own file is intact. + * + * A later `session_meta` must be the file's own id or a declared ancestor + * (`parent_thread_id`, `forked_from_id`, or the shared root `session_id`, and + * transitively the ancestors those declare); any other id still refuses the + * whole file rather than attribute one conversation's answer to another. + * + * ## Compressed rollouts + * + * Codex's `local_thread_store_compression` feature rewrites cold rollouts to + * `*.jsonl.zst` in place. This reader does not decompress them: a store scan + * counts each one in `compressedFiles` (so `complete` is false and a not-found + * error names them), skipping one whose plain `.jsonl` sibling exists, as + * Codex's own discovery does. A Zstandard file given to `file` is refused by + * its magic number, whatever its name. + * + * ## Live rollouts + * + * A running Codex appends to its rollout. Reads take a snapshot: the file's + * identity (device and inode) must match the attributed one and its size may + * only have grown; everything up to the last complete line is read, and an + * unterminated tail is reported as a partial record. + * + * ## Format + * + * The line shapes and the injected-context rule are shared with the Past + * Sessions scanner through `codex-rollout-format.ts`. + * + * @module local-history + */ +import { constants } from 'node:fs'; +import { isUtf8 } from 'node:buffer'; +import { lstat, open, opendir, stat, type FileHandle } from 'node:fs/promises'; +import { basename, join, resolve } from 'node:path'; + +import { codexMessageFromLine, codexSessionsRoot, isCodexInjectedContext } from './codex-rollout-format.js'; + +const KIB = 1024; +const MIB = 1024 * KIB; +const GIB = 1024 * MIB; + +export const HISTORY_DEFAULT_SCAN_BYTES = 16 * MIB; +export const HISTORY_MAX_SCAN_BYTES = 256 * MIB; +export const HISTORY_DEFAULT_METADATA_BYTES = 1 * GIB; +export const HISTORY_MAX_METADATA_BYTES = 16 * GIB; +export const HISTORY_DEFAULT_MAX_ENTRIES = 65536; +export const HISTORY_MAX_ENTRIES = 1048576; +export const HISTORY_DEFAULT_OUTPUT_BYTES = 8192; +export const HISTORY_MAX_OUTPUT_BYTES = 1 * MIB; +/** Per-file head window for `session_meta`. The line itself runs to about 20 KiB. */ +const METADATA_HEAD_BYTES = 128 * KIB; +/** Read the head in steps, so a typical rollout costs one step past its session_meta line. */ +const METADATA_CHUNK_BYTES = 32 * KIB; +/** + * Codex's compressed rollout suffix (codex-rs `rollout/src/compression.rs`: + * `is_compressed_rollout_path` matches a name ending in `.jsonl.zst`). + */ +const COMPRESSED_ROLLOUT_SUFFIX = '.jsonl.zst'; +/** The Zstandard frame magic number, little-endian on disk. */ +const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]); +/** Codex writes `sessions/YYYY/MM/DD`; this leaves five levels of headroom. */ +const MAX_DIRECTORY_DEPTH = 8; + +const ID = /^[0-9a-f]{8}(?:[0-9a-f-]{0,28})$/i; +const FULL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +type Json = Record<string, unknown>; + +export interface LocalHistoryOptions { + file?: string; + sessionId?: string; + rootSessionId?: string; + sessionsRoot?: string; + topic?: string; + /** Inclusive UTC date, `YYYY-MM-DD`. */ + after?: string; + /** Inclusive UTC date, `YYYY-MM-DD`. */ + before?: string; + scanBytes?: number; + metadataBytes?: number; + maxEntries?: number; +} +export interface HistoryRecord { + rootSessionId: string | null; + sessionId: string; + timestamp: string; + role: 'user' | 'assistant'; + /** Codex's own phase for assistant messages (`commentary`, `final_answer`); null when none was recorded. */ + phase: string | null; + message: string; +} +interface Source { + path: string; + sessionId: string; + parentId?: string; + forkedFromId?: string; + rootId?: string; + /** `subagent_history_start_ordinal`: records below it are inherited. */ + historyStart?: number; + timestamp: string; + /** `dev:ino` of the attributed file. */ + identity: string; + /** Size when attributed; a later read may only see it grow. */ + size: number; +} +export interface LocalHistoryResult { + source: 'codex-jsonl'; + complete: boolean; + sessions: Array<{ sessionId: string; rootSessionId: string | null; parentSessionId?: string; path: string }>; + records: HistoryRecord[]; + scan: { + /** Transcript bytes read (`--scan-bytes`). */ + bytesRead: number; + /** Head bytes read to attribute rollouts (`--metadata-bytes`). */ + metadataBytesRead: number; + entries: number; + malformedRecords: number; + partialRecords: number; + skippedSymlinks: number; + unattributedFiles: number; + /** + * Zstandard-compressed rollouts (`*.jsonl.zst`, Codex's `local_thread_store_compression`) + * this reader cannot open. Their threads are missing from the result. + */ + compressedFiles: number; + unreadableDirectories: number; + /** Inherited fork or sub-agent prefixes dropped (informational). */ + inheritedPrefixes: number; + /** Inherited prefixes that never reached their end marker. */ + incompletePrefixes: number; + /** Legacy prefixes whose end could not be checked against the parent's own file. */ + unverifiedPrefixes: number; + }; +} +interface Budget { + transcriptRemaining: number; + metadataRemaining: number; + entriesRemaining: number; + scan: LocalHistoryResult['scan']; + /** Paths behind `scan.compressedFiles`, for the not-found error. */ + compressedPaths: string[]; +} + +/** A budget ran out: always fatal, never counted. */ +class HistoryBudgetError extends Error {} +/** One store file could not be attributed: fatal for `--file`, counted for a store scan. */ +class UnattributableFileError extends Error {} + +function object(value: unknown): Json { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Json) : {}; +} +function string(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} +function fullId(value: unknown): string | undefined { + const id = string(value); + return id && FULL_ID.test(id) ? id.toLowerCase() : undefined; +} +function fileIdentity(value: { dev: number; ino: number }): string { + return `${value.dev}:${value.ino}`; +} +function validDate(value: string | undefined): boolean { + return ( + value === undefined || + (/^\d{4}-\d{2}-\d{2}$/.test(value) && + Number.isFinite(Date.parse(value)) && + new Date(value).toISOString().slice(0, 10) === value) + ); +} +/** The UTC calendar date of a native timestamp, or undefined when it does not parse. */ +function utcDate(timestamp: string): string | undefined { + const ms = Date.parse(timestamp); + return Number.isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : undefined; +} +function checkBudget(value: number, max: number, flag: string): void { + if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new Error(`Invalid ${flag} (1-${max})`); +} +function validate(options: LocalHistoryOptions): void { + if ([options.file, options.sessionId, options.rootSessionId].filter((v) => v !== undefined).length !== 1) + throw new Error('Select exactly one of --file, --session-id, or --root-session-id'); + if (options.file !== undefined && !options.file) throw new Error('A nonempty file is required'); + if (options.sessionId !== undefined && !ID.test(options.sessionId)) throw new Error('Invalid native session id'); + if (options.rootSessionId !== undefined && !FULL_ID.test(options.rootSessionId)) + throw new Error('Invalid root session id'); + if ( + !validDate(options.after) || + !validDate(options.before) || + (options.after && options.before && options.after > options.before) + ) + throw new Error('Invalid date or date range (use YYYY-MM-DD, UTC)'); + if (options.topic !== undefined && !options.topic.trim()) throw new Error('Invalid empty topic'); + checkBudget(options.scanBytes ?? HISTORY_DEFAULT_SCAN_BYTES, HISTORY_MAX_SCAN_BYTES, '--scan-bytes'); + checkBudget(options.metadataBytes ?? HISTORY_DEFAULT_METADATA_BYTES, HISTORY_MAX_METADATA_BYTES, '--metadata-bytes'); + checkBudget(options.maxEntries ?? HISTORY_DEFAULT_MAX_ENTRIES, HISTORY_MAX_ENTRIES, '--max-entries'); +} +async function regularFile(path: string): Promise<FileHandle> { + // O_NOFOLLOW prevents a final-component link swap between lstat and open. fstat + // validates the opened descriptor, not a previously inspected pathname. + if (!(await lstat(path)).isFile()) throw new Error('History source must be a regular file, not a symlink'); + const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + if (!(await handle.stat()).isFile()) { + await handle.close(); + throw new Error('History source must be a regular file'); + } + return handle; +} +async function readAt(handle: FileHandle, position: number, size: number): Promise<Buffer> { + const buffer = Buffer.alloc(size); + let filled = 0; + while (filled < size) { + const { bytesRead } = await handle.read(buffer, filled, size - filled, position + filled); + if (!bytesRead) break; + filled += bytesRead; + } + return buffer.subarray(0, filled); +} +async function readMetadataChunk(handle: FileHandle, position: number, size: number, budget: Budget) { + if (size > budget.metadataRemaining) + throw new HistoryBudgetError('History metadata byte budget exhausted; raise --metadata-bytes'); + const chunk = await readAt(handle, position, size); + budget.metadataRemaining -= chunk.length; + budget.scan.metadataBytesRead += chunk.length; + return chunk; +} +async function readTranscript(handle: FileHandle, size: number, budget: Budget): Promise<Buffer> { + if (size > budget.transcriptRemaining) + throw new HistoryBudgetError('History scan byte budget exhausted; narrow the selection or raise --scan-bytes'); + const buffer = await readAt(handle, 0, size); + budget.transcriptRemaining -= buffer.length; + budget.scan.bytesRead += buffer.length; + return buffer; +} + +type Identity = Omit<Source, 'identity' | 'size'>; +function metadata(value: unknown, path: string): Identity | undefined { + const entry = object(value); + if (entry.type !== 'session_meta') return undefined; + const p = object(entry.payload); + const sessionId = fullId(p.id) ?? fullId(p.session_id); + if (!sessionId) return undefined; + const spawn = object(object(object(p.source).subagent).thread_spawn); + const forkedFromId = fullId(p.forked_from_id); + // A fork with no spawn parent (a `/fork` in the TUI) still descends from its source thread. + const parentId = + fullId(p.parent_thread_id) ?? fullId(p.parent_session_id) ?? fullId(spawn.parent_thread_id) ?? forkedFromId; + // Codex: "session_id is equal to the root thread's ID". + const declaredRoot = fullId(p.root_session_id) ?? fullId(p.session_id); + const start = p.subagent_history_start_ordinal; + return { + path, + sessionId, + parentId, + forkedFromId, + rootId: declaredRoot && declaredRoot !== sessionId ? declaredRoot : undefined, + historyStart: Number.isSafeInteger(start) && (start as number) >= 0 ? (start as number) : undefined, + timestamp: string(p.timestamp) ?? string(entry.timestamp) ?? '', + }; +} +function sameIdentity(a: Identity, b: Identity): boolean { + return ( + a.sessionId === b.sessionId && + a.parentId === b.parentId && + a.forkedFromId === b.forkedFromId && + a.rootId === b.rootId && + a.historyStart === b.historyStart + ); +} + +/** + * The first `session_meta` within the file's head. Problems with the file + * itself throw {@link UnattributableFileError}; a budget throws + * {@link HistoryBudgetError}; a short file with no metadata yet returns undefined. + */ +async function readMetadata(path: string, budget: Budget): Promise<Source | undefined> { + let handle: FileHandle; + try { + handle = await regularFile(path); + } catch (error) { + throw new UnattributableFileError(error instanceof Error ? error.message : String(error)); + } + try { + const before = await handle.stat(); + const limit = Math.min(before.size, METADATA_HEAD_BYTES); + let buffer = Buffer.alloc(0); + let parsed = 0; + while (buffer.length < limit) { + const chunk = await readMetadataChunk( + handle, + buffer.length, + Math.min(METADATA_CHUNK_BYTES, limit - buffer.length), + budget + ); + if (!chunk.length) break; + if (!buffer.length && chunk.subarray(0, ZSTD_MAGIC.length).equals(ZSTD_MAGIC)) + throw new UnattributableFileError( + 'History source is Zstandard-compressed (a Codex .jsonl.zst rollout); only plain .jsonl is read' + ); + buffer = Buffer.concat([buffer, chunk]); + let end = buffer.indexOf(10, parsed); + while (end >= 0) { + const line = buffer.subarray(parsed, end); + parsed = end + 1; + end = buffer.indexOf(10, parsed); + if (!isUtf8(line)) throw new UnattributableFileError('History metadata contains invalid UTF8'); + let value: unknown; + try { + value = JSON.parse(line.toString('utf8')); + } catch { + continue; + } + const found = metadata(value, path); + if (!found) continue; + // session_meta is written once; a growing file is a live rollout, not a change. + const after = await handle.stat(); + if (fileIdentity(after) !== fileIdentity(before) || after.size < before.size) + throw new UnattributableFileError('History source changed during metadata scan'); + return { ...found, identity: fileIdentity(before), size: before.size }; + } + } + if (buffer.length < limit) throw new UnattributableFileError('History source changed during metadata scan'); + if (before.size > METADATA_HEAD_BYTES) + throw new UnattributableFileError('No session metadata within the first 128 KiB'); + return undefined; + } finally { + await handle.close(); + } +} + +async function findSources(root: string, budget: Budget): Promise<Source[]> { + // The root may itself be a symlink, as the Past Sessions scanner allows + // (`codex-transcript.ts` reads it with readdir, which follows it); links + // BELOW the root are never followed by either reader. + let rootStat; + try { + rootStat = await stat(root); + } catch { + throw new Error(`Sessions root not found: ${root}`); + } + if (!rootStat.isDirectory()) throw new Error('Sessions root must be a directory'); + const sources: Source[] = []; + const visit = async (dir: string, depth: number): Promise<void> => { + if (depth > MAX_DIRECTORY_DEPTH) throw new HistoryBudgetError('History directory scan depth budget exhausted'); + let entries; + try { + entries = await opendir(dir); + } catch { + budget.scan.unreadableDirectories++; + return; + } + for await (const entry of entries) { + if (--budget.entriesRemaining < 0) + throw new HistoryBudgetError('History directory scan entry budget exhausted; raise --max-entries'); + budget.scan.entries++; + const path = join(dir, entry.name); + if (entry.isSymbolicLink()) { + budget.scan.skippedSymlinks++; + continue; + } + if (entry.isDirectory()) { + // Recheck immediately before descending; never intentionally follow links. + const now = await lstat(path).catch(() => undefined); + if (!now?.isDirectory()) { + budget.scan.unreadableDirectories++; + continue; + } + await visit(path, depth + 1); + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + let source: Source | undefined; + try { + source = await readMetadata(path, budget); + } catch (error) { + if (!(error instanceof UnattributableFileError)) throw error; + } + if (source) sources.push(source); + else budget.scan.unattributedFiles++; + } else if (entry.isFile() && entry.name.endsWith(COMPRESSED_ROLLOUT_SUFFIX)) { + // Codex reads the plain sibling instead when both exist (`should_skip_compressed_sibling`), + // and that sibling is visited (or counted) on its own. + const plain = await lstat(path.slice(0, -'.zst'.length)).catch(() => undefined); + if (plain) continue; + budget.scan.compressedFiles++; + budget.compressedPaths.push(path); + } + } + }; + await visit(root, 0); + return sources; +} + +interface Message { + representation: 'event' | 'modern' | 'response'; + role: 'user' | 'assistant'; + message: string; + phase: string | null; + timestamp: string; +} +function message(value: unknown): Message | undefined { + const entry = object(value); + const p = object(entry.payload); + if (entry.isSidechain || p.isSidechain) return undefined; + const found = codexMessageFromLine(value); + if (!found) return undefined; + // Codex context is not a human turn. Real prompts also arrive as events, which are never filtered. + if (found.injectionProne && isCodexInjectedContext(found.text)) return undefined; + return { + role: found.role, + message: found.text, + // Codex records a phase on assistant messages only; a user turn has none. + phase: found.role === 'assistant' ? (found.phase ?? null) : null, + representation: found.shape === 'item' ? 'modern' : found.shape, + timestamp: string(entry.timestamp) ?? string(p.timestamp) ?? '', + }; +} +/** Native events and response_item commonly duplicate one accepted message. */ +function collapse(items: Message[]): Message[] { + const records: Message[] = []; + let representations = new Set<Message['representation']>(); + for (const raw of items) { + const item = { ...raw }; + const previous = records.at(-1); + if ( + previous?.role === item.role && + previous.message === item.message && + !representations.has(item.representation) + ) { + representations.add(item.representation); + if (!previous.timestamp) previous.timestamp = item.timestamp; + if (item.phase === 'final_answer' || previous.phase === null) previous.phase = item.phase ?? previous.phase; + continue; + } + records.push(item); + representations = new Set([item.representation]); + } + return records; +} +function isPrefixOf(prefix: Message[], whole: Message[]): boolean { + return ( + prefix.length <= whole.length && + prefix.every((item, i) => item.role === whole[i].role && item.message === whole[i].message) + ); +} + +interface ReadContext { + /** + * The ancestor's own messages, uncut, or undefined when its file is not in the + * store or could not be read for any reason other than a budget. + */ + ancestorMessages(id: string): Promise<Message[] | undefined>; +} +type Piece = Message | { boundary: true }; +interface LegacyPrefix { + ancestorId: string; + /** Messages and `thread_settings_applied` boundaries after the inherited session_meta. */ + pieces: Piece[]; +} +/** One read of one rollout: every view later needed of it, so no file is read (or charged) twice. */ +interface ParsedSource { + /** Every message in file order, inherited copies included: what a child's copy of this thread holds. */ + raw: Message[]; + /** Messages before any legacy inherited prefix, paginated inherited records already dropped. */ + own: Message[]; + legacy?: LegacyPrefix; +} + +async function parseSource(source: Source, budget: Budget): Promise<ParsedSource> { + const handle = await regularFile(source.path); + let buffer: Buffer; + try { + const now = await handle.stat(); + // A live rollout only grows. A different file or a shorter one is a rewrite. + if (fileIdentity(now) !== source.identity || now.size < source.size) + throw new Error('History source changed after attribution; retry explicitly'); + buffer = await readTranscript(handle, now.size, budget); + if (buffer.length !== now.size) throw new Error('History source shrank while reading; retry explicitly'); + } finally { + await handle.close(); + } + // Snapshot up to the last complete line: a running Codex may be mid-append. + const end = buffer.lastIndexOf(10) + 1; + const complete = buffer.subarray(0, end); + if (buffer.subarray(end).toString('latin1').trim()) budget.scan.partialRecords++; + if (!isUtf8(complete)) throw new Error('History source contains invalid UTF8'); + + const ancestors = new Set([source.parentId, source.forkedFromId, source.rootId].filter((id): id is string => !!id)); + const raw: Message[] = []; + const own: Message[] = []; + let legacy: LegacyPrefix | undefined; + let lastOrdinal = -1; + for (const line of complete.toString('utf8').split('\n')) { + if (!line.trim()) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + budget.scan.malformedRecords++; + continue; + } + const entry = object(parsed); + const ordinal = typeof entry.ordinal === 'number' ? entry.ordinal : undefined; + if (ordinal !== undefined) lastOrdinal = Math.max(lastOrdinal, ordinal); + if (source.historyStart !== undefined && ordinal === undefined) { + // Codex's projection skips a paginated record without an ordinal; so does this, visibly. + budget.scan.malformedRecords++; + continue; + } + const inherited = source.historyStart !== undefined && ordinal! < source.historyStart; + if (entry.type === 'session_meta') { + const declared = metadata(parsed, source.path); + if (declared && declared.sessionId === source.sessionId && sameIdentity(declared, source)) continue; + if (!declared || declared.sessionId === source.sessionId || !ancestors.has(declared.sessionId)) + throw new Error('History source contains conflicting native metadata; attribution refused'); + // An ancestor that was itself a fork carries its own ancestors' metadata in the copy. + for (const id of [declared.parentId, declared.forkedFromId, declared.rootId]) if (id) ancestors.add(id); + if (inherited || legacy) continue; + budget.scan.inheritedPrefixes++; + legacy = { ancestorId: declared.sessionId, pieces: [] }; + continue; + } + if (legacy && entry.type === 'event_msg' && object(entry.payload).type === 'thread_settings_applied') { + if (!inherited) legacy.pieces.push({ boundary: true }); + continue; + } + const item = message(parsed); + if (!item) continue; + raw.push(item); + if (inherited) continue; + if (legacy) legacy.pieces.push(item); + else own.push(item); + } + if (source.historyStart !== undefined && source.historyStart > 0) { + budget.scan.inheritedPrefixes++; + // Initialization died while copying the prefix: Codex refuses to resume such a file. + if (lastOrdinal < source.historyStart - 1) budget.scan.incompletePrefixes++; + } + return { raw: collapse(raw), own, legacy }; +} + +/** The thread's own messages: its inherited prefix dropped, proven against the parent where one is needed. */ +async function readMessages(parsed: ParsedSource, budget: Budget, context: ReadContext): Promise<Message[]> { + const own = [...parsed.own]; + if (parsed.legacy) own.push(...(await afterLegacyPrefix(parsed.legacy, budget, context))); + return collapse(own); +} + +/** The child's own messages after a legacy inherited prefix. */ +async function afterLegacyPrefix(prefix: LegacyPrefix, budget: Budget, context: ReadContext): Promise<Message[]> { + const boundaries = prefix.pieces.flatMap((piece, i) => ('boundary' in piece ? [i] : [])); + if (!boundaries.length) { + budget.scan.incompletePrefixes++; + return []; + } + const messagesBefore = (index: number) => + collapse(prefix.pieces.slice(0, index).filter((piece): piece is Message => !('boundary' in piece))); + let cut = boundaries[0]; + if (boundaries.length > 1) { + // The last candidate whose preceding messages the parent's own file also holds. + const parent = await context.ancestorMessages(prefix.ancestorId); + const proven = parent ? boundaries.filter((candidate) => isPrefixOf(messagesBefore(candidate), parent)) : []; + if (proven.length) cut = proven.at(-1)!; + else { + // Unprovable: drop up to the last candidate, so parent text is never attributed to the child. + budget.scan.unverifiedPrefixes++; + cut = boundaries.at(-1)!; + } + } + return prefix.pieces.slice(cut + 1).filter((piece): piece is Message => !('boundary' in piece)); +} + +function selectedTurns(messages: Message[], options: LocalHistoryOptions): Message[] { + const turns: Message[][] = []; + for (const item of messages) { + if (item.role === 'user' || !turns.length) turns.push([]); + turns.at(-1)!.push(item); + } + const needle = options.topic?.trim().toLocaleLowerCase(); + return turns + .filter((turn) => { + const dateMatches = + (!options.after && !options.before) || + turn.some((item) => { + const date = utcDate(item.timestamp); + return ( + date !== undefined && + (!options.after || date >= options.after) && + (!options.before || date <= options.before) + ); + }); + return dateMatches && (!needle || turn.some((item) => item.message.toLocaleLowerCase().includes(needle))); + }) + .flat(); +} + +/** Native ancestry is metadata evidence, not the conversation selected by a query. */ +function nativeRoot(source: Source, sources: Source[]): string | null { + const seen = new Set<string>(); + let current = source; + while (true) { + if (seen.has(current.sessionId)) throw new Error('Native history contains cyclic ancestry'); + seen.add(current.sessionId); + if (current.rootId) return current.rootId; + if (!current.parentId) return current.sessionId; + const parents = sources.filter((candidate) => candidate.sessionId === current.parentId); + if (parents.length === 0) return null; + if (parents.length > 1) throw new Error('Native ancestor id is ambiguous'); + current = parents[0]; + } +} + +/** + * The not-found error, naming what the store held but could not attribute. + * Codex names a rollout `rollout-<timestamp>-<thread id>.jsonl[.zst]`, so a + * compressed file whose name carries the wanted id is named outright; the name + * is only a hint here, never identity. + */ +function missingSessionMessage(wanted: string, budget: Budget): string { + const { scan } = budget; + const notes: string[] = []; + if (scan.compressedFiles) { + // `rollout-` (8) + `YYYY-MM-DDTHH-MM-SS` (19) + `-` (1), then the thread id. + const named = budget.compressedPaths.filter((path) => { + const name = basename(path).toLowerCase(); + return name.startsWith('rollout-') && name.slice(28).startsWith(wanted); + }); + const shown = named.slice(0, 3).join(', ') + (named.length > 3 ? ` and ${named.length - 3} more` : ''); + notes.push( + named.length + ? `it may be in compressed rollout ${shown}, which this reader cannot open; read a decompressed copy with --file (zstd -dc <path> > copy.jsonl)` + : `${scan.compressedFiles} compressed .jsonl.zst rollout(s) could not be read` + ); + } + if (scan.unattributedFiles) notes.push(`${scan.unattributedFiles} file(s) could not be attributed`); + return `Native session missing from history store${notes.length ? ` (${notes.join('; ')})` : ''}`; +} + +export async function readLocalHistory(options: LocalHistoryOptions): Promise<LocalHistoryResult> { + validate(options); + const scan: LocalHistoryResult['scan'] = { + bytesRead: 0, + metadataBytesRead: 0, + entries: 0, + malformedRecords: 0, + partialRecords: 0, + skippedSymlinks: 0, + unattributedFiles: 0, + compressedFiles: 0, + unreadableDirectories: 0, + inheritedPrefixes: 0, + incompletePrefixes: 0, + unverifiedPrefixes: 0, + }; + const budget: Budget = { + transcriptRemaining: options.scanBytes ?? HISTORY_DEFAULT_SCAN_BYTES, + metadataRemaining: options.metadataBytes ?? HISTORY_DEFAULT_METADATA_BYTES, + entriesRemaining: options.maxEntries ?? HISTORY_DEFAULT_MAX_ENTRIES, + scan, + compressedPaths: [], + }; + let sources: Source[]; + let attributionSources: Source[]; + if (options.file !== undefined) { + let source: Source | undefined; + try { + source = await readMetadata(resolve(options.file), budget); + } catch (error) { + throw error instanceof UnattributableFileError ? new Error(error.message) : error; + } + if (!source) throw new Error('History source is missing valid native session metadata'); + sources = [source]; + attributionSources = sources; + } else { + const root = options.sessionsRoot ?? codexSessionsRoot(); + const all = await findSources(resolve(root), budget); + const wanted = (options.sessionId ?? options.rootSessionId)!.toLowerCase(); + const matches = all.filter((source) => source.sessionId.startsWith(wanted)); + if (!matches.length) throw new Error(missingSessionMessage(wanted, budget)); + if (matches.length !== 1) throw new Error('Native session id is ambiguous'); + const target = matches[0]; + attributionSources = all; + const queryRootId = target.sessionId; + sources = [target]; + if (options.rootSessionId) { + const selected = new Set([target.sessionId]); + let changed = true; + while (changed) { + changed = false; + for (const source of all) { + if ( + !selected.has(source.sessionId) && + (source.rootId === queryRootId || (source.parentId && selected.has(source.parentId))) + ) { + selected.add(source.sessionId); + sources.push(source); + changed = true; + } + } + } + for (const source of sources) { + if (all.filter((other) => other.sessionId === source.sessionId).length !== 1) + throw new Error('Native family contains ambiguous duplicated session ids'); + } + } + } + sources.sort((a, b) => a.timestamp.localeCompare(b.timestamp) || a.sessionId.localeCompare(b.sessionId)); + const roots = new Map(sources.map((source) => [source.sessionId, nativeRoot(source, attributionSources)])); + // Each selected file is read once, whether the main loop or a child's boundary proof asks first. + const parsedCache = new Map<Source, Promise<ParsedSource>>(); + const parse = (source: Source) => { + let parsed = parsedCache.get(source); + if (!parsed) { + parsed = parseSource(source, budget); + parsedCache.set(source, parsed); + } + return parsed; + }; + const context: ReadContext = { + // Raw messages need no boundary proof of their own, so this never recurses. + async ancestorMessages(id) { + const candidates = attributionSources.filter((source) => source.sessionId === id); + if (candidates.length !== 1) return undefined; + try { + return (await parse(candidates[0])).raw; + } catch (error) { + // A budget always fails the read; any other failure only leaves the boundary unproven. + if (error instanceof HistoryBudgetError) throw error; + return undefined; + } + }, + }; + const records: HistoryRecord[] = []; + for (const source of sources) { + records.push( + ...selectedTurns(await readMessages(await parse(source), budget, context), options).map((item) => ({ + rootSessionId: roots.get(source.sessionId) ?? null, + sessionId: source.sessionId, + role: item.role, + message: item.message, + phase: item.phase, + timestamp: item.timestamp, + })) + ); + } + if (!records.length) throw new Error('No matching user/assistant evidence found'); + return { + source: 'codex-jsonl', + complete: + scan.malformedRecords === 0 && + scan.partialRecords === 0 && + scan.skippedSymlinks === 0 && + scan.unattributedFiles === 0 && + scan.compressedFiles === 0 && + scan.unreadableDirectories === 0 && + scan.incompletePrefixes === 0 && + scan.unverifiedPrefixes === 0 && + [...roots.values()].every((root) => root !== null), + sessions: sources.map((source) => ({ + sessionId: source.sessionId, + rootSessionId: roots.get(source.sessionId) ?? null, + ...(source.parentId ? { parentSessionId: source.parentId } : {}), + path: source.path, + })), + records, + scan, + }; +} +/** The `--max-bytes` check, exported so the CLI can make it before any file is read. */ +export function validateOutputBytes(maxBytes: number): void { + checkBudget(maxBytes, HISTORY_MAX_OUTPUT_BYTES, '--max-bytes'); +} +/** Output never splits a JSON document, UTF8 character, record, or selected turn. */ +export function formatLocalHistory( + result: LocalHistoryResult, + format: 'json' | 'text' = 'json', + maxBytes = HISTORY_DEFAULT_OUTPUT_BYTES +): string { + validateOutputBytes(maxBytes); + const body = + format === 'json' + ? JSON.stringify(result) + : [ + `source=${result.source} complete=${result.complete} scannedBytes=${result.scan.bytesRead} metadataBytes=${result.scan.metadataBytesRead}`, + ...result.records.map( + (row) => + `[root=${row.rootSessionId} session=${row.sessionId} ${row.timestamp} ${row.role}${row.phase ? ` ${row.phase}` : ''}]\n${row.message}` + ), + ].join('\n\n'); + if (Buffer.byteLength(body) + 1 > maxBytes) + throw new Error('History output byte budget exceeded; narrow filters or raise --max-bytes'); + return body; +} diff --git a/src/mcp-server.ts b/src/mcp-server.ts index c79b2b4a..7d9a1b81 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -38,6 +38,7 @@ import { buildSendBody, composerReadyMark, describeFailure, + describeRequestError, httpRequest, inputRefusal, isSelfSession, @@ -497,7 +498,7 @@ export function createToolRunner(deps: ToolRunnerDeps) { } catch (err) { if (signal?.aborted) throw err; throw new ToolError( - `Cannot reach Codeman at ${ctx.apiUrl}: ${getErrorMessage(err)}. Check that it is running. If every call ` + + `Cannot reach Codeman at ${ctx.apiUrl}: ${describeRequestError(err)}. Check that it is running. If every call ` + 'fails at once, a sandbox around this MCP server may be blocking network access to this machine.' ); } diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 0f37758f..5853e12e 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -271,7 +271,7 @@ export interface PaneCaptureOptions { * - `sessionKilled` (data: { sessionId: string }) - Session terminated * - `sessionDied` (data: { sessionId: string }) - Session died unexpectedly * - `statsUpdated` (sessions: MuxSessionWithStats[]) - Stats refreshed - * - `paneExitsUpdated` () - A pane read finished; ask `getPaneExit()` per session + * - `paneExitsUpdated` () - A pane read finished; ask `getPaneExit()` (and `getPaneCurrentPath()`) per session */ export interface TerminalMultiplexer extends EventEmitter { /** Which backend this instance uses */ @@ -314,9 +314,10 @@ export interface TerminalMultiplexer extends EventEmitter { // ========== Input ========== /** - * Send input to a session via tmux send-keys. + * Send input to a session via tmux send-keys. The optional internal Enter-only + * guard runs synchronously immediately before transport dispatch. */ - sendInput(sessionId: string, input: string): Promise<boolean>; + sendInput(sessionId: string, input: string, options?: { beforeDispatch?: () => boolean }): Promise<boolean>; // ========== Metadata ========== @@ -395,6 +396,16 @@ export interface TerminalMultiplexer extends EventEmitter { */ getPaneExit?(muxName: string): PaneExit | undefined; + /** + * The directory the session's pane was in at the last pane read + * (`#{pane_current_path}`), or `undefined` when unknown. Read from the same + * batched watcher as {@link getPaneExit}, so it costs no tmux call. A relative + * path printed in the terminal resolves against it; it is never a + * confinement root. A multiplexer without it leaves relative paths resolving + * against `workingDir`. + */ + getPaneCurrentPath?(muxName: string): string | undefined; + /** * How many authoritative pane reads have agreed on the exit `getPaneExit()` * reports, or 0 when it reports none. The exited-agent sweep closes a session diff --git a/src/prompt-cache-keepwarm.ts b/src/prompt-cache-keepwarm.ts index 2dd5e754..40080c34 100644 --- a/src/prompt-cache-keepwarm.ts +++ b/src/prompt-cache-keepwarm.ts @@ -338,9 +338,11 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler { try { settings = await this.readSettings(); } catch (err) { - // A read that lands mid-write is retried. A settings.json that stays broken (a hand edit - // with a trailing comma) fails every read until it is fixed, so it is logged once rather - // than once a minute per session, and only a session that could nudge retries. + // A failed read is retried (settings.json is replaced atomically, so a read never sees a + // half-written file; a failure is a read error or a file that does not parse). A settings.json + // that stays broken (a hand edit with a trailing comma) fails every read until it is fixed, so + // it is logged once rather than once a minute per session, and only a session that could + // nudge retries. const message = err instanceof Error ? err.message : String(err); if (message !== this.settingsFailure) { this.settingsFailure = message; diff --git a/src/ralph-fix-plan-watcher.ts b/src/ralph-fix-plan-watcher.ts index 17396fde..5347c107 100644 --- a/src/ralph-fix-plan-watcher.ts +++ b/src/ralph-fix-plan-watcher.ts @@ -203,6 +203,9 @@ export class RalphFixPlanWatcher extends EventEmitter { /** Path to the @fix_plan.md file being watched */ private _fixPlanPath: string | null = null; + /** The path is on a REMOTE host (`setRemoteWorkingDir`): never read or watch it locally. */ + private _remote = false; + /** File watcher for @fix_plan.md */ private _fixPlanWatcher: FSWatcher | null = null; @@ -238,6 +241,7 @@ export class RalphFixPlanWatcher extends EventEmitter { * @param workingDir - The session's working directory */ setWorkingDir(workingDir: string): void { + this._remote = false; this._workingDir = workingDir; this._fixPlanPath = join(workingDir, '@fix_plan.md'); @@ -248,12 +252,28 @@ export class RalphFixPlanWatcher extends EventEmitter { this.startWatchingFixPlan(); } + /** + * Record a REMOTE session's working directory (a path on the remote host) without reading or + * watching it: the local disk holds nothing of it. The fix plan stays authoritative exactly as for a + * local session, so the output-based completion guessers stay off (they are off for every session + * with a working directory); the file reaches the tracker only through an explicit import + * (`POST .../fix-plan/read`, which reads it over ssh). + * @param workingDir - The session's working directory on the remote host + */ + setRemoteWorkingDir(workingDir: string): void { + this.stopWatchingFixPlan(); + this._remote = true; + this._workingDir = workingDir; + this._fixPlanPath = join(workingDir, '@fix_plan.md'); + } + /** * Load @fix_plan.md from disk if it exists. - * Called on initialization and when file changes are detected. + * Called on initialization and when file changes are detected. A remote path is never read here: + * the local disk would hold a missing path or an unrelated same-named file. */ async loadFixPlanFromDisk(): Promise<number> { - if (!this._fixPlanPath) return 0; + if (!this._fixPlanPath || this._remote) return 0; try { if (!existsSync(this._fixPlanPath)) { @@ -281,7 +301,7 @@ export class RalphFixPlanWatcher extends EventEmitter { * Reloads todos when the file is modified. */ private startWatchingFixPlan(): void { - if (!this._fixPlanPath || !this._workingDir) return; + if (!this._fixPlanPath || !this._workingDir || this._remote) return; // Stop existing watcher if any this.stopWatchingFixPlan(); diff --git a/src/ralph-tracker.ts b/src/ralph-tracker.ts index 9fc6a7c8..40f25158 100644 --- a/src/ralph-tracker.ts +++ b/src/ralph-tracker.ts @@ -717,6 +717,15 @@ export class RalphTracker extends EventEmitter { this.fixPlanWatcher.setWorkingDir(workingDir); } + /** + * Record a remote session's working directory without touching the local disk (see + * `RalphFixPlanWatcher.setRemoteWorkingDir`): the fix plan stays authoritative, as for a local session. + * @param workingDir - The session's working directory on the remote host + */ + setRemoteWorkingDir(workingDir: string): void { + this.fixPlanWatcher.setRemoteWorkingDir(workingDir); + } + /** * Load @fix_plan.md from disk if it exists. */ diff --git a/src/session-cli-registry-bridge.ts b/src/session-cli-registry-bridge.ts index 0c82c4bd..c5b1aa7c 100644 --- a/src/session-cli-registry-bridge.ts +++ b/src/session-cli-registry-bridge.ts @@ -17,6 +17,7 @@ * @module session-cli-registry-bridge */ +import { getDiscordStartupHint } from './discord-bridge.js'; import type { CliEntry } from './config/cli-registry/types.js'; import { renderLaunch, type EngineValues, type ParamValues } from './config/cli-registry/argv.js'; import { matchesPattern } from './config/cli-registry/patterns.js'; @@ -248,6 +249,10 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri engineValues.effortSettingsJson = JSON.stringify(settingsObj); } + // The Discord bridge's startup hint: undefined (so the flag is omitted) unless the bridge and its + // `startupHint` setting are on. Claude-only in practice, like the settings object above. + engineValues.appendSystemPrompt = getDiscordStartupHint(); + // Preserves buildSpawnCommand's original fallback exactly: an EXPLICIT `undefined` probes // the local claude CLI (getClaudeCliVersion, null under vitest); an explicit `null` means // "known to be unresolvable" and must not probe. The probe only ever runs from diff --git a/src/session.ts b/src/session.ts index 1cb18fe9..c91a70d5 100644 --- a/src/session.ts +++ b/src/session.ts @@ -30,6 +30,7 @@ import { EventEmitter } from 'node:events'; import { basename } from 'node:path'; +import { realpath } from 'node:fs/promises'; import { execSync, execFileSync } from 'node:child_process'; import { v4 as uuidv4 } from 'uuid'; import * as pty from 'node-pty'; @@ -68,8 +69,11 @@ import { type StatusLineExporter, type KeepWarmStatus, } from './types.js'; +import type { GitFetchStatus } from './git-fetch.js'; import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js'; import { claudeTranscriptExists } from './utils/claude-transcript.js'; +import { probePathKind } from './utils/bounded-path-probe.js'; +import { PATH_PROBE_TIMEOUT_MS, WORKSPACE_REALPATH_RETRY_MS } from './config/path-probe.js'; import { matchesPattern } from './config/cli-registry/patterns.js'; import { probeDockerCliVersion } from './docker-hosts.js'; import { probeRemoteCliVersion } from './remote-hosts.js'; @@ -129,6 +133,14 @@ import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js'; import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; import { getCli } from './config/cli-registry/registry.js'; import { SubmitVerifier } from './session-submit-verifier.js'; +import { + guardedComposerChrome, + promptSha256, + readGuardedSnapshotDetails, + type GuardedInputSnapshot, +} from './agent-staging.js'; +import { isKeyboardTerminalInput } from './terminal-input-classifier.js'; +import { stripAnsi } from './utils/regex-patterns.js'; import { KEEP_WARM_NUDGE } from './prompt-cache-keepwarm.js'; import { compileVersionRegex } from './config/cli-registry/patterns.js'; import { resolveSessionCliVersion } from './utils/cli-resolver.js'; @@ -389,6 +401,21 @@ const DOCKER_CLI_VERSION_PROBE_DELAY_MS = 3000; /** Delay before the over-ssh Claude CLI version probe (keeps session start off the ssh round-trip). */ const REMOTE_CLI_VERSION_PROBE_DELAY_MS = 3000; +/** Whether absolute `path` is `root` or lies under it (a lexical compare). */ +function isSameOrUnder(path: string, root: string): boolean { + return path === root || path.startsWith(root === '/' ? '/' : `${root}/`); +} + +/** + * Rewrite `path` from under `from` to the same place under `to`; a path not at or + * under `from` comes back unchanged. Exported for unit testing. + */ +export function rebasePath(path: string, from: string, to: string): string { + if (path === from) return to; + if (path.startsWith(`${from}/`)) return to + path.slice(from.length); + return path; +} + /** * Ask tmux for the current window geometry of `muxName` so a re-attaching PTY * client can spawn at the same size and avoid the resize-flicker / scrollback @@ -457,6 +484,17 @@ export interface ClaudeMessage { duration_ms?: number; } +/** + * A write that a guarded submit's lease could not hold, because the per-lease hold + * queue (`Session.MAX_GUARDED_HELD_INPUTS`) was full. Nothing was written; retry it. + */ +export class GuardedInputBusyError extends Error { + constructor() { + super('A guarded submit is in progress on this session; nothing was written. Retry the input.'); + this.name = 'GuardedInputBusyError'; + } +} + /** * Whether a programmatic write leaves the keep-alive turn running: the nudge itself, or a bare * Enter (which submits nothing new). Anything else carrying an Enter starts a turn of its own. @@ -614,6 +652,12 @@ export class Session extends EventEmitter { private _keepWarmEnabled: boolean | undefined; /** Last status the keep-warm service published for this session; ephemeral. */ private _keepWarm: KeepWarmStatus | null = null; + /** Git fetch on an interval for this session; undefined = follow the app-wide default. Persisted. */ + private _gitFetchEnabled: boolean | undefined; + /** Seconds between this session's fetch runs; undefined = follow the app-wide default. Persisted. */ + private _gitFetchIntervalSeconds: number | undefined; + /** Last status the fetch scheduler published for this session; ephemeral. */ + private _gitFetch: GitFetchStatus | null = null; /** Until when the current turn counts as Codeman's own keep-alive (hooks open pre-acknowledged). */ private _keepAliveTurnUntil = 0; /** Last keystroke a person sent through a `fromUser` write. */ @@ -717,6 +761,21 @@ export class Session extends EventEmitter { * rendered as "alive". */ private _paneExit: PaneExit | null = null; + /** + * The pane's live directory, from the pane-exit watcher's read. Runtime-only: + * serialized by toState() but never restored by the constructor. + */ + private _paneCwd: string | undefined; + /** + * `workingDir` with symlinks resolved, so a pane reading (tmux reads the + * process cwd, always the resolved path) can be put back into `workingDir`'s + * spelling. `undefined` until the first resolution settles; `null` when there + * is nothing to translate (no symlink in the path) or it could not be resolved. + */ + private _workspaceRealPath: string | null | undefined; + private _workspaceRealPathPending = false; + /** When a resolution that met an unreachable folder may be tried again (0 = never). */ + private _workspaceRealPathRetryAt = 0; /** * How many starts, attaches or relaunches are running for this session's * pane. While one is, a dead-pane reading may describe a pane that is being @@ -926,6 +985,10 @@ export class Session extends EventEmitter { lastRealPromptAt?: number; /** Restored prompt-cache keep-warm choice (see `keepWarmEnabled`). */ keepWarmEnabled?: boolean; + /** Restored Git fetch override for this session (see `gitFetchEnabled`). */ + gitFetchEnabled?: boolean; + /** Restored Git fetch interval for this session (see `gitFetchIntervalSeconds`). */ + gitFetchIntervalSeconds?: number; /** Restored conversation chain, oldest first (see `claudeSessionChain`). */ claudeSessionChain?: string[]; /** Restored agent-exit observation for this session's pane (see `paneExit`). */ @@ -1015,6 +1078,10 @@ export class Session extends EventEmitter { // from before the field has only `lastSubmitAt`, and every submit then was a real one. this._lastRealPromptAt = config.lastRealPromptAt ?? config.lastSubmitAt ?? 0; this._keepWarmEnabled = config.keepWarmEnabled; + // Restored, like the keep-warm choice: the interval is the user's decision about their repositories + // and outlives the run that made it, so a restart keeps it rather than falling back to the app default. + this._gitFetchEnabled = config.gitFetchEnabled; + this._gitFetchIntervalSeconds = config.gitFetchIntervalSeconds; // Restored chain: its tail is the conversation the CLI was actually on when // the server stopped, which outranks the launch id seeded just above. The // FIRST-HAND flag is deliberately NOT restored — a persisted claim is not a @@ -1296,6 +1363,17 @@ export class Session extends EventEmitter { return value && value.trim() ? value.trim() : undefined; } + /** + * One env var from this session's overrides, for internal path resolution only + * (custom-model injection linking an isolated config dir back to a path that must + * survive lives in). Single-key lookup by design, like `deepSeekHomeOverride`: the + * map can hold provider credentials and stays off the public `SessionState`. + */ + envOverrideValue(key: string): string | undefined { + const value = this._envOverrides?.[key]; + return value && value.trim() ? value.trim() : undefined; + } + /** Owning username in multi-user mode, else undefined. */ get owner(): string | undefined { return this._owner; @@ -1308,6 +1386,7 @@ export class Session extends EventEmitter { /** Set the owning username (used by recovery to restore ownership). */ set owner(username: string | undefined) { + if (this._owner !== username) this._guardedPaneRevision++; this._owner = username; } @@ -1412,11 +1491,13 @@ export class Session extends EventEmitter { * tmux session whose record is about to be deleted (Ark0N/Codeman#446). */ markClosing(closing: boolean): void { + if (this._closing !== closing) this._guardedPaneRevision++; this._closing = closing; } /** Run one pane start, attach or relaunch with {@link paneLifecycleInFlight} raised. */ private async _withPaneLifecycle<T>(op: () => Promise<T>): Promise<T> { + this._guardedPaneRevision++; this._paneLifecycleOps++; try { return await op(); @@ -1443,10 +1524,104 @@ export class Session extends EventEmitter { * Ark0N/Codeman#446 review; both now persist on their success path. */ private clearPaneExitForNewPane(): void { + this._guardedPaneRevision++; this.setPaneExit(undefined); if (this._muxSession) this._mux?.clearPaneExit?.(this._muxSession.muxName); } + /** The pane's live working directory, when the pane read has reported one. */ + get paneCwd(): string | undefined { + return this._paneCwd; + } + + /** + * Record the pane's live directory. Returns whether it CHANGED, so the caller + * broadcasts only on a real transition. + * + * Scoped by {@link paneExitApplies}, the same rule as the exit reading: a + * remote session's local pane runs the ssh client and a docker case's runs a + * `docker exec`, so their pane directory is a host directory the agent was + * never in, and a direct-PTY or rediscovered session has no pane Codeman can + * vouch for. A relative value is refused rather than stored. + * + * tmux reads the pane's directory from the process cwd, which is always the + * resolved path, while `workingDir` keeps the spelling the case was created + * with. A workspace reached through a symlink would otherwise read as outside + * itself, and the browser's preview routing (a lexical compare against + * `workingDir`) would send every relative link to the attachment route. So a + * reading at or under the workspace's resolved path is written back in + * `workingDir`'s spelling. Until that resolution has settled, only a reading + * lexically inside `workingDir` is accepted. + */ + setPaneCwd(next: string | undefined): boolean { + let resolved = this.paneExitApplies && next?.startsWith('/') ? next : undefined; + if (resolved !== undefined) { + if ( + this._workspaceRealPath === undefined || + (this._workspaceRealPathRetryAt !== 0 && Date.now() >= this._workspaceRealPathRetryAt) + ) { + this._resolveWorkspaceRealPath(); + } + if (this._workspaceRealPath === undefined) { + // The workspace's resolved path is not known yet. A reading outside + // `workingDir` could be the workspace itself seen through its symlink, so + // it waits for the next tick rather than mis-route links for one. + if (!isSameOrUnder(resolved, this._workspaceSpelling())) return false; + } else if (this._workspaceRealPath !== null) { + resolved = rebasePath(resolved, this._workspaceRealPath, this._workspaceSpelling()); + } + } + if (resolved === this._paneCwd) return false; + this._paneCwd = resolved; + return true; + } + + /** `workingDir` without a trailing separator (the root stays `/`). */ + private _workspaceSpelling(): string { + return this.workingDir.length > 1 ? this.workingDir.replace(/\/+$/, '') : this.workingDir; + } + + /** + * Resolve `workingDir`'s symlinks in the background for {@link setPaneCwd}. + * + * Bounded: the folder can sit on a network mount that stopped answering, so it + * is probed with `probePathKind()` first and the `realpath()` is raced against + * the same timeout. An `unknown` probe, a failed or slow `realpath()` leave no + * translation and allow one more try after `WORKSPACE_REALPATH_RETRY_MS`; a + * folder that is absent or not a directory is settled for good. + */ + private _resolveWorkspaceRealPath(): void { + if (this._workspaceRealPathPending) return; + this._workspaceRealPathPending = true; + const spelling = this._workspaceSpelling(); + void (async () => { + let real: string | null = null; + let retry = false; + try { + const kind = await probePathKind(spelling); + if (kind === 'unknown') { + retry = true; + } else if (kind === 'directory') { + let timer: ReturnType<typeof setTimeout> | undefined; + const answer = await Promise.race([ + realpath(spelling).catch(() => undefined), + new Promise<undefined>((resolveTimeout) => { + timer = setTimeout(() => resolveTimeout(undefined), PATH_PROBE_TIMEOUT_MS); + timer.unref?.(); + }), + ]).finally(() => clearTimeout(timer)); + if (answer === undefined) retry = true; + else if (answer !== spelling && answer !== '/') real = answer; + } + } catch { + retry = true; + } + this._workspaceRealPath = real; + this._workspaceRealPathRetryAt = retry ? Date.now() + WORKSPACE_REALPATH_RETRY_MS : 0; + this._workspaceRealPathPending = false; + })(); + } + /** * Record what the mux layer observed of this pane's agent, and say whether * that changed the answer. The caller persists and broadcasts on a true. @@ -1579,6 +1754,37 @@ export class Session extends EventEmitter { return changed; } + /** Git fetch override for this session; undefined means the app-wide setting applies. */ + get gitFetchEnabled(): boolean | undefined { + return this._gitFetchEnabled; + } + + /** Interval override for this session; undefined means the app-wide setting applies. */ + get gitFetchIntervalSeconds(): number | undefined { + return this._gitFetchIntervalSeconds; + } + + /** + * Set the session's override. The Session Options rows save on their own, so a caller passes what it has + * and leaves the other half as it was; `undefined` on either means the app-wide setting applies again. + */ + setGitFetchSettings(enabled: boolean | undefined, intervalSeconds: number | undefined): void { + this._gitFetchEnabled = enabled; + this._gitFetchIntervalSeconds = intervalSeconds; + } + + /** What the fetch scheduler last reported for this session, or null before its first run. */ + get gitFetch(): GitFetchStatus | null { + return this._gitFetch; + } + + /** Replace the fetch status; true when it differs from the previous one. */ + setGitFetchStatus(status: GitFetchStatus): boolean { + const changed = JSON.stringify(status) !== JSON.stringify(this._gitFetch); + this._gitFetch = status; + return changed; + } + /** * True while the running turn is Codeman's own keep-alive nudge: its stop hook and idle * prompt open pre-acknowledged, and its submit moves `lastSubmitAt` (it IS a submit, and the @@ -2093,6 +2299,7 @@ export class Session extends EventEmitter { pid: this.pid, status: this._status, workingDir: this.workingDir, + paneCwd: this._paneCwd, remote: this._remote, docker: this._docker, owner: this._owner, @@ -2116,6 +2323,9 @@ export class Session extends EventEmitter { statusLineExporter: this.statusLineExporter, keepWarmEnabled: this._keepWarmEnabled, keepWarm: this._keepWarm ?? undefined, + gitFetchEnabled: this._gitFetchEnabled, + gitFetchIntervalSeconds: this._gitFetchIntervalSeconds, + gitFetch: this._gitFetch ?? undefined, imageWatcherEnabled: this._imageWatcherEnabled, pinned: this._pinned || undefined, pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined, @@ -2620,7 +2830,7 @@ export class Session extends EventEmitter { if (entry?.launch.chain !== 'fallback') return options; const resumeIdPattern = entry.launch.params?.resumeId; - const configDir = this._claudeConfigDir(); + const configDir = this.claudeConfigDir(); const chainTail = this._claudeSessionChain[this._claudeSessionChain.length - 1]; const candidates = [chainTail, options.resumeSessionId, this.id].filter((v): v is string => !!v); for (const candidate of candidates) { @@ -2642,8 +2852,11 @@ export class Session extends EventEmitter { return options; } - /** The session's Claude config dir when it has been relocated (#255), else undefined. */ - private _claudeConfigDir(): string | undefined { + /** + * The session's Claude config dir when it has been relocated (#255), else undefined. + * Public for the edit history reader, which looks for the transcript there. + */ + claudeConfigDir(): string | undefined { // Trimmed like `claudeProjectsDir()` trims the process-wide override: the // envOverrides schema validates keys only, and a whitespace-only value would // otherwise resolve to a relative path and read "no transcript" for everything. @@ -2652,7 +2865,8 @@ export class Session extends EventEmitter { /** * Force the custom-model selection's `launchModel` (pi/omp `custom/<id>`, grok's - * `[model.<name>]` block name) onto the CLI's `model` launch param. Where that param + * `[model.<name>]` block name, codex's bare selected id) onto the CLI's `model` + * launch param. Where that param * lives is registry DATA — the entry's `legacyConfigField` (`piConfig`, `grokConfig`, * ...) or the top-level `model` for an entry that declares none — so this stays a * generic reader rather than a branch per CLI. Applied on the OPTIONS only: the stored @@ -4206,6 +4420,11 @@ export class Session extends EventEmitter { prompt: string, options?: { model?: string; onProgress?: (info: { tokens?: number; status?: string }) => void } ): Promise<{ result: string; cost: number }> { + if (this.guardedInputHeld) { + this._guardedInputRevision++; + throw new Error('Guarded submission in progress'); + } + this._guardedPaneRevision++; return new Promise((resolve, reject) => { if (this.ptyProcess) { reject(new Error('Session already has a running process')); @@ -4715,6 +4934,229 @@ export class Session extends EventEmitter { // Note: checkAutoCompact/checkAutoClear moved to SessionAutoOps (this._autoOps) + private readonly _inspectionInstance = uuidv4(); + private _guardedPaneRevision = 0; + private _guardedGeometryRevision = 0; + private _guardedInputRevision = 0; + private _guardedPendingWrites = 0; + private _guardedSubmitInFlight = false; + /** + * Input that arrived while a guarded submit held the lease, in arrival order. Each + * entry runs when the lease is released; an entry that returns a promise (a mux + * write, a named key) is waited for before the next runs, so nothing overtakes it. + */ + private _guardedHeld: Array<{ run: () => void | Promise<unknown>; bytes: number }> = []; + private _guardedHeldBytes = 0; + /** True while the drain waits for a held asynchronous write to finish. */ + private _guardedHeldDraining = false; + /** Held entries per lease. A lease lasts milliseconds; past this the caller backs off. */ + static readonly MAX_GUARDED_HELD_INPUTS = 256; + /** Held bytes per lease (16 browser frames at the 64 KiB frame cap). */ + static readonly MAX_GUARDED_HELD_BYTES = 16 * 64 * 1024; + /** Ephemeral identity, never persisted or based on a potentially reused pid alone. */ + get inspectionGeneration(): string { + return `${this._inspectionInstance}:${this._guardedPaneRevision}:${this.owner ?? ''}`; + } + /** Staging additionally binds geometry; transcript cursors survive a terminal resize. */ + get guardedInputGeneration(): string { + return `${this.inspectionGeneration}:${this._guardedGeometryRevision}`; + } + get guardedInputPendingWrites(): number { + return this._guardedPendingWrites; + } + /** + * True while a guarded submit holds the input lease (a few milliseconds). Input that + * arrives in that span is held and written after it (see {@link guardedInputHeld}). + */ + get guardedSubmitInFlight(): boolean { + return this._guardedSubmitInFlight; + } + /** + * True while input is being held behind a guarded submit: the lease itself, or held + * input not yet written after it. A write that is not routed through + * {@link holdForGuardedLease} must refuse in this window, or it overtakes held input. + * `write()` is the known exception: it refuses during the lease only, since the drain + * writes held input through it, so a direct `write()` caller (cron's `paste` mode in + * `cron/cron-service.ts`) can still land ahead of input that is draining. + */ + get guardedInputHeld(): boolean { + return this._guardedSubmitInFlight || this._guardedHeld.length > 0 || this._guardedHeldDraining; + } + /** + * Hold input behind a guarded submit's lease instead of refusing it. + * + * `free`: nothing is held, so the caller writes inline as it always did. `held`: `run` + * is queued and runs, in arrival order with every other held input, once the lease is + * released (whatever the submit's outcome). `full`: the per-lease cap is reached and + * nothing was queued; the caller must stop delivering from that source until it can + * resend in order. Refusing instead lost input: the browser's WebSocket path does not + * wait for ACKs, so a refused seq N was overtaken by N+1 and its redelivery was then + * rejected as a duplicate. `data`, when given, is observed as input on arrival, so a + * keystroke held during the lease still moves the input revision at once. + */ + holdForGuardedLease(run: () => void | Promise<unknown>, bytes = 0, data?: string): 'free' | 'held' | 'full' { + if (!this.guardedInputHeld) return 'free'; + if (data !== undefined) this._observeGuardedKeyboardInput(data); + if ( + this._guardedHeld.length >= Session.MAX_GUARDED_HELD_INPUTS || + this._guardedHeldBytes + bytes > Session.MAX_GUARDED_HELD_BYTES + ) + return 'full'; + this._guardedHeld.push({ run, bytes }); + this._guardedHeldBytes += bytes; + return 'held'; + } + /** + * Run an asynchronous write now, or in its turn behind a guarded submit's lease. The + * turn lasts until the write settles, so later held input waits for it. Resolves + * `whenFull` without running anything when the hold queue is full. + */ + private _inGuardedTurn<T>(write: () => Promise<T>, whenFull: () => T): Promise<T> { + if (!this.guardedInputHeld) return write(); + return new Promise<T>((resolve, reject) => { + const held = this.holdForGuardedLease(() => { + const running = write(); + running.then(resolve, reject); + return running; + }); + if (held === 'full') { + try { + resolve(whenFull()); + } catch (err) { + reject(err); + } + } + }); + } + /** Run held input in arrival order until it is all written or a new lease is taken. */ + private _drainGuardedHeld(): void { + while (this._guardedHeld.length > 0 && !this._guardedSubmitInFlight && !this._guardedHeldDraining) { + const entry = this._guardedHeld.shift()!; + this._guardedHeldBytes -= entry.bytes; + let running: void | Promise<unknown>; + try { + running = entry.run(); + } catch (err) { + console.error(`[Session ${this.id}] held input failed after a guarded submit:`, getErrorMessage(err)); + continue; + } + if (running && typeof running.then === 'function') { + this._guardedHeldDraining = true; + void running + .catch(() => {}) + .then(() => { + this._guardedHeldDraining = false; + this._drainGuardedHeld(); + }); + } + } + } + /** Expose only the path selection needed for attributed Codex evidence, never credentials. */ + getCodexHomeOverride(): string | undefined { + return this._envOverrides?.CODEX_HOME || undefined; + } + private _observeGuardedKeyboardInput(data: string): void { + if (data && isKeyboardTerminalInput(data)) this._guardedInputRevision++; + } + guardedInputSnapshot(): GuardedInputSnapshot | null { + return this._readGuardedInputSnapshot(false); + } + private _readGuardedInputSnapshot(allowLease: boolean): GuardedInputSnapshot | null { + if ( + this._isStopped || + this._closing || + this.paneLifecycleInFlight || + this._guardedPendingWrites || + (!allowLease && this.guardedInputHeld) || + this.remote || + this.docker || + this.paneExit || + !this._muxSession + ) + return null; + // Only a CLI whose registry entry describes its composer chrome takes guarded input. + const chrome = guardedComposerChrome(getCli(this.mode)); + if (!chrome) return null; + const capture: import('./mux-interface.js').PaneCaptureOptions = {}; + const screen = this._mux?.capturePaneBuffer?.(this._muxSession.muxName, undefined, capture); + if (!screen) return null; + // tmux's replay has CUP addresses rather than line breaks. Read the SAME + // fresh frame for dialog/work chrome and exact composer/caret proof. + // eslint-disable-next-line no-control-regex + const plain = stripAnsi(screen.replace(/\x1b\[\d+;\d+H/g, '\n')); + const working = this._workingLinePattern(); + working.lastIndex = 0; + if (this._paneShowsBlocked(plain) || working.test(plain) || this._paneAwaitsWorkers(plain)) return null; + const composer = readGuardedSnapshotDetails(screen, chrome); + return composer === null + ? null + : { + generation: this.guardedInputGeneration, + revision: this._guardedInputRevision, + composerSha: promptSha256(composer.composer), + availableColumns: Number.isFinite(capture.capturedGeometry?.cols) + ? capture.capturedGeometry!.cols - composer.prefixWidth + : 0, + }; + } + /** Stage through ordinary delivery, after cancelling any older Enter retry timer. */ + async stageGuardedInput(message: string): Promise<boolean> { + if (/[\r\n]/.test(message)) return false; + if (this._guardedPendingWrites || this.guardedInputHeld) { + this._guardedInputRevision++; + return false; + } + this._submitVerifier?.cancel(); + return this.writeViaMux(message, { fromUser: true }); + } + /** One conditional Enter. An ambiguous acknowledgement is never replayed. */ + async submitGuardedInput(expected: GuardedInputSnapshot): Promise<boolean> { + const matches = (current: GuardedInputSnapshot | null): boolean => + !!current && + current.generation === expected.generation && + current.revision === expected.revision && + current.composerSha === expected.composerSha && + current.availableColumns === expected.availableColumns; + if (!matches(this.guardedInputSnapshot()) || !this._mux || !this._muxSession) return false; + this._guardedSubmitInFlight = true; + let dispatched = false; + try { + const sent = await this._mux.sendInput(this.id, '\r', { + beforeDispatch: () => { + if (!matches(this._readGuardedInputSnapshot(true))) return false; + this._guardedInputRevision++; + dispatched = true; + return true; + }, + }); + if (sent && dispatched) this._emitSubmittedPrompt(this._trackSubmit('\r', { fromUser: true })); + return sent && dispatched; + } finally { + this._guardedSubmitInFlight = false; + this._drainGuardedHeld(); + } + } + /** + * Lease/pending boundary for named keys whose bytes bypass the ordinary write methods. + * During a guarded submit the operation waits for the lease like any other write; + * only a full hold queue refuses it, with {@link GuardedInputBusyError}. + */ + async performAuxiliaryInput(operation: () => Promise<void>): Promise<void> { + this._guardedInputRevision++; + return this._inGuardedTurn( + async () => { + this._guardedPendingWrites++; + try { + await operation(); + } finally { + this._guardedPendingWrites--; + } + }, + () => { + throw new GuardedInputBusyError(); + } + ); + } /** * Sends input directly to the PTY process. * @@ -4734,6 +5176,9 @@ export class Session extends EventEmitter { * input could disappear while the caller believed it had been delivered. */ write(data: string, options: SessionWriteOptions = {}): boolean { + this._observeGuardedKeyboardInput(data); + // The lease only, not the drain, which writes held input through here (see guardedInputHeld). + if (this._guardedSubmitInFlight) return false; const submittedPrompt = this._trackSubmit(data, options); if (!this.ptyProcess) return false; this.ptyProcess.write(data); @@ -4782,6 +5227,7 @@ export class Session extends EventEmitter { * separator. Reports submissions like a write would (a line feed never is one). */ trackUserInput(data: string): void { + this._observeGuardedKeyboardInput(data); if (!this._acceptsPrompts) return; this._emitSubmittedPrompt(this._submittedPromptTracker.feed(data)); } @@ -4803,6 +5249,7 @@ export class Session extends EventEmitter { * their prompt must count as real and alert normally. */ markPromptSubmitted(prompt?: string): void { + this._guardedInputRevision++; if (prompt !== undefined && prompt.trim() !== KEEP_WARM_NUDGE) this._keepAliveTurnUntil = 0; this._stampSubmit(); } @@ -4898,22 +5345,39 @@ export class Session extends EventEmitter { * ``` */ async writeViaMux(data: string, options: SessionWriteOptions = {}): Promise<boolean> { - const submittedPrompt = this._trackSubmit(data, options); - if (this._mux && this._muxSession) { - const sent = await this._mux.sendInput(this.id, data); - if (sent) { - this._emitSubmittedPrompt(submittedPrompt); - this._verifySubmitted(data); + this._observeGuardedKeyboardInput(data); + // A guarded submit's lease lasts for its Enter only: wait for it, in arrival order, + // rather than refusing, so an approval answer or a programmatic prompt is not + // dropped. Without a lease the write starts synchronously, so the pending count + // rises before any await and no lease can be taken while it is in flight. + return this._inGuardedTurn( + () => this._writeViaMuxNow(data, options), + () => false + ); + } + + private async _writeViaMuxNow(data: string, options: SessionWriteOptions): Promise<boolean> { + this._guardedPendingWrites++; + try { + const submittedPrompt = this._trackSubmit(data, options); + if (this._mux && this._muxSession) { + const sent = await this._mux.sendInput(this.id, data); + if (sent) { + this._emitSubmittedPrompt(submittedPrompt); + this._verifySubmitted(data); + } + return sent; } - return sent; + // Fallback to PTY write + if (this.ptyProcess) { + this.ptyProcess.write(data); + this._emitSubmittedPrompt(submittedPrompt); + return true; + } + return false; + } finally { + this._guardedPendingWrites--; } - // Fallback to PTY write - if (this.ptyProcess) { - this.ptyProcess.write(data); - this._emitSubmittedPrompt(submittedPrompt); - return true; - } - return false; } /** @@ -4941,7 +5405,16 @@ export class Session extends EventEmitter { this._isStopped || !this._mux || !this._muxSession ? null : this._mux.capturePaneText?.(this._muxSession.muxName), - sendEnter: () => this._mux?.sendInput(this.id, '\r'), + sendEnter: () => { + // A retry Enter is never held behind a guarded submit: once that submit's + // Enter went out, a re-press would submit whatever is on the composer next. + if (this.guardedInputHeld) { + this._guardedInputRevision++; + this._submitVerifier?.cancel(); + return Promise.resolve(false); + } + return this.writeViaMux('\r'); + }, // ⚠ NO fallback glyph here, unlike the screen-reading probe elsewhere in this file. // Only claude, codex, pi, opencode, omp, gemini and antigravity declare a promptGlyph; the other modes would fall back // to claude's `❯`, which is ALSO starship's default shell prompt (and pure's, and @@ -4970,6 +5443,7 @@ export class Session extends EventEmitter { * `resize()` skipped a real resize that happened to match them. */ private _notePtySpawnGeometry(cols: number, rows: number): void { + this._guardedPaneRevision++; this._ptyCols = cols; this._ptyRows = rows; } @@ -5092,6 +5566,7 @@ export class Session extends EventEmitter { } const dimsChanged = cols !== this._ptyCols || rows !== this._ptyRows; if (this.ptyProcess && (dimsChanged || options.force)) { + this._guardedGeometryRevision++; this._ptyCols = cols; this._ptyRows = rows; if (!IS_TEST_MODE && this._mux && this._muxSession) { @@ -5108,6 +5583,10 @@ export class Session extends EventEmitter { // Legacy method for sending input - wraps runPrompt async sendInput(input: string): Promise<void> { + if (this.guardedInputHeld) { + this._guardedInputRevision++; + throw new Error('Guarded submission in progress'); + } this._status = 'busy'; this._markActivity(true); this.runPrompt(input).catch((err) => { diff --git a/src/terminal-input-classifier.ts b/src/terminal-input-classifier.ts new file mode 100644 index 00000000..34ea2e85 --- /dev/null +++ b/src/terminal-input-classifier.ts @@ -0,0 +1,62 @@ +/** @fileoverview Distinguish complete terminal focus/mouse reports from keyboard-bearing input. */ +const FOCUS_REPORT_LENGTH = 3; +const X10_REPORT_LENGTH = 6; + +function sgrMouseReportLength(rest: string): number { + if (!rest.startsWith('\x1b[<')) return 0; + + let offset = 3; + for (let field = 0; field < 3; field += 1) { + const digitStart = offset; + while (offset < rest.length && rest.charCodeAt(offset) >= 48 && rest.charCodeAt(offset) <= 57) { + offset += 1; + } + if (offset === digitStart) return 0; + + if (field < 2) { + if (rest[offset] !== ';') return 0; + offset += 1; + } + } + + return rest[offset] === 'M' || rest[offset] === 'm' ? offset + 1 : 0; +} + +function x10MouseReportLength(rest: string): number { + if (!rest.startsWith('\x1b[M') || rest.length < X10_REPORT_LENGTH) return 0; + + const button = rest.charCodeAt(3); + const x = rest.charCodeAt(4); + const y = rest.charCodeAt(5); + if (button < 32 || button > 255 || x < 33 || x > 255 || y < 33 || y > 255) return 0; + + return X10_REPORT_LENGTH; +} + +function terminalReportLengthAt(data: string, offset: number): number { + const rest = data.slice(offset); + if (rest.startsWith('\x1b[I') || rest.startsWith('\x1b[O')) return FOCUS_REPORT_LENGTH; + + const sgrLength = sgrMouseReportLength(rest); + if (sgrLength > 0) return sgrLength; + + return x10MouseReportLength(rest); +} + +/** + * Whether terminal input contains any keyboard-bearing bytes. + * + * Returns false only when every byte is consumed as an allowlisted mouse or + * focus report. Unknown, malformed, mixed, and empty input fails closed. + */ +export function isKeyboardTerminalInput(data: string): boolean { + if (data.length === 0) return true; + + let offset = 0; + while (offset < data.length) { + const reportLength = terminalReportLengthAt(data, offset); + if (reportLength === 0) return true; + offset += reportLength; + } + return false; +} diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 15888850..fc944d14 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -422,6 +422,46 @@ export function derivePaneExits(rows: PaneRow[], now: number): Map<string, PaneE return exits; } +/** + * Decide, from every pane tmux listed, which directory each tmux session's pane + * is in now (`#{pane_current_path}`). Exported for unit testing. + * + * This is what a relative path printed in the terminal is relative TO: a + * session's `workingDir` is fixed at create time and never follows a `cd`. + * + * - Only sessions with EXACTLY ONE pane answer, for the reason + * {@link derivePaneExits} gives: a hand-split session has no single "the" + * pane to speak for it. + * - Only absolute paths count. A blank field is a tmux that could not read the + * process cwd: usually a pane whose program exited under `remain-on-exit` + * (tmux 3.4 then reports it blank on every read), otherwise a read that + * failed that tick. Either way the directory is unknown, not gone, so a + * single-pane session with a blank field keeps its entry from `previous`. + * A session split into several panes, or one the read no longer lists, gets + * no entry: those are authoritative absences, not unanswered questions. + * + * The launch directory is NOT filtered here (unlike the rediscovery path): a + * remote or docker pane that never leaves it is refused by `Session.setPaneCwd()`, + * and a local pane reporting it really is there. + */ +export function derivePaneCurrentPaths( + rows: PaneRow[], + previous: ReadonlyMap<string, string> = new Map() +): Map<string, string> { + const panesPerSession = new Map<string, number>(); + for (const row of rows) { + panesPerSession.set(row.sessionName, (panesPerSession.get(row.sessionName) ?? 0) + 1); + } + const paths = new Map<string, string>(); + for (const row of rows) { + if (panesPerSession.get(row.sessionName) !== 1) continue; + const path = row.currentPath ?? previous.get(row.sessionName); + if (!path || !path.startsWith('/')) continue; + paths.set(row.sessionName, path); + } + return paths; +} + /** * Could any of these tmux sessions ever produce a pane-exit answer? Exported * for unit testing. @@ -984,6 +1024,20 @@ export function formatCursorRestore(geometry: PaneCursorGeometry): string { return `${up > 0 ? `\x1b[${up}A` : ''}\r${right > 0 ? `\x1b[${right}C` : ''}`; } +/** + * Split the output of `capture-pane -p \; display-message -p '<cursor format>'` + * into the captured rows and the cursor line, which tmux prints last. Null unless + * that last line has the cursor query's shape (four leading integers), so a query + * that printed nothing can never have the frame's own last row taken for it. + */ +export function splitFrameAndCursor(output: string): { frame: string; cursor: string } | null { + const body = output.endsWith('\n') ? output.slice(0, -1) : output; + const split = body.lastIndexOf('\n'); + const cursor = body.slice(split + 1); + if (!/^\d+ \d+ \d+ \d+(?: |$)/.test(cursor)) return null; + return { frame: body.slice(0, split + 1), cursor }; +} + export function formatPaneSnapshot( lines: string[], geometry: { cols: number; rows: number; cursorX: number; cursorY: number } @@ -1991,6 +2045,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * raw pane reading. */ private paneExits: Map<string, TrackedPaneExit> = new Map(); + /** + * muxName -> `#{pane_current_path}` from the last authoritative pane read + * ({@link derivePaneCurrentPaths}). Replaced wholesale by each non-empty read, + * so it is bounded by the panes on the socket. Like {@link paneExits} it holds + * the raw reading; `Session.setPaneCwd()` decides which sessions may use it. + */ + private paneCurrentPaths: Map<string, string> = new Map(); /** The pane-exit watcher's own interval. Runs whether or not stats are on. */ private paneExitInterval: NodeJS.Timeout | null = null; /** @@ -3493,6 +3554,19 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { })); } + /** + * The directory the session's pane was in at the last authoritative pane + * read, or `undefined` when unknown. Costs nothing: it reads the map the + * pane-exit watcher's batched `list-panes -a` fills, so no tmux call is made + * per click. A failed or empty read keeps the previous answers, and so does + * a blank `#{pane_current_path}` on a single-pane session. After a successful + * read, `undefined` means that read listed no single directory for the + * session: it was split into several panes or is no longer on the socket. + */ + getPaneCurrentPath(muxName: string): string | undefined { + return this.paneCurrentPaths.get(muxName); + } + /** * What the last pane read saw of this tmux session's agent. `undefined` is * the UNKNOWN answer and must never be rendered as "alive": it covers a pane @@ -3554,6 +3628,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { this.paneExitReadInFlight = false; } if (rows.length === 0) return; + // The directory reading rides the same read. It is applied before the + // generation check below: a respawn invalidates an exit observation, but the + // pane's directory at read time is still the best answer there is. A blank + // field on a single-pane session carries the previous answer forward. + this.paneCurrentPaths = derivePaneCurrentPaths(rows, this.paneCurrentPaths); // A pane was respawned or killed while this read was out, so what it saw is // already history. Dropping it is what stops a freshly respawned pane from // being republished as exited. @@ -3983,7 +4062,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * - `Enter` key is sent as a SEPARATE tmux invocation after a small delay * - Ink (Claude CLI) needs text and Enter split to avoid treating Enter as a newline */ - async sendInput(sessionId: string, input: string): Promise<boolean> { + async sendInput(sessionId: string, input: string, options?: { beforeDispatch?: () => boolean }): Promise<boolean> { + if (options?.beforeDispatch && input !== '\r') return false; const session = this.sessions.get(sessionId); if (!session) { console.error( @@ -3996,7 +4076,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // inputRunner() so a test subclass can record what WOULD reach tmux. const run = this.inputRunner(); if (!run) { - return true; + return options?.beforeDispatch?.() ?? true; } console.log( @@ -4050,7 +4130,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { `${this.tmux()} send-keys -t "${session.muxName}" -l -- ${shellescape(escapeTmuxTrailingSemicolon(textPart))}` ); } else if (hasCarriageReturn) { - // Enter only + // Enter only: no await between the guard and dispatch. + if (options?.beforeDispatch && !options.beforeDispatch()) return false; await run(`${this.tmux()} send-keys -t "${session.muxName}" Enter`); } @@ -4279,7 +4360,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } capturePaneBuffer(muxName: string, paneTarget?: string, opts?: PaneCaptureOptions): string | null { - if (IS_TEST_MODE) return ''; + const exec = this.captureRunner(); + if (!exec) return ''; const target = resolveTmuxPaneTarget(muxName, paneTarget); if (!target) { console.error('[TmuxManager] Invalid pane target in capturePaneBuffer:', { muxName, paneTarget }); @@ -4317,9 +4399,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { execOpts.maxBuffer = (opts?.maxCaptureBytes ?? DEFAULT_TERMINAL_BUFFER_MAX_BYTES) + FULL_HISTORY_CAPTURE_SLACK_BYTES; } + const cursorQuery = `display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'`; + const captureCommand = `${this.tmux()} ${captureFlags} -t ${shellescape(target)}`; + // The visible frame and its caret come from ONE tmux invocation + // (the capture, `\;`, then the cursor query): tmux runs a command list back to back + // in its command queue, with no pane output parsed between the two, so the + // caret belongs to the frame. Two separate calls raced the CLI's repaint: a + // CLI that redraws its composer a few ms after input arrives (codex) could be + // captured before the redraw and its caret queried after it, and guarded + // stage then refused a frame the keys had already reached. Only output that + // splitFrameAndCursor() cannot split (a last line that is not the query's) + // falls back to the two separate calls, which is what this always did. A call + // that THREW (a hung tmux timing out) goes to the catch below like any other + // capture failure: retrying it would block the event loop for a second timeout + // on every `GET .../terminal` load. + const combined = fullHistory + ? null + : splitFrameAndCursor( + exec(`${captureCommand} \\; ${cursorQuery}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }) + ); const rawCapture = joinScrollbackAndFrame( scrollbackOverride, - execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts), + combined?.frame ?? exec(captureCommand, execOpts), scrollbackOverride !== undefined ? opts?.scrollbackOverrideScreen : undefined ); // Query the cursor BEFORE deciding anything else. On the full-history path @@ -4327,10 +4428,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // appended, and those two have to agree: trailing blank rows are only safe // to keep when a move follows to put the caret back above them. const geometry = queryPaneCursor(() => - execSync( - `${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'`, - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } - ) + combined + ? combined.cursor + : exec(`${this.tmux()} ${cursorQuery}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }) ); // Report the size the pane was really drawing at. The visible-frame path // below addresses every row absolutely, so a consumer whose terminal is @@ -4396,6 +4496,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } } + /** + * How {@link capturePaneBuffer} runs tmux, or null where it must not (vitest). + * Split out like {@link inputRunner} so a test subclass can count and script the + * calls a capture makes. + */ + protected captureRunner(): + | ((cmd: string, opts: { encoding: 'utf-8'; timeout: number; maxBuffer?: number }) => string) + | null { + if (IS_TEST_MODE) return null; + return (cmd, opts) => execSync(cmd, opts); + } + /** * Capture the active pane for a tmux session. * diff --git a/src/types/discord.ts b/src/types/discord.ts new file mode 100644 index 00000000..42782aca --- /dev/null +++ b/src/types/discord.ts @@ -0,0 +1,60 @@ +/** + * @fileoverview Discord bridge type definitions. + * + * The option lists and the config, result and status shapes of the two-way Discord bridge, kept apart + * from the IO module (`src/discord-bridge.ts`, which pulls in `ws`) so the request schemas and the + * route can import them alone, as the webhook's equivalents live in `push.ts`. + * + * Key exports: + * - DISCORD_THREAD_CREATION, DISCORD_ON_DELETE, DISCORD_PING_MODES (+ their types): the option lists + * - DiscordConfig: `~/.codeman/discord.json` (mode 0600; the bot token is a secret) + * - DiscordResult, DiscordGatewayState, DiscordStatus: what `GET /api/discord` reports + * + * No dependencies on other domain modules but `push.ts` (the shared scope list). + */ + +import type { WebhookScope } from './push.js'; + +export const DISCORD_THREAD_CREATION = ['lazy', 'onSessionStart'] as const; +export type DiscordThreadCreation = (typeof DISCORD_THREAD_CREATION)[number]; +export const DISCORD_ON_DELETE = ['keep', 'archive', 'delete'] as const; +export type DiscordOnDelete = (typeof DISCORD_ON_DELETE)[number]; +export const DISCORD_PING_MODES = ['off', 'critical', 'all'] as const; +export type DiscordPingMode = (typeof DISCORD_PING_MODES)[number]; + +export interface DiscordConfig { + enabled: boolean; + token: string; + guildId: string; + parentChannelId: string; + allowedUserIds: string[]; + threadCreation: DiscordThreadCreation; + onSessionDelete: DiscordOnDelete; + /** What to do with threads the bridge made that no tab maps to any more: checked after boot, then daily. */ + staleThreads: DiscordOnDelete; + pingMode: DiscordPingMode; + /** `attention` skips info-level events (a finished response); `all` sends them too. */ + scope: WebhookScope; + /** Tell each session how to reach the user (see DISCORD_STARTUP_HINT). On by default while the bridge is on. */ + startupHint: boolean; +} + +export interface DiscordResult { + ok: boolean; + /** True when the same event for the same tab was posted within the last few seconds, so nothing was sent. */ + deduped?: boolean; + status?: number; + error?: string; + at: number; +} + +export type DiscordGatewayState = 'off' | 'connecting' | 'connected' | 'error'; + +/** `GET /api/discord`: the config without its token, plus what the bridge is doing. */ +export type DiscordStatus = Omit<DiscordConfig, 'token'> & { + hasToken: boolean; + tokenMasked: string; + gateway: DiscordGatewayState; + gatewayError: string | null; + lastResult: DiscordResult | null; +}; diff --git a/src/types/index.ts b/src/types/index.ts index 1af34926..08e30f7f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -25,6 +25,7 @@ * | tools | ActiveBashTool, ImageDetectedEvent | In-memory, broadcast via SSE | * | teams | TeamConfig, TeamMember, TeamTask, InboxMessage, PaneInfo | `~/.claude/teams/`, `~/.claude/tasks/` → `GET /api/teams` | * | push | PushSubscriptionRecord, VapidKeys, WebhookConfig, WebhookStatus, WebhookResult | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json`, `~/.codeman/webhook.json` | + * | discord | DiscordConfig, DiscordResult, DiscordStatus, DiscordGatewayState | `~/.codeman/discord.json` → `GET`/`PUT /api/discord` | * | plan | PlanItem, PlanTaskStatus, TddPhase | In-memory → `GET /api/sessions/:id/plan/tasks` | * | orchestrator | OrchestratorState, OrchestratorPlan, OrchestratorConfig, OrchestratorPersistState | `~/.codeman/state.json` → `GET /api/orchestrator/status` | * | mcp-sync | McpSyncResult, McpSyncTargetResult | Other CLIs' own config files → `GET`/`POST /api/mcp-sync` | @@ -65,6 +66,7 @@ export * from './run-summary.js'; export * from './tools.js'; export * from './teams.js'; export * from './push.js'; +export * from './discord.js'; export * from './plan.js'; export * from './orchestrator.js'; export * from './update.js'; diff --git a/src/types/session.ts b/src/types/session.ts index 7186b644..e4a0e93a 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -33,6 +33,7 @@ import type { RespawnConfig } from './respawn.js'; import type { AttachmentDetectedType } from './tools.js'; +import type { GitFetchStatus } from '../git-fetch.js'; /** Status of a Claude session */ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error'; @@ -833,6 +834,19 @@ export interface SessionState { status: SessionStatus; /** Working directory path */ workingDir: string; + /** + * The directory the session's local tmux pane is in NOW (`#{pane_current_path}`), + * when known. `workingDir` is fixed at create time and never follows a `cd`; + * this is what a relative path printed in the terminal is relative to. + * + * Runtime-only: refreshed from the pane-exit watcher's read and never restored + * from disk, so a restart starts blank. Absent for remote and docker sessions + * (their local pane runs the ssh / docker-exec client) and for direct-PTY or + * rediscovered sessions. NOT a confinement root: the file routes stay confined + * to `workingDir`, and a path resolved outside it goes through the attachment + * routes like any other out-of-workspace path. + */ + paneCwd?: string; /** Remote execution metadata, present when this session runs over SSH through local tmux */ remote?: SessionRemote; /** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */ @@ -891,6 +905,20 @@ export interface SessionState { keepWarmEnabled?: boolean; /** What the keep-warm is doing right now; ephemeral, never restored. */ keepWarm?: KeepWarmStatus; + /** + * Git fetch on an interval for this session's repositories; absent = follow the app-wide + * `gitFetchEnabled` default. Restored on a restart, like `keepWarmEnabled`, because the choice is the + * user's and outlives the run. + */ + gitFetchEnabled?: boolean; + /** Seconds between this session's runs; absent = follow the app-wide `gitFetchIntervalSeconds` default. */ + gitFetchIntervalSeconds?: number; + /** + * What this session's last fetch did. Written to disk with the rest of the record, but the restore copies + * only the fields its own config lists and this is not one of them, so it is the panel's truth for the + * life of the session, not across a restart. + */ + gitFetch?: GitFetchStatus; /** Pinned to the top of the session manager list (COD-139) */ pinned?: boolean; /** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */ diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index 1c884dfc..7f5ab0bf 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -20,7 +20,7 @@ **OSC 52 clipboard** (`_installOsc52Clipboard`, terminal-ui.js; main terminal + every `TerminalTile`): a program in the PTY (Claude Code's `/copy`, tmux yanks, vim) sets the browser clipboard with `ESC ] 52`. ⚠️ Writes only: a `52;?` read query is always refused, never answered. ⚠️ A replay must never copy: `chunkedTerminalWrite` strips every OSC 52 (`OSC52_STRIP_RE`, constants.js) because xterm parses after `_isLoadingBuffer` is cleared, so the flag alone does not hold; a tile gates on its own `_bufferLoading`, which does. ⚠️ One copy arrives up to three times (Claude's own OSC 52, `tmux load-buffer -w`, set-clipboard forwarding), so identical text within `OSC52_DEDUPE_MS` is dropped. ⚠️ Every tab on the session receives it; only the focused one opens the manual-copy fallback. ⚠️ Restore prior focus after `_copyText` (its execCommand fallback focuses a temp textarea). Tests: `test/terminal-osc52-clipboard.test.ts` (replays drive the real `chunkedTerminalWrite` over `@xterm/headless`). -**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](../../docs/architecture-invariants.md#terminal-paste-ctrlv) +**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, plain text is a Codeman-built bracketed paste handed to that pane's terminal via `terminal.input(framed)` (strip nested 200/201 markers, newlines to `\r`, wrap CSI 200~/201~) because `terminal.paste()` drops those markers after a replay clears xterm's DECSET 2004 mirror (#716). `input()` fires onData as user input, so the main pane's bracketed-paste branch and a tile's `_onTerminalData` deliver the frame, including the local-echo flush (#219). The terminal is only refocused. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](../../docs/architecture-invariants.md#terminal-paste-ctrlv) **File Viewer text view: rendered markdown + Lines/Wrap toggles** (`_renderFilePreviewText()` in panels-ui.js): a `.md`/`.markdown` opens RENDERED by default with an `MD` pill back to source; the plain-text view has `Lines` (CSS-counter gutter) and `Wrap` toggles. ⚠️ ONE markdown pipeline: the viewer calls `_renderMarkdown(text, { breaks: false })` (marked + the DOMPurify allowlist, the Response Viewer's; chat keeps the default `breaks: true`, a file must not turn every hard wrap into a `<br>`) and binds the Response Viewer's click delegate (`_bindResponseViewerInteractions`) on the preview body for code-copy buttons and path links; never a second parser or handler. ⚠️ The document is built inside a `<template>` (a detached div with `innerHTML` set starts fetching every `<img src>` before the rewrite), then `_rebaseFilePreviewMarkdownRefs()` points relative images at the workspace-confined `file-raw` under the document's directory and root-relative ones under the workspace root (never a widened route; a failed load degrades to alt text), after `decodeURIComponent`ing the ref and dropping `?query`/`#fragment` (marked percent-encodes destinations, and the route encodes again), and turns workspace links into `a.rv-path` carrying `data-session-id` for the delegate (the linkifier's absolute paths get it too), stripping the `target` marked gave them. ⚠️ A preview opened by attachment id under a bare file name (attachment cards, history drawer: the registry keeps no relative path) has no directory, so its workspace refs degrade (images to alt text, links to their text), never resolve against the workspace root. ⚠️ The container carries `data-i18n-skip` or the translator rewrites the document's prose. ⚠️ Toggles are per-device localStorage keys (`codeman:filePreview*`), never `SettingsUpdateSchema`; Lines/Wrap are class flips on the ONE `<pre>`, with rules scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's code blocks. Markdown fetches `lines=10000` (the route ceiling), other text keeps 500. ⚠️ `md`/`markdown` are IN `FILE_PREVIEW_EXTENSIONS` (owner decision 2026-10-13): a markdown path clicked or tapped in the TERMINAL (main pane, grid tiles and split Pane B share `registerFilePathLinkProvider`) opens this rendered view like every other surface, never the streaming log viewer; other in-workspace text keeps the tail viewer (live follow). The terminal pattern links `.markdown` and `.htm` too. Tests: `test/file-preview-markdown.test.ts`. → [architecture-invariants#file-viewer-text-view-rendered-markdown-and-text-toggles](../../docs/architecture-invariants.md#file-viewer-text-view-rendered-markdown-and-text-toggles) @@ -30,15 +30,15 @@ ## Frontend modules and UI subsystems -Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order is the `<script>` order in `index.html` (each module's `@loadorder` tag mirrors it). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module: the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea. +Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order is the `<script>` order in `index.html` (each module's `@loadorder` tag mirrors it). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module (`create()` returns null unless `matchMedia('(pointer: coarse)')` matches or the user agent is Android, so a desktop dead key is not forwarded twice, #716, while an Android device whose mouse, trackpad cover or stylus Chromium reports as the fine primary pointer keeps the soft-keyboard recovery): the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea. **Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows, connection lines and tile-grid tiles, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` / `data-tile-anim` on `<html>`; the default `legacy` theme short-circuits every hook (tiles keep the grid's own `settle`). ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Every animation setting lives in App Settings → **Animations** (`#settings-animations`); put new ones there. ⚠️ Tiles are OFF by default (Animations → Tile Animations, default `settle` = the grid's own fade, no screen beat); a theme only presets it. A tile's FRAME animates transform/opacity only (six at once, no filter) with `tile-enter*`/`tile-leave*` keyframe names (what the mount and the still copy listen for); its SCREEN replays the pane style on `.tile-body.term-enter`; a reload's restore always settles. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](../../docs/architecture-invariants.md#entrance-animations) -**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`) or, grouped by state, the ACTIVE tab moved to another state band (`_noteActiveTabBand()`, both render paths, scrolling row only; another tab's move never scrolls). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Tests: `test/mobile-tab-tap-zones.test.ts`, `test/tab-triage.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](../../docs/architecture-invariants.md#mobile-tab-strip-scrolling) +**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`) or, grouped by state, the ACTIVE tab moved to another state band (`_noteActiveTabBand()`, both render paths, scrolling row only; another tab's move never scrolls). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. With the opt-in tab "+" on (`showTabNewButton`, `html.tabs-show-new`) a third icon joins them and the reserve grows to 68px, still under the 80px `max-width`. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Tests: `test/mobile-tab-tap-zones.test.ts`, `test/tab-triage.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](../../docs/architecture-invariants.md#mobile-tab-strip-scrolling) **Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk and the phone swipe follow computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. ⚠️ The rail's **Search sessions** box (`setTabRailSearch`, in memory only) and the sidebar filter share ONE row filter, `_applyTabListFilter()` over the pure `CodemanTabSearch` (constants.js): classes only, never a reorder or layout write; the rail matches the name, the sidebar name + folder. ⚠️ A session row with a tab alert (`tabAlerts`, red or yellow) is never hidden by either box: it goes in as `keep: true`, counts toward its group (which stays on screen) but not toward `matchCount`. ⚠️ Escape in the rail box is claimed by the global capture-phase Escape branch (the box's own `onkeydown` runs after it, too late to stop `closeAllPanels()`), and a filter change that shows or hides anything redraws the connector lines, since a keystroke re-renders nothing; a floating window finds its parent tab only through `_paintedSessionTab()` (a hidden row's rect is all zero but truthy, which drew connectors from the viewport's corner). While it runs `_projectTabGroups()` projects every group open (a collapsed group's rows are not in the DOM) and `toggleTabGroupCollapsed()` refuses, leaving the stored collapse untouched; drag stays on during a search (owner decision), a drop landing between the visible neighbours; `applyTabOrientation()` clears it off the rail. Tests: `test/tab-rail-search.test.ts`, `test/tab-rail-search.browser.test.ts`. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](../../docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar) -**Tab layouts** (`tabArrangement`, per-device, default `classic`; `state`/`case`/`ledger` are opt-in; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (idle is `quiet`: its heading stays as the row's anchor but draws no label; `tabStateOrder: 'urgent-last'` flips the order, needs you at the bottom): rows in the desktop header strip with left-aligned labels in a measured column (the brand sits over the strip's corner, so the rows after the first start under "Codeman"), sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. `case` (option A) wraps each case's tabs in one `.tab-cluster` box and hides the `-<case>` of a generated name (`.tab-name-case`). `ledger` (option B) is a CSS-only grid on the desktop header strip. `classic` is the old strip. Pure cores: `CodemanTabTriage` and `CodemanTabClusters` (constants.js). ⚠️ `state` is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (a state change is an incremental pass). ⚠️ `case` boxes ARE a DOM grouping, so the incremental path rebuilds whenever `_tabClusterLayout().key` changes; membership follows `_mobileOverviewCaseFor()` like the home screens. ⚠️ Drag only reorders within a group or box (`_isTabDropAcrossGroups()`). ⚠️ Named groups in the vertical rail win over both groupings. ⚠️ `classic` must leave no trace (no headings, no inline order, no boxes, no name split), and the ledger must not change the markup. ⚠️ Grouping is a render layer: `sessionOrder`, Alt+[ / Alt+], Ctrl+Tab and Alt+1..9 keep the canonical order, and only the phone swipe walks the drawn order (`nextSession({ visual: true })` through `_stripSessionOrder()`, the same `_visibleTabRows()` walk the strip's arrow keys use); an active session the list does not draw steps in at the first or last row, and an empty drawn list goes nowhere rather than falling back to a hidden session (`test/session-swipe-gesture.test.ts`). Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. → [architecture-invariants#tab-layouts-by-state-by-case-ledger](../../docs/architecture-invariants.md#tab-layouts-by-state-by-case-ledger) +**Tab layouts** (`tabArrangement`, per-device, default `classic`; `state`/`case`/`ledger` are opt-in; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (idle is `quiet`: its heading stays as the row's anchor but draws no label; `tabStateOrder: 'urgent-last'` flips the order, needs you at the bottom): rows in the desktop header strip with left-aligned labels in a measured column (the brand sits over the strip's corner, so the rows after the first start under "Codeman"), sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. `case` (option A) wraps each case's tabs in one `.tab-cluster` box and hides the `-<case>` of a generated name (`.tab-name-case`). `ledger` (option B) is a CSS-only grid on the desktop header strip. `classic` is the old strip; once it wraps on desktop it is L-shaped via `_syncTabStripCorner()` (the toolbar sits over its top-right corner and later rows run the full width under it, a strip already past its max-height keeps the plain wrap): ⚠️ `.tabs-corner` comes off before the wrap is measured, and the strip is `display: block` in that state, so flex-only rules do not apply to it there (`test/tab-strip-corner.browser.test.ts`). Pure cores: `CodemanTabTriage` and `CodemanTabClusters` (constants.js). ⚠️ `state` is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (a state change is an incremental pass). ⚠️ `case` boxes ARE a DOM grouping, so the incremental path rebuilds whenever `_tabClusterLayout().key` changes; membership follows `_mobileOverviewCaseFor()` like the home screens. ⚠️ Drag only reorders within a group or box (`_isTabDropAcrossGroups()`). ⚠️ Named groups in the vertical rail win over both groupings. ⚠️ `classic` must leave no trace (no headings, no inline order, no boxes, no name split), and the ledger must not change the markup. ⚠️ Grouping is a render layer: `sessionOrder`, Alt+[ / Alt+], Ctrl+Tab and Alt+1..9 keep the canonical order, and only the phone swipe walks the drawn order (`nextSession({ visual: true })` through `_stripSessionOrder()`, the same `_visibleTabRows()` walk the strip's arrow keys use); an active session the list does not draw steps in at the first or last row, and an empty drawn list goes nowhere rather than falling back to a hidden session (`test/session-swipe-gesture.test.ts`). Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. → [architecture-invariants#tab-layouts-by-state-by-case-ledger](../../docs/architecture-invariants.md#tab-layouts-by-state-by-case-ledger) **Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `compact`; Discussion #426 option G): `tiles` / `compact` (two pills, a ring beside every value) / `classic` (as before), keyed on `data-header-stats` (pre-paint + `applyHeaderStatsStyle()`), forced to `classic` below 768px and in a solo window. ⚠️ The clustered styles MOVE `#connectionIndicator` into `#headerSystemStats` and `#planUsageChip` right after it; `classic` moves them back to comment anchors. ⚠️ WS joins the pill only while System Stats is shown. ⚠️ The extra parts (stat rings, tile words, plan rings, meters) are always rendered and hidden by default in CSS, which is what keeps `classic` unchanged; tile words are derived beside the connection descriptor, never added to it (`test/connection-indicator.test.ts` pins its shape). ⚠️ A tile is a three-row grid (label, value, bar) with pixel line-heights and the bundled JetBrains Mono; never lay the bar over a fixed-height tile (a taller system mono put the value on top of it). The WS tile's grid lives on the inner `.connection-tile`, because JS writes the indicator's `display` inline. Tests: `test/header-stats-style.test.ts`. → [architecture-invariants#header-stats-styles](../../docs/architecture-invariants.md#header-stats-styles) @@ -52,7 +52,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte into the PTY. ⚠️ The global Escape handler (app.js) calls EVERY close method on every Escape, in the capture phase, so a close method that does more than hide (the palette and Session Manager restore focus) must return early when its overlay is not open. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)**: with no selection it must `return true` without `preventDefault()` or the interrupt is lost; keep `copyTerminalSelection` out of `SHORTCUT_ACTIONS`. The gate tests the CLEANED selection (`CodemanCopySelection.clean`: trailing padding, plus a LEADING margin only up to the width the CLI declares in `capabilities.transcriptGutter`, never one derived from the pane); the strip is not idempotent, so clean once and pass the RAW selection on, and leave Alt+drag column selections untouched. → [architecture-invariants#command-palette-and-shortcut-registry](../../docs/architecture-invariants.md#command-palette-and-shortcut-registry) -**Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema. +**Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `keyboardHoldsRows`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema. **Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `<details>` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `<select>`s, which stay the source of truth. ⚠️ `.modal-tabs*` classes are retired; `admin-ui.js` needs `.set-rail-items` + `.set-doc` to survive any restructure. Guard: `test/app-settings-structure.test.ts`. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](../../docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case) @@ -66,6 +66,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Folding devices: a fold is not a keyboard, and dialogs avoid the hinge**: ⚠️ in `handleViewportResize()`, a visual-viewport resize that changes the WIDTH is a shape change (rotation, fold) and must never be read as the keyboard; it re-baselines instead, or `keyboardVisible` latches with no keyboard. ⚠️ `init()` must seed `lastViewportWidth`. ⚠️ With the keyboard up, a shape change baselines to `window.innerHeight`, never the shrunk visual height. ⚠️ The hinge is reserved via `--fold-inline-end`/`--fold-block-end` (0px when unfolded): each overlay fold rule must re-state its own gutter, a base gutter overridden by a later `@media` block needs its own fold restatement there on a zero base, and dialogs use physical sides (left/top segment) in every language. Guard: `test/foldable-layout.test.ts`. → [architecture-invariants#folding-devices](../../docs/architecture-invariants.md#folding-devices) +**Rows held under the on-screen keyboard** (#672, `keyboardHoldsRows`, per-device, default ON): while the keyboard is up, an eligible session keeps its PTY rows (`app._keyboardHeldRows`, captured by `KeyboardHandler` on the show transition and read through `getTerminalDimensions()`) and `.xterm-screen` is translated up by `--kb-clip` (`_applyKeyboardClip()`, `keyboardClipPx()`), with the local-echo overlay bounded through `setRowLimit()`. ⚠️ Eligibility is `_keyboardHoldEligible()` only (the published `window.__codemanKeyboardHoldModes`, never an id; never with `cliMouseTracking`, remote or docker). ⚠️ Every path that can change eligibility calls `_reconcileKeyboardHold()` (tab switch, `session:updated`, reconnect, settings save), keyboard hide and a shape change release it (a shape change also arms the viewport settle, the only refit while the keyboard is up), and nothing re-holds with the keyboard already up. → [architecture-invariants#pty-and-browser-terminal-geometry](../../docs/architecture-invariants.md#pty-and-browser-terminal-geometry) + **WebGL renderer toggle** (`webglRendererEnabled`, per-device): the GPU-stall watchdog's sticky `codeman-webgl-disabled` marker survives page loads and is cleared only by an explicit OFF→ON save or `?webgl=force`. `?nowebgl` forces the DOM renderer per-load. → [architecture-invariants#webgl-renderer-toggle](../../docs/architecture-invariants.md#webgl-renderer-toggle) **Shell keyboard accessory bar + one-shot Ctrl** (`keyboard-accessory.js`): a shell-mode session swaps the mobile accessory bar for terminal controls; `setMode()` records `extendedKeyboardBar` as the base layout and `refreshForActiveSession()` resolves base-vs-shell. ⚠️ Ctrl is a one-shot modifier applied in `terminal.onData` (after `shouldSuppressTerminalQueryResponse`, before every send path), and must skip `isTerminalFocusOrMouseReport()` chunks. ⚠️ It must disarm on use, second tap, any other accessory key, session switch, keyboard dismissal and layout swap. ⚠️ `_handleCjkInput()` must apply it too (the CJK textarea bypasses onData). Mapping: `ctrlByteFor()`. ⚠️ mobile.css's light-skin repaint must keep excluding `.accessory-btn:not(.armed)` or the armed state is invisible. → [architecture-invariants#shell-keyboard-accessory-bar-and-one-shot-ctrl](../../docs/architecture-invariants.md#shell-keyboard-accessory-bar-and-one-shot-ctrl) @@ -82,7 +84,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L ⚠️ **`sendEnterKey()` MUST go through `terminal._core.coreService.triggerDataEvent('\r', true)`** — not `sendInput()`, and never a raw POST to `/api/sessions/:id/input`. `localEchoEnabled` defaults to `MobileDetection.isTouchDevice()`, so on every phone the characters you type are buffered in the `LocalEchoOverlay` and have **never reached the PTY**; the `onData` Enter branch in terminal-ui.js is what flushes `pendingText` first and only then sends `\r` (after an 80ms delay so text lands first). Sending a bare `\r` submits an empty line and strands the typed text on screen, so the button looks dead. Replaying the keypress reuses the overlay flush, the flushed-offset cleanup and the ordering instead of reimplementing them. `KeyboardAccessory.sendKey()` is for escape sequences (arrows/Esc) and is the WRONG template to copy for input. -⚠️ **Skin overrides outrank plain class rules.** `styles.css` nests its skin block inside `html:not([data-skin="og"]) { … }`, so a bare `.btn-toolbar` rule in there resolves to specificity **(0,2,1)** and beats a `.btn-toolbar.btn-x` rule **(0,2,0)** in `mobile.css` regardless of load order. Toolbar-button colors set from mobile.css therefore need `!important` — that is why mobile.css leans on it so heavily. Symptom: only your `!important` properties land and everything else silently renders in generic toolbar grey. +⚠️ **Skin overrides outrank plain class rules.** `styles.css` nests its skin block inside `html:not([data-skin="og"]) { … }`, so a bare `.btn-toolbar` rule in there resolves to specificity **(0,2,1)** and beats a `.btn-toolbar.btn-x` rule **(0,2,0)** in `mobile.css` regardless of load order. Toolbar-button colors set from mobile.css therefore need `!important`: that is why mobile.css leans on it so heavily. Symptom: only your `!important` properties land and everything else silently renders in generic toolbar grey. Press feedback on the phone bars sidesteps this: the `:active` rules for `.btn-toolbar`, the mobile icon buttons and `.accessory-btn` (mobile.css) use `transform` and `filter` only, which nothing on these buttons sets at a higher specificity, so one rule reads the same on every skin; a background `:active` lost to the skin block on all but og, and an inset `box-shadow` lost to the skin hover rules, which is why neither is used. The light skins darken instead of brightening, and `prefers-reduced-motion` drops the scale. **Connection-loss UI** (`computeConnectionLossUi()` in constants.js, writer `_updateConnectionLossUi()` in app.js): the service worker serves the cached app shell, so an unreachable server (phone off the tailnet, VPN down, server stopped) used to render a normal-looking empty dashboard whose only tell was the 8px header dot, which reads as "no sessions", not "no connection". Two surfaces now: a full-screen **overlay** while no server state has loaded this page load (nothing behind it is worth preserving), and a non-blocking **banner** once it has (the terminal scrollback stays readable). ⚠️ A **2.5s grace** is load-bearing: a COM deploy restarts the server and SSE is back in ~200ms, and a banner on every deploy trains the user to ignore it. `navigator.onLine === false` skips the grace, since that is never a blip. Retry re-arms SSE **and** the terminal WS (`planWsReconnect` can 'give-up', and the SSE backoff caps at 30s). diff --git a/src/web/case-path.ts b/src/web/case-path.ts index 369464d9..e1219b66 100644 --- a/src/web/case-path.ts +++ b/src/web/case-path.ts @@ -60,6 +60,8 @@ export interface NewCasePathContext { * listed as a local case, so it must not be registered as a linked one too. */ casesDirs?: readonly string[]; + /** What to do instead when the path is the cases folder or directly in it. Defaults to Create New's wording. */ + casesDirAdvice?: string; } export type NewCasePathResult = @@ -106,10 +108,10 @@ export function blockedReason( // refusing everything below it blocked most of the tree. for (const dir of ctx.casesDirs ?? []) { if (absPath === dir) { - return `That is the cases folder itself (${dir}); create a case inside it with plain Create New (no custom folder)`; + return `That is the cases folder itself (${dir}); ${ctx.casesDirAdvice ?? 'create a case inside it with plain Create New (no custom folder)'}`; } if (dirname(absPath) === dir) { - return `That folder is directly in the cases folder (${dir}), where it is listed as a local case; create it there with plain Create New (no custom folder)`; + return `That folder is directly in the cases folder (${dir}), where it is listed as a local case; ${ctx.casesDirAdvice ?? 'create it there with plain Create New (no custom folder)'}`; } } return null; @@ -132,7 +134,7 @@ async function resolvedPolicy(ctx: NewCasePathContext): Promise<[NewCasePathCont Promise.all((ctx.casesDirs ?? []).map(realpathOr)), Promise.all(BLOCKED_SYSTEM_ROOTS.map(realpathOr)), ]); - return [{ home, dataDir, casesDirs }, systemRoots]; + return [{ home, dataDir, casesDirs, casesDirAdvice: ctx.casesDirAdvice }, systemRoots]; } /** diff --git a/src/web/edit-history-sync.ts b/src/web/edit-history-sync.ts new file mode 100644 index 00000000..ad525c77 --- /dev/null +++ b/src/web/edit-history-sync.ts @@ -0,0 +1,497 @@ +/** + * @fileoverview Reads a claude session's edit history on request: the new bytes of its + * transcripts (main + subagents) go into its `EditHistoryStore`, and the Bash image + * candidates that came with them are confirmed against the files on disk. + * + * WHEN. Only when the panel asks (`GET …/edits?sync=1` on open and while it follows the + * session, `POST …/edits/older`): no timer, no hook, no background read for sessions nobody + * looks at. One sync per session runs at a time; requests during a run are folded into one + * more forward read and/or one more older read afterwards. + * + * HOW MUCH. A forward read shares one budget over all files, main first. A file whose chunk + * the shared budget cut short is not applied (the store would take a chunk with no line end + * for a line longer than the budget) but read in the next pass; up to + * {@link MAX_FORWARD_PASSES} passes per sync, the rest with the next one. + * + * NEW CONVERSATION. When the transcript path names another conversation (`/clear`, resume), + * the previous transcript (same directory) is first read to its end, so edits it got after + * the last read are not lost with its cursors; then the new one is read from its end. + * "Load older" reaches back within the current conversation only. + * + * MEMORY. At most {@link MAX_LOADED_STORES} stores stay loaded; past that the least recently + * used one without a run is written (if it changed) and dropped, and loaded again on demand. + * + * GONE. `remove()` (the session's record is deleted) marks the session gone, deletes the file + * and then drops the session's slot: a run in flight stops after its current read and its + * store no longer writes; a request during the removal reads nothing (`unavailable`, reason + * `removed`). A session that comes back with the same id (reboot restore) starts afresh. + * `sweep()` deletes, at startup, the stored history of sessions that no longer exist. A + * subagent transcript that vanished loses its cursor (`dropSource`), not its entries. + * + * OLDER. "Load older" reads a full budget block of up to {@link MAX_OLDER_SOURCES} sources + * with older lines per click; with more sources they take turns. + * + * WHICH SESSIONS. Local claude sessions. Remote, Docker and non-claude sessions answer + * `unsupported` and are never read. + * + * IMAGES. A Bash candidate counts only when the file's mtime lies in the call's window + * (start − 2 s … end + 2 s), so `ls *.png` lists nothing. + * + * Tests: `test/edit-history-sync.test.ts`. + * + * @module web/edit-history-sync + */ + +import { basename, dirname, join } from 'node:path'; +import type { SessionRemote } from '../types/session.js'; +import type { EditSourceReadOptions, EditSourceRequest, EditSources, PathProbe } from '../edit-history-local-source.js'; +import type { ImageCandidate } from '../edit-history-parse.js'; +import { EditHistoryStore, READ_BUDGET_BYTES, type ConfirmedImage } from '../edit-history-store.js'; +import { getCli } from '../config/cli-registry/registry.js'; + +/** Slack around a Bash call's window for the image mtime check. */ +export const IMAGE_MTIME_SLACK_MS = 2000; +/** Forward passes per sync when files grew by more than one budget. */ +export const MAX_FORWARD_PASSES = 3; +/** Sources read back per "Load older" click, a full budget each. */ +export const MAX_OLDER_SOURCES = 3; +/** Stores kept in memory; the least recently used one past this is dropped. */ +export const MAX_LOADED_STORES = 3; +/** `reason` when the session has no transcript yet (no prompt yet, or not found). */ +export const NO_TRANSCRIPT = 'no-transcript'; +/** `reason` once the session's history was deleted. */ +export const REMOVED = 'removed'; + +export type EditHistoryState = 'ok' | 'unavailable' | 'unsupported'; + +export interface EditHistoryStatus { + state: EditHistoryState; + reason?: string; +} + +/** The slice of a session this needs. */ +export interface EditHistorySession { + id: string; + mode: string; + remote: SessionRemote | undefined; + docker?: unknown; + claudeSessionId: string | null; + /** The session's own `CLAUDE_CONFIG_DIR` (Codeman env override), if it has one. */ + claudeConfigDir?: string; +} + +export interface EditHistorySyncDeps { + /** The session's main transcript path, or null when it has none yet. */ + transcriptPath(session: EditHistorySession): Promise<string | null>; + readSources( + mainPath: string, + requests: readonly EditSourceRequest[], + budget: number, + opts?: EditSourceReadOptions + ): Promise<EditSources | null>; + probe(paths: readonly string[]): Promise<Array<PathProbe | null>>; + loadStore(sessionId: string): Promise<EditHistoryStore>; + /** Delete a session's stored history without loading it. */ + deleteStore(sessionId: string): Promise<void>; + /** Delete the stored history of every session `keep` rejects; returns how many. */ + sweepStores(keep: (sessionId: string) => boolean): Promise<number>; + budget?: number; +} + +export interface SyncOptions { + older?: boolean; +} + +/** Why a session has no edit history, or undefined when it can have one. */ +export function unsupportedReason(session: EditHistorySession): string | undefined { + if (session.remote) return 'remote'; + if (session.docker) return 'docker'; + if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl') return 'mode'; + return undefined; +} + +/** One turn of the event loop, between synchronous parses of up to one budget each. */ +function breathe(): Promise<void> { + return new Promise((resolve) => setImmediate(resolve)); +} + +/** + * Bash candidates whose probed file is a regular file with an mtime inside the call's window + * (files claude sent to the user skip the window: they exist and were shown on purpose). + * A candidate whose file version is already recorded is skipped: `known(path)` is the newest + * entry time for that path, and an entry at or after `mtime − slack` saw this version: the + * `ls *.png` right after the render, or a Write followed by a listing. Candidates are taken + * in call order so a batch dedupes itself the same way. + */ +export function confirmImages( + candidates: readonly ImageCandidate[], + probe: (path: string) => PathProbe | null | undefined, + known: (path: string) => number | undefined = () => undefined +): ConfirmedImage[] { + const out: ConfirmedImage[] = []; + const seen = new Map<string, number>(); + for (const c of [...candidates].sort((a, b) => a.end - b.end)) { + for (const path of c.paths) { + const p = probe(path); + if (!p || p.kind !== 'file' || !p.mtimeMs) continue; + const inWindow = p.mtimeMs >= c.start - IMAGE_MTIME_SLACK_MS && p.mtimeMs <= c.end + IMAGE_MTIME_SLACK_MS; + if (!c.sent && !inWindow) continue; + const entryPath = path.startsWith('~/') ? p.realPath : path; + const last = Math.max(seen.get(entryPath) ?? -Infinity, known(entryPath) ?? -Infinity); + if (last >= p.mtimeMs - IMAGE_MTIME_SLACK_MS) continue; + seen.set(entryPath, c.end); + const img: ConfirmedImage = { + toolUseId: c.toolUseId, + path: entryPath, + ts: c.end, + tool: c.sent ? 'sent' : 'bash', + }; + if (c.agentId) img.agentId = c.agentId; + out.push(img); + } + } + return out; +} + +interface SyncWant { + forward: boolean; + older: boolean; +} + +interface SessionSlot { + store?: Promise<EditHistoryStore>; + status: EditHistoryStatus; + running?: Promise<void>; + again?: SyncWant; + /** The history was deleted: nothing reads for this session any more. */ + gone?: boolean; + /** The last source the previous "Load older" read; the next one starts after it. */ + olderAfter?: string; +} + +export class EditHistorySync { + /** By session, least recently used first. */ + private readonly slots = new Map<string, SessionSlot>(); + /** Writes of dropped stores; loading that session again waits for its write. */ + private readonly unloading = new Map<string, Promise<void>>(); + private readonly budget: number; + + constructor(private readonly deps: EditHistorySyncDeps) { + this.budget = deps.budget ?? READ_BUDGET_BYTES; + } + + /** The session's store, loaded from disk on first use (one load for concurrent callers). */ + getStore(sessionId: string): Promise<EditHistoryStore> { + return this.storeOf(sessionId, this.slot(sessionId)); + } + + getStatus(sessionId: string): EditHistoryStatus { + return { ...this.slot(sessionId).status }; + } + + /** + * Read what is new (or, with `older`, the next older block). Resolves when this + * request's run is done; never rejects. A call during a run queues one more run. + */ + async sync(session: EditHistorySession, opts: SyncOptions = {}): Promise<EditHistoryStatus> { + const why = unsupportedReason(session); + const slot = this.slot(session.id); + if (slot.gone) return { ...slot.status }; + if (why) { + slot.status = { state: 'unsupported', reason: why }; + return { ...slot.status }; + } + const want: SyncWant = { forward: !opts.older, older: !!opts.older }; + if (slot.running) { + slot.again = { + forward: (slot.again?.forward ?? false) || want.forward, + older: (slot.again?.older ?? false) || want.older, + }; + await slot.running; + // The queued run starts when the current one ends; wait for it too. + if (slot.running) await slot.running; + return { ...slot.status }; + } + slot.running = this.runLoop(session, slot, want); + try { + await slot.running; + } finally { + slot.running = undefined; + this.unloadIdle(slot); + } + return { ...slot.status }; + } + + /** Write every loaded store's pending changes (server shutdown). */ + async flushAll(): Promise<void> { + await Promise.all([ + ...[...this.slots.values()].map(async (slot) => (await slot.store?.catch(() => undefined))?.settle()), + ...this.unloading.values(), + ]); + } + + /** + * Delete a session's stored history (its record is gone). While it runs the session is + * marked, so a run in flight or a request meanwhile cannot load and write a new store for + * it; then its slot goes, and a session that comes back with this id starts afresh. + */ + async remove(sessionId: string): Promise<void> { + const slot = this.slot(sessionId); + slot.gone = true; + slot.again = undefined; + slot.status = { state: 'unavailable', reason: REMOVED }; + try { + const store = await slot.store?.catch(() => undefined); + if (store) { + await store.remove(); + } else { + await this.unloading.get(sessionId); + await this.deps.deleteStore(sessionId); + } + await slot.running; + } finally { + if (this.slots.get(sessionId) === slot) this.slots.delete(sessionId); + } + } + + /** + * Delete the stored history of sessions that no longer exist (startup: left behind by a + * crash during deletion, or by an older build). A session in use here is kept. + */ + sweep(exists: (sessionId: string) => boolean): Promise<number> { + return this.deps.sweepStores((id) => this.slots.has(id) || exists(id)); + } + + /** The session's slot, now the most recently used. */ + private slot(sessionId: string): SessionSlot { + const slot = this.slots.get(sessionId) ?? { status: { state: 'ok' } }; + this.slots.delete(sessionId); + this.slots.set(sessionId, slot); + return slot; + } + + private storeOf(sessionId: string, slot: SessionSlot): Promise<EditHistoryStore> { + if (!slot.store) { + const loading = (async () => { + await this.unloading.get(sessionId); + return this.deps.loadStore(sessionId); + })(); + slot.store = loading; + loading.catch(() => { + if (slot.store === loading) slot.store = undefined; + }); + this.unloadIdle(slot); + } + return slot.store; + } + + /** + * Past {@link MAX_LOADED_STORES}, drop the least recently used stores no run uses: each is + * written if it changed, and a later request loads it again (after that write). + */ + private unloadIdle(keep?: SessionSlot): void { + let loaded = 0; + for (const slot of this.slots.values()) if (slot.store) loaded++; + for (const [sessionId, slot] of this.slots) { + if (loaded <= MAX_LOADED_STORES) break; + if (!slot.store || slot.running || slot.gone || slot === keep) continue; + this.slots.delete(sessionId); + loaded--; + const writing: Promise<void> = slot.store + .then((store) => store.settle()) + .catch((err) => { + console.warn(`[EditHistory] Saving the history of ${sessionId} failed:`, err); + }) + .finally(() => { + if (this.unloading.get(sessionId) === writing) this.unloading.delete(sessionId); + }); + this.unloading.set(sessionId, writing); + } + } + + private async runLoop(session: EditHistorySession, slot: SessionSlot, want: SyncWant): Promise<void> { + let next: SyncWant | undefined = want; + while (next && !slot.gone) { + if (next.forward) await this.runOnce(session, slot, false); + if (next.older && !slot.gone) await this.runOnce(session, slot, true); + next = slot.again; + slot.again = undefined; + } + } + + private async runOnce(session: EditHistorySession, slot: SessionSlot, older: boolean): Promise<void> { + let status: EditHistoryStatus; + try { + // This slot's store: a run still in flight after `remove()` cannot reach a later one. + const store = await this.storeOf(session.id, slot); + const path = slot.gone ? null : await this.deps.transcriptPath(session); + let found: boolean; + if (!path) { + found = false; + } else if (older) { + found = await this.readOlder(path, store, slot); + } else { + found = true; + for (let pass = 0; pass < MAX_FORWARD_PASSES && found && !slot.gone; pass++) { + const r = await this.readForward(path, store); + found = r.found; + if (!r.more) break; + } + } + status = found ? { state: 'ok' } : { state: 'unavailable', reason: NO_TRANSCRIPT }; + } catch (err) { + status = { state: 'unavailable', reason: err instanceof Error ? err.message : String(err) }; + } + if (!slot.gone) slot.status = status; + } + + private forwardRequests(store: EditHistoryStore): EditSourceRequest[] { + const out: EditSourceRequest[] = []; + for (const relPath of store.knownSources()) { + const c = store.getCursor(relPath); + if (c) out.push({ relPath, offset: c.offset }); + } + return out; + } + + /** + * One forward read of all sources. `more` = some file has unread bytes after it, or was cut + * short by the shared budget and left for the next pass. A transcript other than the stored + * one (`/clear`, resume) is read like a first read, once the stored one is read to its end. + */ + private async readForward(path: string, store: EditHistoryStore): Promise<{ found: boolean; more: boolean }> { + const own = this.storedTranscript(path, store); + if (own !== path && store.knownSources().length > 0) { + const previous = await this.readFrom(own, store, this.forwardRequests(store)); + if (previous.more) return { found: true, more: true }; + } + return this.readFrom(path, store, own === path ? this.forwardRequests(store) : []); + } + + /** + * The path of the conversation the store holds cursors for: `path` itself, or the previous + * conversation's transcript next to it (`/clear` and resume keep the project directory). + */ + private storedTranscript(path: string, store: EditHistoryStore): string { + const known = store.getTranscriptId(); + return known === null || basename(path) === `${known}.jsonl` ? path : join(dirname(path), `${known}.jsonl`); + } + + private async readFrom( + path: string, + store: EditHistoryStore, + requests: EditSourceRequest[] + ): Promise<{ found: boolean; more: boolean }> { + const res = await this.deps.readSources(path, requests, this.budget); + if (!res) return { found: false, more: false }; + this.dropMissing(store, requests, res); + for (const [agentId, text] of Object.entries(res.metas)) { + if (!store.hasAgentMeta(agentId)) store.setAgentMeta(agentId, text); + } + const requested = new Set(requests.map((r) => r.relPath)); + let more = false; + const candidates: ImageCandidate[] = []; + let parsed = false; + for (const f of res.files) { + // Each file is up to one budget (8 MiB) of synchronous parsing: yield between them. + if (parsed) await breathe(); + if (store.isRemoved()) break; + // A requested file reads on from its cursor, a new one its tail. + const available = requested.has(f.relPath) ? f.size - f.startOffset : f.size; + if (f.chunk.length < Math.min(this.budget, available)) { + more = true; + continue; + } + parsed = true; + const r = store.apply({ + transcriptId: res.transcriptId, + source: f.relPath, + startOffset: f.startOffset, + chunk: f.chunk, + size: f.size, + }); + candidates.push(...r.candidates); + if (f.startOffset + f.chunk.length < f.size) more = true; + } + await this.addImages(store, candidates); + return { found: true, more }; + } + + private async readOlder(path: string, store: EditHistoryStore, slot: SessionSlot): Promise<boolean> { + if (!store.backfillTruncated) return true; + // Older lines belong to the stored conversation, even before a forward read moved on. + path = this.storedTranscript(path, store); + const sources = store.sourcesWithOlder().sort(); + if (sources.length === 0) return true; + // Take turns: start after the last source the previous click read. + const after = slot.olderAfter; + const first = + after === undefined + ? 0 + : Math.max( + 0, + sources.findIndex((s) => s > after) + ); + const picked = [...sources.slice(first), ...sources.slice(0, first)].slice(0, MAX_OLDER_SOURCES); + const requests: EditSourceRequest[] = []; + for (const relPath of picked) { + const range = store.olderRange(relPath, this.budget); + if (range) requests.push({ relPath, offset: range.offset, len: range.len }); + } + if (requests.length === 0) return true; + slot.olderAfter = picked[picked.length - 1]; + const res = await this.deps.readSources(path, requests, this.budget * requests.length, { onlyRequested: true }); + if (!res) return false; + if (res.transcriptId !== store.getTranscriptId()) return true; + this.dropMissing(store, requests, res); + const wanted = new Set(requests.map((r) => r.relPath)); + const candidates: ImageCandidate[] = []; + let parsed = false; + for (const f of res.files) { + if (!wanted.has(f.relPath)) continue; + // Each file is up to one budget (8 MiB) of synchronous parsing: yield between them. + if (parsed) await breathe(); + parsed = true; + if (slot.gone) return true; + const r = store.applyOlder({ + transcriptId: res.transcriptId, + source: f.relPath, + startOffset: f.startOffset, + chunk: f.chunk, + size: f.size, + }); + candidates.push(...r.candidates); + } + await this.addImages(store, candidates); + return true; + } + + /** Requested files the reader no longer lists are gone: their cursors go too. */ + private dropMissing(store: EditHistoryStore, requests: readonly EditSourceRequest[], res: EditSources): void { + const listed = new Set(res.files.map((f) => f.relPath)); + for (const r of requests) if (!listed.has(r.relPath)) store.dropSource(r.relPath); + } + + private async addImages(store: EditHistoryStore, candidates: readonly ImageCandidate[]): Promise<void> { + const paths = [...new Set(candidates.flatMap((c) => c.paths))]; + // A removed store keeps nothing: do not probe agent-printed paths for it. + if (paths.length === 0 || store.isRemoved()) return; + let probes: Array<PathProbe | null>; + try { + probes = await this.deps.probe(paths); + } catch { + // The edits of this read are in; only its images are lost. The read itself succeeded. + return; + } + const byPath = new Map(paths.map((p, i) => [p, probes[i]])); + const newest = new Map<string, number>(); + for (const e of store.list()) newest.set(e.path, Math.max(newest.get(e.path) ?? -Infinity, e.ts)); + store.addImageEntries( + confirmImages( + candidates, + (p) => byPath.get(p), + (p) => newest.get(p) + ) + ); + } +} diff --git a/src/web/network-auth-policy.ts b/src/web/network-auth-policy.ts index 3fb04974..4e775460 100644 --- a/src/web/network-auth-policy.ts +++ b/src/web/network-auth-policy.ts @@ -7,15 +7,28 @@ export function isExplicitlyEnabled(value: string | undefined): boolean { } /** - * True when unauthenticated network exposure is acceptable: either a password is - * set (auth active) or the operator explicitly acknowledged it. Used by the - * tunnel-enable guard (COD-55) to refuse publishing an unauthenticated public URL. + * True when an unauthenticated non-loopback BIND is acceptable: either a password is + * set (auth active) or the operator explicitly acknowledged it. Quiets the startup + * warning only; it does not cover the Cloudflare tunnel (#706), which has its own + * acknowledgement below. */ export function isUnauthenticatedNetworkAcknowledged(allowFlag = false): boolean { if (process.env.CODEMAN_PASSWORD) return true; return allowFlag || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK); } +/** + * True when the operator opted in to an unauthenticated public Cloudflare tunnel: + * `--allow-unauthenticated-tunnel` (passed in as `allowFlag`) or + * CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL. Used by the tunnel-enable guard (COD-55) in + * place of a login. Kept apart from the network acknowledgement on purpose (#706): + * install.sh writes that one for every passwordless LAN install, and it must never + * also publish the machine to the internet. install.sh never writes this one. + */ +export function isUnauthenticatedTunnelAcknowledged(allowFlag = false): boolean { + return allowFlag || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL); +} + export function isLoopbackBindHost(host: string): boolean { const normalized = host .trim() diff --git a/src/web/paste-image-gc.ts b/src/web/paste-image-gc.ts index 462fce35..8fef5806 100644 --- a/src/web/paste-image-gc.ts +++ b/src/web/paste-image-gc.ts @@ -38,6 +38,12 @@ export const UPLOADS_DIR = '.codeman-uploads'; export const LEGACY_UPLOADS_DIR = '.claude-images'; /** Every upload dir name, current first. Retiring the legacy one here retires it for every reader. */ export const UPLOAD_DIR_NAMES = [UPLOADS_DIR, LEGACY_UPLOADS_DIR]; +/** + * Where files a person sent from Discord land (discord-bridge.ts). Named here with the others so the + * image watcher can skip it without importing the bridge. Not an upload dir: neither the sweep nor a + * session delete touches it. + */ +export const DISCORD_RECEIVED_DIR = '.codeman-discord'; /** * Every directory a session's uploads sit in, current first. Both consumers diff --git a/src/web/ports/infra-port.ts b/src/web/ports/infra-port.ts index 21e5beca..38d81932 100644 --- a/src/web/ports/infra-port.ts +++ b/src/web/ports/infra-port.ts @@ -35,6 +35,8 @@ export interface InfraPort { readonly scheduledRuns: Map<string, ScheduledRun>; readonly teamWatcher: TeamWatcher; readonly tunnelManager: TunnelManager; + /** `--allow-unauthenticated-tunnel` was passed to `codeman web` (the env var is read per request, #706). */ + readonly allowUnauthenticatedTunnel: boolean; readonly pushStore: PushSubscriptionStore; startScheduledRun(prompt: string, workingDir: string, durationMinutes: number, owner?: string): Promise<ScheduledRun>; stopScheduledRun(id: string): Promise<void>; diff --git a/src/web/ports/session-port.ts b/src/web/ports/session-port.ts index 6a735f5b..5d89fa13 100644 --- a/src/web/ports/session-port.ts +++ b/src/web/ports/session-port.ts @@ -10,6 +10,11 @@ import type { SessionPlacementHint } from '../../tab-layout-service.js'; export interface SessionPort { readonly sessions: ReadonlyMap<string, Session>; + /** + * The last-response reader (the body of `GET /api/sessions/:id/last-response`), filled in when the + * session routes register, so other server code reads the same per-CLI transcript the viewer does. + */ + lastResponseFor?: (session: Session, context?: string) => Promise<{ text: string; timestamp?: string }>; /** * Register a created session with the owner's tab layout. `placement.tabGroupId` * puts it at the end of that group in the same layout write (ignored when the @@ -19,6 +24,11 @@ export interface SessionPort { */ addSession(session: Session, placement?: SessionPlacementHint): Promise<TabLayout>; cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise<void>; + /** + * Delete a session's stored edit history once its record is gone. `cleanupSession` does it + * itself; this is for removing a record without a live session (DELETE route). + */ + forgetEditHistory(sessionId: string): void; setupSessionListeners(session: Session): Promise<void>; persistSessionState(session: Session): void; persistSessionStateNow(session: Session): void; diff --git a/src/web/public/app.js b/src/web/public/app.js index 7a3e52bd..65503816 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -286,6 +286,10 @@ const _SSE_HANDLER_MAP = [ [SSE_EVENTS.IMAGE_DETECTED, '_onImageDetected'], [SSE_EVENTS.ATTACHMENT_DETECTED, '_onAttachmentDetected'], + // Edit history (edit-history-ui.js): an open panel follows its session's tool ends + // (and its turn ends, from _onSessionIdle: one handler per event in this map) + [SSE_EVENTS.TRANSCRIPT_TOOL_END, '_onEditHistoryTranscriptEvent'], + // Tunnel [SSE_EVENTS.TUNNEL_STARTED, '_onTunnelStarted'], [SSE_EVENTS.TUNNEL_STOPPED, '_onTunnelStopped'], @@ -2452,9 +2456,15 @@ class CodemanApp { if (session.id === this.activeSessionId && session.tokens) { this.updateRespawnTokens(session.tokens); } + // cliMouseTracking (Claude's fullscreen mode) ends a keyboard row hold. + if (session.id === this.activeSessionId) this._reconcileKeyboardHold?.(); // Update parentSessionName for any subagents belonging to this session // (fixes stale name display after session rename) this.updateSubagentParentNames(session.id); + // A scheduled fetch publishes the session's result on this same event, so an open Session Options modal + // on that session shows it without polling its own (session-ui.js: updateGitFetchStatus, which leaves the + // control being typed in alone). + if (this.editingSessionId === session.id) this.updateGitFetchStatus(session.id); // If claudeSessionId was just set, re-check orphan subagents // This connects subagents that were waiting for the session to identify itself if (claudeSessionIdJustSet) { @@ -2874,7 +2884,9 @@ class CodemanApp { _bindResponseViewerInteractions(body) { if (!body || body.dataset.rvBound === '1') return; body.dataset.rvBound = '1'; - body.addEventListener('click', async (ev) => { + // The two link kinds whose href is a placeholder the browser cannot open + // (`#`, `#fragment`). Returns true when it took the event. + const openPlaceholderLink = (ev) => { // File path (_linkifyFilePaths): open it in the preview overlay, which // resolves workspace and out-of-workspace paths alike. const pathLink = ev.target.closest('a.rv-path'); @@ -2891,7 +2903,7 @@ class CodemanApp { const target = pathLink.dataset.linkified ? this._resolveLinkedFilePath?.(filePath, sessionId) : filePath; if (filePath && pathLink.dataset.folder) this.openFolderGrid(target || filePath, sessionId); else if (filePath) this.openFilePreview(target || filePath, sessionId); - return; + return true; } // An in-document link (`[Install](#installation)`). The browser must not follow it: with @@ -2908,8 +2920,18 @@ class CodemanApp { const calm = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; target.scrollIntoView({ block: 'start', behavior: calm ? 'auto' : 'smooth' }); } - return; + return true; } + return false; + }; + // A middle click fires auxclick, never click, so the browser followed the + // placeholder and opened the dashboard root in a new tab: do what a click + // does instead. A real URL keeps the browser's own middle click. + body.addEventListener('auxclick', (ev) => { + if (ev.button === 1) openPlaceholderLink(ev); + }); + body.addEventListener('click', async (ev) => { + if (openPlaceholderLink(ev)) return; // A `localhost` URL in the agent's answer: from another device that can // only load through the server, so hand it to a proxied web tab @@ -3071,7 +3093,27 @@ class CodemanApp { const targets = []; for (let node = walker.nextNode(); node; node = walker.nextNode()) { if (node.parentElement?.closest('a')) continue; - const found = findFilePathLinks(node.nodeValue || ''); + // Markdown puts **Saved to:** in a separate <strong> node. Carry a bold + // label that starts its line into detection and let spacedFilePathOnLine() + // judge it, as it does the same line in the terminal (`Note:` is not a + // path label); never a label from another block. Chat renders with + // `breaks: true`, so a later line of a paragraph starts after a <br>. + // A text node after any other inline element (`Use <code>--config</code> + // /a/b c.json`) starts mid-line, so a placeholder word stands in for what + // precedes it. The label itself stays untouched. + const label = node.previousSibling; + let beforeLabel = label?.previousSibling; + if (beforeLabel?.nodeType === 3 && !beforeLabel.nodeValue.trim()) beforeLabel = beforeLabel.previousSibling; + const leadingLabel = + label?.nodeName === 'STRONG' && + /^(?:P|LI)$/.test(node.parentElement?.tagName || '') && + (!beforeLabel || beforeLabel.nodeName === 'BR'); + let prefix = ''; + if (leadingLabel) prefix = `${label.textContent} `; + else if (label?.nodeType === 1 && label.nodeName !== 'BR') prefix = 'x '; + const found = findFilePathLinks(prefix + (node.nodeValue || '')) + .filter((hit) => hit.index >= prefix.length) + .map((hit) => ({ ...hit, index: hit.index - prefix.length })); if (found.length > 0) targets.push([node, found]); } @@ -3575,6 +3617,9 @@ class CodemanApp { this.sendPendingCtrlL(data.id); if (data.id === this.activeSessionId) this._updateLocalEchoState(); } + // An open Edit history panel re-reads at the end of its session's turn (edit-history-ui.js), + // not after Codeman's own keep-alive turn, which edits nothing. + if (!data.keepAlive) this.onEditHistorySessionIdle?.(data.id); // Start stuck detection timer (only if no respawn running). Not at the end of Codeman's // own keep-alive turn (`keepAlive`): the session has been waiting since the last real // turn, whose timer is still running or has already fired. @@ -5192,6 +5237,8 @@ class CodemanApp { // backoff reconnect, but if it is not on this session (dead socket, or // a give-up) nothing else would re-establish it from here. if (this._wsSessionId !== restoreId) this._connectWs(restoreId); + // The fresh session record may no longer qualify for a keyboard row hold. + this._reconcileKeyboardHold?.(); void this._onSessionNeedsRefresh({ id: restoreId }); // The split pane's second terminal reconnects on its own backoff (up to // 10 s between tries); the server is back now, so skip the wait. A no-op @@ -5936,7 +5983,7 @@ class CodemanApp { // tabs-auto-wrap is measured, not derived from settings — updateTabOverflowMode() // drops it in sidebar mode, but drop it here too so nothing paints wrapped // for a frame before the next measure. - if (sidebar) tabsEl.classList.remove('tabs-auto-wrap'); + if (sidebar) tabsEl.classList.remove('tabs-auto-wrap', 'tabs-corner'); // Collapse/expand changes whether the filter is reachable, so re-evaluate it // here too — not only at the render tails. this.applySidebarFilter(this._sidebarFilter); @@ -6927,7 +6974,14 @@ class CodemanApp { updateTabOverflowMode() { const container = this.$('sessionTabs'); if (!container) return; + // The corner takes the toolbar out of the header's flow, which widens the + // strip, so it goes before the wrap is measured and comes back once decided. + container.classList.remove('tabs-corner'); + this._decideTabOverflow(container); + this._syncTabStripCorner(container); + } + _decideTabOverflow(container) { // Lineage routing room (session-lineage.js) changes the strip's padding and // row gap, so it is decided before the wrap is measured below. this._syncLineageGutter?.(); @@ -6989,6 +7043,108 @@ class CodemanApp { container.classList.toggle('tabs-auto-wrap', shouldWrap); } + /** + * L-shaped header strip: once the classic strip wraps, the toolbar + * (#headerRight) sits over the strip's top-right corner (styles.css, + * `.tabs-corner`) and the rows under the first one run the header's full + * width beneath it. The strip keeps the toolbar's corner clear with a float + * sized here, from the toolbar's own box, so a toolbar that wraps onto more + * lines (header-divider.js) or gains a reading keeps every tab out from + * under it. The grouped arrangements (state, case, ledger) lay their rows + * out themselves and keep the plain strip. + */ + _syncTabStripCorner(container) { + const right = this.$('headerRight'); + const on = + !!right && + container.offsetParent !== null && + !!container.parentElement?.classList.contains('session-tabs-host') && + (container.classList.contains('tabs-auto-wrap') || container.classList.contains('tabs-two-rows')) && + !container.classList.contains('tabs-triage') && + !container.classList.contains('tabs-clusters') && + !container.classList.contains('tabs-ledger') && + MobileDetection.getDeviceType() === 'desktop'; + const off = () => { + container.classList.remove('tabs-corner'); + container.style.removeProperty('--tab-corner-width'); + container.style.removeProperty('--tab-corner-height'); + container.style.removeProperty('--tab-corner-first-max'); + for (const tab of container.querySelectorAll(':scope > .tab-corner-fit')) tab.classList.remove('tab-corner-fit'); + }; + // A strip past its max-height scrolls, and the float would scroll its tabs + // under the toolbar, which stays put: keep the plain wrap. The plain strip + // is measured here (updateTabOverflowMode took .tabs-corner off), before + // the class goes on; toggling it on a scrolled strip lets scroll anchoring + // move the strip. + if (!on || container.scrollHeight > container.clientHeight + 1) { + off(); + return; + } + container.classList.add('tabs-corner'); + if (!this._tabCornerObserver && typeof ResizeObserver !== 'undefined') { + let frame = 0; + // A toolbar that changes size (a reading arrives, the divider settles) + // moves the tabs without a render: re-decide the wrap and the corner, and + // redraw the lineage routes that follow the tabs. + this._tabCornerObserver = new ResizeObserver(() => { + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + const strip = this.$('sessionTabs'); + if (!strip || !/\btabs-(corner|auto-wrap|two-rows)\b/.test(strip.className)) return; + this.updateTabOverflowMode(); + this.updateConnectionLines?.(); + }); + }); + this._tabCornerObserver.observe(right); + } + const style = getComputedStyle(container); + const strip = container.getBoundingClientRect(); + const box = right.getBoundingClientRect(); + // The float sits at the strip's content padding edge (the plain strip did + // not scroll, so scrollTop is 0). + const contentTop = strip.top + parseFloat(style.paddingTop); + const contentLeft = strip.left + container.clientLeft + parseFloat(style.paddingLeft); + const contentRight = strip.left + container.clientLeft + container.clientWidth - parseFloat(style.paddingRight); + // The first tab must fit beside the toolbar, or the whole first line drops + // below it and leaves an empty band. A selected tab is wider (gear and + // close show), so falling back to the plain strip here made the layout + // follow the selection; the first tab's name is trimmed to the room + // instead. Only a toolbar that leaves no usable room keeps the plain strip. + // The room is what the float (rounded up below) leaves, less the tab's 2px + // right margin and a pixel for subpixel layout; a tab at the exact width + // still wrapped under the float. + const floatWidth = Math.max(0, Math.ceil(contentRight - box.left + 8)); + const room = Math.floor(contentRight - floatWidth - contentLeft) - 3; + if (room < TAB_CORNER_MIN_FIRST_TAB) { + off(); + return; + } + const firstTab = container.querySelector(':scope > .session-tab'); + // Capped every time, not only once it measures too wide: selectSession() + // widens a newly selected tab at once and this pass is debounced, so a cap + // set only here afterwards would leave the first row below the toolbar until + // it runs. The cap changes nothing on a tab narrower than the room. + for (const tab of container.querySelectorAll(':scope > .tab-corner-fit')) { + if (tab !== firstTab) tab.classList.remove('tab-corner-fit'); + } + firstTab?.classList.add('tab-corner-fit'); + if (container.style.getPropertyValue('--tab-corner-first-max') !== `${room}px`) { + container.style.setProperty('--tab-corner-first-max', `${room}px`); + } + const width = `${floatWidth}px`; + const height = `${Math.max(0, Math.ceil(box.bottom - contentTop))}px`; + if (container.style.getPropertyValue('--tab-corner-width') !== width) { + container.style.setProperty('--tab-corner-width', width); + } + if (container.style.getPropertyValue('--tab-corner-height') !== height) { + container.style.setProperty('--tab-corner-height', height); + } + // A plain strip just under its max-height can pass it once the float is in: + // fall back to the plain strip, which fit. + if (container.scrollHeight > container.clientHeight + 1) off(); + } + /** * True when a case cluster in the header strip wraps inside its own box: a case * wider than the whole strip (styles.css caps a box at the strip's width). The @@ -7167,7 +7323,9 @@ class CodemanApp { : `<span class="tab-harness run-mode-dot ${escapeHtml(mode)}" aria-hidden="true"></span>`; const inlineSessionActions = this.shouldInlineSessionActions(); - const tabActionsHtml = `<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><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">⋯</button></span>`; + // The "+" announces a menu, so Enter and Space open it: the strip's own key + // handler acts only on a focused tab, and the header strip has no row menu. + const tabActionsHtml = `<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-new" onclick="event.stopPropagation(); app.openTabNewSessionMenu(event, ${escapeHtml(JSON.stringify(id))})" onkeydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); app.openTabNewSessionMenu(event, ${escapeHtml(JSON.stringify(id))}); }" title="New session here" aria-label="New session in this tab's case" aria-haspopup="menu" 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><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">⋯</button></span>`; rowHtml.set(id, `<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}${this._tileGrid?.has(id) ? ' in-tiles' : ''}"${richData}${railOrderStyle} 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(paneExitAriaLabel(name, paneExitBadge))}" data-aria-source="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}> ${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''} @@ -8396,12 +8554,100 @@ class CodemanApp { return true; } + /** + * A tab's "+" (and the row menu's "New session here…"): the CLIs that tab's + * case can start, each as its logo and name like the Run menus. A pick starts + * ONE session beside that tab: its case (or the managed worktree it runs in) + * and its named group, if it has one, through `_launchOnce`, so the Run + * button's mode and case are untouched. + */ + openTabNewSessionMenu(event, sessionId) { + const session = this.sessions.get(sessionId); + const worktree = this._managedWorktreeOfSession?.(session) || null; + const caseEntry = worktree ? null : this._tabCaseOf(session); + if (!worktree && !caseEntry) { + event?.stopPropagation?.(); + this.showToast?.("This tab's folder is not a case, so there is nothing to start beside it.", 'info'); + return false; + } + const caseName = worktree ? worktree.caseName : caseEntry.name; + // An ungrouped tab sends no group, so the server's case default group (if the + // case has one) places the new session, as it does for a toolbar Run. + const tabGroupId = this._tabRefLocation({ kind: 'session', id: sessionId })?.groupId || null; + // The toolbar probes only the case it has selected, so ask this tab's container + // now: this open reads what is already known, the next one the container's answer. + if (caseEntry?.location === 'docker' && !this._dockerCaseModes?.[caseName]) { + void this._probeDockerCaseModes(caseEntry, null); + } + // The phone overview's list, gated by the Run menu's own rule for this case + // (_runModeAvailableForCase: the container's CLIs for a Docker case; shell is + // never gated), in the Run menu's order: agents in catalog order, the shell last. + const isShell = (entry) => registryCliById(entry.mode)?.kind === 'shell'; + const available = mobileOverviewRunModes().filter( + (entry) => isShell(entry) || this._runModeAvailableForCase(entry.mode, caseName) + ); + const modes = [...available.filter((entry) => !isShell(entry)), ...available.filter(isShell)]; + const current = this.runMode; + const actions = modes.map((entry) => ({ + label: entry.label, + icon: entry.mode, + className: 'run-mode-option', + selected: entry.mode === current, + run: () => + this._launchOnce(entry.mode, { + ...(worktree ? { worktree } : { caseName }), + ...(tabGroupId ? { tabGroupId } : {}), + }), + })); + return this._openTabLayoutMenu(event, `new:${sessionId}`, 'New session here', actions, { anyLayout: true }); + } + + /** + * The case a session tab belongs to, by what the session runs on. GET /api/cases + * gives a remote or Docker case a DISPLAY path (`user@host:/path`, + * `container:/path`) with the raw values under `remote` / `docker`, while a + * session carries its raw folder, so a path match alone misses those cases or + * lands on a local case at the same folder. A Docker session belongs to its + * container's case on its own engine host, a remote one to the case on its own + * host whose path encloses its own, a local one to the deepest case enclosing + * its folder (a display path never encloses one). Null when none fits. + */ + _tabCaseOf(session) { + if (!session) return null; + const cases = this.cases || []; + const deepestEnclosing = (dir, candidates, rootOf) => { + let best = null; + let bestLength = -1; + for (const c of candidates) { + const root = (rootOf(c) || '').replace(/\/+$/, ''); + if (!root || (dir !== root && !dir.startsWith(`${root}/`)) || root.length <= bestLength) continue; + best = c; + bestLength = root.length; + } + return best; + }; + if (session.docker) { + const { hostId, containerName } = session.docker; + return ( + cases.find((c) => c.location === 'docker' && c.docker?.hostId === hostId && c.docker?.container === containerName) || + null + ); + } + if (session.remote) { + const { hostId, remotePath } = session.remote; + const onHost = cases.filter((c) => c.location === 'remote' && c.remote?.hostId === hostId); + return deepestEnclosing((remotePath || '').replace(/\/+$/, ''), onHost, (c) => c.remote.path); + } + return deepestEnclosing((session.workingDir || '').replace(/\/+$/, ''), cases, (c) => c.path); + } + // ─── Group and web-tab menus (right-click, the header's ⋯, Shift+F10) ── /** - * Close the open group / web-tab menu. Every dismissal path lands here: - * Escape, a pointer outside it, focus leaving it, Tab, a viewport resize, an - * action, and any full re-render of the rail (which would orphan its trigger). + * Close the open group / web-tab / tab "+" menu. Every dismissal path lands + * here: Escape, a pointer outside it, focus moving to an element outside it, + * Tab, a viewport resize, an action, and any full re-render of the rail (which + * would orphan its trigger). */ closeTabGroupMenu({ restoreFocus = false } = {}) { const menu = this._tabGroupMenu; @@ -8461,7 +8707,7 @@ class CodemanApp { ]); } - _openTabLayoutMenu(event, identity, ariaLabel, actions) { + _openTabLayoutMenu(event, identity, ariaLabel, actions, { anyLayout = false } = {}) { event?.preventDefault?.(); event?.stopPropagation?.(); const trigger = event?.currentTarget || null; @@ -8472,7 +8718,7 @@ class CodemanApp { } this.closeTabGroupMenu(); this.closeTabRailActionMenu?.(); - if (!this._tabLayoutEditable()) return false; + if (!anyLayout && !this._tabLayoutEditable()) return false; const menu = document.createElement('div'); menu.className = 'tab-rail-action-menu tab-layout-group-action-menu'; menu.setAttribute('role', 'menu'); @@ -8481,8 +8727,17 @@ class CodemanApp { const button = document.createElement('button'); button.type = 'button'; button.setAttribute('role', 'menuitem'); - button.textContent = action.label; + if (action.icon) { + // A Run menu entry's markup (renderRegistryRunOptions): the CLI's logo, then its name. + const dot = document.createElement('span'); + dot.className = `run-mode-dot ${action.icon}`; + dot.setAttribute('aria-hidden', 'true'); + button.append(dot, action.label); + } else { + button.textContent = action.label; + } if (action.className) button.className = action.className; + if (action.selected) button.classList.add('selected'); button.addEventListener('click', () => { this.closeTabGroupMenu(); action.run(); @@ -8517,6 +8772,12 @@ class CodemanApp { document.addEventListener('keydown', this._tabGroupMenuKeydown, true); window.addEventListener('resize', this._tabGroupMenuResize); menu.addEventListener('keydown', (keyEvent) => { + // A held Enter or Space that opened the menu repeats onto the entry it + // focuses; cancelling the repeat keeps it from also picking that entry. + if (keyEvent.repeat && (keyEvent.key === 'Enter' || keyEvent.key === ' ')) { + keyEvent.preventDefault(); + return; + } const buttons = [...menu.querySelectorAll('button')]; const at = buttons.indexOf(document.activeElement); if (keyEvent.key === 'ArrowDown' || keyEvent.key === 'ArrowUp') { @@ -8531,13 +8792,20 @@ class CodemanApp { this.closeTabGroupMenu({ restoreFocus: true }); } }); - // Focus leaving by any other route (a click elsewhere, a programmatic move). - // Hops between the menu's own items are not a departure. + // Focus moving to another element outside the menu (a programmatic move; + // a click elsewhere is the pointerdown listener's). Hops between the + // menu's own items are not a departure, and neither is focus going nowhere: + // Safari never focuses a clicked button, so a press on an item blurs the + // focused one with no relatedTarget, and closing then dropped its click + // (the tile count menu's rule, tile-grid.js). Nor is focus landing on the + // trigger: a press on a focusable one (a tab's "+") focuses it before its + // click, and closing there let that click open the menu again. menu.addEventListener('focusout', (focusEvent) => { - if (focusEvent.relatedTarget && menu.contains(focusEvent.relatedTarget)) return; + const to = focusEvent.relatedTarget; + if (!to || menu.contains(to) || trigger?.contains?.(to)) return; if (this._tabGroupMenu === menu) this.closeTabGroupMenu(); }); - menu.querySelector('button')?.focus(); + (menu.querySelector('button.selected') || menu.querySelector('button'))?.focus(); return true; } @@ -9729,6 +9997,9 @@ class CodemanApp { this._cleanupPreviousSession(sessionId); this.activeSessionId = sessionId; this._activateFileBrowserSession?.(sessionId); + // A keyboard row hold belongs to the session it was taken for: release it here + // if this one resizes instead (the geometry work below then fits it). + this._reconcileKeyboardHold?.({ resize: false }); // Repaint the partial-history banner for the tab being switched TO. The // replay paths refresh it when their fetch lands; without this the previous // session's notice stays on screen until then (#258). The switch lands at @@ -9770,6 +10041,8 @@ class CodemanApp { // Remote-host reachability banner: only meaningful for a remote session, so this // also clears it when the newly active tab is local. this.refreshHostWakeBanner?.(sessionId); + // An open Edit history panel follows the active session (edit-history-ui.js; no poll). + this.onEditHistoryActiveSession?.(sessionId); // Restore flushed offset AND text IMMEDIATELY so backspace/typing work during // the async buffer load. Without this, the offset is 0 during the diff --git a/src/web/public/constants.js b/src/web/public/constants.js index eb16edcd..8d7446b8 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -281,6 +281,11 @@ function resolveTabRailKeyboardWidth(input = {}) { // the gap is what tells the user there is more strip to swipe to. const TAB_SCROLL_REVEAL_PX = 16; +// Narrowest room beside the toolbar the L-shaped header strip trims its first +// tab into (`_syncTabStripCorner()`, app.js). Less than this keeps the plain +// wrap: a tab squeezed further shows its number and buttons but no name. +const TAB_CORNER_MIN_FIRST_TAB = 120; + // Phone/tablet tab-strip scroll policy (issue #257). Those breakpoints scroll // the strip horizontally (desktop wraps to a second row instead and never // scrolls), so the active tab can sit entirely outside the visible slice with @@ -2106,7 +2111,7 @@ function computeRewriteScrollLine(input) { * a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call. */ const FILE_PATH_LINK_PATTERN = - /(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g; + /(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|conf|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g; /** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */ function absoluteFilePathPattern() { @@ -2159,18 +2164,21 @@ function previewsInFileViewer(filePath) { * {@link findFilePathLinks}. */ const RELATIVE_FILE_PATH_LINK_PATTERN = - /(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g; + /(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|conf|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g; /** The `(?:log|txt|…)` extension group, read off the rooted pattern so it cannot drift. */ const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LINK_PATTERN.source)[1]; /** - * Every linkable file path in `text`, in order: absolute (rooted) paths first, - * then `~/` and relative ones, then folder paths — each only where it overlaps - * neither an earlier one nor a URL. + * Every linkable file path in `text`, sorted by position. Spaced candidates + * reserve their whole span first, including paths bounded by parentheses, even when unlinkable; rooted, `~/`, relative + * and folder detectors then skip reserved spans, earlier matches and URLs. * * The one entry point for both consumers (terminal link provider, response - * viewer), so a path form is clickable in both or in neither. + * viewer). The viewer carries a leading bold label into the text it passes + * (spacedFilePathOnLine judges it, as for the same line in the terminal) and + * reads a text node that follows another inline element as mid-line; paths + * split across inline elements are still detected per text node. * * @param {string} text * @returns {Array<{path: string, index: number, folder?: true}>} `index` is where `path` starts in @@ -2184,32 +2192,178 @@ function findFilePathLinks(text) { // `?img=b.png` belong to the URL link, not to a second, overlapping file link. // (`file:///home/x.png` is not one of them: its path is the file.) const urls = []; - const urlPattern = /\bhttps?:\/\/\S+/gi; + const webUrlPattern = /\bhttps?:\/\/\S+/gi; let match; - while ((match = urlPattern.exec(value)) !== null) urls.push({ index: match.index, path: match[0] }); + while ((match = webUrlPattern.exec(value)) !== null) urls.push({ index: match.index, path: match[0] }); const inUrl = (index, length) => urls.some((u) => index < u.index + u.path.length && u.index < index + length); + // A path with spaces is taken whole, or the span it covers links nothing + // (spacedFilePathOnLine). Either way no detector below may cut a fragment out of it. + const reserved = []; + const isReserved = (index, length) => + reserved.some((r) => index < r.index + r.path.length && r.index < index + length); + const reserve = (spaced, index) => { + if (!spaced) return; + const start = index + spaced.index; + if (inUrl(start, spaced.path.length) || isReserved(start, spaced.path.length)) return; + reserved.push({ index: start, path: spaced.path }); + if (spaced.link) found.push({ path: spaced.path, index: start }); + }; + let lineStart = 0; + for (const line of value.split('\n')) { + reserve(spacedFilePathOnLine(line), lineStart); + // Parentheses bound an inline path just as a whole line bounds a standalone path. + const parenthesised = /\(([^()\r\n]{1,1000})\)/g; + while ((match = parenthesised.exec(line)) !== null) { + if (opensBashCall(line, match.index)) continue; + reserve(spacedFilePathOnLine(match[1], { parenthesised: true }), lineStart + match.index + 1); + } + lineStart += line.length + 1; + } const absolute = absoluteFilePathPattern(); while ((match = absolute.exec(value)) !== null) { - if (!inUrl(match.index, match[1].length)) found.push({ path: match[1], index: match.index }); + if (!inUrl(match.index, match[1].length) && !isReserved(match.index, match[1].length)) { + found.push({ path: match[1], index: match.index }); + } } const relative = new RegExp(RELATIVE_FILE_PATH_LINK_PATTERN.source, 'g'); while ((match = relative.exec(value)) !== null) { const path = match[2]; const index = match.index + match[1].length; - if (overlaps(index, path.length) || inUrl(index, path.length)) continue; + if (overlaps(index, path.length) || inUrl(index, path.length) || isReserved(index, path.length)) continue; found.push({ path, index }); } const folders = new RegExp(FOLDER_PATH_LINK_PATTERN.source, 'g'); while ((match = folders.exec(value)) !== null) { const path = folderLinkPath(match[2]); const index = match.index + match[1].length; - if (!path || overlaps(index, path.length) || inUrl(index, path.length)) continue; + if (!path || overlaps(index, path.length) || inUrl(index, path.length) || isReserved(index, path.length)) continue; found.push({ path, index, folder: true }); } return found.sort((a, b) => a.index - b.index); } +/** Labels that explicitly identify a path, for {@link spacedFilePathOnLine}. */ +const SPACED_FILE_PATH_LABEL_PATTERN = + /^(?:path|files?|saved (?:to|as)|wrote|written to|output|created|location|attachment):[ \t]*$/i; + +/** + * True when the `(` at `open` starts a `Bash(` tool row, which holds a command rather than + * a path: `Bash(/tmp/a.out input.txt)` is a program and its argument, so the parentheses do + * not bound one spaced path and the ordinary detectors keep the row. Only `Bash(`: the path + * tools (`Read(`, `Update(`, `Write(`) do hold a path, spaces included. + * + * @param {string} text + * @param {number} open Index of the `(`. + * @returns {boolean} + */ +function opensBashCall(text, open) { + return /(?:^|\W)Bash\($/.test(text.slice(Math.max(0, open - 5), open + 1)); +} + +/** + * A file path WITH SPACES on one line, or null. + * + * Every path pattern ends a path at whitespace, so `/mnt/c/OneDrive - Contoso/a.docx` + * linked `/mnt/c/OneDrive` plus a relative `Contoso/a.docx`, two links to the + * wrong targets. A space is only safe inside a path where the line itself bounds + * the path or a parenthesised group bounds it, so a spaced path is recognised in these shapes: + * + * Updated (/mnt/c/My Docs/report.docx) inside parentheses in prose + * /mnt/c/My Docs/report.docx the whole line is the path + * - /mnt/c/My Docs/report.docx after a list marker (`-`, `*`, `•`, `1.`, `2)`) + * Saved to: 03 Attachments/Build SIPOC Design/plan.pptx after a path label (SPACED_FILE_PATH_LABEL_PATTERN) + * + * An absolute path may stand alone or follow a marker. Any label, before an + * absolute, relative or `~/` path, must be an explicit path label + * (SPACED_FILE_PATH_LABEL_PATTERN), with an optional marker before it: generic + * labels (`Error:`, `Note:`) and bare bullets also introduce prose, so a relative + * path needs a path label to be taken spaced. One trailing sentence mark is left + * out, and a leading run of `./` or `../` is not a segment. The candidate must read as ONE path: + * it ends on a known extension, a rooted one has a directory before its first + * space, no earlier word holds an extension (`a.md,`, `a.ts#L12`) or ends in `/`, + * and none holds a `:` and a digit (`a.ts:42`, a line suffix ends the path). Later words cannot start a new + * `/`, `~/`, `./` or `../` path, no earlier word ends in `:` (or `,` inside parentheses), is an arrow or has + * no letter or digit (a prompt glyph such as `❯` or `$`; a bare `-` is kept), and + * a run of spaces (a column gap) is refused, so two paths on a line are never + * merged. A relative candidate whose only `/` word is its last is prose after the + * label (`Wrote: 120 lines to src/foo.ts`) and is left alone, which also splits + * `Saved to: My Docs/plan.md` as master does. An earlier word that is a lowercase + * connective or verb (SPACED_PATH_PROSE_WORD: `and`, `in`, `has`, `contains`, ...) + * or a bare `=` makes the candidate a sentence (`/tmp/out has the logs in run.log`), + * so a name such as `Pros and Cons.md` splits as master does. A command line is + * refused: an executable under `bin/`, `.bin/`, `scripts/` or `tools/` as the + * first word, any earlier word ending in `$` (a shell prompt, `/home/me/proj$ cat a.md`, + * `/mnt/c/My Docs$ ls a.md`), or any `-x`/`--x` flag word. + * + * A candidate that is one path but not a linkable one (an unknown root such as + * `/etc`, a `.`/`..`/empty segment) comes back with `link: false` when its last + * word still holds a `/`: its span links nothing, rather than the fragment after + * its last space. Otherwise (`/usr/bin/env node build.js`) the line is left to + * the ordinary detectors. + * + * Linear: one bounded prefix match, then string scans. + * + * @param {string} line One line, no newline. + * @param {{parenthesised?: boolean}} [options] Refuse comma-separated lists inside parentheses only. + * @returns {{path: string, index: number, link: boolean} | null} + */ +function spacedFilePathOnLine(line, options = {}) { + const text = String(line || ''); + const prefix = /^[ \t]*((?:\d{1,3}[.)]|[-*•])[ \t]+)?((?:[A-Za-z][\w-]{0,30}(?: [\w-]{1,30}){0,3}):[ \t]+)?/.exec(text); + const index = prefix[0].length; + const path = text.slice(index).trimEnd().replace(/[.,;:!?]$/, ''); + if (!path.includes(' ') || !path.includes('/') || path.length > 1000) return null; + // After a label, only a path label introduces a path; with no label, only an + // absolute path may stand alone. `Error: /a/b contains c.md` is prose. + if (prefix[2] ? !SPACED_FILE_PATH_LABEL_PATTERN.test(prefix[2]) : !path.startsWith('/')) return null; + if (!PATH_TOKEN_ENDS_WITH_EXTENSION.test(path)) return null; + // A run of spaces is a column gap (`git status`, `ls -l`), not a name. + if (/[\t"'`<>|;&\x00-\x1f\\]/.test(path) || path.includes('://') || path.includes(' ')) return null; + const words = path.split(' '); + // A rooted candidate must have a directory before its first space: `/usr/bin/env node a.js` + // or `/review the changes in src/a.ts` is a command, not a spaced path. + if (path.startsWith('/') && !/^\/[^/]+\//.test(words[0])) return null; + // A command whose executable sits under a known root (`/opt/homebrew/bin/node a.js`, + // `/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`, `/home/me/proj/scripts/deploy prod.yml`) + // is not a path either. + if (/\/(?:\.?bin|scripts?|tools)\/[^/]+$/.test(words[0]) || words.some((word) => /^-{1,2}\w/.test(word))) { + return null; + } + for (const word of words.slice(0, -1)) { + if (word.endsWith('/') || word.endsWith(':') || /^(?:—|–|→|=>|->|<-|=)$/.test(word)) return null; + if (options.parenthesised && word.endsWith(',')) return null; + // A shell prompt that prints its working directory in full (`/home/me/proj$ cat + // notes.md`; WSL's `/mnt/c/...` is outside `$HOME`) is a command line too, and so is + // one whose directory has a space in it (`/mnt/c/My Docs$ ls notes.md`): the `$` + // can end any earlier word, not only the first. + if (word.endsWith('$')) return null; + // A word with no letter or digit (a prompt glyph `❯` or `$`, a `✓`) is never + // part of a file name, apart from a bare `-` (`OneDrive - Contoso`). + if (word !== '-' && !/[\p{L}\p{N}]/u.test(word)) return null; + // `a.ts:42` (grep -n, compiler output): the path already ended at the line suffix. + if (/:\d/.test(word)) return null; + // An extension anywhere in an earlier word (`a.md,`, `a.ts#L12`) ends a path there. + if (PATH_TOKEN_HAS_EXTENSION.test(word)) return null; + // A connective or verb joins a path to prose (`/tmp/out has the logs in run.log`, + // `Files: src/components and utils/x.ts`), so the candidate is a sentence. + if (SPACED_PATH_PROSE_WORD.test(word)) return null; + } + if (words.slice(1).some((word) => /^(?:\/|~\/|\.{1,2}\/)/.test(word))) return null; + const rooted = SPACED_PATH_ROOT.test(path) || path.startsWith('~/'); + const segments = path.replace(/^(?:~?\/|(?:\.\.?\/)+)/, '').split('/'); + const link = (rooted || !path.startsWith('/')) && segments.every((seg) => seg && seg !== '.' && seg !== '..'); + // A path label vouches that a path follows, not for the words before it: when + // only the last word holds a `/` (`Wrote: 120 lines to src/foo.ts`), the spaces + // are prose, so a relative candidate is left to the ordinary detectors. + const firstSlash = words.findIndex((word) => word.includes('/')); + if (link && firstSlash === words.length - 1 && !path.startsWith('/')) return null; + // Reserve an unlinkable span only when it still reads as a path to its end; + // otherwise leave the line to the ordinary detectors. + if (!link && !words[words.length - 1].includes('/')) return null; + return { path, index, link }; +} + /** * Folder paths, for the folder grid: rooted like {@link FILE_PATH_LINK_PATTERN} * (`/mnt/raid1/neon_getaway/styleframes`), `~/renders`, or relative with a @@ -2272,6 +2426,31 @@ function resolveLinkedFilePath(filePath, workingDir) { return `/${parts.join('/')}`; } +/** + * The directory a RELATIVE path printed for a session is relative TO. + * + * `workingDir` is recorded once at create time and never follows a `cd`, so it + * stops describing where a shell (or an agent that changed directory) really + * is. `session.paneCwd` is the pane's live `#{pane_current_path}`, published by the + * server from its pane read. It only changes which BASE a relative path joins; + * the result is then routed like any other path, so a target outside the + * workspace still goes through the attachment routes. + * + * Remote and Docker sessions are excluded (the server never sends them a paneCwd, + * and this checks again): their local pane runs the ssh / docker-exec CLIENT, + * so its directory says nothing about the agent. An unknown paneCwd falls back to + * `workingDir`. + * + * @param {{workingDir?: string, paneCwd?: string, remote?: unknown, docker?: unknown}} [session] + * @returns {string|undefined} + */ +function relativeResolutionBase(session) { + if (!session) return undefined; + const paneCwd = session.paneCwd; + if (!session.remote && !session.docker && typeof paneCwd === 'string' && paneCwd.startsWith('/')) return paneCwd; + return session.workingDir; +} + /** * Extensions the file preview renders as a PAGE, from the sandboxed * `/html-view/<cap>/` route (src/web/routes/html-view-routes.ts, whose @@ -2344,8 +2523,16 @@ function terminalLogicalLine(buffer, row, cols, maxRows) { const continuesPrevious = (r) => { if (r <= 0) return false; if (lineAt(r)?.isWrapped) return true; + // A divider row never continues the row above, even when that row reaches + // the right edge: a URL ending at the edge must not absorb `────` below it. + if (/^[\s─━═╌╍┄┅┈┉-]+$/u.test(lineAt(r)?.translateToString(true) || '')) return false; const prev = lineAt(r - 1); - return !!prev && (prev.translateToString(true) || '').length >= width; + const prevText = prev?.translateToString(true) || ''; + // A full-width table divider is a row boundary, not a hard-wrapped line. + // Treating it as a continuation glues the divider onto the row below and + // hides that row's wrapped URL from the link provider. + if (/^[\s─━═╌╍┄┅┈┉-]+$/u.test(prevText)) return false; + return prevText.length >= width; }; let startRow = row; @@ -2396,6 +2583,16 @@ const FILE_LINK_ROOT_GROUP = /^\(\\\/\(\?:([^()]*)\)/.exec(FILE_PATH_LINK_PATTER const PATH_TOKEN_ENDS_WITH_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}$`); const PATH_TOKEN_HAS_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}\\b`); const PATH_TOKEN_STARTS_NEW_PATH = new RegExp(`^(?:~\\/|\\/(?:${FILE_LINK_ROOT_GROUP})\\/)`); +/** + * Lowercase connectives and verbs that put a spaced candidate in a sentence, for + * {@link spacedFilePathOnLine}. `at` stays out (`Screenshot 2026-10-24 at 10.00.00.png`) + * and so does `of` (`Copy of report.docx`); a name using a listed word + * (`Pros and Cons.md`) splits as it did before spaced paths were linked. + */ +const SPACED_PATH_PROSE_WORD = + /^(?:and|or|to|in|into|from|the|is|are|was|were|has|have|had|contains|holds|see|then|vs|via|as)$/; +/** A rooted path's start (`/home/`, `/mnt/`, …), for {@link spacedFilePathOnLine}. */ +const SPACED_PATH_ROOT = new RegExp(`^\\/(?:${FILE_LINK_ROOT_GROUP})\\/`); /** A relative head that names its own location (`./a`, `../a`) is an entry of its own. */ const PATH_TOKEN_STARTS_EXPLICIT_RELATIVE = /^\.{1,2}\//; /** First directory of a path, a leading `/` or `./` ignored: `src` for `src/a.ts` and `/src/a.ts`. */ @@ -2414,9 +2611,10 @@ const pathFirstSegment = (path) => path.replace(/^(?:\.?\/)+/, '').split('/')[0] * top directory the broken token did (the next entry of a listing). */ function pathContinuation(prevText, nextText) { - const tail = /[^\s"'<>|;&]+$/.exec(String(prevText || '').replace(/\s+$/, ''))?.[0] || ''; - if (!tail.includes('/') || !/[\w\-.~+@%/]$/.test(tail) || PATH_TOKEN_ENDS_WITH_EXTENSION.test(tail)) return null; + const previous = String(prevText || '').trimEnd(); const next = String(nextText || '').replace(/^\s+/, ''); + const tail = /[^\s"'<>|;&]+$/.exec(previous)?.[0] || ''; + if (!tail.includes('/') || !/[\w\-.~+@%/]$/.test(tail) || PATH_TOKEN_ENDS_WITH_EXTENSION.test(tail)) return null; const head = /^[^\s"'<>|;&]+/.exec(next)?.[0] || ''; if (!head || PATH_TOKEN_STARTS_NEW_PATH.test(head) || PATH_TOKEN_STARTS_EXPLICIT_RELATIVE.test(head)) return null; // A listing (`git ls-files`, `find`, `ls -R`) puts one path per row, so `src/components` @@ -2443,7 +2641,9 @@ function pathContinuation(prevText, nextText) { * A following logical line is glued on (its indent dropped, cell mapping kept * exact) when {@link pathContinuation} says it carries the path on and the row it * broke off is at least as long as the row it continues on. Bounded by the same - * `maxRows` as the logical line. + * `maxRows` as the logical line. Hard breaks concatenate without inserting spaces. + * An ambiguous parenthesised filename also carries `pathAlternatives` for a + * guarded lookup on activation; its text and cell mapping remain unchanged. * * @returns Same shape as {@link terminalLogicalLine}; null when the row does not exist. */ @@ -2464,6 +2664,7 @@ function terminalPathLine(buffer, row, cols, maxRows) { /** `first` and the lines a path carries on into, stopping at the last confirmed join. */ const extend = (first) => { const parts = [first]; + const alternatives = []; let text = first.text; let rows = rowsOf(first); let confirmed = 1; @@ -2473,25 +2674,41 @@ function terminalPathLine(buffer, row, cols, maxRows) { const kind = continues(parts[parts.length - 1], next, text); if (!kind) break; parts.push(next); + if (kind === 'join') { + const opening = text.lastIndexOf('('); + const closing = next.text.indexOf(')'); + if (opening >= 0 && closing > 0 && !opensBashCall(text, opening)) { + const before = text.slice(opening + 1); + const after = next.text.slice(0, closing).trimStart(); + const spaced = spacedFilePathOnLine(`${before} ${after}`, { parenthesised: true }); + if (!before.includes(')') && !/[/-]$/.test(before) && !after.includes('/') && spaced?.link) { + alternatives.push({ path: before.slice(spaced.index) + after, spacedPath: spaced.path }); + } + } + } text += next.text.replace(/^\s+/, ''); rows += rowsOf(next); if (kind === 'join') confirmed = parts.length; } - return parts.slice(0, confirmed); + return { parts: parts.slice(0, confirmed), alternatives }; }; - // The hovered row may be the second or third line of a broken path: walk back - // to where it could start, then let the forward pass decide what really joins. + // A middle fragment may have no slash, so only a forward pass from its + // rooted start can confirm a path spanning three or more rows. + let extended = extend(base); let first = base; let rows = rowsOf(base); while (first.startRow > 0) { const prev = lineAt(first.startRow - 1); - if (!prev || rows + rowsOf(prev) > bound || !continues(prev, first, prev.text)) break; + if (!prev || rows + rowsOf(prev) > bound) break; first = prev; rows += rowsOf(prev); + const tail = /[^\s"'<>|;&]+$/.exec(first.text.trimEnd())?.[0] || ''; + if (!tail.includes('/')) continue; + const candidate = extend(first); + if (candidate.parts[candidate.parts.length - 1].endRow >= base.startRow) extended = candidate; } - let parts = extend(first); - if (parts[parts.length - 1].endRow < base.startRow) parts = extend(base); + const { parts, alternatives } = extended; if (parts.length === 1) return parts[0]; const segments = []; @@ -2516,7 +2733,410 @@ function terminalPathLine(buffer, row, cols, maxRows) { } return -1; }; - return { startRow: parts[0].startRow, endRow: parts[parts.length - 1].endRow, text, offsetToCell, cellToOffset }; + return { + startRow: parts[0].startRow, + endRow: parts[parts.length - 1].endRow, + text, + offsetToCell, + cellToOffset, + ...(alternatives.length ? { pathAlternatives: alternatives } : {}), + }; +} + +/** + * Web URLs in terminal text, with the trimming the link provider applies. + * + * A single `&` is part of the URL (query separator); `&&` is the shell operator + * and ends it. `)` is kept INSIDE a URL when it closes a `(` that is also inside + * it (`…/Team-Space(QA)Archive/…` used to link only as far as `(QA`), and an + * unmatched `)` ends the URL, which keeps the closing paren of `(https://…)` and + * `[text](https://…)` out of the link. Trailing sentence punctuation is trimmed. + * + * @param {string} text + * @returns {Array<{url: string, startIndex: number, endIndex: number, rawEnd: number}>} + * `endIndex` is exclusive; `rawEnd` is where the match ran to before the + * paren/punctuation trim (the wrap-continuation test needs it). + */ +function findTerminalUrls(text) { + // Named `urlPattern` on purpose: test/link-provider-regex.test.ts extracts it + // by name to prove it stays linear-time on hostile lines. + const urlPattern = /https?:\/\/(?:[^\s"'<>|;&\]\x00-\x1f]|&(?!&))+/g; + const urls = []; + let match; + while ((match = urlPattern.exec(String(text || ''))) !== null) { + const raw = match[0]; + let end = raw.length; + let depth = 0; + for (let i = 0; i < raw.length; i++) { + if (raw[i] === '(') depth++; + else if (raw[i] === ')') { + if (depth === 0) { + end = i; + break; + } + depth--; + } + } + const url = raw.slice(0, end).replace(/[.,;:!?&]+$/, ''); + if (!/:\/\/[^/]/.test(url)) continue; + urls.push({ url, startIndex: match.index, endIndex: match.index + url.length, rawEnd: match.index + raw.length }); + } + return urls; +} + +/** + * The row span a URL covers when the CLI wrapped it with its OWN newlines at a + * point short of the terminal width. + * + * {@link terminalLogicalLine} joins soft wraps and full-width hard wraps. A CLI + * that word-wraps inside a table cell or a paragraph (codex's markdown tables, + * for one) breaks a long URL at `/`, `?`, `-` … well before the right edge and + * indents the rest to the cell's column, so neither rule fires and each row + * linked a fragment. A row continues the URL above it only when ALL hold: + * - the URL fragment ends on a character a wrapper breaks after + * (`/ ? & = - _ % # + ~`); a following table column may be excluded; + * - the next row is indented, but no further right than the column the URL + * started in (a table cell or list item continuation, not unrelated text), + * and no column gap lies between its indent and the URL on the first row + * (that would be the next record of a table); + * - that row's first token looks like URL material (contains one of + * `% & = / ? . - _`), is not a bare number/version or list marker, + * and is not itself a new `scheme://`, a shell prompt (`user@host:`), + * a home path (`~/`), an email address, an HTTP status line, a label + * ending in `:`, a measurement or clock time (`45%`, `12.3ms`, + * `10:00:00`), or a `name=value` field under a URL ending in `/`; + * without `% & = / ?`, the token must finish its cell (row end or a + * gap of at least two spaces) and must not be code with a call paren + * or a word ending a sentence (`Done.`). + * The last rule is what stops prose such as "…/files/" + "then run it" from + * being glued into one link. + * + * Bounded by `maxRows`, like the logical line. Pure: reads rows only. + * + * @param {{getLine: (row: number) => any, length: number}} buffer + * @param {number} row 0-based absolute row being examined. + * @param {number} cols Terminal width. + * @param {number} [maxRows] + * @returns {{startRow: number, endRow: number, firstRowUrlEnd?: number} | null} + * `firstRowUrlEnd` is where the URL's cell ends on the first row when + * other table columns follow it and that row does not soft-wrap; null + * means the row is not part of a URL continued this way. + */ +function terminalUrlContinuationExtent(buffer, row, cols, maxRows) { + const bound = Math.max(1, maxRows || 12); + const here = terminalLogicalLine(buffer, row, cols, bound); + if (!here) return null; + + const chainFrom = (start) => { + const first = terminalLogicalLine(buffer, start, cols, bound); + if (!first || first.startRow !== start) return null; + let text = first.text.replace(/\s+$/, ''); + let endRow = first.endRow; + let urlStartCol = -1; + let firstRowUrlEnd = null; + let extended = false; + while (endRow - start + 1 < bound) { + const urls = findTerminalUrls(text); + const last = urls[urls.length - 1]; + // Only on a one-row first line: firstRowUrlEnd cuts the first PHYSICAL + // row (terminalLinkLine), so a column sitting on the soft-wrapped part of + // the URL's row would stay in the text and take the next row with it. + const trailingColumns = + first.startRow === first.endRow && + endRow === first.endRow && + last && + last.rawEnd < text.length && + /^ {2,}\S/.test(text.slice(last.rawEnd)); + const urlText = trailingColumns ? text.slice(0, last.rawEnd) : text; + if (!last || last.rawEnd !== urlText.length || !/[/?&=\-_%#+~]$/.test(urlText)) break; + if (urlStartCol < 0) urlStartCol = first.offsetToCell(last.startIndex).col; + const next = buffer.getLine(endRow + 1); + if (!next || next.isWrapped) break; + const nextText = next.translateToString(true) || ''; + const indent = nextText.length - nextText.replace(/^\s+/, '').length; + const token = nextText.slice(indent).split(/\s/, 1)[0]; + // A hard continuation is indented (a table cell, a list item, Claude's + // two-space paragraph indent); a column-0 row is a new line of output. + if (!token || !indent || indent > urlStartCol || /^(?:\d+[.)]|[-*•])(?:\s|$)/.test(nextText.slice(indent))) + break; + // When other table columns follow the URL, the next row must align with + // the start of its cell. A new numbered row must not become URL text. + if ( + trailingColumns && + (!indent || + first.text[indent - 1] !== ' ' || + first.text[indent] === ' ' || + / {2,}/.test(first.text.slice(indent, last.startIndex))) + ) + break; + // The next row must start inside the URL's own cell: a column gap between + // its indent and the URL on the first row means it opens an earlier + // column (the next record of a table), not the rest of the URL. + if (/ {2,}/.test(first.text.slice(indent, last.startIndex).trimStart())) break; + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(token) || !/^[A-Za-z0-9%_~]/.test(token)) break; + // A shell prompt (`user@host:~/dir$`), a home path and an HTTP status line + // are never the rest of a URL, however much URL punctuation they carry. + if (/^[\w.-]+@[\w.-]+:|^~\/|^HTTP\/\d/i.test(token)) break; + // Nor is a token that is a whole email address. + if (/^[\w.+-]+@[\w-]+(?:\.[\w-]+)+$/.test(token)) break; + if (!/[%&=/?.\-_]/.test(token) || /^v?\d+(?:\.\d+)*(?:[-+][\w.-]+)?[.,;:!?)]?$/i.test(token)) break; + // A label ends with a colon (`Failure/Error:`, `prometheus.io/scrape:`); + // the rest of a URL does not. + if (token.endsWith(':')) break; + // After a `/`, a `name=value` token is the next assignment or log field + // (`API_KEY=...`, `level=info`), not the URL: a query starts with `?`. + if (urlText.endsWith('/') && /^[A-Za-z_][\w.-]*=/.test(token)) break; + // Nor is a measurement or a clock time: `45%` and `45%|####` progress, + // `12.3MB/45.0MB`, `12.3ms`, `10:00:00` (`%7B` stays percent-encoding). + if (/^\d+(?:\.\d+)?(?:%(?![0-9A-Fa-f]{2})|(?:ms|s|[kKMGT]i?B)(?![\w-]))|^\d{1,2}:\d{2}/.test(token)) break; + // Hyphens, underscores and dots also occur in prose. Without stronger + // URL punctuation, the token must finish its cell, not introduce a sentence. + const rest = nextText.slice(indent + token.length); + if (!/[%&=/?]/.test(token)) { + // Code (`client.get()`, `expect(x).toBe(y)`) is not a URL piece, and a + // word that ends a sentence (`ready.`, `Done.`) is prose. A wiki title + // (`Mercury_(planet)`) and a file name before its period + // (`install.html.`) still continue the URL. + if (/[^_]\(/.test(token)) break; + if (/[.,;!]$/.test(token) && !/\.[A-Za-z][A-Za-z0-9]{1,4}[.,;!]+$/.test(token)) break; + if (rest.trim() !== '' && !/^ {2,}/.test(rest)) break; + } + const nextLogical = terminalLogicalLine(buffer, endRow + 1, cols, bound); + if (!nextLogical) break; + if (trailingColumns) firstRowUrlEnd = last.rawEnd; + text = urlText + nextLogical.text.slice(indent).replace(/\s+$/, ''); + endRow = nextLogical.endRow; + extended = true; + } + return { startRow: start, endRow, extended, firstRowUrlEnd }; + }; + + // Walk up through earlier logical lines: the nearest one whose URL chain + // reaches this row owns it. + let start = here.startRow; + while (start >= 0 && here.startRow - start < bound) { + const chain = chainFrom(start); + if (chain && chain.extended && chain.endRow >= here.startRow) { + return { + startRow: chain.startRow, + endRow: chain.endRow, + ...(chain.firstRowUrlEnd === null ? {} : { firstRowUrlEnd: chain.firstRowUrlEnd }), + }; + } + if (start === 0) break; + const above = terminalLogicalLine(buffer, start - 1, cols, bound); + if (!above) break; + start = above.startRow; + } + return null; +} + +/** + * The text the link provider scans for `row`: {@link terminalPathLine}, widened + * to the rows of a URL the CLI wrapped short of the edge + * ({@link terminalUrlContinuationExtent}). Same shape as terminalPathLine. + * + * Over a widened span every row is read trimmed (except one a soft wrap + * continues from, which fills the width), a HARD continuation's indent is + * dropped (the cell mapping keeps the cells), and the first row stops at the + * URL's cell when other table columns follow it, so those columns never sit + * between the URL's pieces. Those columns still link: they are moved after the + * stitched rows as a segment of their own, mapped back to their cells. + * + * @returns Same shape as {@link terminalPathLine}; null when the row does not exist. + */ +function terminalLinkLine(buffer, row, cols, maxRows) { + const base = terminalPathLine(buffer, row, cols, maxRows); + if (!base) return null; + const extent = terminalUrlContinuationExtent(buffer, row, cols, maxRows); + if (!extent || (extent.startRow >= base.startRow && extent.endRow <= base.endRow)) return base; + const startRow = Math.min(extent.startRow, base.startRow); + const endRow = Math.max(extent.endRow, base.endRow); + const segments = []; + let text = ''; + let columnsAfterUrl = ''; + for (let r = startRow; r <= endRow; r++) { + const line = buffer.getLine(r); + if (!line) break; + const next = r < endRow ? buffer.getLine(r + 1) : null; + let rowText = line.translateToString(!(next && next.isWrapped)) || ''; + if (r === extent.startRow && extent.firstRowUrlEnd != null) { + columnsAfterUrl = rowText.slice(extent.firstRowUrlEnd); + rowText = rowText.slice(0, extent.firstRowUrlEnd); + } + let colStart = 0; + if (r > startRow && !line.isWrapped) { + colStart = rowText.length - rowText.replace(/^\s+/, '').length; + rowText = rowText.slice(colStart); + } + segments.push({ row: r, textStart: text.length, colStart }); + text += rowText; + } + const lastRow = segments[segments.length - 1].row; + // The columns cut off the URL's first row (a path or date column of a table) + // are scanned after the stitched rows, so they keep their links on that row. + // They open with the cell gap, which keeps them apart from the URL; the one + // space before them belongs to the last row, so an end offset there still + // maps onto that row and never onto their first cell. + if (columnsAfterUrl.trim()) { + text += ' '; + segments.push({ row: extent.startRow, textStart: text.length, colStart: extent.firstRowUrlEnd }); + text += columnsAfterUrl; + } + const offsetToCell = (offset) => { + for (let i = segments.length - 1; i >= 0; i--) { + const seg = segments[i]; + if (offset >= seg.textStart || i === 0) return { row: seg.row, col: seg.colStart + (offset - seg.textStart) }; + } + return { row: startRow, col: offset }; + }; + // The cut row has two segments: the one starting at or left of the cell owns it. + const cellToOffset = (targetRow, targetCol) => { + let seg = null; + for (const s of segments) if (s.row === targetRow && (!seg || s.colStart <= targetCol)) seg = s; + return seg ? seg.textStart + Math.max(0, targetCol - seg.colStart) : -1; + }; + return { + startRow, + endRow: lastRow, + text, + offsetToCell, + cellToOffset, + ...(base.pathAlternatives ? { pathAlternatives: base.pathAlternatives } : {}), + }; +} + +/** + * Absolute file paths the CLI word-wrapped INSIDE a table cell (codex markdown + * tables), returned as one link per path with a segment per row. + * + * Before screenshot (/tmp/x/docs/review/ After screenshot (/tmp/x/review/BLDMESH- + * BLDMESH-243/before-overview.png) 243/after-overview.png) + * + * Neither fragment matches FILE_PATH_LINK_PATTERN alone (the first has no + * extension, the second no absolute root), and a linear stitch of the rows + * cannot serve a two-column table: each column's continuation sits in the same + * next row. So each fragment is followed DOWN ITS OWN CELL: the cell's left + * edge is where its text starts after the previous ≥2-space column gap, and the + * continuation is the token starting at exactly that column on the next row. + * A fragment qualifies only when it is the last thing in its cell on that row + * (row end or a ≥2-space gap follows) and ends on `/` or `-`, the only places + * a wrapper breaks a path; a cell that ends inside a name (`/home/u/proj` over + * `config.yaml`) is a whole value, never the first piece of the next row's. A + * continuation row must be blank to the left of the cell (a wrapper wraps every + * column of one table row together), unless a path in a left column continued + * on it too: a label there (`Entry`, `│ Log │`) is the next record. Every + * continuation must pass pathContinuation and its cell must be no wider than the + * preceding cell's text, so independent listing rows and longer prose never + * displace links. + * + * Pure: reads rows only. Cells are taken one per string character (xterm rows + * with wide glyphs would shift columns; such rows simply fail the alignment). + * + * @param {{getLine: (row: number) => any, length: number}} buffer + * @param {number} row 0-based absolute row being examined. + * @param {number} [maxRows] Bound on rows one path may span. + * @returns {Array<{path: string, segments: Array<{row: number, startCol: number, endCol: number}>}>} + * Paths with a segment on `row`; `endCol` is exclusive, columns 0-based. + */ +function terminalCellWrappedPaths(buffer, row, maxRows) { + const bound = Math.max(2, maxRows || 4); + const rootPattern = new RegExp(`\\/(?:${FILE_LINK_ROOT_GROUP})\\/`, 'g'); + // Box-drawing verticals are cell borders (Claude Code draws tables with them). + const isPathChar = (ch) => !!ch && !/[\s"'<>|;&\x00-\x1f│┃║]/.test(ch); + const rowText = (r) => { + const line = buffer.getLine(r); + return line ? (line.translateToString(true) || '').replace(/\s+$/, '') : null; + }; + // A fragment is the last text in its cell on this row when the row ends, a + // column gap (≥2 spaces) follows it, or a cell border does. + const endsCell = (text, end) => end >= text.length || /^(?: {2,}| *[│┃║|])/.test(text.slice(end)); + + const fromStart = (startRow) => { + const found = []; + const text = rowText(startRow); + if (!text) return found; + rootPattern.lastIndex = 0; + let m; + while ((m = rootPattern.exec(text)) !== null) { + const start = m.index; + if (start > 0 && !/[\s(\[{"'`<]/.test(text[start - 1])) continue; + let end = start; + while (end < text.length && isPathChar(text[end])) end++; + rootPattern.lastIndex = Math.max(end, start + 1); + if (!endsCell(text, end)) continue; + // Already a complete path on its own row: the ordinary detector owns it. + const alone = absoluteFilePathPattern().exec(text.slice(start, end)); + if (alone && alone.index === 0 && alone[0].length === end - start) continue; + // The cell's left edge: just past the nearest ≥2-space gap or cell border + // (and the padding after it) before the path. + const before = text.slice(0, start); + let cellCol = before.length - before.replace(/^\s+/, '').length; + const edge = /(?: {2,}|[│┃║|] *)(?=\S)/g; + let e; + const upToPath = text.slice(0, start + 1); + while ((e = edge.exec(upToPath)) !== null) cellCol = e.index + e[0].length; + + let joined = text.slice(start, end); + let previousCellLength = end - cellCol; + const segments = [{ row: startRow, startCol: start, endCol: end }]; + for (let r = startRow + 1; r < startRow + bound; r++) { + const line = buffer.getLine(r); + if (!line || line.isWrapped) break; + const next = rowText(r); + if (!next || !isPathChar(next[cellCol]) || (cellCol > 0 && next[cellCol - 1] !== ' ')) break; + let tokenEnd = cellCol; + while (tokenEnd < next.length && isPathChar(next[tokenEnd])) tokenEnd++; + const token = next.slice(cellCol, tokenEnd); + // Include prose after the token in this cell, but not another column. + const cellText = next.slice(cellCol).split(/ {2,}|[│┃║|]/, 1)[0].trimEnd(); + // A wrapper breaks a path after `/` or `-`; a fragment that ends inside a + // name (`/home/u/proj` over `config.yaml`) is a whole value, not a piece. + // codex wraps table cells on Unicode line-break opportunities plus a + // hyphen splitter, which break after `/` and `-` but never at `_` (UAX #14 + // class AL). + if (!/[/-]$/.test(joined)) break; + // A wrapper wraps every column of one table row together, so a row that + // carries a cell's continuation is blank to its left unless a path in a + // left column continued on that row too. A label there (`Entry`, + // `│ Log │`) opens the next record, even under a directory ending in `/` + // (`Workspace /home/u/proj/app/` over `Entry main.py`). A left column of + // wrapped prose (a `Description | Path` table) fills that row as well, so + // the path beside it is not joined and keeps its per-row fragment links: + // the safe direction. + // Known trade-off: with nothing to the left, a directory written with its + // trailing slash over a relative path in the same column (`/home/u/proj/` + // over `index.ts`) still joins, since it reads exactly like a wrap at `/`. + if ( + /[^\s│┃║|]/.test(next.slice(0, cellCol)) && + !found.some((hit) => hit.segments.some((seg) => seg.row === r && seg.endCol <= cellCol)) + ) { + break; + } + if (cellText.length > previousCellLength || !pathContinuation(joined, token)) break; + const candidate = joined + token; + const match = absoluteFilePathPattern().exec(candidate); + if (match && match.index === 0 && match[0].length > joined.length) { + segments.push({ row: r, startCol: cellCol, endCol: cellCol + match[0].length - joined.length }); + found.push({ path: match[0], segments }); + break; + } + if (!endsCell(next, tokenEnd)) break; + joined = candidate; + previousCellLength = cellText.length; + segments.push({ row: r, startCol: cellCol, endCol: tokenEnd }); + } + } + return found; + }; + + const results = []; + for (let s = row; s >= 0 && row - s < bound; s--) { + for (const hit of fromStart(s)) { + if (hit.segments.some((seg) => seg.row === row)) results.push(hit); + } + } + return results; } // ═══════════════════════════════════════════════════════════════ @@ -3349,7 +3969,9 @@ function clampTerminalDimensions(proposed) { * could reach it. Output visible, typing invisible, for as long as the claim * stayed hot. Width is the axis the wrap arithmetic depends on; rows only * decide how much is on screen at once, and keeping the local row count keeps - * the composer at the bottom of a viewport that scrolls. + * the composer at the bottom of a viewport that scrolls. The one deliberate + * screen taller than its box is this device's own rows held under the + * on-screen keyboard, which keyboardClipPx keeps the composer in view for. * * @param {{cols: number, rows: number}|null} local - what xterm currently holds * @param {{cols: number, rows: number}|null} pty - what the server just reported @@ -3361,6 +3983,62 @@ function reconcilePtyGeometry(local, pty) { return { adopt: true, cols: pty.cols }; } +/** Rows below the last content within which the cursor still counts (keyboardClipPx). */ +const KEYBOARD_CLIP_CURSOR_REACH = 2; + +/** + * How far to scroll the terminal SCREEN up while its rows are held under the + * on-screen keyboard (setting `keyboardHoldsRows`). + * + * Every PTY height change makes Claude Code clear the screen and reprint only + * the transcript tail that fits, so rows above that tail are lost from tmux and + * older ones stay in history as stale copies. The keyboard opening and closing + * was such a change each time. Instead the rows stay as they were and + * `.xterm-screen` (taller than its box now) is translated up by this many px. + * + * This is the one deliberate exception to "a screen taller than its container + * hides the input line" (see reconcilePtyGeometry): the offset below is what + * keeps the live content in view. + * + * - Following live output: the last row with content sits at the bottom of the + * box. A fresh shell prompting on row 1 shows the top; Claude's footer under + * its composer stays visible. The cursor counts only when it is right below + * that content (a prompt or composer line still empty): a TUI that parks its + * cursor far below what it drew (codex's dialogs, with the cursor on the last + * row of an otherwise blank screen) must not scroll its content out of view. + * - Reading history: the offset follows the scroll position, so the top of the + * scrollback is reachable and scrolling stays continuous. + * + * Recomputed on every render with no hysteresis; add some if a TUI's mid-redraw + * frames ever make the view jitter. + * + * @param {{rows: number, cellHeight: number, viewHeight: number, cursorRow: number, + * lastContentRow: number, following: boolean, viewportY: number, baseY: number}} input + * lastContentRow: the last non-blank screen row, or -1 when the screen is blank + * @returns {number} px, between 0 and the screen's overflow + */ +function keyboardClipPx(input) { + const { rows, cellHeight, viewHeight, cursorRow, lastContentRow, following, viewportY, baseY } = input; + if (!(cellHeight > 0) || !(viewHeight > 0) || !(rows > 0)) return 0; + const max = Math.max(0, rows * cellHeight - viewHeight); + if (max === 0) return 0; + if (!following && baseY > 0) return Math.round(max * Math.min(1, Math.max(0, viewportY / baseY))); + const cursorCounts = lastContentRow < 0 || cursorRow <= lastContentRow + KEYBOARD_CLIP_CURSOR_REACH; + const contentBottomRow = cursorCounts ? Math.max(cursorRow, lastContentRow) : lastContentRow; + const bottom = (Math.min(rows - 1, Math.max(0, contentBottomRow)) + 1) * cellHeight; + return Math.round(Math.min(max, Math.max(0, bottom - viewHeight))); +} + +/** + * How many screen rows, from the top, are fully visible at a given offset: the + * local-echo overlay grows upward from the last of them (`setRowLimit`). + * @returns {number} + */ +function keyboardVisibleRowLimit(clipPx, viewHeight, cellHeight) { + if (!(cellHeight > 0)) return 0; + return Math.max(1, Math.floor((clipPx + viewHeight) / cellHeight)); +} + /** * Which session does a dashboard URL's fragment ask for? Another page that * holds the dashboard's window, such as a task board, points it at @@ -3435,12 +4113,20 @@ if (typeof window !== 'undefined') { absoluteFilePathPattern, findFilePathLinks, resolveLinkedFilePath, + relativeResolutionBase, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS, rendersAsPage, HTML_PAGE_EXTENSIONS, }; - window.CodemanTerminalLines = { terminalLogicalLine, terminalPathLine }; + window.CodemanTerminalLines = { + terminalLogicalLine, + terminalPathLine, + terminalLinkLine, + terminalUrlContinuationExtent, + findTerminalUrls, + terminalCellWrappedPaths, + }; window.CodemanUrlSession = { sessionIdFromFragment }; window.CodemanSplitPane = { clampDividerPercent, @@ -3491,6 +4177,8 @@ if (typeof window !== 'undefined') { window.CodemanTerminalGeometry = { clampTerminalDimensions, reconcilePtyGeometry, + keyboardClipPx, + keyboardVisibleRowLimit, TERMINAL_MIN_COLS, TERMINAL_MIN_ROWS, }; diff --git a/src/web/public/edit-history-ui.js b/src/web/public/edit-history-ui.js new file mode 100644 index 00000000..da26ac2d --- /dev/null +++ b/src/web/public/edit-history-ui.js @@ -0,0 +1,630 @@ +/** + * @fileoverview Edit history: a header button (next to Attachments) and the floating panel it opens + * right below, listing every file edit the ACTIVE session's agent (and its subagents) made, newest + * first. + * + * OPTIONAL and per-device: `showEditHistory` (App Settings → Header & Panels → Header), default + * OFF. While it is off the button never shows and nothing is requested. The panel DRIVES the + * server's reading, which never runs in the background: it reads + * `GET /api/sessions/:id/edits?sync=1` when it opens and when the active session changes, and, + * only while it is open, re-reads (debounced) on the active session's `transcript:tool_end` for a + * tool that can change files (the existing transcript watcher's event) and on its `session:idle` + * (the end of a turn). No poll, and nothing at all while the panel is closed. + * + * - A code entry opens its diff inline (`GET /api/sessions/:id/edits/:entryId`), drawn with the Git + * panel's diff classes (`git-diff`, `git-diff-line--hunk|add|del`). + * - An image entry opens the shared file preview (panels-ui.js `openFilePreview`), as does a click + * on any path; inside the workspace it also shows a thumbnail. Both go through the existing file + * routes only: the thumbnail is the confined `file-raw`, and the preview of a file outside the + * workspace registers it on click with `notify: false` (`_registerExternalPreview`, which + * enforces the attachment guard and its extension allowlist). A path from the transcript is + * never served by a route of its own. + * - `backfillTruncated` offers "Load older" (`POST /api/sessions/:id/edits/older`). + * + * Paths, agent descriptions and diffs are untrusted text: they are only ever written with + * `textContent`, never `innerHTML`. + * + * @mixin Extends CodemanApp.prototype via Object.assign + * @dependency app.js (this.activeSessionId, this.sessions, this.loadAppSettingsFromStorage, this.getDefaultSettings, this.$) + * @dependency api-client.js (this._api, this._apiJson; resolved at call time) + * @dependency panels-ui.js (openFilePreview, _isExternalPreviewPath) + * @dependency i18n.js (window.codemanT) + * @loadorder 12.58 of 16, after git-status-ui.js, before entrance-animations.js + */ + +/** SSE bursts (one event per tool call) collapse into one read. */ +const EDIT_HISTORY_SSE_DEBOUNCE_MS = 600; +/** Tools whose end can mean changed files (Agent/Task: a subagent's edits; SendUserFile: an image). */ +const EDIT_HISTORY_FOLLOW_TOOLS = new Set([ + 'Edit', + 'Write', + 'MultiEdit', + 'NotebookEdit', + 'Bash', + 'Agent', + 'Task', + 'SendUserFile', +]); +/** Per-device view toggles (a viewer convenience, so plain localStorage). */ +const EDIT_HISTORY_GROUP_KEY = 'codeman-edit-history-group'; +const EDIT_HISTORY_MAIN_ONLY_KEY = 'codeman-edit-history-main-only'; + +Object.assign(CodemanApp.prototype, { + /** Per-device setting, default OFF. */ + isEditHistoryEnabled() { + const settings = this.loadAppSettingsFromStorage(); + const defaults = this.getDefaultSettings(); + return (settings.showEditHistory ?? defaults.showEditHistory ?? false) === true; + }, + + /** Translate an English source string (i18n.js), with `{var}` interpolation. */ + _eht(source, vars) { + if (typeof window.codemanT === 'function') return window.codemanT(source, vars); + return vars ? source.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m)) : source; + }, + + /** A row badge ("new"/"edit") through its scoped i18n key; English shows the bare word. */ + _ehBadge(word) { + const key = `Edit history badge: ${word}`; + const out = this._eht(key); + return out === key ? word : out; + }, + + /** Called from applyHeaderVisibilitySettings() (boot and every settings save). */ + applyEditHistoryVisibility() { + const on = this.isEditHistoryEnabled(); + // Marker class, not [hidden]: the base rule is display:inline-flex !important (src/web/CLAUDE.md). + this.$('editHistoryBtn')?.classList.toggle('btn-edit-history--hidden', !on); + if (!on) { + this.closeEditHistoryPanel(); + this._editHistory = null; + this._editHistoryEpoch = (this._editHistoryEpoch || 0) + 1; // an in-flight read must not repaint + clearTimeout(this._editHistoryDebounce); + this._editHistoryDebounce = null; + } + }, + + // ── Per-device view toggles ───────────────────────────────────────────── + + _editHistoryPref(key) { + try { + return localStorage.getItem(key) === '1'; + } catch { + return false; + } + }, + + _setEditHistoryPref(key, on) { + try { + localStorage.setItem(key, on ? '1' : '0'); + } catch { + /* private window / blocked storage: the toggle still works for this page */ + } + }, + + isEditHistoryGrouped() { + return this._editHistoryGroupOn ?? (this._editHistoryGroupOn = this._editHistoryPref(EDIT_HISTORY_GROUP_KEY)); + }, + + isEditHistoryMainOnly() { + return this._editHistoryMainOnly ?? (this._editHistoryMainOnly = this._editHistoryPref(EDIT_HISTORY_MAIN_ONLY_KEY)); + }, + + setEditHistoryGrouped(on) { + this._editHistoryGroupOn = !!on; + this._setEditHistoryPref(EDIT_HISTORY_GROUP_KEY, !!on); + this._renderEditHistoryPanel(); + }, + + setEditHistoryMainOnly(on) { + this._editHistoryMainOnly = !!on; + this._setEditHistoryPref(EDIT_HISTORY_MAIN_ONLY_KEY, !!on); + this._renderEditHistoryPanel(); + }, + + // ── Data ──────────────────────────────────────────────────────────────── + + /** The data for the session on screen, or null. */ + _currentEditHistory() { + const h = this._editHistory; + return h && h.sessionId === this.activeSessionId ? h : null; + }, + + _editHistoryBase(sid) { + return `/api/sessions/${encodeURIComponent(sid)}/edits`; + }, + + /** + * Read the active session's history and repaint. `older` asks the server for entries before the + * backfill window (POST), otherwise the normal read. Answers for another session are dropped. + */ + async refreshEditHistory({ older = false } = {}) { + if (!this.isEditHistoryEnabled()) return; + const sid = this.activeSessionId || null; + if (!sid) { + this._editHistory = null; + this._renderEditHistoryPanel(); + return; + } + const epoch = (this._editHistoryEpoch = (this._editHistoryEpoch || 0) + 1); + const prev = this._currentEditHistory(); + if (!prev) { + // Another session's list must not stay on screen while this one loads. + this._editHistory = { sessionId: sid, state: 'loading', data: null, diffs: new Map(), open: new Set() }; + this._renderEditHistoryPanel(); + } else if (older) { + prev.loadingOlder = true; + this._renderEditHistoryPanel(); + } + const base = this._editHistoryBase(sid); + const data = older + ? await this._apiJson(`${base}/older`, { method: 'POST', body: {} }) + : await this._apiJson(`${base}?sync=1`); + if (epoch !== this._editHistoryEpoch || sid !== this.activeSessionId || !this.isEditHistoryEnabled()) return; + const cur = this._currentEditHistory() || { + sessionId: sid, + diffs: new Map(), + open: new Set(), + }; + cur.loadingOlder = false; + if (data && typeof data === 'object') { + cur.state = 'ok'; + cur.data = data; + } else if (!cur.data) { + cur.state = 'error'; + } + this._editHistory = cur; + this._renderEditHistoryPanel(); + }, + + loadOlderEditHistory() { + return this.refreshEditHistory({ older: true }); + }, + + /** + * SSE `transcript:tool_end` ({ sessionId, toolName }), and the end of a turn through + * `onEditHistorySessionIdle`: while the panel is open on that session, re-read after a tool that + * can change files, and when the turn ends (a parallel tool call reports only one tool end; a + * background subagent writes after the tool end that started it). Closed: nothing. + */ + _onEditHistoryTranscriptEvent(data) { + if (!data || data.sessionId !== this.activeSessionId || !this._isEditHistoryPanelOpen()) return; + if (data.toolName !== undefined && !EDIT_HISTORY_FOLLOW_TOOLS.has(data.toolName)) return; + if (!this.isEditHistoryEnabled()) return; + clearTimeout(this._editHistoryDebounce); + this._editHistoryDebounce = setTimeout(() => { + this._editHistoryDebounce = null; + if (this._isEditHistoryPanelOpen()) this.refreshEditHistory(); + }, EDIT_HISTORY_SSE_DEBOUNCE_MS); + }, + + /** + * Called by app.js's `session:idle` handler ({ id }): the end of a turn. Claude's interactive + * transcript has no `result` line, so the transcript watcher's `transcript:complete` never fires + * for these sessions. + */ + onEditHistorySessionIdle(sessionId) { + this._onEditHistoryTranscriptEvent({ sessionId }); + }, + + /** Called by selectSession(): the open panel follows the active session. */ + onEditHistoryActiveSession(sessionId) { + if (!this.isEditHistoryEnabled() || !this._isEditHistoryPanelOpen()) return; + if (this._editHistory?.sessionId === sessionId) return; + this.refreshEditHistory(); + }, + + // ── Panel ─────────────────────────────────────────────────────────────── + + _isEditHistoryPanelOpen() { + return !!this.$('editHistoryPanel')?.classList.contains('visible'); + }, + + toggleEditHistoryPanel() { + if (this._isEditHistoryPanelOpen()) { + this.closeEditHistoryPanel(); + return; + } + const panel = this.$('editHistoryPanel'); + if (!panel) return; + panel.classList.add('visible'); + this.$('editHistoryBtn')?.setAttribute('aria-expanded', 'true'); + const group = this.$('editHistoryGroupToggle'); + if (group) group.checked = this.isEditHistoryGrouped(); + const main = this.$('editHistoryMainOnlyToggle'); + if (main) main.checked = this.isEditHistoryMainOnly(); + this._ensureEditHistoryPanelDrag(); + this._renderEditHistoryPanel(); + this.refreshEditHistory(); + }, + + closeEditHistoryPanel() { + const panel = this.$('editHistoryPanel'); + if (panel) { + panel.classList.remove('visible'); + // Reset a dragged position so it reopens at the default spot (a resized size is kept). + panel.style.left = panel.style.top = panel.style.right = panel.style.bottom = ''; + } + this.$('editHistoryBtn')?.setAttribute('aria-expanded', 'false'); + clearTimeout(this._editHistoryDebounce); + this._editHistoryDebounce = null; + }, + + /** Drag by the header (pointer events: mouse, pen and touch). One set of listeners per page. */ + _ensureEditHistoryPanelDrag() { + const panel = this.$('editHistoryPanel'); + const handle = panel?.querySelector('.edit-history-header'); + if (!panel || !handle || handle._dragReady) return; + handle._dragReady = true; + let drag = null; + handle.addEventListener('pointerdown', (e) => { + if (e.target.closest('button')) return; + const rect = panel.getBoundingClientRect(); + drag = { dx: e.clientX - rect.left, dy: e.clientY - rect.top }; + // Switch from right/bottom anchoring to explicit left/top so the drag has one coordinate system. + panel.style.left = `${rect.left}px`; + panel.style.top = `${rect.top}px`; + panel.style.right = 'auto'; + panel.style.bottom = 'auto'; + // Pin the size too: the phone layout stretches between left and right, which a drag gives up. + panel.style.width = `${rect.width}px`; + panel.style.height = `${rect.height}px`; + handle.setPointerCapture?.(e.pointerId); + e.preventDefault(); + }); + handle.addEventListener('pointermove', (e) => { + if (!drag) return; + const maxX = window.innerWidth - panel.offsetWidth - 4; + const maxY = window.innerHeight - panel.offsetHeight - 4; + panel.style.left = `${Math.max(4, Math.min(e.clientX - drag.dx, maxX))}px`; + panel.style.top = `${Math.max(4, Math.min(e.clientY - drag.dy, maxY))}px`; + }); + const end = (e) => { + drag = null; + handle.releasePointerCapture?.(e.pointerId); + }; + handle.addEventListener('pointerup', end); + handle.addEventListener('pointercancel', end); + }, + + _ehEl(tag, className, text) { + const el = document.createElement(tag); + if (className) el.className = className; + if (text !== undefined) el.textContent = text; + return el; + }, + + /** "14:03:22", with the date in front when it is not today. */ + _editHistoryTime(ts) { + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return ''; + const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + return d.toDateString() === new Date().toDateString() ? time : `${d.toLocaleDateString()} ${time}`; + }, + + /** Open `path` in the shared file preview overlay (panels-ui.js). */ + _openEditHistoryPreview(path) { + const sid = this.activeSessionId; + if (sid && path) this.openFilePreview?.(path, sid); + }, + + _editHistoryUrl(url) { + return typeof CodemanBase !== 'undefined' && CodemanBase.url ? CodemanBase.url(url) : url; + }, + + /** + * Thumbnail URL for an image entry inside the workspace: the confined `file-raw` route the file + * preview uses. An image outside the workspace gets none: showing it needs an attachment id + * (`POST …/attachments`, `notify: false`), and minting one per rendered row would push the + * session's real attachments out of its capped registry. Clicking it opens the file preview, + * which registers it like any other external file. SVGs get none either (`file-raw` serves + * them as a download). + */ + _editHistoryThumbSrc(path, sid) { + if (!path || /\.svg$/i.test(path) || this._isExternalPreviewPath?.(path, sid) !== false) return null; + return this._editHistoryUrl(`/api/sessions/${encodeURIComponent(sid)}/file-raw?path=${encodeURIComponent(path)}`); + }, + + _renderEditHistoryPanel() { + const body = this.$('editHistoryBody'); + const count = this.$('editHistoryCount'); + if (!body || !this._isEditHistoryPanelOpen()) return; + const el = (tag, cls, text) => this._ehEl(tag, cls, text); + const t = (s, v) => this._eht(s, v); + const focusKey = body.contains(document.activeElement) + ? document.activeElement.closest?.('[data-edit-key]')?.dataset.editKey + : null; + const scroll = body.scrollTop; + body.replaceChildren(); + if (count) count.textContent = ''; + + if (!this.activeSessionId) { + body.append(el('div', 'edit-history-empty', t('Open a session to see its edits.'))); + return; + } + const h = this._currentEditHistory(); + if (!h || h.state === 'loading') { + body.append(el('div', 'edit-history-empty', t('Reading the edit history…'))); + return; + } + if (h.state === 'error' || !h.data) { + body.append(el('div', 'edit-history-empty', t('Could not read the edit history.'))); + return; + } + const data = h.data; + if (data.state === 'unsupported') { + body.append(el('div', 'edit-history-empty', t('Edit history is only available for local Claude sessions.'))); + return; + } + if (data.state === 'unavailable') { + body.append( + el( + 'div', + 'edit-history-note edit-history-note--warn', + data.reason === 'no-transcript' + ? t( + 'No transcript found for this session yet. It is picked up with the next prompt; until then a CLAUDE_CONFIG_DIR set outside Codeman cannot be found.' + ) + : t('Showing saved entries only: {reason}', { reason: data.reason || '' }) + ) + ); + } + + const all = Array.isArray(data.entries) ? data.entries : []; + const entries = this.isEditHistoryMainOnly() ? all.filter((e) => !e.agentId) : all; + if (count) count.textContent = all.length ? String(entries.length) : ''; + if (!all.length) { + body.append(el('div', 'edit-history-empty', t('No edits yet.'))); + } else if (!entries.length) { + body.append(el('div', 'edit-history-empty', t('No edits by the main agent.'))); + } else if (this.isEditHistoryGrouped()) { + const groups = new Map(); + for (const e of entries) { + const key = e.path || e.relPath; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(e); + } + for (const items of groups.values()) body.append(this._editHistoryGroup(items, data, h)); + } else { + const list = el('div', 'edit-history-list'); + for (const e of entries) list.append(...this._editHistoryEntry(e, data, h, true)); + body.append(list); + } + + if (data.backfillTruncated) { + const more = el( + 'button', + 'btn-toolbar btn-sm edit-history-older', + h.loadingOlder ? t('Loading older edits…') : t('Load older') + ); + more.type = 'button'; + more.disabled = !!h.loadingOlder; + more.addEventListener('click', () => this.loadOlderEditHistory()); + body.append(more); + } + + body.scrollTop = scroll; + if (focusKey) { + const again = [...body.querySelectorAll('[data-edit-key]')].find((n) => n.dataset.editKey === focusKey); + again?.focus({ preventScroll: true }); + } + }, + + /** One file with all its edits (newest first), for the "group by file" view. */ + _editHistoryGroup(items, data, h) { + const el = (tag, cls, text) => this._ehEl(tag, cls, text); + const first = items[0]; + const group = el('section', 'edit-history-group'); + const head = el('div', 'edit-history-group-head'); + head.append(this._editHistoryPathButton(first)); + const code = items.filter((e) => e.kind !== 'image'); + if (code.length) { + const added = code.reduce((s, e) => s + (Number(e.added) || 0), 0); + const removed = code.reduce((s, e) => s + (Number(e.removed) || 0), 0); + head.append(this._editHistoryStats(added, removed)); + } + head.append(el('span', 'edit-history-group-count', String(items.length))); + group.append(head); + for (const e of items) group.append(...this._editHistoryEntry(e, data, h, false)); + return group; + }, + + /** + * The path as folder + file name: the folder is cut from its start when the row is narrow, so a + * long absolute path (an image in claude's scratchpad) still shows which file it is. + */ + _editHistoryPathButton(e) { + const shown = e.relPath || e.path || ''; + const btn = this._ehEl('button', 'edit-history-path'); + const cut = shown.lastIndexOf('/') + 1; + if (cut > 0) { + const dir = this._ehEl('span', 'edit-history-path-dir'); + const bdi = this._ehEl('bdi', null, shown.slice(0, cut)); + bdi.dir = 'ltr'; + dir.append(bdi); + btn.append(dir); + } + btn.append(this._ehEl('span', 'edit-history-path-name', shown.slice(cut))); + btn.type = 'button'; + btn.title = e.path || e.relPath || ''; + btn.addEventListener('click', (ev) => { + ev.stopPropagation(); + this._openEditHistoryPreview(e.path); + }); + return btn; + }, + + _editHistoryStats(added, removed) { + const stats = this._ehEl('span', 'edit-history-stats'); + stats.append(this._ehEl('span', 'edit-history-add', `+${added}`)); + stats.append(this._ehEl('span', 'edit-history-del', `−${removed}`)); + return stats; + }, + + /** A row, followed by its diff when the row is open. `withPath` false inside a file group. */ + _editHistoryEntry(e, data, h, withPath) { + const el = (tag, cls, text) => this._ehEl(tag, cls, text); + const t = (s, v) => this._eht(s, v); + const isImage = e.kind === 'image'; + const row = el('div', `edit-history-entry edit-history-entry--${isImage ? 'image' : 'code'}`); + row.dataset.editKey = e.id; + row.tabIndex = 0; + row.setAttribute('role', 'button'); + row.append(el('span', 'edit-history-time', this._editHistoryTime(e.ts))); + + let badge; + if (isImage) { + badge = el('span', 'edit-history-badge edit-history-badge--image'); + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('width', '12'); + svg.setAttribute('height', '12'); + svg.setAttribute('aria-hidden', 'true'); + for (const [tag, attrs] of [ + ['rect', { x: '3', y: '4', width: '18', height: '16', rx: '2' }], + ['circle', { cx: '9', cy: '10', r: '2' }], + ['path', { d: 'M21 17l-5-5-9 8' }], + ]) { + const n = document.createElementNS('http://www.w3.org/2000/svg', tag); + for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v); + svg.append(n); + } + badge.append(svg); + badge.title = t('Image'); + } else if (e.created) { + badge = el('span', 'edit-history-badge edit-history-badge--new', this._ehBadge('new')); + badge.title = t('File created'); + } else { + badge = el('span', 'edit-history-badge edit-history-badge--edit', this._ehBadge('edit')); + badge.title = t('File edited ({tool})', { tool: e.tool || '' }); + } + row.append(badge); + + if (withPath) row.append(this._editHistoryPathButton(e)); + row.append(el('span', 'edit-history-spacer')); + if (!isImage) row.append(this._editHistoryStats(Number(e.added) || 0, Number(e.removed) || 0)); + + if (e.agentId) { + const agent = data.agents?.[e.agentId]; + const chip = el('span', 'edit-history-agent', agent?.description || e.agentId); + const bits = [agent?.agentType, agent?.worktreeBranch].filter(Boolean); + chip.title = bits.join(' · ') || t('Subagent'); + row.append(chip); + } + + const out = [row]; + if (isImage) { + row.title = t('Open in the file preview'); + const open = () => this._openEditHistoryPreview(e.path); + row.addEventListener('click', open); + row.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + open(); + } + }); + const src = this._editHistoryThumbSrc(e.path, h.sessionId); + if (src) { + const img = el('img', 'edit-history-thumb'); + img.loading = 'lazy'; + img.alt = e.relPath || ''; + img.addEventListener('click', (ev) => { + ev.stopPropagation(); + open(); + }); + img.addEventListener('error', () => img.remove()); + img.src = src; + out.push(img); + } + return out; + } + + const isOpen = h.open.has(e.id); + row.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); + row.title = isOpen ? t('Hide the diff') : t('Show what changed'); + const toggle = () => this.toggleEditHistoryDiff(e.id); + row.addEventListener('click', toggle); + row.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + toggle(); + } + }); + if (isOpen) { + row.classList.add('edit-history-entry--open'); + out.push(this._editHistoryDiffView(h.diffs.get(e.id))); + } + return out; + }, + + /** Open or close one entry's diff; the first open loads it, later ones reuse it. */ + async toggleEditHistoryDiff(entryId) { + const h = this._currentEditHistory(); + if (!h) return; + if (h.open.has(entryId)) { + h.open.delete(entryId); + this._renderEditHistoryPanel(); + return; + } + h.open.add(entryId); + const cached = h.diffs.get(entryId); + if (cached && cached.state !== 'error') { + this._renderEditHistoryPanel(); + return; + } + const view = { state: 'loading' }; + h.diffs.set(entryId, view); + this._renderEditHistoryPanel(); + const res = await this._api(`${this._editHistoryBase(h.sessionId)}/${encodeURIComponent(entryId)}`); + let body = null; + try { + body = res ? await res.json() : null; + } catch { + /* fall through */ + } + if (h.diffs.get(entryId) !== view) return; + if (res && res.ok && body?.success && typeof body.data?.diff === 'string') { + view.state = 'ok'; + view.diff = body.data.diff; + view.truncated = !!body.data.truncated; + } else { + view.state = 'error'; + view.error = body?.error || this._eht('Could not read the diff.'); + } + if (this._editHistory === h) this._renderEditHistoryPanel(); + }, + + /** The diff block under an open row, drawn with the Git panel's classes. */ + _editHistoryDiffView(view) { + const el = (tag, cls, text) => this._ehEl(tag, cls, text); + const t = (s, v) => this._eht(s, v); + const wrap = el('div', 'edit-history-diff'); + if (!view || view.state === 'loading') { + wrap.append(el('div', 'edit-history-empty', t('Reading the diff…'))); + return wrap; + } + if (view.state === 'error') { + wrap.append(el('div', 'edit-history-empty', view.error)); + return wrap; + } + if (!view.diff.trim()) { + wrap.append(el('div', 'edit-history-empty', t('No textual changes.'))); + } else { + const pre = el('pre', 'git-diff'); + const frag = document.createDocumentFragment(); + for (const line of view.diff.replace(/\n$/, '').split('\n')) { + let cls = 'git-diff-line'; + // The API sends hunks only (no `---`/`+++` headers), so a `---` line is a removed `--` line. + if (line.startsWith('@@')) cls += ' git-diff-line--hunk'; + else if (line.startsWith('+')) cls += ' git-diff-line--add'; + else if (line.startsWith('-')) cls += ' git-diff-line--del'; + frag.append(el('span', cls, line + '\n')); + } + pre.append(frag); + wrap.append(pre); + } + if (view.truncated) + wrap.append(el('div', 'edit-history-more', t('Diff cut short: it is larger than the viewer shows.'))); + return wrap; + }, +}); diff --git a/src/web/public/git-status-ui.js b/src/web/public/git-status-ui.js index 967516eb..599e4a99 100644 --- a/src/web/public/git-status-ui.js +++ b/src/web/public/git-status-ui.js @@ -9,9 +9,13 @@ * OPTIONAL and per-device: `showGitStatus` (App Settings → Header & Panels → Bottom bar), default * OFF. While it is off nothing polls and the button never shows. While it is on, the page asks * `GET /api/sessions/:id/git-status` for the active session on a slow poll (and at once when the - * session changes or the window regains focus). The route is read-only and offline: it never fetches - * or changes the repository, so the "behind" number reflects the last `git fetch`, which the panel - * footer says. Remote (SSH) and Docker sessions answer `unsupported` and show no indicator. + * session changes or the window regains focus). The status route is read-only: it never changes the + * repository, so the "behind" number reflects the last `git fetch`, which the panel footer says. Fetching + * happens when the user clicks Fetch in that window's header (`POST /api/sessions/:id/git-fetch`, + * src/git-fetch.ts), or on the interval when the session is on: this is the only git command the Git panel + * runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, + * but only when the user asked for them at that moment). Remote (SSH) + * and Docker sessions answer `unsupported`, show no indicator, and cannot be fetched. * * Everything that comes from git (file names, commit subjects, author names) is untrusted text: it is * only ever written with `textContent`, never `innerHTML`. @@ -265,6 +269,78 @@ Object.assign(CodemanApp.prototype, { return this.refreshGitStatus({ fresh: true }); }, + /** + * Fetch, on this click only: one `git fetch --quiet` per repository the panel shows + * (`POST /api/sessions/:id/git-fetch`). It is the only git command the Git panel runs that writes, and the + * only one Codeman runs on a timer, and it updates remote-tracking refs only: nothing here commits or + * touches the working tree, so an agent's own work is left alone, though a fetch does hold git's ref lock, + * so an agent's `git pull` in the same repository can hit the same `cannot lock ref` while it runs. One + * click means one fetch per repository: the button is busy and a second click is ignored while it runs, and + * a click that joins a fetch already running reports that run, not itself. The panel then re-reads, so the + * "behind" number reflects what this fetch brought. + */ + async fetchGitStatusNow() { + const sid = this.activeSessionId || null; + const btn = this.$('gitStatusFetchBtn'); + if (!sid || !btn || this._gitFetchInFlight) return; + this._gitFetchInFlight = true; + this._renderGitFetchButton(); + let report = null; + try { + const query = new URLSearchParams(this.gitStatusLimits()); + report = await this._apiJson(`/api/sessions/${encodeURIComponent(sid)}/git-fetch?${query}`, { method: 'POST' }); + } catch { + report = null; + } + // A fetch that lands after a session switch is not shown for the session on screen now; the refs it + // moved are still on disk, so that session's own read reflects them. + if (sid === this.activeSessionId) this._gitStatusFetch = report ? { sessionId: sid, data: report } : null; + this._gitFetchInFlight = false; + await this.refreshGitStatus({ fresh: true }); + }, + + /** The last Fetch this window ran for the session on screen, or null. */ + _currentGitFetch() { + const f = this._gitStatusFetch; + return f && f.sessionId === this.activeSessionId && f.data ? f.data : null; + }, + + /** Busy while a fetch runs, and never enabled for a session there is nothing to fetch for. */ + _renderGitFetchButton() { + const btn = this.$('gitStatusFetchBtn'); + if (!btn) return; + const overview = this._currentGitStatus(); + const fetchable = !!this.activeSessionId && !!overview && overview.state === 'ok'; + btn.disabled = this._gitFetchInFlight || !fetchable; + if (this._gitFetchInFlight) btn.setAttribute('aria-busy', 'true'); + else btn.removeAttribute('aria-busy'); + }, + + /** What the last Fetch brought: a summary line, then one row per repository it could not fetch. */ + _gitFetchNote() { + const report = this._currentGitFetch(); + if (!report || report.state !== 'ok' || !report.repos?.length) return null; + const el = (tag, cls, text) => this._gitEl(tag, cls, text); + const failed = report.repos.filter((r) => !r.ok); + const count = report.repos.length; + const note = el('div', 'git-status-fetch-note'); + note.append( + el( + 'div', + 'git-status-fetch-summary', + `Fetched ${new Date(report.fetchedAt).toLocaleTimeString()}: ${count - failed.length} of ${count} ${count === 1 ? 'repository' : 'repositories'}.` + ) + ); + for (const r of failed) { + const row = el('div', 'git-status-fetch-row'); + row.append(el('span', 'git-status-repo-name', r.name)); + if (r.path !== r.name) row.append(el('span', 'git-status-repo-path', r.path)); + row.append(el('span', 'git-status-repo-unreadable', `⚠ ${r.failure?.message || 'git fetch failed'}`)); + note.append(row); + } + return note; + }, + /** Drag by the header. Pointer events cover mouse, pen and touch; one set of listeners lives as long as the page. */ _ensureGitStatusPanelDrag() { const panel = this.$('gitStatusPanel'); @@ -313,6 +389,7 @@ Object.assign(CodemanApp.prototype, { if (!body) return; const overview = this._currentGitStatus(); const el = (tag, cls, text) => this._gitEl(tag, cls, text); + this._renderGitFetchButton(); // The 15 s poll replaces every row: put keyboard focus back on the same file afterwards. const focusKey = body.contains(document.activeElement) ? document.activeElement.closest?.('[data-git-key]')?.dataset.gitKey @@ -379,8 +456,13 @@ Object.assign(CodemanApp.prototype, { } } + const note = this._gitFetchNote(); + if (note) body.append(note); + if (foot) { - foot.textContent = `Checked ${new Date(overview.checkedAt).toLocaleTimeString()}. Read-only: Codeman never fetches or changes the repository, so “behind” is as of your last fetch.`; + // Runtime text (the panel footer carries data-i18n-skip), so naming the interval here does not fight the + // translator. It says what actually runs: this click, or the interval the user turned on. + foot.textContent = `Checked ${new Date(overview.checkedAt).toLocaleTimeString()}. Read-only apart from Fetch: Codeman fetches on your click, or on the interval you turned on in Settings, and never commits or changes the repository, so “behind” is as of the last fetch.`; } if (focusKey) { const again = [...body.querySelectorAll('[data-git-key]')].find((n) => n.dataset.gitKey === focusKey); @@ -449,7 +531,8 @@ Object.assign(CodemanApp.prototype, { if (data.ahead) line.append(el('span', 'git-status-chip git-status-chip--warn', `↑ ${data.ahead} ahead`)); if (data.behind) { const behind = el('span', 'git-status-chip', `↓ ${data.behind} behind`); - behind.title = 'As of the last git fetch: Codeman never fetches.'; + behind.title = + 'As of the last git fetch. Click Fetch in this window, or turn on the fetch interval in Settings, to bring it current.'; line.append(behind); } } else if (data.hasRemote) { diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 0f17c78d..9aa28a44 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -107,6 +107,11 @@ 'New session': '新建会话', // The Cmd+K palette's new-session agent switch (panels-ui.js, its title tooltip). 'Agent for the new session (Left/Right to change)': '新会话使用的智能体(按左/右方向键切换)', + 'New session here': '在此新建会话', + 'New session here…': '在此新建会话…', + "New session in this tab's case": '在此标签页的案例中新建会话', + "This tab's folder is not a case, so there is nothing to start beside it.": + '此标签页的文件夹不是案例,无法在其旁边新建会话。', 'A session is already starting.': '已有会话正在启动。', 'Follow parent again': '重新跟随父会话', 'Parent closed; placed on its own': '父会话已关闭;单独放置', @@ -351,6 +356,7 @@ Delete: '删除', Remove: '移除', Edit: '编辑', + Fetch: '拉取', Refresh: '刷新', Back: '返回', Next: '下一步', @@ -483,6 +489,9 @@ '会话列表显示为顶栏横向标签条,或左侧可折叠侧边栏(Alt+B)。完整侧边栏为每个会话显示与主界面相同的详细信息。', 'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)', 'Pop-out Button on Tabs': '标签页弹出窗口按钮', + 'New Session Button on Tabs': '标签页新建会话按钮', + 'A + on the tab that starts a session in the same case and group, in any installed CLI.': + '标签页上的 + 按钮,可在同一案例和分组中,用任一已安装的 CLI 启动新会话。', 'CLI Logos on Tabs': '标签页上的 CLI 图标', "Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.": '在标签页和主界面的标签列表中,于会话名称前显示每个智能体的 CLI 图标。关闭后仍保留状态圆点和 Shell 的 SH 标记。平铺、分屏标题栏和运行菜单中的图标不受影响。', @@ -519,12 +528,13 @@ 'Connection tile: offline': '离线', 'Connection tile: queued': '已排队', 'Connection tile: retry': '重连中', - // App Settings → Bottom bar, translated as one group (the Git status rows, - // #543's two included). Keys are the trimmed label text, without the scope tag. + // App Settings → Bottom bar, translated as one group (the Git status rows, #543's two included, and the + // two Git fetch rows, in the order the page lists them). Keys are the trimmed label text, without the + // scope tag. The Session Options → Git strings are in that group's own block further down. 'Bottom bar': '底部栏', 'Git status': 'Git 状态', - "Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only: Codeman never fetches or changes the repository. Not shown for Docker or remote sessions. Off by default.": - '在底部栏右侧显示当前会话的仓库(或其文件夹内向下两层以内的每个仓库)是否有未提交的文件或未推送的提交。点击可查看列表。只读:{name} 从不拉取或更改仓库。Docker 和远程会话不显示。默认关闭。', + "Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only apart from Fetch: Codeman never commits or changes the repository, and `git fetch` runs only when you click Fetch in that window, or on the interval below if you turned it on. It is the only git command the Git window runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked). Not shown for Docker or remote sessions. Off by default.": + '在底部栏右侧显示当前会话的仓库(或其文件夹内向下两层以内的每个仓库)是否有未提交的文件或未推送的提交。点击可查看列表。除拉取外只读:{name} 从不提交或更改仓库,而拉取是 Git 窗口运行的唯一会写入的 git 命令,也是 {name} 唯一会按间隔自行运行的命令(克隆和工作树管理也会写入,但只在您当时要求时才运行),它只在您点击该窗口中的拉取按钮时运行,或在您开启了下方的定时后按间隔运行。Docker 和远程会话不显示。默认关闭。', 'Git status: group files by folder': 'Git 状态:按文件夹分组显示文件', 'In the Git window, show changed files under their folders, collapsed until you click a folder. Off lists every file by its full path. On by default.': '在 Git 窗口中,将更改的文件显示在各自的文件夹下,点击文件夹前保持折叠。关闭时按完整路径列出每个文件。默认开启。', @@ -534,8 +544,48 @@ 'Git status: git timeout': 'Git 状态:git 超时', 'Seconds one git command may run before that repository is reported as unreadable (5 to 120, default 30). Raise it for repositories on a slow network share.': '单条 git 命令可运行的秒数,超时后该仓库会被报告为无法读取(5 到 120,默认 30)。仓库位于较慢的网络共享上时请调高此值。', + 'Git fetch on an interval': 'Git 定时拉取', + 'Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked), so nothing runs until you turn it on. A session can override both the switch and the interval in Session Options → Git.': + '按间隔为 Git 窗口列出的仓库运行拉取,这样“落后”的数字无需点击就是最新的。默认关闭:这是 Git 窗口运行的唯一会写入的 git 命令,也是 {name} 唯一会按间隔自行运行的命令(克隆和工作树管理也会写入,但只在您当时要求时才运行),所以在您开启之前不会有任何运行。会话可以在“会话选项 → Git”中同时覆盖开关和间隔。', + 'Git fetch: how often': 'Git 定时拉取:多久一次', + 'Seconds between runs for a session that has no override of its own (30 to 3600, default 300). Only a session that is on (here, or in Session Options → Git) is fetched, and sessions sharing one folder are fetched by one run.': + '没有自己覆盖设置的会话,两次运行之间的秒数(30 到 3600,默认 300)。只有已开启的会话(这里,或在“会话选项 → Git”)才会被拉取,共用一个文件夹的会话由一次运行拉取。', + 'Fetch git status': '拉取 Git 状态', 'Refresh git status': '刷新 Git 状态', 'Close git status': '关闭 Git 状态', + // Edit history (edit-history-ui.js): settings chip, header button and panel. + // The two row badges use scoped keys: a bare "new"/"edit" key would also + // translate unrelated words elsewhere (the lookup is case-insensitive). + 'Edit history': '编辑历史', + 'Open edit history': '打开编辑历史', + 'Refresh edit history': '刷新编辑历史', + 'Close edit history': '关闭编辑历史', + 'Group by file': '按文件分组', + 'Main agent only': '仅主智能体', + 'Open a session to see its edits.': '打开一个会话以查看其编辑。', + 'Reading the edit history…': '正在读取编辑历史…', + 'Could not read the edit history.': '无法读取编辑历史。', + 'Edit history is only available for local Claude sessions.': '编辑历史仅适用于本地 Claude 会话。', + 'Showing saved entries only: {reason}': '仅显示已保存的条目:{reason}', + 'No transcript found for this session yet. It is picked up with the next prompt; until then a CLAUDE_CONFIG_DIR set outside Codeman cannot be found.': + '尚未找到此会话的记录。它会在下一次提示时被识别;在此之前,在 Codeman 之外设置的 CLAUDE_CONFIG_DIR 无法被找到。', + 'No edits yet.': '暂无编辑。', + 'No edits by the main agent.': '主智能体没有编辑。', + 'Load older': '加载更早的编辑', + 'Loading older edits…': '正在加载更早的编辑…', + 'Edit history badge: new': '新建', + 'Edit history badge: edit': '编辑', + 'File created': '已创建文件', + 'File edited ({tool})': '已编辑文件({tool})', + Image: '图片', + Subagent: '子智能体', + 'Open in the file preview': '在文件预览中打开', + 'Show what changed': '显示更改内容', + 'Hide the diff': '隐藏差异', + 'Reading the diff…': '正在读取差异…', + 'Could not read the diff.': '无法读取差异。', + 'No textual changes.': '没有文本更改。', + 'Diff cut short: it is larger than the viewer shows.': '差异已截断:内容超出查看器可显示的范围。', Panels: '面板', Monitor: '监视器', 'Project Insights': '项目洞察', @@ -562,7 +612,6 @@ Send: '发送', Permission: '权限', Question: '问题', - Idle: '空闲', 'Read My Mind': '读心术', 'Read My Mind: predict your next prompt': '读心术:预测您的下一条提示', 'Predict my next prompt': '预测我的下一条提示', @@ -599,6 +648,9 @@ 'API key': 'API 密钥', 'Optional. Left blank on edit keeps the existing key.': '可选。编辑时留空将保留现有密钥。', 'Auth header': '认证请求头', + 'Default context length (tokens)': '默认上下文长度(token 数)', + "Used for models whose size cannot be discovered. The model's whole window; the Claude floor check divides it by any stored slot count. Leave empty to let each CLI assume its own default.": + '用于无法自动发现上下文大小的模型。这是模型的完整窗口;Claude 下限检查会将其除以已存储的槽位数。留空则让各个 CLI 使用其自身的默认值。', 'Never send both — some servers hang indefinitely.': '切勿同时发送两者 — 部分服务器会因此无限期挂起。', 'Authorization: Bearer (default)': 'Authorization: Bearer(默认)', 'api-key header (Azure)': 'api-key 请求头(Azure)', @@ -608,9 +660,15 @@ 'Custom Endpoints': '自定义端点', 'Choose a model': '选择模型', 'Currently loaded': '当前已加载', + 'In use by a session': '会话正在使用', 'Last used': '上次使用', + 'Nothing is marked: this endpoint did not report which model it has loaded, and none of your running sessions uses one. Pick the model to launch.': + '未标记任何模型:此端点没有报告它已加载了哪个模型,你运行中的会话也没有在使用它。请选择要启动的模型。', 'That endpoint no longer exists': '该端点已不存在', 'No models discovered for this endpoint yet': '此端点尚未发现任何模型', + // Model picker search box (index.html placeholder/aria-label and the no-matches line) + 'Search models': '搜索模型', + 'No models match': '没有匹配的模型', 'Subagent Options': '子智能体选项', 'Enable Tracking': '启用跟踪', 'Active Tab Only': '仅活动标签页', @@ -667,6 +725,9 @@ 'Weight for bold terminal text. Only useful with a family carrying something heavier than its bold face.': '终端粗体文本的字重。仅当字体提供比其粗体更重的字形时才有意义。', 'Local Echo': '本地回显', + 'Keep Size Under Keyboard': '键盘弹出时保持尺寸', + "For local Claude sessions, opening the on-screen keyboard keeps the session's size and scrolls the view instead, so Claude does not reprint its screen and lose transcript lines. Full-screen interfaces (Codex, Claude's fullscreen mode) are resized as before.": + '对于本地 Claude 会话,打开屏幕键盘时会保持会话尺寸并改为滚动视图,这样 Claude 不会重绘屏幕而丢失对话记录行。全屏界面(Codex、Claude 的全屏模式)仍像以前一样调整尺寸。', 'CJK Input': '中日韩输入', 'Extended Keyboard Bar': '扩展键盘栏', 'Gesture Control (beta)': '手势控制(测试版)', @@ -697,6 +758,9 @@ 'New sessions for this case land in this group': '此案例的新会话将放入此分组', 'Could not save the default session group.': '无法保存默认会话分组。', 'Remote auto-reconnect': '远程自动重连', + 'Experimental agent coordination': '实验性智能体协作', + "Lets agents read another session's output in bounded pages and type a prompt into a local Codex composer, then press Enter once after checking it is unchanged (the inspect, stage and submit commands). Experimental: the commands and their HTTP routes may change in any release. Off by default; while off those routes refuse every request.": + '允许智能体分页读取另一个会话的输出(每页有上限),并把提示词输入本地 Codex 输入框,确认未被改动后只按一次 Enter(即 inspect、stage 和 submit 命令)。实验性功能:这些命令及其 HTTP 路由可能在任何版本中变化。默认关闭;关闭时这些路由拒绝所有请求。', 'Pin tab names on the CLI': '在 CLI 上固定标签名', "Launch every Claude session with its tab name, so the CLI's prompt label and /resume list match your tabs. Claude then stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. Applies from the next session start or respawn, no server restart needed.": '以标签名启动每个 Claude 会话,让 CLI 的提示框标签和 /resume 列表与你的标签一致。Claude 随后不再为这些会话生成自己的标题,在 Codeman 中的重命名也只保留到下一轮。从下一次会话启动或重新生成时生效,无需重启服务器。', @@ -832,6 +896,24 @@ Memory: '内存', GPUs: 'GPU', + // Session Options → Git (the tab's blurb, its group head, its hint and its three rows in index.html, plus + // the save toast session-ui.js raises). 'Status' is not a key here: that row's text is built at runtime + // by updateGitFetchStatus, and 'Git' stays a technical name. + "Whether Codeman fetches this session's repositories on a timer.": '{name} 是否按间隔拉取此会话的仓库。', + 'Fetch on an interval': '按间隔拉取', + "Fetching is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer, so nothing runs until you turn it on (here, or in App Settings → Bottom bar). Cloning and worktree management write too, but only when you asked for them at the moment you asked. The panel's Fetch button is always just a click.": + '拉取是 Git 窗口运行的唯一会写入的 git 命令,也是 {name} 唯一会按间隔自行运行的命令,所以在您开启之前(这里,或“应用设置 → 底部栏”)不会有任何运行。克隆和工作树管理也会写入,但只在您当时要求时才运行。该窗口中的拉取按钮始终只是一次点击。', + 'Follow the app setting': '跟随应用设置', + 'Uses the app-wide switch and interval for this session. Switch it off to pick your own.': + '为此会话使用应用级的开关和间隔。关闭它即可自行选择。', + 'Fetch this session': '拉取此会话', + "Runs on its own even while the app-wide switch is off: a session that turned itself on means exactly that. Sessions sharing one folder share one run, and each sees that run's result.": + '即使应用级开关关闭也自行运行:一个自己开启的会话正是这个意思。共用一个文件夹的会话共享一次运行,每个会话都会看到该运行的结果。', + 'How often': '多久一次', + '30 sec to 1 hr, timed from the start of a run, so a slow repository does not push the next one out.': + '30 秒到 1 小时,从一次运行的开始计时,所以较慢的仓库不会把下一次推远。', + 'Git fetch settings saved': 'Git 定时拉取设置已保存', + // Cron / lifecycle / panels 'Cron Jobs': '定时任务', '+ New Job': '+ 新建任务', @@ -877,6 +959,8 @@ 'Saved on this device. Server-wide settings can only be changed by an admin.': '已保存到本设备。服务器全局设置只能由管理员更改。', 'Server-wide settings can only be changed by an admin': '服务器全局设置只能由管理员更改', + // The reason a settings write gives when no answer came back (settings-ui.js _settingsWriteError). + 'the server could not be reached': '无法连接到服务器', 'Tunnel active': '隧道已启用', 'Tunnel starting — QR code will appear when ready...': '隧道正在启动,准备好后将显示二维码…', 'Push notifications enabled': '推送通知已启用', @@ -1339,6 +1423,7 @@ [/^Lifetime: (\d+) sessions created$/, (_m, count) => `累计已创建 ${count} 个会话`], [/^Tunnel active: (.+)$/, (_m, url) => `隧道已启用:${url}`], [/^Tunnel error: (.+)$/, (_m, error) => `隧道错误:${error}`], + [/^Tunnel not stopped: ([\s\S]+)$/, (_m, why) => `隧道未停止:${why}`], [/^Update to v(.+)$/, (_m, version) => `更新到 v${version}`], [/^You're up to date \(v(.+)\)\.$/, (_m, version) => `已是最新版本(v${version})。`], [/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`], @@ -1407,6 +1492,17 @@ (_m, n) => `窗口太小,容纳不下 ${n} 个窗格:只显示聚焦的窗格`, ], [/^The agent exited \((-?\d+)\)$/, (_m, code) => `智能体已退出(${code})`], + // A settings write the server did not store (settings-ui.js). The server's reason passes + // through untranslated; the one the page supplies itself has its own entry. + [ + /^Saved on this device, not on the server: ([\s\S]+)$/, + (_m, why) => `已保存到本设备,但未保存到服务器:${ZH_CN[why] || why}`, + ], + [ + /^(Run mode|Respawn presets) saved on this device, not on the server: ([\s\S]+)$/, + (_m, what, why) => + `${what === 'Run mode' ? '运行模式' : '重生预设'}已保存到本设备,但未保存到服务器:${ZH_CN[why] || why}`, + ], // A dialog read off a hook-less CLI's screen (src/web/screen-blocked.ts), as the // notification drawer shows it. The CLI's label passes through. [/^(.+) is waiting for your answer in the terminal$/, (_m, cli) => `${cli} 正在终端中等待你的回答`], diff --git a/src/web/public/image-input.js b/src/web/public/image-input.js index a0ec33c3..1f18526d 100644 --- a/src/web/public/image-input.js +++ b/src/web/public/image-input.js @@ -193,7 +193,10 @@ Object.assign(CodemanApp.prototype, { // the result for images. Works on plain HTTP (no Clipboard API needed). // `target` names the terminal the Ctrl+V came from and its session; both // default to the primary pane. A second terminal (the split pane) passes its - // own, so text pastes into THAT xterm and images upload to THAT session. + // own. Images upload to THAT session. Plain text goes to that pane's session + // as a Codeman-built bracketed-paste frame: the terminal is only refocused + // and receives input(framed). onData delivers the frame; it is not written + // into the xterm buffer. _handleImagePaste(target = {}) { const self = this; const terminal = target.terminal || this.terminal; @@ -244,14 +247,20 @@ Object.assign(CodemanApp.prototype, { if (imageFiles.length > 0) { self._uploadAndInsertImages(imageFiles, { sessionId: sessionId }); - } else { - // No image -- route text through xterm's paste() so bracketed-paste - // markers (CSI 200~ ... CSI 201~) survive when the inner application - // has enabled bracketed-paste mode (Claude Code does). Sending text - // via raw sendInput() strips those markers and makes pasted input - // indistinguishable from typed input, weakening the CLI's - // prompt-injection defenses. - if (text && terminal) terminal.paste(text); + } else if (text && terminal) { + // No image. xterm's paste() emits CSI 200~/201~ only while its DECSET + // 2004 mirror is on, and a terminal replay clears that mirror, so the + // markers never arrive and a copied newline submits (#716). Build the + // frame here and hand it to this terminal through input(). That fires + // onData as user input (scroll to the bottom, clear the selection). + // The main pane's bracketed-paste branch and a tile's _onTerminalData + // deliver it, including the local-echo flush (#219). Do not also write + // the frame from here. Markers are stripped until none is left: one pass over + // `ESC[20` + `ESC[201~` + `1~` leaves an `ESC[201~` that would end the frame early. + let body = text; + while (/\x1b\[20[01]~/.test(body)) body = body.replace(/\x1b\[20[01]~/g, ''); + body = body.replace(/\r?\n/g, '\r'); + terminal.input(`\x1b[200~${body}\x1b[201~`); } }); diff --git a/src/web/public/index.html b/src/web/public/index.html index b52a2956..039d55f7 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -216,6 +216,13 @@ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg> <span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span> </button> + <!-- Edit history of the active session (edit-history-ui.js), next to Attachments: both show + what the session produced, and the panel opens right below. Optional and per-device + (App Settings → Header & Panels → Header, default OFF): hidden by its marker class, + never on phones (mobile.css). --> + <button type="button" class="btn-icon-header btn-edit-history btn-edit-history--hidden" id="editHistoryBtn" aria-expanded="false" aria-controls="editHistoryPanel" title="Edit history" aria-label="Open edit history" onclick="app.toggleEditHistoryPanel()"> + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg> + </button> <button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path class="icon-folder-closed" d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><g class="icon-folder-open"><path d="M3 17V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1.5"/><path d="M3 17l2.3-5.4A2 2 0 0 1 7.2 10.5H20a1.5 1.5 0 0 1 1.4 2l-1.9 5.2A2 2 0 0 1 17.6 19H5a2 2 0 0 1-2-2z"/></g></svg></button> <button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button> <button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button> @@ -625,6 +632,7 @@ <span class="git-status-title">Git <span class="git-status-branch" id="gitStatusBranch" data-i18n-skip></span></span> <div class="git-status-actions"> <button class="btn-icon-sm" onclick="app.refreshGitStatusNow()" title="Refresh" aria-label="Refresh git status">↻</button> + <button class="btn-icon-sm" id="gitStatusFetchBtn" onclick="app.fetchGitStatusNow()" title="Fetch" aria-label="Fetch git status">⤓</button> <button class="btn-icon-sm" onclick="app.closeGitStatusPanel()" title="Close" aria-label="Close git status">×</button> </div> </div> @@ -632,6 +640,22 @@ <div class="git-status-footer" id="gitStatusFooter" data-i18n-skip></div> </div> + <!-- Edit history panel (edit-history-ui.js): every file edit the active session's agents made. --> + <div class="edit-history-panel" id="editHistoryPanel" role="dialog" aria-label="Edit history"> + <div class="edit-history-header"> + <span class="edit-history-title">Edit history <span class="edit-history-count" id="editHistoryCount" data-i18n-skip></span></span> + <div class="edit-history-actions"> + <button class="btn-icon-sm" onclick="app.refreshEditHistory()" title="Refresh" aria-label="Refresh edit history">↻</button> + <button class="btn-icon-sm" onclick="app.closeEditHistoryPanel()" title="Close" aria-label="Close edit history">×</button> + </div> + </div> + <div class="edit-history-controls"> + <label class="edit-history-toggle"><input type="checkbox" id="editHistoryGroupToggle" onchange="app.setEditHistoryGrouped(this.checked)"><span>Group by file</span></label> + <label class="edit-history-toggle"><input type="checkbox" id="editHistoryMainOnlyToggle" onchange="app.setEditHistoryMainOnly(this.checked)"><span>Main agent only</span></label> + </div> + <div class="edit-history-body" id="editHistoryBody" data-i18n-skip></div> + </div> + <!-- File Preview Overlay --> <div class="file-preview-overlay" id="filePreviewOverlay"> <div class="file-preview-window"> @@ -1032,6 +1056,20 @@ </div> <div class="modal-body"> <p class="form-hint" id="customModelPickHint"></p> + <!-- Case-insensitive filter over the rendered rows (app.filterCustomModelPick): + a display layer only, the rows themselves are never re-rendered. Reset on every + open and focused only on non-touch devices, same in-memory rule as the rail's + search box. --> + <input type="search" id="customModelPickSearch" class="session-sidebar-filter-input custom-model-pick-search" + placeholder="Search models" aria-label="Search models" + autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="search" + oninput="app.filterCustomModelPick(this.value)"> + <p class="form-hint custom-model-pick-no-matches" id="customModelPickNoMatches" role="status" hidden>No models match</p> + <!-- Shown only when no row carries a loaded/in-use/last-used mark AND the + running-status probe answered `isLlamaSwap: false` (see + _openCustomModelPickModal): it says out loud WHY nothing is marked, in the user's language, rather than leaving an + alphabetical wall that looks like the question was ignored. --> + <p class="form-hint custom-model-pick-unknown" id="customModelPickUnknown" role="status" hidden>Nothing is marked: this endpoint did not report which model it has loaded, and none of your running sessions uses one. Pick the model to launch.</p> <div id="customModelPickList" class="run-mode-custom-models"></div> </div> </div> @@ -1331,6 +1369,13 @@ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3v18h18"/><polyline points="7 14 11 10 14 13 20 7"/></svg> <span>Summary</span> </button> + <!-- Git is last: the section shows for every local session, but until the interval feature is + in use it holds one toggle and a status row that has never run, so it earns its place at the + end of the rail rather than among the tabs a session uses every day. --> + <button type="button" class="set-rail-item" data-tab="git" onclick="app.switchOptionsTab('git')"> + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M6 3v12"/><path d="M18 9a9 9 0 0 1-9 9"/></svg> + <span>Git</span> + </button> </div> <p class="set-rail-foot">These options belong to the selected session and save as you change them.</p> </nav> @@ -1719,6 +1764,54 @@ </div> </div> </section> + + <!-- ══ Git ══════════════════════════════════════════════════════ --> + <section class="set-section hidden" id="git-tab" data-label="Git"> + <div class="set-section-head"> + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M6 3v12"/><path d="M18 9a9 9 0 0 1-9 9"/></svg> + <h2>Git</h2> + </div> + <p class="set-section-blurb">Whether Codeman fetches this session's repositories on a timer.</p> + + <div class="set-group"> + <div class="set-group-head"><h4>Fetch on an interval</h4></div> + <p class="set-group-hint">Fetching is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer, so nothing runs until you turn it on (here, or in App Settings → Bottom bar). Cloning and worktree management write too, but only when you asked for them at the moment you asked. The panel's Fetch button is always just a click.</p> + <div class="set-group-body"> + <!-- Live from the session's own last run (session-ui.js: updateGitFetchStatus), which is the + run its folder got: several sessions on one folder share one run and read the same row. --> + <div class="set-row"> + <div class="set-row-text"> + <span class="set-row-label">Status</span> + <span class="set-row-desc" id="gitFetchStatus" data-i18n-skip hidden></span> + </div> + </div> + <div class="set-row"> + <div class="set-row-text"> + <span class="set-row-label">Follow the app setting</span> + <span class="set-row-desc">Uses the app-wide switch and interval for this session. Switch it off to pick your own.</span> + </div> + <label class="switch switch-sm"><input type="checkbox" id="modalGitFetchFollow" checked onchange="app.autoSaveGitFetch()"><span class="slider"></span></label> + </div> + <div class="set-row" id="gitFetchEnabledRow"> + <div class="set-row-text"> + <span class="set-row-label">Fetch this session</span> + <span class="set-row-desc">Runs on its own even while the app-wide switch is off: a session that turned itself on means exactly that. Sessions sharing one folder share one run, and each sees that run's result.</span> + </div> + <label class="switch switch-sm"><input type="checkbox" id="modalGitFetchEnabled" onchange="app.autoSaveGitFetch()"><span class="slider"></span></label> + </div> + <div class="set-row" id="gitFetchIntervalRow"> + <div class="set-row-text"> + <span class="set-row-label">How often</span> + <span class="set-row-desc">30 sec to 1 hr, timed from the start of a run, so a slow repository does not push the next one out.</span> + </div> + <div class="set-row-actions"> + <input type="number" id="modalGitFetchInterval" class="set-num set-num-wide" value="300" min="30" max="3600" step="30" onchange="app.autoSaveGitFetch()"> + <span class="set-unit">sec</span> + </div> + </div> + </div> + </div> + </section> </div> </div> </div> @@ -1944,6 +2037,13 @@ </div> <label class="switch switch-sm"><input type="checkbox" id="appSettingsLocalEcho"><span class="slider"></span></label> </div> + <div class="set-row" data-search="keyboard hold rows resize redraw mobile"> + <div class="set-row-text"> + <span class="set-row-label">Keep Size Under Keyboard</span> + <span class="set-row-desc">For local Claude sessions, opening the on-screen keyboard keeps the session's size and scrolls the view instead, so Claude does not reprint its screen and lose transcript lines. Full-screen interfaces (Codex, Claude's fullscreen mode) are resized as before.</span> + </div> + <label class="switch switch-sm"><input type="checkbox" id="appSettingsKeyboardHoldsRows"><span class="slider"></span></label> + </div> <div class="set-row" data-search="cjk input ime chinese japanese korean"> <div class="set-row-text"> <span class="set-row-label">CJK Input</span> @@ -2128,6 +2228,7 @@ <label class="set-chip" data-preview="header" data-preview-order="5"><input type="checkbox" id="appSettingsShowAwayDigestButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg><span>Away Digest</span></label> <label class="set-chip" data-preview="header" data-preview-order="6"><input type="checkbox" id="appSettingsShowSessionButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg><span>Session Manager</span></label> <label class="set-chip" data-preview="header" data-preview-order="9"><input type="checkbox" id="appSettingsShowAttachmentsButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>Attachments</span></label> + <label class="set-chip" data-search="edit history edits changed files diffs" data-preview="header" data-preview-order="9.5"><input type="checkbox" id="appSettingsShowEditHistory"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg><span>Edit history</span></label> <label class="set-chip" data-preview="header" data-preview-order="10"><input type="checkbox" id="appSettingsShowFileViewerButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span>File Viewer</span></label> <label class="set-chip" data-preview="header" data-preview-order="11"><input type="checkbox" id="appSettingsShowMultiMonitorButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg><span>Multi-monitor</span></label> <label class="set-chip" data-search="split pane side by side two sessions" data-preview="header" data-preview-order="11.5"><input type="checkbox" id="appSettingsShowSplitButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg><span>Split</span></label> @@ -2178,6 +2279,13 @@ </div> <label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentInboxPersist"><span class="slider"></span></label> </div> + <div class="set-row" data-search="experimental agent coordination inspect stage submit codeman agent guarded input"> + <div class="set-row-text"> + <span class="set-row-label">Experimental agent coordination</span> + <span class="set-row-desc">Lets agents read another session's output in bounded pages and type a prompt into a local Codex composer, then press Enter once after checking it is unchanged (the inspect, stage and submit commands). Experimental: the commands and their HTTP routes may change in any release. Off by default; while off those routes refuse every request.</span> + </div> + <label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentCoordination"><span class="slider"></span></label> + </div> </div> </div> @@ -2196,7 +2304,7 @@ <div class="set-row" data-search="git status uncommitted unpushed commit push indicator bottom bar toolbar"> <div class="set-row-text"> <span class="set-row-label">Git status <span class="set-scope">device</span></span> - <span class="set-row-desc">Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only: Codeman never fetches or changes the repository. Not shown for Docker or remote sessions. Off by default.</span> + <span class="set-row-desc">Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only apart from Fetch: Codeman never commits or changes the repository, and `git fetch` runs only when you click Fetch in that window, or on the interval below if you turned it on. It is the only git command the Git window runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked). Not shown for Docker or remote sessions. Off by default.</span> </div> <label class="switch switch-sm"><input type="checkbox" id="appSettingsShowGitStatus"><span class="slider"></span></label> </div> @@ -2221,6 +2329,20 @@ </div> <input type="number" id="appSettingsGitStatusTimeout" class="set-num" value="30" min="5" max="120" step="1"> </div> + <div class="set-row" data-search="git fetch interval timer behind remote tracking refs poll run automatically"> + <div class="set-row-text"> + <span class="set-row-label">Git fetch on an interval <span class="set-scope">synced</span></span> + <span class="set-row-desc">Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked), so nothing runs until you turn it on. A session can override both the switch and the interval in Session Options → Git.</span> + </div> + <label class="switch switch-sm"><input type="checkbox" id="appSettingsGitFetchEnabled"><span class="slider"></span></label> + </div> + <div class="set-row" data-search="git fetch how often seconds between runs interval case override session options"> + <div class="set-row-text"> + <span class="set-row-label">Git fetch: how often <span class="set-scope">synced</span></span> + <span class="set-row-desc">Seconds between runs for a session that has no override of its own (30 to 3600, default 300). Only a session that is on (here, or in Session Options → Git) is fetched, and sessions sharing one folder are fetched by one run.</span> + </div> + <input type="number" id="appSettingsGitFetchIntervalSeconds" class="set-num" value="300" min="30" max="3600" step="1"> + </div> </div> </div> @@ -2413,6 +2535,13 @@ </div> <label class="switch switch-sm"><input type="checkbox" id="appSettingsShowTabDetachButton"><span class="slider"></span></label> </div> + <div class="set-row" data-search="new session plus add tab same case group cli"> + <div class="set-row-text"> + <span class="set-row-label">New Session Button on Tabs</span> + <span class="set-row-desc">A + on the tab that starts a session in the same case and group, in any installed CLI.</span> + </div> + <label class="switch switch-sm"><input type="checkbox" id="appSettingsShowTabNewButton"><span class="slider"></span></label> + </div> <div class="set-row" id="appSettingsTileDetachItem" data-search="detach tiles tile pop out window drag move another window"> <div class="set-row-text"> <span class="set-row-label">Detach Tiles <span class="set-tag">desktop</span> <span class="set-tag set-tag-beta">beta</span></span> @@ -2700,6 +2829,10 @@ <div class="set-row-text"><span class="set-row-label">Base URL</span></div> <input type="text" id="customModelHostBaseUrl" class="set-input" placeholder="http://192.168.1.50:8080"> </div> + <div class="set-row has-field"> + <div class="set-row-text"><span class="set-row-label">Default context length (tokens)</span><span class="set-row-desc">Used for models whose size cannot be discovered. The model's whole window; the Claude floor check divides it by any stored slot count. Leave empty to let each CLI assume its own default.</span></div> + <input type="number" id="customModelHostDefaultContextLength" class="set-input" min="1" max="100000000" step="1" placeholder="131072"> + </div> <div class="set-row has-field"> <div class="set-row-text"><span class="set-row-label">API key</span><span class="set-row-desc">Optional. Left blank on edit keeps the existing key.</span></div> <input type="password" id="customModelHostApiKey" class="set-input" autocomplete="new-password"> @@ -3143,6 +3276,97 @@ <div id="webhookResult" class="set-note" style="display:none" data-i18n-skip></div> </div> </div> + <div class="set-group" id="discordGroup" style="display:none"> + <div class="set-group-head"><h4>Discord (two-way, one thread per tab)</h4><span class="set-scope">server</span></div> + <div class="set-group-body"> + <div class="set-row" data-search="discord bot thread reply phone notification blocked"> + <div class="set-row-text"> + <span class="set-row-label">Talk to your sessions from Discord</span> + <span class="set-row-desc">Posts alerts into a thread per tab and types your reply in that thread into the tab. Only the user IDs listed below can reply. Setup (bot, least-privilege permissions) is in docs/discord-bridge.md. This group has its own Save button.</span> + </div> + <label class="switch switch-sm"><input type="checkbox" id="discordEnabled"><span class="slider"></span></label> + </div> + <div class="set-row has-field"> + <div class="set-row-text"> + <span class="set-row-label">Bot token</span> + <span class="set-row-desc" id="discordTokenHint">Nothing saved yet.</span> + </div> + <input type="password" id="discordToken" class="set-input" autocomplete="off" spellcheck="false" placeholder="Bot token from the Developer Portal"> + </div> + <div class="set-row has-field"> + <div class="set-row-text"><span class="set-row-label">Guild (server) ID</span></div> + <input type="text" id="discordGuildId" class="set-input" autocomplete="off" spellcheck="false" inputmode="numeric"> + </div> + <div class="set-row has-field"> + <div class="set-row-text"><span class="set-row-label">Parent channel ID</span><span class="set-row-desc">Threads are created inside this channel.</span></div> + <input type="text" id="discordParentChannelId" class="set-input" autocomplete="off" spellcheck="false" inputmode="numeric"> + </div> + <div class="set-row has-field"> + <div class="set-row-text"><span class="set-row-label">Allowed user IDs</span><span class="set-row-desc">Comma separated. Only these Discord users can reply into a session.</span></div> + <input type="text" id="discordAllowedUserIds" class="set-input" autocomplete="off" spellcheck="false"> + </div> + <div class="set-row has-field"> + <div class="set-row-text"><span class="set-row-label">Create threads</span></div> + <select id="discordThreadCreation" class="set-select"> + <option value="lazy">On the first alert</option> + <option value="onSessionStart">For every new tab</option> + </select> + </div> + <div class="set-row has-field"> + <div class="set-row-text"><span class="set-row-label">When a tab is deleted</span><span class="set-row-desc">Deleting a thread needs the Manage Threads permission on the channel.</span></div> + <select id="discordOnSessionDelete" class="set-select"> + <option value="keep">Keep the thread</option> + <option value="archive">Archive the thread</option> + <option value="delete">Delete the thread</option> + </select> + </div> + <div class="set-row has-field"> + <div class="set-row-text"><span class="set-row-label">Stale threads</span><span class="set-row-desc">Threads this bot made that no tab uses any more, checked after startup and then daily. This includes threads that "When a tab is deleted" kept or archived. Threads under an hour old are skipped. Use one channel per Codeman.</span></div> + <select id="discordStaleThreads" class="set-select"> + <option value="keep">Leave them</option> + <option value="archive">Archive them</option> + <option value="delete">Delete them</option> + </select> + </div> + <div class="set-row has-field"> + <div class="set-row-text"><span class="set-row-label">@ ping</span></div> + <select id="discordPingMode" class="set-select"> + <option value="off">Never</option> + <option value="critical">Critical events only</option> + <option value="all">Every post</option> + </select> + </div> + <div class="set-row"> + <div class="set-row-text"> + <span class="set-row-label">Tell Claude how to reach you</span> + <span class="set-row-desc">Adds a short note about the codeman notify command to Claude's system prompt at launch, for when it is blocked on something only you can do. Nothing appears in the conversation. Local tabs only: remote and Docker tabs have no codeman command.</span> + </div> + <label class="switch switch-sm"><input type="checkbox" id="discordStartupHint"><span class="slider"></span></label> + </div> + <div class="set-row has-field"> + <div class="set-row-text"> + <span class="set-row-label">Which events</span> + <span class="set-row-desc">"Needs attention" skips the routine "response complete" message.</span> + </div> + <select id="discordScope" class="set-select"> + <option value="attention">Needs attention</option> + <option value="all">Everything</option> + </select> + </div> + <div class="set-row"> + <div class="set-row-text"> + <span class="set-row-label">Save and test</span> + <span class="set-row-desc">Send test saves first, then posts to the parent channel.</span> + </div> + <span> + <button class="btn-toolbar btn-sm btn-primary" id="discordSaveBtn" onclick="app.saveDiscord()">Save</button> + <button class="btn-toolbar btn-sm" id="discordTestBtn" onclick="app.testDiscord()">Send test</button> + <button class="btn-toolbar btn-sm" id="discordClearBtn" onclick="app.clearDiscord()" style="display:none">Remove token</button> + </span> + </div> + <div id="discordResult" class="set-note" style="display:none" data-i18n-skip></div> + </div> + </div> </section> <!-- ══ Voice ════════════════════════════════════════════════════ --> @@ -3563,7 +3787,19 @@ <div class="form-row"> <label>Case Name</label> <input type="text" id="cloneCaseName" placeholder="repo" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false" oninput="app.onCloneNameEdited()"> - <span class="form-hint">Filled in from the URL. Cloned into ~/codeman-cases/<name>, so deleting the case deletes this working tree.</span> + <span class="form-hint" id="cloneCaseNameHint">Filled in from the URL. Cloned into ~/codeman-cases/<name>, so deleting the case deletes this working tree.</span> + </div> + <div class="form-row" id="cloneCustomPathToggleRow"> + <label class="checkbox-row"><input type="checkbox" id="cloneCustomPathToggle" onchange="app.toggleCloneCustomPath()"> 📁 Clone into a custom folder</label> + <span class="form-hint">By default the clone goes under ~/codeman-cases. Choose a folder and the repository is cloned into that folder itself (an empty folder, or one that does not exist yet); it is listed like any other case, and deleting the case only unlinks it (your files stay).</span> + </div> + <div class="form-row" id="cloneCustomPathRow" style="display:none"> + <label>Folder</label> + <div class="path-input-group"> + <input type="text" id="cloneCasePath" placeholder="~/projects" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" oninput="app.updateCloneCasePathPreview()"> + <button type="button" class="btn path-input-browse" onclick="app.openCloneCasePathPicker()">Browse…</button> + </div> + <span class="form-hint" id="cloneCasePathPreview">Pick an empty folder, or one that does not exist yet, to clone into.</span> </div> <div class="form-row"> <label>Branch or Tag (optional)</label> @@ -4429,6 +4665,7 @@ <script defer src="mobile-overview.js"></script> <script defer src="home-sessions.js"></script> <script defer src="git-status-ui.js"></script> + <script defer src="edit-history-ui.js"></script> <script defer src="entrance-animations.js"></script> <script defer src="ralph-wizard.js"></script> <script defer src="api-client.js"></script> diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index abfcf8f1..6a526e9d 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -80,7 +80,7 @@ const PathPicker = { /** * Open the lazy filesystem browser. * @param {{sessionId?: string, initialPath?: string, directoriesOnly?: boolean, - * title?: string, onSelect: (path: string) => void}} options + * allowNewFolder?: boolean, title?: string, onSelect: (path: string) => void}} options */ open(options) { this.close(false); @@ -114,9 +114,15 @@ const PathPicker = { <input type="text" class="path-picker-current" aria-label="Current folder path" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go"> <button type="submit" class="path-picker-go" title="Go to this path" aria-label="Go to this path">Go</button> </form> + <button type="button" class="path-picker-newfolder" title="New folder" aria-label="New folder" hidden>+📁</button> <button type="button" class="path-picker-hidden" title="Show hidden files and folders" aria-label="Show hidden files and folders" aria-pressed="false">.*</button> <button type="button" class="path-picker-refresh" title="Refresh" aria-label="Refresh">↻</button> </div> + <form class="path-picker-newfolder-form" hidden> + <input type="text" class="path-picker-newfolder-name" aria-label="New folder name" placeholder="New folder name" maxlength="255" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="done"> + <button type="submit" class="path-picker-newfolder-create">Create</button> + <button type="button" class="path-picker-newfolder-cancel">Cancel</button> + </form> <div class="path-picker-toolbar"> <div class="path-picker-status" aria-live="polite">Loading...</div> <label class="path-picker-sort-label">Sort @@ -147,6 +153,7 @@ const PathPicker = { overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load(this._currentPath)); overlay.querySelector('.path-picker-hidden').addEventListener('click', () => this.toggleHidden()); this._syncHiddenButton(); + this._wireNewFolder(overlay, !!options.allowNewFolder); // Typing a path is the fast way there. The listing is loaded ONLY on Enter/Go, // never on each keystroke: a half-typed path is a 404 the server has to // answer for nothing, and jumping mid-edit would yank the field around. @@ -183,6 +190,70 @@ const PathPicker = { this.load(options.initialPath || ''); }, + /** + * "New folder": opt-in per caller (`allowNewFolder`), so a picker that browses somewhere the host + * filesystem route cannot create in (the container-workdir picker) never shows it. The folder is made + * inside the folder being viewed and is selected once it exists. + */ + _wireNewFolder(overlay, enabled) { + const button = overlay.querySelector('.path-picker-newfolder'); + const form = overlay.querySelector('.path-picker-newfolder-form'); + const input = overlay.querySelector('.path-picker-newfolder-name'); + if (!enabled) return; + button.hidden = false; + const close = () => { + form.hidden = true; + input.value = ''; + }; + button.addEventListener('click', () => { + form.hidden = !form.hidden; + if (!form.hidden) input.focus(); + }); + overlay.querySelector('.path-picker-newfolder-cancel').addEventListener('click', close); + input.addEventListener('keydown', (event) => { + // Escape closes the form first; the document handler would close the whole picker. + if (event.key !== 'Escape') return; + event.preventDefault(); + event.stopPropagation(); + close(); + }); + form.addEventListener('submit', async (event) => { + event.preventDefault(); + const name = input.value.trim(); + if (!name || !this._currentPath) return; + const status = overlay.querySelector('.path-picker-status'); + const create = overlay.querySelector('.path-picker-newfolder-create'); + const parent = this._currentPath; + // One request at a time: a double tap would send a second one whose 409 shows after the first succeeded. + create.disabled = true; + try { + const res = await fetch('/api/filesystem/mkdir', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + path: parent, + name, + sessionId: this._options?.sessionId || undefined, + showHidden: this._showHidden ? 'true' : undefined, + }), + }); + const result = await res.json(); + if (!result?.success) throw new Error(result?.error || 'Could not create the folder'); + if (!this.overlay) return; + close(); + await this.load(parent); + // The listing may have moved on while the request ran; only select it if it is still on screen. + if (this._currentPath === parent) this.select(result.data.path); + } catch (error) { + if (!this.overlay) return; + status.textContent = error.message || 'Could not create the folder'; + status.classList.add('error'); + } finally { + create.disabled = false; + } + }); + }, + _loadShowHidden() { try { return localStorage.getItem(PATH_PICKER_SHOW_HIDDEN_KEY) === '1'; @@ -1246,7 +1317,11 @@ const KeyboardAccessoryBar = { // the single-line input transport and is what terminal.paste() emitted. // Paste markers inside the text are dropped first: a clipboard carrying // `ESC[201~` would end the bracket early and run the rest without Enter. - const pasteText = text.replace(/\x1b\[20[01]~/g, '').replace(/\r?\n/g, '\r'); + // Strip until none is left: one pass over `ESC[20` + `ESC[201~` + `1~` leaves + // an `ESC[201~` behind. + let pasteText = text; + while (/\x1b\[20[01]~/.test(pasteText)) pasteText = pasteText.replace(/\x1b\[20[01]~/g, ''); + pasteText = pasteText.replace(/\r?\n/g, '\r'); if (pasteText.length > this._composerMaxLength) { app.showToast?.(`${label} is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error'); return false; diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index f63b848c..e172683e 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -344,6 +344,13 @@ const KeyboardHandler = { // branches are skipped on a shape change, whichever way the height moved. if (!shapeChanged && heightDiff > 150 && !this.keyboardVisible) { this.keyboardVisible = true; + // Hold the PTY's rows for as long as the keyboard covers the terminal + // (keyboardHoldsRows, keyboardClipPx in constants.js): every height + // change makes Claude Code clear and reprint only the tail that fits. + // Captured before anything refits, so these are the keyboard-free rows. + if (typeof app !== 'undefined' && app.terminal && app._keyboardHoldEligible?.()) { + app._keyboardHeldRows = app.terminal.rows; + } document.body.classList.add('keyboard-visible'); // While the keyboard is open, size the app to the visual viewport so // xterm's bottom row and cursor sit above the OS keyboard. @@ -355,6 +362,7 @@ const KeyboardHandler = { // iOS 26's persistent 24px discrepancy, and Safari bottom bar changes else if (!shapeChanged && heightDiff < 100 && this.keyboardVisible) { this.keyboardVisible = false; + this._releaseHeldRows(); document.body.classList.remove('keyboard-visible'); this.onKeyboardHide(); // Re-sync --app-height now that keyboard is gone (MobileDetection skipped @@ -381,6 +389,12 @@ const KeyboardHandler = { // drop can re-arm the show branch against a baseline that already sits // at the shrunk height. if (shapeChanged) { + // Held rows belong to the display that is gone: release them and arm the + // settle, which refits and tells the PTY. Nothing else would: with the + // keyboard up throttledResize stands aside, and a rotation alone arms no + // settle, so xterm kept the held rows with no clip and the composer sat + // under the keyboard until it closed. + if (this._releaseHeldRows()) this._scheduleViewportSettle(); this.initialViewportHeight = this.keyboardVisible ? window.innerHeight : currentHeight; } else if (!this.keyboardVisible) { this.initialViewportHeight = currentHeight; @@ -394,6 +408,11 @@ const KeyboardHandler = { this.lastViewportHeight = currentHeight; }, + /** End a keyboard row hold (app._releaseKeyboardHold); true when one was held. */ + _releaseHeldRows() { + return typeof app !== 'undefined' && !!app._releaseKeyboardHold?.(); + }, + /** Update layout when keyboard shows/hides */ updateLayoutForKeyboard() { if (!window.visualViewport) return; @@ -606,6 +625,9 @@ const KeyboardHandler = { // geometry xterm will actually keep (issue #464). app.syncTerminalGeometry?.(); if (this.keyboardVisible) this._shrinkPaddingToFit(); + // Rows held: nothing re-renders on its own (the size did not change), + // so place the screen for the settled box here. + app._applyKeyboardClip?.(); // Following live output → bottom, as before. Reading history → back to // the pre-reflow anchor instead of being yanked down (#259). if (shouldRestoreScroll) { diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 6dcd57d2..13c1e367 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -586,6 +586,8 @@ html.mobile-init .file-browser-panel { .btn-icon-header.btn-away-digest, .btn-icon-header.btn-session-manager, .btn-icon-header.btn-file-viewer, + /* Edit history: a desktop panel; phones never show the button (opt-in, default OFF anyway). */ + .btn-icon-header.btn-edit-history, /* Approvals bell: phones answer from the overview's NEEDS YOU rows instead (inline approve/deny in mobile-overview.js); the bell would only crowd the header it was designed to stay out of. */ @@ -949,6 +951,29 @@ html.mobile-init .file-browser-panel { display: none !important; } + /* The "+" is opt-in (showTabNewButton, per-device; html.tabs-show-new). + Switched on it joins gear + close on the active tab only, and the label + reserve above grows to clear all three (test/mobile-tab-tap-zones). */ + .session-tab .tab-new { + display: none; + } + + html.tabs-show-new .session-tab.active .tab-new { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 1rem; + line-height: 1; + width: 28px; + height: 32px; + padding: 0; + opacity: 0.8; + } + + html.tabs-show-new .session-tab.active .tab-name { + min-width: 68px; + } + /* Gear icon on active tab - tiny, subtle */ .session-tab.active .tab-gear { display: inline-flex; @@ -3427,7 +3452,8 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat @media (hover: none) and (pointer: coarse) { /* Remove hover-dependent UI patterns */ .session-tab .tab-close, - .session-tab .tab-gear { + .session-tab .tab-gear, + .session-tab .tab-new { opacity: 1; width: auto; padding: 0.15rem 0.25rem; @@ -3449,6 +3475,47 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat .subagent-dropdown-trigger:focus + .subagent-dropdown-menu { display: block; } + + /* Press feedback for the touch bars. The pressed look is transform + filter, + never a background or a shadow: the skin block (html:not([data-skin="og"]), + specificity 0,2,1) and the per-run-mode colors own the background and its + hover rules own box-shadow, so a background or shadow :active lost to them + on every non-og skin. Nothing on these buttons sets transform or filter + (the base .btn-toolbar:active nudge sits at lower specificity), so one rule + reads the same on all of them. 80ms so a quick tap shows the press at all, + on the press's own properties rather than `all`. */ + .toolbar .btn-toolbar, + .btn-voice-mobile, + .btn-attach-mobile, + .btn-settings-mobile, + .accessory-btn { + -webkit-tap-highlight-color: transparent; + transition: transform 80ms ease, filter 80ms ease, background-color 80ms ease, border-color 80ms ease; + } + + .toolbar .btn-toolbar:active, + .btn-voice-mobile:active, + .btn-attach-mobile-wrap:active .btn-attach-mobile, + .btn-settings-mobile:active, + .accessory-btn:active { + transform: scale(0.94); + filter: brightness(1.25); + } + + /* Light skins darken on press instead of brightening (white on white reads as nothing). */ + html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.toolbar .btn-toolbar:active, .btn-voice-mobile:active, .btn-attach-mobile-wrap:active .btn-attach-mobile, .btn-settings-mobile:active, .accessory-btn:active) { + filter: brightness(0.9); + } +} + +@media (hover: none) and (pointer: coarse) and (prefers-reduced-motion: reduce) { + .toolbar .btn-toolbar:active, + .btn-voice-mobile:active, + .btn-attach-mobile-wrap:active .btn-attach-mobile, + .btn-settings-mobile:active, + .accessory-btn:active { + transform: none; + } } @media (max-width: 599px) { @@ -4127,6 +4194,7 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab { Matches the (hover: none) block above, but has to be repeated here because the phone block hides them on non-active tabs with (0,2,0). */ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-gear, +html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-new, html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close { display: inline-flex; align-items: center; diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 5da8666c..e46baa4a 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -643,9 +643,9 @@ Object.assign(CodemanApp.prototype, { /** * Left/Right change the agent only while the new-session row is highlighted, - * and only when the key would not move the caret anyway (Left at the start of - * the query, Right at its end, no selection), so editing a typed case name - * keeps working. + * and the caret is at either query boundary, with no selection. Both arrows + * stay in picker navigation after typing a case name; inside the query they + * still edit text, as do modified arrows and selection changes. */ _commandPaletteArrowCyclesAgent(e) { const active = (this.commandPaletteItems || [])[this.commandPaletteActiveIndex || 0]; @@ -657,7 +657,7 @@ Object.assign(CodemanApp.prototype, { const start = Number.isFinite(input?.selectionStart) ? input.selectionStart : value.length; const end = Number.isFinite(input?.selectionEnd) ? input.selectionEnd : start; if (start !== end) return false; - return e.key === 'ArrowRight' ? end >= value.length : start <= 0; + return start <= 0 || end >= value.length; }, _findCommandPaletteCaseMatch(query = '') { @@ -4195,7 +4195,7 @@ Object.assign(CodemanApp.prototype, { }, /** - * Register an out-of-workspace path as a live external attachment and return + * Register a file path as a live external attachment and return * its id, so the preview can render it through the by-id attachment routes. * * `notify: false` keeps this quiet: the caller is already opening the file in @@ -4204,7 +4204,7 @@ Object.assign(CodemanApp.prototype, { * full attachment guard (blocked secret trees, extension allowlist, symlinks * resolved), so a refusal here is a policy answer worth showing verbatim. * - * @returns {Promise<{attachmentId?: string, size?: number, error?: string}>} + * @returns {Promise<{attachmentId?: string, size?: number, error?: string, status?: number}>} */ async _registerExternalPreview(filePath, sessionId) { try { @@ -4220,14 +4220,18 @@ Object.assign(CodemanApp.prototype, { const reason = result?.error || `Cannot open this file (HTTP ${res.status})`; // The registry's type answer is a policy term, not an explanation, and the // user just clicked a file they can see on disk. Say what IS previewable - // from outside the workspace instead. + // through this lookup instead, using the path's actual workspace scope. if (/unsupported/i.test(reason)) { const ext = (filePath.split('.').pop() || '').toLowerCase(); + const target = this._isExternalPreviewPath(filePath, sessionId) + ? `.${ext} from outside the session workspace` + : `"${filePath}" through the attachment lookup`; return { - error: `Cannot preview .${ext} from outside the session workspace (images, video, audio, PDF, Office documents and text files only).`, + status: res.status, + error: `Cannot preview ${target} (images, video, audio, PDF, Office documents and text files only).`, }; } - return { error: reason }; + return { error: reason, status: res.status }; } catch (err) { return { error: err.message || 'Cannot open this file' }; } diff --git a/src/web/public/respawn-ui.js b/src/web/public/respawn-ui.js index e7f8dadf..1ba7ce24 100644 --- a/src/web/public/respawn-ui.js +++ b/src/web/public/respawn-ui.js @@ -601,13 +601,13 @@ Object.assign(CodemanApp.prototype, { // presets are a key of the server-wide settings.json there, and its PUT is admin-only // (403), so they are kept as their own on this device and win over the server's on the // next load (_rememberOwnSetting, settings-ui.js). A PUT refused that way (sent before the - // role was known) marks the page read-only for the writes after it. + // role was known) marks the page read-only for the writes after it; any other failure is toasted. if (this._settingsReadOnly?.()) { this._rememberOwnSetting?.('respawnPresets', custom); return; } this._apiPut('/api/settings', { respawnPresets: custom }) - .then((res) => this._noteSettingsWriteRefusal?.(res)) + .then((res) => this._noteSettingsWriteResult?.(res, 'Respawn presets')) .catch(() => {}); }, diff --git a/src/web/public/security-notices.json b/src/web/public/security-notices.json index 13402a07..b6ec72f1 100644 --- a/src/web/public/security-notices.json +++ b/src/web/public/security-notices.json @@ -1,21 +1 @@ -{ - "notices": [ - { - "id": "opencode-console-breach-2026-10", - "severity": "warning", - "cli": "opencode", - "published": "2026-10-17", - "expires": "2026-12-31", - "title": "OpenCode Console breach: check your API keys", - "message": "OpenCode reported unauthorized access to a legacy system on 17 October. Provider API keys (OpenAI, Anthropic and others) saved in some OpenCode Console workspaces may have been exposed. If OpenCode emailed you about it, revoke and replace those keys and check their recent usage. If you got no email, OpenCode says you were not affected.", - "link": { "label": "Read OpenCode's notice", "url": "https://x.com/opencode/status/2111519637049319818" }, - "i18n": { - "zh-CN": { - "title": "OpenCode Console 遭入侵:请检查你的 API 密钥", - "message": "OpenCode 于 10 月 17 日报告,其一个旧系统遭到未授权访问,部分用户保存在 OpenCode Console 工作区中的提供商 API 密钥(OpenAI、Anthropic 等)可能已泄露。如果你收到了 OpenCode 的相关邮件,请撤销并更换这些密钥,同时检查近期用量。OpenCode 表示,没有收到邮件的用户不受影响。", - "label": "查看 OpenCode 公告" - } - } - } - ] -} +{ "notices": [] } diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index c4dde624..fc0d2582 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -684,9 +684,12 @@ Object.assign(CodemanApp.prototype, { /** * Run using the selected mode (Claude Code, OpenCode, Codex, Gemini, or Antigravity). - * `options` comes from the tab group menu's "New session": `count` overrides - * the instance steppers for this launch only, `tabGroupId` rides every create - * request so the server places the session in that group. + * `options` comes from the tab group menu's "New session", `_launchOnce` (a + * worktree, a tab's "+"): `count` overrides the instance steppers for this + * launch only, `tabGroupId` rides every create request so the server places the + * session in that group, `caseName` launches in that case instead of the + * toolbar's selected one, and `mode` starts that CLI instead of the Run + * button's mode, which is never touched. */ async run(options = {}) { if (this._runInFlight) return; @@ -701,7 +704,7 @@ Object.assign(CodemanApp.prototype, { } try { - const mode = this._runMode || 'claude'; + const mode = options.mode || this._runMode || 'claude'; if (mode === 'shell') { return await this.runShell(options); } @@ -732,12 +735,12 @@ Object.assign(CodemanApp.prototype, { // where settings.json is server-wide and its PUT admin-only: the mode is kept as their own // on this device and wins over the server's on the next load (_rememberOwnSetting, // settings-ui.js). A PUT refused that way (sent before the role was known) marks the page - // read-only for the writes after it (_noteSettingsWriteRefusal). + // read-only for the writes after it (_noteSettingsWriteRefusal); any other failure is toasted. if (this._settingsReadOnly?.()) { this._rememberOwnSetting?.('runMode', mode); } else { this._apiPut('/api/settings', { runMode: mode }) - .then((res) => this._noteSettingsWriteRefusal?.(res)) + .then((res) => this._noteSettingsWriteResult?.(res, 'Run mode')) .catch(() => {}); } // Close menu @@ -820,7 +823,8 @@ Object.assign(CodemanApp.prototype, { * never gated; both callers skip it before asking. * * Reads only what is already known and never starts a container probe: the - * Run menu starts one (_probeDockerCaseModes), and its answer lands in + * Run menu and a tab's "+" (openTabNewSessionMenu, app.js) start one + * (_probeDockerCaseModes), and its answer lands in * _dockerCaseModes, which this reads. Until then (a probe still in flight) * the stored availableModes decide, else nothing is gated. */ @@ -998,12 +1002,23 @@ Object.assign(CodemanApp.prototype, { }, /** - * Best-effort lookup of the model llama-swap currently has loaded and ready on this - * endpoint, so the picker can offer it first instead of making the user remember what - * they picked last time it mattered. Mirrors `_watchLlamaSwapLoading`'s own - * `state === 'ready'` check. Returns null for a plain (non-llama-swap) server, an - * unreachable endpoint, or a loaded model this host no longer lists as discovered — - * never throws, since a failed probe should just skip promotion, not break the picker. + * Best-effort lookup of what the endpoint itself says it has loaded and ready, so the + * picker can offer it first instead of making the user remember what they picked last + * time it mattered. Only llama-swap answers this question (the route reads its + * `/running`); every other OpenAI-compatible server (a bare llama.cpp, Ollama, vLLM, + * or a gateway such as Bifrost fronting a whole fleet) replies `isLlamaSwap: false`, + * which is why `_getCustomModelInUseBySession` exists. `state === 'ready'` mirrors + * `_watchLlamaSwapLoading`'s own check. + * + * Resolves the raw route payload rather than a model id so the caller can tell the + * three answers apart: llama-swap naming a model; `isLlamaSwap: false`, which is what + * the route answers for EVERY failure it catches (a server without a `/running` + * route, a refused or unresolvable endpoint, a 401 from a wrong key): safe to + * explain in the UI as long as the wording also fits an endpoint that is down; and + * `null`, only when Codeman's own request to the route failed or the client-side + * bound below won the race, which stays silent, since the endpoint may merely have + * been asleep. Never throws: a failed probe should + * just skip promotion, not break the picker. * * ⚠️ Client-side bounded to ~800ms via Promise.race, on top of (never instead of) the * route's own 5s server-side timeout (`RUNNING_TIMEOUT_MS`, custom-model-routes.ts) — @@ -1020,17 +1035,58 @@ Object.assign(CodemanApp.prototype, { * `vi.useFakeTimers()` patches, so a param is the only way to test the timeout without * actually waiting on it. Real callers never pass it. */ - async _getCustomModelCurrentlyLoaded(host, timeoutMs = 800) { + async _getCustomModelRunningStatus(host, timeoutMs = 800) { const probe = this._apiJson(`/api/model-endpoints/${encodeURIComponent(host.id)}/running-status`).catch( () => null ); const timeout = new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs)); - const status = await Promise.race([probe, timeout]); + return Promise.race([probe, timeout]); + }, + + /** + * The model llama-swap reports `ready` on this endpoint AND that this host still lists + * as discovered (an undropped-but-undiscovered model has no row to promote), else null. + */ + _pickReadyRunningModel(status, host) { if (!status?.isLlamaSwap) return null; const ready = (status.running || []).find((r) => r.state === 'ready' && (host.models || []).includes(r.model)); return ready?.model || null; }, + /** + * The model a LIVE session of this instance is pointed at on `host`, or null. It is the + * fallback for every endpoint that cannot answer "what is loaded?" about itself. + * A session already running on the endpoint is first-hand evidence of a model in use + * there: Codeman itself injected that endpoint and model into it, so it earns the same + * promotion llama-swap's own answer gets, under its own honest tag. + * + * ⚠️ Reads ONLY `customModel` (what Codeman injected), never `displayModel`: that field + * is display text scraped off a CLI's footer or statusline for a session header (pane + * derived, `source: 'screen'`), and promoting a row because a terminal printed a + * similar string is worse than showing nothing. + * ⚠️ Requires a live pane: `status: 'stopped'`, a null `pid` (the PTY exited; the + * same live-pane test keep-warm applies, `src/prompt-cache-keepwarm.ts`) or a recorded + * `paneExit` means nothing is on the endpoint any more, and a dead session's stale pin + * is exactly what an "in use" claim must not resurrect. `session:updated` keeps it + * current, so a session cleared back to the CLI's own backend or moved to another + * endpoint stops counting. + * ⚠️ A since-deleted endpoint simply matches no host, so no extra liveness check is + * needed here. + */ + _getCustomModelInUseBySession(host) { + const models = host?.models || []; + if (models.length === 0) return null; + let newest = null; + for (const [, session] of this.sessions || []) { + if (!session || session.status === 'stopped' || session.pid === null || session.paneExit) continue; + const pinned = session.customModel; + if (!pinned || pinned.endpointId !== host.id) continue; + if (!models.includes(pinned.modelId)) continue; // undiscovered since: no row to promote + if (!newest || (newest.createdAt || 0) <= (session.createdAt || 0)) newest = session; + } + return newest?.customModel?.modelId || null; + }, + /** * Renders the "which model" picker for a (harness, endpoint) pair with more than one * discovered model. Async since it now awaits the currently-loaded-model probe below, @@ -1051,15 +1107,17 @@ Object.assign(CodemanApp.prototype, { const generation = (this._customModelPickGeneration = (this._customModelPickGeneration || 0) + 1); const isCurrent = () => this._customModelPickGeneration === generation; - // Whichever model llama-swap actually has loaded right now beats a merely - // remembered choice — it's what a launch would attach to with zero wait, while - // "last used" might have been swapped out by another session since. Neither - // reorders past the top: exactly one model is promoted, everything else keeps - // its discovery order. - const currentlyLoaded = await this._getCustomModelCurrentlyLoaded(host); + // Three kinds of knowledge, best first: what llama-swap says it has loaded right + // now (zero wait to attach to), what a live session of this instance is pointed at + // on that endpoint (the endpoint itself cannot say, but Codeman put it there), and + // only then this device's memory of the last launch. One wins, and it never reorders + // anything else: every other row keeps its discovery order. + const runningStatus = await this._getCustomModelRunningStatus(host); if (!isCurrent()) return; // a newer pick opened (and possibly already rendered) while this probe was in flight - const lastUsed = currentlyLoaded ? null : this._getCustomModelLastUsed(mode, host.id); - const promoted = currentlyLoaded || lastUsed; + const currentlyLoaded = this._pickReadyRunningModel(runningStatus, host); + const inUse = currentlyLoaded ? null : this._getCustomModelInUseBySession(host); + const lastUsed = currentlyLoaded || inUse ? null : this._getCustomModelLastUsed(mode, host.id); + const promoted = currentlyLoaded || inUse || lastUsed; const models = [...(host.models || [])]; if (promoted && models.includes(promoted)) { models.splice(models.indexOf(promoted), 1); @@ -1074,23 +1132,91 @@ Object.assign(CodemanApp.prototype, { document.getElementById('customModelPickTitle').textContent = 'Choose a model'; document.getElementById('customModelPickHint').textContent = `${cliLabel} → ${host.label} — ${(host.models || []).length} models discovered.`; + // A plain static line (its markup lives in index.html), shown when NOTHING is known + // about any row: 40 alphabetical rows with no mark on any of them otherwise read as + // the app ignoring the question. `isLlamaSwap: false` is what the route answers for + // every failure it catches: a server that cannot report AND a down or refused one + // alike (`getLlamaSwapStatus`, custom-model-routes.ts), so the line's wording + // promises only that nothing was reported, which stays true either way. A probe + // that never got an answer at all (`runningStatus === null`: Codeman's own request + // failed or the 800ms bound won) keeps the line hidden, the box may merely be + // asleep. + // A remembered id that has since vanished from host.models is no promotion: count + // only a promoted id that is actually a row, or a stale "last used" would both skip + // tagging any row and hide this line, leaving 40 unmarked rows with no explanation. + const nothingKnown = !(promoted && models.includes(promoted)) && runningStatus?.isLlamaSwap === false; + const unknownLine = document.getElementById('customModelPickUnknown'); + if (unknownLine) unknownLine.hidden = !nothingKnown; list.innerHTML = models .map((m) => { - // Two independent tags, never one exclusive slot: the promotion tag says what - // llama-swap (or this device's history) knows about the model, the Default pill - // says what the saved endpoint says about it, and on a single-purpose GPU box the - // promoted model IS the default more often than not. One slot holding whichever - // applied first silently dropped the Default marking for exactly that row. - const promotion = m === currentlyLoaded ? 'Currently loaded' : m === lastUsed ? 'Last used' : null; + // Two independent tags, never one exclusive slot: the promotion tag says what the + // endpoint, a live session, or this device's history knows about the model, the + // Default pill says what the saved endpoint says about it, and on a + // single-purpose GPU box the promoted model IS the default more often than not. + // One slot holding whichever applied first silently dropped the Default marking + // for exactly that row. + const promotion = + m === currentlyLoaded + ? 'Currently loaded' + : m === inUse + ? 'In use by a session' + : m === lastUsed + ? 'Last used' + : null; const tags = [promotion, m === host.defaultModelId ? 'Default' : null].filter(Boolean); const arg = escapeHtml(JSON.stringify(m)); + // data-model-id is the filter's haystack (escapeHtml covers `"` as well as + // `<>&`, so a hostile model id cannot break out of the attribute); the + // filter reads it back through dataset, which decodes it. return ` - <button class="run-mode-option" onclick="app.chooseCustomModelAndRun(${arg})"> + <button class="run-mode-option" data-model-id="${escapeHtml(m)}" onclick="app.chooseCustomModelAndRun(${arg})"> <span class="run-mode-dot ${escapeHtml(mode)}"></span>${escapeHtml(m)}${tags.map((t) => ` <span class="set-scope">${escapeHtml(t)}</span>`).join('')} </button>`; }) .join(''); modal.classList.add('active'); + // A fresh open always starts unfiltered with the caret in the search box: the + // list was just rebuilt, so clearing the input is the whole reset; focus is the + // user's next keystroke away. Inside the generation guard above, so a + // superseded probe can neither focus nor clear a modal that now shows another + // endpoint's models. + const search = document.getElementById('customModelPickSearch'); + if (search) { + search.value = ''; + // readmymind-ui.js's rule: on touch devices never focus an input unprompted, or + // the OS keyboard pops over the very list the picker just opened to show. Desktop + // keeps the caret in the box, one keystroke from filtering. + if (!(typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice())) { + search.focus(); + } + } + this.filterCustomModelPick(''); + }, + + /** + * View filter for the open "Choose a model" picker: case-insensitive substring + * over each row's model id, driven from the search input's own `oninput`. + * Deliberately toggles `hidden` on the already-rendered buttons rather than + * re-rendering from a second copy of the model list: the loaded/last-used + * promotion, the Default pill, the escaped inline click argument and the async + * generation guard all then stay exactly where they are, and clearing the + * query restores the list byte for byte. ⚠️ `.run-mode-option` carries its own + * `display: flex` (styles.css, restated in mobile.css), which outranks the UA + * `[hidden]` rule, so styles.css re-asserts `display: none !important` for + * these rows: without it the filter would silently do nothing. + */ + filterCustomModelPick(value) { + const list = document.getElementById('customModelPickList'); + if (!list) return; + const query = String(value || '').trim().toLowerCase(); + let visible = 0; + for (const btn of list.querySelectorAll('button[data-model-id]')) { + const match = !query || btn.dataset.modelId.toLowerCase().includes(query); + btn.hidden = !match; + if (match) visible += 1; + } + const noMatches = document.getElementById('customModelPickNoMatches'); + if (noMatches) noMatches.hidden = visible > 0; }, closeCustomModelPickModal() { @@ -2014,11 +2140,28 @@ Object.assign(CodemanApp.prototype, { } }, + /** The case a launch runs in: its worktree's, the one it names (a tab's "+"), else the toolbar's. */ + _runCaseName(options) { + if (options.worktree) return options.worktree.caseName; + return options.caseName || document.getElementById('quickStartCase').value || 'testcase'; + }, + + /** + * Re-reads the case list after a launch, keeping the toolbar on its own case. + * The no-argument reload would re-select the server's `lastUsedCase`, and a + * tab's "+" launching through quick-start sets that to the tab's case while the + * toolbar stays where it was, so every later launch (a worktree's, a plain Run, + * a group's "New session") would otherwise move the toolbar there. + */ + _reloadCasesAfterRun() { + this.loadQuickStartCases(document.getElementById('quickStartCase').value || null); + }, + async runClaude(options = {}) { // `options.worktree` ({ caseName, name, path }, from the worktree modal): the session runs in // that git worktree of the case and its tab is named after the worktree. const wt = options.worktree || null; - const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase'; + const caseName = this._runCaseName(options); const nameBase = wt ? wt.name : caseName; const tabCount = this._readTabCount(options.count); const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {}; @@ -2123,7 +2266,7 @@ Object.assign(CodemanApp.prototype, { this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} remote session(s) ready`); if (remoteIds[0]) { await this.selectSession(remoteIds[0]); - this.loadQuickStartCases(); + this._reloadCasesAfterRun(); } this.terminal.focus(); return; @@ -2202,7 +2345,7 @@ Object.assign(CodemanApp.prototype, { // Auto-switch to the new session using selectSession (does proper refresh) if (firstSessionId) { await this.selectSession(firstSessionId); - this.loadQuickStartCases(); + this._reloadCasesAfterRun(); } this.terminal.focus(); @@ -2249,7 +2392,7 @@ Object.assign(CodemanApp.prototype, { async runShell(options = {}) { // `options.worktree`: see runClaude. const wt = options.worktree || null; - const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase'; + const caseName = this._runCaseName(options); const nameBase = wt ? wt.name : caseName; // Run Shell reads the toolbar's one instance stepper, like every other run*(); // its own second `− 1 +` group (#shellCount) was removed (#428). @@ -2450,7 +2593,7 @@ Object.assign(CodemanApp.prototype, { // `options.worktree`: see runClaude. Quick-start resolves it server-side (`worktree`), so the // session starts through the same path as any other, only in the worktree's folder. const wt = options.worktree || null; - const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase'; + const caseName = this._runCaseName(options); // Remote/docker cases run the CLI on the OTHER side — the local status // probe and the local-only config/env below don't apply (quick-start // rejects them for remote cases). @@ -2685,7 +2828,13 @@ Object.assign(CodemanApp.prototype, { document.getElementById('modalKeepWarmEnabled').checked = session.keepWarm?.enabled ?? session.keepWarmEnabled ?? false; this.updateKeepWarmStatus(sessionId); - document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true; + // Git: the session's own choice if it has one, otherwise the app setting. updateGitFetchStatus is also + // what SSE calls when a scheduled run lands, so the rows stay current while the modal is open. + this.updateGitFetchStatus(sessionId); + const imageWatcherToggle = document.getElementById('modalImageWatcherEnabled'); + imageWatcherToggle.checked = session.imageWatcherEnabled ?? true; + // The server never watches a remote session's directory (it is on another host), so the row is hidden. + imageWatcherToggle.closest('.set-row').style.display = session.remote ? 'none' : ''; document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false; // Populate session name input with prefix/suffix split @@ -2741,6 +2890,12 @@ Object.assign(CodemanApp.prototype, { if (respawnTabBtn) respawnTabBtn.style.display = ''; } + // Git is local git only: a remote (SSH) or Docker case answers 'unsupported' on both git routes and is + // never fetched by the interval scheduler, so its Git tab has nothing to say and its rail entry goes + // hidden the same way. + const gitTabBtn = document.querySelector('#sessionOptionsModal .set-rail-item[data-tab="git"]'); + if (gitTabBtn) gitTabBtn.style.display = session.remote || session.docker ? 'none' : ''; + // Populate Ralph Wiggum form with current session values (skip for external CLI sessions) if (!isExternalCli) { const ralphState = this.ralphStates.get(sessionId); @@ -3022,11 +3177,114 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * The Git tab: the session's last scheduled run, and which values its rows show. A session with no choice of + * its own follows the app setting, so its switch and interval display the app's numbers and go inert (the + * same inert-row pattern as updateKeepWarmStatus above), because changing them here is what makes the session + * stop following. The app numbers come from the settings cache, so what this shows can be one settings load + * behind; the server decides from a fresh read, not from what the page displays. + * + * SSE calls this on every `session:updated` for the open session (app.js `_onSessionUpdated`), and a working + * session sends one on each working/idle change, so a control the user is in (`document.activeElement`) is + * left as it is: rewriting it put a half-typed interval back to the stored one before `onchange` could save it. + */ + updateGitFetchStatus(sessionId) { + const statusEl = document.getElementById('gitFetchStatus'); + const followBox = document.getElementById('modalGitFetchFollow'); + const box = document.getElementById('modalGitFetchEnabled'); + const num = document.getElementById('modalGitFetchInterval'); + if (!statusEl || !followBox || !box || !num || this.editingSessionId !== sessionId) return; + const session = this.sessions.get(sessionId); + if (!session) return; + + const app = this.loadAppSettingsFromStorage() || {}; + // No choice of its own on either field means the session follows the app. A partial one (its own switch, + // the app's interval) means it does not, and the row that still follows shows the app's value, which is + // also the value the scheduler runs it with (a default here would show one number and fetch at another). + const follows = session.gitFetchEnabled === undefined && session.gitFetchIntervalSeconds === undefined; + const enabled = session.gitFetchEnabled ?? (app.gitFetchEnabled === true); + const interval = session.gitFetchIntervalSeconds ?? app.gitFetchIntervalSeconds ?? 300; + + const focused = document.activeElement; + if (followBox !== focused) followBox.checked = follows; + if (box !== focused) box.checked = enabled; + if (num !== focused) num.value = interval; + for (const el of [box, num]) { + el.closest('.set-row')?.classList.toggle('set-row-disabled', follows); + el.disabled = follows; + } + + const run = session.gitFetch; + const parts = run + ? [ + `Last run ${this._gitFetchAgo(run.ranAt)}${run.trigger === 'interval' ? ' on the interval' : ' by click'}`, + run.state === 'ok' ? `${run.repos} repositories` : run.state, + run.failed ? `${run.failed} failed` : '', + ] + : [follows ? 'Nothing fetched on a timer yet, and this session follows the app setting.' : 'Nothing fetched on a timer yet for this session.']; + statusEl.textContent = parts.filter(Boolean).join(' · '); + statusEl.hidden = false; + }, + + /** 'just now', '4m', '2h': the short stamps the tab rows use, measured from a run's start. */ + _gitFetchAgo(ts) { + const seconds = Math.max(0, Math.round((Date.now() - ts) / 1000)); + if (seconds < 60) return seconds < 10 ? 'just now' : `${seconds}s`; + if (seconds < 3600) return `${Math.round(seconds / 60)}m`; + return `${Math.round(seconds / 3600)}h`; + }, + + async autoSaveGitFetch() { + if (!this.editingSessionId) return; + const follow = document.getElementById('modalGitFetchFollow').checked; + const num = document.getElementById('modalGitFetchInterval'); + // `null` on the route means "no choice of your own", which is what following the app setting is. + const body = follow + ? { enabled: null, intervalSeconds: null } + : { + enabled: document.getElementById('modalGitFetchEnabled').checked, + intervalSeconds: Math.min(3600, Math.max(30, parseInt(num.value, 10) || 300)), + }; + // Enter fires `onchange` with the field still focused, and updateGitFetchStatus leaves a focused field + // alone, so the clamped number is written back here or the field would keep showing what was typed. + if (!follow) num.value = body.intervalSeconds; + try { + // `_apiPost` hands back the Response without checking it, so a refused body (a 400) still read as a + // save. `_apiJson` is the one that says whether the call went through, and null here means it did not. + const data = await this._apiJson(`/api/sessions/${this.editingSessionId}/git-fetch-settings`, { + method: 'POST', + body, + }); + if (!data) { + this.showToast('Failed to save Git fetch settings', 'error'); + return; + } + const session = this.sessions.get(this.editingSessionId); + const saved = data.gitFetchSettings; + if (session && saved) { + session.gitFetchEnabled = saved.enabled ?? undefined; + session.gitFetchIntervalSeconds = saved.intervalSeconds ?? undefined; + } + this.updateGitFetchStatus(this.editingSessionId); + this.showToast('Git fetch settings saved', 'success'); + } catch (err) { + this.showToast('Failed to save Git fetch settings: ' + err.message, 'error'); + } + }, + async toggleSessionImageWatcher() { if (!this.editingSessionId) return; - const enabled = document.getElementById('modalImageWatcherEnabled').checked; + const toggle = document.getElementById('modalImageWatcherEnabled'); + const enabled = toggle.checked; try { - await this._apiPost(`/api/sessions/${this.editingSessionId}/image-watcher`, { enabled }); + const res = await this._apiPost(`/api/sessions/${this.editingSessionId}/image-watcher`, { enabled }); + // `_apiPost` hands back the Response without checking it, so a refused request (a 400) must not + // read as a save: put the switch back and say so. + if (!res?.ok) { + toggle.checked = !enabled; + this.showToast('Failed to toggle image watcher', 'error'); + return; + } // Update local session state const session = this.sessions.get(this.editingSessionId); if (session) { @@ -3160,6 +3418,7 @@ Object.assign(CodemanApp.prototype, { document.getElementById('context-tab').classList.toggle('hidden', tabName !== 'context'); document.getElementById('ralph-tab').classList.toggle('hidden', tabName !== 'ralph'); document.getElementById('summary-tab').classList.toggle('hidden', tabName !== 'summary'); + document.getElementById('git-tab').classList.toggle('hidden', tabName !== 'git'); // A switched-to section starts at its own top, not at the scroll offset the // previous one was left at. @@ -3960,6 +4219,15 @@ Object.assign(CodemanApp.prototype, { set('cloneRepoUrl', ''); set('cloneCaseName', ''); set('cloneRepoRef', ''); + set('cloneCasePath', ''); + // Custom folder starts off each time, and is not offered to a non-admin in multi-user mode: the + // server refuses it (it writes outside the cases directory and into the shared registry). + const customToggle = document.getElementById('cloneCustomPathToggle'); + if (customToggle) customToggle.checked = false; + const customRow = document.getElementById('cloneCustomPathToggleRow'); + const me = window.__codemanUser || {}; + if (customRow) customRow.style.display = me.multiUser && me.role !== 'admin' ? 'none' : ''; + this.toggleCloneCustomPath(); const shallow = document.getElementById('cloneShallow'); if (shallow) shallow.checked = false; const start = document.getElementById('cloneStartSession'); @@ -3987,6 +4255,50 @@ Object.assign(CodemanApp.prototype, { } }, + /** Custom-folder row for Clone Repo: shows or hides the parent-folder field and rewords the name hint. */ + toggleCloneCustomPath() { + const custom = document.getElementById('cloneCustomPathToggle'); + const row = document.getElementById('cloneCustomPathRow'); + if (!custom || !row) return; + row.style.display = custom.checked ? '' : 'none'; + const hint = document.getElementById('cloneCaseNameHint'); + if (hint) { + hint.textContent = custom.checked + ? 'Filled in from the URL. Names the case only: the repository is cloned into the folder chosen below.' + : 'Filled in from the URL. Cloned into ~/codeman-cases/<name>, so deleting the case deletes this working tree.'; + } + this.updateCloneCasePathPreview(); + }, + + /** The folder the repository is cloned into: exactly what the field holds (no case name is added). */ + _cloneTargetPath() { + const raw = (document.getElementById('cloneCasePath')?.value || '').trim(); + // Trailing slashes off, but `/` stays the root rather than becoming an empty path. + return raw.length > 1 ? raw.replace(/\/+$/, '') || '/' : raw; + }, + + updateCloneCasePathPreview() { + const hint = document.getElementById('cloneCasePathPreview'); + if (!hint) return; + const target = this._cloneTargetPath(); + hint.textContent = target ? `Will clone into: ${target}` : 'Pick an empty folder, or one that does not exist yet, to clone into.'; + }, + + openCloneCasePathPicker() { + const input = document.getElementById('cloneCasePath'); + PathPicker.open({ + title: 'Choose the folder to clone into', + initialPath: input.value.trim(), + directoriesOnly: true, + onSelect: (path) => { + input.value = path; + this.updateCloneCasePathPreview(); + input.focus(); + input.setSelectionRange(path.length, path.length); + }, + }); + }, + _setCloneStatus(message, kind) { const el = document.getElementById('cloneRepoStatus'); if (!el) return; @@ -4135,6 +4447,12 @@ Object.assign(CodemanApp.prototype, { return; } + const customFolder = !!document.getElementById('cloneCustomPathToggle')?.checked; + if (customFolder && !(document.getElementById('cloneCasePath')?.value || '').trim()) { + this.showToast('Choose the folder to clone into', 'error'); + return; + } + this._setCloneStatus(`Cloning ${url}… this can take a while for a large repository.`, ''); try { const res = await fetch('/api/cases/clone', { @@ -4142,7 +4460,13 @@ Object.assign(CodemanApp.prototype, { headers: { 'Content-Type': 'application/json' }, // Zod `.optional()` rejects an explicit null, and JSON.stringify keeps one // on the wire — omit the empty fields instead of sending null. - body: JSON.stringify({ name, repository: url, ...(ref ? { ref } : {}), ...(shallow ? { shallow: true } : {}) }), + body: JSON.stringify({ + name, + repository: url, + ...(ref ? { ref } : {}), + ...(shallow ? { shallow: true } : {}), + ...(customFolder ? { path: this._cloneTargetPath() } : {}), + }), }); const data = await res.json(); if (!data.success) { @@ -4178,6 +4502,7 @@ Object.assign(CodemanApp.prototype, { title: 'Select Existing Project Folder', initialPath: pathInput.value.trim(), directoriesOnly: true, + allowNewFolder: true, onSelect: (path) => { pathInput.value = path; const nameInput = document.getElementById('linkCaseName'); @@ -5754,7 +6079,7 @@ Object.assign(CodemanApp.prototype, { const created = r.data.worktree; this.showToast?.(`Worktree ${created.name} created on branch ${created.branch}`, 'success'); this.closeWorktreeModal(); - await this._launchInWorktree({ caseName, name: created.name, path: created.path }, mode); + await this._launchOnce(mode, { worktree: { caseName, name: created.name, path: created.path } }); } finally { if (btn) btn.disabled = false; } @@ -5766,26 +6091,22 @@ Object.assign(CodemanApp.prototype, { if (!caseName || !wt) return; const mode = this._selectedWorktreeMode(); this.closeWorktreeModal(); - await this._launchInWorktree({ caseName, name, path: wt.path }, mode); + await this._launchOnce(mode, { worktree: { caseName, name, path: wt.path } }); }, /** - * Start ONE session of `mode` in a worktree through run(), the Run button's own entry (its - * launch lock, its per-mode paths), with the same temporary `_runMode` swap the custom-model - * entries use, so the Run button's chosen mode is untouched afterwards. + * Start ONE session of `mode` through run(), the Run button's own entry (its launch lock, its + * per-mode paths), outside the toolbar's choices: `runOptions` names a worktree, or a case and + * tab group (a tab's "+"). The mode rides the call (`run({ mode })`) rather than a swap of the + * Run button's mode, so a mode the user picks while the launch is pending is never overwritten. */ - async _launchInWorktree(worktree, mode) { + async _launchOnce(mode, runOptions) { if (this._runInFlight) { this.showToast?.('A launch is already in progress', 'info'); return; } - const previous = this._runMode; - this._runMode = mode || previous; - try { - await this.run({ count: 1, worktree }); - } finally { - this._runMode = previous; - } + if (runOptions.tabGroupId) this.toggleTabGroupCollapsed?.(runOptions.tabGroupId, false); + await this.run({ count: 1, ...runOptions, ...(mode ? { mode } : {}) }); }, /** The Codeman-managed worktree a session runs in, or null (see managedWorktreeOfDir). */ diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index db745f45..fd4c41af 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -541,6 +541,8 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsApprovalsInbox').checked = settings.approvalsInboxEnabled === true; // Agent inbox snapshot on disk: synced, default OFF. document.getElementById('appSettingsAgentInboxPersist').checked = settings.agentInboxPersistEnabled === true; + // Experimental agent coordination (inspect/stage/submit routes): synced, default OFF. + document.getElementById('appSettingsAgentCoordination').checked = settings.agentCoordinationEnabled === true; // Custom Model Endpoint Profiles: synced, default OFF. The toggle governs both // the Run-menu picker's generated entries and this settings panel's visibility; // the endpoint list itself is server state, loaded on demand below. @@ -568,6 +570,7 @@ Object.assign(CodemanApp.prototype, { this.loadConfigBackups(); this._applyDoctorAdminGate(); this.loadWebhook(); + this.loadDiscord(); // Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens). document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true; document.getElementById('appSettingsUltracodeFloatingWindows').checked = @@ -602,6 +605,12 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsGitStatusTree').checked = settings.gitStatusTree ?? defaults.gitStatusTree ?? true; document.getElementById('appSettingsGitStatusMaxRepos').value = settings.gitStatusMaxRepos ?? defaults.gitStatusMaxRepos ?? 12; document.getElementById('appSettingsGitStatusTimeout').value = settings.gitStatusTimeoutSeconds ?? defaults.gitStatusTimeoutSeconds ?? 30; + // Git fetch on an interval: synced, default OFF, interval default 300 s (DEFAULT_GIT_FETCH_INTERVAL_SECONDS). + // Synced means the SERVER decides whether to fetch, so these keys stay out of the per-device display list + // below and are not stripped from a save: a stripped key would never reach the scheduler. + document.getElementById('appSettingsGitFetchEnabled').checked = settings.gitFetchEnabled === true; + document.getElementById('appSettingsGitFetchIntervalSeconds').value = settings.gitFetchIntervalSeconds ?? 300; + document.getElementById('appSettingsShowEditHistory').checked = settings.showEditHistory ?? defaults.showEditHistory ?? false; // Gesture control lives in the Input section (alongside Local Echo / CJK Input) // but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets // window.__codemanGestureAvailable). Hide just this item otherwise so the toggle @@ -618,6 +627,8 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false; this.loadTunnelStatus(); document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice(); + // Default ON, so an absent key reads as enabled rather than as off. + document.getElementById('appSettingsKeyboardHoldsRows').checked = settings.keyboardHoldsRows !== false; // Auto Copy (copy-on-select): per-device, default OFF everywhere. It quietly // overwrites the system clipboard on a gesture the user may have meant only as // a way to read, so it is opt-in rather than a default anyone has to discover. @@ -658,6 +669,8 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsShowTabDetachButton').checked = this.tabDetachButtonEnabled?.(settings, defaults) ?? (settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false); + // The tab's "+" (applyHeaderVisibilitySettings): per-device, OFF unless switched on. + document.getElementById('appSettingsShowTabNewButton').checked = settings.showTabNewButton === true; // Detach Tiles (tile-grid.js): per-device, OFF unless switched on. The tile // grid is desktop-only, so the row is hidden elsewhere, like the lineage lines. document.getElementById('appSettingsTileDetach').checked = settings.tileDetachEnabled === true; @@ -1722,6 +1735,115 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * Discord bridge (Settings → Notifications). Server-side config behind /api/discord, not a + * settings-payload field: the bot token is a secret, so it never round-trips through settings.json + * or this page. The token box is write-only. Unlike the webhook group it has its own Save button + * only (the modal's main Save does not touch it), so a half-typed token is never sent by accident. + */ + _discordSay(text, bad = false) { + const out = document.getElementById('discordResult'); + if (!out) return; + out.textContent = text; + out.style.display = text ? 'block' : 'none'; + out.style.color = bad ? 'var(--danger, #e5534b)' : ''; + }, + + async loadDiscord() { + const group = document.getElementById('discordGroup'); + if (!group) return; + const res = await this._api('/api/discord'); + let body = null; + try { body = res && res.ok ? await res.json() : null; } catch { /* leave hidden */ } + if (!body || body.success === false) { group.style.display = 'none'; return; } // not an admin, or an older server + const d = body.data; + group.style.display = ''; + document.getElementById('discordEnabled').checked = d.enabled === true; + const token = document.getElementById('discordToken'); + token.value = ''; + token.placeholder = d.hasToken ? 'Saved. Paste a new token to replace it' : 'Bot token from the Developer Portal'; + document.getElementById('discordTokenHint').textContent = d.hasToken ? `Saved: ${d.tokenMasked}` : 'Nothing saved yet.'; + document.getElementById('discordGuildId').value = d.guildId || ''; + document.getElementById('discordParentChannelId').value = d.parentChannelId || ''; + document.getElementById('discordAllowedUserIds').value = (d.allowedUserIds || []).join(', '); + document.getElementById('discordThreadCreation').value = d.threadCreation; + document.getElementById('discordOnSessionDelete').value = d.onSessionDelete; + document.getElementById('discordStaleThreads').value = d.staleThreads || 'keep'; + document.getElementById('discordPingMode').value = d.pingMode; + document.getElementById('discordScope').value = d.scope; + document.getElementById('discordStartupHint').checked = d.startupHint !== false; + const clearBtn = document.getElementById('discordClearBtn'); + if (clearBtn) clearBtn.style.display = d.hasToken ? '' : 'none'; + const gw = { off: 'Not connected (disabled).', connecting: 'Connecting to Discord…', connected: 'Connected: replies are being read.', error: `Not connected: ${d.gatewayError || 'error'}` }; + let line = gw[d.gateway] || ''; + if (d.lastResult) { + const when = new Date(d.lastResult.at).toLocaleString(); + line += d.lastResult.ok ? ` Last post succeeded (${when}).` : ` Last post failed (${when}): ${d.lastResult.error}`; + } + this._discordSay(line.trim(), d.gateway === 'error' || (d.lastResult && !d.lastResult.ok)); + }, + + /** PUT the group's state. Resolves to '' on success, else the error (also shown in the group). */ + async saveDiscord() { + const ids = document.getElementById('discordAllowedUserIds').value.split(/[\s,]+/).filter(Boolean); + const payload = { + enabled: document.getElementById('discordEnabled').checked, + guildId: document.getElementById('discordGuildId').value.trim(), + parentChannelId: document.getElementById('discordParentChannelId').value.trim(), + allowedUserIds: ids, + threadCreation: document.getElementById('discordThreadCreation').value, + onSessionDelete: document.getElementById('discordOnSessionDelete').value, + staleThreads: document.getElementById('discordStaleThreads').value, + pingMode: document.getElementById('discordPingMode').value, + scope: document.getElementById('discordScope').value, + startupHint: document.getElementById('discordStartupHint').checked, + }; + const token = document.getElementById('discordToken').value.trim(); + if (token) payload.token = token; // blank = keep the saved one (Remove token is the way to clear it) + const res = await this._api('/api/discord', { method: 'PUT', body: payload }); + let body = null; + try { body = res ? await res.json() : null; } catch { /* fall through */ } + if (!res || !res.ok || !body || body.success === false) { + const error = body?.error || 'Could not save the Discord settings.'; + this._discordSay(error, true); + return error; + } + await this.loadDiscord(); + return ''; + }, + + async clearDiscord() { + if (!confirm('Remove the saved bot token from the server? The Discord bridge stops until you save a new one.')) return; + const res = await this._api('/api/discord', { method: 'PUT', body: { token: '', enabled: false } }); + let body = null; + try { body = res ? await res.json() : null; } catch { /* fall through */ } + if (!res || !res.ok || !body || body.success === false) { + this._discordSay(body?.error || 'Could not remove the token.', true); + return; + } + await this.loadDiscord(); + }, + + async testDiscord() { + const btn = document.getElementById('discordTestBtn'); + if (btn) btn.disabled = true; + try { + if (await this.saveDiscord()) return; // saves first, so it never tests the old values; the error is shown + this._discordSay('Sending…'); + const res = await this._apiPost('/api/discord/test', {}); + let body = null; + try { body = res ? await res.json() : null; } catch { /* fall through */ } + if (!res || !res.ok || !body || body.success === false) { + this._discordSay(body?.error || 'Could not send the test.', true); + return; + } + const r = body.data; + this._discordSay(r.ok ? 'Test sent. Check the channel.' : `Delivery failed: ${r.error}`, !r.ok); + } finally { + if (btn) btn.disabled = false; + } + }, + /** * Settings → System → Diagnostics: run `codeman doctor` on the server (GET /api/doctor) and list * each tool. Built with DOM nodes and textContent: paths and versions come from the host. @@ -2271,10 +2393,16 @@ Object.assign(CodemanApp.prototype, { btn.disabled = true; try { const newEnabled = !isActive; + const gate = newEnabled ? await this._confirmTunnelEnable() : { proceed: true, acknowledge: false }; + if (!gate.proceed) { + this._updateWelcomeTunnelBtn(false); + btn.disabled = false; + return; + } const res = await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tunnelEnabled: newEnabled }), + body: JSON.stringify({ tunnelEnabled: newEnabled, ...(gate.acknowledge ? { acknowledgeUnauthTunnel: true } : {}) }), }); // COD-55: server refuses an unauthenticated public tunnel (403). Surface it. if (newEnabled && (await this._handleTunnelEnableRefusal(res))) { @@ -2283,6 +2411,14 @@ Object.assign(CodemanApp.prototype, { btn.disabled = false; return; } + // A stop the server did not store (a 409 for a settings.json that does not parse) left the + // tunnel running: say so, and leave the button showing it on. + const stopError = newEnabled ? '' : await this._settingsWriteError(res); + if (stopError) { + this.showToast(`Tunnel not stopped: ${stopError}`, 'error', { duration: 0 }); + btn.disabled = false; + return; + } if (newEnabled) { this._showTunnelConnecting(); // Poll tunnel status as fallback in case SSE event is missed @@ -2582,15 +2718,73 @@ Object.assign(CodemanApp.prototype, { return `${Math.floor(hrs / 24)}d ago`; }, + /** + * End an App Settings save whose tunnel enable did not go through (confirm cancelled, or + * refused by the server): the WHOLE settings PUT is off, so turn the tunnel toggle back + * off in the UI and localStorage. Other settings persisted to localStorage still apply locally. + */ + _abandonTunnelEnable(settings, keepOpen) { + settings.tunnelEnabled = false; + this.saveAppSettingsToStorage(settings); + const cb = document.getElementById('appSettingsTunnelEnabled'); + if (cb) cb.checked = false; + if (!keepOpen) this.closeAppSettings(); + }, + + /** The one SECURITY WARNING prompt for an unauthenticated public tunnel. */ + _askUnauthTunnel() { + return confirm( + '⚠️ SECURITY WARNING — no password set\n\n' + + 'Enabling the Cloudflare tunnel will publish THIS machine to a public URL with ' + + 'NO login. Anyone who gets the URL has full terminal control — effectively remote ' + + 'code execution on your computer.\n\n' + + 'Strongly recommended: set CODEMAN_PASSWORD instead.\n\n' + + 'Enable the unauthenticated public tunnel anyway?' + ); + }, + + /** + * #706: ask BEFORE a tunnel enable whenever the server has no login (no CODEMAN_PASSWORD, + * not multi-user), every time, whatever the server would accept. With + * CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL set the PUT is never refused, so the refusal + * handler below would never ask. The ONE gate for the welcome link, the tunnel panel and + * the App Settings save. A tunnel that is already running is not being turned on, and + * the server does not guard it either. When the status cannot be read, the PUT goes out + * without the acknowledgement and `_handleTunnelEnableRefusal` stays the fallback. + * @returns {Promise<{ proceed: boolean, acknowledge: boolean }>} proceed=false: cancelled, + * send nothing; acknowledge: send `acknowledgeUnauthTunnel: true` with the enable. + */ + async _confirmTunnelEnable() { + let status = null; + try { + const res = await fetch('/api/tunnel/status'); + if (res.ok) { + const env = await res.json(); + status = env?.success === true ? env.data : env; + } + } catch { + /* unknown: the 403 handler asks instead */ + } + if (!status || status.running || status.loginConfigured !== false) return { proceed: true, acknowledge: false }; + if (!this._askUnauthTunnel()) { + this.showToast('Tunnel not enabled', 'info'); + return { proceed: false, acknowledge: false }; + } + return { proceed: true, acknowledge: true }; + }, + /** * COD-55: detect the server's refusal to start an unauthenticated public tunnel. * The PUT /api/settings route returns a 4xx with { success:false, error } when no - * CODEMAN_PASSWORD is set and the unauthenticated-network opt-in is not acknowledged. - * Shows the server's (actionable) message as an error toast. + * CODEMAN_PASSWORD is set and the unauthenticated-tunnel opt-in is not acknowledged. + * Shows the server's (actionable) message as an error toast. Since #706 this is the + * fallback for when `_confirmTunnelEnable` could not read the tunnel status. * @param {Response|null} res - the fetch Response from the settings PUT + * @param {object} [sent] - what that PUT sent, resent with the acknowledgement: the refusal + * rejected all of it, so an App Settings save retried with the tunnel keys alone lost the rest * @returns {Promise<boolean>} true if the tunnel-enable was refused (caller should abort) */ - async _handleTunnelEnableRefusal(res) { + async _handleTunnelEnableRefusal(res, sent = { tunnelEnabled: true }) { if (!res || res.ok) return false; let message = 'Tunnel refused: set CODEMAN_PASSWORD before exposing Codeman publicly.'; let errorCode = ''; @@ -2612,14 +2806,7 @@ Object.assign(CodemanApp.prototype, { // 403 = the no-password safety refusal (COD-55). Warn loudly and let the // operator acknowledge the risk; on confirm, retry with explicit acknowledgment. if (res.status === 403) { - const confirmed = confirm( - '⚠️ SECURITY WARNING — no password set\n\n' + - 'Enabling the Cloudflare tunnel will publish THIS machine to a public URL with ' + - 'NO login. Anyone who gets the URL has full terminal control — effectively remote ' + - 'code execution on your computer.\n\n' + - 'Strongly recommended: set CODEMAN_PASSWORD instead.\n\n' + - 'Enable the unauthenticated public tunnel anyway?' - ); + const confirmed = this._askUnauthTunnel(); if (!confirmed) { this._dismissTunnelConnecting?.(); this.showToast('Tunnel not enabled', 'info'); @@ -2629,7 +2816,7 @@ Object.assign(CodemanApp.prototype, { const retry = await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tunnelEnabled: true, acknowledgeUnauthTunnel: true }), + body: JSON.stringify({ ...sent, acknowledgeUnauthTunnel: true }), }); if (retry.ok) { this.showToast('Public tunnel enabling — no password set ⚠️', 'warning'); @@ -2664,16 +2851,28 @@ Object.assign(CodemanApp.prototype, { return; } try { + const gate = enable ? await this._confirmTunnelEnable() : { proceed: true, acknowledge: false }; + if (!gate.proceed) { + this.closeTunnelPanel(); + return; + } const res = await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tunnelEnabled: enable }), + body: JSON.stringify({ tunnelEnabled: enable, ...(gate.acknowledge ? { acknowledgeUnauthTunnel: true } : {}) }), }); // COD-55: server refuses an unauthenticated public tunnel (403). Surface it. if (enable && (await this._handleTunnelEnableRefusal(res))) { this.closeTunnelPanel(); return; } + // A stop the server did not store left the tunnel running (see toggleTunnelFromWelcome). + const stopError = enable ? '' : await this._settingsWriteError(res); + if (stopError) { + this.showToast(`Tunnel not stopped: ${stopError}`, 'error', { duration: 0 }); + this.closeTunnelPanel(); + return; + } if (enable) { this._updateTunnelIndicator(false); const indicator = document.getElementById('tunnelIndicator'); @@ -2892,7 +3091,11 @@ Object.assign(CodemanApp.prototype, { // Gesture overlay is injected at page render (server-side), so a change to it // only takes effect on reload — remember the prior value to decide below. const _prev = this.loadAppSettingsFromStorage(); - const _prevGestureEnabled = (_prev.gestureControlEnabled ?? false) === true; + // After a save the server did not store, this device's blob is ahead of the server, so the + // two server-side changes judged against it (the gesture reload, the plan-usage collection + // flip) are judged against the blob that save started from until a save lands. + const _prevServer = this._unsavedSettingsBaseline ?? _prev; + const _prevGestureEnabled = (_prevServer.gestureControlEnabled ?? false) === true; // WebGL toggle: default ON (desktop), so only an explicit stored false counts // as "previously off" — used below to detect a real OFF→ON flip. const _prevWebglEnabled = (_prev.webglRendererEnabled ?? true) === true; @@ -2929,6 +3132,7 @@ Object.assign(CodemanApp.prototype, { configBackupDir: document.getElementById('appSettingsConfigBackupDir').value.trim(), ...this._configBackupRetentionPayload(_prev), agentInboxPersistEnabled: document.getElementById('appSettingsAgentInboxPersist').checked, + agentCoordinationEnabled: document.getElementById('appSettingsAgentCoordination').checked, readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked, ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked, showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked, @@ -2948,6 +3152,14 @@ Object.assign(CodemanApp.prototype, { // Clamped here and again on the server; an empty or odd value falls back to the default. gitStatusMaxRepos: Math.min(50, Math.max(1, parseInt(document.getElementById('appSettingsGitStatusMaxRepos').value, 10) || 12)), gitStatusTimeoutSeconds: Math.min(120, Math.max(5, parseInt(document.getElementById('appSettingsGitStatusTimeout').value, 10) || 30)), + // Clamped to the schema's bounds here too: a value outside them fails the whole save, and these keys + // must survive the strip below because the server's scheduler is what reads them. + gitFetchEnabled: document.getElementById('appSettingsGitFetchEnabled').checked, + gitFetchIntervalSeconds: Math.min( + 3600, + Math.max(30, parseInt(document.getElementById('appSettingsGitFetchIntervalSeconds').value, 10) || 300) + ), + showEditHistory: document.getElementById('appSettingsShowEditHistory').checked, gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked, subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked, subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked, @@ -2958,6 +3170,7 @@ Object.assign(CodemanApp.prototype, { Math.min(24, Math.max(0.5, parseFloat(document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value) || 2)), tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked, localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked, + keyboardHoldsRows: document.getElementById('appSettingsKeyboardHoldsRows').checked, autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked, copyStripMargin: document.getElementById('appSettingsCopyStripMargin').checked, terminalFontFamily: document.getElementById('appSettingsTerminalFont').value.trim(), @@ -2978,6 +3191,7 @@ Object.assign(CodemanApp.prototype, { tabArrangement: document.getElementById('appSettingsTabArrangement').value, tabStateOrder: document.getElementById('appSettingsTabStateOrder').value, showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked, + showTabNewButton: document.getElementById('appSettingsShowTabNewButton').checked, tileDetachEnabled: document.getElementById('appSettingsTileDetach').checked, sessionListLayout: document.getElementById('appSettingsSessionListLayout').value, sessionSidebarFontSize: this.resolveSessionSidebarFontSize( @@ -3050,6 +3264,8 @@ Object.assign(CodemanApp.prototype, { // Save to localStorage this.saveAppSettingsToStorage(settings); this._updateLocalEchoState(); + // Turning Keep Size Under Keyboard off while it holds releases it now. + this._reconcileKeyboardHold?.(); this.applyTerminalFontFamily?.(settings.terminalFontFamily); this.applyTerminalFontWeights?.(settings); @@ -3199,9 +3415,12 @@ Object.assign(CodemanApp.prototype, { // switch collection off for every desktop, whose chip then went stale with // no error anywhere. An explicit toggle on any device still writes it, in // either direction. - const _chipFlip = this.planUsageCollectionFlip(_prev, settings.showPlanUsageLimits); + const _chipFlip = this.planUsageCollectionFlip(_prevServer, settings.showPlanUsageLimits); const { localEchoEnabled: _leo, + // Per-device (it is about this screen's keyboard) and absent from + // SettingsUpdateSchema (.strict()), so sending it would 400 the whole PUT. + keyboardHoldsRows: _khr, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, skin: _skin, @@ -3248,7 +3467,10 @@ Object.assign(CodemanApp.prototype, { gitStatusTree: _gst, gitStatusMaxRepos: _gsm, gitStatusTimeoutSeconds: _gst2, + // Per-device header button (edit-history-ui.js), likewise absent from the .strict() schema. + showEditHistory: _seh, showTabDetachButton: _tdb, + showTabNewButton: _tnb, // Detach Tiles: what a tile drag does is a property of this device's // windows, and the key is absent from the .strict() SettingsUpdateSchema. tileDetachEnabled: _tde, @@ -3268,16 +3490,29 @@ Object.assign(CodemanApp.prototype, { ...serverSettings } = settings; let webhookError = ''; + let saveError = ''; let serverSaved = false; try { + // #706: a save that turns the tunnel on asks first when the server has no login + // (one already running is not asked about, see _confirmTunnelEnable). + const tunnelGate = settings.tunnelEnabled && !settingsReadOnly + ? await this._confirmTunnelEnable() + : { proceed: true, acknowledge: false }; + if (!tunnelGate.proceed) { + this._unsavedSettingsBaseline = _prevServer; + this._abandonTunnelEnable(settings, keepOpen); + return; + } // A non-admin's save is not sent at all (see settingsReadOnly above): the server // would refuse it with a 403, the plan-usage collection flip included. - const res = settingsReadOnly ? null : await this._apiPut('/api/settings', { + const body = { ...serverSettings, ...(_chipFlip !== undefined ? { showPlanUsageLimits: _chipFlip } : {}), + ...(tunnelGate.acknowledge ? { acknowledgeUnauthTunnel: true } : {}), notificationPreferences: notifPrefsToSave, voiceSettings, - }); + }; + const res = settingsReadOnly ? null : await this._apiPut('/api/settings', body); // Multi-user mode, a non-admin whose role was not known when this save started // (/api/me had not answered, or failed and admin-ui.js fell back to a single-user @@ -3292,20 +3527,25 @@ Object.assign(CodemanApp.prototype, { // COD-55: the server refuses an unauthenticated public tunnel with a 403 — which // rejects the WHOLE settings PUT. Surface the message and revert the tunnel toggle - // (in the UI + localStorage) so it doesn't look enabled. Other settings persisted - // to localStorage above still apply locally. - if (!refusedAsNonAdmin && settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) { - settings.tunnelEnabled = false; - this.saveAppSettingsToStorage(settings); - const cb = document.getElementById('appSettingsTunnelEnabled'); - if (cb) cb.checked = false; - if (!keepOpen) this.closeAppSettings(); + // so it doesn't look enabled. Only a 403: any other failure with the switch on is + // not about the tunnel, and turning the switch off for it would be wrong too. + const tunnelRefused = !refusedAsNonAdmin && settings.tunnelEnabled && res?.status === 403; + if (tunnelRefused && (await this._handleTunnelEnableRefusal(res, body))) { + this._unsavedSettingsBaseline = _prevServer; + this._abandonTunnelEnable(settings, keepOpen); return; } // `_apiPut` answers null or a non-ok response instead of throwing, so this is the only - // evidence the server kept the flags the Apply refresh below reads. - serverSaved = !!res?.ok; + // evidence the server kept the flags the Apply refresh below reads (a tunnel 403 still + // here was retried with the whole save and accepted). + serverSaved = !!res?.ok || tunnelRefused; + // Any other failure (a 409 for a settings.json that does not parse, a 400, a 500, a dropped + // connection) stored nothing on the server, so the save must not say it did. A known + // non-admin sent nothing (res is null). + saveError = + settingsReadOnly || refusedAsNonAdmin || tunnelRefused ? '' : await this._settingsWriteError(res); + this._unsavedSettingsBaseline = saveError ? _prevServer : null; // Save model configuration separately. For a non-admin it is not sent either, and the // answer says whether this save would have changed it. @@ -3316,7 +3556,9 @@ Object.assign(CodemanApp.prototype, { // it. Only when the group was edited: an untouched group must not re-PUT. A refusal (bad URL, // enabled with no URL) keeps the modal open below, with the pasted URL still in the box. webhookError = this._webhookPending() ? await this.saveWebhook() : ''; - if (webhookError) { + if (saveError) { + this.showToast(`Saved on this device, not on the server: ${saveError}`, 'warning', { duration: 0 }); + } else if (webhookError) { this.showToast(`Settings saved, but not the webhook: ${webhookError}`, 'warning'); } else if (syncedChangeRefused || modelConfigRefused || refusedAsNonAdmin) { // ONE toast for the whole save, and only when the user really changed a server-wide @@ -3329,7 +3571,7 @@ Object.assign(CodemanApp.prototype, { // Show tunnel-specific feedback if toggled on (never for a non-admin, whose save // starts nothing: the box only shows what the server already has) - if (settings.tunnelEnabled && !settingsReadOnly && !refusedAsNonAdmin) { + if (settings.tunnelEnabled && !settingsReadOnly && !refusedAsNonAdmin && !saveError) { this.showToast('Tunnel starting — QR code will appear when ready...', 'info'); } } catch (err) { @@ -3341,9 +3583,10 @@ Object.assign(CodemanApp.prototype, { // old flags, and a webhook-only failure still saved the rest, so this runs ahead of that branch. if (keepOpen && serverSaved) this._refreshSettingsAfterApply(settings); + // A failed settings PUT keeps the modal open too, with the form as the user left it. if (webhookError) { document.getElementById('webhookGroup')?.scrollIntoView({ block: 'center' }); - } else if (!keepOpen) { + } else if (!keepOpen && !saveError) { this.closeAppSettings(); } @@ -3354,10 +3597,10 @@ Object.assign(CodemanApp.prototype, { // The gesture overlay is injected at page render (server reads // gestureControlEnabled from settings.json), so a change only takes effect on // reload. Reload when it actually changed — the server PUT above already - // persisted the new value. + // persisted the new value (not after a failed PUT: the reload would render the old one). // `?? false`: a non-admin's save puts a synced key the server never stored back to // absent (_keepServerSyncedSettings), and absent means off, as in _prevGestureEnabled. - if ((settings.gestureControlEnabled ?? false) !== _prevGestureEnabled) { + if (!saveError && (settings.gestureControlEnabled ?? false) !== _prevGestureEnabled) { this.showToast( settings.gestureControlEnabled ? 'Enabling gesture control — reloading…' : 'Disabling gesture control — reloading…', 'info' @@ -3406,6 +3649,35 @@ Object.assign(CodemanApp.prototype, { return true; }, + /** + * Why a settings write did not land, or '' when it did: the server's `error` (a 409 when + * settings.json does not parse, a 400, ...), else the HTTP status, else, for the null that + * `_apiPut` answers a dropped connection with, that the server could not be reached. Reads a + * clone, like _noteSettingsWriteRefusal. + * @returns {Promise<string>} + */ + async _settingsWriteError(res) { + if (!res) return 'the server could not be reached'; + if (res.ok) return ''; + let body = null; + try { + body = await res.clone().json(); + } catch { + /* not JSON: the status is all there is */ + } + return body?.error || `HTTP ${res.status}`; + }, + + /** + * The end of a background settings write (run mode, respawn presets): the admin-only refusal is + * _noteSettingsWriteRefusal's, and any other failure says that `what` stayed on this device only. + */ + async _noteSettingsWriteResult(res, what) { + if (await this._noteSettingsWriteRefusal(res)) return; + const error = await this._settingsWriteError(res); + if (error) this.showToast(`${what} saved on this device, not on the server: ${error}`, 'warning', { duration: 0 }); + }, + /** * Where a non-admin's OWN run mode and respawn presets live (multi-user mode). Both are keys of * the server-wide settings.json, whose PUT is admin-only, so this user's picks are kept on this @@ -3717,6 +3989,9 @@ Object.assign(CodemanApp.prototype, { document.getElementById('customModelHostApiKey').value = ''; // the server never returns the real value (apiKeySet is a bool) document.getElementById('customModelHostApiKey').placeholder = host?.apiKeySet ? '•••••••• (unchanged if left blank)' : ''; document.getElementById('customModelHostAuthStyle').value = host?.authStyle || 'bearer'; + // User-set fallback, round-tripped verbatim (discovery never touches it, so the + // cached copy is authoritative); blank means "no default", cleared on save. + document.getElementById('customModelHostDefaultContextLength').value = host?.defaultContextLength ?? ''; this._populateCustomModelDefaultSelect(host); document.getElementById('customModelHostEditor').style.display = ''; }, @@ -3742,6 +4017,18 @@ Object.assign(CodemanApp.prototype, { const baseUrl = document.getElementById('customModelHostBaseUrl').value.trim(); const apiKeyInput = document.getElementById('customModelHostApiKey').value; const authStyle = document.getElementById('customModelHostAuthStyle').value; + // Blank means "no default" (key omitted, so PUT clears it); a value must satisfy + // the same bound the server schema enforces, caught here for a friendlier message. + const defaultContextLengthInput = document.getElementById('customModelHostDefaultContextLength').value.trim(); + let defaultContextLength; + if (defaultContextLengthInput !== '') { + const parsed = Number(defaultContextLengthInput); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100000000) { + this.showToast('Default context length must be a whole number of tokens between 1 and 100,000,000', 'warning'); + return; + } + defaultContextLength = parsed; + } const defaultModelId = document.getElementById('customModelHostDefaultModel').value || undefined; if (!id || !label || !baseUrl) { this.showToast('Id, label and base URL are all required', 'warning'); @@ -3762,6 +4049,7 @@ Object.assign(CodemanApp.prototype, { baseUrl, authStyle, defaultModelId, + defaultContextLength, apiKey: apiKeyInput || undefined, models: existing?.models, lastDiscoveredAt: existing?.lastDiscoveredAt, @@ -4387,6 +4675,9 @@ Object.assign(CodemanApp.prototype, { this.tabDetachButtonEnabled?.(settings, defaults) ?? (settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false); document.documentElement.classList.toggle('tabs-show-detach', showTabDetach); + // The tab's "+" (new session beside it): per-device and opt-in everywhere, + // like the pop-out button. On a phone it also widens the active tab (mobile.css). + document.documentElement.classList.toggle('tabs-show-new', settings.showTabNewButton === true); const compactHeader = MobileDetection.getDeviceType() !== 'desktop'; const showFontControls = compactHeader ? false : (settings.showFontControls ?? defaults.showFontControls ?? false); const showSystemStats = compactHeader ? false : (settings.showSystemStats ?? defaults.showSystemStats ?? true); @@ -4539,6 +4830,8 @@ Object.assign(CodemanApp.prototype, { // Bottom-bar Git indicator (git-status-ui.js): opt-in, per-device. Starts or stops its poll to // match the setting, so a live toggle needs no reload. this.applyGitStatusVisibility?.(); + // Header Edit history button (edit-history-ui.js): opt-in, per-device, marker-class hidden. + this.applyEditHistoryVisibility?.(); // Notification bell is retired (notifications live in Settings → Notifications // + the drawer); keep it hidden regardless of the notification-enabled state. @@ -4896,14 +5189,15 @@ Object.assign(CodemanApp.prototype, { 'showFontControls', 'showSystemStats', 'showGpuStats', 'showCpuMhz', 'headerStatsStyle', 'showTokenCount', 'showCost', 'showLifecycleLog', 'showResponseViewer', 'showRedrawButton', 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', - 'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar', + 'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'keyboardHoldsRows', 'cjkInputEnabled', 'extendedKeyboardBar', 'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled', 'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold', 'language', 'terminalWheelLocalScrollback', 'autoCopySelection', 'copyStripMargin', - 'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds', + 'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds', 'showEditHistory', 'showTabDetachButton', + 'showTabNewButton', 'mobileOverviewEnabled', 'sessionLineageLines', 'showSplitButton', diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 0ed5ee0c..6e0efbef 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -616,6 +616,52 @@ body { padding-left: var(--lineage-spine-channel); } +/* L-shaped strip (`_syncTabStripCorner()`, app.js): a wrapped classic strip + runs the header's full width, the toolbar sits over its top-right corner, + and only the rows beside the toolbar stop short of it. A flex line cannot + be shortened on its own, so the strip lays its tabs out inline, around a + float the size of the toolbar's box (measured into --tab-corner-width and + --tab-corner-height). The strip is a scroll container, so it contains the + float and is never shorter than the toolbar it now holds up; a strip that + would actually scroll keeps the plain wrap instead. Inline tabs space their + rows with a bottom margin (the old row-gap, lineage's included); the strip + carries no font size of its own, so the whitespace between tabs is 0 wide. */ +.header:has(> .session-tabs-host > .session-tabs.tabs-corner) > .header-right { + position: absolute; + top: 0.35rem; + right: 0.75rem; +} + +.session-tabs-host > .session-tabs.tabs-corner { + display: block; + font-size: 0; + padding-bottom: 0; +} + +.session-tabs-host > .session-tabs.tabs-corner::before { + content: ''; + float: right; + width: var(--tab-corner-width, 0px); + height: var(--tab-corner-height, 0px); +} + +.session-tabs-host > .session-tabs.tabs-corner > .session-tab { + display: inline-flex; + vertical-align: top; + margin: 0 2px var(--lineage-row-gap, 4px) 0; +} + +/* The first tab is capped to the room beside the toolbar: one wider than that (a + selected one shows its gear and close) has its name trimmed to fit, so the + layout never depends on which tab is selected. */ +.session-tabs-host > .session-tabs.tabs-corner > .session-tab.tab-corner-fit { + max-width: var(--tab-corner-first-max, none); +} + +.session-tabs-host > .session-tabs.lineage-tree.tabs-corner { + padding-bottom: calc(var(--lineage-bottom-room) - var(--lineage-row-gap)); +} + .session-tabs::-webkit-scrollbar { width: 4px; height: 0; @@ -2992,6 +3038,33 @@ html[data-header-stats] .header-system-stats .stat-item[hidden] { transform: rotate(45deg); } +/* "+" (new session beside this tab, app.openTabNewSessionMenu): same reveal as the gear. */ +.session-tab .tab-new { + opacity: 0; + width: 0; + padding: 0; + font-size: 1rem; + line-height: 1; + color: var(--text-muted); + cursor: pointer; + overflow: hidden; + transition: opacity 0.15s, width 0.15s, padding 0.15s; +} +.session-tab.active .tab-new { + opacity: 1; + width: auto; + padding: 0 0.3rem; +} +.session-tab .tab-new:hover { + color: var(--accent-hover); +} + +/* Per-device toggle (App Settings → Tab Bar), mirrored as tabs-show-new on <html> + by settings-ui.js. !important so no layout's reveal rule (sidebar, touch) wins. */ +html:not(.tabs-show-new) .session-tab .tab-new { + display: none !important; +} + /* ===== Session detach / undock (beta/session-detach) ===================== */ /* Pop-out (detach) icon on each tab — mirrors .tab-gear reveal-on-hover. */ @@ -3145,7 +3218,7 @@ html[data-tab-orientation='vertical']:not(.tab-rail-compact) .tab-rail pointer-events: auto; } -html[data-tab-orientation='vertical'].tab-rail-compact .session-tab .tab-actions > :is(.tab-gear, .tab-detach, .tab-close) { +html[data-tab-orientation='vertical'].tab-rail-compact .session-tab .tab-actions > :is(.tab-gear, .tab-new, .tab-detach, .tab-close) { display: none; } @@ -7741,6 +7814,52 @@ html[data-skin="og"] .welcome-primary:hover { opacity: 0.8; } +/* The picker's search box reuses .session-sidebar-filter-input's theme-token + styling (same reasoning as the .set-scope pill above): only its own spacing + and the no-matches line are added here. 0.35rem is the standard gap the + modal's neighbouring form fields already use (`.form-hint`'s own margin-top): + the global `* { margin: 0 }` reset left the box sitting flush under the CLI + hint line above it, and the old 0.5rem read as too big a gap above the list + (compounded by the always-visible no-matches line the rule below fixes). */ +#customModelPickModal .custom-model-pick-search { + margin-top: 0.35rem; + margin-bottom: 0.35rem; +} + +#customModelPickModal .custom-model-pick-no-matches { + text-align: center; + padding: 1rem 0; +} + +/* "Nothing is marked: this endpoint did not report which model it has loaded": the same + `.form-hint` `display: block` beats `[hidden]` trap the two rules below fix for the + no-matches line and the filtered rows, so the attribute needs the same re-assertion. */ +#customModelPickModal .custom-model-pick-unknown { + padding-bottom: 0.15rem; +} + +#customModelPickModal .custom-model-pick-unknown[hidden] { + display: none !important; +} + +/* Same class of bug as the row rule below: the no-matches line carries + `.form-hint`, whose `display: block` outranks the UA `[hidden]` rule, so + `filterCustomModelPick()` toggling `hidden` changed nothing on screen and + "No models match" showed while every model was listed with an empty search + box. The `[hidden]` re-assertion makes the attribute hide the line again. */ +#customModelPickModal .custom-model-pick-no-matches[hidden] { + display: none !important; +} + +/* A filtered-out row's `hidden` must actually hide it: `.run-mode-option` sets + `display: flex` (and mobile.css restates the class in a media query), which + outranks the UA `[hidden] { display: none }` rule, so the filter would + silently do nothing without this re-assertion. Scoped to this modal's rows; + the Run menu's own .run-mode-option entries never take `hidden`. */ +#customModelPickModal .run-mode-option[hidden] { + display: none !important; +} + /* Custom Model Endpoint Profiles: llama-swap model-swap confirmation — replaces a native confirm() popup (docs/custom-model-endpoints-plan.md) so it looks and feels like the rest of the app instead of a browser chrome dialog. Shares the context-window-too-small @@ -12544,13 +12663,21 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat /* ---- File Viewer rendered markdown ---- Styling comes from the Response Viewer's .rv-text rules; .rv-text itself - carries no padding or base font (the chat card supplies those). */ + carries no padding or base font (the chat card supplies those). + + No width cap. The window is whatever the user drags or expands it to, so the + document must reflow to fill it; the fixed 960px reading column this rule + shipped before the resize grip existed stops reflow once the window is wider + than the cap. --rv-content-max: none lets ASCII-diagram blocks + (.rv-text .rv-diagram-wrap, capped by that variable with a 720px fallback + meant for the Response Viewer) follow the rest of the document too. Pinned by + test/file-preview-window.test.ts. */ .file-preview-body > .file-preview-md { padding: 1rem 1.25rem 2rem; font-size: 15px; line-height: 1.55; - max-width: 960px; + --rv-content-max: none; } /* Relative links in the document are rebased onto a.rv-path so the Response @@ -15694,6 +15821,7 @@ body.touch-device.cjk-input-visible .main { .path-picker-up, .path-picker-refresh, +.path-picker-newfolder, .path-picker-hidden { flex: 0 0 38px; height: 38px; @@ -15718,6 +15846,45 @@ body.touch-device.cjk-input-visible .main { border-color: var(--accent); } +.path-picker-newfolder { + flex: 0 0 auto; + padding: 0 10px; + white-space: nowrap; +} + +.path-picker-newfolder[hidden], +.path-picker-newfolder-form[hidden] { + display: none; +} + +.path-picker-newfolder-form { + display: flex; + gap: 8px; + padding: 0 12px 10px; +} + +.path-picker-newfolder-name { + flex: 1 1 auto; + min-width: 0; + height: 38px; + padding: 0 10px; + color: var(--text); + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 8px; +} + +.path-picker-newfolder-create, +.path-picker-newfolder-cancel { + height: 38px; + padding: 0 12px; + color: var(--text); + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; +} + .path-picker-up:disabled { opacity: 0.35; cursor: default; @@ -19869,6 +20036,7 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-row > .ta actions permanently visible on the active session — no layout jitter when the pointer crosses the list. */ html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-gear, +html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-new, html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-detach, html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-close { opacity: 1; @@ -20383,6 +20551,7 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .se html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-info, html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-number, html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-gear, +html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-new, html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-detach, html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-close { display: none; @@ -22152,6 +22321,42 @@ html .toolbar .btn-git-status[aria-expanded='true'] { gap: 0.25rem; } +/* A Fetch button cannot be clicked twice while its fetch is running, and is not offered for a session + there is nothing to fetch for (no repository, remote or Docker). Scoped to the Git panel's header: the + File Viewer's Back/Forward and the folder grid's Up button are .btn-icon-sm too, and keep their own look. */ +.git-status-actions .btn-icon-sm[disabled] { + color: var(--text-dim); + opacity: 0.45; + cursor: default; +} + +.git-status-actions .btn-icon-sm[disabled]:hover { + background: none; +} + +.git-status-actions .btn-icon-sm[aria-busy='true'] { + color: var(--text); + animation: btn-icon-busy 1.1s ease-in-out infinite; +} + +@keyframes btn-icon-busy { + 0%, + 100% { + opacity: 0.55; + } + 50% { + opacity: 0.9; + } +} + +/* No pulse under reduced motion: the button holds the pulse's brighter step, still dimmer than at rest. */ +@media (prefers-reduced-motion: reduce) { + .git-status-actions .btn-icon-sm[aria-busy='true'] { + animation: none; + opacity: 0.7; + } +} + .git-status-body { flex: 1; overflow-y: auto; @@ -22171,7 +22376,8 @@ html .toolbar .btn-git-status[aria-expanded='true'] { .git-status-empty, .git-status-ok, .git-status-note, -.git-status-more { +.git-status-more, +.git-status-fetch-note { color: var(--text-muted); padding: 0.25rem 0; } @@ -22180,6 +22386,24 @@ html .toolbar .btn-git-status[aria-expanded='true'] { color: rgb(34, 197, 94); } +/* The last Fetch's result: a summary line, then a row per repository it could not fetch. */ +.git-status-fetch-note { + margin-top: 0.6rem; + padding-top: 0.4rem; + border-top: 1px solid var(--border); +} + +.git-status-fetch-summary { + margin-bottom: 0.2rem; +} + +.git-status-fetch-row { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + padding: 0.1rem 0; +} + .git-status-branchline { display: flex; flex-wrap: wrap; @@ -22459,6 +22683,297 @@ html .toolbar .btn-git-status[aria-expanded='true'] { font-size: 0.68rem; } +/* ── Edit history: header button + panel (edit-history-ui.js) ───────────────── */ + +/* "Edit history" header button: opt-in (App Settings → Header & Panels), hidden by default. + Same marker pattern as the Attachments button: a base inline-flex !important so an inline + style can't override it, and a more-specific marker rule to hide. Phones hide it regardless + (mobile.css). */ +.btn-edit-history { + display: inline-flex !important; +} +.btn-edit-history.btn-edit-history--hidden { + display: none !important; +} + +.btn-edit-history[aria-expanded='true'] { + background: color-mix(in srgb, currentColor 16%, transparent); +} + +/* Same frame as the Git and Files windows: draggable by the header, resizable at the corner. */ +.edit-history-panel { + position: fixed; + top: calc(var(--header-height) + 10px); + /* Left of the Git window's default spot; on a narrow viewport slide right so the left edge stays on screen. */ + right: clamp(8px, calc(100vw - 428px), 410px); + width: 420px; + max-width: calc(100vw - 16px); + height: calc(100vh - var(--header-height) - var(--toolbar-height) - 40px); + height: calc(100dvh - var(--header-height) - var(--toolbar-height) - 40px); + max-height: 640px; + min-width: 260px; + min-height: 220px; + background: var(--floating-bg); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--control-border); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.2); + z-index: 100; + display: none; + flex-direction: column; + overflow: hidden; + resize: both; +} + +.edit-history-panel.visible { + display: flex; +} + +.edit-history-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.4rem 0.6rem; + background: var(--bg-input); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + cursor: move; + user-select: none; + touch-action: none; +} + +.edit-history-title { + font-size: 0.8rem; + font-weight: 600; + color: var(--text); +} + +.edit-history-count { + margin-left: 0.4rem; + font-family: 'SF Mono', Monaco, monospace; + font-size: 0.7rem; + font-weight: 400; + color: var(--text-muted); +} + +.edit-history-actions { + display: flex; + gap: 0.25rem; +} + +.edit-history-controls { + display: flex; + flex-wrap: wrap; + gap: 0.3rem 0.9rem; + padding: 0.3rem 0.6rem; + border-bottom: 1px solid var(--border); + font-size: 0.68rem; + color: var(--text-muted); + flex-shrink: 0; +} + +.edit-history-toggle { + display: inline-flex; + align-items: center; + gap: 0.3rem; + cursor: pointer; +} + +.edit-history-body { + flex: 1; + overflow-y: auto; + padding: 0.4rem 0.5rem; + font-size: 0.74rem; + color: var(--text); +} + +.edit-history-empty, +.edit-history-note, +.edit-history-more { + color: var(--text-muted); + padding: 0.25rem 0; +} + +.edit-history-note--warn { + color: #e0a030; +} + +.edit-history-entry { + display: flex; + align-items: baseline; + gap: 0.45rem; + padding: 0.15rem 0.25rem; + border-radius: 4px; + min-width: 0; + cursor: pointer; +} + +.edit-history-entry:hover, +.edit-history-entry:focus-visible, +.edit-history-entry--open { + background: var(--bg-hover); + outline: none; +} + +.edit-history-time { + flex: 0 0 auto; + font-family: 'SF Mono', Monaco, monospace; + font-size: 0.64rem; + color: var(--text-muted); +} + +.edit-history-badge { + flex: 0 0 auto; + min-width: 2rem; + text-align: center; + font-size: 0.6rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #e0a030; +} + +.edit-history-badge--new { + color: rgb(34, 197, 94); +} + +.edit-history-badge--image { + color: var(--text-muted); + align-self: center; +} + +.edit-history-badge--image svg { + display: block; + margin: 0 auto; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* Only the name itself opens the preview: the rest of the row (the spacer) toggles the diff. */ +/* The folder part gives way first, so the file name stays readable (…/icons/bremse_alt1.png). */ +.edit-history-path { + display: inline-flex; + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + white-space: nowrap; + padding: 0; + border: 0; + background: none; + color: var(--text); + text-align: left; + font-family: 'SF Mono', Monaco, monospace; + font-size: 0.7rem; + cursor: pointer; +} + +.edit-history-path-dir { + /* Shrinks long before the name does; the name only gives way once the folder is at min-width. */ + flex: 0 10000 auto; + min-width: 1.2em; + overflow: hidden; + text-overflow: ellipsis; + /* Ellipsis at the START of the folder (rtl); the inner <bdi dir="ltr"> keeps its text in order. */ + direction: rtl; + text-align: left; + opacity: 0.7; +} + +.edit-history-path-name { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.edit-history-path:hover, +.edit-history-path:focus-visible { + text-decoration: underline; +} + +.edit-history-spacer { + flex: 1; + align-self: stretch; + min-width: 0.5rem; + min-height: 1em; +} + +.edit-history-stats { + flex: 0 0 auto; + display: inline-flex; + gap: 0.3rem; + font-family: 'SF Mono', Monaco, monospace; + font-size: 0.64rem; +} + +.edit-history-add { + color: rgb(34, 197, 94); +} + +.edit-history-del { + color: #e5534b; +} + +.edit-history-agent { + flex: 0 1 auto; + max-width: 40%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 0 0.4rem; + border: 1px solid var(--border); + border-radius: 999px; + font-size: 0.62rem; + color: var(--text-muted); +} + +.edit-history-thumb { + display: block; + max-width: 160px; + max-height: 90px; + margin: 0.15rem 0 0.35rem 3.2rem; + border: 1px solid var(--border); + border-radius: 4px; + object-fit: contain; + cursor: zoom-in; +} + +.edit-history-diff { + margin: 0.2rem 0 0.5rem 0.25rem; +} + +.edit-history-group { + margin-bottom: 0.45rem; + padding-bottom: 0.2rem; + border-bottom: 1px solid var(--border); +} + +.edit-history-group-head { + display: flex; + align-items: baseline; + gap: 0.45rem; + padding: 0.2rem 0.25rem; + min-width: 0; +} + +.edit-history-group-head .edit-history-path { + font-weight: 600; +} + +.edit-history-group-count { + flex: 0 0 auto; + font-size: 0.64rem; + color: var(--text-muted); +} + +.edit-history-older { + margin: 0.5rem auto 0.25rem; +} + /* ============================================================ Tab grouping by state (tabArrangement: 'state', opt-in; Discussion #426 option C) @@ -23199,3 +23714,12 @@ html[data-header-stats='tiles'] .header-plan-usage:not(:has(.pu-row)) { border: 1px solid var(--control-border); color: var(--text-muted); } + +/* Rows held under the on-screen keyboard (keyboardHoldsRows; keyboardClipPx in + constants.js): the screen stays taller than its box and is scrolled up by + --kb-clip, set on #terminalContainer by _applyKeyboardClip (terminal-ui.js). + The helper textarea, IME preview, link layer and both echo overlays live + inside .xterm-screen, so they move with it, and every hit test reads rects. */ +body.keyboard-visible .terminal-container .xterm-screen { + transform: translateY(calc(-1 * var(--kb-clip, 0px))); +} diff --git a/src/web/public/tab-rail-resize.js b/src/web/public/tab-rail-resize.js index 0c271c86..4dc434ae 100644 --- a/src/web/public/tab-rail-resize.js +++ b/src/web/public/tab-rail-resize.js @@ -319,6 +319,7 @@ Object.assign(CodemanApp.prototype, { event.preventDefault(); event.stopPropagation(); this.closeTabRailActionMenu(); + this.closeTabGroupMenu?.(); const trigger = event.currentTarget; const menu = document.createElement('div'); menu.className = 'tab-rail-action-menu'; @@ -327,6 +328,12 @@ Object.assign(CodemanApp.prototype, { const settings = this.loadAppSettingsFromStorage(); const actions = [ { label: 'Session options', run: () => this.openSessionOptions(sessionId) }, + // The row's "+" (showTabNewButton on) as a menu item: a keyboard route beside + // Enter or Space on the "+" itself, and its only route in the compact rail, + // which hides it. Session rows only: tiles share this menu and carry no "+". + ...(document.documentElement.classList.contains('tabs-show-new') && trigger.closest('.session-tab') + ? [{ label: 'New session here…', run: () => this.openTabNewSessionMenu({ currentTarget: trigger }, sessionId) }] + : []), // Focus and group placement (vertical rail with a tab layout only; [] elsewhere). ...(this._tabRefFocusActions?.({ kind: 'session', id: sessionId }) || []), ...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []), diff --git a/src/web/public/terminal-keycode229-recovery.js b/src/web/public/terminal-keycode229-recovery.js index 22e44cba..d3fd3dfc 100644 --- a/src/web/public/terminal-keycode229-recovery.js +++ b/src/web/public/terminal-keycode229-recovery.js @@ -7,6 +7,17 @@ * that guard, so xterm returns without emitting and the committed character is * silently dropped. * + * ⚠ Desktop fine pointers must not run this (#716). xterm already forwards the + * composed character, and a second forward doubles a dead-key backtick. + * `create()` returns null unless `matchMedia('(pointer: coarse)')` matches or + * the browser is Android, so soft keyboards keep the recovery and a desktop + * does not attach it. Android is checked by user agent because Chromium reports + * its PRIMARY pointer as fine once a mouse, a trackpad cover or a stylus is + * attached, while the text still comes from the soft keyboard (#441, #541 were + * measured there); Android always ran this before #716, so the check keeps it. + * A missing or throwing `matchMedia` stands down elsewhere: a missed recovery is + * acceptable, a duplicated keystroke is not. + * * ⚠ The gap is NARROWER than "keyCode 229", and assuming otherwise produces a * controller that looks useful while doing nothing. For a keydown that really * does report `keyCode: 229`, xterm ALREADY self-rescues: `CompositionHelper @@ -64,7 +75,22 @@ return { deleted: a.length - prefix, inserted: b.slice(prefix).join('') }; } + /** + * Touch / coarse pointer, or any Android browser. See the file header: on a + * desktop fine pointer this forward runs on top of xterm's own composition + * handling. + */ + function isSoftKeyboardPlatform() { + try { + if (/Android/i.test(global.navigator?.userAgent || '')) return true; + return global.matchMedia?.('(pointer: coarse)')?.matches === true; + } catch { + return false; + } + } + function create(options) { + if (!isSoftKeyboardPlatform()) return null; const textarea = options?.textarea; const emitRecovered = options?.emitRecovered; if (!textarea?.addEventListener || !textarea?.removeEventListener || typeof emitRecovered !== 'function') { diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 867ede2c..a069eeee 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -244,6 +244,8 @@ scrollback: this.scrollback ?? DEFAULT_SCROLLBACK, allowTransparency: true, allowProposedApi: true, + // OSC 8 hyperlinks open through the primary pane's opener (terminal-ui.js). + linkHandler: global.app?._terminalOsc8LinkHandler?.(), }); this.fitAddon = new FitAddon.FitAddon(); @@ -342,9 +344,10 @@ if (global.app?.tileShortcutFor?.(ev)) return false; // Ctrl+V / Cmd+V: the primary pane's paste trap, aimed at THIS pane, so // a pasted image uploads to this pane's session and its path is typed - // here, and pasted text goes into this xterm with its bracketed-paste - // markers intact. Mirrors terminal-ui.js's own Ctrl+V gate; without it - // xterm's default only ever pasted text. + // here. Plain text goes to this pane's session as a Codeman-built + // bracketed-paste frame; this terminal is only refocused and receives + // input(framed), which onData delivers. Mirrors terminal-ui.js's own + // Ctrl+V gate; without it xterm's default only ever pasted text. if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v' && ev.type === 'keydown') { global.app?._handleImagePaste?.({ terminal: this.terminal, sessionId: this.sessionId }); return false; @@ -644,9 +647,10 @@ // Recovered text goes straight to _onTerminalData(), never through xterm's // onData, so it is not counted as xterm's own (see connect()'s onData). // Created after terminal.open(): xterm's capture `input` listener on the - // textarea is registered there, and must run before the controller's. No - // device or mode gate, as in the primary pane: with a hardware keyboard it - // costs one assignment per keydown. A failure leaves xterm's own handling. + // textarea is registered there, and must run before the controller's. + // create() returns null on a fine pointer (#716): the recovery is for soft + // keyboards, and on a desktop it delivers a dead-key character twice. + // A failure leaves xterm's own handling. _createKeyCode229Recovery() { this._destroyKeyCode229Recovery(); if (!this.terminal) return; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index dd006652..53985d79 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -767,6 +767,7 @@ Object.assign(CodemanApp.prototype, { scrollback: scrollback, allowTransparency: true, allowProposedApi: true, + linkHandler: this._terminalOsc8LinkHandler(), }); this.fitAddon = new FitAddon.FitAddon(); @@ -1060,6 +1061,9 @@ Object.assign(CodemanApp.prototype, { if (MobileDetection.isTouchDevice()) { this.terminal.onCursorMove(() => this._syncMobileHelperTextareaToCursor()); this.terminal.onRender(() => this._syncMobileHelperTextareaToCursor()); + // Keep the live content above the keyboard while the rows are held. + this.terminal.onCursorMove(() => this._applyKeyboardClip()); + this.terminal.onRender(() => this._applyKeyboardClip()); } // Renderer liveness — see _startRenderLivenessWatchdog. Registered for every @@ -2104,6 +2108,7 @@ Object.assign(CodemanApp.prototype, { // Twin: TerminalTile (terminal-tile.js _createKeyCode229Recovery and its // connect() key handler and onData) wires its own controller the same way // for every grid tile and the split's Pane B; keep the two in step. + // create() returns null unless the primary pointer is coarse (#716). try { this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({ textarea: this.terminal.textarea, @@ -2141,13 +2146,16 @@ Object.assign(CodemanApp.prototype, { /** * A clicked path as the preview needs it: relative paths resolved against the - * session's working directory, absolute and `~/` paths unchanged + * directory the session's pane is in now, else its working directory + * (`relativeResolutionBase`), absolute and `~/` paths unchanged * (`resolveLinkedFilePath`, constants.js). Shared by the terminal link * provider and the response viewer, so one path opens the same file in both. */ _resolveLinkedFilePath(filePath, sessionId) { if (typeof resolveLinkedFilePath !== 'function') return filePath; - return resolveLinkedFilePath(filePath, this.sessions?.get?.(sessionId)?.workingDir); + const session = this.sessions?.get?.(sessionId); + const base = typeof relativeResolutionBase === 'function' ? relativeResolutionBase(session) : session?.workingDir; + return resolveLinkedFilePath(filePath, base); }, /** @@ -2208,8 +2216,11 @@ Object.assign(CodemanApp.prototype, { // links as the one file it is. // Bounded so a screenful of full-width output (wide tables, box drawing) // cannot make every hover stitch and re-scan the entire viewport. + // `terminalLinkLine` widens that once more to the rows of a URL the CLI + // wrapped short of the edge (a table cell or an indented paragraph), + // where nothing marks the break and no row is full width. const MAX_STITCHED_ROWS = 12; - const logical = window.CodemanTerminalLines?.terminalPathLine( + const logical = window.CodemanTerminalLines?.terminalLinkLine( buffer, bufferLineNumber - 1, terminal.cols, @@ -2227,8 +2238,16 @@ Object.assign(CodemanApp.prototype, { return { x: cell.col + 1, y: cell.row + 1 }; }; + // Paths a CLI word-wrapped inside a table cell (terminalCellWrappedPaths, + // constants.js). They follow their own cell down the rows, which the + // linear stitch above cannot do for a multi-column table, so they are + // found from the buffer directly and claim their cells over the rest. + const cellWrappedPaths = ( + window.CodemanTerminalLines?.terminalCellWrappedPaths?.(buffer, bufferLineNumber - 1, 4) ?? [] + ).filter((hit) => !/(^|\/)\.\.(\/|$)/.test(hit.path)); + // A path needs a `/` or an extension's `.`; a line with neither has no link. - if (!lineText || (!lineText.includes('/') && !lineText.includes('.'))) { + if (!lineText || (!lineText.includes('/') && !lineText.includes('.') && cellWrappedPaths.length === 0)) { callback(undefined); return; } @@ -2240,44 +2259,46 @@ Object.assign(CodemanApp.prototype, { const taken = []; const isTaken = (start, length) => taken.some((t) => start < t.end && t.start < start + length); - // Pattern 0: URLs (https://, http://) — matched first so they take priority - // - // A single `&` is PART of the URL: it separates query parameters, so excluding - // it truncated every real query string (`?post=1479&action=edit` linked only - // through `1479`, landing on the wrong page). `&&` is still a boundary, since - // that is the shell operator and never appears inside a URL. A lone trailing - // `&` is trimmed below with the other trailing punctuation. - const urlPattern = /https?:\/\/(?:[^\s"'<>|;&)\]\x00-\x1f]|&(?!&))+/g; - - const addUrlLink = (url, matchIndex) => { - // Strip trailing punctuation that's likely not part of the URL - const cleaned = url.replace(/[.,;:!?)&]+$/, ''); - const startCol = lineText.indexOf(cleaned, matchIndex); - if (startCol === -1) return; - - const start = coordAt(startCol); - const end = coordAt(startCol + cleaned.length); + // Pattern 0: URLs (https://, http://) — matched first so they take priority. + // The grammar (balanced parens, `&` vs `&&`, trailing punctuation) is + // findTerminalUrls() in constants.js, which the wrap-continuation extent + // above reads URLs with too, so the two cannot disagree. + const addUrlLink = ({ url, startIndex }) => { + const start = coordAt(startIndex); if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return; - taken.push({ start: startCol, end: startCol + cleaned.length }); + taken.push({ start: startIndex, end: startIndex + url.length }); - links.push({ - text: cleaned, - range: { start, end }, - decorations: { pointerCursor: true, underline: true }, - activate(_event, text) { - // A `localhost` link tapped from another device can only work - // through the server: route it into a proxied web tab - // (webview-tabs.js). Anything else opens as before. - if (self.openLinkThroughWebTabIfLoopback?.(text)) return; - window.open(text, '_blank', 'noopener,noreferrer'); - }, - hover() { - setHovered(true); - }, - leave() { - setHovered(false); - }, - }); + // One link per terminal row the URL occupies, each opening the WHOLE + // URL. A single range spanning rows would also underline (and make + // clickable) the indent and table columns between the pieces of a + // URL the CLI wrapped. A single-row URL keeps its original range. + const endIndex = startIndex + url.length; + const pieces = []; + let pieceStart = startIndex; + for (let i = startIndex + 1; i < endIndex; i++) { + if (coordAt(i).y !== coordAt(i - 1).y) { + pieces.push([pieceStart, i]); + pieceStart = i; + } + } + pieces.push([pieceStart, endIndex]); + for (let n = 0; n < pieces.length; n++) { + const [from, to] = pieces[n]; + links.push({ + text: url, + range: { start: coordAt(from), end: pieces.length === 1 ? coordAt(to) : coordAt(to - 1) }, + decorations: { pointerCursor: true, underline: true }, + activate() { + self._openTerminalUrl(url); + }, + hover() { + setHovered(true); + }, + leave() { + setHovered(false); + }, + }); + } }; // Pattern 1: Commands with file paths (tail -f, cat, head, grep pattern, etc.) @@ -2300,6 +2321,59 @@ Object.assign(CodemanApp.prototype, { // Pattern 3: Bash() tool output const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g; + const activateFilePath = (text, folder = false, verifiedSessionId = null) => { + // Tailing a PNG in the log viewer shows binary noise; the file preview + // already renders images, PDFs, documents and media inline — and it + // now reaches files outside the workspace too, which is where an + // agent's screenshots and scratchpad captures actually land. + // + // Text goes to the log viewer, which follows a file that is still + // being written — but ONLY where it can actually read: it spawns + // `tail -f` and allows the workspace, /var/log and ~/logs, so an + // out-of-workspace path there answered "Path must be within + // working directory or allowed log directories" while the SAME + // path clicked in the response viewer previewed fine. The preview + // reads those through the guarded attachment routes, so external + // paths route there and the two surfaces agree. + // + // A relative path is resolved against the session's working + // directory first, and a `~/` path always previews (only the + // server knows that host's home, and the log viewer cannot ask). + const sessionId = verifiedSessionId || getSessionId(); + if (!sessionId) return; + const target = self._resolveLinkedFilePath(text, sessionId); + const alternative = !folder && logical.pathAlternatives?.find((candidate) => candidate.path === text); + if (alternative && !verifiedSessionId) { + return (async () => { + const original = await self._registerExternalPreview(target, sessionId); + // Attachment policy must not gate the workspace's normal opener. + if (original.status !== 404) return activateFilePath(target, false, sessionId); + const path = self._resolveLinkedFilePath(alternative.spacedPath, sessionId); + const registered = await self._registerExternalPreview(path, sessionId); + if (registered.status === 404) return activateFilePath(target, false, sessionId); + if (!registered.attachmentId) { + self.showToast(registered.error || 'Cannot open this file', 'error'); + return; + } + // The normal opener rechecks its guards; attachment registration deduplicates the path. + return activateFilePath(path, false, sessionId); + })(); + } + // A folder path opens the folder grid (which falls back to the + // preview if the server finds a file there after all). + if (folder) { + self.openFolderGrid(target, sessionId); + return; + } + // An HTML file renders as a page in the preview, never as source + // in the log viewer (markdown is in FILE_PREVIEW_EXTENSIONS). + if (previewsInFileViewer(target) || rendersAsPage(target) || self._isExternalPreviewPath(target, sessionId)) { + self.openFilePreview(target, sessionId); + return; + } + self.openLogViewerWindow(target, sessionId); + }; + const addLink = (filePath, matchIndex, folder = false) => { const startCol = lineText.indexOf(filePath, matchIndex); if (startCol === -1) return; @@ -2318,43 +2392,8 @@ Object.assign(CodemanApp.prototype, { pointerCursor: true, underline: true, }, - activate(event, text) { - // Tailing a PNG in the log viewer shows binary noise; the file preview - // already renders images, PDFs, documents and media inline — and it - // now reaches files outside the workspace too, which is where an - // agent's screenshots and scratchpad captures actually land. - // - // Text goes to the log viewer, which follows a file that is still - // being written — but ONLY where it can actually read: it spawns - // `tail -f` and allows the workspace, /var/log and ~/logs, so an - // out-of-workspace path there answered "Path must be within - // working directory or allowed log directories" while the SAME - // path clicked in the response viewer previewed fine. The preview - // reads those through the guarded attachment routes, so external - // paths route there and the two surfaces agree. - // - // A relative path is resolved against the session's working - // directory first, and a `~/` path always previews (only the - // server knows that host's home, and the log viewer cannot ask). - const sessionId = getSessionId(); - const target = self._resolveLinkedFilePath(text, sessionId); - // A folder path opens the folder grid (which falls back to the - // preview if the server finds a file there after all). - if (folder) { - self.openFolderGrid(target, sessionId); - return; - } - // An HTML file renders as a page in the preview, never as source - // in the log viewer (markdown is in FILE_PREVIEW_EXTENSIONS). - if ( - previewsInFileViewer(target) || - rendersAsPage(target) || - self._isExternalPreviewPath(target, sessionId) - ) { - self.openFilePreview(target, sessionId); - return; - } - self.openLogViewerWindow(target, sessionId); + activate(_event, text) { + return activateFilePath(text, folder); }, hover() { setHovered(true); @@ -2368,9 +2407,8 @@ Object.assign(CodemanApp.prototype, { // Match all patterns — URLs first so they take priority let match; - urlPattern.lastIndex = 0; - while ((match = urlPattern.exec(lineText)) !== null) { - addUrlLink(match[0], match.index); + for (const reference of window.CodemanTerminalLines?.findTerminalUrls?.(lineText) ?? []) { + addUrlLink(reference); } cmdPattern.lastIndex = 0; @@ -2387,6 +2425,54 @@ Object.assign(CodemanApp.prototype, { addLink(match[1], match.index); } + // One link per row segment, each opening the WHOLE path (as for a wrapped + // URL). A fragment of the same cells linked by another detector (a + // relative `243/after-overview.png`, say) would open the wrong file, so + // the cell-wrapped path displaces it. + // A path the line stitch already joined whole (a single-column wrap, which + // terminalPathLine handles) keeps its one link. + const cellOnlyPaths = cellWrappedPaths.filter( + (hit) => + !links.some( + (l) => + l.text === hit.path && + l.range.start.y === hit.segments[0].row + 1 && + l.range.start.x === hit.segments[0].startCol + 1 + ) + ); + if (cellOnlyPaths.length > 0) { + const claimed = cellOnlyPaths.flatMap((hit) => hit.segments); + const overlapsClaim = (link) => + claimed.some((seg) => { + const y = seg.row + 1; + if (y < link.range.start.y || y > link.range.end.y) return false; + const from = y === link.range.start.y ? link.range.start.x : 1; + const to = y === link.range.end.y ? link.range.end.x : Number.MAX_SAFE_INTEGER; + return from <= seg.endCol && to >= seg.startCol + 1; + }); + for (let i = links.length - 1; i >= 0; i--) { + if (overlapsClaim(links[i])) links.splice(i, 1); + } + for (const hit of cellOnlyPaths) { + for (const seg of hit.segments) { + links.push({ + text: hit.path, + range: { start: { x: seg.startCol + 1, y: seg.row + 1 }, end: { x: seg.endCol, y: seg.row + 1 } }, + decorations: { pointerCursor: true, underline: true }, + activate() { + activateFilePath(hit.path); + }, + hover() { + setHovered(true); + }, + leave() { + setHovered(false); + }, + }); + } + } + } + if (links.length > 0) { console.debug( '[LinkProvider] Found links:', @@ -2409,6 +2495,30 @@ Object.assign(CodemanApp.prototype, { return provider; }, + /** + * xterm's handler for OSC 8 hyperlinks (a program's own `ESC ]8;;url` links), + * which take precedence over the text link provider. Routed through the same + * opener after confirming the real target (the label may hide it), and only + * for web URLs: any other scheme a program sends is ignored. + */ + _terminalOsc8LinkHandler() { + return { + activate: (_event, text) => { + if (!/^https?:\/\/[^\s\x00-\x1f]+$/i.test(text)) return; + if (window.confirm(`Open this link?\n\n${text}`)) this._openTerminalUrl(text); + }, + }; + }, + + /** Open a URL clicked in a terminal (text link or OSC 8). */ + _openTerminalUrl(text) { + // A `localhost` link tapped from another device can only work through the + // server: route it into a proxied web tab (webview-tabs.js). Anything else + // opens in a new tab. + if (this.openLinkThroughWebTabIfLoopback?.(text)) return; + window.open(text, '_blank', 'noopener,noreferrer'); + }, + /** * The terminal link under a viewport point, or null. * @@ -6556,12 +6666,124 @@ Object.assign(CodemanApp.prototype, { // timer and the resize observer, where an exception takes the rest of the // callback (the padding fit, the scroll restore, the SIGWINCH) with it. try { - return window.CodemanTerminalGeometry.clampTerminalDimensions(this.fitAddon?.proposeDimensions()); + const dims = window.CodemanTerminalGeometry.clampTerminalDimensions(this.fitAddon?.proposeDimensions()); + // Rows held under the on-screen keyboard (see keyboardClipPx): the + // container shrank, the PTY must not. Columns still follow the container. + const held = this._keyboardHeldRowsActive?.(); + if (dims && held) dims.rows = Math.max(dims.rows, held); + return dims; } catch { return null; } }, + /** + * Rows held while the on-screen keyboard is up, or 0. Set by KeyboardHandler + * on the show transition when `keyboardHoldsRows` is on (default), cleared on + * hide and on a shape change. Every geometry read goes through + * getTerminalDimensions(), so holding it there holds the fit, the PTY resize + * and every capture-geometry comparison alike. + * @returns {number} + */ + _keyboardHeldRowsActive() { + if (!(this._keyboardHeldRows > 0)) return 0; + return typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible ? this._keyboardHeldRows : 0; + }, + + /** + * May the keyboard hold this session's rows? Only an INLINE renderer (the + * `keyboardHold` capability, read from `window.__codemanKeyboardHoldModes`, + * never an id literal here): a CLI that lays out the full height (codex) cannot + * show both its content and its composer above the keyboard and must be + * resized, as before. Never while the CLI reports mouse tracking (Claude's + * fullscreen mode), and never for a remote or docker session, whose pane hosts + * a nested client. Absent data means no hold. Per-device setting + * `keyboardHoldsRows`, default ON. + */ + _keyboardHoldEligible(sessionId = this.activeSessionId) { + if (this.loadAppSettingsFromStorage?.()?.keyboardHoldsRows === false) return false; + const session = sessionId ? this.sessions?.get(sessionId) : null; + if (!session || session.remote || session.docker || session.cliMouseTracking) return false; + const modes = window.__codemanKeyboardHoldModes; + return Array.isArray(modes) && modes.includes(session.mode || 'claude'); + }, + + /** + * Drop a hold the active session no longer qualifies for (switched to a CLI + * that resizes, entered fullscreen, setting turned off) and, unless the caller + * resizes itself (selectSession), fit and tell the PTY at once, which is the + * old keyboard behavior. Never re-holds: a session that becomes eligible with + * the keyboard already up keeps the short rows until the keyboard closes. + */ + _reconcileKeyboardHold({ resize = true } = {}) { + if (!this._keyboardHeldRows || this._keyboardHoldEligible()) return; + this._releaseKeyboardHold(); + if (resize && this.activeSessionId) this.sendResize(this.activeSessionId)?.catch?.(() => {}); + }, + + /** + * End a keyboard row hold and put the screen back in place (no offset, no + * overlay bound). Shared by KeyboardHandler (keyboard hide, shape change) + * and _reconcileKeyboardHold. + * @returns {boolean} whether a hold was released + */ + _releaseKeyboardHold() { + if (!this._keyboardHeldRows) return false; + this._keyboardHeldRows = 0; + this._applyKeyboardClip(); + return true; + }, + + /** + * Scroll the (held, taller than its box) terminal screen so the live content + * sits above the keyboard, and bound the local-echo overlay to the rows that + * are visible. Runs on every render and cursor move while the hold is active; + * resets once when it ends. + */ + _applyKeyboardClip() { + const container = this.terminal?.element?.parentElement; + if (!container) return; + const rows = this._keyboardHeldRowsActive() ? this.terminal.rows : 0; + const cellHeight = rows ? this.terminal._core?._renderService?.dimensions?.css?.cell?.height : 0; + const viewHeight = rows ? container.clientHeight : 0; + let clip = 0; + if (cellHeight && viewHeight) { + const buffer = this.terminal.buffer.active; + const following = buffer.viewportY >= buffer.baseY; + let lastContentRow = -1; + if (following) { + for (let row = rows - 1; row >= 0; row--) { + if (buffer.getLine(buffer.baseY + row)?.translateToString(true).trim()) { + lastContentRow = row; + break; + } + } + } + clip = window.CodemanTerminalGeometry.keyboardClipPx({ + rows, + cellHeight, + viewHeight, + cursorRow: buffer.cursorY, + lastContentRow, + following, + viewportY: buffer.viewportY, + baseY: buffer.baseY, + }); + } + if (clip !== (this._keyboardClipPx || 0)) { + this._keyboardClipPx = clip; + if (clip) container.style.setProperty('--kb-clip', `${clip}px`); + else container.style.removeProperty('--kb-clip'); + } + // Bounded whenever rows are held, even at offset 0 (a prompt near the top + // still has held rows below it, under the keyboard). setRowLimit dedupes. + this._localEchoOverlay?.setRowLimit?.( + cellHeight && viewHeight + ? window.CodemanTerminalGeometry.keyboardVisibleRowLimit(clip, viewHeight, cellHeight) + : null + ); + }, + /** * Fit xterm to its container and return the geometry that was APPLIED. * @@ -6589,7 +6811,10 @@ Object.assign(CodemanApp.prototype, { syncTerminalGeometry() { if (!this.fitAddon || !this.terminal) return null; try { - this.fitAddon.fit(); + // fit() would shrink xterm to the keyboard-shortened box before the held + // rows put it back: skip it while they are held (_resizeTerminalTo below + // still applies the container's columns). + if (!this._keyboardHeldRowsActive?.()) this.fitAddon.fit(); } catch { /* a disposed or unattached terminal cannot be fitted; fall through to the read */ } @@ -6614,8 +6839,9 @@ Object.assign(CodemanApp.prototype, { * part of the scrollback in between. The mobile retry asks every 30 seconds, * so that happened on a timer for as long as the refusal lasted. So the * columns stay at the PTY's (the #464 invariant: the browser never draws at - * a width the PTY does not have), the rows follow the container (they are - * never adopted, see reconcilePtyGeometry), and the container's columns go + * a width the PTY does not have), the rows follow the container or the + * keyboard hold (they are never adopted, see reconcilePtyGeometry and + * getTerminalDimensions), and the container's columns go * out as the request. An accepted request is adopted by the report. * * @returns {{cols: number, rows: number}|null} the geometry to request diff --git a/src/web/public/whats-new.json b/src/web/public/whats-new.json index a38515af..fff23513 100644 --- a/src/web/public/whats-new.json +++ b/src/web/public/whats-new.json @@ -1,13 +1,13 @@ { - "version": "1.45.0", + "version": "1.48.0", "highlights": [ - "Rename a case and its folder (#680)", - "A per-session agent inbox (#690)", - "Pick the agent from Cmd+K (#685)", - "A resizable header toolbar (#689)", - "GPU and CPU clock readings (#692, opt-in)", - "A tunnel no longer locks everyone out after ten typos (#684, multi-user mode)", - "The first wheel notch moves a Codex transcript (#699)", - "The wheel no longer moves a Codex menu's selection (#698)" + "Talk to your agents from Discord (#741)", + "Edit history panel (#693)", + "Custom endpoints go further (#752, #755, #757)", + "Clone into a custom folder, and make a folder from the picker (#744, #745)", + "Relative links follow your `cd` (#740)", + "File links keep the whole name (#735)", + "A failed settings save says so (#736)", + "Phone keys show a press (#737)" ] } diff --git a/src/web/request-body-parser.ts b/src/web/request-body-parser.ts new file mode 100644 index 00000000..10ae9f0e --- /dev/null +++ b/src/web/request-body-parser.ts @@ -0,0 +1,59 @@ +/** + * @fileoverview Accept empty, untyped API POSTs even when a reverse proxy frames them as chunked. + * + * Cloudflare Tunnel (cloudflared, Go's HTTP client) forwards a bodyless POST as an HTTP/1.1 + * chunked request with no Content-Type and a zero-length body. Fastify only skips its + * content-type check for a `Content-Length: 0` request, so this framing was refused 415 + * (`FST_ERR_CTP_INVALID_MEDIA_TYPE`) before the route ran, breaking actions such as opening a + * saved web tab. This parser lets such a request reach its route with `request.body === undefined`, + * exactly as a `Content-Length: 0` request does. + * + * Scope is POST under `/api/` only: Go frames an empty body of unknown length as chunked for + * POST, PUT and PATCH and drops it for DELETE, and every PUT/PATCH the frontend sends carries a + * JSON body. Anything else (another method, a non-API route, a Content-Type, a non-zero + * Content-Length, or a single body byte) is still the 415 Fastify would have answered. + * + * ⚠️ It owns the ROOT `'*'` content-type slot. The JSON, `text/plain` (kept RAW, see the CSRF + * note in CLAUDE.md) and multipart parsers match first; the web-tab proxy registers its own + * `'*'` in an encapsulated scope and is unaffected. Fastify refuses a second root `'*'` parser at + * boot (`FST_ERR_CTP_ALREADY_PRESENT`), so extend this function instead of adding one. + */ +import { errorCodes, type FastifyInstance, type FastifyRequest } from 'fastify'; +import type { Readable } from 'node:stream'; + +export function installEmptyApiPostParser(app: FastifyInstance): void { + // Existing JSON, text and multipart parsers take precedence. The webview + // proxy installs its own parser in an encapsulated scope. + app.addContentTypeParser('*', async (req: FastifyRequest, payload: Readable) => { + // Fastify normally skips an unsupported parser on its 404 path. Preserve + // that behavior, including the unread stream used by referer fallbacks. + if (req.is404) return undefined; + + if ( + req.method !== 'POST' || + !req.routeOptions.url?.startsWith('/api/') || + req.headers['content-type'] !== undefined || + (req.headers['content-length'] !== undefined && req.headers['content-length'] !== '0') + ) { + throw new errorCodes.FST_ERR_CTP_INVALID_MEDIA_TYPE(); + } + + // Wait for the terminating chunk to prove the body is empty. Reject on the + // first byte instead of buffering an unsupported body. Leave the socket + // alive so Fastify can send its 415 and close the connection on parser errors. + try { + for await (const chunk of payload.iterator({ destroyOnReturn: false })) { + if (Buffer.byteLength(chunk) > 0) throw new errorCodes.FST_ERR_CTP_INVALID_MEDIA_TYPE(); + } + } catch (error) { + // Match Fastify's body reader: an interrupted request is a client error. + const err = error as Error & { statusCode?: number }; + if (typeof err.statusCode !== 'number' || err.statusCode < 400) err.statusCode = 400; + throw err; + } + + // No synthetic JSON or header rewriting: required-body validation still + // sees the same undefined value as a direct Content-Length: 0 request. + return undefined; + }); +} diff --git a/src/web/routes/agent-session-routes.ts b/src/web/routes/agent-session-routes.ts new file mode 100644 index 00000000..01101893 --- /dev/null +++ b/src/web/routes/agent-session-routes.ts @@ -0,0 +1,193 @@ +/** + * @fileoverview Agent-facing bounded inspection and guarded stage/submit endpoints. + * Ownership is checked before any disk or pane read. Native evidence comes only + * from strict conversation attribution; all other modes get labeled snapshots. + * + * Experimental and opt-in: all three routes answer `403 FORBIDDEN`, naming the + * setting, until the synced `agentCoordinationEnabled` is on (default OFF), the + * same shape as `cliManagementEnabled` and `mcpSyncEnabled`. The gate runs first, + * before session lookup, so a disabled install reveals nothing about its sessions. + */ +import type { FastifyInstance } from 'fastify'; +import type { Session } from '../../session.js'; +import type { SessionPort, InfraPort } from '../ports/index.js'; +import { getCli } from '../../config/cli-registry/registry.js'; +import { findExactCodexConversation, resolveEffectiveCodexHome } from '../../agent-codex-identity.js'; +import { InspectionReader, InspectionConflict, boundedUtf8Tail } from '../../agent-inspection.js'; +import { + GuardedStageStore, + GuardedInputConflict, + guardedComposerChrome, + MAY_BE_TYPED, + type GuardedInputTarget, +} from '../../agent-staging.js'; +import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js'; +import { findSessionOrFail, getAuthUser, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js'; +import { AgentInspectQuerySchema, AgentStageSchema, AgentSubmitSchema } from '../schemas.js'; + +/** Default OFF, same shape as `readMcpSyncEnabled`: read fresh so a toggle applies at once. */ +export async function readAgentCoordinationEnabled(): Promise<boolean> { + const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {}); + return settings.agentCoordinationEnabled === true; +} + +/** The refusal every route sends while the setting is off. Exported for the tests. */ +export const AGENT_COORDINATION_DISABLED_MESSAGE = + 'Experimental agent coordination (inspect, stage, submit) is disabled. Turn on "Experimental agent coordination" in Settings (agentCoordinationEnabled) and save first.'; + +async function gate(): Promise<ApiResponse<never> | null> { + if (await readAgentCoordinationEnabled()) return null; + return createErrorResponse(ApiErrorCode.FORBIDDEN, AGENT_COORDINATION_DISABLED_MESSAGE); +} + +/** A Node error code (`EACCES`, `EIO`, ...) or the error's class name; never its message. */ +export function errorCode(error: unknown): string { + const code = (error as { code?: unknown } | null)?.code; + if (typeof code === 'string' && /^[A-Z0-9_]{1,40}$/.test(code)) return code; + return error instanceof Error ? error.constructor.name : 'unknown'; +} + +export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPort & InfraPort): void { + const reader = new InspectionReader(); + const stages = new GuardedStageStore(); + app.addHook('onClose', async () => { + reader.clear(); + stages.clear(); + }); + const generation = (session: Session) => session.inspectionGeneration; + const target = (session: Session): GuardedInputTarget => ({ + id: session.id, + chrome: () => guardedComposerChrome(getCli(session.mode)), + snapshot: () => { + if (ctx.sessions.get(session.id) !== session) return null; + const snapshot = session.guardedInputSnapshot?.(); + return snapshot ?? null; + }, + write: (text) => session.stageGuardedInput(text), + submit: (expected) => session.submitGuardedInput(expected), + }); + app.get('/api/sessions/:id/inspect', async (req, reply) => { + const refused = await gate(); + if (refused) return reply.code(403).send(refused); + const { id } = req.params as { id: string }; + const session = findSessionOrFail(ctx, id, req); + const { maxBytes, since } = parseBody(AgentInspectQuerySchema, req.query); + const initialGeneration = generation(session); + const identity = { sessionId: session.id, mode: session.mode }; + const activity = { status: session.status, lastSubmitAt: session.lastSubmitAt || null, observedAt: Date.now() }; + try { + if (getCli(session.mode)?.capabilities.transcript === 'codex-rollout' && !session.remote && !session.docker) { + const conversation = await findExactCodexConversation( + { + codexHome: resolveEffectiveCodexHome(session.getCodexHomeOverride(), session.workingDir), + sessionId: session.id, + workingDir: session.workingDir, + resumeSessionId: session.codexConfig?.resumeSessionId, + }, + { maxEntries: 8192, maxBytes: 1048576 } + ); + if (generation(session) !== initialGeneration || ctx.sessions.get(id) !== session) + throw new InspectionConflict('Session changed during inspection'); + if (conversation) { + const result = await reader.read( + conversation.path, + { + owner: getAuthUser(req).username, + sessionId: id, + generation: initialGeneration, + conversationId: conversation.id, + }, + maxBytes, + since + ); + if (generation(session) !== initialGeneration || ctx.sessions.get(id) !== session) + throw new InspectionConflict('Session changed during inspection'); + return { + success: true, + data: { + source: 'codex-jsonl', + identity: { ...identity, conversationId: conversation.id }, + activity, + incremental: true, + maxBytes, + ...result, + }, + }; + } + } + if (since) + throw new InspectionConflict('Attributed transcript unavailable; this cursor cannot resume a pane snapshot'); + const frame = session.muxName ? ctx.mux.capturePaneText?.(session.muxName) : null; + const evidence = frame ?? session.terminalBuffer; + return { + success: true, + data: { + source: 'pane-snapshot', + identity, + activity, + incremental: false, + cursor: null, + maxBytes, + evidence: boundedUtf8Tail(evidence, maxBytes), + truncated: evidence.length > maxBytes || Buffer.byteLength(evidence) > maxBytes, + }, + }; + } catch (error) { + if (error instanceof InspectionConflict) + return reply.code(409).send(createErrorResponse(ApiErrorCode.CONFLICT, error.message)); + // Anything else (an unreadable rollout, an IO error) is not a retryable cursor + // conflict. Log the session and the error code only: the message can carry the + // native transcript path, which must never reach the log or the response. + console.warn(`[agent-inspect] session ${session.id}: inspection failed (${errorCode(error)})`); + return reply.code(422).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Inspection source unavailable')); + } + }); + app.post('/api/sessions/:id/stage', async (req, reply) => { + const refused = await gate(); + if (refused) return reply.code(403).send(refused); + const session = findSessionOrFail(ctx, (req.params as { id: string }).id, req); + const { message } = parseBody(AgentStageSchema, req.body); + try { + const receipt = await stages.stage(target(session), getAuthUser(req).username, message); + return { success: true, data: receipt }; + } catch (error) { + if (!(error instanceof GuardedInputConflict)) + console.warn(`[agent-stage] session ${session.id}: delivery failed (${errorCode(error)})`); + return reply + .code(409) + .send( + createErrorResponse( + ApiErrorCode.CONFLICT, + error instanceof GuardedInputConflict + ? error.message + : `Stage delivery failed or ambiguous; ${MAY_BE_TYPED}` + ) + ); + } + }); + app.post('/api/sessions/:id/submit', async (req, reply) => { + const refused = await gate(); + if (refused) return reply.code(403).send(refused); + const session = findSessionOrFail(ctx, (req.params as { id: string }).id, req); + const { stageId, confirmSha } = parseBody(AgentSubmitSchema, req.body); + try { + return { + success: true, + data: await stages.submit(target(session), getAuthUser(req).username, stageId, confirmSha), + }; + } catch (error) { + if (!(error instanceof GuardedInputConflict)) + console.warn(`[agent-submit] session ${session.id}: delivery failed (${errorCode(error)})`); + return reply + .code(409) + .send( + createErrorResponse( + ApiErrorCode.CONFLICT, + error instanceof GuardedInputConflict + ? error.message + : 'Submit delivery failed or ambiguous; receipt consumed, inspect the pane' + ) + ); + } + }); +} diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 7fbe7b5e..0cfc5f5d 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -51,6 +51,8 @@ import { probeGitRemote, } from '../../git-clone.js'; import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js'; +import { isPathWithin } from '../../git-worktrees.js'; +import { defaultDockerWorkspaces } from './git-status-routes.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { prepareNewCasePath } from '../case-path.js'; import { @@ -78,6 +80,12 @@ import { parseBody, readJsonConfig, } from '../route-helpers.js'; +import { + assertSettingsWritable, + mutateSettingsFile, + SettingsWriteRefusedError, + settingsWriteErrorResponse, +} from '../settings-writer.js'; import { isMultiUserMode } from '../../config/multiuser.js'; import type { AuthUser } from '../../types.js'; import { SseEvent } from '../sse-events.js'; @@ -144,6 +152,10 @@ const APP_VERSION = (() => { const LOCAL_CLONE_ADMIN_ONLY = 'Cloning from a local path is admin-only in multi-user mode. Use a repository URL instead.'; +/** Refusal text for a clone at or inside a Docker case workspace (git would run its hooks on the host). */ +const DOCKER_CLONE_REFUSAL = + 'Cloning is not available into a folder inside a Docker case workspace: a container can write there, and git would run the new repository’s hooks and filters on this machine. Clone outside the workspace and link it, or clone from inside the container.'; + /** * Whether a clone or preflight must run with git's credential helpers cleared: * a non-admin in multi-user mode. Every user's git runs as the one server @@ -655,7 +667,8 @@ export function registerCaseRoutes( * request died mid-clone still sees the case appear over SSE when git finishes. * * Deliberately NOT admin-gated in multi-user mode: unlike `/api/cases/link`, - * this writes only inside the caller's own `resolveCasesDir`. Two things would + * this writes only inside the caller's own `resolveCasesDir`, EXCEPT with a `path` (a custom folder, + * registered as a linked case in the shared registry), which is admin-only like Link Existing. Two things would * otherwise read through that boundary: a `local`-transport source (refused for * non-admins) and the server account's git credential helpers, which every user * shares (cleared for non-admins, see `cloneWithoutCredentialHelpers`). @@ -680,7 +693,7 @@ export function registerCaseRoutes( warnings: string[]; }> > => { - const { name, repository, ref, shallow, description } = parseBody(CloneCaseSchema, req.body); + const { name, repository, ref, shallow, description, path: customPath } = parseBody(CloneCaseSchema, req.body); const user = getAuthUser(req); const parsed = parseGitRepositoryUrl(repository); @@ -699,8 +712,59 @@ export function registerCaseRoutes( ); } + // A custom folder writes outside the cases directory and registers the path in the shared, + // ownerless linked-cases registry, so it carries the same bar as POST /api/cases/link. + if (customPath !== undefined) { + const denied = adminOnly(req, reply); + if (denied) return denied; + } + const casesDir = resolveCasesDir(user); - const casePath = validatePathWithinBase(name, casesDir); + let casePath: string | null; + let intoEmptyDir = false; + if (customPath !== undefined) { + const prepared = await prepareNewCasePath(customPath, { + home: homedir(), + dataDir: getDataDir(), + casesDirs: [...new Set([casesDir, resolveCasesDir()])], + casesDirAdvice: 'clone there without a custom folder', + }); + if (!prepared.ok) { + reply.code( + prepared.code === 'UNREACHABLE' + ? 422 + : prepared.code === 'NOT_FOUND' + ? 404 + : prepared.code === 'EXISTS' + ? 409 + : 400 + ); + return createErrorResponse( + prepared.code === 'UNREACHABLE' + ? ApiErrorCode.OPERATION_FAILED + : prepared.code === 'NOT_FOUND' + ? ApiErrorCode.NOT_FOUND + : prepared.code === 'EXISTS' + ? ApiErrorCode.ALREADY_EXISTS + : ApiErrorCode.INVALID_INPUT, + prepared.code === 'EXISTS' + ? `${customPath} already has files in it. Pick an empty folder, or a folder that does not exist yet.` + : prepared.reason + ); + } + intoEmptyDir = prepared.existedEmpty; + casePath = prepared.path; + // The same folder listed as a local case in the cases dir would make deleting it remove files. + if (existsSync(join(casesDir, name))) { + reply.code(409); + return createErrorResponse( + ApiErrorCode.ALREADY_EXISTS, + 'A case with this name already exists in codeman-cases.' + ); + } + } else { + casePath = validatePathWithinBase(name, casesDir); + } if (!casePath) return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path'); // Reject a duplicate name across EVERY case kind before invoking git, so a @@ -709,8 +773,14 @@ export function registerCaseRoutes( const linkedCases = await readLinkedCases(); const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR); const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR); + const linkedAs = Object.entries(linkedCases).find(([, p]) => p === casePath)?.[0]; + if (linkedAs && customPath !== undefined) { + reply.code(409); + return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `That folder is already the case "${linkedAs}"`); + } if ( - existsSync(casePath) || + // A custom folder was judged by prepareNewCasePath (an existing empty one is fine). + (customPath === undefined && existsSync(casePath)) || linkedCases[name] || dockerCases.some((item) => item.name === name) || remoteCases.some((item) => item.name === name) @@ -718,16 +788,47 @@ export function registerCaseRoutes( return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists'); } - // git creates the leaf, not necessarily the case space above it. + // git creates the leaf, not necessarily the case space above it (a custom folder's parent + // already exists: prepareNewCasePath refused a missing one). + if (customPath === undefined) { + try { + mkdirSync(casesDir, { recursive: true }); + } catch (err) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + } + } + + // git runs the new repository's hooks and filters on THIS machine once the clone finishes, and a container + // with the workspace bind-mounted can write both while it fetches. Like git status, fetch and worktrees, + // never clone at or inside a Docker case workspace. Fails closed when the Docker case list cannot be read. try { - mkdirSync(casesDir, { recursive: true }); - } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + // Probed first (#516): a case's workspace can sit on a mount that stopped answering, and an unbounded + // realpath there would hold this request and a threadpool worker. Such a root is judged as spelled. + const roots = await Promise.all( + (await defaultDockerWorkspaces()).map(async (root) => + (await probePathKind(root, { pastCap: true })) === 'unknown' + ? resolve(root) + : fs.realpath(root).catch(() => resolve(root)) + ) + ); + // A custom folder is already symlink-resolved; the default one is judged through its real cases dir. + const dest = + customPath !== undefined ? casePath : join(await fs.realpath(casesDir).catch(() => casesDir), name); + if (roots.some((root) => isPathWithin(dest, root))) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, DOCKER_CLONE_REFUSAL); + } + } catch { + reply.code(503); + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + 'Could not read the Docker case list, so Codeman cannot tell whether that folder is a Docker case workspace. Try again.' + ); } const clone = await cloneRepository({ repository: parsed.repository, destination: casePath, + ...(intoEmptyDir ? { intoEmptyDir: true } : {}), withoutCredentialHelpers: cloneWithoutCredentialHelpers(req), ...(ref ? { ref } : {}), ...(shallow ? { shallow: true } : {}), @@ -788,6 +889,26 @@ export function registerCaseRoutes( warnings.push(`Case scaffolding was incomplete: ${getErrorMessage(err)}`); } + if (customPath !== undefined) { + // Re-read right before writing so a case linked while git ran is not dropped. Like + // POST /api/cases/link the write is not serialized, so this only narrows the window. + try { + const fresh = await readLinkedCases(); + if (fresh[name]) + throw Object.assign(new Error(`Case "${name}" was linked while the clone ran`), { conflict: true }); + fresh[name] = casePath; + await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(fresh, null, 2)); + } catch (err) { + // The clone itself succeeded: keep the folder (the user waited for it) and say where it is. + const conflict = (err as { conflict?: boolean }).conflict; + reply.code(conflict ? 409 : 500); + return createErrorResponse( + conflict ? ApiErrorCode.ALREADY_EXISTS : ApiErrorCode.OPERATION_FAILED, + `Cloned into ${casePath} but could not register the case (${getErrorMessage(err)}). Use Link Existing to add it.` + ); + } + } + ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath }); return { success: true, @@ -1849,6 +1970,17 @@ export function registerCaseRoutes( if (open.some((s) => !canAccessOwned(user, s.owner))) { return fail(403, ApiErrorCode.FORBIDDEN, "Another user's session has this case open"); } + // The admin's rename writes settings.json only after the sessions are ended and the folder moved, and + // the writer refuses a file that is not a JSON object: check it by the writer's own rule now, so no + // session is ended for a rename that cannot complete. Not between the starting check and the lock below. + if (isAdmin(req)) { + try { + await assertSettingsWritable(); + } catch (err) { + if (err instanceof SettingsWriteRefusedError) return fail(409, ApiErrorCode.CONFLICT, err.message); + throw err; + } + } // A session being created in the folder is not in ctx.sessions yet, so it could be neither listed nor // stopped: it would start in the old path after the move. Checked in the same tick that takes the lock // below (nothing is awaited in between), and the Session constructor refuses the folder from then on. @@ -1876,27 +2008,23 @@ export function registerCaseRoutes( } if (moveFolder) await moveCaseFolder(oldPath, newPath); const originalLinks = isLinked ? await readLinkedCases() : undefined; - // The settings.json content before this rename wrote it, set only once a write is attempted (an - // absent file reads as {}, which must not be written back as a file that never existed). - let originalSettings: Record<string, unknown> | undefined; + // The settings.json values this rename replaced, set only once it wrote them. A failure puts back + // these keys alone, so a settings save that landed in between is kept. + let settingsUndo: Record<string, unknown> | undefined; try { // settings.json is server-wide and caseOrder/lastUsedCase name the admin's cases, so only the // admin's rename touches it (a non-admin's own rename must not rewrite someone else's list). // Written before linked-cases.json so a failure here leaves the registry untouched. if (isAdmin(req)) { - const existing = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {}); - const order = renameInOrder(existing.caseOrder, name, newName); - const last = existing.lastUsedCase === name ? newName : undefined; - if (order || last) { - originalSettings = existing; - await fs.writeFile( - SETTINGS_PATH, - JSON.stringify( - { ...existing, ...(order ? { caseOrder: order } : {}), ...(last ? { lastUsedCase: last } : {}) }, - null, - 2 - ) - ); + const { before, after } = await mutateSettingsFile((existing) => { + const order = renameInOrder(existing.caseOrder, name, newName); + const last = existing.lastUsedCase === name ? newName : undefined; + if (!order && !last) return undefined; + return { ...existing, ...(order ? { caseOrder: order } : {}), ...(last ? { lastUsedCase: last } : {}) }; + }); + if (after) { + const keys = ['caseOrder', 'lastUsedCase'].filter((k) => after[k] !== before[k]); + settingsUndo = Object.fromEntries(keys.map((k) => [k, before[k]])); } } if (originalLinks) { @@ -1913,9 +2041,8 @@ export function registerCaseRoutes( } catch (err) { // The folder moved but the registries did not follow: put settings.json and the folder back // rather than leave a case whose name and folder disagree. - if (originalSettings) { - await fs.writeFile(SETTINGS_PATH, JSON.stringify(originalSettings, null, 2)).catch(() => {}); - } + const undo = settingsUndo; + if (undo) await mutateSettingsFile((current) => ({ ...current, ...undo })).catch(() => {}); if (moveFolder) { const movedBack = await fs.rename(newPath, oldPath).then( () => true, @@ -1970,22 +2097,11 @@ export function registerCaseRoutes( const { order } = parseBody(CaseOrderSchema, req.body, 'Invalid order data'); try { - const dir = getDataDir(); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - let existing: Record<string, unknown> = {}; - try { - existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8')); - } catch { - /* ignore */ - } - const merged = { ...existing, caseOrder: order }; - await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2)); + await mutateSettingsFile((current) => ({ ...current, caseOrder: order })); ctx.broadcast(SseEvent.CaseOrderChanged, { order }); return { success: true, data: { order } }; } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + return settingsWriteErrorResponse(err); } }); diff --git a/src/web/routes/config-backup-routes.ts b/src/web/routes/config-backup-routes.ts index 5cf78eba..1eadcfbb 100644 --- a/src/web/routes/config-backup-routes.ts +++ b/src/web/routes/config-backup-routes.ts @@ -128,8 +128,8 @@ export async function runConfigBackup( reason: string, opts: { force?: boolean } = {} ): Promise<{ result: ConfigBackupResult; removed: string[] } | null> { - // Read settings.json directly: readJsonConfig answers {} for ANY failure, so a syntax error or a read - // that lands inside PUT /api/settings's non-atomic write used to prune with the defaults (20 / 30 + // Read settings.json directly: readJsonConfig answers {} for ANY failure, so a syntax error (or, before + // settings writes became atomic, a read that landed inside one) used to prune with the defaults (20 / 30 // days) and drop the longer history the user asked to keep. const raw = await readSettingsForBackup(); const unreadable = raw === 'unreadable'; @@ -150,7 +150,12 @@ export async function runConfigBackup( }); } -export function registerConfigBackupRoutes(app: FastifyInstance): void { +export interface ConfigBackupRouteDeps { + /** Re-read discord.json into the running bridge (it caches its config and would keep the pre-restore token). */ + reloadDiscord?: () => Promise<void>; +} + +export function registerConfigBackupRoutes(app: FastifyInstance, deps: ConfigBackupRouteDeps = {}): void { const deny = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => { if (isMultiUserMode() && !isAdmin(req)) { reply.code(403); @@ -236,6 +241,8 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void { if (restored.includes('intents.json')) intentStore.reload(); // restoreConfigBackup writes clis.json directly (the one writer besides mutateRegistryFile). if (restored.includes('clis.json')) reloadCliRegistry(); + // The Discord bridge caches discord.json and rewrites nothing from it, so a restore must reach the running bridge. + if (restored.includes('discord.json')) void deps.reloadDiscord?.().catch(() => undefined); // A restore of users.json replaces accounts, password hashes, roles and bypass grants. if (isMultiUserMode()) { void appendAdminAudit({ diff --git a/src/web/routes/custom-model-routes.ts b/src/web/routes/custom-model-routes.ts index 3339ae3c..26207239 100644 --- a/src/web/routes/custom-model-routes.ts +++ b/src/web/routes/custom-model-routes.ts @@ -21,7 +21,7 @@ import { isMultiUserMode } from '../../config/multiuser.js'; import { getDataDir } from '../../config/instance.js'; import { isBlockedWebviewUrl } from '../webview-egress-policy.js'; import { egressBlockedReason, webviewFetch } from '../webview-egress.js'; -import { CustomModelHostSchema } from '../schemas.js'; +import { CustomModelHostSchema, MAX_DISCOVERED_CONTEXT_TOKENS } from '../schemas.js'; import { readCustomModelHosts, writeCustomModelHosts, type CustomModelHost } from '../../custom-model-hosts.js'; import type { CliEntry } from '../../config/cli-registry/types.js'; @@ -158,9 +158,14 @@ function authHeaders(host: Pick<CustomModelHost, 'apiKey' | 'authStyle'>): Recor export interface DiscoveryResult { models: string[]; - /** See `CustomModelHost.modelContextLengths` — only ever populated for models already loaded. */ + /** + * See `CustomModelHost.modelContextLengths`: populated from a model's own `/v1/models` + * entry (`context_length`, else `context_window`) whenever the server states one, and + * from the launch command or `/props` for loaded models, which overwrite (and so win + * over) any listed figure for the same model. + */ contextLengths: Record<string, number>; - /** See `CustomModelHost.modelSlotCounts`; populated on the same loaded-models-only basis as `contextLengths`. */ + /** See `CustomModelHost.modelSlotCounts`; populated only for models the server reports loaded (launch command or `/props`). No listing states a slot count, unlike `contextLengths`. */ slotCounts: Record<string, number>; /** See `CustomModelHost.modelSizesGB` — populated for every model whose own listing states one. */ sizesGB: Record<string, number>; @@ -182,6 +187,20 @@ function parseSizeGB(description: unknown): number | undefined { return Number.isFinite(size) && size > 0 ? size : undefined; } +/** + * Best-effort: reads a context length (tokens) stated by a model's OWN entry in + * `GET /v1/models`. OpenRouter-style servers call the field `context_length`; current + * llama-swap sends both `context_length` and `context_window` per model, and current + * Bifrost (a gateway in front of llama-swap) passes `context_window` through. Older + * servers send neither, and a server that states nothing gets no entry, never a guess. + * Non-numeric, zero, negative, fractional or over-the-ceiling values are ignored, the + * same rule the stored field's schema enforces: a wrong figure here is worse than none. + */ +function parseListedContextLength(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) return undefined; + return value <= MAX_DISCOVERED_CONTEXT_TOKENS ? value : undefined; +} + /** * Best-effort: fetches `GET /props?model=<id>` (llama.cpp-native, llama-swap-proxied) for * ONE already-loaded model and pulls its `n_ctx` and `total_slots` out. Never called for a model that @@ -312,25 +331,42 @@ async function discoverModels( }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const body = (await res.json()) as { - data?: Array<{ id?: unknown; status?: { value?: unknown }; description?: unknown }>; + data?: Array<{ + id?: unknown; + status?: { value?: unknown }; + description?: unknown; + context_length?: unknown; + context_window?: unknown; + }>; }; const entries = body.data ?? []; const models = entries.map((m) => m.id).filter((id): id is string => typeof id === 'string' && id.length > 0); const sizesGB: Record<string, number> = {}; + // The lowest-precedence context source, and the only one a gateway without /running or + // /props of its own (e.g. Bifrost) can offer: the figure a model's own listing states + // (`context_length`, else `context_window`). The loaded-model pass below OVERWRITES + // these for whatever it reads from the launch command or /props, so precedence per + // model stays: `/running` cmd, then `/props`, then this listed figure. + const contextLengths: Record<string, number> = {}; for (const entry of entries) { if (typeof entry.id !== 'string' || !entry.id) continue; const size = parseSizeGB(entry.description); if (size !== undefined) sizesGB[entry.id] = size; + const listed = parseListedContextLength(entry.context_length) ?? parseListedContextLength(entry.context_window); + if (listed !== undefined) contextLengths[entry.id] = listed; } // llama-swap-specific, feature-detected: a server that never mentions `status` on ANY - // entry gets no context-length enrichment at all, rather than treating "no status field" - // as "assume unloaded" — either reading is a guess, and skipping is the safe one, since - // fetchContextLength must only ever run against a model this server itself calls loaded. + // entry gets no launch-command/props context enrichment at all, rather than treating + // "no status field" as "assume unloaded"; either reading is a guess, and skipping is + // the safe one, since fetchContextLength must only ever run against a model this server + // itself calls loaded. Listed figures above are unaffected: reading them triggers nothing. const hasStatusField = entries.some((m) => m && typeof m === 'object' && 'status' in m); - const contextLengths: Record<string, number> = {}; const slotCounts: Record<string, number> = {}; + // Ids whose figure THIS round's loaded-model probe answered: the listed figure remains + // the source only for the ids NOT in this set (see the per-slot correction below). + const ctxFromProbe = new Set<string>(); if (hasStatusField) { const loadedIds = entries .filter((m) => m.status && typeof m.status === 'object' && (m.status as { value?: unknown }).value === 'loaded') @@ -364,11 +400,30 @@ async function discoverModels( // merges onto what was learned before). if (cmdSlots !== undefined && ctxFromProps) slots = 1; } - if (ctx !== undefined) contextLengths[id] = ctx; + // Assigning only when a probe answered keeps the listed figure (if any) when + // neither the launch command nor /props stated one, and replaces it with the + // stronger source whenever they did: cmd > /props > listed, never the reverse. + if (ctx !== undefined) { + contextLengths[id] = ctx; + ctxFromProbe.add(id); + } if (slots !== undefined) slotCounts[id] = slots; } } } + // llama-swap documents the LISTED `context_length` as the PER-SLOT window, so whenever + // the listed figure is the one taken this round (the model was not loaded, or the + // probe answered nothing), its slot count is 1 by definition: the floor check must NOT + // divide a per-slot figure a second time. Without recording 1 here, the count a loaded + // round stored survives the merge (applyDiscoveredModels keeps what a later round does + // not overwrite): 262144 @ 4 slots stored, model unloaded, listed 65536 → 65536 with 4 + // slots → a false 16384-per-slot warning. Even a count this round's launch command + // parsed must yield: pairing a per-slot listed figure with the cmd's --parallel is the + // same double division. Same rule the /props branch above applies. Ids whose figure the + // probe DID answer keep their probe-derived count untouched. + for (const id of Object.keys(contextLengths)) { + if (!ctxFromProbe.has(id)) slotCounts[id] = 1; + } return { models, contextLengths, slotCounts, sizesGB }; } diff --git a/src/web/routes/discord-routes.ts b/src/web/routes/discord-routes.ts new file mode 100644 index 00000000..2ab85511 --- /dev/null +++ b/src/web/routes/discord-routes.ts @@ -0,0 +1,228 @@ +/** + * @fileoverview Discord bridge settings and the deliberate-post route (src/discord-bridge.ts). + * + * GET /api/discord the config WITHOUT its token, plus gateway state and last result + * PUT /api/discord change any field; an empty `token` clears it + * POST /api/discord/test post one test message to the parent channel + * POST /api/sessions/:id/discord-notify a CLI asks for the user (physical step, blocker, timed-out watch) + * + * The bot token is a bearer secret, so it is stored in its own 0600 file and never returned. In + * multi-user mode the three settings routes are admin only: the server receives every session's + * events. `discord-notify` follows the session's own ownership rules instead. + */ + +import { readFile, realpath, stat } from 'node:fs/promises'; +import { basename, isAbsolute, relative, sep } from 'node:path'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js'; +import { canAccessOwned, findSessionOrFail, getAuthUser, isAdmin, parseBody } from '../route-helpers.js'; +import type { SessionPort } from '../ports/session-port.js'; +import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js'; +import { isMultiUserMode } from '../../config/multiuser.js'; +import { describeUnknownPath, probePathKind } from '../../utils/bounded-path-probe.js'; +import { DiscordNotifySchema, DiscordUpdateSchema } from '../schemas.js'; +import { + discordConfigProblem, + MAX_FILE_BYTES, + maskToken, + readDiscordConfig, + writeDiscordConfig, + type DiscordBridge, +} from '../../discord-bridge.js'; +import type { DiscordConfig, DiscordResult, DiscordStatus } from '../../types/discord.js'; + +export interface DiscordRouteDeps { + bridge: DiscordBridge; + configDir: string; + ctx: SessionPort; + hostTitle: () => string; +} + +/** + * A Docker case exports CODEMAN_SESSION_ID cut to 8 characters, so accept a unique prefix of at + * least that length. Anything ambiguous or shorter is left as given and 404s like any unknown id. + */ +function resolveSessionId(ctx: SessionPort, req: FastifyRequest, id: string): string { + if (ctx.sessions.has(id) || id.length < 8) return id; + // Only sessions the caller may see count, so another user's session is never revealed by its prefix. + const user = getAuthUser(req); + const hits = [...ctx.sessions.values()].filter((s) => s.id.startsWith(id) && canAccessOwned(user, s.owner)); + return hits.length === 1 ? hits[0].id : id; +} + +// A loop or a prompt-injected agent must not ping the phones until Discord answers 429, nor hold 8 MB +// uploads in memory without bound. Per session, refilled continuously (the same shape as paste-image's). +const NOTIFY_PER_MINUTE = 6; +const notifyBuckets = new Map<string, { tokens: number; at: number }>(); + +function consumeNotifyToken(sessionId: string, now: number = Date.now()): boolean { + if (notifyBuckets.size > 1000) { + for (const [k, b] of notifyBuckets) if (now - b.at > 3_600_000) notifyBuckets.delete(k); + } + const b = notifyBuckets.get(sessionId) ?? { tokens: NOTIFY_PER_MINUTE, at: now }; + b.tokens = Math.min(NOTIFY_PER_MINUTE, b.tokens + ((now - b.at) * NOTIFY_PER_MINUTE) / 60_000); + b.at = now; + notifyBuckets.set(sessionId, b); + if (b.tokens < 1) return false; + b.tokens -= 1; + return true; +} + +/** Give back a token spent on a request that was refused before anything was posted. */ +function refundNotifyToken(sessionId: string): void { + const b = notifyBuckets.get(sessionId); + if (b) b.tokens = Math.min(NOTIFY_PER_MINUTE, b.tokens + 1); +} + +/** Test hook: forget every bucket between runs. */ +export function _resetNotifyBuckets(): void { + notifyBuckets.clear(); +} + +/** + * A file a session may send: a regular file, at most MAX_FILE_BYTES, whose REAL path (symlinks + * followed) lies inside the session's working folder and is not on the attachment blocklist. The + * blocklist matters as much as the folder: with a home directory as the session folder, confinement + * alone would let a CLI be talked into posting ~/.codeman/discord.json, an ssh key or a `.env`. + * Returns the bytes, or the reason. + */ +async function fileProblem( + workingDir: string, + remote: boolean, + file: string +): Promise<{ name: string; data: Uint8Array } | string> { + if (remote) return 'Files can only be sent from a local session'; + if (!isAbsolute(file)) return 'The file path must be absolute'; + // Bounded first: realpath on a dead mount would hang the request. + if ((await probePathKind(workingDir, { pastCap: true })) !== 'directory') { + return describeUnknownPath('The session folder', workingDir, { pastCap: true }); + } + // The agent chose this path: probe it bounded too, before realpath can hold a worker on a dead mount. + const kind = await probePathKind(file, { pastCap: true }); + if (kind === 'unknown') return describeUnknownPath('The file', file, { pastCap: true }); + if (kind !== 'file') return 'Not a regular file'; + try { + const [root, real] = await Promise.all([realpath(workingDir), realpath(file)]); + const rel = relative(root, real); + if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) + return 'The file must be inside the session folder'; + if (isBlockedAttachmentPath(real, (await loadAttachmentGuardConfig()).blockedTrees)) { + return 'That file cannot be sent: it can hold credentials'; + } + const info = await stat(real); + if (!info.isFile()) return 'Not a regular file'; + if (info.size > MAX_FILE_BYTES) return `The file is over ${MAX_FILE_BYTES / 1024 / 1024} MB`; + return { name: basename(real), data: new Uint8Array(await readFile(real)) }; + } catch { + return 'The file could not be read'; + } +} + +export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDeps): void { + const denied = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => { + if (isMultiUserMode() && !isAdmin(req)) { + reply.code(403); + return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode'); + } + return null; + }; + + const status = async (): Promise<DiscordStatus> => { + const { token, ...rest } = await readDiscordConfig(deps.configDir); + return { + ...rest, + hasToken: token !== '', + tokenMasked: maskToken(token), + gateway: deps.bridge.gatewayState, + gatewayError: deps.bridge.gatewayError, + lastResult: deps.bridge.lastResult, + }; + }; + + app.get('/api/discord', async (req, reply): Promise<ApiResponse<DiscordStatus>> => { + const no = denied(req, reply); + if (no) return no; + return { success: true, data: await status() }; + }); + + app.put('/api/discord', async (req, reply): Promise<ApiResponse<DiscordStatus>> => { + const no = denied(req, reply); + if (no) return no; + const patch = parseBody(DiscordUpdateSchema, req.body, 'Invalid Discord settings'); + const current = await readDiscordConfig(deps.configDir); + const next: DiscordConfig = { + ...current, + ...patch, + token: patch.token !== undefined ? patch.token.trim() : current.token, + }; + const problem = discordConfigProblem(next); + if (problem) { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem); + } + try { + await writeDiscordConfig(deps.configDir, next); + await deps.bridge.reload(); + } catch (err) { + reply.code(500); + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + } + return { success: true, data: await status() }; + }); + + app.post('/api/discord/test', async (req, reply): Promise<ApiResponse<DiscordResult>> => { + const no = denied(req, reply); + if (no) return no; + const cfg = await readDiscordConfig(deps.configDir); + if (!cfg.token || !cfg.parentChannelId) { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Save the bot token and parent channel ID first'); + } + // 200 even when delivery failed: the request to Codeman worked, `data.ok` says whether Discord did. + return { success: true, data: await deps.bridge.sendTest(cfg, deps.hostTitle()) }; + }); + + app.post('/api/sessions/:id/discord-notify', async (req, reply): Promise<ApiResponse<DiscordResult | null>> => { + const { id: rawId } = req.params as { id: string }; + const id = resolveSessionId(deps.ctx, req, rawId); + const { message, urgent, file } = parseBody(DiscordNotifySchema, req.body, 'Invalid Discord message'); + const session = findSessionOrFail(deps.ctx, id, req); + if (!consumeNotifyToken(session.id)) { + reply.code(429); + reply.header('Retry-After', '60'); + return createErrorResponse( + ApiErrorCode.RATE_LIMITED, + `Too many messages (${NOTIFY_PER_MINUTE} per minute per session)` + ); + } + let upload: { name: string; data: Uint8Array } | undefined; + if (file !== undefined) { + const problem = await fileProblem(session.workingDir, !!session.remote, file); + if (typeof problem === 'string') { + refundNotifyToken(session.id); + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem); + } + upload = problem; + } + const result = await deps.bridge.notify( + { + event: 'session:discord-notify', + title: urgent ? 'Needs you now' : 'Message from the CLI', + body: message, + urgency: urgent ? 'critical' : 'warning', + sessionId: session.id, + sessionName: session.name, + host: deps.hostTitle(), + file: upload, + }, + // deliberate: an agent's own message is never deduped, so a second, different post is not lost. + { urgent: urgent === true, deliberate: true } + ); + if (result === null) { + reply.code(409); + return createErrorResponse(ApiErrorCode.CONFLICT, 'The Discord bridge is not enabled'); + } + return { success: true, data: result }; + }); +} diff --git a/src/web/routes/edit-history-routes.ts b/src/web/routes/edit-history-routes.ts new file mode 100644 index 00000000..227a16a1 --- /dev/null +++ b/src/web/routes/edit-history-routes.ts @@ -0,0 +1,140 @@ +/** + * @fileoverview The edit history panel's API: which files a claude session edited (main agent + * and subagents), each edit's diff, and Bash-generated images (src/web/edit-history-sync.ts). + * + * GET /api/sessions/:id/edits[?sync=1] list (no diffs); `sync=1` reads what is new first + * GET /api/sessions/:id/edits/:entryId one edit's diff as unified text + * POST /api/sessions/:id/edits/older read the next older block, then list + * + * Read-only, and reads only on these requests (the panel drives it; nothing syncs in the + * background). Remote, Docker and non-claude sessions answer `state: 'unsupported'` + * without being read. Ownership goes through `findSessionOrFail` with `req`, like every + * session-scoped route. No route here serves a file: an image entry is shown through the + * existing file routes (workspace `file-raw`, or the attachment routes outside it). + */ + +import type { FastifyInstance } from 'fastify'; +import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js'; +import { findSessionOrFail } from '../route-helpers.js'; +import type { SessionPort } from '../ports/index.js'; +import type { EditHistoryEntry, EditHistoryStore } from '../../edit-history-store.js'; +import type { EditHunk } from '../../edit-history-parse.js'; +import { + unsupportedReason, + type EditHistorySession, + type EditHistoryState, + type EditHistoryStatus, + type EditHistorySync, +} from '../edit-history-sync.js'; + +export interface EditHistoryListEntry extends Omit<EditHistoryEntry, 'hunks'> { + relPath: string; +} + +export interface EditHistoryList { + state: EditHistoryState; + reason?: string; + backfillTruncated: boolean; + agents: Record<string, { description: string; agentType: string; worktreeBranch?: string }>; + entries: EditHistoryListEntry[]; +} + +export interface EditHistoryDiff { + diff: string; + truncated: boolean; +} + +/** `path` relative to `base` when it lies below it, else unchanged. */ +export function relativeTo(path: string, base: string | undefined): string { + if (!base) return path; + const root = base.endsWith('/') ? base : `${base}/`; + return path.startsWith(root) ? path.slice(root.length) : path; +} + +/** Hunks as unified diff text, the form the Git panel's renderer reads. */ +export function hunksToUnified(hunks: readonly EditHunk[]): string { + const out: string[] = []; + for (const h of hunks) { + out.push(`@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`); + out.push(...h.lines); + } + return out.join('\n'); +} + +function listOf( + store: EditHistoryStore, + workingDir: string, + status: { state: EditHistoryState; reason?: string } +): EditHistoryList { + const agents = store.getAgents(); + const entries = store.list().map(({ hunks: _hunks, ...rest }) => ({ + ...rest, + relPath: relativeTo(rest.path, (rest.agentId && agents[rest.agentId]?.worktreePath) || workingDir), + })); + const publicAgents: EditHistoryList['agents'] = {}; + for (const [id, a] of Object.entries(agents)) { + publicAgents[id] = { description: a.description, agentType: a.agentType }; + if (a.worktreeBranch) publicAgents[id].worktreeBranch = a.worktreeBranch; + } + const list: EditHistoryList = { + state: status.state, + backfillTruncated: store.backfillTruncated, + agents: publicAgents, + entries, + }; + if (status.reason) list.reason = status.reason; + return list; +} + +/** The answer for a session that is never read (remote, Docker, non-claude): no store involved. */ +function unsupportedList(reason: string): EditHistoryList { + return { state: 'unsupported', reason, backfillTruncated: false, agents: {}, entries: [] }; +} + +export function registerEditHistoryRoutes(app: FastifyInstance, ctx: SessionPort, sync: EditHistorySync): void { + const sessionOf = (id: string, req: Parameters<typeof findSessionOrFail>[2]) => { + const session = findSessionOrFail(ctx, id, req); + const view: EditHistorySession = { + id: session.id, + mode: session.mode, + remote: session.remote, + docker: session.docker, + claudeSessionId: session.claudeSessionId, + claudeConfigDir: session.claudeConfigDir(), + }; + return { session, view }; + }; + + app.get('/api/sessions/:id/edits', async (req): Promise<ApiResponse<EditHistoryList>> => { + const { id } = req.params as { id: string }; + const { sync: doSync } = req.query as { sync?: string }; + const { session, view } = sessionOf(id, req); + const why = unsupportedReason(view); + // Never read: answer an empty list without loading (or creating) a store for it. + if (why) return { success: true, data: unsupportedList(why) }; + // The sync starts in the same tick as the session check: a deletion right after it marks + // the running sync gone (EditHistorySync.remove), so nothing writes a deleted session. + const status: EditHistoryStatus = doSync === '1' ? await sync.sync(view) : sync.getStatus(session.id); + return { success: true, data: listOf(await sync.getStore(session.id), session.workingDir, status) }; + }); + + app.post('/api/sessions/:id/edits/older', async (req): Promise<ApiResponse<EditHistoryList>> => { + const { id } = req.params as { id: string }; + const { session, view } = sessionOf(id, req); + const why = unsupportedReason(view); + if (why) return { success: true, data: unsupportedList(why) }; + const status = await sync.sync(view, { older: true }); + return { success: true, data: listOf(await sync.getStore(session.id), session.workingDir, status) }; + }); + + app.get('/api/sessions/:id/edits/:entryId', async (req, reply): Promise<ApiResponse<EditHistoryDiff>> => { + const { id, entryId } = req.params as { id: string; entryId: string }; + const { session, view } = sessionOf(id, req); + const entry = unsupportedReason(view) ? undefined : (await sync.getStore(session.id)).get(entryId); + if (!entry) { + reply.code(404); + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'No such edit in this session'); + } + return { success: true, data: { diff: hunksToUnified(entry.hunks), truncated: entry.truncated } }; + }); +} diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index 395e6af2..95896bfd 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -57,6 +57,7 @@ import { findSessionOrFail, getAuthUser, parseBody, + resolveCasesDir, validateSessionFilePath, validateSessionFilePathLexical, } from '../route-helpers.js'; @@ -74,7 +75,12 @@ import { parseByteRange } from '../http-range.js'; import { isSensitivePath } from '../sensitive-path.js'; import { SseEvent } from '../sse-events.js'; import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js'; -import { FilesystemBrowseQuerySchema, FilesystemPreviewQuerySchema, FileWriteSchema } from '../schemas.js'; +import { + FilesystemBrowseQuerySchema, + FilesystemMkdirSchema, + FilesystemPreviewQuerySchema, + FileWriteSchema, +} from '../schemas.js'; import { MAX_EDITABLE_BYTES, applyEol, @@ -767,7 +773,11 @@ const FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif' const FILESYSTEM_TEXT_PREVIEW_EXTENSIONS = new Set(['md', 'txt', 'json']); const FILESYSTEM_DOCUMENT_PREVIEW_EXTENSIONS = new Set(['pdf', 'docx', 'pptx']); -function isPathWithinRoot(root: string, candidate: string): boolean { +/** + * Whether `candidate` is `root` itself or lies beneath it (both already resolved). The one lexical + * containment check for workspace confinement; ralph-routes.ts uses it for a remote `@fix_plan.md`. + */ +export function isPathWithinRoot(root: string, candidate: string): boolean { const rel = relative(root, candidate); return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`)); } @@ -1444,6 +1454,83 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even }; }); + // "New folder" in the path picker: creates ONE folder directly inside a folder the picker may browse. + app.post('/api/filesystem/mkdir', async (req, reply): Promise<ApiResponse<{ path: string }>> => { + const { path: parentPath, name, sessionId, showHidden } = parseBody(FilesystemMkdirSchema, req.body); + // Probed first (#516): the picker's resolver runs realpathSync on this path, which would freeze the + // whole server on a mount that stopped answering. + if ((await probePathKind(parentPath, { pastCap: true })) === 'unknown') { + reply.code(422); + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + describeUnknownPath('The folder', parentPath, { pastCap: true }) + ); + } + const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath( + ctx, + req, + parentPath, + sessionId, + wantsHiddenPickerEntries(showHidden) + ); + const target = join(resolvedPath, name); + if (isBlockedPickerPath(resolvedPath, blockedTrees, true) || isBlockedPickerPath(target, blockedTrees, true)) { + reply.code(403); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Folders cannot be created here'); + } + // The cases directories (shared, and the caller's own in multi-user mode) are what GET /api/cases lists as + // local cases: a folder made there is a case with no scaffold, which Link then refuses by name and which + // deleting would take the linked folder with. A case there is made through Add Case > New Case. + const casesDirs = await Promise.all( + [...new Set([CASES_DIR, resolveCasesDir(getAuthUser(req))])].map((dir) => + fs.realpath(dir).catch(() => resolve(dir)) + ) + ); + if (casesDirs.includes(resolvedPath)) { + reply.code(400); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'This is a cases folder: every folder in it is a case. Use Add Case > New Case to make one there.' + ); + } + // Like browse, a file is not a place to create in. + try { + if (!(await fs.stat(resolvedPath)).isDirectory()) throw new Error('not a directory'); + } catch { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'The folder to create in must be a directory'); + } + try { + // Not recursive: the parent was just resolved inside an allowed root, and an existing folder must be a 409. + await fs.mkdir(target); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'EEXIST') { + reply.code(409); + return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `"${name}" already exists in this folder`); + } + if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') { + reply.code(403); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'This folder is not writable'); + } + if (code === 'ENOENT') { + reply.code(404); + return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${candidatePath}`); + } + if (code === 'ENAMETOOLONG') { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Folder name is too long'); + } + // Never the errno text: it names the resolved, symlink-followed path, not the one the picker shows. + reply.code(500); + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + `Could not create the folder${code ? ` (${code})` : ''}` + ); + } + return { success: true, data: { path: join(candidatePath, name) } }; + }); + // Inline preview for files selected through the root-confined filesystem picker. app.get('/api/filesystem/preview', { compress: false }, async (req, reply): Promise<void> => { const { path: requestedPath, sessionId, showHidden } = parseBody(FilesystemPreviewQuerySchema, req.query); diff --git a/src/web/routes/git-status-routes.ts b/src/web/routes/git-status-routes.ts index 0d6d8cbc..aff43d23 100644 --- a/src/web/routes/git-status-routes.ts +++ b/src/web/routes/git-status-routes.ts @@ -1,21 +1,25 @@ /** - * @fileoverview `GET /api/sessions/:id/git-status`: what the session's workspace has not committed or - * pushed (src/git-workspace-status.ts), for the bottom-bar Git indicator and its panel. The answer is + * @fileoverview The Git panel's routes: `GET /api/sessions/:id/git-status` (what the session's workspace + * has not committed or pushed, src/git-workspace-status.ts) and `POST /api/sessions/:id/git-fetch` (the + * panel's Fetch button, src/git-fetch.ts), for the bottom-bar Git indicator and its window. The answer is * an overview: the enclosing repository, or each repository found below a folder that holds several * projects (see `getGitWorkspaceOverview` for exactly which). * - * Read-only and offline: it never fetches and never runs a git write command. A remote (SSH) or - * Docker session is not inspected and answers `state: 'unsupported'`, and neither is any repository at or - * inside a Docker case workspace (a container can write there, and git here would run on the host). Ownership goes through + * Read-only and offline: the status route never fetches and never runs a git write command. Fetching is a + * separate, user-triggered path (`POST /api/sessions/:id/git-fetch`, src/git-fetch.ts) that the panel calls + * only when the user clicks Fetch. A remote (SSH) or Docker session is not inspected and answers + * `state: 'unsupported'` on both routes, and neither is any repository at or inside a Docker case + * workspace (a container can write there, and git here would run on the host). Ownership goes through * `findSessionOrFail`, like every session-scoped route. */ import type { FastifyInstance } from 'fastify'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js'; import { redactGitCredentials } from '../../git-clone.js'; -import { readDockerCases } from '../../docker-hosts.js'; +import { emptyReport, fetchWorkspaceOnce, gitFetchStatusFrom, type GitFetchReport } from '../../git-fetch.js'; +import { readDockerCasesOrThrow } from '../../docker-hosts.js'; import { getDataDir } from '../../config/instance.js'; -import { findSessionOrFail } from '../route-helpers.js'; +import { findSessionOrFail, persistAndBroadcastSession } from '../route-helpers.js'; import { emptyOverview, findWorkspaceRepo, @@ -28,11 +32,24 @@ import { type GitRunner, type GitWorkspaceOverview, } from '../../git-workspace-status.js'; -import type { SessionPort } from '../ports/index.js'; +import type { EventPort, SessionPort } from '../ports/index.js'; -/** Host paths of every Docker case workspace: repositories at or inside these are never inspected. */ -const defaultDockerWorkspaces = async (): Promise<string[]> => - (await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean); +/** + * Host paths of every Docker case workspace: repositories at or inside these are never inspected. + * Exported so the interval scheduler (src/git-fetch-scheduler.ts) uses the SAME list: the Docker rule has + * to be one rule, checked by every path that could run git. + * + * REJECTS when `docker-cases.json` exists but cannot be read or parsed (its writer truncates and rewrites it + * in place, so a read can land mid-write). The fetch paths fail closed on that: a fetch runs a repository's + * own hooks, and without the list nothing tells which folders a container can write to. The read-only + * status and diff routes read it as no Docker workspaces, as they always have. + */ +export const defaultDockerWorkspaces = async (): Promise<string[]> => + (await readDockerCasesOrThrow(getDataDir())).map((c) => c.hostWorkspacePath).filter(Boolean); + +/** The read-only routes' reading of the list: unchanged, an unreadable list is no Docker workspaces. */ +const dockerWorkspacesOrNone = (dockerWorkspaces: () => Promise<string[]>): Promise<string[]> => + dockerWorkspaces().catch(() => []); /** * The repository limit and git timeout a request asks for (Settings → Bottom bar, per device): @@ -50,7 +67,9 @@ function limitsFrom(query: { maxRepos?: unknown; timeout?: unknown }) { export function registerGitStatusRoutes( app: FastifyInstance, - ctx: SessionPort, + // EventPort as well as SessionPort: the fetch route publishes the run it did, and a route that broadcasts + // has to say so in its own signature. + ctx: SessionPort & EventPort, git?: GitRunner, dockerWorkspaces: () => Promise<string[]> = defaultDockerWorkspaces ): void { @@ -68,7 +87,7 @@ export function registerGitStatusRoutes( git, fresh: fresh === '1', ...limits, - dockerWorkspaces: await dockerWorkspaces(), + dockerWorkspaces: await dockerWorkspacesOrNone(dockerWorkspaces), }), }; }); @@ -92,7 +111,7 @@ export function registerGitStatusRoutes( ? await findWorkspaceRepo(session.workingDir, repo, { git, ...limits, - dockerWorkspaces: await dockerWorkspaces(), + dockerWorkspaces: await dockerWorkspacesOrNone(dockerWorkspaces), }) : null; const status = repoRoot @@ -119,4 +138,54 @@ export function registerGitStatusRoutes( ); } }); + + // Fetch, and only because the user clicked Fetch in the panel (src/git-fetch.ts): one + // `git fetch --quiet` per repository the panel would show, through the same resolver so the two cannot + // disagree about which repositories belong to the workspace. This is the only git command the Git panel runs + // that writes, and it writes remote-tracking refs only: nothing commits, checks out or merges. Cloning and + // worktree management write too, but those happen when the user asked for them; this route is the one that + // can also run later, on a timer. It is not as harmless as a read though: a fetch holds git's ref lock, so + // an agent's own `git pull` in the same clone can collide with it, which is why the run goes through + // `fetchWorkspaceOnce`: a click while a tick is already fetching this folder joins that run instead of + // starting a second one. It can also run a + // repository's own hooks (`reference-transaction`, `pre-auto-gc` through the maintenance git runs after a + // fetch) and its config commands such as `credential.helper`, so the `FETCH_CONFIG_ARGS` overrides turn + // maintenance and gc off, and a repository a container can write to is refused before any git runs. Remote + // and Docker sessions answer `unsupported` like the status route. + app.post('/api/sessions/:id/git-fetch', async (req): Promise<ApiResponse<GitFetchReport>> => { + const { id } = req.params as { id: string }; + const query = req.query as { maxRepos?: unknown; timeout?: unknown }; + const limits = limitsFrom(query); + const session = findSessionOrFail(ctx, id, req); + if (session.remote) return { success: true, data: emptyReport('unsupported', { reason: 'remote' }) }; + if (session.docker) return { success: true, data: emptyReport('unsupported', { reason: 'docker' }) }; + // Fail CLOSED on a Docker case list that cannot be read: a fetch runs the repository's hooks, so it never + // runs without knowing which folders a container can write to. Answered as a failed run (`state: 'error'`), + // published like one, so the Session Options Git row shows it. + let dockerRoots: string[]; + try { + dockerRoots = await dockerWorkspaces(); + } catch (err) { + console.warn(`[git fetch] Docker case list unreadable, nothing fetched: ${getErrorMessage(err)}`); + const report = emptyReport('error', { + error: + 'Nothing was fetched: Codeman could not read its Docker case list (docker-cases.json), so it cannot tell which folders a container can write to.', + }); + if (session.setGitFetchStatus(gitFetchStatusFrom(report, 'click'))) persistAndBroadcastSession(ctx, session); + return { success: true, data: report }; + } + const run = await fetchWorkspaceOnce( + session.workingDir, + { git, ...limits, dockerWorkspaces: dockerRoots }, + 'click' + ); + // The response answers the click, and the run is also the session's current truth: what the panel's + // "behind" number is based on. Publishing it is what the api docs say a click does, and what makes a + // second tab open on the same folder see the same run. The trigger recorded is the RUN's, not this + // request's: a click that joined a tick already going fetched nothing itself, so the row has to name the + // interval as what fetched. + if (session.setGitFetchStatus(gitFetchStatusFrom(run.report, run.trigger))) + persistAndBroadcastSession(ctx, session); + return { success: true, data: run.report }; + }); } diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts index ec5e0e80..2dde7fa3 100644 --- a/src/web/routes/index.ts +++ b/src/web/routes/index.ts @@ -16,6 +16,7 @@ export { registerInboxRoutes } from './inbox-routes.js'; export { registerRebootRestoreRoutes } from './reboot-restore-routes.js'; export { registerReadMyMindRoutes } from './readmymind-routes.js'; export { registerGitStatusRoutes } from './git-status-routes.js'; +export { registerEditHistoryRoutes } from './edit-history-routes.js'; export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js'; export { registerCaseRoutes } from './case-routes.js'; export { registerWorktreeRoutes } from './worktree-routes.js'; @@ -35,6 +36,7 @@ export { registerTabLayoutRoutes } from './tab-layout-routes.js'; export { registerMcpSyncRoutes } from './mcp-sync-routes.js'; export { registerConfigBackupRoutes, runConfigBackup } from './config-backup-routes.js'; export { registerWebhookRoutes } from './webhook-routes.js'; +export { registerDiscordRoutes } from './discord-routes.js'; export { registerDoctorRoutes } from './doctor-routes.js'; export { registerCustomModelRoutes, @@ -47,3 +49,5 @@ export { type CustomModelSwapDisplacement, } from './custom-model-routes.js'; export { registerCliRegistryRoutes, readCliManagementEnabled, type CliListItem } from './cli-registry-routes.js'; + +export { registerAgentSessionRoutes } from './agent-session-routes.js'; diff --git a/src/web/routes/ralph-routes.ts b/src/web/routes/ralph-routes.ts index 0f624a19..5aec61d5 100644 --- a/src/web/routes/ralph-routes.ts +++ b/src/web/routes/ralph-routes.ts @@ -5,7 +5,7 @@ */ import { FastifyInstance } from 'fastify'; -import { join, dirname, resolve, relative, isAbsolute } from 'node:path'; +import { join, resolve, relative, isAbsolute } from 'node:path'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import fs from 'node:fs/promises'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js'; @@ -20,17 +20,23 @@ import { ownerFor, resolveCasesDir, sessionCapacityMessage, - SETTINGS_PATH, findSessionOrFail, parseBody, } from '../route-helpers.js'; +import { mutateSettingsFile } from '../settings-writer.js'; import { resolveClaudeModeForUsername } from '../../user-store.js'; import { writeHooksConfig, stripCaseEnvKeys } from '../../hooks-config.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { buildRalphLoopPrompt } from '../../prompts/index.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js'; +import { remoteProbePaths, remoteReadFile, RemoteFileAccessError } from '../../remote-files.js'; +import type { SessionRemote } from '../../types/session.js'; +import { isPathWithinRoot } from './file-routes.js'; import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js'; +/** Cap on a remote `@fix_plan.md` read over ssh (a task list, not a document). */ +const MAX_REMOTE_FIX_PLAN_BYTES = 1024 * 1024; + export function registerRalphRoutes( app: FastifyInstance, ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort @@ -191,6 +197,15 @@ export function registerRalphRoutes( app.post('/api/sessions/:id/fix-plan/write', async (req) => { const { id } = req.params as { id: string }; const session = findSessionOrFail(ctx, id, req); + // A remote session's workingDir is a path on the remote host. Writing it with local fs would land + // the file in a same-named local folder (or fail), and remote writes are out of scope by design + // (src/remote-files.ts is read-only), so it is refused, like the File Viewer's remote edit. + if (session.remote) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Writing @fix_plan.md is not supported for a remote (SSH) session' + ); + } const workingDir = session.workingDir; if (!workingDir) { @@ -227,7 +242,14 @@ export function registerRalphRoutes( const filePath = join(workingDir, '@fix_plan.md'); try { - const content = await fs.readFile(filePath, 'utf-8'); + // A remote session's workingDir is a path on the remote host: read the file there, over the + // same ssh path the File Viewer uses, never a same-named local file. + const content = session.remote + ? await readRemoteFixPlan(session.remote, workingDir, filePath) + : await fs.readFile(filePath, 'utf-8'); + if (content === null) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, '@fix_plan.md not found in working directory'); + } const importedCount = session.ralphTracker.importFixPlanMarkdown(content); ctx.persistSessionState(session); @@ -243,7 +265,8 @@ export function registerRalphRoutes( if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return createErrorResponse(ApiErrorCode.NOT_FOUND, '@fix_plan.md not found in working directory'); } - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${error}`); + const reason = error instanceof RemoteFileAccessError ? error.message : String(error); + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${reason}`); } }); @@ -257,6 +280,13 @@ export function registerRalphRoutes( const { id } = req.params as { id: string }; const { content } = parseBody(RalphPromptWriteSchema, req.body, 'Invalid request body'); const session = findSessionOrFail(ctx, id, req); + // Same rule as fix-plan/write: never write a remote session's file on the local disk. + if (session.remote) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Writing @ralph_prompt.md is not supported for a remote (SSH) session' + ); + } const workingDir = session.workingDir; if (!workingDir) { @@ -470,18 +500,10 @@ export function registerRalphRoutes( // in single-user mode. if (isAdmin(req)) { try { - let settings: Record<string, unknown> = {}; - try { - settings = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8')); - } catch { - /* ignore */ - } - settings.lastUsedCase = caseName; - const dir = dirname(SETTINGS_PATH); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {}); // Ignore - persisting lastUsedCase is non-critical - } catch { - /* non-critical */ + await mutateSettingsFile((settings) => ({ ...settings, lastUsedCase: caseName })); + } catch (err) { + // Non-critical: the loop starts regardless. + console.warn('[Server] Failed to save settings (lastUsedCase):', getErrorMessage(err)); } } @@ -519,3 +541,22 @@ export function registerRalphRoutes( }; }); } + +/** + * Read a remote session's `@fix_plan.md` on its host. `null` = no such file, or one that resolves outside + * the workspace (the caller's `NOT_FOUND`, as for a local ENOENT); anything that is not a regular file, a + * file over the cap, or an ssh failure throws `RemoteFileAccessError`, which the route reports as a failed + * read. + */ +async function readRemoteFixPlan(remote: SessionRemote, workingDir: string, filePath: string): Promise<string | null> { + // The file and the workspace in one round trip, so a symlinked @fix_plan.md that resolves outside the + // workspace is refused like any missing file (the File Viewer's confinement, file-routes.ts). + const [probe, rootProbe] = await remoteProbePaths(remote, [filePath, workingDir]); + if (!probe) return null; + if (!isPathWithinRoot(rootProbe?.realPath ?? resolve(workingDir), probe.realPath)) return null; + if (probe.kind !== 'file') throw new RemoteFileAccessError('@fix_plan.md is not a regular file'); + if (probe.size > MAX_REMOTE_FIX_PLAN_BYTES) { + throw new RemoteFileAccessError(`@fix_plan.md is larger than ${MAX_REMOTE_FIX_PLAN_BYTES / 1024 / 1024}MB`); + } + return (await remoteReadFile(remote, probe.realPath, MAX_REMOTE_FIX_PLAN_BYTES)).toString('utf-8'); +} diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 7569a4dc..2bcd85a6 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -7,7 +7,7 @@ import { claudeProjectDirName } from '../../case-rename.js'; import { FastifyInstance, type FastifyRequest } from 'fastify'; import { z } from 'zod'; -import { join, dirname, extname, basename, resolve as resolvePath } from 'node:path'; +import { join, dirname, extname, basename, isAbsolute, resolve as resolvePath } from 'node:path'; import { homedir } from 'node:os'; import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs'; import { execFile } from 'node:child_process'; @@ -35,6 +35,7 @@ import { type RemoteHost, } from '../../types.js'; import { + GuardedInputBusyError, Session, cliTakesSessionModel, isAltScreenStripMode, @@ -57,6 +58,7 @@ import { AutoCompactSchema, AutoResumeSchema, KeepWarmSchema, + GitFetchSettingsSchema, PinSessionSchema, ImageWatcherSchema, FlickerFilterSchema, @@ -68,8 +70,9 @@ import { SessionWaitOutputQuerySchema, CustomModelSelectionSchema, } from '../schemas.js'; -import { readCustomModelHosts } from '../../custom-model-hosts.js'; +import { readCustomModelHosts, resolveCustomModelContextLength } from '../../custom-model-hosts.js'; import { applyCustomModelInjection, removeConfigDir } from '../../custom-model-injection-apply.js'; +import { buildCustomModelInjection } from '../../custom-model-injection.js'; import { getLlamaSwapStatus, triggerLlamaSwapLoad, @@ -114,17 +117,17 @@ import { resolveCasesDir, resolveParentSessionId, sessionCapacityMessage, - SETTINGS_PATH, validatePathWithinBase, waitCapacityResponse, abortOnClientHangUp, } from '../route-helpers.js'; +import { mutateSettingsFile } from '../settings-writer.js'; import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-marker.js'; import { agentInbox } from '../agent-inbox.js'; import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js'; import { clampEnvOverridesForOwner } from '../../session-env-clamp.js'; import { enabledClis, getCli } from '../../config/cli-registry/registry.js'; -import type { AgentSkillDir, NewlineSequence } from '../../config/cli-registry/types.js'; +import type { AgentSkillDir, CliEntry, NewlineSequence } from '../../config/cli-registry/types.js'; import { resolveCliLaunchError } from '../../utils/cli-launcher.js'; import { applyLaunchDefaults } from '../launch-defaults.js'; import { legacyConfigForMode } from '../../session-cli-registry-bridge.js'; @@ -228,6 +231,39 @@ function landedInGroup(layout: TabLayout | undefined, sessionId: string): boolea return !!layout?.groups.some((group) => group.refs.some((ref) => ref.kind === 'session' && ref.id === sessionId)); } +/** + * Guard a session-supplied configDir home BEFORE `applyCustomModelInjection` runs. + * + * The apply resolves the CLI's real home from this session's env override and then runs + * synchronous `existsSync`/`mkdirSync` on it (deepseek `DSH_HOME`, and any future + * `linkFromRealHome` recipe). A path on an unreachable network mount makes a synchronous + * stat wait forever, and this runs INSIDE a request handler, so it would freeze the whole + * web server (CLAUDE.md, the #516 rule on user-chosen paths). A relative home is left to + * the apply, which already refuses to join non-absolute values. + * + * Probe the session's own home with `probePathKind(..., { pastCap: true })` first: on + * `'unknown'` (a dead mount that never answered) hand the apply a lookup that hides that + * one env var, so linking falls through to the server's own environment and the unresponsive + * path is never touched. Any other entry's lookup passes through untouched. + */ +export async function guardSessionConfigDirHome( + entry: Pick<CliEntry, 'capabilities'>, + baseLookup: (envVar: string) => string | undefined +): Promise<(envVar: string) => string | undefined> { + const recipe = entry.capabilities.customModelInjection; + if (recipe?.kind !== 'configDir' || !recipe.linkFromRealHome?.length) return baseLookup; + const dirEnvVar = recipe.dirEnvVar; + const supplied = baseLookup(dirEnvVar)?.trim(); + if (!supplied || !isAbsolute(supplied)) return baseLookup; + if ((await probePathKind(supplied, { pastCap: true })) === 'unknown') { + console.warn( + `[custom-model] ${dirEnvVar} (${supplied}) did not answer; linking from the server's own home instead` + ); + return (envVar: string) => (envVar === dirEnvVar ? undefined : baseLookup(envVar)); + } + return baseLookup; +} + /** * The case a POST /api/sessions `workingDir` belongs to, for its default session * group: a direct child of the caller's case space, or (admin / single-user, as @@ -1539,9 +1575,36 @@ export function registerSessionRoutes( if (!endpoint) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found'); } - const contextLength = endpoint.modelContextLengths?.[body.modelId]; + // Discovery's per-model figure wins; the endpoint's user-set default covers models + // whose size discovery could not determine (a gateway that states none, say). With + // neither, the existing "no override, CLI assumes its own default" behaviour stands. + const contextLength = resolveCustomModelContextLength(endpoint, body.modelId); const slotCount = endpoint.modelSlotCounts?.[body.modelId]; + // A CLI whose config alone cannot select the model also gets its `model` launch param + // forced (pi/omp `custom/<id>`, grok's block name, and codex now carries its bare + // selected id too). The argv engine DROPS a token that + // fails its pattern rather than quoting it, which would silently launch the CLI on its + // own default provider again, so refuse an id the pattern cannot carry up front. + // The check runs on the PURE build result, before applyCustomModelInjection below: + // the forced launch id only comes out of that build, and writing first would mean a + // refused id already replaced the config dir a live codex session uses as CODEX_HOME. + // It also runs before the two confirmations, so nobody agrees to evict a model only + // to be told the id was never usable. + const modelSpec = entry.launch.params.model; + const injection = buildCustomModelInjection(entry, endpoint, body.modelId, contextLength); + const forcedLaunchModel = injection.kind === 'unsupported' ? undefined : injection.launchModel; + if ( + forcedLaunchModel !== undefined && + modelSpec?.type === 'token' && + !matchesPattern(modelSpec.pattern, forcedLaunchModel) + ) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + `Model id ${JSON.stringify(body.modelId)} cannot be passed to ${session.mode} on its command line` + ); + } + // Some CLIs (today: only claude) carry enough of their own fixed system-prompt/tool- // schema overhead that a small enough real context guarantees a first-message failure // no matter what CLAUDE_CODE_MAX_CONTEXT_TOKENS says — confirmed live at ~36.4K tokens @@ -1605,33 +1668,18 @@ export function registerSessionRoutes( } } - // A CLI whose config alone cannot select the model also gets its `model` launch param - // forced (pi/omp `custom/<id>`, grok's block name). The argv engine DROPS a token that - // fails its pattern rather than quoting it, which would silently launch the CLI on its - // own default provider again, so refuse an id the pattern cannot carry up front. - const modelSpec = entry.launch.params.model; const applied = applyCustomModelInjection( entry, endpoint, body.modelId, session.id, contextLength, - session.workingDir + session.workingDir, + await guardSessionConfigDirHome(entry, (envVar) => session.envOverrideValue(envVar)) ); if (!applied) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${session.mode} has no known custom-model mechanism`); } - if ( - applied.launchModel !== undefined && - modelSpec?.type === 'token' && - !matchesPattern(modelSpec.pattern, applied.launchModel) - ) { - removeConfigDir(applied.configDir); - return createErrorResponse( - ApiErrorCode.INVALID_INPUT, - `Model id ${JSON.stringify(body.modelId)} cannot be passed to ${session.mode} on its command line` - ); - } const { previousConfigDir } = session.setCustomModel( { @@ -1681,7 +1729,8 @@ export function registerSessionRoutes( // Called for its existence/ownership 404 side effect only — demoteOrRemoveSession // below re-looks-up the record by id, so the returned SessionState is unused here. findPersistedSessionOrFail(ctx.store, id, req); - ctx.store.demoteOrRemoveSession(id); + // The edit history goes with the record (same rule as `cleanupSession`). + if (ctx.store.demoteOrRemoveSession(id) === 'removed') ctx.forgetEditHistory(id); // A detached session kept its agent inbox (server.ts _doCleanupSession drops it // only on killMux); retiring the row is the delete, so the mail goes now. agentInbox.drop(id); @@ -2077,6 +2126,32 @@ export function registerSessionRoutes( } } + // A guarded submit (agent-staging.ts) holds the input lease for the few + // milliseconds of its conditional Enter, and input that arrives meanwhile is held + // and written in arrival order after it. A TAGGED request is refused for that span + // (the lease plus the held input's drain) rather than overtake held input: say so + // instead of answering 200 for bytes that were never written, and give the seq + // back so the browser's durable queue (which keeps a frame on any 409, and whose + // POST drainer sends one frame at a time) delivers it on its next retry. An + // UNTAGGED request has no retry behind it (the phone accessory keys and the Stop + // button's Ctrl+C are fire-and-forget POSTs that never read the answer), so it is + // held instead, like a WebSocket frame: `writeViaMux` takes its own turn behind + // the lease, and the raw write below is held explicitly. Nothing from here to the + // write below awaits, so the lease cannot be taken in between. + const refuseGuarded = () => + reply + .code(409) + .send( + createErrorResponse( + ApiErrorCode.CONFLICT, + 'A guarded submit is in progress on this session; nothing was written. Retry the input.' + ) + ); + if (!duplicate && tagged && session.guardedInputHeld === true) { + session.forgetInputSeq(clientId as string, seq as number); + return refuseGuarded(); + } + // 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); @@ -2187,7 +2262,38 @@ export function registerSessionRoutes( // 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, { fromUser: true }); + // + // During a guarded submit (untagged only: a tagged one was refused above) the + // write is held and runs in arrival order once the lease is released. The answer + // is a 200 once held; a send-and-wait reads `delivered` from the held write. Only + // a full hold queue refuses, after releasing the waiter registered above. + let heldWrite: Promise<boolean> | null = null; + if (session.guardedInputHeld === true) { + let settle!: (written: boolean) => void; + const written = new Promise<boolean>((resolve) => { + settle = resolve; + }); + const held = session.holdForGuardedLease( + () => { + let ok = false; + try { + ok = session.write(inputStr, { fromUser: true }); + } finally { + settle(ok); + } + }, + inputStr.length, + inputStr + ); + if (held === 'full') { + abort.abort(); + stopDeathWatch(); + return refuseGuarded(); + } + if (held === 'held') heldWrite = written; + } + if (heldWrite && !waitPromise) return {}; + delivered = heldWrite ? await heldWrite : session.write(inputStr, { fromUser: true }); if (!delivered && tagged) { session.forgetInputSeq(clientId as string, seq as number); } @@ -2398,7 +2504,7 @@ export function registerSessionRoutes( // own `capabilities.newline`. Claude Code's Ink input recognizes 0x0a as "insert newline" vs // 0x0d (carriage return = submit). - app.post('/api/sessions/:id/send-key', async (req) => { + app.post('/api/sessions/:id/send-key', async (req, reply) => { const { id } = req.params as { id: string }; const body = req.body as Record<string, unknown>; const key = typeof body?.key === 'string' ? body.key : ''; @@ -2428,18 +2534,26 @@ export function registerSessionRoutes( try { // Route through the dedicated Codeman socket — bare `tmux` would target the // user's default server and never find this session (same #80 regression class). - await new Promise<void>((resolve, reject) => { - execFile( - 'tmux', - ['-L', ctx.mux.muxSocket, 'send-keys', '-H', '-t', muxName, ...hex], - { timeout: 5000 }, - (err) => { - if (err) reject(err); - else resolve(); - } - ); - }); + await session.performAuxiliaryInput( + () => + new Promise<void>((resolve, reject) => { + execFile( + 'tmux', + ['-L', ctx.mux.muxSocket, 'send-keys', '-H', '-t', muxName, ...hex], + { timeout: 5000 }, + (err) => { + if (err) reject(err); + else resolve(); + } + ); + }) + ); } catch (err) { + // A guarded submit's lease holds the key until its Enter is out; only a full + // hold queue refuses it, and that is a retryable conflict, not a tmux failure. + if (err instanceof GuardedInputBusyError) { + return reply.code(409).send(createErrorResponse(ApiErrorCode.CONFLICT, err.message)); + } console.error('[Server] send-key failed:', err); return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'tmux send-keys failed'); } @@ -2807,16 +2921,14 @@ export function registerSessionRoutes( return candidates[0] ?? null; } - app.get('/api/sessions/:id/last-response', async (req) => { - const { id } = req.params as { id: string }; - const session = findSessionOrFail(ctx, id, req); - + // The body of GET /api/sessions/:id/last-response, so the Discord bridge reads the same transcript + // the response viewer does (per-CLI reader, pane fallback) without a second copy of this logic. + const lastResponseFor = async (session: Session, context?: string) => { // Codex sessions don't write to ~/.claude/projects — their transcripts // live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the // response-viewer works for Codex panes too. if (getCli(session.mode)?.capabilities.transcript === 'codex-rollout') { - const codexQuery = req.query as { context?: string }; - return await readCodexLastResponse(session, codexQuery.context === 'full'); + return await readCodexLastResponse(session, context === 'full'); } // DeepSeek Harness writes a real structured transcript under @@ -2835,8 +2947,7 @@ export function registerSessionRoutes( // would starve on an answer that exists. Those configurations keep the // pane segmenter below: coarse, but the real conversation. if (getCli(session.mode)?.capabilities.transcript === 'deepseek-zstd' && !session.docker && !session.remote) { - const deepSeekQuery = req.query as { context?: string }; - const full = deepSeekQuery.context === 'full'; + const full = context === 'full'; const transcript = await readDeepSeekLastResponse(session, { blocks: full }); if (transcript) { return { @@ -2854,7 +2965,6 @@ export function registerSessionRoutes( // the pane IS the transcript for these CLIs. Codex is already handled above, // where a real rollout file is the better source. if (isExternalCliTranscriptMode(session.mode)) { - const externalQuery = req.query as { context?: string }; // A repainting full-screen TUI (copilot) streams absolute cursor moves, not lines, so the // buffer cannot be segmented; read the pane's visible screen instead, and fall back to the // buffer when there is no tmux pane to ask (direct PTY, tests). @@ -2867,7 +2977,7 @@ export function registerSessionRoutes( text: getLastTranscriptResponse(blocks), timestamp: '', hasContext: blocks.length > 0, - messages: externalQuery.context === 'full' ? blocks : undefined, + messages: context === 'full' ? blocks : undefined, }; } @@ -2893,11 +3003,10 @@ export function registerSessionRoutes( } } - const query = req.query as { context?: string }; // `turn` is the brief view's context: the last ANSWERED turn's assistant // messages, so a multi-row answer is not reduced to its final row. `text` // stays the last assistant row in every mode (agent pollers hash it). - const wantsMessages = query.context === 'full' || query.context === 'turn'; + const wantsMessages = context === 'full' || context === 'turn'; const claudeSessionId = session.claudeSessionId || session.id; const transcript = await findClaudeTranscript(projectsDir, claudeSessionId, session.id); if (!transcript) { @@ -2918,7 +3027,7 @@ export function registerSessionRoutes( try { const content = await fs.readFile(transcript.path, 'utf8'); const parsed = parseClaudeResponseTranscript(content, wantsMessages); - if (query.context === 'turn') { + if (context === 'turn') { return { text: parsed.text, timestamp: parsed.timestamp, @@ -2929,6 +3038,13 @@ export function registerSessionRoutes( } catch { return wantsMessages ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' }; } + }; + ctx.lastResponseFor = lastResponseFor; + + app.get('/api/sessions/:id/last-response', async (req) => { + const { id } = req.params as { id: string }; + const session = findSessionOrFail(ctx, id, req); + return lastResponseFor(session, (req.query as { context?: string }).context); }); function isCodexInjectedContext(text: string): boolean { @@ -3608,6 +3724,40 @@ export function registerSessionRoutes( }; }); + // ========== Git fetch on an interval ========== + + // Per-session override for the fetch interval (src/git-fetch-scheduler.ts), which is what the Session + // Options rows say it is: two sessions on one folder can hold different choices, and that folder is + // fetched when the first of them is due. `null` on either field clears this session's choice so the + // app-wide setting applies again, and a field left out of the body leaves the other half as it was: the + // Session Options rows save on their own. Refused for a remote or Docker session, exactly as the Git + // panel excludes them: a container can write to the repository it mounts, so git there would run that + // repository's own configuration on the host. + app.post('/api/sessions/:id/git-fetch-settings', async (req) => { + const { id } = req.params as { id: string }; + const body = parseBody(GitFetchSettingsSchema, req.body, 'Invalid request body'); + const session = findSessionOrFail(ctx, id, req); + if (session.remote || session.docker) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git fetch does not run for remote or Docker sessions'); + } + + const enabled = 'enabled' in body ? (body.enabled ?? undefined) : session.gitFetchEnabled; + const intervalSeconds = + 'intervalSeconds' in body ? (body.intervalSeconds ?? undefined) : session.gitFetchIntervalSeconds; + session.setGitFetchSettings(enabled, intervalSeconds); + persistAndBroadcastSession(ctx, session); + + return { + success: true, + data: { + gitFetchSettings: { + enabled: session.gitFetchEnabled ?? null, + intervalSeconds: session.gitFetchIntervalSeconds ?? null, + }, + }, + }; + }); + // ========== Auto-Resume (usage-limit pause) ========== app.post('/api/sessions/:id/auto-resume', async (req) => { @@ -3679,6 +3829,11 @@ export function registerSessionRoutes( const { id } = req.params as { id: string }; const body = parseBody(ImageWatcherSchema, req.body, 'Invalid request body'); const session = findSessionOrFail(ctx, id, req); + // A remote session's workingDir is a path on the remote host; the watcher would watch it on the + // LOCAL disk (a missing folder, or a same-named local one whose images are not the session's). + if (body.enabled && session.remote) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Image watching does not run for remote sessions'); + } if (body.enabled) { imageWatcher.watchSession(session.id, session.workingDir); @@ -4341,9 +4496,28 @@ export function registerSessionRoutes( const cmHosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR); const cmEndpoint = cmHosts.find((h) => h.id === customModel.endpointId); if (!cmEndpoint) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found'); - const cmContextLength = cmEndpoint.modelContextLengths?.[customModel.modelId]; + // Per-model discovery figure first, user-set endpoint default second, nothing third + // (see the dedicated route's own comment above). + const cmContextLength = resolveCustomModelContextLength(cmEndpoint, customModel.modelId); const cmSlotCount = cmEndpoint.modelSlotCounts?.[customModel.modelId]; + // See the dedicated route: refused on the pure build, before either confirmation, so + // nobody agrees to evict a model for an id that could never launch, and before anything + // is written. + const cmModelSpec = cmEntry.launch.params.model; + const cmInjection = buildCustomModelInjection(cmEntry, cmEndpoint, customModel.modelId, cmContextLength); + const cmForcedLaunchModel = cmInjection.kind === 'unsupported' ? undefined : cmInjection.launchModel; + if ( + cmForcedLaunchModel !== undefined && + cmModelSpec?.type === 'token' && + !matchesPattern(cmModelSpec.pattern, cmForcedLaunchModel) + ) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + `Model id ${JSON.stringify(customModel.modelId)} cannot be passed to ${mode} on its command line` + ); + } + // See the dedicated route's own comment for the full reasoning: some CLIs' own fixed // overhead can exceed a small enough real context on the very first message, // regardless of contextLengthVar. Warn before creating a session that's certain to @@ -4408,24 +4582,12 @@ export function registerSessionRoutes( customModel.modelId, qsCustomModelSessionId, cmContextLength, - workspacePath + workspacePath, + await guardSessionConfigDirHome(cmEntry, (envVar) => qsGatedEnvOverrides?.[envVar]) ); if (!cmApplied) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`); } - const cmModelSpec = cmEntry.launch.params.model; - if ( - cmApplied.launchModel !== undefined && - cmModelSpec?.type === 'token' && - !matchesPattern(cmModelSpec.pattern, cmApplied.launchModel) - ) { - removeConfigDir(cmApplied.configDir); - return createErrorResponse( - ApiErrorCode.INVALID_INPUT, - `Model id ${JSON.stringify(customModel.modelId)} cannot be passed to ${mode} on its command line` - ); - } - qsCustomModelEnvOverrides = { ...qsGatedEnvOverrides, ...cmApplied.envOverrides }; qsCustomModelAppliedEnvOverrides = cmApplied.envOverrides; qsCustomModelLaunchModel = cmApplied.launchModel; @@ -4576,28 +4738,14 @@ export function registerSessionRoutes( // settings.json, readable by every user through GET /api/settings, so a non-admin's // case name is not written there in multi-user mode (writes are admin-only, like // PUT /api/settings). isAdmin is always true in single-user mode. + // Awaited and serialized with every other settings.json writer (mutateSettingsFile): the + // un-awaited write this replaced could land after the response, over a settings save. if (isAdmin(req)) { try { - const settingsFilePath = SETTINGS_PATH; - let settings: Record<string, unknown> = {}; - try { - settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8')); - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; - } - settings.lastUsedCase = caseName; - const dir = dirname(settingsFilePath); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - // Use async write to avoid blocking event loop - fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => { - // Non-critical but log for debugging - console.warn('[Server] Failed to save settings (lastUsedCase):', err); - }); + await mutateSettingsFile((settings) => ({ ...settings, lastUsedCase: caseName })); } catch (err) { - // Non-critical but log for debugging - console.warn('[Server] Failed to prepare settings update:', err); + // Non-critical: the session is already running. + console.warn('[Server] Failed to save settings (lastUsedCase):', getErrorMessage(err)); } } diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 1ebbef00..4623d75f 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -15,7 +15,7 @@ import { randomBytes } from 'node:crypto'; import { dataPath } from '../../config/instance.js'; import { readHostSensors } from '../../host-sensors.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js'; -import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js'; +import { isExplicitlyEnabled, isUnauthenticatedTunnelAcknowledged } from '../network-auth-policy.js'; import { isMultiUserMode } from '../../config/multiuser.js'; import { findUser, canUsernameRunPrivilegedCommands } from '../../user-store.js'; import { getAuthUser, isAdmin, requireAdmin, canAccessOwned, revokeUserSessions } from '../route-helpers.js'; @@ -48,6 +48,7 @@ import { toggleService, SETTINGS_PATH, } from '../route-helpers.js'; +import { mutateSettingsFile, settingsWriteErrorResponse } from '../settings-writer.js'; import { SseEvent } from '../sse-events.js'; import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js'; @@ -177,6 +178,43 @@ export function settingsForNonAdmin(settings: unknown): unknown { return out; } +/** + * True when a login protects the server: CODEMAN_PASSWORD is set, or multi-user mode is + * on (every person has their own credential). The tunnel guard and the `loginConfigured` + * field of GET /api/tunnel/status both read this, so the page's answer mirrors the guard. + */ +function isLoginConfigured(): boolean { + return !!process.env.CODEMAN_PASSWORD || isMultiUserMode(); +} + +/** + * The refusal of a tunnel enable with no login and no tunnel opt-in or acknowledgement: logs + * the diagnostic line and returns the message for the 403. `late` is the case where the tunnel + * was running when the request arrived and had stopped by the time it would be started; the + * request's other settings were saved by then. + */ +function unauthenticatedTunnelRefusal(late: boolean): string { + // Someone who set the network variable to get here before #706 is told why it stopped working. + const networkAcknowledged = isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK); + console.warn( + `[tunnel] Refused an unauthenticated tunnel ${late ? 'restart after a concurrent stop' : 'enable'} (no login, no tunnel opt-in, no acknowledgement${networkAcknowledged ? '; CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK does not cover the tunnel' : ''})` + ); + return ( + (late + ? 'The Cloudflare tunnel stopped while these settings were being saved and was not restarted; ' + + 'the other settings were saved. ' + : '') + + 'Refusing to start the Cloudflare tunnel without authentication: it would publish ' + + 'full terminal control to a public URL with no password. Set CODEMAN_PASSWORD to ' + + 'require login, set CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1, or resend with ' + + 'acknowledgeUnauthTunnel:true to acknowledge an unauthenticated public tunnel.' + + (networkAcknowledged + ? ' CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK is set, but it only acknowledges the network ' + + 'bind and no longer covers the tunnel.' + : '') + ); +} + export function registerSystemRoutes( app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort & KeepWarmPort @@ -194,7 +232,14 @@ export function registerSystemRoutes( // ========== Tunnel ========== - app.get('/api/tunnel/status', async () => ctx.tunnelManager.getStatus()); + // `loginConfigured` (#706) tells the page whether a tunnel would have a login in front + // of it, so it can ask before an enable even when the server would accept one. It is + // the guard's own login test (isLoginConfigured above), never TunnelManager's, which + // knows nothing about auth. + app.get('/api/tunnel/status', async () => ({ + ...ctx.tunnelManager.getStatus(), + loginConfigured: isLoginConfigured(), + })); app.get('/api/tunnel/info', async (req) => { const status = ctx.tunnelManager.getStatus(); @@ -811,6 +856,7 @@ export function registerSystemRoutes( // Keeping a pane that died while detached errs toward the 12 h TTL and the boot // prune, the same trade the boot keep set makes. if (!ctx.mux.getSession(s.id)) agentInbox.drop(s.id); + ctx.forgetEditHistory(s.id); } return { cleanedSessions: result.count }; }); @@ -1032,11 +1078,10 @@ export function registerSystemRoutes( app.get('/api/settings', async (req) => { // A plain read. This route must NEVER write settings.json: readJsonConfig() - // answers `{}` for ANY read failure (a parse error, EACCES, EMFILE, a read - // that lands inside PUT's non-atomic write), not only for a missing file, - // and every page load calls this route, so a "persist the default when the - // key is absent" reconcile here replaced a whole settings file with one key - // on the first unlucky read. The plan-usage default is resolved by the + // answers `{}` for ANY read failure (a parse error, EACCES, EMFILE), not + // only for a missing file, and every page load calls this route, so a + // "persist the default when the key is absent" reconcile here replaced a + // whole settings file with one key on the first unlucky read. The plan-usage default is resolved by the // READERS instead: an absent `showPlanUsageLimits` means ON to // readPlanUsageTelemetryEnabled() (hooks-config.ts), the same way an absent // `workspaceHooksEnabled` means ON, and the client resolves its own display @@ -1063,54 +1108,41 @@ export function registerSystemRoutes( // tunnel binds to loopback, server.ts's non-loopback bind guard never trips, and // with no CODEMAN_PASSWORD the auth middleware is inactive — so the tunnel URL is // unauthenticated. Refuse to start a tunnel unless auth is configured OR exposure - // is acknowledged: either the CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var, or an + // is acknowledged: either the CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL opt-in, or an // explicit per-request `acknowledgeUnauthTunnel:true` (the UI sends this after a // confirm dialog). This keeps curl/API/CLI callers protected by default while // letting an operator opt in from the browser without setting the env var. // Guard runs BEFORE persisting so a refused tunnelEnabled:true is not saved. - if (settings.tunnelEnabled === true && !ctx.tunnelManager.isRunning()) { - // Multi-user mode makes the tunnel authenticated (every person has their own - // credential), so it satisfies the same requirement as CODEMAN_PASSWORD. - const acknowledged = - isMultiUserMode() || isUnauthenticatedNetworkAcknowledged() || settings.acknowledgeUnauthTunnel === true; - if (!acknowledged) { - const msg = - 'Refusing to start the Cloudflare tunnel without authentication: it would publish ' + - 'full terminal control to a public URL with no password. Set CODEMAN_PASSWORD to ' + - 'require login, set CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1, or resend with ' + - 'acknowledgeUnauthTunnel:true to acknowledge an unauthenticated public tunnel.'; - throw Object.assign(new Error(msg), { - statusCode: 403, - body: createErrorResponse(ApiErrorCode.OPERATION_FAILED, msg), - }); - } - // Loud warning whenever a public tunnel is started with no password — whether - // acknowledged via env var or the per-request UI confirmation. - if (!process.env.CODEMAN_PASSWORD) { - console.warn( - '⚠️ [tunnel] Starting an UNAUTHENTICATED public Cloudflare tunnel — no CODEMAN_PASSWORD set. ' + - 'Anyone with the tunnel URL gets full terminal control (effectively RCE). ' + - 'Set CODEMAN_PASSWORD to require login.' - ); - } + // The tunnel opt-in is CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL / --allow-unauthenticated-tunnel, + // never the network one (#706): install.sh writes CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK + // for every passwordless LAN install, and that acknowledges the LAN bind only. + // Decided ONCE per request, whether or not the tunnel is running now, and required again + // where the tunnel is really started below: the settings file awaits in between leave room + // for a concurrent stop, and a save that found the tunnel running (so nothing was refused + // here) must not then restart it unguarded. + const tunnelEnableAuthorized = + settings.tunnelEnabled === true && + (isLoginConfigured() || + isUnauthenticatedTunnelAcknowledged(ctx.allowUnauthenticatedTunnel) || + settings.acknowledgeUnauthTunnel === true); + if (settings.tunnelEnabled === true && !tunnelEnableAuthorized && !ctx.tunnelManager.isRunning()) { + const msg = unauthenticatedTunnelRefusal(false); + throw Object.assign(new Error(msg), { + statusCode: 403, + body: createErrorResponse(ApiErrorCode.OPERATION_FAILED, msg), + }); } try { - const dir = dirname(SETTINGS_PATH); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - let existing: Record<string, unknown> = {}; - try { - existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8')); - } catch { - /* ignore */ - } // acknowledgeUnauthTunnel is an ACTION field (not a stored setting) — strip // it before persisting so settings.json stays clean. const { acknowledgeUnauthTunnel, ...settingsToStore } = settings; + // An enable that is not authorized only got this far because the tunnel was running. + // It must never write tunnelEnabled:true: a concurrent stop has written false by now, + // and true on disk would show a stopped tunnel as on. The stored value stays as it is. + if (settings.tunnelEnabled === true && !tunnelEnableAuthorized) delete settingsToStore.tunnelEnabled; + const { before: existing } = await mutateSettingsFile((current) => ({ ...current, ...settingsToStore })); const merged = { ...existing, ...settingsToStore }; - await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2)); // tmux 3.7+ resizes tracked panes; older versions apply this to new panes. // Already-evicted history cannot be recovered on either version. @@ -1152,9 +1184,10 @@ export function registerSystemRoutes( // Handle image watcher toggle dynamically toggleService((merged.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => { - // Re-watch all active sessions that have image watcher enabled + // Re-watch all active sessions that have image watcher enabled (never a remote session: its + // workingDir is a path on the remote host, not on this disk) for (const session of ctx.sessions.values()) { - if (session.imageWatcherEnabled) { + if (session.imageWatcherEnabled && !session.remote) { imageWatcher.watchSession(session.id, session.workingDir); } } @@ -1182,6 +1215,20 @@ export function registerSystemRoutes( if ('tunnelEnabled' in settings) { const tunnelEnabled = settings.tunnelEnabled as boolean; if (tunnelEnabled && !ctx.tunnelManager.isRunning()) { + // The tunnel stopped since the guard above saw it running: the same authorization applies. + if (!tunnelEnableAuthorized) { + reply.code(403); + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, unauthenticatedTunnelRefusal(true)); + } + // Loud warning whenever a public tunnel is started with no login in front of it, whether + // acknowledged via the tunnel opt-in or the per-request UI confirmation. + if (!isLoginConfigured()) { + console.warn( + '⚠️ [tunnel] Starting an UNAUTHENTICATED public Cloudflare tunnel — no CODEMAN_PASSWORD set. ' + + 'Anyone with the tunnel URL gets full terminal control (effectively RCE). ' + + 'Set CODEMAN_PASSWORD to require login.' + ); + } ctx.tunnelManager.start(ctx.port, ctx.https); console.log('Tunnel started via settings change'); } else if (tunnelEnabled && ctx.tunnelManager.isRunning() && ctx.tunnelManager.getUrl()) { @@ -1196,7 +1243,7 @@ export function registerSystemRoutes( return {}; } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + return settingsWriteErrorResponse(err); } }); @@ -1214,25 +1261,10 @@ export function registerSystemRoutes( const modelConfig = parseBody(ModelConfigUpdateSchema, req.body, 'Invalid model config') as Record<string, unknown>; try { - let existingSettings: Record<string, unknown> = {}; - try { - const content = await fs.readFile(SETTINGS_PATH, 'utf-8'); - existingSettings = JSON.parse(content); - } catch { - // File doesn't exist yet, start fresh - } - - existingSettings.modelConfig = modelConfig; - - const dir = dirname(SETTINGS_PATH); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - await fs.writeFile(SETTINGS_PATH, JSON.stringify(existingSettings, null, 2)); - + await mutateSettingsFile((current) => ({ ...current, modelConfig })); return {}; } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + return settingsWriteErrorResponse(err); } }); diff --git a/src/web/routes/ws-routes.ts b/src/web/routes/ws-routes.ts index 19c2ddca..99a86691 100644 --- a/src/web/routes/ws-routes.ts +++ b/src/web/routes/ws-routes.ts @@ -29,6 +29,11 @@ * {"t":"i","d":"...","seq":N,"cid":"..."} — input (keystroke or paste). seq+cid are * optional reliable-delivery tags: the server applies each * (cid,seq) at-most-once and ACKs with {"t":"ia","seq":N}. + * A frame that arrives while a guarded submit holds the + * session's input lease is held, then applied and ACKed in + * arrival order once it is released; when that hold queue + * is full the socket is closed with 1013 and nothing later + * from it is applied, so the client resends in order. * {"t":"z","c":N,"r":N,"f":bool} — resize terminal (f=true forces SIGWINCH even if dims unchanged) */ @@ -169,12 +174,69 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost const sizingToken = Symbol('ws-desktop-sizing'); let holdsDesktopClaim = false; + // Set once an input frame could not be held behind a guarded submit's lease + // (the hold queue was full). Nothing later from this socket is applied: it is + // closed, and the browser reconnects and resends everything unACKed, in order. + let inputStalled = false; + + // Apply one input frame and ACK it. Runs inline, or later and in arrival order + // when a guarded submit held the input lease as the frame arrived. + const applyInputFrame = (msg: { d: string; seq?: unknown; cid?: unknown }) => { + // Reliable delivery: when the frame carries a clientId + seq, apply it + // exactly once (skip a duplicate redelivery) but ACK it regardless so + // the client can drop it from its durable queue. Frames without seq + // (legacy/other tools) are applied as-is — no behavior change. + 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(); + // Browser keystrokes are the user's own, so they may name the tab. + delivered = session.write(msg.d, { fromUser: true }); + // 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 (apply) { + if (delivered) socket.send(`{"t":"ia","seq":${seq}}`); + } else { + // REJECTED as a duplicate. ACK it — the client must still drop it + // from its durable queue — but say so, and hand back our watermark. + // + // A plain ACK here is indistinguishable from "applied", which is + // what made a client with a rolled-back counter unrecoverable: its + // seqs persist to localStorage on a DEBOUNCED write, so a tab killed + // between a send and that write comes back with a counter BELOW this + // watermark, every later keystroke lands at or under it, and each one + // is dropped-but-ACKed. The UI stays clean, nothing is delivered, and + // a reload restores the same stale counter. `last` is what lets the + // client lift itself out. + // ⚠️ Defensive: the session arrives through a structural port, and an + // implementation without this method must not take the whole input + // path down with it — a throw here aborts the message handler and the + // frame is never ACKed at all, which strands it in the client's queue. + const watermark = + typeof (session as { lastInputSeq?: (c: string) => number }).lastInputSeq === 'function' + ? (session as { lastInputSeq: (c: string) => number }).lastInputSeq(cid as string) + : seq; + socket.send(`{"t":"ia","seq":${seq},"dup":true,"last":${watermark}}`); + } + } + }; + // Attach message handler synchronously BEFORE any async work // (@fastify/websocket requirement to avoid dropped messages). socket.on('message', (raw) => { try { const msg = JSON.parse(String(raw)); if (msg.t === 'i' && typeof msg.d === 'string') { + if (inputStalled) return; if (msg.d.length > MAX_INPUT_LENGTH) { // Refused for good, so say so: a silent return left the frame // unACKed and the client redelivered it every few seconds forever @@ -185,51 +247,27 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost } return; } - // Reliable delivery: when the frame carries a clientId + seq, apply it - // exactly once (skip a duplicate redelivery) but ACK it regardless so - // the client can drop it from its durable queue. Frames without seq - // (legacy/other tools) are applied as-is — no behavior change. - 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(); - // Browser keystrokes are the user's own, so they may name the tab. - delivered = session.write(msg.d, { fromUser: true }); - // 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 (apply) { - if (delivered) socket.send(`{"t":"ia","seq":${seq}}`); - } else { - // REJECTED as a duplicate. ACK it — the client must still drop it - // from its durable queue — but say so, and hand back our watermark. - // - // A plain ACK here is indistinguishable from "applied", which is - // what made a client with a rolled-back counter unrecoverable: its - // seqs persist to localStorage on a DEBOUNCED write, so a tab killed - // between a send and that write comes back with a counter BELOW this - // watermark, every later keystroke lands at or under it, and each one - // is dropped-but-ACKed. The UI stays clean, nothing is delivered, and - // a reload restores the same stale counter. `last` is what lets the - // client lift itself out. - // ⚠️ Defensive: the session arrives through a structural port, and an - // implementation without this method must not take the whole input - // path down with it — a throw here aborts the message handler and the - // frame is never ACKed at all, which strands it in the client's queue. - const watermark = - typeof (session as { lastInputSeq?: (c: string) => number }).lastInputSeq === 'function' - ? (session as { lastInputSeq: (c: string) => number }).lastInputSeq(cid as string) - : seq; - socket.send(`{"t":"ia","seq":${seq},"dup":true,"last":${watermark}}`); - } + // A guarded submit (agent-staging.ts) holds the input lease across its + // awaited Enter, so frames keep arriving while it is held. Refusing one + // lost it: this path does not wait for ACKs, so the next seq landed after + // the lease, moved the watermark past the refused one, and its redelivery + // was then dropped as a duplicate. Hold the frame instead; the session runs + // it, ACK included, in arrival order the moment the lease is released. + // ⚠️ Defensive, like `lastInputSeq` below: the session arrives through a + // structural port, and one without this method must still deliver input + // (a throw here would drop the frame unACKed). + const held = + typeof session.holdForGuardedLease === 'function' + ? session.holdForGuardedLease(() => applyInputFrame(msg), msg.d.length, msg.d) + : 'free'; + if (held === 'free') { + applyInputFrame(msg); + } else if (held === 'full') { + // Not held, not applied, not ACKed, and no later frame from this socket + // is applied either, so nothing can overtake it. + inputStalled = true; + console.warn('[ws] input backlog during a guarded submit; closing for redelivery', { sessionId: id }); + socket.close(1013, 'Input backlog during guarded submit'); } } else if ( msg.t === 'z' && diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 8336a2e3..5c72e74a 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -20,6 +20,7 @@ import { import { MAX_EDITABLE_BYTES } from '../config/file-editing.js'; import { CODEX_REASONING_EFFORTS } from '../types/session.js'; import { WEBHOOK_KINDS, WEBHOOK_SCOPES } from '../types/push.js'; +import { DISCORD_ON_DELETE, DISCORD_PING_MODES, DISCORD_THREAD_CREATION } from '../types/discord.js'; import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js'; import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js'; import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js'; @@ -82,6 +83,35 @@ const filesystemPickerPathSchema = z */ const showHiddenQuerySchema = z.enum(['true', 'false']).optional(); +/** + * Body for the path picker's "New folder": one folder name created directly inside `path`. The name is held + * to the character set `safePathSchema` accepts (letters, digits, `_ - . ~` and space, no `..`), so every + * name it accepts is one Link Existing and a session working directory accept (the parent's own path is + * judged there separately), and never a control character. It is also one path segment, not dot-prefixed (the picker hides those by default) and not tilde-prefixed (a folder named `~` is an `rm -rf ~` accident), and at + * most 255 BYTES, the usual filesystem limit. + */ +export const FilesystemMkdirSchema = z.object({ + path: filesystemPickerPathSchema, + name: z + .string() + .trim() + .min(1, 'Folder name is required') + .refine((n) => Buffer.byteLength(n) <= 255, { message: 'Folder name is too long' }) + .refine((n) => /^[\p{L}\p{N}_\-. ~]+$/u.test(n), { + message: 'Folder name can only use letters, numbers, spaces and _ - . ~', + }) + .refine((n) => !n.includes('..') && !n.startsWith('.') && !n.startsWith('~'), { + message: 'Folder name cannot contain "..", or start with a dot or a tilde', + }), + sessionId: z + .string() + .max(100) + .regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id') + .optional(), + /** Same opt-in flag as browse: lets the picker create inside a hidden folder it is showing. */ + showHidden: showHiddenQuerySchema, +}); + /** Query validation for the lazy, allowlisted filesystem path picker. */ export const FilesystemBrowseQuerySchema = z.object({ path: filesystemPickerPathSchema.optional(), @@ -785,6 +815,12 @@ export const CloneCaseSchema = z.object({ /** `--depth 1`. */ shallow: z.boolean().optional(), description: z.string().max(1000).optional(), + /** + * Clone into this exact folder (the folder itself, no case-name subfolder is added) instead of under the cases directory, and + * register it as a linked case. Absolute or starting with `~`; only length-bounded here, since + * `prepareNewCasePath()` (web/case-path.ts) judges it and produces the user-facing reason. + */ + path: z.string().min(1).max(1000).optional(), }); /** Schema for POST /api/cases/clone-preflight — ask the remote what it has, clone nothing. */ @@ -1488,6 +1524,19 @@ export const SettingsUpdateSchema = z promptCacheKeepWarm: z.boolean().optional(), /** Hours after the last real prompt during which keep-alives are sent (default 2, DEFAULT_KEEP_WARM_MAX_HOURS). */ promptCacheKeepWarmMaxHours: z.number().min(0.5).max(24).optional(), + /** + * Git fetch on an interval (src/git-fetch-scheduler.ts): `git fetch` for the repositories the Git panel + * lists, run on a timer instead of only on the panel's Fetch click. SYNCED, default OFF, because the + * server is what runs it: `git fetch` is the only git command the Git panel runs that writes (cloning + * and worktree management write too, but only when the user asked for them at the moment they asked), and + * the only one that can run later by itself, on a timer, and the only one that can run a repository's own + * hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config + * commands such as `credential.helper`), so nothing runs on a timer until the user turns this on. Cap in seconds between runs (30 to 3600, default 300). A + * session can override both in Session Options → Git; `null` there clears the override, and sessions + * sharing one folder are fetched by one run. + */ + gitFetchEnabled: z.boolean().optional(), + gitFetchIntervalSeconds: z.number().int().min(30).max(3600).optional(), /** * Spawned tabs follow their parent: a session created by another one keeps * following its parent's tab (into its group, moving with it) until moved by @@ -1544,6 +1593,13 @@ export const SettingsUpdateSchema = z * inboxes stay). On, pending messages between agents survive a server restart. */ agentInboxPersistEnabled: z.boolean().optional(), + /** + * Experimental agent coordination (src/web/routes/agent-session-routes.ts): bounded + * inspection and the guarded stage/submit routes behind `codeman agent inspect|stage|submit`. + * SYNCED, default OFF: the routes are labeled experimental and may change in a minor or + * patch release, so automation has to opt in. While OFF, all three routes answer 403. + */ + agentCoordinationEnabled: z.boolean().optional(), /** * Read My Mind predictor model override. Empty/absent = the AI-checker * default (opus: prediction quality is the product and it runs only on an @@ -1554,7 +1610,7 @@ export const SettingsUpdateSchema = z // Action field (NOT persisted): explicit per-request acknowledgment that the // operator accepts exposing an UNAUTHENTICATED public tunnel (no CODEMAN_PASSWORD). // Lets the UI enable a tunnel after a confirm dialog without the - // CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting. + // CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL opt-in. Stripped before persisting. acknowledgeUnauthTunnel: z.boolean().optional(), tabTwoRows: z.boolean().optional(), /** @@ -1928,6 +1984,15 @@ export const AutoResumeSchema = z.object({ /** `POST /api/sessions/:id/keep-warm`: `null` clears the session's choice so the app-wide default applies. */ export const KeepWarmSchema = z.object({ enabled: z.boolean().nullable() }); +/** + * `POST /api/sessions/:id/git-fetch-settings`: `null` clears the session's choice so the app-wide default + * applies. Both fields are optional, so a row that saves on its own does not clear the other. + */ +export const GitFetchSettingsSchema = z.object({ + enabled: z.boolean().nullable().optional(), + intervalSeconds: z.number().int().min(30).max(3600).nullable().optional(), +}); + /** POST /api/sessions/:id/pin (COD-139) — explicit pin state for idempotency. */ export const PinSessionSchema = z.object({ pinned: z.boolean(), @@ -2185,6 +2250,37 @@ export const WebhookUpdateSchema = z }) .strict(); +/** + * PUT /api/discord. `.strict()` and every field optional, so a change of one option never needs the + * token re-sent; an empty `token` clears it. Option lists are the bridge's own, so the schema cannot + * accept a value the store would coerce away. Ids are checked again (as snowflakes) by the route. + */ +export const DiscordUpdateSchema = z + .object({ + enabled: z.boolean().optional(), + token: z.string().max(200).optional(), + guildId: z.string().max(25).optional(), + parentChannelId: z.string().max(25).optional(), + allowedUserIds: z.array(z.string().max(25)).max(20).optional(), + threadCreation: z.enum(DISCORD_THREAD_CREATION).optional(), + onSessionDelete: z.enum(DISCORD_ON_DELETE).optional(), + staleThreads: z.enum(DISCORD_ON_DELETE).optional(), + pingMode: z.enum(DISCORD_PING_MODES).optional(), + scope: z.enum(WEBHOOK_SCOPES).optional(), + startupHint: z.boolean().optional(), + }) + .strict(); + +/** POST /api/sessions/:id/discord-notify */ +export const DiscordNotifySchema = z + .object({ + message: z.string().min(1).max(1500), + urgent: z.boolean().optional(), + /** Absolute path of a file inside the session's working folder, uploaded with the post. */ + file: z.string().min(1).max(1024).optional(), + }) + .strict(); + // ========== Ralph Loop ========== /** POST /api/ralph-loop/start */ @@ -2323,6 +2419,14 @@ export const WebviewUpdateSchema = WebviewBaseSchema.partial(); /** POST /api/webviews/probe: reachability + framing check for the editor's Test button. */ export const WebviewProbeSchema = z.object({ url: webviewUrlSchema }); +/** + * Ceiling on a stated context length (tokens), shared between the bounds below and + * discovery (`custom-model-routes.ts`): anything above it reads as nonsense and is + * ignored, because a bogus figure here overflows the smaller server, which is the exact + * failure the discovery field exists to prevent. + */ +export const MAX_DISCOVERED_CONTEXT_TOKENS = 100_000_000; + // Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) — a // user-configured custom OpenAI-compatible endpoint, local (llama.cpp) or cloud // (Azure AI Foundry, etc.). Lives below `webviewUrlSchema` because `baseUrl` IS that @@ -2346,11 +2450,16 @@ export const CustomModelHostSchema = z.object({ defaultModelId: z.string().max(200).optional(), // Server-populated by discovery (custom-model-routes.ts); accepted here only so a client // round-tripping the GET response back through PUT (edit-save) doesn't drop it. - modelContextLengths: z.record(z.string().max(200), z.number().int().positive().max(100_000_000)).optional(), + modelContextLengths: z + .record(z.string().max(200), z.number().int().positive().max(MAX_DISCOVERED_CONTEXT_TOKENS)) + .optional(), // Same reasoning as modelContextLengths above. modelSlotCounts: z.record(z.string().max(200), z.number().int().positive().max(4096)).optional(), // Same reasoning as modelContextLengths above. modelSizesGB: z.record(z.string().max(200), z.number().positive().max(100_000)).optional(), + // User-set fallback for models discovery has no context length for (session-routes.ts); + // discovery never writes it. Same bound as the modelContextLengths values above. + defaultContextLength: z.number().int().positive().max(MAX_DISCOVERED_CONTEXT_TOKENS).optional(), }); /** @@ -2410,3 +2519,15 @@ export const CustomModelSelectionSchema = z.union([ }), z.object({ clear: z.literal(true) }), ]); + +/** Agent inspection bounds are bytes of evidence, independent of the JSON envelope. */ +export const AgentInspectQuerySchema = z + .object({ + maxBytes: z.coerce.number().int().min(1).max(65536).default(8192), + since: z.string().uuid().optional(), + }) + .strict(); +export const AgentStageSchema = z.object({ message: z.string().min(1).max(8192) }).strict(); +export const AgentSubmitSchema = z + .object({ stageId: z.string().uuid(), confirmSha: z.string().regex(/^[a-f0-9]{64}$/) }) + .strict(); diff --git a/src/web/sensitive-path.ts b/src/web/sensitive-path.ts index a9499629..0189dbe4 100644 --- a/src/web/sensitive-path.ts +++ b/src/web/sensitive-path.ts @@ -102,6 +102,21 @@ const SENSITIVE_PATTERNS: RegExp[] = [ /\/\.codeman[^/]*\/intents\.json$/, // Pending agent-inbox messages (opt-in snapshot): same text as prompts, same 0600. /\/\.codeman[^/]*\/agent-inbox\.json$/, + // The edit history panel's stores (edit-history-store.ts) hold every diff an + // agent wrote, keys and .env contents included; written 0600 and kept out of + // /api/search for the same reason as intents.json. + /\/\.codeman[^/]*\/edit-history\//, + // Credentials kept outside settings.json: the Discord bot token, the webhook URL + // (a bearer secret), the custom endpoints' API keys, and the generated per-session + // configs, some of which carry that key inline. + /\/\.codeman[^/]*\/discord\.json$/, + /\/\.codeman[^/]*\/webhook\.json$/, + /\/\.codeman[^/]*\/custom-model-hosts\.json$/, + /\/\.codeman[^/]*\/custom-model-configs\//, + // Config backups are dated COPIES of the files above (users.json and settings.json + // included), which the name-anchored patterns do not match. A custom + // `configBackupDir` is outside what a pattern here can name. + /\/\.codeman[^/]*\/backups\//, ]; /** diff --git a/src/web/server.ts b/src/web/server.ts index 2b3e582c..64bebcf3 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -45,6 +45,8 @@ import { looksLikeHostReboot, newestPersistedActivity, planRebootRestore } from import { rebootRestoreRegistry } from './reboot-restore-registry.js'; import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js'; import { WebhookNotifier, readWebhookConfig } from '../webhook-notify.js'; +import { DiscordBridge, screenTail } from '../discord-bridge.js'; +import { isExternalCliTranscriptMode } from './response-viewer-transcript.js'; import { stopHostSensors } from '../host-sensors.js'; import type { WebhookUrgency } from '../types/push.js'; import { webviewFetch } from './webview-egress.js'; @@ -80,8 +82,13 @@ import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment import { folderGrants } from '../folder-listing.js'; import { getCli, enabledClis, listClis } from '../config/cli-registry/registry.js'; import { isCliEntryInstalled, probeStockCliAvailability } from '../utils/cli-installed-probes.js'; -import { readCustomModelHosts } from '../custom-model-hosts.js'; -import { applyCustomModelInjection, customModelConfigDir, removeConfigDir } from '../custom-model-injection-apply.js'; +import { readCustomModelHosts, resolveCustomModelContextLength } from '../custom-model-hosts.js'; +import { + applyCustomModelInjection, + customModelConfigDir, + removeConfigDir, + type AppliedCustomModel, +} from '../custom-model-injection-apply.js'; import type { CustomModelBookkeeping } from '../types/session.js'; import { registerGeneratedArtifactAttachment } from '../generated-artifact-attachments.js'; import { @@ -100,6 +107,7 @@ import { getLifecycleLog } from '../session-lifecycle-log.js'; import { applyWorkspaceHooks, pruneAgentSessionPreambles, + readGitFetchSettings, readKeepWarmSettingsOrThrow, readPlanUsageTelemetryEnabled, removeAgentSessionPreamble, @@ -123,8 +131,15 @@ import { AI_CHECK_MODEL } from '../config/ai-defaults.js'; import { approvalInbox } from './approval-inbox.js'; import { SCREEN_BLOCKED_SOURCE } from './screen-blocked.js'; import { PromptCacheKeepWarmService } from '../prompt-cache-keepwarm.js'; +import { fetchWorkspaceOnce } from '../git-fetch.js'; +import { GitFetchScheduler } from '../git-fetch-scheduler.js'; +import { probePathKind } from '../utils/bounded-path-probe.js'; +import { defaultDockerWorkspaces } from './routes/git-status-routes.js'; import { composerIsEmpty, plainFrame } from '../session-submit-verifier.js'; import { detectUsageLimitPause } from '../usage-limit-patterns.js'; +import { EditHistorySync } from './edit-history-sync.js'; +import { EditHistoryStore } from '../edit-history-store.js'; +import { probeLocalPaths, readClaudeEditSources, resolveClaudeTranscript } from '../edit-history-local-source.js'; import { stopDeepSeekWeb } from '../deepseek-web-server.js'; import { wireRespawnListeners, @@ -174,6 +189,7 @@ import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js'; import { resolveTerminalHistoryConfig } from '../config/terminal-history.js'; import { SseEvent } from './sse-events.js'; import { MAX_ROUTE_PARAM_LENGTH } from './route-helpers.js'; +import { mutateSettingsFile } from './settings-writer.js'; import { getLatestPlanUsage, setLatestCodexPlanUsage, setLatestCopilotPlanUsage } from './plan-usage-latest.js'; import { telemetrySignature } from '../usage-telemetry.js'; import { readCodexPlanUsage, resolveCodexBinaryPath } from '../utils/codex-cli-resolver.js'; @@ -194,6 +210,7 @@ import { import { isMultiUserMode } from '../config/multiuser.js'; import { bootstrapInitialAdmin, hasUsers, resolveClaudeModeForUsername } from '../user-store.js'; import { installRouteErrorHandler } from './route-error-handler.js'; +import { installEmptyApiPostParser } from './request-body-parser.js'; import { isExplicitlyEnabled, isLoopbackBindHost, buildHostPolicy, type HostPolicy } from './network-auth-policy.js'; import { registerPushRoutes, @@ -208,11 +225,13 @@ import { registerRebootRestoreRoutes, registerReadMyMindRoutes, registerGitStatusRoutes, + registerEditHistoryRoutes, registerStatusTelemetryRoutes, registerSystemRoutes, registerCaseRoutes, registerWorktreeRoutes, registerSessionRoutes, + registerAgentSessionRoutes, registerRespawnRoutes, registerRalphRoutes, registerPlanRoutes, @@ -230,6 +249,7 @@ import { registerConfigBackupRoutes, runConfigBackup, registerWebhookRoutes, + registerDiscordRoutes, registerDoctorRoutes, registerCustomModelRoutes, refreshAllCustomModelHosts, @@ -293,6 +313,7 @@ import { STATS_COLLECTION_INTERVAL_MS, INACTIVITY_TIMEOUT_MS, CRON_TICK_INTERVAL, + GIT_FETCH_TICK_INTERVAL_MS, } from '../config/server-timing.js'; /** @@ -334,6 +355,24 @@ function getOrCreateSelfSignedCert(): { key: string; cert: string } { export class WebServer extends EventEmitter { private app: FastifyInstance; private sessions: Map<string, Session> = new Map(); + /** + * The edit history panel's reader (edit-history-sync.ts). Reads only when the panel asks; + * the main transcript is the one the transcript watcher follows (hook-reported path), or, + * before the first hook after a restart, a lookup under the session's claude config dir. + */ + private readonly editHistorySync = new EditHistorySync({ + transcriptPath: (session) => + resolveClaudeTranscript( + this.transcriptWatchers.get(session.id)?.getPath(), + [session.claudeSessionId || session.id, session.id], + session.claudeConfigDir + ), + readSources: readClaudeEditSources, + probe: probeLocalPaths, + loadStore: (sessionId) => EditHistoryStore.load(sessionId), + deleteStore: (sessionId) => EditHistoryStore.delete(sessionId), + sweepStores: (keep) => EditHistoryStore.sweep(keep), + }); private respawnControllers: Map<string, RespawnController> = new Map(); private respawnTimers: Map<string, { timer: NodeJS.Timeout; endAt: number; startedAt: number }> = new Map(); private runSummaryTrackers: Map<string, RunSummaryTracker> = new Map(); @@ -389,6 +428,37 @@ export class WebServer extends EventEmitter { }, log: (message) => console.log(message), }); + // Git fetch on an interval (git-fetch-scheduler.ts): the due-tracking lives in the service; the server + // lends it the facts a decision needs and stores what it reports on the session. A field initializer for + // the same reason as the keep-warm one above, and the scheduler reads the app setting FRESH on every tick: + // that is what makes a live toggle take effect on the next tick, and what makes turning it off actually + // stop the fetching. Default OFF, so an install that never enabled it spawns nothing at all. + private readonly gitFetchScheduler = new GitFetchScheduler({ + sessionIds: () => this.sessions.keys(), + getSession: (id) => this.sessions.get(id), + readSettings: readGitFetchSettings, + // The same Docker list the panel's Fetch button uses: the rule has to be one rule. It rejects on a list + // that cannot be read, and the tick then fetches nothing (fail closed). + dockerWorkspaces: defaultDockerWorkspaces, + // Bounded probe, DEFAULT options (rule #516): a tick is a bulk scan, so it keeps the stall cap and must + // never be handed `pastCap`. A folder on a mount that stopped answering is skipped, not fetched. + probePath: (path) => probePathKind(path), + // Through the join, so the tick and the panel's Fetch button are the same run: a click on a folder the + // tick is already fetching joins it rather than starting a second `git fetch` in the same clone, and the + // run that gets published is the one that ran, with its own trigger. + fetch: (cwd, opts, trigger) => fetchWorkspaceOnce(cwd, opts, trigger), + publish: (id, status) => { + const session = this.sessions.get(id); + if (!session?.setGitFetchStatus(status)) return; + // No new SSE event: the status rides the session state the page already listens for, and the Git + // panel polls on its own clock. It is written to disk with the rest of the record, but the restore + // copies only the fields its own config lists and this is not one of them (src/types/session.ts), so + // what a run found is the panel's truth for the life of the session, not across a restart. + this.persistSessionState(session); + this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session)); + }, + log: (message) => console.log(message), + }); /** * Wake-on-LAN registry, returned by `registerSessionRoutes`. Held for its LIFETIME * only — `drop()` on session cleanup, `stop()` on shutdown. Waking from here would @@ -466,12 +536,35 @@ export class WebServer extends EventEmitter { private pushStore: PushSubscriptionStore = new PushSubscriptionStore(); /** ntfy / Slack / Discord / generic webhook for the push events; config in webhook.json (0600). */ private webhookNotifier = new WebhookNotifier(() => readWebhookConfig(getDataDir()), webviewFetch); + /** Two-way Discord bridge (thread per tab); config in discord.json (0600). Idle until enabled in Settings. */ + /** Set by the session routes when they register (see SessionPort). */ + lastResponseFor?: (session: Session, context?: string) => Promise<{ text: string; timestamp?: string }>; + private discordBridge = new DiscordBridge({ + configDir: getDataDir(), + fetchImpl: webviewFetch, + sendInput: async (sessionId, text) => { + const session = this.sessions.get(sessionId); + if (!session) return false; + // A Discord reply is a person's own input, so it names the tab like any other prompt. + if (await session.writeViaMux(text, { fromUser: true }).catch(() => false)) return true; + return session.write(text, { fromUser: true }); + }, + agentExited: (id) => !!this.sessions.get(id)?.paneExit, + awaitingChoice: (id) => approvalInbox.listPending().some((item) => item.sessionId === id && item.kind !== 'idle'), + // Remote (ssh) sessions keep their folder on another host, so received files have nowhere local to go. + sessionDir: (sessionId) => { + const session = this.sessions.get(sessionId); + return session && !session.remote ? session.workingDir : undefined; + }, + }); private teamWatcher: TeamWatcher = new TeamWatcher(); private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null; private readonly titleHostname: string; private windowTitle: string; private readonly indexHtmlTemplate: string; private readonly allowUnauthenticatedNetwork: boolean; + /** `--allow-unauthenticated-tunnel`; the tunnel guard reads it through the route context (#706). */ + private readonly allowUnauthenticatedTunnel: boolean; private _pasteImageGcStop: (() => void) | null = null; private _eventLoopMonitor: EventLoopMonitorHandle | null = null; /** Opt-in hooks-only listener on the docker bridge gateway (CODEMAN_DOCKER_BRIDGE_HOOKS). */ @@ -489,7 +582,8 @@ export class WebServer extends EventEmitter { host: string = '127.0.0.1', titleHostname?: string, allowUnauthenticatedNetwork: boolean = false, - basePath: string = '' + basePath: string = '', + allowUnauthenticatedTunnel: boolean = false ) { super(); this.setMaxListeners(0); @@ -501,6 +595,7 @@ export class WebServer extends EventEmitter { this.testMode = testMode; this.allowUnauthenticatedNetwork = allowUnauthenticatedNetwork || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK); + this.allowUnauthenticatedTunnel = allowUnauthenticatedTunnel; this.titleHostname = titleHostname || getHostname(); this.windowTitle = `codeman:${this.titleHostname}`; this.indexHtmlTemplate = readFileSync(join(__dirname, 'public', 'index.html'), 'utf-8'); @@ -829,6 +924,7 @@ export class WebServer extends EventEmitter { addSession: this.registerSessionWithLayout.bind(this), tabLayouts: this.tabLayouts, cleanupSession: this.cleanupSession.bind(this), + forgetEditHistory: this.forgetEditHistory.bind(this), setupSessionListeners: this.setupSessionListeners.bind(this), persistSessionState: this.persistSessionState.bind(this), persistSessionStateNow: this._persistSessionStateNow.bind(this), @@ -880,6 +976,7 @@ export class WebServer extends EventEmitter { scheduledRuns: this.scheduledRuns, teamWatcher: this.teamWatcher, tunnelManager: this.tunnelManager, + allowUnauthenticatedTunnel: this.allowUnauthenticatedTunnel, pushStore: this.pushStore, startScheduledRun: this.startScheduledRun.bind(this), stopScheduledRun: this.stopScheduledRun.bind(this), @@ -970,6 +1067,9 @@ export class WebServer extends EventEmitter { this.userIpFailures = authState.userIpFailures; } + // Bodyless API POSTs may arrive as empty chunked streams through a proxy. + installEmptyApiPostParser(this.app); + // WebSocket support (terminal I/O — low-latency bidirectional channel) await this.app.register(fastifyWebsocket); @@ -1242,6 +1342,22 @@ export class WebServer extends EventEmitter { registerRebootRestoreRoutes(this.app, ctx); registerReadMyMindRoutes(this.app, ctx); registerGitStatusRoutes(this.app, ctx); + registerEditHistoryRoutes(this.app, ctx, this.editHistorySync); + // Git fetch on an interval: this is only the wake-up, the scheduler itself decides what is due. Off in + // tests for the same reason as the polls below: a test drives `tick()` directly instead of waiting on + // a timer the harness would have to keep alive. The setting is read FRESH inside `tick()`, so turning + // it on takes effect on the next tick and turning it off actually stops the fetching. + if (!this.testMode) { + this.cleanup.setInterval( + () => { + this.gitFetchScheduler.tick().catch((err) => { + console.error('[git fetch] interval tick failed:', getErrorMessage(err)); + }); + }, + GIT_FETCH_TICK_INTERVAL_MS, + { description: 'git fetch interval checker' } + ); + } registerStatusTelemetryRoutes(this.app, ctx); registerSystemRoutes(this.app, ctx); registerCaseRoutes(this.app, ctx); @@ -1249,6 +1365,9 @@ export class WebServer extends EventEmitter { // The registry's lifetime is the server's: it drops per-session wake state on every // cleanup path and resolves in-flight wakes on shutdown. this.remoteWake = registerSessionRoutes(this.app, ctx); + // The routes' ctx is a separate object: take the last-response reader they registered on it (Discord alerts). + this.lastResponseFor = (ctx as { lastResponseFor?: WebServer['lastResponseFor'] }).lastResponseFor; + registerAgentSessionRoutes(this.app, ctx); registerRespawnRoutes(this.app, ctx); registerRalphRoutes(this.app, ctx); registerPlanRoutes(this.app, ctx); @@ -1260,12 +1379,23 @@ export class WebServer extends EventEmitter { registerWebviewRoutes(this.app, ctx, this.basePath); registerTabLayoutRoutes(this.app, ctx); registerMcpSyncRoutes(this.app); - registerConfigBackupRoutes(this.app); + registerConfigBackupRoutes(this.app, { reloadDiscord: () => this.discordBridge.reload() }); registerWebhookRoutes(this.app, { notifier: this.webhookNotifier, configDir: getDataDir(), hostTitle: () => this.windowTitle, }); + registerDiscordRoutes(this.app, { + bridge: this.discordBridge, + configDir: getDataDir(), + ctx, + hostTitle: () => this.windowTitle, + }); + if (!this.testMode) { + // The gateway waits for restoreMuxSessions(): a reply during restore would be told the tab is gone. + this.discordBridge.hold(); + void this.discordBridge.ensureLoaded().catch(() => undefined); + } registerDoctorRoutes(this.app); registerCustomModelRoutes(this.app); registerCliRegistryRoutes(this.app); @@ -1390,7 +1520,7 @@ export class WebServer extends EventEmitter { private async _rebuildCustomModelEnv( session: Session, saved: CustomModelBookkeeping - ): Promise<Record<string, string> | undefined> { + ): Promise<AppliedCustomModel | undefined> { const entry = getCli(session.mode); if (!entry) return undefined; const endpoint = (await readCustomModelHosts(getDataDir())).find((h) => h.id === saved.endpointId); @@ -1401,19 +1531,56 @@ export class WebServer extends EventEmitter { return undefined; } try { + // Boot recovery omits both `workingDir` (no reason to re-answer a trust dialog that + // already fired once) and the session-env real-home lookup: the injected VALUES are + // never persisted, so after a real restart the session can no longer carry its + // allowlisted `DSH_HOME` either (`getEnvOverridesForPersist()` keeps only + // `CLAUDE_CODE_*`/`CLAUDE_CONFIG_DIR`): the lookup could only ever hit on the first + // apply in-process. Real-home resolution therefore falls back to the SERVER's + // `DSH_HOME` or the recipe's `realHomeFallback` (~/.dsh). return applyCustomModelInjection( entry, endpoint, saved.modelId, session.id, - endpoint.modelContextLengths?.[saved.modelId] - )?.envOverrides; + resolveCustomModelContextLength(endpoint, saved.modelId) + ); } catch (err) { console.warn('[WebServer] Failed to rebuild custom-model env on recovery:', err); return undefined; } } + /** + * Restore one persisted custom-model selection (both boot-recovery paths). The rebuilt + * bookkeeping must come from the CURRENT recipe, not from the saved record: this is the + * first feature where a shipped recipe changed KIND (deepseek: env redirect -> per-session + * patch home), so a selection saved by an old build restores with e.g. no `configDir` + * while the new recipe writes one, and clearing it later could then never delete the + * generated dir. `envKeys` is the UNION of saved and applied keys: the tmux session may + * still carry `setenv`s from both, and a clear has to unset every one. + */ + private async _restoreCustomModel(session: Session, saved: CustomModelBookkeeping): Promise<void> { + const applied = await this._rebuildCustomModelEnv(session, saved); + if (!applied) { + // Endpoint gone or CLI unregistered: restore the bookkeeping exactly as saved, which + // is what a later clear needs. + session.setCustomModel(saved); + return; + } + session.setCustomModel( + { + ...saved, + // Prefer the current recipe's dir; keep the saved one when the recipe writes none, + // so a clear can still delete a directory an older recipe created. + configDir: applied.configDir ?? saved.configDir, + launchModel: applied.launchModel, + envKeys: [...new Set([...saved.envKeys, ...applied.envKeys])], + }, + applied.envOverrides + ); + } + /** Persists full session state including respawn config to state.json */ private _persistSessionStateNow(session: Session): void { // See session-manager.updateSessionState: __envOverrides is an internal disk-only @@ -1499,6 +1666,9 @@ export class WebServer extends EventEmitter { ): Promise<void> { const session = this.sessions.get(sessionId); this.keepWarmService.remove(sessionId); + // A closed session takes its place in the fetch schedule with it: otherwise a detached-and-forgotten id + // would still count as tracked, and its next run would land on a session that no longer exists. + this.gitFetchScheduler.remove(sessionId); const pinned = session?.pinned === true || this.store.getSession(sessionId)?.pinned === true; if (coordinateLayout && session && killMux && !pinned) { return this.tabLayouts.runSessionDeletion([{ id: sessionId, owner: session.owner }], () => @@ -1685,7 +1855,8 @@ export class WebServer extends EventEmitter { // Only remove from state.json if we're also killing the mux session. // When killMux=false (server shutdown), preserve state for recovery. if (killMux) { - this.store.demoteOrRemoveSession(sessionId); + // The edit history goes with the record; a pinned session's demoted record keeps it. + if (this.store.demoteOrRemoveSession(sessionId) !== 'preserved') this.forgetEditHistory(sessionId); } } @@ -1700,7 +1871,11 @@ export class WebServer extends EventEmitter { // detach: mail posted before that is still there when the session is re-attached // (while detached, the routes answer 404, so nothing new arrives). Before // cancelAll, so a reader released by it reads an empty inbox. - if (killMux) agentInbox.drop(sessionId); + if (killMux) { + agentInbox.drop(sessionId); + // A real delete only: a detach (killMux=false) leaves the session running and re-attachable. + void this.discordBridge.sessionDeleted(sessionId).catch(() => undefined); + } sessionWaits.notifySignal(sessionId, 'exit'); sessionWaits.cancelAll(sessionId); approvalInbox.resolveForSession(sessionId, 'session_ended'); @@ -1914,6 +2089,16 @@ export class WebServer extends EventEmitter { '</head>', () => `<script>window.__codemanTranscriptPageKeys=${JSON.stringify(pageKeyClis)};</script>\n</head>` ); + // Which run modes keep their PTY rows under a phone keyboard (inline renderers, + // the `keyboardHold` capability; `_keyboardHoldEligible` in terminal-ui.js). Same + // shape and reasoning as the page-key list above. + const keyboardHoldClis = enabledClis() + .filter((entry) => entry.capabilities.keyboardHold === 'inline') + .map((entry) => entry.id); + html = html.replace( + '</head>', + () => `<script>window.__codemanKeyboardHoldModes=${JSON.stringify(keyboardHoldClis)};</script>\n</head>` + ); if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') { html = html.replace('</head>', () => `<script>window.__codemanGestureAvailable=true;</script>\n</head>`); if (settings.gestureControlEnabled === true) { @@ -1989,13 +2174,23 @@ export class WebServer extends EventEmitter { this.runSummaryTrackers.set(session.id, summaryTracker); summaryTracker.recordSessionStarted(session.mode, session.workingDir); - // Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for external CLIs) + // Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for external CLIs). + // A remote session's workingDir is a path on the remote host, and the watcher reads and + // fs.watch()es it on the LOCAL disk, so a remote session only records the path: the fix plan stays + // authoritative as for a local session (keeping the output-based completion guessers off), and its + // file is read over ssh only on an explicit import (fix-plan/read). if (!isExternalCliMode(session.mode)) { - session.ralphTracker.setWorkingDir(session.workingDir); + if (session.remote) { + session.ralphTracker.setRemoteWorkingDir(session.workingDir); + } else { + session.ralphTracker.setWorkingDir(session.workingDir); + } } - // Start watching for new images in this session's working directory (if enabled globally and per-session) - if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled) { + // Start watching for new images in this session's working directory (if enabled globally and per-session). + // A remote session's workingDir is a path on the remote host, and the watcher watches it on the LOCAL + // disk, so it is skipped there (the per-session and app-wide toggles skip it too). + if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled && !session.remote) { imageWatcher.watchSession(session.id, session.workingDir); } @@ -2503,10 +2698,27 @@ export class WebServer extends EventEmitter { const lifecycleLog = getLifecycleLog(); for (const s of result.cleaned) { lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name }); + this.forgetEditHistory(s.id); } + // History files no session owns (a crash between removing a record and deleting its file). + const exists = (id: string) => this.sessions.has(id) || !!this.store.getSession(id); + await this.editHistorySync.sweep(exists).catch((err) => { + console.warn('[EditHistory] Sweeping orphaned histories failed:', getErrorMessage(err)); + }); return result.count; } + /** + * Delete a session's edit history (diffs can hold secrets) once its record is gone: deleted + * live (`cleanupSession`), deleted as a record only (DELETE route), or cleaned as stale + * (startup, `POST /api/cleanup-state`). + */ + private forgetEditHistory(sessionId: string): void { + void this.editHistorySync.remove(sessionId).catch((err) => { + console.warn(`[EditHistory] Removing history of ${sessionId} failed:`, getErrorMessage(err)); + }); + } + /** * Get lightweight state for SSE init - excludes full terminal buffers * to prevent browser freezes. Terminal buffers are fetched on-demand. @@ -2612,6 +2824,23 @@ export class WebServer extends EventEmitter { // Multi-user: derive an ownership routing hint so an event only reaches the // clients entitled to it (no-op in single-user — hint stays undefined). this.sse.broadcast(event, data, isMultiUserMode() ? this.deriveSseHint(event, data) : undefined); + this.bridgeSessionEvent(event, data); + } + + /** Keep the Discord thread in step with the tab: created, renamed, deleted. Never throws. */ + private bridgeSessionEvent(event: string, data: unknown): void { + // Deletion is NOT handled here: `session:deleted` also fires for a detach (killMux=false), where the + // session keeps running and can be re-attached. The real delete calls the bridge from the cleanup path. + if (event !== SseEvent.SessionCreated && event !== SseEvent.SessionUpdated) return; + const d = (data ?? {}) as { id?: string; name?: string; session?: { id?: string; name?: string } }; + const s = d.session ?? d; + if (!s.id) return; + const id = s.id; + const run = + event === SseEvent.SessionCreated + ? this.discordBridge.sessionCreated(id, s.name).then(() => this.discordBridge.sessionRenamed(id, s.name)) + : this.discordBridge.sessionRenamed(id, s.name); + void run.catch(() => undefined); } /** @@ -2725,13 +2954,24 @@ export class WebServer extends EventEmitter { private applyPaneExits(): void { const getPaneExit = this.mux.getPaneExit?.bind(this.mux); if (!getPaneExit) return; + const getPaneCurrentPath = this.mux.getPaneCurrentPath?.bind(this.mux); for (const session of this.sessions.values()) { const muxName = session.muxName; // No pane, so nothing to report — and `setPaneExit()` would force UNKNOWN // for such a session anyway. if (!muxName) continue; - if (!session.setPaneExit(getPaneExit(muxName))) continue; - this.persistSessionState(session); + // The pane's live directory rides the same read: it is the base a relative + // path printed in the terminal resolves against. The manager's answer is + // applied as given, `undefined` included: it keeps its map across a failed + // or empty read and carries a blank `#{pane_current_path}` forward, so an + // `undefined` here means an authoritative read listed no single directory + // for the session (split into several panes, or gone from the socket). + const paneCwdChanged = getPaneCurrentPath ? session.setPaneCwd(getPaneCurrentPath(muxName)) : false; + if (session.setPaneExit(getPaneExit(muxName))) { + this.persistSessionState(session); + } else if (!paneCwdChanged) { + continue; + } this.broadcastSessionStateDebounced(session.id); } this.closeCleanlyExitedSessions(); @@ -2847,6 +3087,27 @@ export class WebServer extends EventEmitter { }) .catch(() => undefined); + // Discord: same event, same independence from Web Push. It adds the tail of what the CLI printed, + // so the person reading the thread can see what is being asked without opening the tab. + const dcSession = sessionId ? this.sessions.get(sessionId) : undefined; + // A question or permission dialog lives on the screen, so it keeps the screen tail (its options are + // there); a finished turn posts the CLI's whole last message instead, from its transcript. + const dialogEvent = event === SseEvent.HookPermissionPrompt || event === SseEvent.HookElicitationDialog; + void this.discordBridge + .notify({ + event, + title: template.title, + body, + urgency: template.urgency as WebhookUrgency, + sessionId: sessionId || undefined, + sessionName: sessionName || undefined, + // A function, so the screen is only read (a synchronous tmux exec) once the bridge has decided to post. + tail: dcSession ? () => this.discordTail(dcSession, !dialogEvent) : undefined, + fullText: dcSession && !dialogEvent ? () => this.discordFullText(dcSession) : undefined, + host: this.windowTitle, + }) + .catch(() => undefined); + const subscriptions = this.pushStore.getAll(); if (subscriptions.length === 0) return; @@ -2928,6 +3189,34 @@ export class WebServer extends EventEmitter { } } + /** + * What the person would see on the tab right now, for a Discord alert: the rendered tmux screen + * (`capture-pane -p`), never the raw output stream, which a full-screen UI repaints in place and + * reads as garbage. A session with no pane falls back to its plain text output, else nothing. + */ + private discordTail(session: Session, cutComposer: boolean): string { + const pane = session.muxName ? (this.mux.capturePaneText?.(session.muxName) ?? null) : null; + // The composer row and the status lines under it are screen chrome, not what the CLI said. + const glyph = cutComposer ? getCli(session.mode)?.capabilities.workDetect?.promptGlyph : undefined; + return screenTail(pane ?? session.textOutput ?? '', 14, 900, glyph); + } + + /** + * The CLI's whole last message from the response viewer's reader (the CLI's own transcript, or the pane + * segmented into turns for the CLIs that keep none); undefined for a shell, which has no turns, and the + * screen tail stands in whenever the reader finds nothing. + */ + private async discordFullText(session: Session): Promise<{ text: string; version?: string } | undefined> { + if (!this.lastResponseFor) return undefined; + // A CLI with a transcript reader, or whose pane the viewer segments into turns; a shell has neither. + if (getCli(session.mode)?.capabilities.transcript === 'none' && !isExternalCliTranscriptMode(session.mode)) { + return undefined; + } + const r = await this.lastResponseFor(session); + // The turn it came from: the transcript's stamp, else when the person last submitted (a pane has no stamp). + return { text: r.text, version: r.timestamp || String(session.lastSubmitAt ?? '') }; + } + /** The notification body for an event (shared by Web Push and the webhook channel). */ private static pushBodyText(event: string, data: Record<string, unknown>, sessionName: string): string { let body = sessionName ? `[${sessionName}]` : ''; @@ -3069,6 +3358,20 @@ export class WebServer extends EventEmitter { if (restoredMail > 0) console.log(`[AgentInbox] restored ${restoredMail} pending message(s)`); const restored = await this.restoreMuxSessions(); await this.finalizeRestoredState(restored); + void this.discordBridge + .release() + .then(async () => { + // Only after a successful restore, like the agent inbox prune and the tab layout reconciliation, and + // with the same keep set: a reboot-restore rebuild reuses the old id. + if (!restored) return; + await this.discordBridge.prune([ + ...this.sessions.keys(), + ...Object.keys(this.store.getSessions()), + ...rebootRestoreRegistry.list(() => true).map((entry) => entry.sessionId), + ]); + }) + .then(() => this.discordBridge.startStaleSweep()) + .catch(() => undefined); // Instance-scoped reaper: after restore, `docker rm -f` managed containers of // THIS instance whose case is gone from docker-cases.json (best-effort, never @@ -3346,16 +3649,12 @@ export class WebServer extends EventEmitter { // Tunnel only starts when user clicks the toggle in the UI — never on boot. // Reset persisted tunnelEnabled so the UI toggle reflects actual state. if (await this.isTunnelEnabled()) { - const settingsPath = dataPath('settings.json'); try { - const content = await fs.readFile(settingsPath, 'utf-8'); - const settings = JSON.parse(content); - settings.tunnelEnabled = false; - await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); - } catch { - /* ignore */ + await mutateSettingsFile((settings) => ({ ...settings, tunnelEnabled: false })); + console.log('Cloudflare tunnel setting reset (tunnel only starts on explicit UI toggle)'); + } catch (err) { + console.warn('Could not reset the Cloudflare tunnel setting:', getErrorMessage(err)); } - console.log('Cloudflare tunnel setting reset (tunnel only starts on explicit UI toggle)'); } // Start team watcher for agent team awareness (always on — lightweight polling) @@ -3521,7 +3820,7 @@ export class WebServer extends EventEmitter { // bookkeeping survives a restart and the values are re-derived here. const savedCustomModel = (saved as { __customModel?: CustomModelBookkeeping }).__customModel; if (savedCustomModel) { - session.setCustomModel(savedCustomModel, await this._rebuildCustomModelEnv(session, savedCustomModel)); + await this._restoreCustomModel(session, savedCustomModel); } if (saved.niceEnabled !== undefined || saved.niceValue !== undefined) { session.setNice({ enabled: saved.niceEnabled, niceValue: saved.niceValue }); @@ -3540,6 +3839,9 @@ export class WebServer extends EventEmitter { if (saved.autoClearEnabled !== undefined || saved.autoClearThreshold !== undefined) { session.setAutoClear(saved.autoClearEnabled ?? false, saved.autoClearThreshold); } + if (saved.gitFetchEnabled !== undefined || saved.gitFetchIntervalSeconds !== undefined) { + session.setGitFetchSettings(saved.gitFetchEnabled, saved.gitFetchIntervalSeconds); + } if (saved.autoResumeEnabled) { // The stamp is re-armed by default, because a Codeman restart leaves the // limit footer un-reprinted and dropping it there would strand the pause. @@ -3654,6 +3956,9 @@ export class WebServer extends EventEmitter { } catch (err) { console.warn(`[Server] releasing the tab layout slot failed: ${getErrorMessage(err)}`); } + // Per-tab bridge state only: a reboot-restore rebuild reuses the old id and stays on offer for a + // retry, so its thread must survive the discard. + this.discordBridge.forgetTab(sessionId); // Any `session:updated` the half-built session emitted before it failed left a // tab on every other open board, and the client's handler is an upsert. this.broadcast(SseEvent.SessionDeleted, { id: sessionId }); @@ -3762,6 +4067,11 @@ export class WebServer extends EventEmitter { // below, a future `expiresAt` re-arms the timer once the pane is attached. lastRealPromptAt: savedState?.lastRealPromptAt, keepWarmEnabled: savedState?.keepWarmEnabled, + // The session's own fetch switch and interval, carried over rather than reset to the app default: + // the choice is the user's and outlives the run that made it. The scheduler reads them fresh + // on its next tick, so a restored session is back on its own schedule without a restart. + gitFetchEnabled: savedState?.gitFetchEnabled, + gitFetchIntervalSeconds: savedState?.gitFetchIntervalSeconds, // Conversations this pane provably owned, oldest first. Its tail is // the conversation the CLI was on when the server stopped, which is // what a re-attach must point the viewer at instead of the launch id. @@ -3815,7 +4125,7 @@ export class WebServer extends EventEmitter { // the isolated config dir; an endpoint that has since been deleted still gets // the bookkeeping restored, which is what a later clear needs to unset. if (savedCustomModel) { - session.setCustomModel(savedCustomModel, await this._rebuildCustomModelEnv(session, savedCustomModel)); + await this._restoreCustomModel(session, savedCustomModel); } // Update session name if it was a "Restored:" placeholder or doesn't match saved name @@ -4221,6 +4531,9 @@ export class WebServer extends EventEmitter { // only by the periodic idle sweep, whose interval is disposed just below. closeAllLlamaSwapLogTails(); + // Close the Discord gateway socket and its heartbeat, and clear the bridge's per-tab maps. + this.discordBridge.stop(); + // Dispose all managed timers (intervals + resettable timeouts) this.cleanup.dispose(); @@ -4233,6 +4546,9 @@ export class WebServer extends EventEmitter { sessionWaits.cancelEverything(); approvalInbox.stop(); this.keepWarmService.stop(); + // Stops the fetch sweep from starting another run while the server is closing; a git process already + // running is left to finish, which is what a fetch does to a repository nothing else is touching. + this.gitFetchScheduler.stop(); // A post within the debounce window before shutdown would otherwise not reach disk. // Flushed again after `app.close()` below, for a post that lands in between. agentInboxPersistence.flush(); @@ -4348,6 +4664,9 @@ export class WebServer extends EventEmitter { this.respawnTimers.clear(); this.runSummaryTrackers.clear(); this.transcriptWatchers.clear(); + await this.editHistorySync.flushAll().catch((err) => { + console.warn('[EditHistory] Saving on shutdown failed:', getErrorMessage(err)); + }); this.sessionListenerRefs.clear(); this.scheduledRuns.clear(); // Dispose StaleExpirationMaps (stops internal cleanup timers) @@ -4399,9 +4718,19 @@ export async function startWebServer( host: string = '127.0.0.1', titleHostname?: string, allowUnauthenticatedNetwork: boolean = false, - basePath: string = '' + basePath: string = '', + allowUnauthenticatedTunnel: boolean = false ): Promise<WebServer> { - const server = new WebServer(port, https, testMode, host, titleHostname, allowUnauthenticatedNetwork, basePath); + const server = new WebServer( + port, + https, + testMode, + host, + titleHostname, + allowUnauthenticatedNetwork, + basePath, + allowUnauthenticatedTunnel + ); await server.start(); return server; } diff --git a/src/web/settings-writer.ts b/src/web/settings-writer.ts new file mode 100644 index 00000000..d5bbaca4 --- /dev/null +++ b/src/web/settings-writer.ts @@ -0,0 +1,118 @@ +/** + * @fileoverview The writer of the server's `settings.json` (`SETTINGS_PATH`). + * + * Every read-modify-write of that file goes through `mutateSettingsFile()`, the same shape as + * `mutateRegistryFile()` (config/cli-registry/registry-writer.ts). The one direct writer is + * config-backup restore (`restoreConfigBackup()`, as for `clis.json`): it must work on a file that + * does not parse, which this writer refuses. + * + * - **Serialized.** Mutations run one at a time on a single promise chain, each one reading, + * changing and writing before the next starts. The writers used to run unserialized with a + * plain truncate-then-write `fs.writeFile`, and quick-start did not even await its + * `lastUsedCase` write, so a quick-start followed by a settings save could tear the file (one + * write's bytes over the tail of a longer one) or drop the other writer's key. + * - **Atomic.** Each write goes to its own temp file beside the target and is renamed over it, + * so a reader never sees a half-written file. + * - **Refuses a file it must not trust.** Only a missing (or empty) file starts from `{}`. A file + * that cannot be read or does not parse is left alone and the write throws + * `SettingsWriteRefusedError`: the writers used to read such a file as `{}` and write back only + * their own keys, which erased every other setting. + */ + +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../types.js'; +import { SETTINGS_PATH } from './route-helpers.js'; + +export type SettingsFile = Record<string, unknown>; + +/** A write refused because the existing `settings.json` must not be overwritten. The message is user-facing. */ +export class SettingsWriteRefusedError extends Error { + constructor(message: string) { + super(message); + this.name = 'SettingsWriteRefusedError'; + } +} + +async function readSettingsForWrite(path: string): Promise<SettingsFile> { + let raw: string; + try { + raw = await fs.readFile(path, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {}; + throw new SettingsWriteRefusedError( + `Cannot read ${path} (${(err as Error).message}), so this change was not saved.` + ); + } + // An empty file holds no settings to lose. + if (raw.trim() === '') return {}; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + parsed = undefined; + } + // The parser's own message is not repeated: it can quote the file, which may hold an API key. + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new SettingsWriteRefusedError( + `${path} is not a valid JSON object, so this change was not saved (saving would have replaced every other setting in it). Fix or delete the file, or restore it from a config backup, then try again.` + ); + } + return parsed as SettingsFile; +} + +async function writeSettingsFile(path: string, settings: SettingsFile): Promise<void> { + // Through a symlink, as the plain writeFile this replaces did: the rename lands on the real file. + const target = await fs.realpath(path).catch(() => path); + await fs.mkdir(dirname(target), { recursive: true }); + const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`; + try { + await fs.writeFile(tmp, JSON.stringify(settings, null, 2), { mode: 0o600 }); + await fs.rename(tmp, target); + } catch (err) { + await fs.rm(tmp, { force: true }).catch(() => {}); + throw err; + } +} + +let mutationChain: Promise<unknown> = Promise.resolve(); + +/** + * Run one change to `settings.json`. `fn` receives the current settings and returns the settings + * to write, or `undefined` to write nothing. Resolves with the settings before and after the + * change (`after` is `undefined` when nothing was written). A refused or failed mutation rejects + * its own caller only; the chain keeps going. + */ +export function mutateSettingsFile( + fn: (current: SettingsFile) => SettingsFile | undefined +): Promise<{ before: SettingsFile; after: SettingsFile | undefined }> { + const run = mutationChain.then(async () => { + const before = await readSettingsForWrite(SETTINGS_PATH).catch((err: unknown) => { + console.warn(`[settings] write refused: ${getErrorMessage(err)}`); + throw err; + }); + const after = fn({ ...before }); + if (after) await writeSettingsFile(SETTINGS_PATH, after); + return { before, after }; + }); + mutationChain = run.catch(() => {}); + return run; +} + +/** + * Rejects with `SettingsWriteRefusedError` when `mutateSettingsFile()` would refuse a write right + * now: the writer's own read-for-write rule, run on its own. For a route that does something it + * cannot take back (case rename ends sessions) before its settings write. A check only, outside + * the chain (writes are atomic, so it never reads a half-written file); the file can still change + * before the write. + */ +export async function assertSettingsWritable(): Promise<void> { + await readSettingsForWrite(SETTINGS_PATH); +} + +/** A refused write answers 409 CONFLICT with the fix; any other failure stays OPERATION_FAILED. */ +export function settingsWriteErrorResponse(err: unknown): ApiResponse<never> { + if (err instanceof SettingsWriteRefusedError) return createErrorResponse(ApiErrorCode.CONFLICT, err.message); + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); +} diff --git a/test/agent-codex-identity.test.ts b/test/agent-codex-identity.test.ts new file mode 100644 index 00000000..a0e57176 --- /dev/null +++ b/test/agent-codex-identity.test.ts @@ -0,0 +1,253 @@ +/** @fileoverview Strict bounded native rollout attribution without viewer heuristics. */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + __clearCodexAttributionMemo, + findExactCodexConversation, + resolveEffectiveCodexHome, +} from '../src/agent-codex-identity.js'; +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); +const A = '11111111-1111-4111-8111-111111111111'; +const B = '22222222-2222-4222-8222-222222222222'; +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'agent-attribution-')); + roots.push(root); + const home = join(root, 'home'); + await mkdir(join(home, 'sessions'), { recursive: true }); + const options = { codexHome: home, sessionId: 'managed', workingDir: root }; + const put = async (id: string, payload: object = {}, text?: string) => { + const path = join(home, 'sessions', `rollout-${id}.jsonl`); + await writeFile( + path, + text ?? + JSON.stringify({ + type: 'session_meta', + payload: { id, cwd: root, originator: 'codeman_managed', ...payload }, + }) + '\n' + ); + return path; + }; + return { root, home, options, put }; +} +it('requires one complete native session_meta matching the rollout ID, managed originator and canonical workspace', async () => { + const f = await fixture(); + const path = await f.put(A); + expect(await findExactCodexConversation(f.options)).toEqual({ id: A, path }); + await f.put(A, { id: B }); + expect(await findExactCodexConversation(f.options)).toBeUndefined(); +}); +it('never chooses newest cwd files or an ambiguous managed originator', async () => { + const f = await fixture(); + await f.put(A, { originator: 'codex_cli' }); + expect(await findExactCodexConversation(f.options)).toBeUndefined(); + await f.put(A); + await f.put(B); + expect(await findExactCodexConversation(f.options)).toBeUndefined(); +}); +it('allows an explicit resume identity but still requires native metadata and the workspace', async () => { + const f = await fixture(); + const path = await f.put(A, { originator: 'original-launch' }); + expect(await findExactCodexConversation({ ...f.options, resumeSessionId: A })).toEqual({ id: A, path }); + await f.put(B); + expect(await findExactCodexConversation({ ...f.options, resumeSessionId: A })).toBeUndefined(); + await f.put(A, { cwd: f.home }); + expect(await findExactCodexConversation({ ...f.options, resumeSessionId: A })).toBeUndefined(); +}); +it.each(['partial', 'duplicate', 'oversized'] as const)('refuses %s metadata', async (kind) => { + const f = await fixture(); + const row = JSON.stringify({ type: 'session_meta', payload: { id: A, cwd: f.root, originator: 'codeman_managed' } }); + await f.put( + A, + {}, + kind === 'partial' + ? row + : kind === 'duplicate' + ? row + '\n' + row + '\n' + : row.slice(0, -2) + ',"instructions":"' + 'x'.repeat(140000) + '"}}\n' + ); + expect(await findExactCodexConversation(f.options)).toBeUndefined(); +}); +it('enforces traversal/read caps and ignores symlinked directories', async () => { + const f = await fixture(); + await f.put(A); + expect(await findExactCodexConversation(f.options, { maxEntries: 0, maxBytes: 1048576 })).toBeUndefined(); + expect(await findExactCodexConversation(f.options, { maxEntries: 8192, maxBytes: 128 })).toBeUndefined(); + await mkdir(join(f.root, 'elsewhere')); + await symlink(join(f.root, 'elsewhere'), join(f.home, 'sessions', 'alias')); + expect((await findExactCodexConversation(f.options))?.id).toBe(A); +}); +it('resolves per-session, process and default CODEX_HOME relative to the session workspace', () => { + expect(resolveEffectiveCodexHome('custom', '/tmp/work', { CODEX_HOME: '/other' })).toBe('/tmp/work/custom'); + expect(resolveEffectiveCodexHome(undefined, '/tmp/work', { CODEX_HOME: 'process' })).toBe('/tmp/work/process'); + expect(resolveEffectiveCodexHome(undefined, '/tmp/work', { HOME: '/tmp/user' })).toBe('/tmp/user/.codex'); +}); + +it.each([ + ['trailing-space', 'codex-home ', 'codex-home'], + ['whitespace-only', ' ', '.codex'], +])( + 'keeps the raw %s CODEX_HOME namespace through the Session getter and attribution helper', + async (_label, configured, wrongHome) => { + const { Session } = await import('../src/session.js'); + const f = await fixture(); + const expectedHome = join(f.root, configured); + const siblingHome = join(f.root, wrongHome); + const record = + JSON.stringify({ type: 'session_meta', payload: { id: A, cwd: f.root, originator: 'codeman_managed' } }) + '\n'; + for (const home of [expectedHome, siblingHome]) { + await mkdir(join(home, 'sessions'), { recursive: true }); + await writeFile(join(home, 'sessions', `rollout-${A}.jsonl`), record); + } + const session = new Session({ + id: 'managed', + workingDir: f.root, + mode: 'codex', + envOverrides: { CODEX_HOME: configured }, + }); + try { + const codexHome = resolveEffectiveCodexHome(session.getCodexHomeOverride(), session.workingDir, { HOME: f.root }); + expect(await findExactCodexConversation({ ...f.options, codexHome })).toEqual({ + id: A, + path: join(expectedHome, 'sessions', `rollout-${A}.jsonl`), + }); + expect(session.getCodexHomeOverride()).toBe(configured); + } finally { + await session.stop(false); + } + } +); + +it('attributes an owned rollout among 320 unrelated full-size metadata prefixes', async () => { + const f = await fixture(); + const path = await f.put(A); + await Promise.all( + Array.from({ length: 320 }, (_, n) => { + const id = `10000000-1111-4111-8111-${String(n).padStart(12, '0')}`; + return f.put(id, { originator: 'codex_cli', instructions: 'x'.repeat(5000) }); + }) + ); + expect(await findExactCodexConversation(f.options)).toEqual({ id: A, path }); + expect( + await findExactCodexConversation(f.options, { maxEntries: 8192, maxBytes: 1048576, maxPrefixBytes: 4096 }) + ).toBeUndefined(); +}); + +it.each(['workspace', 'metadata'] as const)( + 'probes %s bounded past the cap before canonical filesystem reads', + async (where) => { + const { vi } = await import('vitest'); + const probe = await import('../src/utils/bounded-path-probe.js'); + const f = await fixture(); + const alias = join(f.root, 'workspace-alias'); + await symlink(f.root, alias); + await f.put(A, { cwd: where === 'metadata' ? alias : f.root }); + const blocked = where === 'workspace' ? f.root : alias; + const spy = vi + .spyOn(probe, 'probePathKind') + .mockImplementation(async (path) => (path === blocked ? 'unknown' : 'directory')); + try { + expect(await findExactCodexConversation(f.options)).toBeUndefined(); + expect(spy).toHaveBeenCalledWith(blocked, { pastCap: true }); + } finally { + spy.mockRestore(); + } + } +); + +describe('remembered attribution on a large, never-pruned store', () => { + beforeEach(() => __clearCodexAttributionMemo()); + const OWNED = '33333333-3333-4333-8333-333333333333'; + const NEWER = '44444444-4444-4444-8444-444444444444'; + /** codex's own layout: sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl */ + async function dated(f: Awaited<ReturnType<typeof fixture>>, day: string, id: string, payload: object = {}) { + const dir = join(f.home, 'sessions', ...day.split('/')); + await mkdir(dir, { recursive: true }); + const path = join(dir, `rollout-${day.replaceAll('/', '-')}T00-00-00-${id}.jsonl`); + await writeFile( + path, + JSON.stringify({ + type: 'session_meta', + payload: { id, cwd: f.root, originator: 'codeman_managed', ...payload }, + }) + '\n' + ); + return path; + } + /** `count` unrelated 19 KiB rollouts (a real session_meta size) spread over older days. */ + async function unrelated(f: Awaited<ReturnType<typeof fixture>>, count: number) { + const instructions = 'x'.repeat(19 * 1024); + for (let start = 0; start < count; start += 200) { + await Promise.all( + Array.from({ length: Math.min(200, count - start) }, (_, k) => { + const n = start + k; + const day = `2026/${String(1 + (n % 9)).padStart(2, '0')}/${String(1 + (n % 28)).padStart(2, '0')}`; + return dated(f, day, `10000000-1111-4111-8111-${String(n).padStart(12, '0')}`, { + originator: 'codex_cli', + instructions, + }); + }) + ); + } + } + const small = { maxEntries: 20, maxBytes: 1048576 }; + + it('attributes one owned rollout beside 2,100 unrelated full-size rollouts', async () => { + const f = await fixture(); + await unrelated(f, 2100); + const path = await dated(f, '2026/10/24', OWNED); + expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path }); + }, 60_000); + + it('re-verifies only the remembered file on a later call instead of re-walking the store', async () => { + const f = await fixture(); + await unrelated(f, 300); + const path = await dated(f, '2026/10/24', OWNED); + expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path }); + // 20 entries cannot cover 300 rollouts in 9 months of day directories: only the + // remembered head plus the directories not older than it fit. + expect(await findExactCodexConversation(f.options, small)).toEqual({ id: OWNED, path }); + __clearCodexAttributionMemo(); + expect(await findExactCodexConversation(f.options, small)).toBeUndefined(); + }); + + it('still catches a newer owned rollout (codex /new) after remembering one', async () => { + const f = await fixture(); + await unrelated(f, 50); + const path = await dated(f, '2026/10/24', OWNED); + expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path }); + await dated(f, '2026/10/25', NEWER); + expect(await findExactCodexConversation(f.options, small)).toBeUndefined(); + expect(await findExactCodexConversation(f.options)).toBeUndefined(); + }); + + it('answers unattributed, without a full walk, when the remembered file verifies but its incremental scan runs out', async () => { + const f = await fixture(); + const path = await dated(f, '2026/10/24', OWNED); + expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path }); + // 30 rollouts in the remembered day itself: the incremental scan reads them all, + // so 20 entries cannot cover it, and a full walk (a superset) could not either. + await Promise.all( + Array.from({ length: 30 }, (_, n) => + dated(f, '2026/10/24', `20000000-2222-4222-8222-${String(n).padStart(12, '0')}`, { originator: 'codex_cli' }) + ) + ); + expect(await findExactCodexConversation(f.options, small)).toBeUndefined(); + // The memo was dropped, so a call with room walks the store and attributes again. + expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path }); + }); + + it('drops the remembered file when it is replaced, and walks again', async () => { + const f = await fixture(); + const path = await dated(f, '2026/10/24', OWNED); + expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path }); + await rm(path); + await dated(f, '2026/10/24', OWNED, { originator: 'codex_cli' }); + expect(await findExactCodexConversation(f.options)).toBeUndefined(); + const moved = await dated(f, '2026/10/23', NEWER); + expect(await findExactCodexConversation(f.options)).toEqual({ id: NEWER, path: moved }); + }); +}); diff --git a/test/agent-coordination-setting.test.ts b/test/agent-coordination-setting.test.ts new file mode 100644 index 00000000..57ac54b1 --- /dev/null +++ b/test/agent-coordination-setting.test.ts @@ -0,0 +1,90 @@ +/** + * @fileoverview The synced `agentCoordinationEnabled` setting (default OFF) that gates the + * experimental inspect/stage/submit routes: the strict settings schema admits it, App + * Settings loads and saves it under the same control id, and its row reads in Chinese + * (zh-CN) with no English left beyond the command and product names. The route gate itself + * is pinned in test/routes/agent-session-routes.test.ts. + * + * Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { afterAll, describe, expect, it } from 'vitest'; +import { SettingsUpdateSchema } from '../src/web/schemas.js'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8'); +const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'); +const SETTINGS_UI = readFileSync(resolve(PUBLIC, 'settings-ui.js'), 'utf8'); +const ID = 'appSettingsAgentCoordination'; + +type Api = { configure(o: Record<string, string>): void; start(): void }; + +/** What may stay Latin: the product, CLI, key and protocol names and the three commands. */ +const leftover = (text: string) => + text.replace(/\b(Codex|Enter|HTTP|inspect|stage|submit)\b/g, '').match(/[A-Za-z]+/g) ?? []; + +function rowStrings(doc: Document): { label: string; description: string } { + const row = doc.getElementById(ID)!.closest('.set-row')!; + return { + label: row.querySelector('.set-row-label')!.textContent!.trim(), + description: row.querySelector('.set-row-desc')!.textContent!.trim(), + }; +} + +const source = new JSDOM(INDEX); +const english = rowStrings(source.window.document); +const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' }); +vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' }); +const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n; +api.start(); +api.configure({ language: 'zh-CN' }); +const chinese = rowStrings(dom.window.document); + +afterAll(() => { + source.window.close(); + dom.window.close(); +}); + +describe('agentCoordinationEnabled', () => { + it('is a boolean the strict settings schema accepts, and nothing else', () => { + expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: true }).success).toBe(true); + expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: false }).success).toBe(true); + expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: 'yes' }).success).toBe(false); + }); + + it('has one checkbox in App Settings, loaded only from an explicit true and saved by id', () => { + expect(INDEX.split(`id="${ID}"`)).toHaveLength(2); + expect(source.window.document.getElementById(ID)!.getAttribute('type')).toBe('checkbox'); + expect(SETTINGS_UI).toContain( + `document.getElementById('${ID}').checked = settings.agentCoordinationEnabled === true;` + ); + expect(SETTINGS_UI).toContain(`agentCoordinationEnabled: document.getElementById('${ID}').checked,`); + }); + + it('sits in the synced Cross-session features group', () => { + const group = source.window.document.getElementById(ID)!.closest('.set-group')!; + expect(group.querySelector('.set-group-head h4')!.textContent).toBe('Cross-session features'); + expect(group.querySelector('.set-group-head .set-scope')!.textContent).toBe('synced'); + }); + + it('finds the row (the translation check is not vacuous)', () => { + expect(english.label).toBe('Experimental agent coordination'); + expect(english.description).toMatch(/^Lets agents read another session's output/); + }); + + it('translates the label and the description, with no English left', () => { + for (const key of ['label', 'description'] as const) { + expect(chinese[key]).not.toBe(english[key]); + expect(leftover(chinese[key])).toEqual([]); + } + }); + + it('adds each key to the dictionary once', () => { + for (const text of [english.label, english.description]) { + expect(I18N.split(`'${text}'`).length + I18N.split(`"${text}"`).length - 2).toBe(1); + } + }); +}); diff --git a/test/agent-inbox-server-wiring.test.ts b/test/agent-inbox-server-wiring.test.ts index 6cb416cd..1b8e5a84 100644 --- a/test/agent-inbox-server-wiring.test.ts +++ b/test/agent-inbox-server-wiring.test.ts @@ -29,6 +29,7 @@ function bareServer(live: string[] = [], persisted: string[] = []): CleanupServe Object.assign(server, { sessions: new Map(live.map((id) => [id, {}])), keepWarmService: { remove: () => {} }, + gitFetchScheduler: { remove: () => {} }, store: { getSession: () => null, getSessions: () => Object.fromEntries(persisted.map((id) => [id, {}])), @@ -48,6 +49,8 @@ function bareServer(live: string[] = [], persisted: string[] = []): CleanupServe stopTranscriptWatcher: () => {}, persistDeb: { cancelKey: () => {} }, sse: { cleanupSessionBatches: () => {} }, + // A real delete also tells the Discord bridge, which frees its per-tab state and may archive the thread. + discordBridge: { sessionDeleted: async () => {}, forgetTab: () => {} }, broadcast: () => {}, }); return server; diff --git a/test/agent-inspection.test.ts b/test/agent-inspection.test.ts new file mode 100644 index 00000000..cadbf53f --- /dev/null +++ b/test/agent-inspection.test.ts @@ -0,0 +1,202 @@ +/** @fileoverview Bounded JSONL inspection: cursors, byte budgets and file generations. */ +import { afterEach, beforeEach, expect, it } from 'vitest'; +import { mkdtemp, writeFile, appendFile, rename, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { INSPECT_CURSORS_PER_SCOPE, InspectionReader } from '../src/agent-inspection.js'; +let dir: string; +let file: string; +let reader: InspectionReader; +const scope = { owner: 'alice', sessionId: 's', generation: 'pane-1', conversationId: 'thread-1' }; +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'inspect-')); + file = join(dir, 'rollout.jsonl'); + reader = new InspectionReader(); +}); +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); +it('returns complete UTF8 records within the byte budget and resumes exactly', async () => { + const row = JSON.stringify({ text: 'é😀' }) + '\n'; + await writeFile(file, row + row); + const first = await reader.read(file, scope, Buffer.byteLength(row)); + expect(first.evidence).toBe(row); + expect(first.truncated).toBe(true); + await appendFile(file, row); + const next = await reader.read(file, scope, 1024, first.cursor); + expect(next.evidence).toBe(row); + expect(next.truncated).toBe(false); +}); +it('does not consume partial final records', async () => { + await writeFile(file, '{"text":"hello'); + const first = await reader.read(file, scope, 100); + expect(first.evidence).toBe(''); + await appendFile(file, '"}\n'); + const next = await reader.read(file, scope, 100, first.cursor); + expect(next.evidence).toBe('{"text":"hello"}\n'); +}); +it('reports an oversized incremental record without splitting it or advancing past it', async () => { + await writeFile(file, '{}\n'); + const initial = await reader.read(file, scope, 100); + await appendFile(file, JSON.stringify({ text: 'x'.repeat(300000) }) + '\n'); + const result = await reader.read(file, scope, 100, initial.cursor); + expect(result.evidence).toBe(''); + expect(result.truncated).toBe(true); + expect(result.blockedReason).toBe('record-exceeds-scan-limit'); + expect(result.scannedBytes).toBeLessThanOrEqual(262144); +}); +it('rejects malformed, cross-owner and changed-session cursors', async () => { + await writeFile(file, '{}\n'); + const first = await reader.read(file, scope, 100); + for (const changed of [ + { ...scope, owner: 'bob' }, + { ...scope, sessionId: 'other' }, + { ...scope, generation: 'new' }, + { ...scope, conversationId: 'new' }, + ]) { + await expect(reader.read(file, changed, 100, first.cursor)).rejects.toThrow(/cursor/i); + } + await expect(reader.read(file, scope, 100, 'garbage')).rejects.toThrow(/cursor/i); +}); +it('rejects truncation, replacement and rewritten consumed evidence', async () => { + await writeFile(file, '{}\n{}\n'); + const first = await reader.read(file, scope, 100); + await writeFile(file, '{}\n'); + await expect(reader.read(file, scope, 100, first.cursor)).rejects.toThrow(/cursor/i); + await writeFile(file, '{}\n{}\n'); + const second = await reader.read(file, scope, 100); + await writeFile(file, '[]\n[]\n'); + await expect(reader.read(file, scope, 100, second.cursor)).rejects.toThrow(/cursor/i); + const third = await reader.read(file, scope, 100); + await rename(file, join(dir, 'old')); + await writeFile(file, '[]\n[]\n'); + await expect(reader.read(file, scope, 100, third.cursor)).rejects.toThrow(/cursor/i); +}); +it('expires cursors and bounds retained state', async () => { + await writeFile(file, '{}\n'); + let now = 0; + reader = new InspectionReader(() => now, 2, 1000); + const first = await reader.read(file, scope, 100); + await reader.read(file, scope, 100); + await reader.read(file, scope, 100); + await expect(reader.read(file, scope, 100, first.cursor)).rejects.toThrow(/cursor/i); + const last = await reader.read(file, scope, 100); + now = 1001; + await expect(reader.read(file, scope, 100, last.cursor)).rejects.toThrow(/cursor/i); +}); +it('bounds strict attribution itself and refuses an incomplete inventory', async () => { + const { mkdir } = await import('node:fs/promises'); + const { findExactCodexConversation } = await import('../src/agent-codex-identity.js'); + const home = join(dir, 'codex'); + const sessions = join(home, 'sessions'); + await mkdir(sessions, { recursive: true }); + const id = '11111111-1111-4111-8111-111111111111'; + await writeFile( + join(sessions, `rollout-${id}.jsonl`), + JSON.stringify({ type: 'session_meta', payload: { id, cwd: dir, originator: 'codeman_s' } }) + '\n' + ); + await writeFile(join(sessions, 'other.txt'), 'other'); + const options = { codexHome: home, sessionId: 's', workingDir: dir }; + expect((await findExactCodexConversation(options))?.id).toBe(id); + expect(await findExactCodexConversation(options, { maxEntries: 1, maxBytes: 16384 })).toBeUndefined(); +}); + +it('starts at recent complete records past oversized bootstrap evidence, labeling all omission', async () => { + const old = JSON.stringify({ system: 'x'.repeat(300000) }) + '\n'; + const latest = JSON.stringify({ assistant: 'latest 😀' }) + '\n'; + await writeFile(file, old + latest); + const result = await reader.read(file, scope, Buffer.byteLength(latest)); + expect(result.evidence).toBe(latest); + expect(result.omittedBeforeBytes).toBe(Buffer.byteLength(old)); + expect(result.truncated).toBe(true); + await appendFile(file, '{"assistant":"next"}\n'); + expect((await reader.read(file, scope, 100, result.cursor)).evidence).toBe('{"assistant":"next"}\n'); +}); +it('attributes a real-sized session_meta row rather than requiring a tiny header', async () => { + const { mkdir } = await import('node:fs/promises'); + const { findExactCodexConversation } = await import('../src/agent-codex-identity.js'); + const home = join(dir, 'codex'); + await mkdir(join(home, 'sessions'), { recursive: true }); + const id = '11111111-1111-4111-8111-111111111111'; + await writeFile( + join(home, 'sessions', `rollout-${id}.jsonl`), + JSON.stringify({ + type: 'session_meta', + payload: { id, cwd: dir, originator: 'codeman_s', instructions: 'x'.repeat(20000) }, + }) + '\n' + ); + expect( + ( + await findExactCodexConversation( + { codexHome: home, sessionId: 's', workingDir: dir }, + { maxEntries: 8192, maxBytes: 1048576 } + ) + )?.id + ).toBe(id); +}); +it('rejects same-size rewrites outside the bounded cursor anchors', async () => { + const { utimes } = await import('node:fs/promises'); + const row = JSON.stringify({ text: 'a'.repeat(20000) }) + '\n'; + await writeFile(file, row); + await utimes(file, 1, 1); + const first = await reader.read(file, scope, 30000); + await writeFile(file, row.slice(0, 10000) + 'b' + row.slice(10001)); + await expect(reader.read(file, scope, 30000, first.cursor)).rejects.toThrow(/cursor/i); +}); +it('keeps cursor capacity bounded across concurrent reads', async () => { + await writeFile(file, '{}\n'); + reader = new InspectionReader(Date.now, 2); + const results = await Promise.all(Array.from({ length: 16 }, () => reader.read(file, scope, 100))); + const resumed = await Promise.allSettled(results.map((result) => reader.read(file, scope, 100, result.cursor))); + expect(resumed.filter((result) => result.status === 'fulfilled').length).toBeLessThanOrEqual(2); +}); +it('caps cursors per owner and session, so one polling scope cannot evict another', async () => { + await writeFile(file, '{}\n'); + const other = { ...scope, owner: 'bob', sessionId: 't' }; + const bobs = await reader.read(file, other, 100); + // Alice polls every second for well past the old global FIFO of 1,024. + let last = await reader.read(file, scope, 100); + for (let i = 0; i < 1100; i++) last = await reader.read(file, scope, 100, last.cursor); + // Bob's cursor survives, and so does a retry of Alice's last call. + await expect(reader.read(file, other, 100, bobs.cursor)).resolves.toMatchObject({ evidence: '' }); + await expect(reader.read(file, scope, 100, last.cursor)).resolves.toMatchObject({ evidence: '' }); + // Alice keeps only her newest cursors: the oldest of one more than the cap is retired. + const issued: string[] = []; + for (let i = 0; i <= INSPECT_CURSORS_PER_SCOPE; i++) issued.push((await reader.read(file, scope, 100)).cursor); + await expect(reader.read(file, scope, 100, issued[0])).rejects.toThrow(/cursor/i); + await expect(reader.read(file, scope, 100, issued[issued.length - 1])).resolves.toBeDefined(); + // The cap counts owner and session: a new pane generation of Alice's session shares + // it, Bob's session does not. + const nextPane = { ...scope, generation: 'pane-2' }; + for (let i = 0; i < INSPECT_CURSORS_PER_SCOPE; i++) await reader.read(file, nextPane, 100); + await expect(reader.read(file, scope, 100, issued[issued.length - 1])).rejects.toThrow(/cursor/i); + await expect(reader.read(file, other, 100, bobs.cursor)).resolves.toBeDefined(); +}); +it('keeps an incomplete UTF8 sequence pending until its JSONL record is complete', async () => { + const row = Buffer.from('{"text":"😀"}\n'); + const split = row.indexOf(Buffer.from('😀')) + 2; + await writeFile(file, row.subarray(0, split)); + const first = await reader.read(file, scope, 100); + expect(first.evidence).toBe(''); + await appendFile(file, row.subarray(split)); + expect((await reader.read(file, scope, 100, first.cursor)).evidence).toBe(row.toString('utf8')); +}); + +it('treats a full incremental page as pageable rather than a blocked oversized record', async () => { + await writeFile(file, '{}\n'); + const initial = await reader.read(file, scope, 100); + await appendFile(file, '{}\n{}\n'); + const page = await reader.read(file, scope, 3, initial.cursor); + expect(page.evidence).toBe('{}\n'); + expect(page.truncated).toBe(true); + expect(page.blockedReason).toBeUndefined(); + const next = await reader.read(file, scope, 3, page.cursor); + expect(next.evidence).toBe('{}\n'); +}); + +it('still labels the first selected oversized tail record after omitted older bytes', async () => { + await writeFile(file, '{}\n' + JSON.stringify({ text: 'x'.repeat(100) }) + '\n'); + const page = await reader.read(file, scope, 10); + expect(page.evidence).toBe(''); + expect(page.blockedReason).toBe('record-exceeds-output-budget'); +}); diff --git a/test/agent-staging-session.test.ts b/test/agent-staging-session.test.ts new file mode 100644 index 00000000..38d2d852 --- /dev/null +++ b/test/agent-staging-session.test.ts @@ -0,0 +1,544 @@ +/** @fileoverview Guarded input observes all production write and lifecycle primitives. */ +import { expect, it, vi } from 'vitest'; +import { formatPaneSnapshot } from '../src/tmux-manager.js'; +import { Session } from '../src/session.js'; +import { promptSha256 } from '../src/agent-staging.js'; +it('fences writes and reused-pid pane lifecycles without persisting prompt content', async () => { + let screen = '› \n GPT-6-Luna default · ~/case\n'; + const mux = { + isAvailable: () => true, + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot(screen.trimEnd().split('\n'), { + cols: 100, + rows: 20, + cursorX: screen.startsWith('› hello') ? 7 : 2, + cursorY: 0, + }); + }, + sendInput: async () => true, + }; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux, + muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 12345 }, + } as unknown as ConstructorParameters<typeof Session>[0]); + const before = session.guardedInputSnapshot(); + expect(before?.composerSha).toBe(promptSha256('')); + await session.writeViaMux('hello'); + screen = '› hello\n GPT-6-Luna default · ~/case\n'; + expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 1); + session.trackUserInput('x'); + expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 2); + await session.performAuxiliaryInput(async () => {}); + expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 3); + const generation = session.inspectionGeneration; + await (session as unknown as { _withPaneLifecycle(op: () => Promise<void>): Promise<void> })._withPaneLifecycle( + async () => { + expect(session.guardedInputSnapshot()).toBeNull(); + } + ); + expect(session.inspectionGeneration).not.toBe(generation); + session.markPromptSubmitted(); + expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 4); + await session.stop(false); + expect(session.guardedInputSnapshot()).toBeNull(); +}); + +it('checks conditional Enter at the dispatch boundary and holds concurrent input until after it', async () => { + let dispatch: (() => void) | undefined; + const writes: string[] = []; + const mux = { + isAvailable: () => true, + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 7, + cursorY: 0, + }); + }, + sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { + await new Promise<void>((resolve) => { + dispatch = resolve; + }); + if (options?.beforeDispatch && !options.beforeDispatch()) return false; + writes.push(text); + return true; + }, + }; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux, + muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters<typeof Session>[0]); + try { + const settle = () => new Promise<void>((resolve) => setImmediate(resolve)); + const expected = session.guardedInputSnapshot()!; + const sending = session.submitGuardedInput(expected); + // Held behind the lease, not refused: it still invalidates the pending Enter, and + // it goes out once the lease is released. + const other = session.writeViaMux('other'); + dispatch!(); + expect(await sending).toBe(false); + expect(writes).toEqual([]); + await settle(); + dispatch!(); + expect(await other).toBe(true); + expect(writes).toEqual(['other']); + const next = session.submitGuardedInput(session.guardedInputSnapshot()!); + dispatch!(); + expect(await next).toBe(true); + expect(writes).toEqual(['other', '\r']); + const restarting = session.submitGuardedInput(session.guardedInputSnapshot()!); + await (session as unknown as { _withPaneLifecycle(op: () => Promise<void>): Promise<void> })._withPaneLifecycle( + async () => {} + ); + dispatch!(); + expect(await restarting).toBe(false); + expect(writes).toEqual(['other', '\r']); + const auxiliary = session.submitGuardedInput(session.guardedInputSnapshot()!); + const key = session.performAuxiliaryInput(async () => { + writes.push('aux'); + }); + await settle(); + // The named key waits for the lease instead of failing, and never runs inside it. + expect(writes).toEqual(['other', '\r']); + dispatch!(); + expect(await auxiliary).toBe(false); + await key; + expect(writes).toEqual(['other', '\r', 'aux']); + } finally { + await session.stop(false); + } +}); + +it('cancels an older automatic submit verifier before staging an identical prompt', async () => { + vi.useFakeTimers(); + let composer = ''; + const writes: string[] = []; + const mux = { + isAvailable: () => true, + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot([`› ${composer}`, ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 2 + composer.length, + cursorY: 0, + }); + }, + capturePaneText: () => `› ${composer}\n GPT-6-Luna default · ~/case`, + sendInput: async (_id: string, text: string) => { + writes.push(text); + composer += text; + return true; + }, + }; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux, + muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters<typeof Session>[0]); + try { + session.verifySubmitted('hello'); + expect(await session.stageGuardedInput('hello')).toBe(true); + await vi.advanceTimersByTimeAsync(2000); + expect(writes).toEqual(['hello']); + } finally { + await session.stop(false); + vi.useRealTimers(); + } +}); + +it('routes automatic verifier Enter through the shared guarded input boundary', async () => { + vi.useFakeTimers(); + let release!: () => void; + const writes: string[] = []; + const mux = { + isAvailable: () => true, + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 7, + cursorY: 0, + }); + }, + capturePaneText: () => '› hello\n GPT-6-Luna default · ~/case', + sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { + if (options?.beforeDispatch) { + await new Promise<void>((resolve) => { + release = resolve; + }); + if (!options.beforeDispatch()) return false; + } + writes.push(text); + return true; + }, + }; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux, + muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters<typeof Session>[0]); + try { + session.verifySubmitted('hello'); + const submission = session.submitGuardedInput(session.guardedInputSnapshot()!); + await vi.advanceTimersByTimeAsync(2000); + release(); + expect(await submission).toBe(false); + await vi.advanceTimersByTimeAsync(60000); + expect(writes).toEqual([]); + } finally { + await session.stop(false); + vi.useRealTimers(); + } +}); + +it('refuses legacy one-shot prompt entrypoints during a guarded submission before spawning', async () => { + let release!: () => void; + const writes: string[] = []; + const mux = { + isAvailable: () => true, + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 7, + cursorY: 0, + }); + }, + sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { + await new Promise<void>((resolve) => { + release = resolve; + }); + if (options?.beforeDispatch && !options.beforeDispatch()) return false; + writes.push(text); + return true; + }, + }; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux, + muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters<typeof Session>[0]); + const access = session as unknown as { ptyProcess: { write(data: string): void } | null }; + try { + const submission = session.submitGuardedInput(session.guardedInputSnapshot()!); + // This also prevents a real spawn if the lease guard regresses. + access.ptyProcess = { + write: () => { + throw new Error('must not write'); + }, + }; + await expect(session.runPrompt('other')).rejects.toThrow('Guarded submission'); + await expect(session.sendInput('other')).rejects.toThrow('Guarded submission'); + access.ptyProcess = null; + release(); + expect(await submission).toBe(false); + expect(writes).toEqual([]); + } finally { + access.ptyProcess = null; + await session.stop(false); + } +}); + +it('counts an ordinary transport pending window before any awaited acknowledgement', async () => { + let release!: () => void; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux: { + isAvailable: () => true, + sendInput: async () => { + await new Promise<void>((resolve) => { + release = resolve; + }); + return true; + }, + }, + muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters<typeof Session>[0]); + try { + const writing = session.writeViaMux('hello'); + expect(session.guardedInputPendingWrites).toBe(1); + release(); + await writing; + expect(session.guardedInputPendingWrites).toBe(0); + } finally { + release?.(); + await session.stop(false); + } +}); + +it('uses fresh normalized pane rows for working/dialog denial before exact composer proof', async () => { + let working = false; + const capture = vi.fn(() => + formatPaneSnapshot( + [working ? '• Working (1s · esc to interrupt)' : 'ready', '› hello', ' GPT-6-Luna default · ~/case'], + { cols: 100, rows: 20, cursorX: 7, cursorY: 1 } + ) + ); + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux: { + isAvailable: () => true, + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return capture(); + }, + capturePaneText: () => { + throw Error('must use same frame'); + }, + }, + muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters<typeof Session>[0]); + try { + const blocked = vi.spyOn(session as unknown as { _paneShowsBlocked(text: string): boolean }, '_paneShowsBlocked'); + expect(session.guardedInputSnapshot()).not.toBeNull(); + expect(blocked.mock.calls[0][0]).toContain('ready\n› hello\n'); + working = true; + expect(session.guardedInputSnapshot()).toBeNull(); + blocked.mockReturnValue(true); + working = false; + expect(session.guardedInputSnapshot()).toBeNull(); + } finally { + await session.stop(false); + } +}); + +it('keeps raw user input synchronous and preserves receipts for focus/mouse/noop reports', async () => { + const writes: string[] = []; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux: { + isAvailable: () => true, + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 7, + cursorY: 0, + }); + }, + }, + muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters<typeof Session>[0]); + const access = session as unknown as { + ptyProcess: { write(data: string): void; resize(cols: number, rows: number): void } | null; + }; + access.ptyProcess = { + write: (text) => { + writes.push(text); + }, + resize: () => {}, + }; + try { + const before = session.guardedInputSnapshot()!; + for (const data of ['\x1b[I', '\x1b[O', '\x1b[<0;10;5M', '\x1b[M !!', '']) { + expect(session.write(data, { fromUser: true })).toBe(true); + expect(writes.at(-1)).toBe(data); + expect(session.guardedInputSnapshot()?.revision).toBe(before.revision); + } + session.write('\x1b[Oedit'); + expect(session.guardedInputSnapshot()?.revision).toBe(before.revision + 1); + const generation = session.inspectionGeneration; + session.owner = 'other'; + session.owner = undefined; + expect(session.inspectionGeneration).not.toBe(generation); + const priorResize = session.guardedInputGeneration; + session.resize(80, 24); + expect(session.guardedInputGeneration).not.toBe(priorResize); + } finally { + access.ptyProcess = null; + await session.stop(false); + } +}); + +it('refuses a message that would wrap using the actual captured indent and width before any write', async () => { + const writes: string[] = []; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux: { + isAvailable: () => true, + capturePaneBuffer: ( + _name: string, + _pane: string, + opts: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 30, rows: 20 }; + return formatPaneSnapshot([' › ', ' GPT-6 default · ~/case'], { + cols: 30, + rows: 20, + cursorX: 6, + cursorY: 0, + }); + }, + sendInput: async (_id: string, text: string) => { + writes.push(text); + return true; + }, + }, + muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters<typeof Session>[0]); + const { GuardedStageStore, guardedComposerChrome } = await import('../src/agent-staging.js'); + const { getCli } = await import('../src/config/cli-registry/registry.js'); + try { + const store = new GuardedStageStore(); + const target = { + id: session.id, + chrome: () => guardedComposerChrome(getCli('codex')), + snapshot: () => session.guardedInputSnapshot(), + write: (text: string) => session.stageGuardedInput(text), + submit: (snap: Parameters<Session['submitGuardedInput']>[0]) => session.submitGuardedInput(snap), + }; + await expect(store.stage(target, 'alice', 'x'.repeat(24))).rejects.toThrow(/wrap/); + expect(writes).toEqual([]); + } finally { + await session.stop(false); + } +}); + +it('keeps inspection generations stable across resize but invalidates a staged snapshot', async () => { + const session = new Session({ workingDir: '/tmp', mode: 'codex' }); + const access = session as unknown as { ptyProcess: { resize(cols: number, rows: number): void } | null }; + access.ptyProcess = { resize: () => {} }; + try { + const inspection = session.inspectionGeneration; + const staging = session.guardedInputGeneration; + session.resize(80, 24); + expect(session.inspectionGeneration).toBe(inspection); + expect(session.guardedInputGeneration).not.toBe(staging); + } finally { + access.ptyProcess = null; + await session.stop(false); + } +}); + +it('records a verifier re-press as the latest submit time', async () => { + vi.useFakeTimers(); + vi.setSystemTime(10000); + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux: { isAvailable: () => true, capturePaneText: () => '› hello\n', sendInput: async () => true }, + muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters<typeof Session>[0]); + try { + await session.writeViaMux('hello\r'); + const initial = session.lastSubmitAt; + await vi.advanceTimersByTimeAsync(2000); + expect(session.lastSubmitAt).toBe(initial + 2000); + } finally { + await session.stop(false); + vi.useRealTimers(); + } +}); + +it('stages through a real session whose first post-write capture is the torn frame codex produces', async () => { + // Reproduces the live 0.162.0 race: right after the keys land, the screen still + // shows the empty composer while the caret is already past the new text. That + // frame proves nothing; the repaint a few ms later does. + let composer = ''; + let torn = 0; + const writes: string[] = []; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux: { + isAvailable: () => true, + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + const shown = torn > 0 ? '' : composer; + if (torn > 0) torn--; + return formatPaneSnapshot([`› ${shown}`, ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 2 + composer.length, + cursorY: 0, + }); + }, + sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { + if (options?.beforeDispatch && !options.beforeDispatch()) return false; + writes.push(text); + if (text !== '\r') { + composer += text; + torn = 2; + } + return true; + }, + }, + muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters<typeof Session>[0]); + const { GuardedStageStore, guardedComposerChrome } = await import('../src/agent-staging.js'); + const { getCli } = await import('../src/config/cli-registry/registry.js'); + const sleeps: number[] = []; + const store = new GuardedStageStore(Date.now, 1024, 120000, { + sleep: async (ms) => { + sleeps.push(ms); + }, + }); + const target = { + id: session.id, + chrome: () => guardedComposerChrome(getCli('codex')), + snapshot: () => session.guardedInputSnapshot(), + write: (text: string) => session.stageGuardedInput(text), + submit: (snap: Parameters<Session['submitGuardedInput']>[0]) => session.submitGuardedInput(snap), + }; + try { + const receipt = await store.stage(target, 'alice', 'hello'); + expect(receipt.sha256).toBe(promptSha256('hello')); + expect(sleeps).toHaveLength(2); + await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).resolves.toMatchObject({ + submitted: true, + }); + expect(writes).toEqual(['hello', '\r']); + } finally { + await session.stop(false); + } +}); diff --git a/test/agent-staging.test.ts b/test/agent-staging.test.ts new file mode 100644 index 00000000..e6055b75 --- /dev/null +++ b/test/agent-staging.test.ts @@ -0,0 +1,380 @@ +/** @fileoverview Guarded staging one-shot and content-verification safety. */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createHash } from 'node:crypto'; +import { CODEX_REASONING_EFFORTS } from '../src/types/session.js'; +import { + GuardedStageStore, + STAGE_SETTLE_INTERVAL_MS, + STAGE_SETTLE_TIMEOUT_MS, + SUBMIT_QUIET_MS, + composerTriggerRefusal, + guardedComposerChrome, + readGuardedComposer, + readGuardedSnapshot, + type GuardedInputTarget, +} from '../src/agent-staging.js'; +import { getCli, listClis } from '../src/config/cli-registry/registry.js'; +/** Codex's composer chrome exactly as the registry declares it: no copy of the glyph or footer here. */ +const CODEX = guardedComposerChrome(getCli('codex'))!; +const readGuardedCodexComposer = (screen: string) => readGuardedComposer(screen, CODEX); +const readGuardedCodexSnapshot = (snapshot: string) => readGuardedSnapshot(snapshot, CODEX); +const sha = (s: string) => createHash('sha256').update(s).digest('hex'); +let store: GuardedStageStore; +let time: number; +let generation: string; +let revision: number; +let composer: string | null; +let writes: string[]; +let fail: boolean; +let target: GuardedInputTarget; +/** Every settle-window wait, on the fake clock: no real timer runs in this file. */ +let sleeps: number[]; +const fakeSleep = async (ms: number) => { + sleeps.push(ms); + time += ms; +}; +beforeEach(() => { + sleeps = []; + time = 0; + generation = 'pane-1'; + revision = 0; + composer = ''; + writes = []; + fail = false; + store = new GuardedStageStore(() => time, 2, 1000, { sleep: fakeSleep }); + target = { + id: 's', + chrome: () => CODEX, + snapshot: () => + composer === null ? null : { generation, revision, composerSha: sha(composer), availableColumns: 100 }, + submit: async () => target.write('\r'), + write: async (text) => { + writes.push(text); + revision++; + if (fail) return false; + composer = text === '\r' ? '' : composer + text; + return true; + }, + }; +}); +it('stages without Enter, stores only a hash, and submits exactly once', async () => { + const receipt = await store.stage(target, 'alice', 'hello'); + expect(writes).toEqual(['hello']); + expect(receipt.sha256).toBe(sha('hello')); + expect(JSON.stringify(receipt)).not.toContain('hello'); + await store.submit(target, 'alice', receipt.stageId, receipt.sha256); + expect(writes).toEqual(['hello', '\r']); + await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow(); +}); +it('refuses multiline, controls, oversized prompts and nonempty or unprovable composers before writing', async () => { + for (const text of ['', 'one\ntwo', 'one\rtwo', '\u001b', '\u0085', 'x'.repeat(20000), '\ud800']) + await expect(store.stage(target, 'alice', text)).rejects.toThrow(); + composer = 'existing'; + await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(); + composer = null; + await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(); + expect(writes).toEqual([]); +}); +it('refuses altered composer including whitespace-only differences', async () => { + const receipt = await store.stage(target, 'alice', 'hello world'); + composer = 'helloworld'; + await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow(); + expect(writes).toEqual(['hello world']); +}); +it('refuses changed generation, intervening input, wrong owner/session/hash and expiry', async () => { + for (const change of [ + () => { + generation = 'pane-2'; + }, + () => { + revision++; + }, + () => { + time += 1001; + }, + ]) { + composer = ''; + const receipt = await store.stage(target, 'alice', 'hello'); + change(); + await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow(); + } + composer = ''; + const receipt = await store.stage(target, 'alice', 'hello'); + await expect(store.submit(target, 'bob', receipt.stageId, receipt.sha256)).rejects.toThrow(); + await expect(store.submit({ ...target, id: 'other' }, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow(); + await expect(store.submit(target, 'alice', receipt.stageId, sha('other'))).rejects.toThrow(); + expect(writes.filter((s) => s === '\r')).toHaveLength(0); +}); +it('consumes before write so concurrent and failed deliveries never replay Enter', async () => { + const receipt = await store.stage(target, 'alice', 'hello'); + fail = true; + const results = await Promise.allSettled([ + store.submit(target, 'alice', receipt.stageId, receipt.sha256), + store.submit(target, 'alice', receipt.stageId, receipt.sha256), + ]); + expect(results.every((r) => r.status === 'rejected')).toBe(true); + expect(writes).toEqual(['hello', '\r']); + await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow(); +}); +it('does not issue a receipt on failed staging or intervening input during its write', async () => { + fail = true; + await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(); + fail = false; + composer = ''; + const altered = { + ...target, + write: async (s: string) => { + await target.write(s); + revision++; + return true; + }, + }; + await expect(store.stage(altered, 'alice', 'hello')).rejects.toThrow(); +}); + +it('reads exact composer spaces and refuses wrapped or modal frames', async () => { + expect(readGuardedCodexComposer('› hello world\n GPT-6-Luna default · ~/case\n')).toBe('hello world'); + expect( + readGuardedCodexComposer( + '› \x1b[2mAsk Codex to do anything\x1b[0m\n GPT-6-Luna default · ~/case\n ← for agents · ? for shortcuts' + ) + ).toBe(''); + expect(readGuardedCodexComposer('› wrapped\n more text\n GPT-6-Luna default · ~/case')).toBeNull(); + expect(readGuardedCodexComposer('› 1. Yes\n Press enter to confirm')).toBeNull(); + expect(readGuardedCodexComposer('› hello\n')).toBeNull(); +}); +it('requires capture cursor geometry, rejecting invisible trailing edits and moved cursors', async () => { + const { formatPaneSnapshot } = await import('../src/tmux-manager.js'); + const frame = (text: string, x: number, y = 0) => + formatPaneSnapshot([`› ${text}`, ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: x, cursorY: y }); + expect(readGuardedCodexSnapshot(frame('hello', 7))).toBe('hello'); + expect(readGuardedCodexSnapshot(frame('hello', 8))).toBeNull(); + expect(readGuardedCodexSnapshot(frame('hello', 4))).toBeNull(); + expect(readGuardedCodexSnapshot(frame('hello', 7, 1))).toBeNull(); + expect(readGuardedCodexSnapshot('› hello\n GPT-6-Luna default · ~/case')).toBeNull(); + expect(readGuardedCodexSnapshot(frame('你好', 6))).toBe('你好'); + expect(readGuardedCodexSnapshot(frame('\x1b[38;2;2;100;100mhello\x1b[0m', 7))).toBe('hello'); +}); +it('reserves receipt capacity before concurrent stages type', async () => { + store = new GuardedStageStore(() => time, 1, 1000, { sleep: fakeSleep }); + let release: (() => void) | undefined; + const firstTarget = { + ...target, + write: async (text: string) => { + await new Promise<void>((resolve) => { + release = resolve; + }); + return target.write(text); + }, + }; + const first = store.stage(firstTarget, 'alice', 'hello'); + const otherTarget = { ...target, id: 'other' }; + await expect(store.stage(otherTarget, 'alice', 'other')).rejects.toThrow(/capacity/i); + expect(writes).toEqual([]); + release!(); + await first; +}); + +it.each([...CODEX_REASONING_EFFORTS, 'default'])( + 'accepts the shared Codex effort word %s in the footer', + async (word) => { + expect(readGuardedCodexComposer(`› hello\n GPT-6 ${word} · ~/case\n`)).toBe('hello'); + } +); + +it('refuses messages that reach the available pane columns before typing', async () => { + target.snapshot = () => ({ generation, revision, composerSha: sha(composer ?? ''), availableColumns: 4 }); + for (const text of ['four', '界界']) await expect(store.stage(target, 'alice', text)).rejects.toThrow(); + expect(writes).toEqual([]); + await expect(store.stage(target, 'alice', 'abc')).resolves.toMatchObject({ submitted: false }); +}); + +it('takes the composer glyph and footer from the registry, and only codex declares a footer', () => { + const codex = getCli('codex')!.capabilities.workDetect!; + expect(CODEX.promptGlyph).toBe(codex.promptGlyph); + expect(CODEX.footerLine.source).toBe(codex.composerFooterLine); + // Another glyph drives the same reader: nothing in it is codex-specific. + const other = { promptGlyph: '>>', footerLine: CODEX.footerLine }; + expect(readGuardedComposer('>> hello\n GPT-6 default · ~/case\n', other)).toBe('hello'); + expect(readGuardedComposer('› hello\n GPT-6 default · ~/case\n', other)).toBeNull(); + const declaring = listClis() + .filter((entry) => entry.capabilities.workDetect?.composerFooterLine) + .map((entry) => entry.id); + expect(declaring).toEqual(['codex']); + expect(guardedComposerChrome(getCli('claude'))).toBeNull(); + // A pattern the ReDoS guard refuses yields no chrome, so no guarded input. + const unsafe = structuredClone(getCli('codex')!); + unsafe.capabilities.workDetect!.composerFooterLine = '(a+)+b'; + expect(guardedComposerChrome(unsafe)).toBeNull(); +}); + +describe('the settle window after typing (a live codex pane repaints 30 to 60 ms after the keys)', () => { + /** Serve these post-write frames in order, then the target's own snapshot. */ + function afterWrite(frames: Array<(() => ReturnType<GuardedInputTarget['snapshot']>) | null>) { + let written = false; + const own = target.snapshot; + const write = target.write; + target.write = async (text) => { + const ok = await write(text); + written = true; + return ok; + }; + target.snapshot = () => { + if (!written || frames.length === 0) return own(); + const next = frames.shift()!; + return next ? next() : null; + }; + } + const stale = () => ({ generation, revision, composerSha: sha(''), availableColumns: 100 }); + + it('names its bounds', () => { + expect(STAGE_SETTLE_TIMEOUT_MS).toBe(1000); + expect(STAGE_SETTLE_INTERVAL_MS).toBe(50); + }); + it('issues a receipt once a null first frame is followed by a valid one', async () => { + afterWrite([null]); + const receipt = await store.stage(target, 'alice', 'hello'); + expect(receipt.sha256).toBe(sha('hello')); + expect(sleeps).toEqual([STAGE_SETTLE_INTERVAL_MS]); + }); + it('issues a receipt once a stale frame (the old, empty composer) is followed by a valid one', async () => { + afterWrite([stale, stale]); + await expect(store.stage(target, 'alice', 'hello')).resolves.toMatchObject({ submitted: false }); + expect(sleeps).toHaveLength(2); + }); + it('refuses, saying the text may be in the composer, when no frame verifies within the window', async () => { + target.snapshot = () => (writes.length ? null : stale()); + const refusal = await store.stage(target, 'alice', 'hello').catch((error: Error) => error.message); + expect(refusal).toMatch(/within 1000 ms; no receipt was issued/); + expect(refusal).toMatch(/may already be in the composer.*Ctrl\+U/); + expect(sleeps.length).toBeGreaterThanOrEqual(STAGE_SETTLE_TIMEOUT_MS / STAGE_SETTLE_INTERVAL_MS); + }); + it('issues no receipt for a frame whose composer text differs from the message', async () => { + const write = target.write; + target.write = async (text) => { + await write(text); + composer = 'hellp'; // codex shows something else: a transformed paste, a dropped key + return true; + }; + await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(/did not show the staged message/); + // Nothing to submit: no receipt was stored for the frame that did not match. + composer = 'hello'; + await expect(store.submit(target, 'alice', '11111111-1111-4111-8111-111111111111', sha('hello'))).rejects.toThrow( + /Unknown/ + ); + }); + it('refuses at once, without waiting, when another writer moved the input during the window', async () => { + afterWrite([() => ({ generation, revision: revision + 1, composerSha: sha('hello'), availableColumns: 100 })]); + await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow( + /Input or pane changed while staging; no receipt was issued\. The message may already be in the composer/ + ); + expect(sleeps).toEqual([]); + }); + it('words a failed typing delivery like the submit refusal: it cannot know whether the text landed', async () => { + fail = true; + await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow( + /^Staging delivery failed or ambiguous; no receipt was issued\. The message may already be in the composer/ + ); + }); +}); + +describe('popups and commands that change what Enter does (codex rust-v0.162.0 bottom_pane)', () => { + const footer = ' gpt-5.6-terra default · ~/case'; + /** A frame as codex lays it out: transcript, popup, the composer's blank top row, composer, blank, footer. */ + const frame = (popup: string[], draft: string) => + ['• Earlier answer from the agent.', '', ...popup, '', `› ${draft}`, '', footer].join('\n'); + + it('reads the idle frame with transcript above it', () => { + expect(readGuardedCodexComposer(frame([], 'hello'))).toBe('hello'); + }); + it('refuses the slash-command list codex opens above `› /stat` (Enter would run /status)', () => { + const popup = [ + '› /status show current session configuration and token usage', + ' /statusline configure which items appear in the status line', + ]; + expect(readGuardedCodexComposer(frame(popup, '/stat'))).toBeNull(); + // A wrapped description row at the bottom of the list does not hide it. + expect(readGuardedCodexComposer(frame(['› /tier-one Use faster', ' inference'], '/tier'))).toBeNull(); + }); + it('refuses the skill, mention and file-search popups', () => { + const skill = ['› 1password [Skill] Example skill used in tests.', '', ' enter insert · esc close']; + const mention = [ + ' Mentions', + ' All Results Filesystem Only Plugins', + ' sa', + '› Sample Plugin Plugin with skills and an MCP server Plugin', + ' enter/tab insert · esc close · ↑/↓ select · ←/→ filter', + ]; + expect(readGuardedCodexComposer(frame(skill, '$1p'))).toBeNull(); + expect(readGuardedCodexComposer(frame(mention, '@sa'))).toBeNull(); + expect(readGuardedCodexComposer(frame([' loading...', ''], '@src'))).toBeNull(); + expect(readGuardedCodexComposer(frame(['no matches'], '/zz'))).toBeNull(); + }); + it('looks only at the block directly above the composer, not the transcript further up', () => { + const screen = [' /status shows the status', '', '• Answer.', '', '', '', '› hello', '', footer].join('\n'); + expect(readGuardedCodexComposer(screen)).toBe('hello'); + }); + it('takes the popup pattern and the trigger characters from the registry', () => { + const codex = getCli('codex')!.capabilities.workDetect!; + expect(CODEX.popupLine?.source).toBe(new RegExp(codex.composerPopupLine!).source); + expect(CODEX.commandPrefixes).toBe('/!'); + expect(CODEX.completionSigils).toBe('@$'); + // A popup pattern the ReDoS guard refuses yields no chrome, rather than chrome without it. + const unsafe = structuredClone(getCli('codex')!); + unsafe.capabilities.workDetect!.composerPopupLine = '(a+)+b'; + expect(guardedComposerChrome(unsafe)).toBeNull(); + }); + it.each([ + ['/stat', '/'], + ['/status now', '/'], + [' /review', '/'], + ['!ls -la', '!'], + ['review @src/app', '@'], + ['@notes', '@'], + ['run $skill', '$'], + ['costs $5', '$'], + ])('refuses %j before typing (trigger %s)', async (message, trigger) => { + expect(composerTriggerRefusal(message, CODEX)).toContain(`"${trigger}"`); + await expect(store.stage(target, 'alice', message)).rejects.toThrow( + trigger === '/' || trigger === '!' ? /codeman agent send/ : /completion popup/ + ); + expect(writes).toEqual([]); + }); + it.each(['look at @src/app.ts please', 'mail a@b.example', 'pay $5 today', 'a/b and c!'])( + 'stages %j: no trigger at the start, and the caret word opens nothing', + async (message) => { + expect(composerTriggerRefusal(message, CODEX)).toBeNull(); + await expect(store.stage(target, 'alice', message)).resolves.toMatchObject({ submitted: false }); + } + ); +}); + +describe("the quiet time before Enter (codex's 120 ms paste-burst window turns Enter into a newline)", () => { + it('waits out the rest of the window after the staged keys before checking and sending Enter', async () => { + expect(SUBMIT_QUIET_MS).toBeGreaterThan(120); + const receipt = await store.stage(target, 'alice', 'hello'); + time += 40; + await store.submit(target, 'alice', receipt.stageId, receipt.sha256); + expect(sleeps).toEqual([SUBMIT_QUIET_MS - 40]); + expect(writes).toEqual(['hello', '\r']); + }); + it('does not wait once the window has passed', async () => { + const receipt = await store.stage(target, 'alice', 'hello'); + time += SUBMIT_QUIET_MS; + await store.submit(target, 'alice', receipt.stageId, receipt.sha256); + expect(sleeps).toEqual([]); + }); + it('still refuses when a keystroke lands during the wait', async () => { + store = new GuardedStageStore(() => time, 2, 1000, { + sleep: async (ms) => { + await fakeSleep(ms); + revision++; // the user typed while submit waited + }, + }); + const receipt = await store.stage(target, 'alice', 'hello'); + await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow( + /changed; nothing submitted/ + ); + expect(writes).toEqual(['hello']); + }); +}); diff --git a/test/app-settings-structure.test.ts b/test/app-settings-structure.test.ts index 4045d812..cdd39caf 100644 --- a/test/app-settings-structure.test.ts +++ b/test/app-settings-structure.test.ts @@ -12,6 +12,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import { SettingsUpdateSchema } from '../src/web/schemas.js'; const publicDir = resolve(import.meta.dirname, '../src/web/public'); const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8'); @@ -195,6 +196,47 @@ describe('App Settings modal structure', () => { } }); + /** + * Git fetch on an interval (src/git-fetch-scheduler.ts). Both keys are SYNCED, not display keys, because + * the server's scheduler is what acts on them: kept per-device, the scheduler would read nobody's number. + * Off by default, and the interval clamped on save to the same bounds the schema accepts. + */ + it('keeps the Git fetch rows synced, off by default, and inside the bounds the scheduler acts on', () => { + const modal = settingsModal(); + const bar = modal.match(/<h4>Bottom bar<\/h4>([\s\S]*?)(?=<div class="set-group-head">)/)?.[1]; + expect(bar, 'the Git fetch rows are not in the Bottom bar group').toBeTruthy(); + for (const id of ['appSettingsGitFetchEnabled', 'appSettingsGitFetchIntervalSeconds']) { + const at = modal.indexOf(`id="${id}"`); + expect(at, `no element ${id}`).toBeGreaterThan(-1); + const row = modal.slice(modal.lastIndexOf('<div class="set-row"', at), modal.indexOf('</div>', at)); + expect(row).toContain('<span class="set-scope">synced</span>'); + } + // The number row's own bounds must agree with the schema's, or the browser sends a value the PUT rejects. + expect(modal).toContain('id="appSettingsGitFetchIntervalSeconds" class="set-num" value="300" min="30" max="3600"'); + + // Display keys are the half of the per-device split that keeps a value off the server. + const displayStart = settingsUi.indexOf('const displayKeys = new Set(['); + const displayBlock = settingsUi.slice(displayStart, settingsUi.indexOf(']);', displayStart)); + expect(displayBlock).not.toContain('gitFetchEnabled'); + expect(displayBlock).not.toContain('gitFetchIntervalSeconds'); + // The strip destructure is the other half: an aliased key here never reaches the settings PUT. + expect(settingsUi).not.toMatch(/gitFetch(?:Enabled|IntervalSeconds):\s*_\w+/); + + // Declared in the .strict() schema: an undeclared key 400s the whole settings PUT. + expect(SettingsUpdateSchema.safeParse({ gitFetchEnabled: true, gitFetchIntervalSeconds: 300 }).success).toBe(true); + + // Off by default (only an explicit true loads as on), and an empty box falls back to the default interval. + expect(settingsUi).toContain( + "document.getElementById('appSettingsGitFetchEnabled').checked = settings.gitFetchEnabled === true;" + ); + expect(settingsUi).toContain( + "document.getElementById('appSettingsGitFetchIntervalSeconds').value = settings.gitFetchIntervalSeconds ?? 300;" + ); + expect(settingsUi).toMatch( + /gitFetchIntervalSeconds: Math\.min\(\s*3600,\s*Math\.max\(30, parseInt\(document\.getElementById\('appSettingsGitFetchIntervalSeconds'\)\.value, 10\) \|\| 300\)\s*\),/ + ); + }); + it('has retired the modal-tab chrome everywhere, not just here', () => { // Session Options and Add Case moved onto this same `set-*` surface, so the // old tab classes have no users left. A reappearance means a modal drifted diff --git a/test/cli-agent-inspection.test.ts b/test/cli-agent-inspection.test.ts new file mode 100644 index 00000000..d1858a3a --- /dev/null +++ b/test/cli-agent-inspection.test.ts @@ -0,0 +1,127 @@ +/** @fileoverview Agent inspect/stage/submit transport, refusal and file boundaries. */ +import { expect, it } from 'vitest'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + agentInspect, + agentStage, + agentSubmit, + registerAgentCommands, + type AgentDeps, + type RequestOptions, +} from '../src/cli-agent.js'; +import { Command } from 'commander'; +const SELF = '11111111-1111-4111-8111-111111111111'; +const OTHER = '22222222-2222-4222-8222-222222222222'; +function deps() { + const calls: RequestOptions[] = [], + output: string[] = []; + const deps: AgentDeps = { + ctx: { apiUrl: 'http://localhost:1', selfId: SELF }, + json: true, + now: () => 1, + io: { out: (line) => output.push(line), err: () => {} }, + request: async (_ctx, options) => { + calls.push(options); + return { + status: 200, + text: '', + json: { success: true, data: { evidence: 'hello', stageId: OTHER, sha256: 'a'.repeat(64) } }, + }; + }, + }; + return { deps, calls, output }; +} +it('inspect uses the ownership-checked versioned endpoint with exact bounds and cursor', async () => { + const d = deps(); + expect(await agentInspect(d.deps, { id: OTHER, maxBytes: 42, since: 'cursor' })).toBe(0); + expect(d.calls[0]).toMatchObject({ + method: 'GET', + path: `/api/v1/sessions/${OTHER}/inspect`, + query: { maxBytes: 42, since: 'cursor' }, + }); + expect(JSON.parse(d.output[0]).evidence).toBe('hello'); + expect(await agentInspect(d.deps, { id: OTHER, maxBytes: 65537 })).toBe(4); + expect(d.calls).toHaveLength(1); +}); +it('stage reads a bounded UTF8 file and never adds Enter; submit sends only receipt and hash', async () => { + const dir = await mkdtemp(join(tmpdir(), 'agent-stage-cli-')); + try { + const path = join(dir, 'message'); + await writeFile(path, 'hello'); + const d = deps(); + expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(0); + expect(d.calls[0]).toMatchObject({ + method: 'POST', + path: `/api/v1/sessions/${OTHER}/stage`, + body: { message: 'hello' }, + }); + expect(await agentSubmit(d.deps, { id: OTHER, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(0); + expect(d.calls[1].body).toEqual({ stageId: OTHER, confirmSha: 'a'.repeat(64) }); + await writeFile(path, 'hello\n'); + expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(4); + await writeFile(path, Buffer.from([0xff])); + expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(4); + await writeFile(path, 'x'.repeat(8193)); + expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(4); + expect(d.calls).toHaveLength(2); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); +it('refuses self-target and malformed submission before transport', async () => { + const d = deps(); + expect(await agentStage(d.deps, { id: SELF, messageFile: '/nonexistent' })).toBe(4); + expect(await agentSubmit(d.deps, { id: SELF, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(4); + expect(await agentSubmit(d.deps, { id: OTHER, stageId: OTHER, confirmSha: 'bad' })).toBe(4); + expect(d.calls).toHaveLength(0); +}); +it('registers required file and confirmation options and bounded inspect flags', () => { + const program = new Command(); + registerAgentCommands(program); + const commands = program.commands.find((command) => command.name() === 'agent')!.commands; + for (const name of ['inspect', 'stage', 'submit']) + expect(commands.some((command) => command.name() === name)).toBe(true); + expect( + commands.find((command) => command.name() === 'stage')!.options.find((option) => option.long === '--message-file') + ?.mandatory + ).toBe(true); + expect( + commands.find((command) => command.name() === 'submit')!.options.find((option) => option.long === '--confirm-sha') + ?.mandatory + ).toBe(true); +}); +it('shows the opt-in 403 verbatim on inspect, stage and submit while the setting is off', async () => { + const dir = await mkdtemp(join(tmpdir(), 'agent-stage-cli-')); + try { + const path = join(dir, 'message'); + await writeFile(path, 'hello'); + const message = + 'Experimental agent coordination (inspect, stage, submit) is disabled. Turn on "Experimental agent coordination" in Settings (agentCoordinationEnabled) and save first.'; + for (const json of [false, true]) { + const lines: string[] = []; + const refusing: AgentDeps = { + ctx: { apiUrl: 'http://localhost:1', selfId: SELF }, + json, + now: () => 1, + io: { out: (line) => lines.push(line), err: (line) => lines.push(line) }, + request: async () => ({ + status: 403, + text: '', + json: { success: false, error: message, errorCode: 'FORBIDDEN' }, + }), + }; + expect(await agentInspect(refusing, { id: OTHER, maxBytes: 10 })).toBe(1); + expect(await agentStage(refusing, { id: OTHER, messageFile: path })).toBe(1); + expect(await agentSubmit(refusing, { id: OTHER, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(1); + expect(lines).toHaveLength(3); + for (const line of lines) { + const shown = json ? (JSON.parse(line) as { success: boolean; error: string }).error : line; + expect(shown).toContain(`FORBIDDEN: ${message} (HTTP 403)`); + } + } + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/test/cli-agent-project-spawn.test.ts b/test/cli-agent-project-spawn.test.ts new file mode 100644 index 00000000..68f8f767 --- /dev/null +++ b/test/cli-agent-project-spawn.test.ts @@ -0,0 +1,351 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import http from 'node:http'; +import { tmpdir } from 'node:os'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { Command } from 'commander'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + agentSpawn, + isHomeDirectory, + registerAgentCommands, + EXIT, + type AgentDeps, + type ApiResponse, + type RequestOptions, +} from '../src/cli-agent.js'; + +const SELF = '058ee7b5-b2aa-4c33-8cc1-e900eb0b28af'; +const OTHER = '94990c6d-e461-4a29-aa83-89275327732c'; +const dirs: string[] = []; +afterEach(async () => { + for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true }); +}); +const ok = (data: unknown): ApiResponse => ({ status: 200, json: { success: true, data }, text: '' }); +function deps(responses: ApiResponse[]) { + const calls: RequestOptions[] = []; + const out: string[] = []; + const err: string[] = []; + const d: AgentDeps = { + ctx: { selfId: SELF, apiUrl: 'http://127.0.0.1:1' }, + json: true, + now: Date.now, + io: { out: (s) => out.push(s), err: (s) => err.push(s) }, + request: async (_ctx, options) => { + calls.push(options); + return responses.shift() ?? ok({}); + }, + }; + return { d, calls, out, err }; +} +const base = { mode: 'codex', ready: false, timeoutMs: 1000 }; +describe('project-preserving agent spawn', () => { + it('uses existing session create and interactive endpoints with exact directory and lineage', async () => { + const directory = await mkdtemp(join(tmpdir(), 'cod549-project-')); + dirs.push(directory); + const t = deps([ok({ session: { id: OTHER } }), ok({})]); + expect(await agentSpawn(t.d, { ...base, workingDir: directory, name: 'review' })).toBe(EXIT.ok); + expect(t.calls).toHaveLength(2); + expect(t.calls[0]).toMatchObject({ + method: 'POST', + path: '/api/v1/sessions', + body: { workingDir: directory, mode: 'codex', name: 'review', parentSessionId: SELF }, + }); + expect(t.calls[0].headers ?? {}).not.toHaveProperty('X-Codeman-Agent-Origin'); + expect(t.calls[1]).toMatchObject({ method: 'POST', path: `/api/v1/sessions/${OTHER}/interactive` }); + expect(JSON.parse(t.out[0])).toMatchObject({ sessionId: OTHER, workingDir: directory }); + }); + it('defaults to the current directory when no case is supplied', async () => { + const t = deps([ok({ session: { id: OTHER } }), ok({})]); + expect(await agentSpawn(t.d, base)).toBe(EXIT.ok); + expect(t.calls[0].body).toMatchObject({ workingDir: process.cwd() }); + }); + it('keeps explicit case quick-start behavior', async () => { + const t = deps([ok({ sessionId: OTHER })]); + expect(await agentSpawn(t.d, { ...base, caseName: 'scratch' })).toBe(EXIT.ok); + expect(t.calls).toHaveLength(1); + expect(t.calls[0].path).toBe('/api/v1/quick-start'); + }); + it('refuses case and working directory together before network IO', async () => { + const t = deps([]); + expect(await agentSpawn(t.d, { ...base, caseName: 'scratch', workingDir: process.cwd() })).toBe(EXIT.refused); + expect(t.calls).toEqual([]); + }); + it('refuses missing directories, regular files and unknown modes before creating a session', async () => { + const directory = await mkdtemp(join(tmpdir(), 'cod549-project-')); + dirs.push(directory); + const file = join(directory, 'file'); + await writeFile(file, 'x'); + for (const options of [{ workingDir: join(directory, 'absent') }, { workingDir: file }, { mode: 'not-a-cli' }]) { + const t = deps([]); + expect(await agentSpawn(t.d, { ...base, ...options })).toBe(EXIT.refused); + expect(t.calls).toEqual([]); + } + }); + it('reports the created session ID when startup fails instead of retrying or silently leaking it', async () => { + const t = deps([ + ok({ session: { id: OTHER } }), + { status: 400, text: '', json: { success: false, error: 'cannot start', errorCode: 'OPERATION_FAILED' } }, + ]); + expect(await agentSpawn(t.d, base)).toBe(EXIT.error); + expect(t.out).toHaveLength(1); + expect(JSON.parse(t.out.join('\n'))).toMatchObject({ sessionId: OTHER, created: true, ready: false }); + expect(t.calls).toHaveLength(2); + }); + it('does not request startup when creation is refused', async () => { + const t = deps([{ status: 403, text: 'Forbidden' }]); + expect(await agentSpawn(t.d, base)).toBe(EXIT.error); + expect(t.calls).toHaveLength(1); + }); + it('retains the exact created ID on an ambiguous startup transport failure without retrying', async () => { + const t = deps([ok({ session: { id: OTHER } })]); + const request = t.d.request; + t.d.request = async (context, options) => { + if (options.path.endsWith('/interactive')) { + t.calls.push(options); + throw new Error('connection lost'); + } + return request(context, options); + }; + expect(await agentSpawn(t.d, base)).toBe(EXIT.error); + expect(t.out).toHaveLength(1); + expect(JSON.parse(t.out.join('\n'))).toMatchObject({ + sessionId: OTHER, + created: true, + startupError: 'connection lost', + }); + expect(t.calls).toHaveLength(2); + }); + it('treats an empty startup exception as a failure and keeps the created ID', async () => { + const t = deps([ok({ session: { id: OTHER } })]); + const request = t.d.request; + t.d.request = async (context, options) => { + if (options.path.endsWith('/interactive')) throw new Error(''); + return request(context, options); + }; + expect(await agentSpawn(t.d, base)).toBe(EXIT.error); + expect(t.out).toHaveLength(1); + expect(JSON.parse(t.out[0])).toMatchObject({ + sessionId: OTHER, + ready: false, + startupError: 'Startup request failed', + }); + }); + it('exposes an optional case and working directory in the CLI', () => { + const spawn = registerAgentCommands(new Command()).commands.find((command) => command.name() === 'spawn')!; + expect(spawn.registeredArguments[0].required).toBe(false); + expect(spawn.options.some((option) => option.long === '--working-dir')).toBe(true); + }); + it('starts a shell through its registered startMode endpoint', async () => { + const t = deps([ok({ session: { id: OTHER } }), ok({})]); + expect(await agentSpawn(t.d, { ...base, mode: 'shell' })).toBe(EXIT.ok); + expect(t.calls[1].path).toBe(`/api/v1/sessions/${OTHER}/shell`); + }); + it('a create answer without session.id is a failure, and nothing is started', async () => { + // `POST /api/sessions` answers `{ session: { id } }`; a bare `{ id }` is not that route. + const t = deps([ok({ id: OTHER })]); + expect(await agentSpawn(t.d, base)).toBe(EXIT.error); + expect(t.calls).toHaveLength(1); + }); + it('an empty case name goes to quick-start (which refuses it), never to the caller cwd', async () => { + const t = deps([ + { + status: 400, + text: '', + json: { success: false, error: 'Invalid case name format', errorCode: 'INVALID_INPUT' }, + }, + ]); + expect(await agentSpawn(t.d, { ...base, caseName: '' })).toBe(EXIT.error); + expect(t.calls).toHaveLength(1); + expect(t.calls[0]).toMatchObject({ method: 'POST', path: '/api/v1/quick-start', body: { caseName: '' } }); + expect(t.calls[0].body).not.toHaveProperty('workingDir'); + }); + it('an empty case name with --working-dir is refused like any case plus --working-dir', async () => { + const t = deps([]); + expect(await agentSpawn(t.d, { ...base, caseName: '', workingDir: process.cwd() })).toBe(EXIT.refused); + expect(t.calls).toEqual([]); + }); + it('an empty or whitespace-only --working-dir is refused before any request, never the caller cwd', async () => { + for (const workingDir of ['', ' ', '\t']) { + const t = deps([]); + expect(await agentSpawn(t.d, { ...base, workingDir })).toBe(EXIT.refused); + expect(t.calls).toEqual([]); + expect(t.out.join('\n')).toContain('--working-dir is empty'); + } + }); + it('with ready on claude: create, then /interactive, then wait-output on the created id', async () => { + const t = deps([ok({ session: { id: OTHER } }), ok({}), ok({ wait: { matched: true } })]); + expect(await agentSpawn(t.d, { ...base, mode: 'claude', ready: true })).toBe(EXIT.ok); + expect(t.calls.map((c) => `${c.method} ${c.path}`)).toEqual([ + 'POST /api/v1/sessions', + `POST /api/v1/sessions/${OTHER}/interactive`, + `GET /api/v1/sessions/${OTHER}/wait-output`, + ]); + expect(t.calls[2].query).toMatchObject({ match: 'shift+tab', from: 'buffer' }); + expect(JSON.parse(t.out[0])).toMatchObject({ sessionId: OTHER, created: true, ready: true }); + }); + it('without --json a failed start prints the id alone on stdout, the reason on stderr, and exits 1', async () => { + const t = deps([ + ok({ session: { id: OTHER } }), + { status: 400, text: '', json: { success: false, error: 'cannot start', errorCode: 'OPERATION_FAILED' } }, + ]); + t.d.json = false; + expect(await agentSpawn(t.d, { ...base, ready: true })).toBe(EXIT.error); + expect(t.out).toEqual([OTHER]); + expect(t.err).toHaveLength(1); + expect(t.err[0]).toContain(`Session ${OTHER} was created but startup failed`); + expect(t.err[0]).toContain('cannot start'); + expect(t.calls).toHaveLength(2); // no readiness wait after a failed start + }); +}); + +describe('agent spawn through the real argument parser and transport', () => { + async function runSpawn(args: string[]) { + const seen: Array<{ method?: string; url?: string; body: unknown }> = []; + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (c: Buffer) => chunks.push(c)); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString(); + seen.push({ method: req.method, url: req.url, body: raw ? JSON.parse(raw) : undefined }); + res + .writeHead(400, { 'Content-Type': 'application/json' }) + .end(JSON.stringify({ success: false, error: 'refused', errorCode: 'INVALID_INPUT' })); + }); + }); + await new Promise<void>((done) => server.listen(0, '127.0.0.1', done)); + const { port } = server.address() as { port: number }; + const saved = { ...process.env }; + const savedExit = process.exitCode; + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + Object.assign(process.env, { + CODEMAN_MUX: '1', + CODEMAN_API_URL: `http://127.0.0.1:${port}`, + CODEMAN_SESSION_ID: SELF, + }); + delete process.env.CODEMAN_PASSWORD; + const program = new Command(); + program.exitOverride(); + registerAgentCommands(program); + await program.parseAsync(['node', 'codeman', 'agent', 'spawn', ...args, '--no-ready', '--json']); + return { seen, exitCode: process.exitCode }; + } finally { + process.exitCode = savedExit; + for (const key of ['CODEMAN_MUX', 'CODEMAN_API_URL', 'CODEMAN_SESSION_ID', 'CODEMAN_PASSWORD']) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + log.mockRestore(); + error.mockRestore(); + await new Promise<void>((done) => server.close(() => done())); + } + } + + it('`spawn ""` (as `spawn "$CASE"` with CASE unset) reaches quick-start with caseName "" and exits 1', async () => { + const { seen, exitCode } = await runSpawn(['']); + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ method: 'POST', url: '/api/v1/quick-start', body: { caseName: '' } }); + expect(exitCode).toBe(EXIT.error); + }); + it('`spawn --working-dir ""` (as `--working-dir "$WT"` with WT unset) sends nothing and exits 4', async () => { + for (const value of ['', ' ']) { + const { seen, exitCode } = await runSpawn(['--working-dir', value]); + expect(seen).toEqual([]); + expect(exitCode).toBe(EXIT.refused); + } + }); + it('`spawn` with no case argument creates in the caller cwd', async () => { + const { seen, exitCode } = await runSpawn([]); + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ method: 'POST', url: '/api/v1/sessions', body: { workingDir: process.cwd() } }); + expect(exitCode).toBe(EXIT.error); + }); + it('bare `spawn` from a HOME cwd sends nothing and exits 4; `--working-dir <HOME>` is sent', async () => { + const home = await mkdtemp(join(tmpdir(), 'cod549-home-')); + dirs.push(home); + vi.stubEnv('HOME', home); + const cwd = vi.spyOn(process, 'cwd').mockReturnValue(home); + try { + const bare = await runSpawn([]); + expect(bare.seen).toEqual([]); + expect(bare.exitCode).toBe(EXIT.refused); + const explicit = await runSpawn(['--working-dir', home]); + expect(explicit.seen).toHaveLength(1); + expect(explicit.seen[0]).toMatchObject({ url: '/api/v1/sessions', body: { workingDir: home } }); + } finally { + cwd.mockRestore(); + vi.unstubAllEnvs(); + } + }); +}); + +describe('agent spawn never treats an implicit HOME cwd as a project', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + async function homeDir(): Promise<string> { + const directory = await mkdtemp(join(tmpdir(), 'cod549-home-')); + dirs.push(directory); + return directory; + } + + it('refuses a cwd that is HOME with exit 4 before any request and names the explicit flag', async () => { + const home = await homeDir(); + vi.stubEnv('HOME', home); + vi.spyOn(process, 'cwd').mockReturnValue(home); + const t = deps([]); + expect(await agentSpawn(t.d, { ...base, mode: 'claude' })).toBe(EXIT.refused); + expect(t.calls).toEqual([]); + expect(JSON.parse(t.out[0]).error).toContain('pass --working-dir ~'); + }); + it('compares realpaths, so a symlinked HOME still matches the cwd it points at', async () => { + const real = await homeDir(); + const link = join(await homeDir(), 'home-link'); + await symlink(real, link); + vi.stubEnv('HOME', link); + vi.spyOn(process, 'cwd').mockReturnValue(real); + const t = deps([]); + expect(await agentSpawn(t.d, base)).toBe(EXIT.refused); + expect(t.calls).toEqual([]); + }); + it('allows an explicit --working-dir that is HOME, as POST /api/sessions does', async () => { + const home = await homeDir(); + vi.stubEnv('HOME', home); + vi.spyOn(process, 'cwd').mockReturnValue(home); + const t = deps([ok({ session: { id: OTHER } }), ok({})]); + expect(await agentSpawn(t.d, { ...base, workingDir: home })).toBe(EXIT.ok); + expect(t.calls[0].body).toMatchObject({ workingDir: home }); + }); + it('allows a cwd below HOME', async () => { + const home = await homeDir(); + const project = join(home, 'src', 'app'); + await mkdir(project, { recursive: true }); + vi.stubEnv('HOME', home); + vi.spyOn(process, 'cwd').mockReturnValue(project); + const t = deps([ok({ session: { id: OTHER } }), ok({})]); + expect(await agentSpawn(t.d, base)).toBe(EXIT.ok); + expect(t.calls[0].body).toMatchObject({ workingDir: project }); + }); + it('never throws when HOME cannot be resolved, and compares the resolved paths instead', async () => { + const missing = join(await homeDir(), 'absent'); + vi.stubEnv('HOME', missing); + await expect(isHomeDirectory(process.cwd())).resolves.toBe(false); + await expect(isHomeDirectory(missing)).resolves.toBe(true); + vi.stubEnv('HOME', ''); + await expect(isHomeDirectory(process.cwd())).resolves.toBe(false); + }); +}); + +describe('the directory-spawn --json shape is documented the same way everywhere', () => { + // The wiki wraps lines, so whitespace is normalized before comparing. + const clause = + '`{ sessionId, workingDir, created: true }`, plus `ready` when a readiness wait ran, `readinessError` when that call failed, and `startupError` when the start failed'; + for (const file of ['README.md', 'docs/api-reference.md', 'docs/wiki/Driving-Codeman-From-An-Agent.md']) { + it(file, () => { + expect(readFileSync(new URL(`../${file}`, import.meta.url), 'utf8').replace(/\s+/g, ' ')).toContain(clause); + }); + } +}); diff --git a/test/cli-agent.test.ts b/test/cli-agent.test.ts index c6331aad..7f579696 100644 --- a/test/cli-agent.test.ts +++ b/test/cli-agent.test.ts @@ -30,6 +30,7 @@ import { buildSendBody, deleteRefusal, describeFailure, + describeRequestError, httpRequest, inboxCell, inputRefusal, @@ -548,6 +549,75 @@ describe('agent spawn', () => { expect(deps.calls).toHaveLength(1); expect(deps.err.join('')).toMatch(/SESSION_BUSY/); }); + + it.each([false, true])('preserves the created id when readiness throws (json=%s)', async (json) => { + const deps = fakeDeps((options) => { + if (options.path === '/api/v1/quick-start') return ok({ sessionId: OTHER, caseName: 'c' }); + throw new Error('readiness connection reset'); + }, json); + const exit = await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 }).catch( + () => undefined + ); + + if (json) { + expect(deps.out).toHaveLength(1); + expect(JSON.parse(deps.out[0])).toEqual({ + sessionId: OTHER, + caseName: 'c', + readinessError: 'readiness connection reset', + }); + expect(deps.err).toEqual([]); + } else { + expect(deps.out).toEqual([OTHER]); + expect(deps.err.join('')).toContain('readiness check failed: readiness connection reset'); + expect(deps.err.join('')).not.toMatch(/composer not seen|startup dialog/); + } + expect(exit).toBe(EXIT.error); + expect(deps.calls.map(({ method, path }) => ({ method, path }))).toEqual([ + { method: 'POST', path: '/api/v1/quick-start' }, + { method: 'GET', path: `/api/v1/sessions/${OTHER}/wait-output` }, + ]); + }); + + it.each([ + { error: new Error(''), kind: 'Error' }, + { error: '', kind: 'string' }, + ])('reports a readiness failure when the thrown $kind has no message', async ({ error }) => { + const deps = fakeDeps((options) => { + if (options.path === '/api/v1/quick-start') return ok({ sessionId: OTHER }); + throw error; + }, true); + + expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.error); + expect(deps.out).toHaveLength(1); + expect(JSON.parse(deps.out[0])).toEqual({ sessionId: OTHER, readinessError: 'request failed without a message' }); + expect(deps.calls).toHaveLength(2); + }); + + it('keeps the error code when a refused connection rejects with an empty AggregateError', async () => { + // Node 22 on a host resolving to ::1 and 127.0.0.1: message '', code ECONNREFUSED. + const refused = Object.assign(new AggregateError([new Error('connect ECONNREFUSED ::1:3000')], ''), { + code: 'ECONNREFUSED', + }); + const deps = fakeDeps((options) => { + if (options.path === '/api/v1/quick-start') return ok({ sessionId: OTHER }); + throw refused; + }, true); + + expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.error); + expect(JSON.parse(deps.out[0])).toEqual({ sessionId: OTHER, readinessError: 'ECONNREFUSED' }); + }); +}); + +describe('describeRequestError', () => { + it('prefers the message, then the code, then a generic line', () => { + expect(describeRequestError(new Error('socket hang up'))).toBe('socket hang up'); + expect(describeRequestError(Object.assign(new AggregateError([], ''), { code: 'ECONNREFUSED' }))).toBe( + 'ECONNREFUSED' + ); + expect(describeRequestError(new Error(''))).toBe('request failed without a message'); + expect(describeRequestError('')).toBe('request failed without a message'); + }); }); describe('session id prefixes', () => { diff --git a/test/cli-commands.test.ts b/test/cli-commands.test.ts index 9714313e..68990785 100644 --- a/test/cli-commands.test.ts +++ b/test/cli-commands.test.ts @@ -208,3 +208,29 @@ describe('help text', () => { } }); }); + +describe('unauthenticated tunnel launch option', () => { + it.each(['web', 'service install'])('registers --allow-unauthenticated-tunnel on %s', (path) => { + const command = path === 'web' ? find(program, 'web')! : find(find(program, 'service')!, 'install')!; + const options = command.options.filter((option) => option.long === '--allow-unauthenticated-tunnel'); + expect( + options.map((option) => ({ + flags: option.flags, + attribute: option.attributeName(), + required: option.required, + optional: option.optional, + })) + ).toEqual([ + { + flags: '--allow-unauthenticated-tunnel', + attribute: 'allowUnauthenticatedTunnel', + required: false, + optional: false, + }, + ]); + }); + + it('documents --allow-unauthenticated-tunnel in web help', () => { + expect(find(program, 'web')!.helpInformation()).toContain('--allow-unauthenticated-tunnel'); + }); +}); diff --git a/test/cli-notify.test.ts b/test/cli-notify.test.ts new file mode 100644 index 00000000..5127cf54 --- /dev/null +++ b/test/cli-notify.test.ts @@ -0,0 +1,141 @@ +// @vitest-environment node +/** + * @fileoverview `codeman notify`: what it sends, what it prints and the exit code, driven with a + * recording fake transport plus the real `postJson` against a local server. Port: ephemeral 127.0.0.1. + */ +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { postJson, runNotify, type JsonPost } from '../src/cli-notify.js'; + +const ENV = { CODEMAN_SESSION_ID: 'abc12345-full-id', CODEMAN_API_URL: 'http://127.0.0.1:3000' }; + +function fake(status: number, error?: string, delivery?: { ok: boolean; error?: string }) { + const post = vi.fn<JsonPost>(async () => ({ status, error, delivery })); + return post; +} + +describe('runNotify', () => { + it('refuses outside a Codeman session, before any request', async () => { + const post = fake(200); + const r = await runNotify({ message: 'hi' }, {}, '/work', post); + expect(r.code).toBe(1); + expect(r.err).toMatch(/inside a Codeman session/); + expect(post).not.toHaveBeenCalled(); + }); + + it('posts the message to this session with the session id URL-encoded', async () => { + const post = fake(200); + const r = await runNotify({ message: 'press the reset button' }, ENV, '/work', post); + expect(r).toEqual({ code: 0, out: 'Posted to Discord' }); + expect(post).toHaveBeenCalledWith('http://127.0.0.1:3000', '/api/sessions/abc12345-full-id/discord-notify', { + message: 'press the reset button', + }); + }); + + it('adds urgent, and turns a relative --file into an absolute path under the working folder', async () => { + const post = fake(200); + await runNotify({ message: 'log', urgent: true, file: 'out/run.log' }, ENV, '/work', post); + expect(post.mock.calls[0][2]).toEqual({ message: 'log', urgent: true, file: '/work/out/run.log' }); + await runNotify({ message: 'log', file: '/abs/run.log' }, ENV, '/work', post); + expect(post.mock.calls[1][2]).toEqual({ message: 'log', file: '/abs/run.log' }); + }); + + it('lets --session and --url override the environment', async () => { + const post = fake(200); + await runNotify({ message: 'x', session: 'other', url: 'http://h:1' }, ENV, '/work', post); + expect(post.mock.calls[0].slice(0, 2)).toEqual(['http://h:1', '/api/sessions/other/discord-notify']); + }); + + it('exits 1 when Codeman answered 200 but Discord refused the post, so a blocked agent knows nobody was told', async () => { + const r = await runNotify( + { message: 'x' }, + ENV, + '/w', + fake(200, undefined, { ok: false, error: 'The bot token was refused' }) + ); + expect(r).toEqual({ code: 1, err: 'Could not post to Discord: The bot token was refused' }); + expect((await runNotify({ message: 'x' }, ENV, '/w', fake(200, undefined, { ok: true }))).code).toBe(0); + }); + + it('postJson gives up on a server that never answers, with status 0', async () => { + const server = createServer(() => undefined); + await new Promise<void>((r) => server.listen(0, '127.0.0.1', r)); + try { + const { port } = server.address() as AddressInfo; + const r = await postJson(`http://127.0.0.1:${port}`, '/x', {}, 100); + expect(r.status).toBe(0); + } finally { + server.closeAllConnections(); + server.close(); + } + }); + + it('postJson settles when the server sends headers and then stalls the body', async () => { + const server = createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': '500' }); + res.write('{"success":'); + }); + await new Promise<void>((r) => server.listen(0, '127.0.0.1', r)); + try { + const { port } = server.address() as AddressInfo; + expect((await postJson(`http://127.0.0.1:${port}`, '/x', {}, 100)).status).toBe(0); + } finally { + server.closeAllConnections(); + server.close(); + } + }); + + it('treats a disabled bridge (409) as success that says so', async () => { + const r = await runNotify({ message: 'x' }, ENV, '/work', fake(409)); + expect(r.code).toBe(0); + expect(r.out).toMatch(/bridge is off/); + }); + + it('fails with the server error, or the status, or a plain message when nothing answered', async () => { + expect( + (await runNotify({ message: 'x' }, ENV, '/w', fake(400, 'The file must be inside the session folder'))).err + ).toBe('Could not post to Discord: The file must be inside the session folder'); + expect((await runNotify({ message: 'x' }, ENV, '/w', fake(429))).err).toMatch(/Too many messages/); + expect((await runNotify({ message: 'x' }, ENV, '/w', fake(500))).err).toBe('Could not post to Discord (HTTP 500)'); + expect((await runNotify({ message: 'x' }, ENV, '/w', fake(0))).err).toBe('Could not post to Discord'); + }); +}); + +describe('postJson (real transport)', () => { + let server: Server; + afterEach(() => new Promise<void>((resolve) => server.close(() => resolve()))); + + async function serve(status: number, reply: unknown) { + const seen: { method?: string; url?: string; body: string; type?: string } = { body: '' }; + server = createServer((req, res) => { + req.on('data', (c) => (seen.body += c)); + req.on('end', () => { + seen.method = req.method; + seen.url = req.url; + seen.type = req.headers['content-type']; + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(reply)); + }); + }); + await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)); + return { seen, url: `http://127.0.0.1:${(server.address() as AddressInfo).port}` }; + } + + it('sends a JSON POST and reports the status and the server error text', async () => { + const { seen, url } = await serve(400, { success: false, error: 'nope' }); + expect(await postJson(url, '/api/sessions/s1/discord-notify', { message: 'hi' })).toEqual({ + status: 400, + error: 'nope', + }); + expect(seen).toMatchObject({ method: 'POST', url: '/api/sessions/s1/discord-notify', type: 'application/json' }); + expect(JSON.parse(seen.body)).toEqual({ message: 'hi' }); + }); + + it('answers status 0 when nothing is listening', async () => { + const { url } = await serve(200, {}); + await new Promise<void>((resolve) => server.close(() => resolve())); + expect((await postJson(url, '/x', {})).status).toBe(0); + server = createServer().listen(0); // so afterEach has something to close + }); +}); diff --git a/test/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts index 9fa1c6ee..0de54fdf 100644 --- a/test/cli-registry-schema.test.ts +++ b/test/cli-registry-schema.test.ts @@ -55,6 +55,65 @@ describe('customModelInjection.launchModel', () => { }); }); +/** Mutates the shipped deepseek configDir recipe (the one real-home-linking entry) and expects rejection. */ +function expectRejectedOnDeepSeek(mutate: (recipe: Record<string, unknown>) => void, because: string): void { + const entry = baseEntry('deepseek'); + const recipe = (entry.capabilities as Record<string, unknown>).customModelInjection as Record<string, unknown>; + mutate(recipe); + const result = CliEntrySchema.safeParse(entry); + expect(result.success, `expected rejection: ${because}`).toBe(false); +} + +describe('configDir real-home linking (linkFromRealHome / realHomeFallback)', () => { + it('rejects a realHomeFallback that is not a single relative path segment', () => { + // The consumer joins it under the user's home, so any of these escapes that home: + // and bare `.` IS that home: linking through it would expose the whole tree + // unshielded instead of just the declared segments. + for (const bad of ['../x', '.dsh/evil', '/.dsh', '..', '.']) { + expectRejectedOnDeepSeek((r) => { + r.realHomeFallback = bad; + }, `realHomeFallback ${JSON.stringify(bad)}`); + } + }); + + it('accepts a shipped-style dot-prefixed segment', () => { + const entry = baseEntry('deepseek'); + expect(CliEntrySchema.safeParse(entry).success).toBe(true); // ships as `.dsh` + const recipe = (entry.capabilities as Record<string, unknown>).customModelInjection as Record<string, unknown>; + recipe.realHomeFallback = '.dsh-relocated'; + expect(CliEntrySchema.safeParse(entry).success).toBe(true); + }); + + it('requires realHomeFallback whenever linkFromRealHome is declared', () => { + expectRejectedOnDeepSeek((r) => { + delete r.realHomeFallback; + }, 'without it the consumer would have to hardcode a per-CLI home default'); + }); + + it('rejects a linked entry that collides with fileName', () => { + // A symlink at the generated file's own name would write THROUGH into the user's real config. + expectRejectedOnDeepSeek((r) => { + r.linkFromRealHome = ['cordis.patch.yml', 'profiles']; + }, 'linkFromRealHome containing fileName'); + }); + + it('requires linkFromRealHomeDirs to be a subset of linkFromRealHome', () => { + // types.ts documents the rule; a name absent from the linked list names nothing, + // so the create-missing-directory behaviour would silently never apply to it. + expectRejectedOnDeepSeek((r) => { + r.linkFromRealHome = ['profiles', 'settings.yaml']; + r.linkFromRealHomeDirs = ['profiles', 'sessions']; + }, 'linkFromRealHomeDirs naming an unlinked entry'); + // The shipped recipe is a valid subset (deepseek: profiles and sessions are dirs + // of the three linked entries) and keeps loading. + const entry = baseEntry('deepseek'); + const recipe = (entry.capabilities as Record<string, unknown>).customModelInjection as Record<string, unknown>; + expect(recipe.linkFromRealHome).toEqual(['profiles', 'sessions', 'settings.yaml']); + expect(recipe.linkFromRealHomeDirs).toEqual(['profiles', 'sessions']); + expect(CliEntrySchema.safeParse(entry).success).toBe(true); + }); +}); + describe('the shipped catalog', () => { it('validates every stock entry exactly as shipped', () => { // If this fails, the catalog cannot load at all — every other test here is downstream. @@ -184,6 +243,69 @@ describe('workDetect.workingLine is guarded like every other config regex', () = } }); + it('holds the optional composerFooterLine to the same guard', () => { + // Run over the rows under the composer each time guarded input reads the pane. + for (const composerFooterLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) { + expectRejected((e) => { + (e.capabilities as Record<string, unknown>).workDetect = { + promptGlyph: '>', + workingLine: 'working', + composerFooterLine, + }; + }, 'guarded input tests it against pane rows a clis.json override can supply'); + } + }); + + it('accepts every shipped composerFooterLine', () => { + for (const entry of STOCK_CLIS) { + const src = entry.capabilities.workDetect?.composerFooterLine; + if (!src) continue; + expect(compileVersionRegex(src), `${entry.id} declares a composerFooterLine the guard refuses`).not.toBeNull(); + } + }); + + it('holds the optional composerPopupLine to the same guard, and accepts the shipped one', () => { + // Run over the rows just above the composer each time guarded input reads the pane. + for (const composerPopupLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) { + expectRejected((e) => { + (e.capabilities as Record<string, unknown>).workDetect = { + promptGlyph: '>', + workingLine: 'working', + composerPopupLine, + }; + }, 'guarded input tests it against pane rows a clis.json override can supply'); + } + for (const entry of STOCK_CLIS) { + const src = entry.capabilities.workDetect?.composerPopupLine; + if (src) + expect(compileVersionRegex(src), `${entry.id} declares a composerPopupLine the guard refuses`).not.toBeNull(); + } + }); + + it('takes only short runs of ASCII punctuation as composer trigger characters', () => { + for (const field of ['composerCommandPrefixes', 'composerCompletionSigils']) { + for (const value of ['', 'a', '/a', '/ ', '/\t', '\u00a7', '!!!!!!!!!']) { + expectRejected( + (e) => { + (e.capabilities as Record<string, unknown>).workDetect = { + promptGlyph: '>', + workingLine: 'working', + [field]: value, + }; + }, + `${field} ${JSON.stringify(value)} would refuse letters, spaces or an unbounded set` + ); + } + const entry = baseEntry(); + (entry.capabilities as Record<string, unknown>).workDetect = { + promptGlyph: '>', + workingLine: 'working', + [field]: '/!@$', + }; + expect(CliEntrySchema.safeParse(entry).success, field).toBe(true); + } + }); + it('holds the optional blockedLine to the same guard', () => { // Run over the foot of a capture at every settle and on a timer while a dialog is // up, and a clis.json override can set it. @@ -485,6 +607,30 @@ describe('capability shapes', () => { }, 'transcriptPageKeys is a boolean'); }); + it("takes keyboardHold as an optional 'inline', absent meaning the keyboard resizes", () => { + const inline = baseEntry(); + (inline.capabilities as Record<string, unknown>).keyboardHold = 'inline'; + expect(CliEntrySchema.safeParse(inline).success).toBe(true); + const absent = baseEntry(); + delete (absent.capabilities as Record<string, unknown>).keyboardHold; + expect(CliEntrySchema.safeParse(absent).success).toBe(true); + for (const value of [true, 'fullscreen', '']) { + expectRejected( + (e) => { + (e.capabilities as Record<string, unknown>).keyboardHold = value; + }, + `keyboardHold=${JSON.stringify(value)} names no renderer kind` + ); + } + }); + + it('declares keyboardHold for exactly the inline renderer measured losing lines on a height change', () => { + // Claude Code 2.1.306's inline renderer was measured dropping transcript lines from + // tmux on keyboard-driven height changes; every other stock entry resizes. + const holding = STOCK_CLIS.filter((e) => e.capabilities.keyboardHold === 'inline').map((e) => e.id as string); + expect(holding).toEqual(['claude']); + }); + it('declares which CLIs get the wheel forwarded, and whether a version gates it', () => { // Claude forwards from 2.1.187 (fullscreen only); tracked Codex and Copilot // forward without a version gate. Unmeasured entries stay 'never'. diff --git a/test/cli-registry-spawn-golden.test.ts b/test/cli-registry-spawn-golden.test.ts index c4fe6032..464fe6fd 100644 --- a/test/cli-registry-spawn-golden.test.ts +++ b/test/cli-registry-spawn-golden.test.ts @@ -171,6 +171,15 @@ describe('codex', () => { expect(cx({ model: 'gpt-5', resumeSessionId: 'roll_42' })).toBe('codex --model gpt-5 resume roll_42'); }); + it('carries an Ollama-style `qwen3:32b` model id, and still drops a leading-flag shape', () => { + // `model-plain-colon`: the custom-model feature forces the selected id onto this param, + // and ids like `qwen3:32b` carry a colon. The leading-alphanumeric rule is what keeps a + // flag from riding the flag, so `:x` and `-x` must still drop out. + expect(cx({ model: 'qwen3:32b' })).toBe('codex --model qwen3:32b'); + expect(cx({ model: ':leading' })).toBe('codex'); + expect(cx({ model: '-yolo' })).toBe('codex'); + }); + it('sends reasoning effort as one model_reasoning_effort config value, for every level', () => { for (const level of CODEX_REASONING_EFFORTS) { expect(cx({ reasoningEffort: level })).toBe(`codex --config model_reasoning_effort=${level}`); diff --git a/test/cli-tunnel-flag.test.ts b/test/cli-tunnel-flag.test.ts new file mode 100644 index 00000000..575be2ca --- /dev/null +++ b/test/cli-tunnel-flag.test.ts @@ -0,0 +1,102 @@ +/** + * @fileoverview Issue #706 tunnel flag plumbing through the real command parser into daemon, foreground and service launches. Launch boundaries are mocked, so the CLI's valid nonzero port never binds a socket or creates a service unit. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const launches = vi.hoisted(() => ({ + startDaemon: vi.fn(), + startWebServer: vi.fn(), + installService: vi.fn(), + stop: vi.fn(), +})); + +vi.mock('../src/daemon-control.js', async (importOriginal) => ({ + ...(await importOriginal<typeof import('../src/daemon-control.js')>()), + startDaemon: launches.startDaemon, +})); +vi.mock('../src/service-installer.js', async (importOriginal) => ({ + ...(await importOriginal<typeof import('../src/service-installer.js')>()), + installService: launches.installService, +})); +vi.mock('../src/web/server.js', () => ({ startWebServer: launches.startWebServer })); + +const SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'] as const; +let originalListeners: Map<NodeJS.Signals, ReturnType<typeof process.listeners>>; +let exitSpy: ReturnType<typeof vi.spyOn<typeof process, 'exit'>>; + +beforeEach(() => { + // Commander retains parsed option values; use a fresh real program for each invocation. + vi.resetModules(); + vi.clearAllMocks(); + originalListeners = new Map(SIGNALS.map((signal) => [signal, process.listeners(signal)])); + exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`Unexpected process.exit(${code})`); + }); + launches.startDaemon.mockResolvedValue({ ok: true, url: 'http://127.0.0.1:8080', pid: 123, logPath: 'web.log' }); + launches.stop.mockResolvedValue(undefined); + launches.startWebServer.mockResolvedValue({ stop: launches.stop }); + launches.installService.mockResolvedValue({ ok: true, message: 'Installed', unitPath: 'codeman-web.service' }); +}); + +afterEach(() => { + for (const signal of SIGNALS) { + for (const listener of process.listeners(signal)) { + if (!originalListeners.get(signal)!.includes(listener)) process.removeListener(signal, listener); + } + } + vi.restoreAllMocks(); +}); + +const expectedOptions = (allowUnauthenticatedTunnel: boolean) => ({ + host: '127.0.0.1', + port: 8080, + https: false, + basePath: '', + titleHostname: undefined, + allowUnauthenticatedNetwork: false, + allowUnauthenticatedTunnel, + multiuser: false, +}); + +async function parse(args: string[], enabled: boolean) { + const { program } = await import('../src/cli.js'); + await program.parseAsync([ + 'node', + 'codeman', + ...args, + '--port', + '8080', + '--host', + '127.0.0.1', + '--base-url', + '/', + ...(enabled ? ['--allow-unauthenticated-tunnel'] : []), + ]); + expect(exitSpy).not.toHaveBeenCalled(); +} + +describe('tunnel flag CLI plumbing', () => { + it.each([true, false])('web -d passes allowUnauthenticatedTunnel=%s to startDaemon', async (enabled) => { + await parse(['web', '-d'], enabled); + expect(launches.startDaemon.mock.calls).toEqual([[expectedOptions(enabled)]]); + expect(launches.startWebServer.mock.calls).toEqual([]); + expect(launches.installService.mock.calls).toEqual([]); + }); + + it.each([true, false])('foreground web passes allowUnauthenticatedTunnel=%s as argument eight', async (enabled) => { + await parse(['web'], enabled); + expect(launches.startWebServer.mock.calls).toEqual([ + [8080, false, false, '127.0.0.1', undefined, false, '', enabled], + ]); + expect(launches.startDaemon.mock.calls).toEqual([]); + expect(launches.installService.mock.calls).toEqual([]); + expect(launches.stop.mock.calls).toEqual([]); + }); + + it.each([true, false])('service install passes allowUnauthenticatedTunnel=%s to installService', async (enabled) => { + await parse(['service', 'install'], enabled); + expect(launches.installService.mock.calls).toEqual([[expectedOptions(enabled)]]); + expect(launches.startDaemon.mock.calls).toEqual([]); + expect(launches.startWebServer.mock.calls).toEqual([]); + }); +}); diff --git a/test/codex-rollout-format.test.ts b/test/codex-rollout-format.test.ts new file mode 100644 index 00000000..761f32b8 --- /dev/null +++ b/test/codex-rollout-format.test.ts @@ -0,0 +1,112 @@ +/** + * @fileoverview The Codex rollout shape helpers shared by the Past Sessions + * scanner (codex-transcript.ts) and the offline history reader + * (local-history.ts): store location, message shapes and the injected-context + * rule. Both readers' own tests cover their use of these; this pins the shared + * contract itself. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { join } from 'node:path'; +import { + codexContentText, + codexHomeDir, + codexMessageFromLine, + codexSessionsRoot, + isCodexInjectedContext, +} from '../src/codex-rollout-format.js'; + +const savedHome = process.env.CODEX_HOME; +afterEach(() => { + if (savedHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = savedHome; +}); + +describe('codex rollout format', () => { + it('resolves the store from CODEX_HOME, else ~/.codex', () => { + process.env.CODEX_HOME = '/custom/codex'; + expect(codexHomeDir()).toBe('/custom/codex'); + expect(codexSessionsRoot()).toBe(join('/custom/codex', 'sessions')); + process.env.CODEX_HOME = ''; + expect(codexSessionsRoot()).toBe(join(process.env.HOME!, '.codex', 'sessions')); + }); + + it('reads all three message shapes with their role, shape and phase', () => { + expect(codexMessageFromLine({ type: 'event_msg', payload: { type: 'user_message', message: 'hi' } })).toEqual({ + role: 'user', + text: 'hi', + shape: 'event', + phase: undefined, + injectionProne: false, + }); + expect( + codexMessageFromLine({ + type: 'event_msg', + payload: { type: 'agent_message', message: 'ok', phase: 'commentary' }, + }) + ).toMatchObject({ role: 'assistant', shape: 'event', phase: 'commentary', injectionProne: false }); + expect( + codexMessageFromLine({ + type: 'event_msg', + payload: { + type: 'item_completed', + item: { type: 'AgentMessage', phase: 'final_answer', content: [{ type: 'Text', text: 'done' }] }, + }, + }) + ).toMatchObject({ role: 'assistant', text: 'done', shape: 'item', phase: 'final_answer' }); + expect( + codexMessageFromLine({ + type: 'response_item', + payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'typed' }] }, + }) + ).toMatchObject({ role: 'user', text: 'typed', shape: 'response', injectionProne: true }); + expect( + codexMessageFromLine({ + type: 'response_item', + payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'a' }] }, + }) + ).toMatchObject({ role: 'assistant', shape: 'response', injectionProne: false }); + }); + + it('ignores every other line, empty text and malformed input', () => { + for (const value of [ + null, + 42, + 'text', + [], + { type: 'session_meta', payload: { id: 'x' } }, + { type: 'response_item', payload: { type: 'message', role: 'developer', content: 'policy' } }, + { type: 'response_item', payload: { type: 'reasoning', summary: [] } }, + { type: 'event_msg', payload: { type: 'user_message', message: '' } }, + { type: 'event_msg', payload: { type: 'item_completed', item: { type: 'CommandExecution' } } }, + ]) + expect(codexMessageFromLine(value)).toBeNull(); + }); + + it('flattens only blocks that carry text', () => { + expect(codexContentText('plain')).toBe('plain'); + expect( + codexContentText([ + { type: 'input_text', text: 'a' }, + { type: 'input_image', image_url: 'x' }, + { text: 'b' }, + null, + ]) + ).toBe('a\nb'); + expect(codexContentText(undefined)).toBe(''); + }); + + it('treats a heading or a tag as injected context, wherever the wrapper name is new', () => { + for (const text of [ + '# AGENTS.md instructions for /repo', + '<environment_context>', + '<recommended_plugins>', + '<subagent_notification>', + '<codex_internal_context source="goal">', + '<image name=[Image #1]>', + ' <turn_aborted>', + ]) + expect(isCodexInjectedContext(text)).toBe(true); + for (const text of ['fix the bug', 'You are a reviewer', '$skill run', 'a <b> tag later']) + expect(isCodexInjectedContext(text)).toBe(false); + }); +}); diff --git a/test/command-palette-agent-arrows.browser.test.ts b/test/command-palette-agent-arrows.browser.test.ts new file mode 100644 index 00000000..182f4e23 --- /dev/null +++ b/test/command-palette-agent-arrows.browser.test.ts @@ -0,0 +1,86 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chromium, type Browser } from 'playwright'; +import { WebServer } from '../src/web/server.js'; + +describe('Command-K agent arrow keys in Chromium', () => { + let server: WebServer; + let browser: Browser; + beforeAll(async () => { + server = new WebServer(0, false, true); + await server.start(); + browser = await chromium.launch({ headless: true }); + }, 60000); + afterAll(async () => { + await browser?.close(); + await server?.stop(); + }); + + it('cycles both directions at query boundaries while preserving text editing and selection', async () => { + const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + const errors: string[] = []; + page.on('pageerror', (e) => errors.push(e.message)); + try { + await page.goto(`http://127.0.0.1:${server.boundPort}`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => (window as any).app?.openCommandPalette); + await page.waitForTimeout(3500); + await page.evaluate(() => { + const a = (window as any).app; + a.cases = [{ name: 'buildmesh', path: '/fixture/buildmesh' }]; + a.sessions = new Map(); + a.sessionOrder = []; + a._runMode = 'codex'; + (window as any).__codemanCliCatalog = [ + { id: 'claude', label: 'Claude', enabled: true, kind: 'agent' }, + { id: 'codex', label: 'Codex', enabled: true, kind: 'agent' }, + { id: 'shell', label: 'Shell', enabled: true, kind: 'shell' }, + ]; + a._runModeAvailableForCase = () => true; + a._sendInputEphemeral = () => { + throw new Error('Palette leaked terminal input'); + }; + a.run = () => { + throw new Error('Arrow key launched a session'); + }; + }); + await page.keyboard.press('Control+k'); + await page.locator('#commandPaletteModal.active').waitFor(); + const input = page.locator('#commandPaletteSearch'); + await input.fill('buildmesh'); + const label = page.locator('.command-palette-agent-label'); + expect(await label.innerText()).toBe('Codex'); + await page.keyboard.press('ArrowLeft'); + expect(await label.innerText()).toBe('Claude'); + await page.keyboard.press('ArrowRight'); + expect(await label.innerText()).toBe('Codex'); + expect(await input.inputValue()).toBe('buildmesh'); + expect(await input.evaluate((e: HTMLInputElement) => e.selectionStart)).toBe(9); + await page.keyboard.press('Home'); + await page.keyboard.press('ArrowRight'); + expect(await label.innerText()).toBe('Shell'); + await page.keyboard.press('ArrowLeft'); + expect(await label.innerText()).toBe('Codex'); + await input.evaluate((e: HTMLInputElement) => e.setSelectionRange(4, 4)); + await page.keyboard.press('ArrowLeft'); + expect(await input.evaluate((e: HTMLInputElement) => e.selectionStart)).toBe(3); + await page.keyboard.press('ArrowRight'); + expect(await input.evaluate((e: HTMLInputElement) => e.selectionStart)).toBe(4); + await page.keyboard.press('Shift+End'); + await page.keyboard.press('ArrowLeft'); + expect(await label.innerText()).toBe('Codex'); + await page.keyboard.press('ArrowDown'); + expect( + await page.evaluate( + () => (window as any).app.commandPaletteItems[(window as any).app.commandPaletteActiveIndex].type + ) + ).toBe('browse-sessions'); + await page.keyboard.press('End'); + await page.keyboard.press('ArrowLeft'); + expect(await label.innerText()).toBe('Codex'); + await page.keyboard.press('Escape'); + expect(await page.locator('#commandPaletteModal').evaluate((e) => e.classList.contains('active'))).toBe(false); + expect(errors).toEqual([]); + } finally { + await page.close(); + } + }); +}); diff --git a/test/command-palette-ui.test.ts b/test/command-palette-ui.test.ts index 408a4e1d..516a11dc 100644 --- a/test/command-palette-ui.test.ts +++ b/test/command-palette-ui.test.ts @@ -437,13 +437,28 @@ describe('Command-K new-session agent picker', () => { expect(app.commandPaletteItems[app.commandPaletteActiveIndex]).toMatchObject({ mode: 'opencode' }); }); - it('leaves Left/Right to the caret when they would move it', async () => { + it('cycles both ways at the end of a typed case query without moving its caret', async () => { + const { app, listeners, elements } = loadAgentPalette(); + app.openCommandPalette(); + elements.commandPaletteSearch.value = 'nomatch'; + app.renderCommandPalette(); + const left = arrow('ArrowLeft', 7, 'nomatch'); + await listeners['search:keydown'](left); + expect(left.preventDefault).toHaveBeenCalled(); + expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('opencode'); + const right = arrow('ArrowRight', 7, 'nomatch'); + await listeners['search:keydown'](right); + expect(right.preventDefault).toHaveBeenCalled(); + expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude'); + }); + + it('leaves Left/Right to the caret inside the query', async () => { const { app, listeners, elements } = loadAgentPalette(); app.openCommandPalette(); elements.commandPaletteSearch.value = 'nomatch'; app.renderCommandPalette(); - const left = arrow('ArrowLeft', 7, 'nomatch'); + const left = arrow('ArrowLeft', 3, 'nomatch'); const right = arrow('ArrowRight', 3, 'nomatch'); await listeners['search:keydown'](left); await listeners['search:keydown'](right); @@ -453,6 +468,20 @@ describe('Command-K new-session agent picker', () => { expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude'); }); + it.each(['altKey', 'ctrlKey', 'metaKey', 'shiftKey'])( + 'preserves %s arrow editing at query boundaries', + async (modifier) => { + const { app, listeners, elements } = loadAgentPalette(); + app.openCommandPalette(); + elements.commandPaletteSearch.value = 'nomatch'; + app.renderCommandPalette(); + const event = { ...arrow('ArrowLeft', 7, 'nomatch'), [modifier]: true }; + await listeners['search:keydown'](event); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude'); + } + ); + it('does not cycle while a session row is highlighted', async () => { const { app, listeners } = loadAgentPalette(); app.openCommandPalette(); diff --git a/test/custom-model-endpoint-rediscovery.test.ts b/test/custom-model-endpoint-rediscovery.test.ts index fd60f845..c93dd42e 100644 --- a/test/custom-model-endpoint-rediscovery.test.ts +++ b/test/custom-model-endpoint-rediscovery.test.ts @@ -481,6 +481,52 @@ describe('refreshAllCustomModelHosts: parallel-slot enrichment (llama.cpp splits const [updated] = await readCustomModelHosts(dir); expect(updated.modelSlotCounts).toEqual({ a: 4 }); }); + + it('a round where only the listed figure answers resets the slot count a loaded round learned', async () => { + // The false-warning scenario, measured on a real llama-swap: round 1 has the model + // loaded under `--ctx-size 262144 --parallel 4`: a TOTAL window shared across slots, + // correctly stored with its count. Round 2 finds it unloaded and takes the LISTED + // `context_length`, which llama-swap documents as the PER-SLOT window. If the stored 4 + // survived, the floor check would divide the per-slot figure a second time + // (65536/4 = 16384) and warn about a model that serves 65536 to every request. + const dir = getDataDir(); + await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]); + fetchMock.mockImplementation(async (url: URL) => { + if (url.pathname === '/v1/models') { + return new Response(JSON.stringify({ data: [{ id: 'm', status: { value: 'loaded' } }] }), { status: 200 }); + } + if (url.pathname === '/running') { + return new Response( + JSON.stringify({ + running: [{ model: 'm', state: 'ready', cmd: 'llama-server -m /models/m.gguf --ctx-size 262144 --parallel 4' }], + }), + { status: 200 } + ); + } + throw new Error(`unexpected request: ${url.href}`); // the command answers both questions + }); + + await refreshAllCustomModelHosts(); + + let [round1] = await readCustomModelHosts(dir); + expect(round1.modelContextLengths).toEqual({ m: 262144 }); + expect(round1.modelSlotCounts).toEqual({ m: 4 }); + + // Round 2: unloaded, no /running or /props probe in order, listing its per-slot figure. + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ data: [{ id: 'm', status: { value: 'unloaded' }, context_length: 65536 }] }), { + status: 200, + }) + ); + + await refreshAllCustomModelHosts(); + + const [round2] = await readCustomModelHosts(dir); + expect(round2.modelContextLengths).toEqual({ m: 65536 }); + // 1 slot, not the stale 4: 65536 per slot with 1 recorded slot is the honest pair, so + // the floor check reads 65536/request: exactly what the server gives a single request. + expect(round2.modelSlotCounts).toEqual({ m: 1 }); + }); }); describe('refreshAllCustomModelHosts: model-size enrichment (parsed from /v1/models description)', () => { diff --git a/test/custom-model-injection-apply.test.ts b/test/custom-model-injection-apply.test.ts index 9f870100..6aca903e 100644 --- a/test/custom-model-injection-apply.test.ts +++ b/test/custom-model-injection-apply.test.ts @@ -53,9 +53,12 @@ describe('applyCustomModelInjection: context length', () => { expect(applied?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBeUndefined(); }); - it('deepseek: has no contextLengthVar declared, so a passed-in length is a no-op', () => { - const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', 'sess-ctx-3', 16384); - expect(Object.keys(applied?.envOverrides ?? {}).sort()).toEqual(['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL']); + it('deepseek: passes the known context through its isolated model catalogue', () => { + const sessionId = 'sess-deepseek-context'; + sessionsToClean.push(sessionId); + const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', sessionId, 32768); + expect(Object.keys(applied?.envOverrides ?? {}).sort()).toEqual(['CODEMAN_CUSTOM_MODEL_API_KEY', 'DSH_HOME']); + expect(readFileSync(join(applied!.configDir!, 'cordis.patch.yml'), 'utf8')).toContain('"contextWindow": 32768'); }); }); @@ -103,11 +106,12 @@ describe('applyCustomModelInjection: CLAUDE_CONFIG_DIR isolation', () => { expect(applied?.envOverrides.HOME).toBe(customModelConfigDir(sessionId)); }); - it('deepseek: no configDirVar declared, so no config dir is created at all', () => { + it('deepseek: creates an isolated profile patch', () => { const sessionId = 'sess-cfgdir-deepseek'; + sessionsToClean.push(sessionId); const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', sessionId); - expect(applied?.configDir).toBeUndefined(); - expect(existsSync(customModelConfigDir(sessionId))).toBe(false); + expect(applied?.configDir).toBe(customModelConfigDir(sessionId)); + expect(existsSync(join(applied!.configDir!, 'cordis.patch.yml'))).toBe(true); }); }); diff --git a/test/custom-model-injection-contract.test.ts b/test/custom-model-injection-contract.test.ts index 1685504d..940e1ba0 100644 --- a/test/custom-model-injection-contract.test.ts +++ b/test/custom-model-injection-contract.test.ts @@ -188,18 +188,18 @@ describe('custom-model-injection contract (mock server)', () => { expect(mock.requests[0].headers.authorization).toBe('Bearer contract-test-key'); }); - it('deepseek: DEEPSEEK_BASE_URL already carries the /v1 suffix dsh itself never adds, reaching the mock at the real path dsh requests', async () => { - // Confirmed by reading dsh's own bundled source: it fetches - // `${DEEPSEEK_BASE_URL}/chat/completions` verbatim, no /v1 insertion of its own — so - // this call (unlike gemini's above) passes DEEPSEEK_BASE_URL to callOpenAiCompat - // UNMODIFIED, exactly mirroring what the real harness does, rather than the test - // helping it along. + it('deepseek: the session patch carries the /v1 base URL and exact model, reaching the mock at the real path', async () => { + // dsh fetches `${baseURL}/chat/completions` verbatim, so the patch's baseURL already + // carries /v1 and is passed to callOpenAiCompat unmodified. const injection = buildCustomModelInjection(entryOrThrow('deepseek'), endpointFor(mock), 'qwen3'); - if (injection.kind !== 'env') throw new Error('unreachable'); - expect(Object.keys(injection.envOverrides).sort()).toEqual(['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL']); - expect(injection.envOverrides.DEEPSEEK_BASE_URL).toBe(`${mock.baseUrl}/v1`); + if (injection.kind !== 'configDir') throw new Error('unreachable'); + expect(injection.dirEnvVar).toBe('DSH_HOME'); + expect(Object.keys(injection.extraEnv ?? {})).toEqual(['CODEMAN_CUSTOM_MODEL_API_KEY']); + const provider = JSON.parse(injection.files[0].content)[0].config.providers['codeman-custom']; + expect(provider.baseURL).toBe(`${mock.baseUrl}/v1`); + expect(provider.models[0].id).toBe('qwen3'); - await callOpenAiCompat(injection.envOverrides.DEEPSEEK_BASE_URL, injection.envOverrides.DEEPSEEK_API_KEY, 'qwen3'); + await callOpenAiCompat(provider.baseURL, injection.extraEnv!.CODEMAN_CUSTOM_MODEL_API_KEY, 'qwen3'); expect(mock.requests[0].path).toBe('/v1/chat/completions'); expect(mock.requests[0].headers.authorization).toBe('Bearer contract-test-key'); diff --git a/test/custom-model-injection.test.ts b/test/custom-model-injection.test.ts index c99fff49..b88dbbab 100644 --- a/test/custom-model-injection.test.ts +++ b/test/custom-model-injection.test.ts @@ -8,6 +8,7 @@ */ import { describe, it, expect } from 'vitest'; +import { parse as parseToml } from 'smol-toml'; import { getCli } from '../src/config/cli-registry/index.js'; import { buildCustomModelInjection, @@ -112,6 +113,31 @@ describe('buildCustomModelInjection', () => { expect(parsed.provider.custom.models.qwen3).toEqual({}); }); + it('opencode: threads a discovered context length into the model limit as the full window (issue #749)', () => { + const result = buildCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3', 16384); + if (result.kind !== 'env') throw new Error('unreachable'); + const parsed = JSON.parse(result.envOverrides.OPENCODE_CONFIG_CONTENT); + // `limit.context` IS the full window to opencode (it reserves for the reply itself), + // so the discovered total passes through; `output` only caps the reply at a quarter. + expect(parsed.provider.custom.models.qwen3).toEqual({ limit: { context: 16384, output: 4096 } }); + }); + + it('opencode: caps the response headroom reservation at 8192 for big windows', () => { + const result = buildCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3', 262144); + if (result.kind !== 'env') throw new Error('unreachable'); + const parsed = JSON.parse(result.envOverrides.OPENCODE_CONFIG_CONTENT); + expect(parsed.provider.custom.models.qwen3).toEqual({ limit: { context: 262144, output: 8192 } }); + }); + + it('opencode: never invents a limit for an unknown or non-finite context length (issue #749 regression)', () => { + for (const contextLength of [undefined, NaN]) { + const result = buildCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3', contextLength); + if (result.kind !== 'env') throw new Error('unreachable'); + const parsed = JSON.parse(result.envOverrides.OPENCODE_CONFIG_CONTENT); + expect(parsed.provider.custom.models.qwen3).toEqual({}); + } + }); + it('codex: configDir writes an isolated config.toml with model/base_url, and the key rides as extraEnv (never a literal TOML field)', () => { const result = buildCustomModelInjection(entryOrThrow('codex'), endpoint, 'qwen3'); expect(result.kind).toBe('configDir'); @@ -133,6 +159,30 @@ describe('buildCustomModelInjection', () => { expect(result.files[0].content).toContain('model = "weird\\"model"'); }); + it('codex: writes the discovered window as TOP-LEVEL model_context_window with a 75% compact limit (issue #750)', () => { + const result = buildCustomModelInjection(entryOrThrow('codex'), endpoint, 'qwen3', 16384); + if (result.kind !== 'configDir') throw new Error('unreachable'); + const doc = parseToml(result.files[0].content) as Record<string, unknown>; + // Top-level is the shape codex reads: parked under [model_providers.custom] (its + // provider table) the same keys would be silently ignored by codex itself. + expect(doc.model_context_window).toBe(16384); + expect(doc.model_auto_compact_token_limit).toBe(12288); // 75% of the window, floored + const provider = (doc.model_providers as Record<string, Record<string, unknown>>).custom; + expect(provider.model_context_window).toBeUndefined(); + expect(provider.model_auto_compact_token_limit).toBeUndefined(); + }); + + it('codex: writes neither context line for an unknown, non-finite or zero context length', () => { + for (const contextLength of [undefined, NaN, 0]) { + const result = buildCustomModelInjection(entryOrThrow('codex'), endpoint, 'qwen3', contextLength); + if (result.kind !== 'configDir') throw new Error('unreachable'); + const doc = parseToml(result.files[0].content) as Record<string, unknown>; + expect(doc.model_context_window, String(contextLength)).toBeUndefined(); + expect(doc.model_auto_compact_token_limit, String(contextLength)).toBeUndefined(); + expect(doc.model).toBe('qwen3'); // the rest of the file is still written + } + }); + it('pi: configDir writes .pi/agent/models.json, redirected via HOME (verified live — PI_CONFIG_DIR does nothing for pi)', () => { const result = buildCustomModelInjection(entryOrThrow('pi'), endpoint, 'qwen3'); if (result.kind !== 'configDir') throw new Error('unreachable'); @@ -179,29 +229,32 @@ describe('buildCustomModelInjection', () => { expect(result.extraEnv).toEqual({ XAI_API_KEY: 'my-key' }); }); - it('deepseek: env kind sets base URL (with a /v1 suffix appended) and key, no model var', () => { - // appendV1Suffix is REQUIRED here, not cosmetic: confirmed by reading dsh's own - // bundled source (@deepseek-ai/dsh-llm-deepseek) that it builds the request URL as - // `${DEEPSEEK_BASE_URL}/chat/completions` with no "/v1" of its own, while - // llama-swap/llama.cpp only serves "/v1/chat/completions" — without this, every - // request 404s (confirmed live; this is the fix for the originally-reported - // "dsh: HTTP_404: DeepSeek API error (HTTP 404)"). + it('deepseek: isolates the selected model route and passes its key by reference', () => { const result = buildCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3'); - if (result.kind !== 'env') throw new Error('unreachable'); - expect(result.envOverrides).toEqual({ - DEEPSEEK_BASE_URL: 'http://192.168.1.50:8080/v1', - DEEPSEEK_API_KEY: 'my-key', - }); + if (result.kind !== 'configDir') throw new Error('Expected isolated DeepSeek config'); + expect(result.dirEnvVar).toBe('DSH_HOME'); + expect(result.extraEnv).toEqual({ CODEMAN_CUSTOM_MODEL_API_KEY: 'my-key' }); + expect(result.files[0].content).not.toContain('my-key'); + const patch = JSON.parse(result.files[0].content); + // The patch references the DEDICATED var by name, never dsh's own DEEPSEEK_API_KEY: + // dsh's llm-deepseek/web-search rows read that too and would leak this key to + // api.deepseek.com on an in-session /model switch back to the official route. + expect(patch.find((row: { id: string }) => row.id === 'llm-pi-ai').config.providers['codeman-custom'].apiKeyEnv).toBe( + 'CODEMAN_CUSTOM_MODEL_API_KEY' + ); + expect(result.files[0].content).not.toContain('DEEPSEEK_API_KEY'); + expect(patch.find((row: { id: string }) => row.id === 'dsh-tui').config.model).toBe('qwen3'); }); - it('deepseek: appending the /v1 suffix is idempotent against a baseUrl that already ends in /v1', () => { + it('deepseek: preserves an existing version suffix', () => { const result = buildCustomModelInjection( entryOrThrow('deepseek'), - { ...endpoint, baseUrl: 'http://192.168.1.50:8080/v1' }, + { ...endpoint, baseUrl: endpoint.baseUrl + '/v1' }, 'qwen3' ); - if (result.kind !== 'env') throw new Error('unreachable'); - expect(result.envOverrides.DEEPSEEK_BASE_URL).toBe('http://192.168.1.50:8080/v1'); + if (result.kind !== 'configDir') throw new Error('Expected isolated DeepSeek config'); + expect(result.files[0].content).toContain('http://192.168.1.50:8080/v1'); + expect(result.files[0].content).not.toContain('/v1/v1'); }); it('antigravity: unsupported', () => { @@ -232,7 +285,9 @@ describe('launchModel (the model launch param that selects the injected provider }); it('CLIs whose config selects the model on its own declare none', () => { - for (const id of ['claude', 'opencode', 'codex', 'gemini', 'deepseek']) { + // codex now declares launchModel (selected-model launch override) and is covered by + // its own restart coverage, not here. + for (const id of ['claude', 'opencode', 'gemini', 'deepseek']) { const result = buildCustomModelInjection(entryOrThrow(id), endpoint, 'qwen3'); if (result.kind === 'unsupported') throw new Error('unreachable'); expect(result.launchModel, id).toBeUndefined(); diff --git a/test/custom-model-recovery-context.test.ts b/test/custom-model-recovery-context.test.ts new file mode 100644 index 00000000..c3a96a40 --- /dev/null +++ b/test/custom-model-recovery-context.test.ts @@ -0,0 +1,91 @@ +/** + * @fileoverview Boot recovery rebuilds a saved custom-model selection through the SAME + * context-length resolution the routes use: the model's discovered figure, falling back to + * the endpoint's `defaultContextLength` (`resolveCustomModelContextLength`). Before the + * shared helper, `_rebuildCustomModelEnv` read `modelContextLengths` alone, so a session + * on a model that only the endpoint default covers came back from a Codeman restart with + * `CLAUDE_CODE_MAX_CONTEXT_TOKENS` (or a config file's context figure) silently gone, + * re-arming exactly the window overflow the var exists to prevent. + * + * `_rebuildCustomModelEnv` reads no `this` state (it re-derives everything from the endpoint + * store and the Session it is handed), so it is driven straight off the prototype with an + * empty receiver: `restoreMuxSessions()` itself cannot be reached under vitest, the same + * constraint recorded in test/session-model-recovery.test.ts. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { getDataDir } from '../src/config/instance.js'; +import { writeCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js'; +import { customModelConfigDir, removeConfigDir, type AppliedCustomModel } from '../src/custom-model-injection-apply.js'; +import { Session } from '../src/session.js'; +import { WebServer } from '../src/web/server.js'; +import type { CustomModelBookkeeping } from '../src/types/session.js'; + +type RebuildFn = (session: Session, saved: CustomModelBookkeeping) => Promise<AppliedCustomModel | undefined>; + +/** The real method, with an empty receiver, see the file header for why that is sound. */ +function rebuildCustomModelEnv(session: Session, saved: CustomModelBookkeeping) { + const fn = (WebServer.prototype as unknown as { _rebuildCustomModelEnv: RebuildFn })._rebuildCustomModelEnv; + expect(typeof fn, '_rebuildCustomModelEnv vanished from WebServer.prototype').toBe('function'); + return fn.call({}, session, saved); +} + +function endpoint(overrides: Partial<CustomModelHost> = {}): CustomModelHost { + return { + id: 'ep', + label: 'Ep', + baseUrl: 'http://localhost:8080', + apiKey: 'recovery-probe-key', + models: ['qwen3'], + ...overrides, + }; +} + +describe('_rebuildCustomModelEnv: context-length fallback on boot recovery', () => { + const configs: string[] = []; + + afterEach(() => { + for (const id of configs.splice(0)) removeConfigDir(customModelConfigDir(id)); + }); + + it('falls back to the endpoint defaultContextLength when discovery recorded no per-model figure', async () => { + await writeCustomModelHosts(getDataDir(), [endpoint({ defaultContextLength: 32768 })]); + const session = new Session({ workingDir: '/tmp', mode: 'claude' }); + configs.push(session.id); + + const env = await rebuildCustomModelEnv(session, { endpointId: 'ep', modelId: 'qwen3', envKeys: [] }); + + expect(env?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe('32768'); + }); + + it('a discovered per-model figure still wins over the endpoint default', async () => { + await writeCustomModelHosts(getDataDir(), [ + endpoint({ modelContextLengths: { qwen3: 131072 }, defaultContextLength: 32768 }), + ]); + const session = new Session({ workingDir: '/tmp', mode: 'claude' }); + configs.push(session.id); + + const env = await rebuildCustomModelEnv(session, { endpointId: 'ep', modelId: 'qwen3', envKeys: [] }); + + expect(env?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe('131072'); + }); + + it('states nothing rather than guessing when neither source knows a window', async () => { + await writeCustomModelHosts(getDataDir(), [endpoint()]); + const session = new Session({ workingDir: '/tmp', mode: 'claude' }); + configs.push(session.id); + + const env = await rebuildCustomModelEnv(session, { endpointId: 'ep', modelId: 'qwen3', envKeys: [] }); + + expect(env?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBeUndefined(); + }); + + it('drops the selection to undefined (kept for clearing) when the endpoint no longer exists', async () => { + await writeCustomModelHosts(getDataDir(), []); + const session = new Session({ workingDir: '/tmp', mode: 'claude' }); + configs.push(session.id); + + await expect(rebuildCustomModelEnv(session, { endpointId: 'gone', modelId: 'qwen3', envKeys: [] })).resolves.toBe( + undefined + ); + }); +}); diff --git a/test/custom-model-recovery.test.ts b/test/custom-model-recovery.test.ts new file mode 100644 index 00000000..9c3684a7 --- /dev/null +++ b/test/custom-model-recovery.test.ts @@ -0,0 +1,102 @@ +/** + * @fileoverview Boot recovery re-derives a saved custom-model selection through + * `_rebuildCustomModelEnv` and rebuilds the BOOKKEEPING from what the CURRENT recipe + * applied, not from the saved record. A selection persisted while deepseek still used + * the old env redirect comes back env-shaped (envKeys `DEEPSEEK_BASE_URL`/ + * `DEEPSEEK_API_KEY`, no `configDir`) although the recipe now writes a per-session patch + * home: restoring the saved record unchanged left `DSH_HOME` on a generated config dir + * that clearing could never delete. + * + * Real-home resolution is NOT recovered from the session's env overrides any more: + * `getEnvOverridesForPersist()` keeps only `CLAUDE_CODE_*`/`CLAUDE_CONFIG_DIR`, so a + * session's own `DSH_HOME` never survives a real restart and the lookup only ever hit on + * the first apply in-process. Recovery falls back to the server's `DSH_HOME` or the + * recipe's `realHomeFallback` (~/.dsh), see `_rebuildCustomModelEnv`'s comment. + * + * `_restoreCustomModel` reads no `this` state beyond the rebuild helper (it re-derives + * everything from the endpoint store and the Session it is handed), so it is driven off + * the prototype with a minimal receiver: `restoreMuxSessions()` itself cannot be + * reached under vitest, the same constraint recorded in test/session-model-recovery.test.ts. + */ +import { mkdirSync, rmSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getDataDir } from '../src/config/instance.js'; +import { writeCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js'; +import { customModelConfigDir, removeConfigDir } from '../src/custom-model-injection-apply.js'; +import { Session } from '../src/session.js'; +import { WebServer } from '../src/web/server.js'; +import type { CustomModelBookkeeping } from '../src/types/session.js'; + +/** The real pair, on a receiver that only carries the rebuild helper, see the header. */ +async function restoreCustomModel(session: Session, saved: CustomModelBookkeeping): Promise<void> { + const proto = WebServer.prototype as unknown as { + _rebuildCustomModelEnv: (session: Session, saved: CustomModelBookkeeping) => Promise<unknown>; + _restoreCustomModel: (session: Session, saved: CustomModelBookkeeping) => Promise<void>; + }; + expect(typeof proto._restoreCustomModel, '_restoreCustomModel vanished from WebServer.prototype').toBe('function'); + await proto._restoreCustomModel.call({ _rebuildCustomModelEnv: proto._rebuildCustomModelEnv }, session, saved); +} + +function endpoint(): CustomModelHost { + return { + id: 'ep', + label: 'Ep', + baseUrl: 'http://localhost:8080', + apiKey: 'recovery-probe-key', + models: ['qwen3'], + }; +} + +describe('_restoreCustomModel: bookkeeping rebuilt from the current recipe', () => { + const configs: string[] = []; + + afterEach(() => { + for (const id of configs.splice(0)) removeConfigDir(customModelConfigDir(id)); + vi.unstubAllEnvs(); + }); + + it('carries the configDir and env-key union for a selection saved by the old env recipe', async () => { + // Saved exactly as 1.31.0-1.47.0 persisted a deepseek selection: the env recipe's two + // keys, no configDir. The rebuilt record must carry the per-session config dir the + // CURRENT recipe wrote (so clearing deletes it) while KEEPING the saved keys, which + // the tmux session may still carry `setenv`s for (the union is what a clear unsets). + const dshHome = join(homedir(), 'codeman-recovery-dsh-home'); + mkdirSync(join(dshHome, 'profiles'), { recursive: true }); + vi.stubEnv('DSH_HOME', dshHome); + await writeCustomModelHosts(getDataDir(), [endpoint()]); + const session = new Session({ workingDir: '/tmp', mode: 'deepseek' }); + configs.push(session.id); + + await restoreCustomModel(session, { + endpointId: 'ep', + modelId: 'qwen3', + label: 'Ep', + envKeys: ['DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY'], + }); + + const persisted = session.getCustomModelForPersist(); + expect(persisted?.configDir).toBe(customModelConfigDir(session.id)); + expect(persisted?.launchModel).toBeUndefined(); + expect(new Set(persisted?.envKeys)).toEqual( + new Set(['DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY', 'DSH_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY']) + ); + rmSync(dshHome, { recursive: true, force: true }); + }); + + it('restores an env-shaped record unchanged when the endpoint is gone, so a clear still works', async () => { + await writeCustomModelHosts(getDataDir(), []); + const session = new Session({ workingDir: '/tmp', mode: 'deepseek' }); + const saved: CustomModelBookkeeping = { + endpointId: 'gone', + modelId: 'qwen3', + label: 'Gone', + envKeys: ['DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY'], + }; + + await restoreCustomModel(session, saved); + + expect(session.getCustomModelForPersist()).toEqual(saved); + }); +}); diff --git a/test/custom-model-run-menu-ui.test.ts b/test/custom-model-run-menu-ui.test.ts index 967e4457..17fbea90 100644 --- a/test/custom-model-run-menu-ui.test.ts +++ b/test/custom-model-run-menu-ui.test.ts @@ -20,6 +20,10 @@ import { describe, expect, it, vi } from 'vitest'; const CONSTANTS_JS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8'); const SESSION_UI_JS = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8'); +// Only for the [hidden] CSS guard below: the filter toggles the `hidden` property, and +// `.run-mode-option`'s own `display: flex` would silently defeat it without the +// `!important` re-assertion in styles.css. +const STYLES_CSS = readFileSync(new URL('../src/web/public/styles.css', import.meta.url), 'utf-8'); // Only for the real _showCenterStatus DOM tests below (`bootAppWithRealCenterStatus`) — // every other test in this file stubs _showCenterStatus itself and has no need of it. const PANELS_UI_JS = readFileSync(new URL('../src/web/public/panels-ui.js', import.meta.url), 'utf-8'); @@ -40,6 +44,8 @@ function bootApp( cliAvailable?: (id: string) => boolean; activeCase?: { location?: string } | null; settingsEnabled?: boolean; + /** Live sessions for the "in use by a session" fallback, in `this.sessions` Map shape. */ + sessions?: Array<Record<string, unknown>>; } = {} ) { const dom = new JSDOM( @@ -55,6 +61,10 @@ function bootApp( <div class="modal" id="customModelPickModal"> <h3 id="customModelPickTitle"></h3> <p id="customModelPickHint"></p> + <input type="search" id="customModelPickSearch" aria-label="Search models" + oninput="app.filterCustomModelPick(this.value)"> + <p id="customModelPickNoMatches" role="status" hidden>No models match</p> + <p id="customModelPickUnknown" role="status" hidden>Nothing is marked: this endpoint did not report which model it has loaded, and none of your running sessions uses one. Pick the model to launch.</p> <div id="customModelPickList"></div> </div> <div class="modal" id="customModelSwapConfirmModal"> @@ -81,6 +91,10 @@ function bootApp( app.cases = options.activeCase ? [{ name: 'testcase', ...options.activeCase }] : [{ name: 'testcase' }]; app.loadAppSettingsFromStorage = () => ({ customModelEndpointsEnabled: options.settingsEnabled ?? true }); app.isCliAvailable = options.cliAvailable ?? (() => true); + // The real app keeps live sessions in a Map keyed by id (app.js), which is what + // _getCustomModelInUseBySession walks; empty by default so a test that says nothing + // about sessions sees no promotion, exactly like a box with none running. + app.sessions = new Map((options.sessions || []).map((s, i) => [String(i), s])); app.showToast = () => {}; // Real implementation lives in panels-ui.js, not evaluated into this harness (only // constants.js + session-ui.js are — see below) — a no-op default handle matching its @@ -315,6 +329,27 @@ describe('Custom Model Endpoint Profiles: the "which model" picker', () => { expect(buttons[0].textContent).not.toContain('Currently loaded'); }); + it('treats a remembered "last used" id the host no longer lists as nothing promoted: no tag, the line shows', async () => { + const { win, app } = bootApp({ + hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }], + }); + const origApiJson = app._apiJson; + app._apiJson = async (path: string) => { + if (path === '/api/model-endpoints/llama-box/running-status') return { isLlamaSwap: false, running: [] }; + return origApiJson(path); + }; + // The remembered model was deleted from this endpoint since the last launch. + win.localStorage.setItem('codeman:customModelLastUsed:claude:llama-box', 'gone-model'); + + await app.selectCustomModelEntry('claude', 'llama-box'); + + const list = win.document.getElementById('customModelPickList')!; + expect(list.textContent).not.toContain('Last used'); + // Without a promoted row the probe really did answer "nothing is known", so the + // explanation line must show rather than leave two unmarked rows unexplained. + expect(win.document.getElementById('customModelPickUnknown')!.hidden).toBe(false); + }); + it('prefers the currently-loaded model over a stale "last used" entry when both are present', async () => { const { win, app } = bootApp({ hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3', 'phi4'] }], @@ -532,9 +567,349 @@ describe('Custom Model Endpoint Profiles: the "which model" picker', () => { await app.selectCustomModelEntry('claude', 'llama-box'); expect(toastMessage).toMatch(/no models discovered/i); }); + + it('resets and focuses the search box every time the picker opens', async () => { + const { win, app } = bootApp({ + hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }], + }); + win.app = app; + await app.selectCustomModelEntry('claude', 'llama-box'); + const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement; + // The caret lands in the box, so the first keystroke filters with no click. + expect(win.document.activeElement).toBe(search); + // A stale query from a previous open must not survive into the next one. + search.value = 'zzz'; + search.dispatchEvent(new win.Event('input')); + app.closeCustomModelPickModal(); + + await app.selectCustomModelEntry('claude', 'llama-box'); + expect(search.value).toBe(''); + expect(win.document.activeElement).toBe(search); + expect(win.document.getElementById('customModelPickNoMatches')!.hasAttribute('hidden')).toBe(true); + const visible = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')].filter( + (b) => !b.hasAttribute('hidden') + ); + expect(visible).toHaveLength(2); + }); + + it('leaves the search box unfocused on touch devices so the OS keyboard stays off the list', async () => { + const { win, app } = bootApp({ + hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }], + }); + win.app = app; + // The guard reads the global the browser defines (mobile-detection.js). + (win as unknown as { MobileDetection: { isTouchDevice: () => boolean } }).MobileDetection = { + isTouchDevice: () => true, + }; + + await app.selectCustomModelEntry('claude', 'llama-box'); + + const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement; + expect(win.document.activeElement).not.toBe(search); + // Only the focus is skipped: the rows still render, unfiltered and functional. + const visible = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')].filter( + (b) => !b.hasAttribute('hidden') + ); + expect(visible).toHaveLength(2); + }); + + it('filters rows case-insensitively on the model id, keeping the surviving row fully functional', async () => { + const { win, app } = bootApp({ + hosts: [ + { + id: 'llama-box', + label: 'llama.cpp', + baseUrl: 'http://x', + models: ['qwen3', 'llama3'], + defaultModelId: 'qwen3', + }, + ], + }); + let launched: unknown[] | null = null; + app.runCustomModelEntry = async (...args: unknown[]) => { + launched = args; + }; + win.app = app; + await app.selectCustomModelEntry('claude', 'llama-box'); + const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement; + search.value = 'QWEN'; + search.dispatchEvent(new win.Event('input')); + + const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')]; + expect(buttons.filter((b) => b.hasAttribute('hidden')).map((b) => b.textContent!.trim())).toEqual(['llama3']); + const [visible] = buttons.filter((b) => !b.hasAttribute('hidden')); + // The surviving row keeps its Default badge and its real, callable click handler. + expect(visible.textContent).toContain('qwen3'); + expect(visible.textContent).toContain('Default'); + (visible as unknown as HTMLButtonElement & { onclick: (e: unknown) => void }).onclick( + new (win as any).Event('click') + ); + expect(launched).toEqual(['claude', 'llama-box', 'qwen3']); + }); + + it('shows the no-matches line only while a query matches nothing, and clears it with the query', async () => { + const { win, app } = bootApp({ + hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }], + }); + win.app = app; + await app.selectCustomModelEntry('claude', 'llama-box'); + const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement; + const noMatches = win.document.getElementById('customModelPickNoMatches')!; + const isHidden = () => + win.document.getElementById('customModelPickList')!.querySelectorAll('button[hidden]').length; + + // Whitespace is not a query: everything stays visible, no message. + search.value = ' '; + search.dispatchEvent(new win.Event('input')); + expect(noMatches.hasAttribute('hidden')).toBe(true); + expect(isHidden()).toBe(0); + + search.value = 'ghost'; + search.dispatchEvent(new win.Event('input')); + expect(noMatches.hasAttribute('hidden')).toBe(false); + expect(isHidden()).toBe(2); + + search.value = ''; + search.dispatchEvent(new win.Event('input')); + expect(noMatches.hasAttribute('hidden')).toBe(true); + expect(isHidden()).toBe(0); + }); + + it('keeps an HTML-significant model id inert in the picker rows too, and filterable by its raw text', async () => { + // Same vector as the Run-menu row above, now via data-model-id: escapeHtml covers + // the double quote, and the filter reads the decoded raw id back off dataset. + const dangerousModel = '"><img src=x onerror=alert(1)>'; + const { win, app } = bootApp({ + hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', dangerousModel] }], + }); + win.app = app; + await app.selectCustomModelEntry('claude', 'llama-box'); + const list = win.document.getElementById('customModelPickList')!; + expect(list.querySelector('img')).toBeNull(); + + const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement; + search.value = 'src=x'; + search.dispatchEvent(new win.Event('input')); + const visible = list.querySelectorAll('button:not([hidden])'); + expect(visible).toHaveLength(1); + expect(visible[0].getAttribute('data-model-id')).toBe(dangerousModel); + }); + + it('re-asserts display:none !important for hidden picker rows so the filter cannot be silently defeated', () => { + // `.run-mode-option` carries its own `display: flex` (styles.css, restated in + // mobile.css), which outranks the UA `[hidden]` rule: without this scoped + // !important re-assertion, toggling `hidden` would change nothing on screen. + const rule = STYLES_CSS.match(/#customModelPickModal \.run-mode-option\[hidden\]\s*\{[^}]*\}/); + expect(rule, 'picker rows need a [hidden] rule scoped to #customModelPickModal').toBeTruthy(); + expect(rule![0]).toMatch(/display:\s*none\s*!important/); + }); + + it('pins "No models match" hidden with an empty query and shown only when a query hides every row, in CSS too', async () => { + // The visible symptom: the line carries `.form-hint`, whose `display: block` + // outranks the UA `[hidden]` rule, so `hidden` alone showed "No models match" + // while every model was listed with an empty search box. The attribute half + // was always right (session-ui.js's `filterCustomModelPick`); the CSS half + // needed the same `[hidden] { display: none !important }` re-assertion the + // rows above carry. This test pins both states AND the CSS that makes them + // actually render. + const rule = STYLES_CSS.match(/#customModelPickModal \.custom-model-pick-no-matches\[hidden\]\s*\{[^}]*\}/); + expect(rule, 'the no-matches line needs a [hidden] rule scoped to #customModelPickModal').toBeTruthy(); + expect(rule![0]).toMatch(/display:\s*none\s*!important/); + + const { win, app } = bootApp({ + hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }], + }); + win.app = app; + await app.selectCustomModelEntry('claude', 'llama-box'); + const noMatches = win.document.getElementById('customModelPickNoMatches')!; + + // Empty query, models listed: the attribute must be set, and the CSS rule + // above is what makes that attribute actually hide the line. + expect(noMatches.hasAttribute('hidden')).toBe(true); + + // A non-empty query that hides every row: shown again. + const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement; + search.value = 'ghost'; + search.dispatchEvent(new win.Event('input')); + expect(noMatches.hasAttribute('hidden')).toBe(false); + }); }); -describe('Custom Model Endpoint Profiles: _getCustomModelCurrentlyLoaded is client-side bounded', () => { +/** + * The reported bug: a gateway such as Bifrost (40 models, alphabetical, no llama-swap + * `/running` route of its own) showed "Choose a model" with nothing marked, so the user + * could not tell which model the endpoint was actually serving, while the same endpoint + * reached from the Codex CLI appeared to "default to alpha order" for every other CLI. + * The endpoint genuinely cannot answer, so the app falls back to what it does know: a + * live session it has itself pointed at that endpoint. And it says so plainly when it + * knows neither. + */ +describe('Custom Model Endpoint Profiles: gateways without /running fall back to a live session', () => { + const GATEWAY = { + id: 'bifrost', + label: 'bifrost', + baseUrl: 'https://bifrost.example', + models: ['llama-swap/alpha-3b', 'llama-swap/qwen3.8-flash-next-unsloth-ud-iq4_xs', 'llama-swap/zeta-70b'], + }; + + /** The gateway's exact shape: reachable, OpenAI-compatible, and not llama-swap. */ + function gatewayApp(sessions: Array<Record<string, unknown>>) { + const booted = bootApp({ hosts: [GATEWAY], sessions }); + const origApiJson = booted.app._apiJson; + booted.app._apiJson = async (path: string) => { + if (path === '/api/model-endpoints/bifrost/running-status') return { isLlamaSwap: false, running: [] }; + return origApiJson(path); + }; + return booted; + } + + const live = (modelId: string, overrides: Record<string, unknown> = {}) => ({ + name: 'w9-Gateway', + status: 'busy', + pid: 4242, + createdAt: 1_700_000_000_000, + customModel: { endpointId: 'bifrost', modelId, label: 'bifrost' }, + ...overrides, + }); + + it('marks the model a live session is running, at the top, for EVERY CLI, not just claude', async () => { + const inUse = 'llama-swap/qwen3.8-flash-next-unsloth-ud-iq4_xs'; + for (const mode of ['opencode', 'deepseek', 'codex', 'claude']) { + const { win, app } = gatewayApp([live(inUse)]); + await app._openCustomModelPickModal(mode, GATEWAY); + const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')]; + expect(buttons).toHaveLength(3); + expect(buttons[0].textContent, mode).toContain(inUse); + expect(buttons[0].textContent, mode).toContain('In use by a session'); + // The other rows keep discovery order and carry no tag of their own. + expect(buttons[1].textContent).toContain('llama-swap/alpha-3b'); + expect(buttons[2].querySelectorAll('.set-scope')).toHaveLength(0); + // Nothing was fabricated about the endpoint itself. + expect(win.document.getElementById('customModelPickList')!.textContent).not.toContain('Currently loaded'); + expect(win.document.getElementById('customModelPickUnknown')!.hasAttribute('hidden')).toBe(true); + } + }); + + it('a live session outranks this device’s "last used" memory, and both rank above nothing', async () => { + const { win, app } = gatewayApp([live('llama-swap/zeta-70b')]); + win.localStorage.setItem('codeman:customModelLastUsed:opencode:bifrost', 'llama-swap/alpha-3b'); + + await app._openCustomModelPickModal('opencode', GATEWAY); + + const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')]; + expect(buttons[0].textContent).toContain('llama-swap/zeta-70b'); + expect(buttons[0].textContent).toContain('In use by a session'); + expect(buttons[0].textContent).not.toContain('Last used'); + // The remembered pick is still listed, just not promoted or double-tagged. + expect(buttons[1].textContent).toContain('llama-swap/alpha-3b'); + expect(buttons[1].querySelectorAll('.set-scope')).toHaveLength(0); + }); + + it('a stopped session, an exited process, an exited pane, another endpoint or an undiscovered model mark nothing', async () => { + const inUse = 'llama-swap/qwen3.8-flash-next-unsloth-ud-iq4_xs'; + const cases: Array<[string, Record<string, unknown>]> = [ + ['stopped', { status: 'stopped' }], + ['process exited (pid null)', { pid: null }], + ['pane exited', { paneExit: { at: 1, status: 0 } }], + ['other endpoint', { customModel: { endpointId: 'other', modelId: inUse } }], + ['no custom model', { customModel: undefined }], + ['undiscovered model', { customModel: { endpointId: 'bifrost', modelId: 'llama-swap/retired-model' } }], + ]; + for (const [label, overrides] of cases) { + const { win, app } = gatewayApp([live(inUse, overrides)]); + await app._openCustomModelPickModal('opencode', GATEWAY); + const list = win.document.getElementById('customModelPickList')!; + expect(list.textContent, label).not.toContain('In use by a session'); + expect(list.textContent, label).not.toContain('Last used'); + // Discovery order untouched: nothing got dragged to the top. + expect(list.querySelectorAll('button')![0].textContent).toContain('llama-swap/alpha-3b'); + } + }); + + it('the most recently launched live session wins when two share an endpoint', async () => { + const { win, app } = gatewayApp([ + live('llama-swap/alpha-3b', { createdAt: 1 }), + live('llama-swap/zeta-70b', { createdAt: 2 }), + ]); + + await app._openCustomModelPickModal('opencode', GATEWAY); + + const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')]; + expect(buttons[0].textContent).toContain('llama-swap/zeta-70b'); + expect(buttons.filter((b) => b.textContent!.includes('In use by a session'))).toHaveLength(1); + }); + + it('says so plainly when nothing is known (a down endpoint answers the very same shape)', async () => { + const { win, app } = gatewayApp([]); + + await app._openCustomModelPickModal('opencode', GATEWAY); + + const unknown = win.document.getElementById('customModelPickUnknown')!; + expect(unknown.hasAttribute('hidden')).toBe(false); + expect(unknown.textContent).toContain('did not report which model it has loaded'); + // What REALLY arrives here, not a fiction: `getLlamaSwapStatus` (custom-model-routes.ts) + // turns EVERY upstream failure into `isLlamaSwap: false` (a gateway with no + // `/running` route, a closed port, an unresolvable host, a 401), so this stub is + // also exactly what a DOWN endpoint produces, which is why the line's wording says + // only "did not report" and never why. + // The same class-of-bug trap as the no-matches line: `.form-hint`'s `display: block` + // outranks the UA `[hidden]` rule, so the attribute needs the CSS re-assertion. + const rule = STYLES_CSS.match(/#customModelPickModal \.custom-model-pick-unknown\[hidden\]\s*\{[^}]*\}/); + expect(rule, 'the unknown-endpoint line needs a [hidden] rule scoped to #customModelPickModal').toBeTruthy(); + expect(rule![0]).toMatch(/display:\s*none\s*!important/); + }); + + it('hides that line whenever a row IS marked, and for a probe that never got any answer', async () => { + // Marked by a live session: nothing to explain. + const marked = gatewayApp([live('llama-swap/alpha-3b')]); + await marked.app._openCustomModelPickModal('opencode', GATEWAY); + expect(marked.win.document.getElementById('customModelPickUnknown')!.hasAttribute('hidden')).toBe(true); + + // `null` (no answer at all) only happens when Codeman's OWN request to the route + // failed or the client-side bound won the race. The route itself never answers this + // way for an unreachable endpoint: every fetch failure arrives as `isLlamaSwap: + // false`, pinned by the test above. The box may merely be asleep, so stay silent. + const asleep = bootApp({ hosts: [GATEWAY] }); + asleep.app._apiJson = async (path: string) => (path.includes('running-status') ? null : []); + await asleep.app._openCustomModelPickModal('opencode', GATEWAY); + expect(asleep.win.document.getElementById('customModelPickUnknown')!.hasAttribute('hidden')).toBe(true); + }); + + it('llama-swap’s own answer still wins over a session pin, and keeps its own tag', async () => { + const booted = bootApp({ + hosts: [GATEWAY], + sessions: [live('llama-swap/alpha-3b')], + }); + const origApiJson = booted.app._apiJson; + booted.app._apiJson = async (path: string) => { + if (path === '/api/model-endpoints/bifrost/running-status') { + return { isLlamaSwap: true, running: [{ model: 'llama-swap/zeta-70b', state: 'ready' }] }; + } + return origApiJson(path); + }; + + await booted.app._openCustomModelPickModal('opencode', GATEWAY); + + const buttons = [...booted.win.document.getElementById('customModelPickList')!.querySelectorAll('button')]; + expect(buttons[0].textContent).toContain('llama-swap/zeta-70b'); + expect(buttons[0].textContent).toContain('Currently loaded'); + expect(buttons[0].textContent).not.toContain('In use by a session'); + // The session's model is an ordinary row again: one promotion, not two. + expect(buttons.filter((b) => b.querySelector('.set-scope'))).toHaveLength(1); + }); + + it('never trusts displayModel: a session whose terminal merely printed a model marks nothing', async () => { + const booted = gatewayApp([ + { name: 'w9-Other', status: 'busy', createdAt: 1, displayModel: { model: 'llama-swap/zeta-70b', source: 'screen' } }, + ]); + + await booted.app._openCustomModelPickModal('opencode', GATEWAY); + + expect(booted.win.document.getElementById('customModelPickList')!.textContent).not.toContain('In use by a session'); + }); +}); + +describe('Custom Model Endpoint Profiles: _getCustomModelRunningStatus is client-side bounded', () => { // `timeoutMs` driven in milliseconds rather than the real 800 — same reasoning as // `_watchLlamaSwapLoading`'s own `pollIntervalMs` a few describe blocks down: this // code runs inside the JSDOM window's own realm, whose setTimeout vi.useFakeTimers() @@ -549,26 +924,31 @@ describe('Custom Model Endpoint Profiles: _getCustomModelCurrentlyLoaded is clie // endpoint that is asleep or firewalled, distinct from one that answers an error. app._apiJson = () => new Promise(() => {}); - const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 5); + const status = await app._getCustomModelRunningStatus({ id: 'llama-box', models: ['qwen3'] }, 5); - expect(result).toBeNull(); + // `null` means "no answer at all" (Codeman's own request failed or the client-side + // bound won): the picker keeps its "Nothing is marked" line hidden for exactly this + // case (pinned in the gateway describe above). An endpoint that is merely down is + // NOT here: the route turns every fetch failure into `isLlamaSwap: false`. + expect(status).toBeNull(); + expect(app._pickReadyRunningModel(status, { id: 'llama-box', models: ['qwen3'] })).toBeNull(); }); it('an endpoint that answers well within the bound is unaffected by it', async () => { const { app } = bootApp({}); app._apiJson = async () => ({ isLlamaSwap: true, running: [{ model: 'qwen3', state: 'ready' }] }); - const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 5); + const status = await app._getCustomModelRunningStatus({ id: 'llama-box', models: ['qwen3'] }, 5); - expect(result).toBe('qwen3'); + expect(app._pickReadyRunningModel(status, { id: 'llama-box', models: ['qwen3'] })).toBe('qwen3'); }); it('a rejected probe settles quietly to null rather than leaving an unhandled rejection once the timeout has already won the race', async () => { const { app } = bootApp({}); app._apiJson = () => new Promise((_resolve, reject) => setTimeout(() => reject(new Error('boom')), 10)); - const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 2); - expect(result).toBeNull(); + const status = await app._getCustomModelRunningStatus({ id: 'llama-box', models: ['qwen3'] }, 2); + expect(status).toBeNull(); // Give the loser of the race a turn to actually reject and hit its own .catch — // an unswallowed rejection here would surface as an "Unhandled Errors" failure // for the whole test file, not a failed assertion in this test. diff --git a/test/custom-model-session-home-guard.test.ts b/test/custom-model-session-home-guard.test.ts new file mode 100644 index 00000000..08f36fc7 --- /dev/null +++ b/test/custom-model-session-home-guard.test.ts @@ -0,0 +1,68 @@ +/** + * @fileoverview `guardSessionConfigDirHome` probes a session-supplied configDir home before + * the apply runs synchronous fs calls on it (the #516 rule): a home that does not answer is + * hidden from the apply, so it links from the server's own home instead and says so. + * + * The probe is mocked: a mount that never answers is the case under test, and a real one + * cannot be made here. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../src/utils/bounded-path-probe.js', async (importOriginal) => { + const actual = await importOriginal<typeof import('../src/utils/bounded-path-probe.js')>(); + return { ...actual, probePathKind: vi.fn() }; +}); + +import { getCli } from '../src/config/cli-registry/registry.js'; +import { probePathKind } from '../src/utils/bounded-path-probe.js'; +import { guardSessionConfigDirHome } from '../src/web/routes/session-routes.js'; + +const probe = vi.mocked(probePathKind); +const deepseek = () => getCli('deepseek')!; +const env = + (values: Record<string, string>) => + (name: string): string | undefined => + values[name]; + +describe('guardSessionConfigDirHome', () => { + afterEach(() => { + probe.mockReset(); + vi.restoreAllMocks(); + }); + + it('hides only the home when it does not answer, and warns that the server home is used', async () => { + probe.mockResolvedValue('unknown'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const lookup = await guardSessionConfigDirHome(deepseek(), env({ DSH_HOME: '/mnt/dead/dsh', OTHER: 'kept' })); + + expect(lookup('DSH_HOME')).toBeUndefined(); + expect(lookup('OTHER')).toBe('kept'); + expect(probe).toHaveBeenCalledWith('/mnt/dead/dsh', { pastCap: true }); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain('/mnt/dead/dsh'); + }); + + it('passes a home that answers straight through', async () => { + probe.mockResolvedValue('directory'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const base = env({ DSH_HOME: '/home/u/.dsh' }); + + expect(await guardSessionConfigDirHome(deepseek(), base)).toBe(base); + expect(warn).not.toHaveBeenCalled(); + }); + + it('never probes a relative home: the apply refuses to join one', async () => { + const base = env({ DSH_HOME: 'relative/dsh' }); + + expect(await guardSessionConfigDirHome(deepseek(), base)).toBe(base); + expect(probe).not.toHaveBeenCalled(); + }); + + it('never probes for a CLI with no linkFromRealHome recipe', async () => { + const base = env({ CODEX_HOME: '/mnt/dead/codex' }); + + expect(await guardSessionConfigDirHome(getCli('codex')!, base)).toBe(base); + expect(probe).not.toHaveBeenCalled(); + }); +}); diff --git a/test/daemon-control.test.ts b/test/daemon-control.test.ts index 54c19ff9..3aefd40c 100644 --- a/test/daemon-control.test.ts +++ b/test/daemon-control.test.ts @@ -30,6 +30,36 @@ async function closedPort(): Promise<number> { } describe('buildWebArgs', () => { + it('forwards the tunnel opt-in between the network and multi-user flags', () => { + const options = { + host: '0.0.0.0', + port: 8080, + https: true, + titleHostname: 'tower', + allowUnauthenticatedNetwork: true, + allowUnauthenticatedTunnel: true, + multiuser: true, + }; + expect(buildWebArgs(options)).toEqual([ + 'web', + '--host', + '0.0.0.0', + '--port', + '8080', + '--https', + '--title-hostname', + 'tower', + '--allow-unauthenticated-network', + '--allow-unauthenticated-tunnel', + '--multiuser', + ]); + }); + + it.each([false, undefined])('omits the tunnel opt-in when it is %s', (allowUnauthenticatedTunnel) => { + const options = { host: '127.0.0.1', port: 8080, https: false, allowUnauthenticatedTunnel }; + expect(buildWebArgs(options)).toEqual(['web', '--host', '127.0.0.1', '--port', '8080']); + }); + it('always passes host and port through explicitly', () => { expect(buildWebArgs({ host: '127.0.0.1', port: 3000, https: false })).toEqual([ 'web', diff --git a/test/deepseek-custom-endpoint.test.ts b/test/deepseek-custom-endpoint.test.ts new file mode 100644 index 00000000..199e8089 --- /dev/null +++ b/test/deepseek-custom-endpoint.test.ts @@ -0,0 +1,207 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getCli } from '../src/config/cli-registry/index.js'; +import { buildCustomModelInjection } from '../src/custom-model-injection.js'; +import { + applyCustomModelInjection, + removeConfigDir, + customModelConfigDir, +} from '../src/custom-model-injection-apply.js'; + +afterEach(() => { + removeConfigDir(customModelConfigDir('deepseek-probe')); + vi.unstubAllEnvs(); +}); + +describe('DeepSeek custom endpoint isolation', () => { + it('uses the chosen model and limits without changing installed profiles or embedding the key', () => { + const realHome = join(homedir(), '.dsh'); + const profile = join(realHome, 'profiles', 'dsh-tui'); + mkdirSync(profile, { recursive: true }); + mkdirSync(join(realHome, 'sessions'), { recursive: true }); + writeFileSync(join(realHome, 'settings.yaml'), 'theme: dark'); + const original = join(profile, 'cordis.patch.yml'); + writeFileSync(original, 'original profile'); + vi.stubEnv('DSH_HOME', realHome); + const entry = getCli('deepseek'); + if (!entry) throw new Error('Missing DeepSeek entry'); + const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' }; + const applied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072); + if (!applied?.configDir) throw new Error('Missing isolated profile'); + const content = readFileSync(join(applied.configDir, 'cordis.patch.yml'), 'utf8'); + expect(content).not.toContain(endpoint.apiKey); + // The dedicated key var, never DEEPSEEK_API_KEY: dsh's own llm-deepseek/web-search + // rows read that too, and an in-session /model switch back to the official route must + // not take the custom endpoint's key to api.deepseek.com with it. + expect(applied.envOverrides.CODEMAN_CUSTOM_MODEL_API_KEY).toBe(endpoint.apiKey); + expect(applied.envOverrides.DEEPSEEK_API_KEY).toBeUndefined(); + const patch = JSON.parse(content); + const provider = patch.find((row: { id: string }) => row.id === 'llm-pi-ai').config.providers['codeman-custom']; + expect(provider.api).toBe('openai-completions'); + expect(provider.baseURL).toBe('http://box:8080/v1'); + expect(provider.models).toEqual([ + { id: 'chosen-model', name: 'chosen-model', contextWindow: 131072, maxTokens: 8192 }, + ]); + expect(patch.find((row: { id: string }) => row.id === 'dsh-tui').config).toEqual({ + provider: 'codeman-custom', + model: 'chosen-model', + }); + expect(realpathSync(join(applied.configDir, 'profiles'))).toBe(join(realHome, 'profiles')); + // The rest of the linkFromRealHome list, consumed generically: `sessions` is the + // transcript root removeConfigDir would otherwise delete, and the user's settings.yaml + // must keep applying through the redirect. + expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(realHome, 'sessions')); + expect(realpathSync(join(applied.configDir, 'settings.yaml'))).toBe(join(realHome, 'settings.yaml')); + // Re-apply onto the same config dir (boot recovery re-runs this every restart): the + // lstat guard must skip every existing link, which the old existsSync check missed: + // recreating them would raise EEXIST. + const reapplied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072); + expect(reapplied?.envOverrides.DSH_HOME).toBe(applied.configDir); + removeConfigDir(applied.configDir); + expect(readFileSync(original, 'utf8')).toBe('original profile'); + }); + + it('creates missing directory targets before linking, skips a missing file target, stays fine on re-apply', () => { + // A fresh install (or relocated DSH_HOME) has never run a turn, so the real home + // carries none of the linked entries. Linking a missing DIRECTORY left a dangling + // symlink whose every write dsh attempts THROUGH it failed with ENOENT on EVERY turn, + // and the lstat guard kept that dangling link forever. Directories are now created + // (0700, what dsh itself does on a first turn) before linking; a missing FILE + // (`settings.yaml`) is simply skipped. A stubbed HOME and CODEMAN_DATA_DIR keep both + // the fake real home and the generated dir off the developer's own dotfiles. + const sandbox = mkdtempSync(join(tmpdir(), 'dsh-fresh-home-')); + const realHome = join(sandbox, '.dsh'); + mkdirSync(realHome, { recursive: true }); // exists, but no sessions/, no profiles/, no settings.yaml + vi.stubEnv('HOME', sandbox); + vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data')); + vi.stubEnv('DSH_HOME', ''); // force resolution down to the recipe's `realHomeFallback` + try { + const entry = getCli('deepseek'); + if (!entry) throw new Error('Missing DeepSeek entry'); + const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' }; + const applied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072); + if (!applied?.configDir) throw new Error('Missing isolated profile'); + + expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(realHome, 'sessions')); + expect(realpathSync(join(applied.configDir, 'profiles'))).toBe(join(realHome, 'profiles')); + expect(statSync(join(realHome, 'sessions')).mode & 0o777).toBe(0o700); + // The missing FILE target is skipped outright, not a dangling link either: lstat + // must find nothing, so a later apply (once the file exists for real) links it. + expect(existsSync(join(realHome, 'settings.yaml'))).toBe(false); + expect(readlinkSafe(join(applied.configDir, 'settings.yaml'))).toBeUndefined(); + + // Re-apply stays fine (boot recovery re-runs it every restart): idempotent... + const reapplied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072); + expect(reapplied?.envOverrides.DSH_HOME).toBe(applied.configDir); + // ...and a target deleted AFTER linking keeps the existing link untouched (the + // lstat guard), rather than recreating it and throwing EEXIST. + rmSync(join(realHome, 'sessions'), { recursive: true, force: true }); + expect( + applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072)?.envOverrides.DSH_HOME + ).toBe(applied.configDir); + expect(readlinkSync(join(applied.configDir, 'sessions'))).toBe(join(realHome, 'sessions')); + } finally { + rmSync(sandbox, { recursive: true, force: true }); + } + }); + + it('links from the SESSION-supplied DSH_HOME, not the server environment one', () => { + // Both web routes pass the session's own env-override lookup as the seventh + // argument; a user who relocated the dsh home via an allowlisted DSH_HOME + // override must get their state linked from THAT directory, not the server's. + const sandbox = mkdtempSync(join(tmpdir(), 'dsh-session-home-')); + const serverHome = join(sandbox, 'server-home'); + const sessionHome = join(sandbox, 'session-home'); + mkdirSync(join(sessionHome, 'sessions'), { recursive: true }); + mkdirSync(join(sessionHome, 'profiles'), { recursive: true }); + writeFileSync(join(sessionHome, 'settings.yaml'), 'theme: light'); + vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data')); + vi.stubEnv('DSH_HOME', serverHome); + try { + const entry = getCli('deepseek'); + if (!entry) throw new Error('Missing DeepSeek entry'); + const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' }; + const applied = applyCustomModelInjection( + entry, + endpoint, + 'chosen-model', + 'deepseek-probe', + 131072, + undefined, + (k) => (k === 'DSH_HOME' ? sessionHome : undefined) + ); + if (!applied?.configDir) throw new Error('Missing isolated profile'); + expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(sessionHome, 'sessions')); + expect(realpathSync(join(applied.configDir, 'profiles'))).toBe(join(sessionHome, 'profiles')); + expect(realpathSync(join(applied.configDir, 'settings.yaml'))).toBe(join(sessionHome, 'settings.yaml')); + // The server-environment home was never consulted, let alone created. + expect(existsSync(serverHome)).toBe(false); + } finally { + rmSync(sandbox, { recursive: true, force: true }); + } + }); + + it('never self-links when the session override IS its own config dir; falls through to the server home', () => { + // Boot recovery re-applies onto a session already holding a previous injection's + // DSH_HOME value; linking that value would point the isolated directory at itself. + const sandbox = mkdtempSync(join(tmpdir(), 'dsh-self-home-')); + const serverHome = join(sandbox, 'server-home'); + mkdirSync(join(serverHome, 'sessions'), { recursive: true }); + vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data')); + vi.stubEnv('DSH_HOME', serverHome); + try { + const entry = getCli('deepseek'); + if (!entry) throw new Error('Missing DeepSeek entry'); + const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' }; + const applied = applyCustomModelInjection( + entry, + endpoint, + 'chosen-model', + 'deepseek-probe', + 131072, + undefined, + (k) => (k === 'DSH_HOME' ? customModelConfigDir('deepseek-probe') : undefined) + ); + if (!applied?.configDir) throw new Error('Missing isolated profile'); + expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(serverHome, 'sessions')); + } finally { + rmSync(sandbox, { recursive: true, force: true }); + } + }); + + it('skips a relative DSH_HOME rather than creating it under the server cwd', () => { + // A session may supply DSH_HOME; `work` would otherwise mkdir `work/sessions` in + // the SERVER's working directory and leave relative links that dangle (a relative + // home resolves against the CLI's cwd, which the server cannot know). Skipping it + // falls through to the recipe's `realHomeFallback`, and nothing is created under cwd. + const sandbox = mkdtempSync(join(tmpdir(), 'dsh-relative-home-')); + const fallbackHome = join(sandbox, '.dsh'); + mkdirSync(fallbackHome, { recursive: true }); + const relativeHome = `dsh-relative-probe-${process.pid}-${Date.now()}`; + vi.stubEnv('HOME', sandbox); // the fallback resolves under homedir() + vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data')); + vi.stubEnv('DSH_HOME', relativeHome); + try { + const entry = getCli('deepseek'); + if (!entry) throw new Error('Missing DeepSeek entry'); + const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' }; + const applied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072); + if (!applied?.configDir) throw new Error('Missing isolated profile'); + expect(existsSync(join(process.cwd(), relativeHome))).toBe(false); + expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(fallbackHome, 'sessions')); + } finally { + rmSync(sandbox, { recursive: true, force: true }); + } + }); +}); + +/** readlinkSync without the throw an absent link causes (ENOENT -> undefined). */ +function readlinkSafe(path: string): string | undefined { + try { + return readlinkSync(path); + } catch { + return undefined; + } +} diff --git a/test/deepseek-transcript.test.ts b/test/deepseek-transcript.test.ts index e0837070..292aac14 100644 --- a/test/deepseek-transcript.test.ts +++ b/test/deepseek-transcript.test.ts @@ -4,10 +4,11 @@ * Two of these assertions exist because the obvious implementation was measured * to be wrong against real files: * - * - dsh appends ONE ZSTD FRAME PER WRITE, and Node's `zlib` zstd decoder stops - * at the first frame end. A 56-line transcript decoded as 1 line / 158 bytes, - * which reads as "the worker never answered" rather than as an error. The - * multi-frame fixtures below are the guard. + * - dsh appends ONE ZSTD FRAME PER WRITE, and Node's `zlib` zstd decoder + * before 24.22 and 26.10 stops at the first frame end. A 56-line transcript + * decoded as 1 line / 158 bytes, which reads as "the worker never answered" + * rather than as an error. Newer Node decodes every frame (nodejs/node#65865), + * so the multi-frame fixtures below pin the walker itself, not Node's decoder. * - a fresh worker in a case directory that had been used before answered its * first `last-response` with the PREVIOUS session's reply. Session-to- * transcript pairing is therefore its own describe block. @@ -67,10 +68,6 @@ describe.skipIf(!zstdSupported())('zstd frame walking', () => { const lines = Array.from({ length: 40 }, (_, i) => JSON.stringify({ type: 'noise', seq: i })); const buf = framed(lines); - // The one-shot decoder is what this module had to replace. - const oneShot = (zlib as unknown as { zstdDecompressSync?: (b: Buffer) => Buffer }).zstdDecompressSync!(buf); - expect(oneShot.toString('utf8').trim().split('\n')).toHaveLength(1); - expect(zstdFrameRanges(buf)).toHaveLength(40); expect(decodeZstdFrames(buf).trim().split('\n')).toHaveLength(40); }); diff --git a/test/discord-bridge.test.ts b/test/discord-bridge.test.ts new file mode 100644 index 00000000..32db32d7 --- /dev/null +++ b/test/discord-bridge.test.ts @@ -0,0 +1,1377 @@ +// @vitest-environment node +/** + * @fileoverview Discord bridge: pure rules, the 0600 store, the REST client, the gateway state + * machine (fake socket) and the bridge's gates. No network, no real Discord. + */ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DiscordConfig } from '../src/types/discord.js'; +import { + buildPost, + DISCORD_STARTUP_HINT, + ensureReceivedDir, + isDiscordCdnUrl, + safeAttachmentName, + screenTail, + splitForDiscord, + tablesToCodeBlocks, + DEFAULT_DISCORD_CONFIG, + DiscordApi, + DiscordBridge, + DiscordGateway, + discordConfigPath, + discordConfigProblem, + maskToken, + readDiscordConfig, + readThreadMap, + sanitiseReply, + shouldPing, + threadNameFor, + writeDiscordConfig, + type DiscordFetch, + type DiscordMessage, + type GatewaySocket, +} from '../src/discord-bridge.js'; + +const TOKEN = 'MTA1.secret.token-value-1234567890'; +const GUILD = '111111111111111111'; +const PARENT = '222222222222222222'; +const ME = '333333333333333333'; +const THREAD = '444444444444444444'; + +const CFG: DiscordConfig = { + ...DEFAULT_DISCORD_CONFIG, + enabled: true, + token: TOKEN, + guildId: GUILD, + parentChannelId: PARENT, + allowedUserIds: [ME], +}; + +const MSG: DiscordMessage = { + event: 'hook:permission_prompt', + title: 'Permission Required', + body: '[w1-app] Tool: Bash', + urgency: 'critical', + sessionId: 's1', + sessionName: 'w1-app', +}; + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'discord-bridge-')); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +describe('pure rules', () => { + it('names the command in the hint Claude is launched with', () => { + expect(DISCORD_STARTUP_HINT).toContain('codeman notify'); + }); + + it('sanitiseReply makes one printable line plus Enter', () => { + expect(sanitiseReply('yes')).toBe('yes\r'); + expect(sanitiseReply('line one\nline two\r\nthree')).toBe('line one line two three\r'); + expect(sanitiseReply('a\u001b[31mb\u0000c')).toBe('a[31mbc\r'); + expect(sanitiseReply(' \n\t ')).toBeNull(); + expect(sanitiseReply('x'.repeat(5000))!.length).toBe(1001); + }); + + it('threadNameFor falls back to the id and clips to 100 characters', () => { + expect(threadNameFor('w3-app', 'abcdef123456')).toBe('w3-app'); + expect(threadNameFor('', 'abcdef123456')).toBe('abcdef12'); + expect(threadNameFor('n'.repeat(300), 'x')).toHaveLength(100); + }); + + it('shouldPing follows pingMode', () => { + expect(shouldPing('off', 'critical', true)).toBe(false); + expect(shouldPing('critical', 'critical')).toBe(true); + expect(shouldPing('critical', 'warning')).toBe(false); + expect(shouldPing('critical', 'warning', true)).toBe(true); + expect(shouldPing('all', 'info')).toBe(true); + }); + + it('buildPost pings only allowlisted ids and never parses mentions', () => { + const post = buildPost({ ...MSG, body: '@everyone <@999999999999999999>' }, CFG); + expect(post.content).toContain(`<@${ME}>`); + expect(post.allowed_mentions).toEqual({ parse: [], users: [ME] }); + const quiet = buildPost(MSG, { ...CFG, pingMode: 'off' }); + expect(quiet.content).not.toContain('<@'); + expect(quiet.allowed_mentions.users).toEqual([]); + }); + + it('buildPost fences the output tail and stays under the Discord limit', () => { + const post = buildPost({ ...MSG, tail: 'Press the reset button ``` now\n' + 'x'.repeat(5000) }, CFG); + expect(post.content.length).toBeLessThanOrEqual(1900); + expect(post.content).toContain('Press the reset button'); + expect(post.content).not.toMatch(/button ``` now/); + }); + + it('discordConfigProblem checks ids and demands a complete config to enable', () => { + expect(discordConfigProblem(CFG)).toBeNull(); + expect(discordConfigProblem(DEFAULT_DISCORD_CONFIG)).toBeNull(); + expect(discordConfigProblem({ ...CFG, guildId: 'abc' })).toMatch(/Guild ID/); + expect(discordConfigProblem({ ...CFG, allowedUserIds: ['x'] })).toMatch(/user ID/); + expect(discordConfigProblem({ ...CFG, allowedUserIds: [] })).toMatch(/at least one/i); + expect(discordConfigProblem({ ...CFG, token: '' })).toMatch(/token/); + expect(discordConfigProblem({ ...CFG, token: 'has space' })).toMatch(/token/); + }); + + it('maskToken never reveals the middle', () => { + expect(maskToken(TOKEN)).not.toContain('secret'); + expect(maskToken('')).toBe(''); + }); +}); + +describe('store', () => { + it('round-trips the config, writes it 0600 and coerces junk to defaults', async () => { + await writeDiscordConfig(dir, CFG); + expect(statSync(discordConfigPath(dir)).mode & 0o777).toBe(0o600); + expect(await readDiscordConfig(dir)).toEqual(CFG); + expect(await readDiscordConfig(join(dir, 'missing'))).toEqual(DEFAULT_DISCORD_CONFIG); + }); +}); + +function jsonRes(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); +} + +describe('DiscordApi', () => { + it('sends the token only in Authorization and retries once on 429', async () => { + const calls: Array<{ url: string; init: RequestInit }> = []; + const fetchImpl: DiscordFetch = async (url, init) => { + calls.push({ url: String(url), init }); + return calls.length === 1 ? jsonRes({ retry_after: 0.01 }, 429) : jsonRes({ id: THREAD }); + }; + const api = new DiscordApi( + () => TOKEN, + fetchImpl, + async () => undefined + ); + const out = await api.createThread(PARENT, 'w1-app'); + expect(out).toEqual({ id: THREAD }); + expect(calls).toHaveLength(2); + expect((calls[0].init.headers as Record<string, string>).Authorization).toBe(`Bot ${TOKEN}`); + expect(String(calls[0].init.body)).not.toContain(TOKEN); + }); + + it('turns an HTTP failure into an error that does not contain the token', async () => { + const api = new DiscordApi( + () => TOKEN, + async () => jsonRes({ code: 50001 }, 403) + ); + const err = await api.post(PARENT, { content: 'x', allowed_mentions: { parse: [], users: [] } }).catch((e) => e); + expect(err.status).toBe(403); + expect(String(err.message)).not.toContain(TOKEN); + }); +}); + +class FakeSocket implements GatewaySocket { + sent: Array<{ op: number; d?: Record<string, unknown> | number | null }> = []; + closed: number[] = []; + private h: { message?: (d: { toString(): string }) => void; close?: (c: number) => void } = {}; + send(data: string): void { + this.sent.push(JSON.parse(data)); + } + close(code = 1000): void { + this.closed.push(code); + this.h.close?.(code); + } + on(event: string, cb: (...a: never[]) => void): void { + (this.h as Record<string, unknown>)[event] = cb; + } + recv(frame: unknown): void { + this.h.message?.({ toString: () => JSON.stringify(frame) }); + } + drop(code: number): void { + this.h.close?.(code); + } +} + +describe('DiscordGateway', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + function setup() { + const sockets: FakeSocket[] = []; + const urls: string[] = []; + const seen: unknown[] = []; + const gw = new DiscordGateway( + () => TOKEN, + (m) => seen.push(m), + (url) => { + urls.push(url); + const s = new FakeSocket(); + sockets.push(s); + return s; + }, + () => 10 + ); + return { gw, sockets, urls, seen }; + } + + it('identifies with the three intents, heartbeats, and surfaces MESSAGE_CREATE', () => { + const { gw, sockets, seen } = setup(); + gw.start(); + sockets[0].recv({ op: 10, d: { heartbeat_interval: 1000 } }); + expect(sockets[0].sent[0].op).toBe(2); + expect((sockets[0].sent[0].d as { intents: number }).intents).toBe(1 | 512 | 32768); + sockets[0].recv({ op: 0, s: 1, t: 'READY', d: { session_id: 'sess', resume_gateway_url: 'wss://resume.example' } }); + expect(gw.state).toBe('connected'); + vi.advanceTimersByTime(1000); + expect(sockets[0].sent.at(-1)).toEqual({ op: 1, d: 1 }); + sockets[0].recv({ + op: 0, + s: 2, + t: 'MESSAGE_CREATE', + d: { id: 'm', channel_id: THREAD, content: 'hi', author: { id: ME } }, + }); + expect(seen).toHaveLength(1); + gw.stop(); + }); + + it('resumes on the resume url after a drop', () => { + const { gw, sockets, urls } = setup(); + gw.start(); + sockets[0].recv({ op: 10, d: { heartbeat_interval: 1000 } }); + sockets[0].recv({ op: 0, s: 5, t: 'READY', d: { session_id: 'sess', resume_gateway_url: 'wss://resume.example' } }); + sockets[0].drop(1006); + vi.advanceTimersByTime(10); + expect(urls[1]).toContain('wss://resume.example'); + sockets[1].recv({ op: 10, d: { heartbeat_interval: 1000 } }); + expect(sockets[1].sent[0]).toMatchObject({ op: 6, d: { session_id: 'sess', seq: 5 } }); + gw.stop(); + }); + + it('ignores a frame that arrives after stop(): no heartbeat timer, nothing sent', () => { + const { gw, sockets } = setup(); + gw.start(); + gw.stop(); + sockets[0].recv({ op: 10, d: { heartbeat_interval: 1000 } }); + expect(vi.getTimerCount()).toBe(0); + expect(sockets[0].sent).toHaveLength(0); + }); + + it('stops for good on a close code that retrying cannot fix', () => { + const { gw, sockets } = setup(); + gw.start(); + sockets[0].drop(4014); + vi.advanceTimersByTime(60_000); + expect(sockets).toHaveLength(1); + expect(gw.state).toBe('error'); + expect(gw.lastError).toMatch(/Message Content Intent/); + expect(gw.lastError).not.toContain(TOKEN); + }); + + it('closes a connection that stopped acknowledging heartbeats', () => { + const { gw, sockets } = setup(); + gw.start(); + sockets[0].recv({ op: 10, d: { heartbeat_interval: 1000 } }); + vi.advanceTimersByTime(1000); // beat 1, no ack + vi.advanceTimersByTime(1000); // beat 2 sees no ack + expect(sockets[0].closed).toContain(4000); + gw.stop(); + }); +}); + +describe('DiscordBridge', () => { + let posts: Array<{ path: string; method: string; body: Record<string, unknown> | undefined }>; + let sendInput: ReturnType<typeof vi.fn<(id: string, text: string) => Promise<boolean>>>; + let t: number; + + function bridge(overrides: Partial<DiscordConfig> = {}, fetchOverride?: DiscordFetch) { + const fetchImpl: DiscordFetch = + fetchOverride ?? + (async (url, init) => { + const path = new URL(String(url)).pathname.replace('/api/v10', ''); + posts.push({ path, method: String(init.method), body: init.body ? JSON.parse(String(init.body)) : undefined }); + if (path.endsWith('/threads')) return jsonRes({ id: THREAD }); + return init.method === 'PUT' ? new Response(null, { status: 204 }) : jsonRes({ id: 'msg' }); + }); + return new DiscordBridge({ + configDir: dir, + fetchImpl, + sendInput, + socketFactory: () => new FakeSocket(), + now: () => t, + sleep: async () => undefined, + }); + } + + beforeEach(async () => { + posts = []; + t = 1_000_000; + sendInput = vi.fn(async () => true); + await writeDiscordConfig(dir, CFG); + }); + + it('creates the thread lazily on the first alert, reuses it, and persists the map', async () => { + const b = bridge(); + await b.notify(MSG); + t += 10_000; + await b.notify({ ...MSG, event: 'hook:idle_prompt', urgency: 'warning' }); + expect(posts.filter((p) => p.path.endsWith('/threads'))).toHaveLength(1); + expect(posts.filter((p) => p.path === `/channels/${THREAD}/messages`)).toHaveLength(2); + expect(await readThreadMap(dir)).toEqual({ s1: THREAD }); + b.stop(); + }); + + it('makes one thread when two alerts race', async () => { + const b = bridge(); + await Promise.all([b.notify(MSG), b.notify({ ...MSG, event: 'hook:elicitation_dialog' })]); + expect(posts.filter((p) => p.path.endsWith('/threads'))).toHaveLength(1); + b.stop(); + }); + + it('dedupes an automatic event within 3 s and says so, which is not the "bridge is off" answer', async () => { + const b = bridge(); + expect((await b.notify(MSG))?.ok).toBe(true); + const second = await b.notify(MSG); + expect(second).toMatchObject({ ok: true, deduped: true }); + expect(posts.filter((p) => p.path.endsWith('/messages'))).toHaveLength(1); + b.stop(); + }); + + it('never dedupes a deliberate post: two different messages from one tab within 3 s both go out', async () => { + const b = bridge(); + const a = await b.notify({ ...MSG, event: 'session:discord-notify', body: 'first' }, { deliberate: true }); + const c = await b.notify({ ...MSG, event: 'session:discord-notify', body: 'second' }, { deliberate: true }); + expect(a?.deduped).toBeUndefined(); + expect(c?.deduped).toBeUndefined(); + expect(posts.filter((p) => p.path.endsWith('/messages'))).toHaveLength(2); + b.stop(); + }); + + it('builds the alert tail only for a post that goes out (not while off, not when deduped)', async () => { + const tail = vi.fn(() => 'screen text'); + const b = bridge(); + await b.notify({ ...MSG, tail }); + expect(tail).toHaveBeenCalledTimes(1); + await b.notify({ ...MSG, tail }); // deduped + expect(tail).toHaveBeenCalledTimes(1); + expect(posts.at(-1)?.body?.content).toContain('screen text'); + await writeDiscordConfig(dir, { ...CFG, enabled: false }); + await b.reload(); + await b.notify({ ...MSG, event: 'hook:idle_prompt', tail }); // off + expect(tail).toHaveBeenCalledTimes(1); + b.stop(); + }); + + it('survives a tail that throws', async () => { + const b = bridge(); + const r = await b.notify({ + ...MSG, + tail: () => { + throw new Error('tmux gone'); + }, + }); + expect(r?.ok).toBe(true); + b.stop(); + }); + + it('writes the thread map one write at a time, so threads made at once all survive a restart', async () => { + let n = 0; + const b = bridge({}, async (url, init) => { + const path = new URL(String(url)).pathname.replace('/api/v10', ''); + if (path.endsWith('/threads')) return jsonRes({ id: String(500000000000000000n + BigInt(++n)) }); + return jsonRes({ id: 'msg' }); + }); + await Promise.all(['a1', 'a2', 'a3', 'a4'].map((id) => b.notify({ ...MSG, sessionId: id, event: `e-${id}` }))); + const map = await readThreadMap(dir); + expect(Object.keys(map).sort()).toEqual(['a1', 'a2', 'a3', 'a4']); + b.stop(); + }); + + it('skips info events under scope attention and everything while disabled', async () => { + const b = bridge(); + expect(await b.notify({ ...MSG, urgency: 'info' })).toBeNull(); + await writeDiscordConfig(dir, { ...CFG, enabled: false }); + await b.reload(); + expect(await b.notify(MSG)).toBeNull(); + expect(posts).toHaveLength(0); + b.stop(); + }); + + it('recreates a thread that was deleted by hand', async () => { + let first = true; + const b = bridge({}, async (url, init) => { + const path = new URL(String(url)).pathname.replace('/api/v10', ''); + posts.push({ path, method: String(init.method), body: undefined }); + if (path.endsWith('/threads')) return jsonRes({ id: THREAD }); + if (first && path.endsWith('/messages')) { + first = false; + return jsonRes({ code: 10003 }, 404); + } + return jsonRes({ id: 'msg' }); + }); + const r = await b.notify(MSG); + expect(r?.ok).toBe(true); + expect(posts.filter((p) => p.path.endsWith('/threads'))).toHaveLength(2); + b.stop(); + }); + + it('reports a failure without the token and never throws', async () => { + const b = bridge({}, async () => { + throw new TypeError('fetch failed'); + }); + const r = await b.notify(MSG); + expect(r).toMatchObject({ ok: false, error: 'Network error' }); + expect(JSON.stringify(r)).not.toContain(TOKEN); + b.stop(); + }); + + describe('replies', () => { + const reply = (over: Record<string, unknown> = {}) => ({ + id: 'm1', + channel_id: THREAD, + guild_id: GUILD, + content: 'press y', + author: { id: ME }, + ...over, + }); + + async function ready() { + const b = bridge(); + await b.notify(MSG); + posts.length = 0; + t += 10_000; + return b; + } + + it('types an allowed reply into the mapped session and ticks it', async () => { + const b = await ready(); + await b.onGatewayMessage(reply()); + expect(sendInput).toHaveBeenCalledWith('s1', 'press y\r'); + expect(posts[0].method).toBe('PUT'); + expect(posts[0].path).toContain('/reactions/'); + b.stop(); + }); + + it.each([ + ['a stranger', { author: { id: '999999999999999999' } }], + ['a bot', { author: { id: ME, bot: true } }], + ['another guild', { guild_id: '555555555555555555' }], + ['an unmapped channel', { channel_id: '666666666666666666' }], + ['an empty message', { content: ' ' }], + ])('drops %s without typing anything', async (_n, over) => { + const b = await ready(); + await b.onGatewayMessage(reply(over)); + expect(sendInput).not.toHaveBeenCalled(); + expect(posts).toHaveLength(0); + b.stop(); + }); + + it('turns a multi-line reply into one line and rate limits a burst', async () => { + const b = await ready(); + await b.onGatewayMessage(reply({ content: 'a\nb' })); + await b.onGatewayMessage(reply({ content: 'again' })); + expect(sendInput).toHaveBeenCalledTimes(1); + expect(sendInput).toHaveBeenCalledWith('s1', 'a b\r'); + // The second reply was not typed, and the person is told so instead of it vanishing. + expect(posts.some((p) => p.method === 'PUT' && p.path.includes(encodeURIComponent('⏳')))).toBe(true); + b.stop(); + }); + + it('says so in the thread when the tab is gone', async () => { + sendInput.mockResolvedValueOnce(false); + const b = await ready(); + await b.onGatewayMessage(reply()); + expect(posts[0].method).toBe('POST'); + expect(String(posts[0].body?.content)).toMatch(/gone/); + b.stop(); + }); + }); + + it('starts afresh when the parent channel changes: tabs get a thread in the new channel', async () => { + const b = bridge(); + await b.notify(MSG); + expect(await readThreadMap(dir)).toEqual({ s1: THREAD }); + posts.length = 0; + await writeDiscordConfig(dir, { ...CFG, parentChannelId: '777777777777777777' }); + await b.reload(); + await b.notify({ ...MSG, event: 'hook:idle_prompt' }); + expect(posts.find((p) => p.path.endsWith('/threads'))?.path).toBe('/channels/777777777777777777/threads'); + b.stop(); + }); + + it('keeps the threads across a restart under the same parent channel', async () => { + const first = bridge(); + await first.notify(MSG); + first.stop(); + posts.length = 0; + const second = bridge(); + await second.reload(); + await second.notify({ ...MSG, event: 'hook:idle_prompt' }); + expect(posts.some((p) => p.path.endsWith('/threads'))).toBe(false); + second.stop(); + }); + + it('a failed thread creation reports its own failure even if another post succeeds meanwhile', async () => { + let n = 0; + const b = bridge({}, async (url) => { + if (String(url).endsWith('/threads')) { + await new Promise((r) => setTimeout(r, 20)); + return new Response('{}', { status: 403 }); + } + n++; + return jsonRes({ id: 'msg' }); + }); + const [failed, ok] = await Promise.all([ + b.notify(MSG), + b.notify({ ...MSG, sessionId: undefined, event: 'session:error' }), + ]); + expect(n).toBeGreaterThan(0); + expect(ok?.ok).toBe(true); + expect(failed).toMatchObject({ ok: false, status: 403 }); + b.stop(); + }); + + it('drops a thread whose creation finished after the parent channel changed', async () => { + let release!: () => void; + const gate = new Promise<void>((r) => (release = r)); + const b = bridge({}, async (url, init) => { + if (String(url).endsWith(`/channels/${PARENT}/threads`)) { + await gate; + return jsonRes({ id: THREAD }); + } + posts.push({ + path: new URL(String(url)).pathname.replace('/api/v10', ''), + method: String(init.method), + body: init.body ? JSON.parse(String(init.body)) : undefined, + }); + return jsonRes({ id: 'msg' }); + }); + const first = b.notify(MSG); + await new Promise((r) => setTimeout(r, 10)); + await writeDiscordConfig(dir, { ...CFG, parentChannelId: '777777777777777777' }); + await b.reload(); + release(); + expect(await first).toMatchObject({ ok: false }); + // The thread made in the old channel is tidied, not left open there. + expect(posts).toContainEqual({ path: `/channels/${THREAD}`, method: 'PATCH', body: { archived: true } }); + expect(await readThreadMap(dir)).toEqual({}); + b.stop(); + }); + + describe('full last message', () => { + const LONG = Array.from({ length: 60 }, (_, i) => `**Point ${i}**: ${'detail '.repeat(10)}\nIs this right?`).join( + '\n\n' + ); + + it('posts the whole message as markdown: head and first part together, the rest without a ping', async () => { + const b = bridge(); + await b.notify({ ...MSG, tail: 'screen chrome', fullText: async () => ({ text: LONG }) }); + const sent = posts.filter((p) => p.path.endsWith('/messages')); + expect(sent.length).toBeGreaterThan(1); + const first = String(sent[0].body?.content); + expect(first).toContain(MSG.title); + expect(first).toContain('**Point 0**'); + expect(first).not.toContain('screen chrome'); + expect(first).not.toContain('```'); // markdown, not a code box + expect(sent[1].body?.allowed_mentions).toEqual({ parse: [], users: [] }); + const all = sent.map((p) => String(p.body?.content)).join('\n'); + expect(all.match(/Is this right\?/g)).toHaveLength(60); + b.stop(); + }); + + it('posts a Markdown table as an aligned code block, since Discord does not render tables', async () => { + const b = bridge(); + const table = [ + '| Variant | Decode (tokens/s) |', + '|---|---:|', + '| kv-f16 | 557.0 |', + '| n-gram mod | 550.2 |', + ].join('\n'); + await b.notify({ ...MSG, fullText: async () => ({ text: `Results:\n\n${table}` }) }); + const posted = posts + .filter((p) => p.path.endsWith('/messages')) + .map((p) => String(p.body?.content)) + .join('\n'); + expect(posted).toContain('```\nVariant Decode (tokens/s)\n'); + expect(posted).not.toContain('|---'); + b.stop(); + }); + + it('does not post the same message twice (idle, then stop), and falls back to the tail without a transcript', async () => { + const b = bridge(); + await b.notify({ ...MSG, fullText: async () => ({ text: 'Short answer.' }) }); + t += 10_000; + posts.length = 0; + await b.notify({ ...MSG, event: 'hook:stop', tail: 'chrome', fullText: async () => ({ text: 'Short answer.' }) }); + const again = posts.filter((p) => p.path.endsWith('/messages')).map((p) => String(p.body?.content)); + expect(again).toHaveLength(1); + expect(again[0]).not.toContain('Short answer.'); + expect(again[0]).not.toContain('chrome'); + t += 10_000; + posts.length = 0; + await b.notify({ ...MSG, event: 'hook:idle_prompt', tail: 'the screen', fullText: async () => undefined }); + expect(String(posts.find((p) => p.path.endsWith('/messages'))?.body?.content)).toContain('the screen'); + b.stop(); + }); + + it('leaves a message to be posted again when its first post failed', async () => { + let fail = true; + const b = bridge({}, async (url, init) => { + const path = new URL(String(url)).pathname.replace('/api/v10', ''); + posts.push({ path, method: String(init.method), body: init.body ? JSON.parse(String(init.body)) : undefined }); + if (path.endsWith('/threads')) return jsonRes({ id: THREAD }); + if (fail && path.endsWith('/messages')) return new Response('{}', { status: 500 }); + return jsonRes({ id: 'msg' }); + }); + const first = await b.notify({ ...MSG, event: 'hook:stop', fullText: async () => ({ text: 'The answer.' }) }); + expect(first).toMatchObject({ ok: false }); + fail = false; + t += 10_000; + posts.length = 0; + await b.notify({ ...MSG, event: 'hook:idle_prompt', fullText: async () => ({ text: 'The answer.' }) }); + expect(posts.map((p) => String(p.body?.content)).join('\n')).toContain('The answer.'); + b.stop(); + }); + + it('converts only what can be posted, so one very wide table cell cannot stall the server', async () => { + const b = bridge(); + const wide = ['| a |', '|---|', `| ${'x'.repeat(1024 * 1024)} |`, ...Array(190).fill('| y |')].join('\n'); + const t0 = Date.now(); + await b.notify({ ...MSG, event: 'hook:idle_prompt', fullText: async () => ({ text: wide, version: 'turn-1' }) }); + expect(Date.now() - t0).toBeLessThan(500); + const said = posts.filter((p) => p.path.endsWith('/messages')).map((p) => String(p.body?.content)); + expect(said.length).toBeLessThanOrEqual(9); // the title post plus at most 8 parts + expect(said.at(-1)).toContain('continues in the tab'); + b.stop(); + }); + + it('does not post the same message twice when two alerts for one tab overlap', async () => { + let release!: () => void; + const gate = new Promise<void>((r) => (release = r)); + const b = bridge({}, async (url, init) => { + const path = new URL(String(url)).pathname.replace('/api/v10', ''); + posts.push({ path, method: String(init.method), body: init.body ? JSON.parse(String(init.body)) : undefined }); + if (path.endsWith('/threads')) return jsonRes({ id: THREAD }); + if (path.endsWith('/messages')) await gate; + return jsonRes({ id: 'msg' }); + }); + const full = async () => ({ text: 'The one answer.', version: 'turn-1' }); + const first = b.notify({ ...MSG, event: 'hook:idle_prompt', fullText: full }); + await vi.waitFor(() => expect(posts.some((p) => p.path.endsWith('/messages'))).toBe(true)); + const second = b.notify({ ...MSG, event: 'hook:stop', fullText: full }); + await new Promise((r) => setTimeout(r, 20)); + release(); + await Promise.all([first, second]); + const said = posts.filter((p) => p.path.endsWith('/messages')).map((p) => String(p.body?.content)); + expect(said.filter((c) => c.includes('The one answer.'))).toHaveLength(1); + b.stop(); + }); + + it('posts a later turn that ends on the same words, but not the same turn twice', async () => { + const b = bridge(); + await b.notify({ ...MSG, fullText: async () => ({ text: 'Done.', version: 'turn-1' }) }); + t += 10_000; + posts.length = 0; + await b.notify({ ...MSG, event: 'hook:stop', fullText: async () => ({ text: 'Done.', version: 'turn-1' }) }); + expect(posts.map((p) => String(p.body?.content)).join('\n')).not.toContain('Done.'); + t += 10_000; + posts.length = 0; + await b.notify({ ...MSG, fullText: async () => ({ text: 'Done.', version: 'turn-2' }) }); + expect(posts.map((p) => String(p.body?.content)).join('\n')).toContain('Done.'); + b.stop(); + }); + + it('falls back to the tail when reading the transcript throws or hangs', async () => { + const b = bridge(); + await b.notify({ + ...MSG, + tail: 'the screen', + fullText: async () => { + throw new Error('no transcript'); + }, + }); + expect(String(posts.find((p) => p.path.endsWith('/messages'))?.body?.content)).toContain('the screen'); + b.stop(); + }); + }); + + describe('sweepStaleThreads', () => { + const ME = '555555555555555555'; + const OTHER = '666666666666666666'; + const OLD = (n: number) => `30000000000000000${n}`; // a snowflake from 2015, long before `now` + let young: string; + let calls: Array<{ method: string; path: string; body?: unknown }>; + + async function swept(staleThreads: 'keep' | 'archive' | 'delete') { + await writeDiscordConfig(dir, { ...CFG, staleThreads }); + t = Date.now(); + young = String((BigInt(t - 60_000 - 1420070400000) << 22n) + 5n); + calls = []; + const th = (id: string, owner = ME, parent = PARENT) => ({ id, owner_id: owner, parent_id: parent }); + const b = bridge({}, async (url, init) => { + const path = new URL(String(url)).pathname.replace('/api/v10', ''); + const method = String(init.method); + calls.push({ method, path, body: init.body ? JSON.parse(String(init.body)) : undefined }); + if (path === '/users/@me') return jsonRes({ id: ME }); + if (path.endsWith('/threads/active')) + return jsonRes({ + threads: [ + th(THREAD), // mapped + th(OLD(1)), // stale + th(OLD(2), OTHER), // someone else's thread + th(OLD(3), ME, '999999999999999999'), // another channel + th(young), // too new to judge + ], + }); + if (path.includes('/threads/archived')) return jsonRes({ threads: [th(OLD(4))] }); + if (path.endsWith('/threads')) return jsonRes({ id: THREAD }); + return method === 'PUT' ? new Response(null, { status: 204 }) : jsonRes({ id: 'msg' }); + }); + return b.notify(MSG).then(() => { + calls.length = 0; + return b; + }); + } + const touched = () => calls.filter((c) => c.method !== 'GET').map((c) => `${c.method} ${c.path.split('/').pop()}`); + + it('does nothing while set to keep', async () => { + const b = await swept('keep'); + expect(await b.sweepStaleThreads()).toBe(0); + expect(calls).toHaveLength(0); + b.stop(); + }); + + it("archives only this bot's old, unmapped threads in the parent channel, and skips archived ones", async () => { + const b = await swept('archive'); + expect(await b.sweepStaleThreads()).toBe(1); + expect(touched()).toEqual([`PATCH ${OLD(1)}`]); + expect(calls.some((c) => c.path.includes('/threads/archived'))).toBe(false); + b.stop(); + }); + + it('deletes stale active and archived threads, and still spares the rest', async () => { + const b = await swept('delete'); + expect(await b.sweepStaleThreads()).toBe(2); + expect(touched()).toEqual([`DELETE ${OLD(1)}`, `DELETE ${OLD(4)}`]); + b.stop(); + }); + + it('does nothing when the map is empty, since a lost map would make every thread look stale', async () => { + const b = await swept('delete'); + await b.sessionDeleted('s1'); // empties the map + calls.length = 0; + expect(await b.sweepStaleThreads()).toBe(0); + expect(calls).toHaveLength(0); + b.stop(); + }); + }); + + it('keeps the gateway off while held (boot) and starts it on release', async () => { + const b = bridge(); + b.hold(); + await b.reload(); + expect(b.gatewayState).toBe('off'); + await b.release(); + expect(b.gatewayState).not.toBe('off'); + b.stop(); + }); + + it('prune drops thread entries for tabs that no longer exist, and keeps the live ones', async () => { + const b = bridge(); + await b.notify(MSG); + await b.notify({ ...MSG, sessionId: 's2', sessionName: 'w2' }); + await b.prune(['s2']); + expect(Object.keys(await readThreadMap(dir))).toEqual(['s2']); + await b.prune([]); // nothing restored: leave the map alone + expect(Object.keys(await readThreadMap(dir))).toEqual(['s2']); + b.stop(); + }); + + describe('sendTest', () => { + it('posts with the config it is given and leaves the live config alone', async () => { + const auth: string[] = []; + const b = bridge({}, async (_url, init) => { + auth.push(String((init.headers as Record<string, string>).Authorization)); + return jsonRes({ id: 'msg' }); + }); + await b.reload(); + expect((await b.sendTest({ ...CFG, token: 'test-token' }))?.ok).toBe(true); + // A save that landed during the test must not be undone: the live token is still the saved one. + await b.notify(MSG); + expect(auth[0]).toBe('Bot test-token'); + expect(auth.slice(1).every((a) => a === `Bot ${CFG.token}`)).toBe(true); + b.stop(); + }); + }); + + it('names a thread that Discord answers without an id, instead of reporting an old result', async () => { + const b = bridge({}, async (url) => (String(url).endsWith('/threads') ? jsonRes({}) : jsonRes({ id: 'msg' }))); + const r = await b.notify(MSG); + expect(r).toMatchObject({ ok: false, error: 'Discord did not return a thread' }); + b.stop(); + }); + + it('replaces a gateway that gave up on a fatal close when the same config is saved again', async () => { + const sockets: FakeSocket[] = []; + const b = new DiscordBridge({ + configDir: dir, + fetchImpl: async () => jsonRes({ id: 'msg' }), + sendInput, + socketFactory: () => { + const s = new FakeSocket(); + sockets.push(s); + return s; + }, + now: () => t, + sleep: async () => undefined, + }); + await b.reload(); + await vi.waitFor(() => expect(sockets).toHaveLength(1)); + sockets[0].drop(4014); // Message Content Intent off: fatal, no reconnect + expect(b.gatewayState).toBe('error'); + await b.reload(); + await vi.waitFor(() => expect(sockets).toHaveLength(2)); + expect(b.gatewayState).not.toBe('error'); + b.stop(); + }); + + it('keeps the gateway session across a save that changes neither the switch nor the token', async () => { + const b = bridge(); + await b.reload(); + const first = (b as unknown as { gateway: unknown }).gateway; + expect(first).not.toBeNull(); + await b.reload(); + expect((b as unknown as { gateway: unknown }).gateway).toBe(first); + await writeDiscordConfig(dir, { ...CFG, token: 'another-token' }); + await b.reload(); + expect((b as unknown as { gateway: unknown }).gateway).not.toBe(first); + b.stop(); + }); + + it('does not rename a thread to the name it already has on first sight', async () => { + const b = bridge(); + await b.notify(MSG); + posts.length = 0; + await b.sessionRenamed('s1', 'w1-app'); + expect(posts).toHaveLength(0); + b.stop(); + }); + + describe('per-tab state', () => { + const internals = (b: DiscordBridge) => + b as unknown as { names: Map<string, string>; lastReply: Map<string, number> }; + + it('a deleted tab that never had a thread still frees its entries', async () => { + const b = bridge(); + await b.reload(); + await b.sessionCreated('nothread', 'w'); + await b.sessionRenamed('nothread', 'w'); + internals(b).lastReply.set('nothread', 1); + await b.sessionDeleted('nothread'); + expect(internals(b).names.has('nothread')).toBe(false); + expect(internals(b).lastReply.has('nothread')).toBe(false); + b.stop(); + }); + + it('stop() clears the maps and the gateway', async () => { + const b = bridge(); + await b.reload(); + await b.sessionCreated('x', 'w'); + await b.sessionRenamed('x', 'w'); + b.stop(); + expect(internals(b).names.size).toBe(0); + expect(b.gatewayState).toBe('off'); + }); + }); + + describe('tab lifecycle', () => { + it('creates the thread at once under onSessionStart, not under lazy', async () => { + const lazy = bridge(); + await lazy.sessionCreated('s1', 'w1-app'); + expect(posts).toHaveLength(0); + lazy.stop(); + await writeDiscordConfig(dir, { ...CFG, threadCreation: 'onSessionStart' }); + const eager = bridge(); + await eager.reload(); + await eager.sessionCreated('s1', 'w1-app'); + expect(posts.filter((p) => p.path.endsWith('/threads'))).toHaveLength(1); + eager.stop(); + }); + + it.each([ + ['archive', 'PATCH', { archived: true }], + ['delete', 'DELETE', undefined], + ] as const)('onSessionDelete %s calls the matching API', async (mode, method, body) => { + await writeDiscordConfig(dir, { ...CFG, onSessionDelete: mode }); + const b = bridge(); + await b.notify(MSG); + posts.length = 0; + await b.sessionDeleted('s1'); + expect(posts).toEqual([{ path: `/channels/${THREAD}`, method, body }]); + expect(await readThreadMap(dir)).toEqual({}); + b.stop(); + }); + + it('keep leaves the thread alone but still forgets the mapping', async () => { + await writeDiscordConfig(dir, { ...CFG, onSessionDelete: 'keep' }); + const b = bridge(); + await b.notify(MSG); + posts.length = 0; + await b.sessionDeleted('s1'); + expect(posts).toHaveLength(0); + expect(await readThreadMap(dir)).toEqual({}); + b.stop(); + }); + + it('forgetTab drops the per-tab state but keeps the thread and its map entry', async () => { + await writeDiscordConfig(dir, { ...CFG, onSessionDelete: 'delete' }); + const b = bridge(); + await b.notify(MSG); + posts.length = 0; + b.forgetTab('s1'); + await b.sessionRenamed('s1', 'other'); // first sight again: recorded, no PATCH + expect(posts).toHaveLength(0); + expect(await readThreadMap(dir)).toEqual({ s1: THREAD }); + b.stop(); + }); + + it('a tab closed while its thread is being created has that thread closed and no entry kept', async () => { + await writeDiscordConfig(dir, { ...CFG, onSessionDelete: 'archive' }); + let release!: () => void; + const gate = new Promise<void>((r) => (release = r)); + const b = bridge({}, async (url, init) => { + const path = new URL(String(url)).pathname.replace('/api/v10', ''); + posts.push({ path, method: String(init.method), body: init.body ? JSON.parse(String(init.body)) : undefined }); + if (path.endsWith('/threads')) { + await gate; + return jsonRes({ id: THREAD }); + } + return jsonRes({ id: 'msg' }); + }); + const first = b.notify(MSG); + await vi.waitFor(() => expect(posts.some((p) => p.path.endsWith('/threads'))).toBe(true)); + await b.sessionDeleted('s1'); + release(); + const out = await first; + expect(posts).toContainEqual({ path: `/channels/${THREAD}`, method: 'PATCH', body: { archived: true } }); + expect(out).toMatchObject({ ok: false, error: 'The tab was closed' }); + expect(await readThreadMap(dir)).toEqual({}); + b.stop(); + }); + + it('renames the thread only when the name really changed', async () => { + const b = bridge(); + await b.notify(MSG); + await b.sessionRenamed('s1', 'w1-app'); // same name notify() used: first sight records it + posts.length = 0; + await b.sessionRenamed('s1', 'w1-app'); + await b.sessionRenamed('s1', 'fix login'); + expect(posts).toEqual([{ path: `/channels/${THREAD}`, method: 'PATCH', body: { name: 'fix login' } }]); + b.stop(); + }); + }); +}); + +describe('files', () => { + it('safeAttachmentName keeps one safe path segment', () => { + expect(safeAttachmentName('report final (1).pdf')).toBe('report_final_1_.pdf'); + expect(safeAttachmentName('../../etc/passwd')).toBe('passwd'); + expect(safeAttachmentName('.env')).toBe('env'); + expect(safeAttachmentName('')).toBe('file'); + expect(safeAttachmentName('x'.repeat(300))).toHaveLength(80); + }); + + it('isDiscordCdnUrl accepts only Discord https hosts', () => { + expect(isDiscordCdnUrl('https://cdn.discordapp.com/attachments/1/2/a.png')).toBe(true); + expect(isDiscordCdnUrl('https://media.discordapp.net/attachments/1/2/a.png')).toBe(true); + expect(isDiscordCdnUrl('http://cdn.discordapp.com/a.png')).toBe(false); + expect(isDiscordCdnUrl('https://evil.example/a.png')).toBe(false); + expect(isDiscordCdnUrl('https://discordapp.com.evil.example/a.png')).toBe(false); + expect(isDiscordCdnUrl('http://169.254.169.254/')).toBe(false); + expect(isDiscordCdnUrl('nonsense')).toBe(false); + }); + + it('sanitiseReply appends attached paths after the cap, and a file alone is enough', () => { + expect(sanitiseReply('see this', ['/w/.codeman-discord/1-a.png'])).toBe( + 'see this [attached: /w/.codeman-discord/1-a.png]\r' + ); + expect(sanitiseReply('', ['/w/a'])).toBe('[attached: /w/a]\r'); + expect(sanitiseReply('x'.repeat(5000), ['/w/a'])!.endsWith('[attached: /w/a]\r')).toBe(true); + }); + + it('postWithFile sends multipart with a safe filename and no JSON content type', async () => { + let seen: { init: RequestInit } | null = null; + const api = new DiscordApi( + () => TOKEN, + async (_u, init) => { + seen = { init }; + return jsonRes({ id: 'm' }); + } + ); + await api.postWithFile( + PARENT, + { content: 'hi', allowed_mentions: { parse: [], users: [] } }, + { name: '../x y.log', data: new Uint8Array([1, 2, 3]) } + ); + const body = seen!.init.body as FormData; + expect(body).toBeInstanceOf(FormData); + expect(JSON.parse(String(body.get('payload_json'))).content).toBe('hi'); + expect((body.get('files[0]') as File).name).toBe('x_y.log'); + expect((seen!.init.headers as Record<string, string>)['Content-Type']).toBeUndefined(); + }); +}); + +describe('DiscordBridge files and startup hint', () => { + const reply = (over: Record<string, unknown> = {}) => ({ + id: '555555555555555555', + channel_id: THREAD, + guild_id: GUILD, + content: '', + author: { id: ME }, + attachments: [ + { id: 'a1', filename: 'shot one.png', size: 3, url: 'https://cdn.discordapp.com/attachments/1/2/shot.png' }, + ], + ...over, + }); + let work: string; + let sent: Array<[string, string]>; + let posted: Array<{ path: string; body?: Record<string, unknown> }>; + let downloads: string[]; + + function make( + cfg: Partial<DiscordConfig> = {}, + dirFor: (id: string) => string | undefined = () => work, + awaitingChoice?: (id: string) => boolean, + agentExited?: (id: string) => boolean + ) { + const b = new DiscordBridge({ + configDir: dir, + fetchImpl: async (url, init) => { + const u = new URL(String(url)); + if (u.hostname === 'cdn.discordapp.com') { + downloads.push(String(url)); + return new Response(new Uint8Array([7, 8, 9])); + } + const path = u.pathname.replace('/api/v10', ''); + posted.push({ path, body: init.body ? JSON.parse(String(init.body)) : undefined }); + return path.endsWith('/threads') ? jsonRes({ id: THREAD }) : jsonRes({ id: 'msg' }); + }, + sendInput: async (id, text) => (sent.push([id, text]), true), + sessionDir: dirFor, + awaitingChoice, + agentExited, + socketFactory: () => new FakeSocket(), + now: () => t, + sleep: async () => undefined, + }); + return writeDiscordConfig(dir, { ...CFG, ...cfg }).then(async () => { + await b.reload(); + await b.notify(MSG); + posted.length = 0; + return b; + }); + } + let t = 5_000_000; + + beforeEach(() => { + work = mkdtempSync(join(tmpdir(), 'discord-work-')); + sent = []; + posted = []; + downloads = []; + }); + afterEach(() => rmSync(work, { recursive: true, force: true })); + + it('saves an attachment under .codeman-discord and types its path, even with no text', async () => { + const b = await make(); + await b.onGatewayMessage(reply()); + const dest = join(work, '.codeman-discord', '555555555555555555-shot_one.png'); + expect(readFileSync(dest)).toEqual(Buffer.from([7, 8, 9])); + expect(statSync(dest).mode & 0o777).toBe(0o600); + expect(sent).toEqual([['s1', `[attached: ${dest}]\r`]]); + b.stop(); + }); + + it('refuses a non-Discord URL, an oversize file and a sixth file, and says so in the thread', async () => { + const b = await make(); + const many = Array.from({ length: 6 }, (_v, i) => ({ + id: String(i), + filename: `f${i}.txt`, + size: 1, + url: 'https://cdn.discordapp.com/a/' + i, + })); + await b.onGatewayMessage( + reply({ + content: 'look', + attachments: [ + { id: 'x', filename: 'evil.png', size: 1, url: 'http://169.254.169.254/latest' }, + { id: 'y', filename: 'huge.bin', size: 9 * 1024 * 1024, url: 'https://cdn.discordapp.com/a/huge' }, + ...many, + ], + }) + ); + expect(downloads.some((u) => u.includes('169.254'))).toBe(false); + expect(downloads.some((u) => u.includes('huge'))).toBe(false); + expect(downloads).toHaveLength(3); // 5 allowed in total, two of which were the refused ones above + expect(posted.map((p) => String(p.body?.content)).find((c) => c.startsWith('Not saved'))).toMatch( + /evil\.png.*huge\.bin/ + ); + b.stop(); + }); + + it('says there is no folder for a remote tab and types nothing for a file-only reply', async () => { + const b = await make({}, () => undefined); + await b.onGatewayMessage(reply()); + expect(sent).toHaveLength(0); + expect(String(posted.at(-1)?.body?.content)).toMatch(/no local folder/); + b.stop(); + }); + + it('says the agent has exited and types nothing when the tab reports it', async () => { + const b = await make({}, undefined, undefined, () => true); + await b.onGatewayMessage(reply({ content: 'hello', attachments: [] })); + expect(sent).toHaveLength(0); + expect(String(posted.at(-1)?.body?.content)).toMatch(/exited/); + expect(posted.some((p) => p.path.includes('reactions'))).toBe(false); + b.stop(); + }); + + it('types a reply that waits on a download before a later text reply', async () => { + const b = await make(); + const slow = b.onGatewayMessage(reply({ content: 'first' })); + await new Promise((r) => setTimeout(r, 0)); // the first reply is under way (gate passed) before the clock moves + t += 5_000; + const fast = b.onGatewayMessage(reply({ id: '555555555555555556', content: 'second', attachments: [] })); + await Promise.all([slow, fast]); + expect(sent.map((s) => s[1].split(' ')[0])).toEqual(['first', 'second\r']); + b.stop(); + }); + + it('types a lone digit without Enter while a dialog is waiting, and with Enter otherwise', async () => { + let waiting = true; + const b = await make({}, undefined, () => waiting); + await b.onGatewayMessage(reply({ content: '2', attachments: [] })); + expect(sent.at(-1)?.[1]).toBe('2'); + t += 5_000; + await b.onGatewayMessage(reply({ content: 'yes', attachments: [] })); + expect(sent.at(-1)?.[1]).toBe('yes\r'); + t += 5_000; + waiting = false; + await b.onGatewayMessage(reply({ content: '2', attachments: [] })); + expect(sent.at(-1)?.[1]).toBe('2\r'); + b.stop(); + }); + + it('does not save a file for a message id that is not a snowflake, and says so', async () => { + const b = await make(); + await b.onGatewayMessage(reply({ id: '../../x' })); + expect(downloads).toHaveLength(0); + expect(sent).toHaveLength(0); + expect(String(posted.at(-1)?.body?.content)).toMatch(/could not be saved/); + b.stop(); + }); +}); + +describe('tablesToCodeBlocks', () => { + it('turns a Markdown table into an aligned code block, dropping bold and code marks', () => { + const md = [ + 'Results:', + '', + '| Variant | Decode (tokens/s) |', + '|---|---:|', + '| `kv-f16` | 557.0 |', + '| **n-gram simple** | 554.5 |', + '| n-gram mod | 550.2 |', + '', + 'Done.', + ].join('\n'); + expect(tablesToCodeBlocks(md)).toBe( + [ + 'Results:', + '', + '```', + 'Variant Decode (tokens/s)', + '------------- -----------------', + 'kv-f16 557.0', + 'n-gram simple 554.5', + 'n-gram mod 550.2', + '```', + '', + 'Done.', + ].join('\n') + ); + }); + + it('leaves a table inside a code fence, prose with pipes and a table-less message alone', () => { + const fenced = ['```', '| a | b |', '|---|---|', '| 1 | 2 |', '```'].join('\n'); + expect(tablesToCodeBlocks(fenced)).toBe(fenced); + const prose = 'Use a | b to pipe.\nThen c | d.'; + expect(tablesToCodeBlocks(prose)).toBe(prose); + }); + + it('converts a hostile separator row of 100,000 spaces in well under a second', () => { + const rows = ['| a | b |', `|---${' '.repeat(100_000)}x`, '| 1 | 2 |'].join('\n'); + const t0 = Date.now(); + expect(tablesToCodeBlocks(rows)).toBe(rows); + expect(Date.now() - t0).toBeLessThan(500); + const real = ['| a | b |', `| --- ${' '.repeat(50_000)}| --- |`, '| 1 | 2 |'].join('\n'); + const t1 = Date.now(); + expect(tablesToCodeBlocks(real)).toContain('```'); + expect(Date.now() - t1).toBeLessThan(500); + }); + + it('leaves a wide or long table as written, quickly: 3000 columns over 3000 one-pipe rows', () => { + const wide = [`|${' a |'.repeat(3000)}`, '|---|', ...Array.from({ length: 3000 }, () => '| x |')].join('\n'); + const t0 = Date.now(); + expect(tablesToCodeBlocks(wide)).toBe(wide); + expect(Date.now() - t0).toBeLessThan(500); + const tall = ['| a | b |', '|---|---|', ...Array.from({ length: 300 }, (_, i) => `| ${i} | y |`)].join('\n'); + expect(tablesToCodeBlocks(tall)).toBe(tall); + const big = ['| a |', '|---|', ...Array.from({ length: 200_000 }, () => '| x |')].join('\n'); + const t1 = Date.now(); + expect(() => tablesToCodeBlocks(big)).not.toThrow(); // no spread of 200,000 rows into Math.max + expect(Date.now() - t1).toBeLessThan(1000); + }); + + it('keeps a sentence with a pipe under a table out of the code block', () => { + const out = tablesToCodeBlocks(['| a | b |', '|---|---|', '| 1 | 2 |', 'Use x | y to pipe.'].join('\n')); + expect(out).toBe(['```', 'a b', '- -', '1 2', '```', 'Use x | y to pipe.'].join('\n')); + }); + + it('pads a short row and keeps an escaped pipe in a cell', () => { + const out = tablesToCodeBlocks(['| a | b |', '|---|---|', '| x \\| y |', '| p | q |'].join('\n')); + expect(out).toContain('x | y'); + expect(out.split('\n').every((l) => !/\s$/.test(l))).toBe(true); + }); +}); + +describe('splitForDiscord', () => { + it('keeps a short message whole', () => { + expect(splitForDiscord('Line one\n\n**Two**\n- a\n- b')).toEqual(['Line one\n\n**Two**\n- a\n- b']); + }); + + it('splits a long message into posts under the limit and loses no line', () => { + const lines = Array.from({ length: 120 }, (_, i) => `- item ${i} ${'x'.repeat(40)}`); + const posts = splitForDiscord(lines.join('\n')); + expect(posts.length).toBeGreaterThan(1); + expect(posts.every((p) => p.length <= 1900)).toBe(true); + expect( + posts + .join('\n') + .split('\n') + .filter((l) => l.startsWith('- item')) + ).toEqual(lines); + }); + + it('closes a code fence the cut lands inside and reopens it in the next post', () => { + const body = ['```', ...Array.from({ length: 100 }, (_, i) => `row ${i} ${'y'.repeat(40)}`), '```'].join('\n'); + const posts = splitForDiscord(body); + expect(posts.length).toBeGreaterThan(1); + for (const p of posts) expect((p.match(/^```/gm) ?? []).length % 2).toBe(0); + }); + + it('cuts one enormous line by length, and caps the number of posts with a note', () => { + const posts = splitForDiscord('z'.repeat(50_000), 1900, 3); + expect(posts).toHaveLength(3); + expect(posts.every((p) => p.length <= 2000)).toBe(true); // Discord's own limit, with the note included + expect(posts[2]).toMatch(/continues in the tab/); + }); +}); + +describe('screenTail', () => { + it("cuts the composer row and the status lines under it, but keeps a dialog's selected option", () => { + const idle = [ + " I'll sync the results and update the docs when the run finishes.", + '✻ Crunched for 7m 55s · done 4:22 PM', + '✔ Update installed · /restart to apply', + '❯', + ' Sonnet 5.5 in:169,629 out:751 ctx:17% cache:until 17:22', + ' ⏵⏵ bypass permissions on (shift+tab to cycle)', + ].join('\n'); + expect(screenTail(idle, 14, 900, '❯')).toBe( + [ + " I'll sync the results and update the docs when the run finishes.", + '✻ Crunched for 7m 55s · done 4:22 PM', + '✔ Update installed · /restart to apply', + ].join('\n') + ); + const dialog = ['Do you want to proceed?', '❯ 1. Yes', ' 2. No', 'Esc to cancel'].join('\n'); + expect(screenTail(dialog, 14, 900, '❯')).toBe(dialog); + expect(screenTail(idle)).toContain('❯'); // no glyph given: nothing is cut + }); + + it('keeps the last lines of a rendered screen and drops rules, blanks and escape codes', () => { + const screen = [ + 'old line', + '', + '\u001b[1mWhich option?\u001b[0m', + '─────────────────────────', + ' 1. Cap GPUs', + ' 2. Cap CPU', + '\u001b(B', + ' 4. Type something.', + '━━━━━━━━━━━━━━━━━━━━━━━━━', + 'Enter to select · Esc to cancel\u001b(B', + ].join('\n'); + expect(screenTail(screen)).toBe( + [ + 'old line', + 'Which option?', + ' 1. Cap GPUs', + ' 2. Cap CPU', + ' 4. Type something.', + 'Enter to select · Esc to cancel', + ].join('\n') + ); + }); + + it('caps lines and characters, cutting on a line', () => { + const many = Array.from({ length: 40 }, (_v, i) => `line ${i}`).join('\n'); + expect(screenTail(many).split('\n')).toHaveLength(14); + expect(screenTail(many).endsWith('line 39')).toBe(true); + const wide = Array.from({ length: 5 }, () => 'x'.repeat(400)).join('\n'); + expect(screenTail(wide).length).toBeLessThanOrEqual(900); + }); + + it('is empty for nothing', () => { + expect(screenTail('')).toBe(''); + expect(screenTail('\n\n ───── \n')).toBe(''); + }); +}); + +describe('ensureReceivedDir', () => { + let work: string; + beforeEach(() => { + work = mkdtempSync(join(tmpdir(), 'discord-recv-')); + }); + afterEach(() => rmSync(work, { recursive: true, force: true })); + + it('makes the folder and ignores it with a .gitignore of *, once', async () => { + const dir = await ensureReceivedDir(work); + expect(dir).toBe(join(work, '.codeman-discord')); + expect(readFileSync(join(dir!, '.gitignore'), 'utf8')).toBe('*\n'); + }); + + it('leaves a .gitignore that is already there alone', async () => { + mkdirSync(join(work, '.codeman-discord')); + writeFileSync(join(work, '.codeman-discord', '.gitignore'), 'mine\n'); + expect(await ensureReceivedDir(work)).toBe(join(work, '.codeman-discord')); + expect(readFileSync(join(work, '.codeman-discord', '.gitignore'), 'utf8')).toBe('mine\n'); + }); + + it('refuses a symlink or a plain file at the folder, and a workspace that is not a directory', async () => { + const elsewhere = mkdtempSync(join(tmpdir(), 'discord-elsewhere-')); + symlinkSync(elsewhere, join(work, '.codeman-discord')); + expect(await ensureReceivedDir(work)).toBeNull(); + expect(existsSync(join(elsewhere, '.gitignore'))).toBe(false); + rmSync(elsewhere, { recursive: true, force: true }); + rmSync(join(work, '.codeman-discord')); + writeFileSync(join(work, '.codeman-discord'), 'x'); + expect(await ensureReceivedDir(work)).toBeNull(); + expect(await ensureReceivedDir(join(work, 'missing'))).toBeNull(); + }); +}); diff --git a/test/discord-push-glue.test.ts b/test/discord-push-glue.test.ts new file mode 100644 index 00000000..01210b75 --- /dev/null +++ b/test/discord-push-glue.test.ts @@ -0,0 +1,208 @@ +// @vitest-environment node +/** + * @fileoverview The glue between WebServer and the Discord bridge: alerts reach it even with no + * Web Push subscription (with the rendered screen as the tail), and tab events reach it routed by + * kind. The bridge and routes are tested on their own; this pins the lines that make the feature + * exist. A real WebServer, never started, with the bridge replaced by a recorder. + * + * Port: N/A (no server start). + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { sendNotification } = vi.hoisted(() => ({ sendNotification: vi.fn(async () => undefined) })); +vi.mock('web-push', () => ({ + default: { sendNotification, setVapidDetails: vi.fn(), generateVAPIDKeys: vi.fn() }, +})); + +import { WebServer } from '../src/web/server.js'; +import { SseEvent } from '../src/web/sse-events.js'; + +type Internals = { + pushStore: { getAll: () => never[]; dispose: () => void }; + discordBridge: Record<string, ReturnType<typeof vi.fn>>; + sessions: Map<string, unknown>; + mux: { capturePaneText?: (name: string) => string | null }; + lastResponseFor?: (session: unknown, context?: string) => Promise<{ text: string }>; + sendPushNotifications: (event: string, data: Record<string, unknown>) => Promise<void>; + broadcast: (event: string, data: unknown) => void; + cleanupSession: (id: string, killMux?: boolean, reason?: string) => Promise<void>; + discardPartiallyBuiltSession: (id: string) => Promise<void>; +}; + +function server(): Internals { + const s = new WebServer(0, false, true, '127.0.0.1', 'box') as unknown as Internals; + s.pushStore = { getAll: () => [], dispose: () => undefined }; + s.discordBridge = { + notify: vi.fn(async () => null), + sessionCreated: vi.fn(async () => undefined), + sessionRenamed: vi.fn(async () => undefined), + sessionDeleted: vi.fn(async () => undefined), + forgetTab: vi.fn(), + stop: vi.fn(), + }; + return s; +} + +beforeEach(() => sendNotification.mockClear()); + +describe('bridge deps', () => { + it('agentExited is true only for a session whose pane exit was observed', () => { + const s = new WebServer(0, false, true, '127.0.0.1', 'box') as unknown as Internals & { + discordBridge: { deps: { agentExited: (id: string) => boolean } }; + }; + s.sessions.set('dead', { paneExit: { observedAt: 1 } }); + s.sessions.set('alive', {}); + const { agentExited } = s.discordBridge.deps; + expect(agentExited('dead')).toBe(true); + expect(agentExited('alive')).toBe(false); + expect(agentExited('missing')).toBe(false); + }); + + it("types a reply as the person's own input, so it can name the tab", async () => { + const s = new WebServer(0, false, true, '127.0.0.1', 'box') as unknown as Internals & { + discordBridge: { deps: { sendInput: (id: string, text: string) => Promise<boolean> } }; + }; + const writeViaMux = vi.fn(async () => true); + s.sessions.set('s1', { writeViaMux }); + expect(await s.discordBridge.deps.sendInput('s1', 'yes\r')).toBe(true); + expect(writeViaMux).toHaveBeenCalledWith('yes\r', { fromUser: true }); + expect(await s.discordBridge.deps.sendInput('missing', 'yes\r')).toBe(false); + }); +}); + +describe('sendPushNotifications -> Discord', () => { + it('posts a permission prompt with zero push subscriptions, with the rendered screen as the tail', async () => { + const s = server(); + s.sessions.set('s1', { muxName: 'codeman-s1', textOutput: '', mode: 'claude' }); + const capture = vi.fn(() => 'Which option?\n──────────\n 1. Cap GPUs\n\n 2. Cap CPU\n'); + s.mux = { capturePaneText: capture }; + await s.sendPushNotifications('hook:permission_prompt', { + sessionId: 's1', + sessionName: 'w1-app', + tool_name: 'Bash', + }); + expect(s.discordBridge.notify).toHaveBeenCalledTimes(1); + const msg = s.discordBridge.notify.mock.calls[0][0]; + expect(msg).toMatchObject({ + event: 'hook:permission_prompt', + title: 'Permission Required', + body: '[w1-app] Tool: Bash', + urgency: 'critical', + sessionId: 's1', + sessionName: 'w1-app', + }); + // A function, so the screen is read (a synchronous tmux exec) only once the bridge will post. + expect(typeof msg.tail).toBe('function'); + expect(capture).not.toHaveBeenCalled(); + expect(msg.tail()).toBe('Which option?\n 1. Cap GPUs\n 2. Cap CPU'); + expect(capture).toHaveBeenCalledTimes(1); + expect(sendNotification).not.toHaveBeenCalled(); + }); + + it('reads the whole last message from the transcript on a finished turn, and keeps the screen for a dialog', async () => { + const s = server(); + s.sessions.set('s5', { muxName: 'codeman-s5', textOutput: '', mode: 'claude' }); + const reader = vi.fn(async () => ({ text: 'The full answer.\n\n- one\n- two', timestamp: '2026-10-30T00:00:00Z' })); + s.lastResponseFor = reader; + s.mux = { capturePaneText: () => 'screen\n❯\n status' }; + await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's5', sessionName: 'w5' }); + const idle = s.discordBridge.notify.mock.calls[0][0]; + expect(await idle.fullText()).toEqual({ + text: 'The full answer.\n\n- one\n- two', + version: '2026-10-30T00:00:00Z', + }); + expect(idle.tail()).toBe('screen'); // the composer row and the status under it are cut + await s.sendPushNotifications('hook:permission_prompt', { sessionId: 's5', sessionName: 'w5' }); + const dialog = s.discordBridge.notify.mock.calls[1][0]; + expect(dialog.fullText).toBeUndefined(); + expect(dialog.tail()).toBe('screen\n❯\n status'); + }); + + it.each(['claude', 'codex', 'opencode', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp', 'copilot'])( + 'a finished turn in a %s tab posts the whole last message', + async (mode) => { + const s = server(); + s.sessions.set('s6', { muxName: 'codeman-s6', textOutput: '', mode }); + s.lastResponseFor = vi.fn(async () => ({ text: '1. First?\n2. Second?\n3. Third?' })); + s.mux = { capturePaneText: () => 'screen' }; + await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's6', sessionName: 'w6' }); + const msg = s.discordBridge.notify.mock.calls[0][0]; + expect((await msg.fullText()).text).toBe('1. First?\n2. Second?\n3. Third?'); + } + ); + + it('a shell tab has no turns, so it keeps the screen tail', async () => { + const s = server(); + s.sessions.set('s7', { muxName: 'codeman-s7', textOutput: '', mode: 'shell' }); + s.lastResponseFor = vi.fn(async () => ({ text: 'x' })); + s.mux = { capturePaneText: () => 'prompt$' }; + await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's7', sessionName: 'w7' }); + const msg = s.discordBridge.notify.mock.calls[0][0]; + expect(await msg.fullText()).toBeUndefined(); + expect(msg.tail()).toBe('prompt$'); + }); + + it('falls back to plain text output when there is no tmux pane, and to no tail for an unknown session', async () => { + const s = server(); + s.sessions.set('s2', { muxName: null, textOutput: 'plain answer\n', mode: 'claude' }); + await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's2', sessionName: 'w2' }); + expect(s.discordBridge.notify.mock.calls[0][0].tail()).toBe('plain answer'); + await s.sendPushNotifications('hook:idle_prompt', { sessionId: 'gone', sessionName: 'w3' }); + expect(s.discordBridge.notify.mock.calls[1][0].tail).toBeUndefined(); + }); + + it('sends nothing for an event Web Push does not carry', async () => { + const s = server(); + await s.sendPushNotifications('session:created', { sessionId: 's1', sessionName: 'w1' }); + expect(s.discordBridge.notify).not.toHaveBeenCalled(); + }); +}); + +describe('broadcast -> Discord tab lifecycle', () => { + it('routes created, updated and deleted by kind, reading both payload shapes', () => { + const s = server(); + s.broadcast(SseEvent.SessionCreated, { session: { id: 's1', name: 'w1', createdAt: 123 } }); + expect(s.discordBridge.sessionCreated).toHaveBeenCalledWith('s1', 'w1'); + s.broadcast(SseEvent.SessionUpdated, { id: 's1', name: 'renamed' }); + expect(s.discordBridge.sessionRenamed).toHaveBeenCalledWith('s1', 'renamed'); + }); + + it('does not touch the thread on session:deleted, which a detach also fires', () => { + const s = server(); + s.broadcast(SseEvent.SessionDeleted, { id: 's1' }); + expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled(); + }); + + it('tells the bridge about a real delete (killMux true) but not a detach (killMux false)', async () => { + const s = server(); + await s.cleanupSession('gone', false); + expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled(); + await s.cleanupSession('gone', true); + expect(s.discordBridge.sessionDeleted).toHaveBeenCalledWith('gone'); + }); + + it('a discarded half-built session clears the per-tab state but keeps its thread', async () => { + const s = server(); + s.sessions.set('rebuild', { + owner: 'u', + ralphTracker: { stopWatchingFixPlan: () => undefined, fullReset: () => undefined }, + removeAllListeners: () => undefined, + stop: async () => undefined, + }); + await s.discardPartiallyBuiltSession('rebuild'); + expect(s.discordBridge.forgetTab).toHaveBeenCalledWith('rebuild'); + expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled(); + }); + + it('ignores an event with no session id', () => { + const s = server(); + s.broadcast(SseEvent.SessionUpdated, {}); + expect(s.discordBridge.sessionRenamed).not.toHaveBeenCalled(); + }); + + it('closes the bridge when the server stops', async () => { + const s = server(); + await (s as unknown as { stop: () => Promise<void> }).stop(); + expect(s.discordBridge.stop).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/discord-startup-hint.test.ts b/test/discord-startup-hint.test.ts new file mode 100644 index 00000000..661d183b --- /dev/null +++ b/test/discord-startup-hint.test.ts @@ -0,0 +1,82 @@ +// @vitest-environment node +/** + * @fileoverview The Discord startup hint reaches Claude as `--append-system-prompt`, only while the + * bridge and its `startupHint` setting are on, and never changes a launch that does not use it. + */ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getCli } from '../src/config/cli-registry/registry.js'; +import { buildSpawnCommandFromRegistry } from '../src/session-cli-registry-bridge.js'; +import { + DiscordBridge, + DISCORD_STARTUP_HINT, + getDiscordStartupHint, + writeDiscordConfig, + DEFAULT_DISCORD_CONFIG, +} from '../src/discord-bridge.js'; + +const SID = '0f9c2b14-1111-2222-3333-444455556666'; +const BASE = 'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666"'; + +function launch(mode: 'claude' | 'codex'): string | undefined { + return buildSpawnCommandFromRegistry(getCli(mode)!, { mode, sessionId: SID, claudeCliVersion: null }); +} + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'discord-hint-')); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +function bridge() { + return new DiscordBridge({ + configDir: dir, + fetchImpl: async () => new Response('{}'), + sendInput: async () => true, + socketFactory: () => ({ send() {}, close() {}, on() {} }), + }); +} + +describe('startup hint on the Claude launch', () => { + it('is absent by default, so a launch is byte-identical to before', () => { + expect(getDiscordStartupHint()).toBeUndefined(); + expect(launch('claude')).toBe(BASE); + }); + + it('is appended, single-quoted, while the bridge is on', async () => { + await writeDiscordConfig(dir, { + ...DEFAULT_DISCORD_CONFIG, + enabled: true, + token: 'tok', + guildId: '111111111111111111', + parentChannelId: '222222222222222222', + allowedUserIds: ['333333333333333333'], + }); + const b = bridge(); + await b.reload(); + expect(getDiscordStartupHint()).toBe(DISCORD_STARTUP_HINT); + expect(launch('claude')).toBe(`${BASE} --append-system-prompt '${DISCORD_STARTUP_HINT}'`); + expect(DISCORD_STARTUP_HINT).not.toContain("'"); + expect(launch('codex')).not.toContain('append-system-prompt'); + b.stop(); + expect(launch('claude')).toBe(BASE); + }); + + it('is absent when startupHint is switched off', async () => { + await writeDiscordConfig(dir, { + ...DEFAULT_DISCORD_CONFIG, + enabled: true, + token: 'tok', + guildId: '111111111111111111', + parentChannelId: '222222222222222222', + allowedUserIds: ['333333333333333333'], + startupHint: false, + }); + const b = bridge(); + await b.reload(); + expect(launch('claude')).toBe(BASE); + b.stop(); + }); +}); diff --git a/test/docker-entrypoint.test.ts b/test/docker-entrypoint.test.ts index 417b67fe..b9143658 100644 --- a/test/docker-entrypoint.test.ts +++ b/test/docker-entrypoint.test.ts @@ -26,7 +26,7 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { readFileSync, mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; +import { readFileSync, mkdtempSync, rmSync, writeFileSync, mkdirSync, chownSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -309,6 +309,21 @@ describe('Update-Codeman.sh (the scripted major-update path — docker/README.md const casesPath = join(dir, 'cases'); const socketPath = join(dir, 'docker.sock'); // deliberately NOT a real socket — see above mkdirSync(appdataPath); + // Both scripts derive PUID:PGID from the appdata owner and REFUSE a + // root-owned one (correctly: the container must never run as root). + // mkdirSync gives it the runner's uid, so under a root runner (a CI or + // agent container) the update path stopped at that guard before its + // build. Give it the non-root owner docker/README.md's own + // `chown -R 99:100` example sets up, as a real deployment would have. + if (process.getuid?.() === 0) { + try { + chownSync(appdataPath, 99, 100); + } catch { + // No CAP_CHOWN (root with --cap-drop ALL, as Codeman's own Docker + // case containers run) or a single-uid user namespace: leave it + // root-owned and let the scripts' own guard refuse, as before. + } + } mkdirSync(casesPath); writeFileSync(socketPath, ''); diff --git a/test/docker-hosts.test.ts b/test/docker-hosts.test.ts index 123c73ec..8913b64a 100644 --- a/test/docker-hosts.test.ts +++ b/test/docker-hosts.test.ts @@ -28,6 +28,7 @@ import { persistDockerCaseClaudeSessionId, probeDockerCliVersion, readDockerCases, + readDockerCasesOrThrow, readDockerHosts, resolveClaudeJsonSeedMount, resolveDockerClaudeArtifacts, @@ -70,6 +71,28 @@ describe('docker-hosts storage', () => { expect(await readDockerCases(dir)).toEqual([]); }); + /** + * The git fetch paths read the list through the strict reader: the writer truncates and rewrites the file in + * place, and a list read as `[]` there would let a fetch run git inside a Docker workspace. + */ + it('has a strict reader that rejects a list it cannot parse, where the plain reader returns []', async () => { + expect(await readDockerCasesOrThrow(dir)).toEqual([]); // missing: no cases + await writeDockerCases(dir, [CASE]); + expect(await readDockerCasesOrThrow(dir)).toEqual([CASE]); + const path = join(dir, 'docker-cases.json'); + for (const text of ['', '[{"name":"myproj","hostWorkspacePath":"/home/arkon/cas', '{"name":"myproj"}', 'null']) { + writeFileSync(path, text); + expect(await readDockerCases(dir)).toEqual([]); + const err = await readDockerCasesOrThrow(dir).then( + () => null, + (e: Error) => e + ); + expect(err?.message).toMatch(/not a valid JSON array/); + // The parser's message quotes the file; the strict reader's never does. + expect(err?.message).not.toContain('/home/arkon/cas'); + } + }); + it('persists the last Claude conversation id keyed by container name', async () => { await writeDockerCases(dir, [CASE, { ...CASE, name: 'other', container: 'custom-name' }]); await persistDockerCaseClaudeSessionId(dir, dockerContainerName(CASE.name), 'conv-1'); diff --git a/test/edit-history-local-source.test.ts b/test/edit-history-local-source.test.ts new file mode 100644 index 00000000..fdeb2d5e --- /dev/null +++ b/test/edit-history-local-source.test.ts @@ -0,0 +1,165 @@ +/** + * Local edit-source reader against a temp projects dir: the transcript lookup, byte-offset + * reads of the main transcript and its subagents, and the image probe. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + findClaudeTranscript, + probeLocalPaths, + readClaudeEditSources, + resolveClaudeTranscript, +} from '../src/edit-history-local-source.js'; + +let root: string; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'edit-src-')); +}); +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +function put(rel: string, content: string | Buffer, mtimeSec?: number): string { + const path = join(root, rel); + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, content); + if (mtimeSec) utimesSync(path, mtimeSec, mtimeSec); + return path; +} + +describe('findClaudeTranscript', () => { + it('returns null when no transcript exists under any id', async () => { + put('p/other.jsonl', 'x\n'); + expect(await findClaudeTranscript(['sid'], root)).toBeNull(); + expect(await findClaudeTranscript(['sid'], join(root, 'missing'))).toBeNull(); + }); + + it('takes the first id that has a transcript, the newest one across project dirs', async () => { + put('a/sid.jsonl', 'old\n', 1000); + const newest = put('b/sid.jsonl', 'new\n', 2000); + put('c/second.jsonl', 'x\n'); + expect(await findClaudeTranscript(['bad id!', 'sid', 'second'], root)).toBe(newest); + }); +}); + +describe('resolveClaudeTranscript', () => { + it('takes the watcher path (what the hooks reported) as is', async () => { + expect(await resolveClaudeTranscript('/hooked/conv.jsonl', ['sid'], join(root, 'cfg'))).toBe('/hooked/conv.jsonl'); + }); + + it("without one, looks under the session's own claude config dir", async () => { + const path = put('cfg/projects/p/sid.jsonl', 'x\n'); + expect(await resolveClaudeTranscript(null, ['sid'], join(root, 'cfg'))).toBe(path); + expect(await resolveClaudeTranscript(undefined, ['other'], join(root, 'cfg'))).toBeNull(); + }); +}); + +describe('readClaudeEditSources', () => { + it('returns null for a missing file or a path that is not an absolute <id>.jsonl', async () => { + const path = put('p/sid.jsonl', 'x\n'); + expect(await readClaudeEditSources(join(root, 'p/gone.jsonl'), [], 100)).toBeNull(); + expect(await readClaudeEditSources('p/sid.jsonl', [], 100)).toBeNull(); + expect(await readClaudeEditSources(path.replace('.jsonl', '.txt'), [], 100)).toBeNull(); + expect(await readClaudeEditSources(put('p/bad id.jsonl', 'x\n'), [], 100)).toBeNull(); + }); + + it('names the conversation after the file', async () => { + const res = await readClaudeEditSources(put('p/conv-1.jsonl', 'x\n'), [], 100); + expect(res?.transcriptId).toBe('conv-1'); + }); + + it('reads a new file from its end within the budget, a requested one from its offset', async () => { + const path = put('p/sid.jsonl', '0123456789'); + const fresh = await readClaudeEditSources(path, [], 4); + expect(fresh?.files).toEqual([{ relPath: 'main', size: 10, startOffset: 6, chunk: Buffer.from('6789') }]); + const cont = await readClaudeEditSources(path, [{ relPath: 'main', offset: 3, len: 2 }], 100); + expect(cont?.files[0]).toMatchObject({ startOffset: 3, chunk: Buffer.from('34') }); + }); + + it('clamps an offset past the end to the size (a replaced, shorter file)', async () => { + const path = put('p/sid.jsonl', 'abc'); + const res = await readClaudeEditSources(path, [{ relPath: 'main', offset: 50 }], 100); + expect(res?.files[0]).toMatchObject({ size: 3, startOffset: 3, chunk: Buffer.alloc(0) }); + }); + + it('lists subagents with their meta for new ones only, and shares the budget main first', async () => { + const path = put('p/sid.jsonl', 'MAIN'); + put('p/sid/subagents/agent-a1.jsonl', 'AAAA'); + put('p/sid/subagents/agent-a1.meta.json', '{"description":"Fix it"}'); + put('p/sid/subagents/agent-b2.jsonl', 'BBBB'); + put('p/sid/subagents/agent-b2.meta.json', '{"description":"Other"}'); + put('p/sid/subagents/notes.txt', 'ignored'); + const res = await readClaudeEditSources(path, [{ relPath: 'subagents/agent-b2.jsonl', offset: 0 }], 6); + expect(res?.files.map((f) => [f.relPath, f.startOffset, f.chunk.toString()])).toEqual([ + ['main', 0, 'MAIN'], + ['subagents/agent-a1.jsonl', 2, 'AA'], + ['subagents/agent-b2.jsonl', 0, ''], + ]); + expect(res?.metas).toEqual({ a1: '{"description":"Fix it"}' }); + }); + + it('ignores invalid requests', async () => { + const path = put('p/sid.jsonl', 'abcdef'); + const res = await readClaudeEditSources( + path, + [ + { relPath: '../etc/passwd', offset: 0 }, + { relPath: 'main', offset: -1 }, + ], + 3 + ); + // The invalid main request is dropped, so main is read like a new file. + expect(res?.files[0]).toMatchObject({ startOffset: 3, chunk: Buffer.from('def') }); + }); + + it('keeps multibyte bytes intact (byte offsets, not string lengths)', async () => { + const path = put('p/sid.jsonl', Buffer.from('äöü\n', 'utf8')); + const res = await readClaudeEditSources(path, [{ relPath: 'main', offset: 2 }], 100); + expect(res?.files[0].chunk).toEqual(Buffer.from('äöü\n', 'utf8').subarray(2)); + }); + + it('with onlyRequested, reads the requested files and nothing else', async () => { + const path = put('p/sid.jsonl', 'MAIN'); + put('p/sid/subagents/agent-a1.jsonl', 'AAAA'); + put('p/sid/subagents/agent-b2.jsonl', 'BBBB'); + const res = await readClaudeEditSources(path, [{ relPath: 'subagents/agent-b2.jsonl', offset: 1, len: 2 }], 100, { + onlyRequested: true, + }); + expect(res?.files.map((f) => [f.relPath, f.startOffset, f.chunk.toString()])).toEqual([ + ['subagents/agent-b2.jsonl', 1, 'BB'], + ]); + }); + + it('reads the end of a block that does not fit the budget', async () => { + const path = put('p/sid.jsonl', '0123456789'); + const res = await readClaudeEditSources(path, [{ relPath: 'main', offset: 2, len: 6 }], 3); + expect(res?.files[0]).toMatchObject({ startOffset: 5, chunk: Buffer.from('567') }); + }); +}); + +describe('probeLocalPaths', () => { + it('reports size and mtime of files, expands ~/ against home, null for missing and directories', async () => { + put('home/img.png', 'PNG!', 1_700_000_000); + mkdirSync(join(root, 'dir')); + const res = await probeLocalPaths(['~/img.png', join(root, 'dir'), join(root, 'nope.png')], join(root, 'home')); + expect(res[0]).toEqual({ realPath: join(root, 'home/img.png'), kind: 'file', size: 4, mtimeMs: 1_700_000_000_000 }); + expect(res[1]).toBeNull(); + expect(res[2]).toBeNull(); + }); + + it('touches a path only after the bounded probe saw a file there', async () => { + put('a.png', 'PNG!', 1_700_000_000); + put('b.png', 'PNG!', 1_700_000_000); + const asked: string[] = []; + const res = await probeLocalPaths([join(root, 'a.png'), join(root, 'b.png'), 'rel.png'], root, async (p) => { + asked.push(p); + return p.endsWith('a.png') ? 'unknown' : 'file'; + }); + // a.png exists, but its filesystem did not answer the bounded probe: no unbounded stat. + expect(res.map((r) => r?.realPath ?? null)).toEqual([null, join(root, 'b.png'), null]); + expect(asked).toEqual([join(root, 'a.png'), join(root, 'b.png')]); // relative: never probed + }); +}); diff --git a/test/edit-history-parse.test.ts b/test/edit-history-parse.test.ts new file mode 100644 index 00000000..7e9577c3 --- /dev/null +++ b/test/edit-history-parse.test.ts @@ -0,0 +1,420 @@ +/** + * Edit history parser: claude transcript lines → edit records and Bash image candidates. + * Fixture lines mirror real transcript records (Claude Code 2.1.300), trimmed to the + * fields the parser reads plus the bulky ones it must ignore (`originalFile`, `content`). + */ +import { describe, expect, it } from 'vitest'; +import { + classifyEditPath, + extractImagePathCandidates, + MAX_HUNK_BYTES, + MAX_IMAGE_CANDIDATES, + MAX_PENDING_BASH, + MAX_PENDING_COMMAND_CHARS, + MAX_PENDING_FIELD_CHARS, + parseClaudeEditLines, +} from '../src/edit-history-parse.js'; + +const CWD = '/work/repo'; + +function toolResultLine(id: string, ts: string, toolUseResult: unknown, extra: Record<string, unknown> = {}): string { + return JSON.stringify({ + type: 'user', + isSidechain: false, + timestamp: ts, + cwd: CWD, + message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] }, + toolUseResult, + ...extra, + }); +} + +function bashUseLine(id: string, ts: string, command: string, extra: Record<string, unknown> = {}): string { + return JSON.stringify({ + type: 'assistant', + isSidechain: false, + timestamp: ts, + cwd: CWD, + message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] }, + ...extra, + }); +} + +const hunk = (lines: string[]) => ({ oldStart: 3, oldLines: 2, newStart: 3, newLines: 3, lines }); + +const editLine = toolResultLine('toolu_edit', '2026-10-15T19:01:28.880Z', { + filePath: '/work/repo/src/app.ts', + oldString: 'a', + newString: 'b', + originalFile: 'x'.repeat(5000), + structuredPatch: [hunk([' keep', '-old', '+new', '+added'])], + userModified: false, + replaceAll: false, +}); + +describe('parseClaudeEditLines: edits', () => { + it('turns an Edit result into a record without the whole-file fields', () => { + const { records } = parseClaudeEditLines(editLine + '\n'); + expect(records).toEqual([ + { + id: 'toolu_edit', + ts: Date.parse('2026-10-15T19:01:28.880Z'), + path: '/work/repo/src/app.ts', + kind: 'code', + tool: 'Edit', + created: false, + added: 2, + removed: 1, + hunks: [hunk([' keep', '-old', '+new', '+added'])], + truncated: false, + }, + ]); + expect(JSON.stringify(records)).not.toContain('xxxxx'); + }); + + it('reads replaceAll edits like any other Edit', () => { + const line = toolResultLine('toolu_all', '2026-10-15T19:02:00.000Z', { + filePath: '/work/repo/a.ts', + oldString: 'x', + newString: 'y', + originalFile: '', + structuredPatch: [hunk(['-x', '+y']), { ...hunk(['-x', '+y']), oldStart: 9, newStart: 9 }], + userModified: false, + replaceAll: true, + }); + const [rec] = parseClaudeEditLines(line).records; + expect(rec).toMatchObject({ tool: 'Edit', added: 2, removed: 2 }); + expect(rec.hunks).toHaveLength(2); + }); + + it('marks a Write that created the file, and one that replaced it', () => { + const create = toolResultLine('toolu_c', '2026-10-15T19:03:00.000Z', { + type: 'create', + filePath: '/work/repo/new.md', + content: 'hello\nworld', + structuredPatch: [], + originalFile: null, + }); + const update = toolResultLine('toolu_u', '2026-10-15T19:04:00.000Z', { + type: 'update', + filePath: '/work/repo/new.md', + content: 'hi', + structuredPatch: [hunk(['-hello', '-world', '+hi'])], + originalFile: 'hello\nworld', + }); + const { records } = parseClaudeEditLines([create, update].join('\n')); + expect(records.map((r) => [r.tool, r.created, r.added, r.removed])).toEqual([ + ['Write', true, 2, 0], + ['Write', false, 1, 2], + ]); + expect(records[0].hunks).toEqual([ + { oldStart: 0, oldLines: 0, newStart: 1, newLines: 2, lines: ['+hello', '+world'] }, + ]); + }); + + it('a created file’s diff drops the trailing newline and is capped like any hunk', () => { + const big = 'x'.repeat(1000) + '\n'; + const line = toolResultLine('toolu_big', '2026-10-15T19:03:30.000Z', { + type: 'create', + filePath: '/work/repo/big.txt', + content: big.repeat(100), + structuredPatch: [], + originalFile: null, + }); + const [rec] = parseClaudeEditLines(line).records; + expect(rec.added).toBe(100); + expect(rec.truncated).toBe(true); + expect(rec.hunks[0].lines.length).toBeLessThan(100); + expect(rec.hunks[0].lines.every((l) => l === '+' + 'x'.repeat(1000))).toBe(true); + }); + + it('classifies an image written by Write as an image', () => { + const line = toolResultLine('toolu_svg', '2026-10-15T19:05:00.000Z', { + type: 'create', + filePath: '/work/repo/icon.SVG', + content: '<svg/>', + structuredPatch: [], + originalFile: null, + }); + expect(parseClaudeEditLines(line).records[0].kind).toBe('image'); + }); + + it('keeps a result of an unknown edit tool that has a path and a patch', () => { + const line = toolResultLine('toolu_nb', '2026-10-15T19:06:00.000Z', { + filePath: '/work/repo/n.ipynb', + structuredPatch: [hunk(['+cell'])], + }); + expect(parseClaudeEditLines(line).records[0]).toMatchObject({ tool: 'edit', added: 1 }); + }); + + it('skips failed tool results (a string), unrelated results and lines that do not parse', () => { + const failed = toolResultLine('toolu_err', '2026-10-15T19:07:00.000Z', 'Error: String to replace not found'); + const read = toolResultLine('toolu_read', '2026-10-15T19:07:01.000Z', { type: 'text', file: { filePath: '/x' } }); + const text = [failed, read, '{"type":"user",broken', '', 'not json'].join('\n'); + expect(parseClaudeEditLines(text).records).toEqual([]); + }); + + it('skips a first line cut mid-record (a byte range starts inside it)', () => { + const cut = editLine.slice(40); + expect(parseClaudeEditLines(cut + '\n' + editLine).records).toHaveLength(1); + }); + + it('carries the subagent id of a sidechain line', () => { + const line = toolResultLine('toolu_sub', '2026-10-15T19:08:00.000Z', JSON.parse(editLine).toolUseResult, { + isSidechain: true, + agentId: 'a0038561ea4d57a42', + }); + expect(parseClaudeEditLines(line).records[0].agentId).toBe('a0038561ea4d57a42'); + }); + + it('caps an oversized patch and says so, counting lines from the whole patch', () => { + const big = Array.from({ length: 4000 }, (_, i) => `+line ${i} ${'y'.repeat(40)}`); + const line = toolResultLine('toolu_big', '2026-10-15T19:09:00.000Z', { + type: 'create', + filePath: '/work/repo/big.ts', + content: '', + structuredPatch: [hunk(big)], + originalFile: null, + }); + const [rec] = parseClaudeEditLines(line).records; + expect(rec.truncated).toBe(true); + expect(rec.added).toBe(4000); + expect(JSON.stringify(rec.hunks).length).toBeLessThanOrEqual(MAX_HUNK_BYTES + 1024); + expect(rec.hunks[0].lines.length).toBeLessThan(4000); + }); +}); + +describe('parseClaudeEditLines: Bash image candidates', () => { + const start = '2026-10-15T20:00:00.000Z'; + const end = '2026-10-15T20:00:05.000Z'; + + it('pairs a Bash call with its result and collects image paths from command and output', () => { + const use = bashUseLine('toolu_b', start, 'python3 render.py --out out/frame.png'); + const res = toolResultLine('toolu_b', end, { + stdout: 'Saved to: file:///tmp/gen/a b.png\nwrote /work/repo/out/thumb.webp.', + stderr: '', + interrupted: false, + isImage: false, + }); + const { candidates, pending } = parseClaudeEditLines([use, res].join('\n')); + expect(pending).toEqual([]); + expect(candidates).toEqual([ + { + toolUseId: 'toolu_b', + start: Date.parse(start), + end: Date.parse(end), + paths: ['/work/repo/out/frame.png', '/tmp/gen/a b.png', '/work/repo/out/thumb.webp'], + }, + ]); + }); + + it('yields no candidate for a Bash call without image paths', () => { + const use = bashUseLine('toolu_n', start, 'git status'); + const res = toolResultLine('toolu_n', end, { stdout: 'clean', stderr: '', interrupted: false, isImage: false }); + expect(parseClaudeEditLines([use, res].join('\n')).candidates).toEqual([]); + }); + + it('uses the tool_result text when the Bash call failed (toolUseResult is a string)', () => { + const use = bashUseLine('toolu_f', start, 'make render'); + const res = JSON.stringify({ + type: 'user', + timestamp: end, + cwd: CWD, + message: { + role: 'user', + content: [{ tool_use_id: 'toolu_f', type: 'tool_result', is_error: true, content: 'Exit 2\nwrote out/x.jpg' }], + }, + toolUseResult: 'Error: Exit 2', + }); + expect(parseClaudeEditLines([use, res].join('\n')).candidates[0].paths).toEqual(['/work/repo/out/x.jpg']); + }); + + it('hands an unanswered Bash call back as pending and resolves it in the next chunk', () => { + const first = parseClaudeEditLines(bashUseLine('toolu_p', start, 'convert a.svg out.png') + '\n'); + expect(first.candidates).toEqual([]); + expect(first.pending).toEqual([ + { id: 'toolu_p', ts: Date.parse(start), command: 'convert a.svg out.png', cwd: CWD }, + ]); + const res = toolResultLine('toolu_p', end, { stdout: '', stderr: '', interrupted: false, isImage: false }); + const second = parseClaudeEditLines(res, first.pending); + expect(second.pending).toEqual([]); + expect(second.candidates[0].paths).toEqual(['/work/repo/a.svg', '/work/repo/out.png']); + }); + + it('carries the subagent id onto the candidate', () => { + const use = bashUseLine('toolu_s', start, 'render > shot.png', { isSidechain: true, agentId: 'abc' }); + const res = toolResultLine('toolu_s', end, { stdout: '', stderr: '', interrupted: false, isImage: false }); + expect(parseClaudeEditLines([use, res].join('\n')).candidates[0].agentId).toBe('abc'); + }); + + it('bounds the pending list, dropping the oldest', () => { + const lines = Array.from({ length: MAX_PENDING_BASH + 5 }, (_, i) => + bashUseLine(`toolu_${i}`, start, 'sleep 1') + ).join('\n'); + const { pending } = parseClaudeEditLines(lines); + expect(pending).toHaveLength(MAX_PENDING_BASH); + expect(pending[0].id).toBe('toolu_5'); + }); + + it(`keeps no call with an id, cwd or agent id over ${MAX_PENDING_FIELD_CHARS} characters (the store would refuse it on load)`, () => { + const long = 'x'.repeat(MAX_PENDING_FIELD_CHARS + 1); + const lines = [ + bashUseLine(long, start, 'render > a.png'), + bashUseLine('toolu_cwd', start, 'render > a.png', { cwd: '/' + long }), + bashUseLine('toolu_agent', start, 'render > a.png', { agentId: long }), + bashUseLine('toolu_ok', start, 'render > a.png', { cwd: '/' + 'x'.repeat(MAX_PENDING_FIELD_CHARS - 1) }), + ]; + expect(parseClaudeEditLines(lines.join('\n')).pending.map((p) => p.id)).toEqual(['toolu_ok']); + }); + + it('keeps only the head of a long command (the pending list is persisted)', () => { + const command = 'render > out.png && ' + 'x'.repeat(MAX_PENDING_COMMAND_CHARS * 2); + const { pending } = parseClaudeEditLines(bashUseLine('toolu_long', start, command)); + expect(pending[0].command).toBe(command.slice(0, MAX_PENDING_COMMAND_CHARS)); + }); +}); + +describe('parseClaudeEditLines: images sent to the user', () => { + it('takes the image attachments of a SendUserFile result, absolute paths only, without a window', () => { + const line = toolResultLine( + 'toolu_send', + '2026-10-15T20:50:12.538Z', + { + caption: 'Vier Alternativen', + attachments: [ + { path: '/tmp/s/icons/bremse_alt1_pedal.png', file_uuid: 'x' }, + { path: '/tmp/s/icons/bremse_alt1_pedal.png' }, + { path: '/tmp/s/report.pdf' }, + { path: 'relative.png' }, + { nope: 1 }, + ], + }, + { agentId: 'a1' } + ); + const { candidates, records } = parseClaudeEditLines(line); + expect(records).toEqual([]); + const at = Date.parse('2026-10-15T20:50:12.538Z'); + expect(candidates).toEqual([ + { + toolUseId: 'toolu_send', + start: at, + end: at, + paths: ['/tmp/s/icons/bremse_alt1_pedal.png'], + sent: true, + agentId: 'a1', + }, + ]); + }); +}); + +describe('extractImagePathCandidates', () => { + it('keeps a colon inside a file:// path (timestamped names)', () => { + expect(extractImagePathCandidates('', 'Saved to file:///tmp/plot-12:30.png', '', CWD)).toEqual([ + '/tmp/plot-12:30.png', + ]); + }); + + it('resolves relative paths against cwd and keeps ~/ paths literal', () => { + expect(extractImagePathCandidates('cp a.png ../b.jpeg ~/shots/c.gif', '', '', CWD)).toEqual([ + '/work/repo/a.png', + '/work/b.jpeg', + '~/shots/c.gif', + ]); + }); + + it('reads quoted paths with spaces', () => { + expect(extractImagePathCandidates(`magick "in dir/x.png" 'out dir/y.avif'`, '', '', CWD)).toEqual([ + '/work/repo/in dir/x.png', + '/work/repo/out dir/y.avif', + ]); + }); + + it('ignores URLs, globs and names that only contain an image extension', () => { + const cmd = 'curl https://x.org/a.png; ls *.png out/frame-?.png; mv a.png.bak b.pngx; echo c.png'; + expect(extractImagePathCandidates(cmd, '', '', CWD)).toEqual(['/work/repo/c.png']); + }); + + it('splits key=value and trailing punctuation off', () => { + expect(extractImagePathCandidates('render --out=out/a.png', 'done (out/b.png), see out/c.png.', '', CWD)).toEqual([ + '/work/repo/out/a.png', + '/work/repo/out/b.png', + '/work/repo/out/c.png', + ]); + }); + + it('deduplicates and caps the list', () => { + const many = Array.from({ length: MAX_IMAGE_CANDIDATES + 10 }, (_, i) => `f${i}.png`).join(' '); + const out = extractImagePathCandidates(`a.png a.png ${many}`, '', '', CWD); + expect(out).toHaveLength(MAX_IMAGE_CANDIDATES); + expect(out[0]).toBe('/work/repo/a.png'); + expect(out[1]).toBe('/work/repo/f0.png'); + }); + + it('drops relative paths when cwd is unknown', () => { + expect(extractImagePathCandidates('x.png /abs/y.png', '', '', '')).toEqual(['/abs/y.png']); + }); +}); + +describe('extractImagePathCandidates: linear in the line length (#693 review)', () => { + // Claude Code keeps up to 30,000 characters of Bash output, all of it agent-printed, and the + // scan runs synchronously in the request. Before the fix one such line took ~2.7 s. + const N = 30_000; + // Generous: the gated suite runs at nice 19 next to the live service. Linear is ~1 ms. + const BUDGET_MS = 1000; + const timed = (text: string) => { + const t = performance.now(); + const out = extractImagePathCandidates('render', text, '', CWD); + return { out, ms: performance.now() - t }; + }; + const base64 = Buffer.from(Array.from({ length: (N * 3) / 4 }, (_, i) => (i * 131 + 7) & 255)).toString('base64'); + + it.each([ + ...'#+%@![{^:/a.~-$'.split('').map((c) => [`30,000 × ${c}`, c.repeat(N)] as const), + ['a base64 blob', base64] as const, + ['repeated file:///', 'file:///'.repeat(N / 8)] as const, + ['repeated file:///x', 'file:///x'.repeat(N / 9)] as const, + ['repeated file:///a:', 'file:///a:'.repeat(N / 10)] as const, + ['file:/// then 30,000 characters', 'file:///' + 'a'.repeat(N)] as const, + ['an unclosed quote', '"' + 'a'.repeat(N)] as const, + ['repeated =a', '=a'.repeat(N / 2)] as const, + ])('%s stays under the time budget and still finds a path after it', (_label, line) => { + const { out, ms } = timed(`${line}\nsaved out/plot.png`); + expect(ms).toBeLessThan(BUDGET_MS); + expect(out).toContain('/work/repo/out/plot.png'); + }); + + it('parses a chunk of 139 Bash results with a 30,000-character base64 blob each under budget', () => { + const lines: string[] = []; + for (let i = 0; i < 139; i++) { + lines.push(bashUseLine(`toolu_${i}`, '2026-10-15T20:00:00.000Z', 'cat img.b64')); + lines.push( + toolResultLine(`toolu_${i}`, '2026-10-15T20:00:01.000Z', { + stdout: base64, + stderr: '', + interrupted: false, + isImage: false, + }) + ); + } + const t = performance.now(); + parseClaudeEditLines(lines.join('\n')); + expect(performance.now() - t).toBeLessThan(3000); // 6.6 s before the fix + }); +}); + +describe('classifyEditPath', () => { + it.each([ + ['/a/b.png', 'image'], + ['/a/b.JPG', 'image'], + ['/a/b.jpeg', 'image'], + ['/a/b.webp', 'image'], + ['/a/b.gif', 'image'], + ['/a/b.avif', 'image'], + ['/a/b.svg', 'image'], + ['/a/b.ts', 'code'], + ['/a/png', 'code'], + ['/a/b.png.orig', 'code'], + ])('%s → %s', (path, kind) => { + expect(classifyEditPath(path)).toBe(kind); + }); +}); diff --git a/test/edit-history-pipeline.test.ts b/test/edit-history-pipeline.test.ts new file mode 100644 index 00000000..7693cbc9 --- /dev/null +++ b/test/edit-history-pipeline.test.ts @@ -0,0 +1,214 @@ +/** + * Edit history end to end: the real reader (edit-history-local-source.ts), sync and store on + * temp transcripts: what the fakes in edit-history-sync.test.ts cannot show (file order, + * a new conversation next to the old one, every line read exactly once). + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { appendFileSync, existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { readClaudeEditSources } from '../src/edit-history-local-source.js'; +import { EditHistoryStore } from '../src/edit-history-store.js'; +import { EditHistorySync, MAX_FORWARD_PASSES, type EditHistorySession } from '../src/web/edit-history-sync.js'; + +let root: string; +const opened: EditHistoryStore[] = []; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'edit-pipeline-')); +}); +afterEach(async () => { + await Promise.all(opened.splice(0).map((s) => s.remove())); + rmSync(root, { recursive: true, force: true }); +}); + +const T0 = Date.parse('2026-10-15T20:00:00.000Z'); + +/** One edit result line of `pad` bytes or more, newline included. */ +function editLine(id: string, i: number, pad = 0, agentId?: string): string { + const rec: Record<string, unknown> = { + type: 'user', + timestamp: new Date(T0 + i * 1000).toISOString(), + cwd: '/w', + message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] }, + toolUseResult: { + filePath: `/w/${id}.ts`, + oldString: 'a', + newString: 'b', + structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }], + }, + pad: '', + }; + if (agentId) rec.agentId = agentId; + rec.pad = 'p'.repeat(Math.max(0, pad - JSON.stringify(rec).length - 1)); + return JSON.stringify(rec) + '\n'; +} + +function setup(budget?: number, base = root) { + const project = join(base, 'projects', 'p'); + mkdirSync(project, { recursive: true }); + const at = (transcriptId: string) => join(project, `${transcriptId}.jsonl`); + /** The transcript watcher's path (the hooks report it; `/clear` moves it). */ + let current = at('convA'); + const sync = new EditHistorySync({ + transcriptPath: async () => (existsSync(current) ? current : null), + readSources: readClaudeEditSources, + probe: async (paths) => paths.map(() => null), + loadStore: async (id) => { + const store = await EditHistoryStore.load(id, { dir: join(base, 'store'), debounceMs: 10 }); + opened.push(store); + return store; + }, + deleteStore: (id) => EditHistoryStore.delete(id, { dir: join(base, 'store') }), + sweepStores: async () => 0, + budget, + }); + const session: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'convA' }; + const ids = async () => + (await sync.getStore('s1')) + .list() + .map((e) => e.id) + .sort(); + return { + project, + at, + sync, + session, + ids, + switchTo: (transcriptId: string) => (current = at(transcriptId)), + }; +} + +describe('a new conversation (/clear, resume)', () => { + it('reads the previous transcript to its end before the new one', async () => { + const { at, sync, session, ids, switchTo } = setup(); + appendFileSync(at('convA'), editLine('a1', 1)); + await sync.sync(session); + // The turn's last edits land, then /clear before the panel's debounced re-read. + appendFileSync(at('convA'), editLine('a2', 2) + editLine('a3', 3)); + switchTo('convB'); + appendFileSync(at('convB'), editLine('b1', 4)); + await sync.sync(session); + expect(await ids()).toEqual(['a1', 'a2', 'a3', 'b1']); + const store = await sync.getStore('s1'); + expect(store.getTranscriptId()).toBe('convB'); + expect(store.knownSources()).toEqual(['main']); + }); + + it('a previous backlog larger than one sync reads on with the next ones; the new transcript follows', async () => { + const { at, sync, session, ids, switchTo } = setup(600); + appendFileSync(at('convA'), editLine('a0', 0)); + await sync.sync(session); + const backlog = Array.from({ length: MAX_FORWARD_PASSES * 3 }, (_, i) => `a${i + 1}`); + appendFileSync(at('convA'), backlog.map((id, i) => editLine(id, i + 1, 500)).join('')); + switchTo('convB'); + appendFileSync(at('convB'), editLine('b1', 99)); + await sync.sync(session); + expect(await ids()).not.toContain('b1'); + for (let i = 0; i < 5; i++) await sync.sync(session); + expect(await ids()).toEqual(['a0', ...backlog, 'b1'].sort()); + }); + + it('subagents of the previous conversation are read to their end too', async () => { + const { project, at, sync, session, ids, switchTo } = setup(); + const sub = join(project, 'convA', 'subagents'); + mkdirSync(sub, { recursive: true }); + appendFileSync(at('convA'), editLine('a1', 1)); + appendFileSync(join(sub, 'agent-x.jsonl'), editLine('x1', 2, 0, 'x')); + await sync.sync(session); + appendFileSync(join(sub, 'agent-x.jsonl'), editLine('x2', 3, 0, 'x')); + switchTo('convB'); + appendFileSync(at('convB'), editLine('b1', 4)); + await sync.sync(session); + expect(await ids()).toEqual(['a1', 'b1', 'x1', 'x2']); + }); + + it('a previous transcript that is gone does not hold up the new one', async () => { + const { at, sync, session, ids, switchTo } = setup(); + appendFileSync(at('convA'), editLine('a1', 1)); + await sync.sync(session); + rmSync(at('convA')); + switchTo('convB'); + appendFileSync(at('convB'), editLine('b1', 2)); + await sync.sync(session); + expect(await ids()).toEqual(['a1', 'b1']); + }); + + it('"Load older" before the forward read moved on reads the stored conversation, not the new file', async () => { + const { at, sync, session, ids, switchTo } = setup(600); + const older = Array.from({ length: 4 }, (_, i) => `a${i}`); + appendFileSync(at('convA'), older.map((id, i) => editLine(id, i, 500)).join('')); + await sync.sync(session); + expect(await ids()).toEqual(['a3']); + switchTo('convB'); + appendFileSync(at('convB'), editLine('b1', 99)); + await sync.sync(session, { older: true }); + expect(await ids()).toEqual(['a2', 'a3']); + }); +}); + +describe('every line exactly once (seeded random transcripts)', () => { + /** Deterministic: a seed fixes budget, line lengths, subagents, partial writes and sync points. */ + async function run(seed: number): Promise<string | null> { + let state = seed; + const rnd = () => { + state = (state * 1103515245 + 12345) & 0x7fffffff; + return state / 0x7fffffff; + }; + const ri = (a: number, b: number) => a + Math.floor(rnd() * (b - a + 1)); + const budget = ri(400, 1500); + const { project, at, sync, session } = setup(budget, mkdtempSync(join(root, 'run-'))); + const main = at('convA'); + const subDir = join(project, 'convA', 'subagents'); + const expected = new Set<string>(); + const tooLong = new Set<string>(); + const agents: string[] = []; + const tails = new Map<string, string>(); + let n = 0; + const write = () => { + if (rnd() < 0.15 && agents.length < 6) { + mkdirSync(subDir, { recursive: true }); + agents.push(`a${agents.length}`); + } + const k = ri(0, agents.length); + const agent = k === 0 ? undefined : agents[k - 1]; + const file = agent ? join(subDir, `agent-${agent}.jsonl`) : main; + const id = `e${n++}`; + // Some lines are longer than the budget: skipped, never shown, never blocking. + const line = editLine(id, n, rnd() < 0.12 ? ri(budget + 2, budget * 3) : ri(250, budget - 2), agent); + if (Buffer.byteLength(line) < budget) expected.add(id); + else if (Buffer.byteLength(line) > budget) tooLong.add(id); + // Sometimes half a line: claude is still writing it. + const cut = rnd() < 0.15 ? ri(1, line.length - 1) : line.length; + appendFileSync(file, (tails.get(file) ?? '') + line.slice(0, cut)); + if (cut < line.length) tails.set(file, line.slice(cut)); + else tails.delete(file); + }; + for (let i = ri(0, 20); i > 0; i--) write(); // backlog before the panel opens + if (!existsSync(main)) appendFileSync(main, ''); + for (let r = ri(5, 25); r > 0; r--) { + for (let i = ri(0, 6); i > 0; i--) write(); + await sync.sync(session); + } + for (const [file, rest] of tails) appendFileSync(file, rest); + for (let i = 0; i < 40; i++) await sync.sync(session); + const store = await sync.getStore('s1'); + for (let clicks = 0; store.backfillTruncated && clicks < 400; clicks++) await sync.sync(session, { older: true }); + const got = new Set(store.list().map((e) => e.id)); + const missing = [...expected].filter((id) => !got.has(id)); + const extra = [...got].filter((id) => tooLong.has(id)); + if (missing.length || extra.length || store.backfillTruncated) { + return `seed ${seed} (budget ${budget}): missing ${missing.join(',')} extra ${extra.join(',')}`; + } + return null; + } + + it('reads every line shorter than the budget once, forward and back, and none longer', async () => { + const failures: string[] = []; + for (let seed = 1; seed <= 40; seed++) { + await Promise.all(opened.splice(0).map((s) => s.remove())); + const r = await run(seed); + if (r) failures.push(r); + } + expect(failures).toEqual([]); + }); +}); diff --git a/test/edit-history-probe-stall.test.ts b/test/edit-history-probe-stall.test.ts new file mode 100644 index 00000000..5c0dd54b --- /dev/null +++ b/test/edit-history-probe-stall.test.ts @@ -0,0 +1,106 @@ +/** + * Edit history image probe against a filesystem that stopped answering (#693 review): a Bash + * call printed a path on a dead hard mount, whose stat() never settles. The real sync with the + * real probe (`probeLocalPaths` → bounded `probePathKind`) must still finish, the next queued + * sync too, and `remove()` must not wait on it. Own file: the stalled probe stays recorded + * process-wide for the rest of the run. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const DEAD = '/mnt/dead-nas'; + +vi.hoisted(() => { + process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '100'; +}); + +vi.mock('node:fs/promises', async (importOriginal) => { + const real = await importOriginal<typeof import('node:fs/promises')>(); + const hangs = (p: unknown) => String(p).startsWith('/mnt/dead-nas'); + const stat = ((p: Parameters<typeof real.stat>[0], ...rest: unknown[]) => + hangs(p) ? new Promise(() => {}) : (real.stat as (...a: unknown[]) => unknown)(p, ...rest)) as typeof real.stat; + const realpath = ((p: Parameters<typeof real.realpath>[0], ...rest: unknown[]) => + hangs(p) + ? new Promise(() => {}) + : (real.realpath as (...a: unknown[]) => unknown)(p, ...rest)) as typeof real.realpath; + return { ...real, stat, realpath, default: { ...real, stat, realpath } }; +}); + +import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { EditHistoryStore } from '../src/edit-history-store.js'; +import { probeLocalPaths, type EditSources } from '../src/edit-history-local-source.js'; +import { EditHistorySync, type EditHistorySession } from '../src/web/edit-history-sync.js'; + +const T0 = Date.parse('2026-10-15T20:00:00.000Z'); +const session: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'conv1' }; + +function bashPair(id: string, start: number, end: number, stdout: string): string { + const use = { + type: 'assistant', + timestamp: new Date(start).toISOString(), + cwd: '/w', + message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command: 'render' } }] }, + }; + const res = { + type: 'user', + timestamp: new Date(end).toISOString(), + cwd: '/w', + message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: stdout }] }, + toolUseResult: { stdout, stderr: '', interrupted: false, isImage: false }, + }; + return JSON.stringify(use) + '\n' + JSON.stringify(res) + '\n'; +} + +/** Rejects after `ms`, so a hang fails the test instead of timing it out silently. */ +function within<T>(p: Promise<T>, ms: number, what: string): Promise<T> { + return Promise.race([ + p, + new Promise<T>((_, reject) => setTimeout(() => reject(new Error(`${what} still pending after ${ms} ms`)), ms)), + ]); +} + +let dir: string; +let stores: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'edit-stall-')); + stores = join(dir, 'stores'); +}); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe('edit history image probe on an unresponsive mount', () => { + it('finishes sync, a queued sync and remove() while one printed path never answers', async () => { + const ok = join(dir, 'ok.png'); + writeFileSync(ok, 'PNG!'); + utimesSync(ok, new Date(T0 + 2000), new Date(T0 + 2000)); + let main = Buffer.from(bashPair('b1', T0, T0 + 5000, `${DEAD}/plots/a.png\nsaved ${ok}\n`)); + + const sync = new EditHistorySync({ + transcriptPath: async () => '/p/conv1.jsonl', + readSources: async (_path, requests, budget): Promise<EditSources> => { + const req = requests.find((r) => r.relPath === 'main'); + const start = Math.min(req?.offset ?? 0, main.length); + const len = Math.min(budget, main.length - start); + return { + transcriptId: 'conv1', + files: [{ relPath: 'main', size: main.length, startOffset: start, chunk: main.subarray(start, start + len) }], + metas: {}, + }; + }, + probe: (paths) => probeLocalPaths(paths), + loadStore: (id) => EditHistoryStore.load(id, { dir: stores, debounceMs: 0 }), + deleteStore: (id) => EditHistoryStore.delete(id, { dir: stores }), + sweepStores: async () => 0, + }); + + const first = sync.sync(session); + main = Buffer.concat([main, Buffer.from(bashPair('b2', T0 + 6000, T0 + 7000, `${DEAD}/plots/b.png\n`))]); + const second = sync.sync(session); + expect(await within(first, 1500, 'first sync')).toEqual({ state: 'ok' }); + expect(await within(second, 1500, 'queued sync')).toEqual({ state: 'ok' }); + expect((await sync.getStore('s1')).list().map((e) => e.path)).toEqual([ok]); + await within(sync.remove('s1'), 1500, 'remove()'); + }); +}); diff --git a/test/edit-history-store.test.ts b/test/edit-history-store.test.ts new file mode 100644 index 00000000..dbe7f1ca --- /dev/null +++ b/test/edit-history-store.test.ts @@ -0,0 +1,704 @@ +/** + * Edit history store: byte ranges, cursors, dedupe, cap, persistence. + * Transcript bytes are built from JSONL lines shaped like real claude records. + */ +import { mkdtempSync, rmSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + EditHistoryStore, + entryBytes, + MAIN_SOURCE, + MAX_AGENT_META_CHARS, + MAX_AGENTS, + MAX_ENTRIES, + MAX_FILE_BYTES, + MAX_STORE_BYTES, + parseAgentMeta, + READ_BUDGET_BYTES, + TMP_TAG, +} from '../src/edit-history-store.js'; +import { MAX_HUNK_BYTES } from '../src/edit-history-parse.js'; + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'edit-history-store-')); +}); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +const T0 = Date.parse('2026-10-15T19:00:00.000Z'); +const iso = (s: number) => new Date(T0 + s * 1000).toISOString(); + +function editLine(id: string, sec: number, path = `/work/${id}.ts`, extra: Record<string, unknown> = {}): string { + return JSON.stringify({ + type: 'user', + timestamp: iso(sec), + cwd: '/work', + message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] }, + toolUseResult: { + filePath: path, + oldString: 'a', + newString: 'b', + originalFile: 'a', + structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }], + userModified: false, + replaceAll: false, + }, + ...extra, + }); +} + +/** An Edit whose diff carries `size` bytes of added text. */ +function bigEditLine(id: string, sec: number, size: number): string { + const line = JSON.parse(editLine(id, sec)); + line.toolUseResult.structuredPatch[0].lines = ['-a', '+' + 'x'.repeat(size)]; + return JSON.stringify(line); +} + +function bashUse(id: string, sec: number, command: string): string { + return JSON.stringify({ + type: 'assistant', + timestamp: iso(sec), + cwd: '/work', + message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] }, + }); +} + +function bashResult(id: string, sec: number): string { + return JSON.stringify({ + type: 'user', + timestamp: iso(sec), + cwd: '/work', + message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: '' }] }, + toolUseResult: { stdout: '', stderr: '', interrupted: false, isImage: false }, + }); +} + +const jsonl = (lines: string[]) => Buffer.from(lines.map((l) => l + '\n').join('')); + +/** + * Read like the reader (edit-history-local-source.ts): on from the cursor of the current + * conversation (clamped to the size), else the file's last `budget` bytes; then apply. + * Null when nothing is new. + */ +function syncForward( + store: EditHistoryStore, + file: Buffer, + budget = READ_BUDGET_BYTES, + source = MAIN_SOURCE, + tid = 'conv-1' +) { + const cursor = tid === store.getTranscriptId() ? store.getCursor(source) : undefined; + const offset = cursor ? Math.min(cursor.offset, file.length) : Math.max(0, file.length - budget); + const len = Math.min(budget, file.length - offset); + if (cursor && offset === cursor.offset && len === 0) return null; + const chunk = file.subarray(offset, offset + len); + return store.apply({ transcriptId: tid, source, startOffset: offset, chunk, size: file.length }); +} + +function syncOlder(store: EditHistoryStore, file: Buffer, budget?: number, source = MAIN_SOURCE, tid = 'conv-1') { + const range = store.olderRange(source, budget); + if (!range) return null; + const chunk = file.subarray(range.offset, range.offset + range.len); + return store.applyOlder({ transcriptId: tid, source, startOffset: range.offset, chunk, size: file.length }); +} + +const ids = (store: EditHistoryStore) => store.list().map((e) => e.id); + +describe('EditHistoryStore: forward reads', () => { + it('reads a small file whole, then only what was appended', () => { + const store = new EditHistoryStore('s1', { dir }); + let file = jsonl([editLine('e1', 1), editLine('e2', 2)]); + expect(syncForward(store, file)?.added).toBe(2); + expect(store.getCursor(MAIN_SOURCE)).toEqual({ offset: file.length, floor: 0 }); + expect(syncForward(store, file)).toBeNull(); + file = Buffer.concat([file, jsonl([editLine('e3', 3)])]); + expect(syncForward(store, file)?.added).toBe(1); + expect(ids(store)).toEqual(['e3', 'e2', 'e1']); + expect(store.backfillTruncated).toBe(false); + }); + + it('leaves a partial last line (still being written) for the next read', () => { + const store = new EditHistoryStore('s1', { dir }); + const full = jsonl([editLine('e1', 1), editLine('e2', 2)]); + const firstLineEnd = full.indexOf(0x0a) + 1; + const partial = full.subarray(0, firstLineEnd + 30); + expect(syncForward(store, partial)?.added).toBe(1); + expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(firstLineEnd); + expect(syncForward(store, full)?.added).toBe(1); + expect(ids(store)).toEqual(['e2', 'e1']); + }); + + it('deduplicates by tool_use_id', () => { + const store = new EditHistoryStore('s1', { dir }); + const file = jsonl([editLine('e1', 1), editLine('e1', 1)]); + expect(syncForward(store, file)?.added).toBe(1); + expect(store.list()).toHaveLength(1); + }); + + it('starts a large file near its end, drops the cut line and keeps the floor there', () => { + const store = new EditHistoryStore('s1', { dir }); + const lines = Array.from({ length: 20 }, (_, i) => editLine(`e${i}`, i)); + const file = jsonl(lines); + const budget = Math.floor(file.length / 3); + syncForward(store, file, budget); + const cursor = store.getCursor(MAIN_SOURCE)!; + expect(cursor.offset).toBe(file.length); + expect(cursor.floor).toBeGreaterThan(file.length - budget); + expect(file[cursor.floor - 1]).toBe(0x0a); + expect(store.backfillTruncated).toBe(true); + expect(ids(store)[0]).toBe('e19'); + expect(ids(store)).not.toContain('e0'); + }); + + it('reads a backlog in budget-sized forward steps without losing a line', () => { + const store = new EditHistoryStore('s1', { dir }); + const first = jsonl([editLine('e0', 0)]); + syncForward(store, first); + const more = Array.from({ length: 30 }, (_, i) => editLine(`e${i + 1}`, i + 1)); + const file = Buffer.concat([first, jsonl(more)]); + const budget = 700; + for (let i = 0; i < 100 && syncForward(store, file, budget); i++); + expect(store.list()).toHaveLength(31); + expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(file.length); + }); + + it('skips a line longer than the budget instead of re-reading it forever', () => { + const store = new EditHistoryStore('s1', { dir }); + const huge = editLine('big', 1, '/work/big.ts', { pad: 'x'.repeat(5000) }); + const first = jsonl([editLine('e0', 0)]); + syncForward(store, first); + const file = Buffer.concat([first, jsonl([huge, editLine('e2', 2)])]); + for (let i = 0; i < 50 && syncForward(store, file, 1000); i++); + expect(ids(store)).toEqual(['e2', 'e0']); + expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(file.length); + }); + + it('reads the line after a skipped one whole, wherever it falls against the chunk grid', async () => { + const budget = 1000; + const first = jsonl([editLine('e0', 0)]); + const lost: number[] = []; + for (let pad = 0; pad < budget; pad++) { + const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 }); + syncForward(store, first); + const huge = JSON.stringify({ type: 'user', message: { content: 'h'.repeat(2400 + pad) } }); + const next = editLine('next', 2, '/work/next.ts', { pad: 'p'.repeat(300) }); // < budget + const file = Buffer.concat([first, jsonl([huge, next, editLine('e3', 3)])]); + for (let i = 0; i < 50 && syncForward(store, file, budget); i++); + if (!store.get('next') || !store.get('e3')) lost.push(pad); + await store.remove(); + } + expect(lost).toEqual([]); + }); + + it('keeps a multibyte character split by the chunk boundary intact', () => { + const store = new EditHistoryStore('s1', { dir }); + const path = '/work/Größe – Ümlaut 😀.ts'; + const file = jsonl([editLine('e1', 1), editLine('e2', 2, path)]); + const emoji = file.indexOf(Buffer.from('😀')); + // Cut inside the 4-byte emoji, then continue. + const cut = file.subarray(0, emoji + 2); + syncForward(store, cut); + syncForward(store, file); + expect(store.get('e2')?.path).toBe(path); + }); + + it('restarts a file that shrank (replaced) from its end, with the next read', () => { + const store = new EditHistoryStore('s1', { dir }); + syncForward(store, jsonl([editLine('e1', 1), editLine('e2', 2)])); + const replaced = jsonl([editLine('n1', 3)]); + // The reader clamps the cursor to the size: an empty chunk at the end, and the cursor goes. + expect(syncForward(store, replaced)?.added).toBe(0); + expect(store.getCursor(MAIN_SOURCE)).toBeUndefined(); + expect(syncForward(store, replaced)?.added).toBe(1); + expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(replaced.length); + }); + + it('ignores a chunk that does not start at the cursor', () => { + const store = new EditHistoryStore('s1', { dir }); + const file = jsonl([editLine('e1', 1), editLine('e2', 2)]); + syncForward(store, file); + const res = store.apply({ transcriptId: 'conv-1', source: MAIN_SOURCE, startOffset: 3, chunk: file, size: 999 }); + expect(res.added).toBe(0); + }); +}); + +describe('EditHistoryStore: conversations and sources', () => { + it('starts new cursors for a new transcriptId and keeps the old entries', () => { + const store = new EditHistoryStore('s1', { dir }); + syncForward(store, jsonl([editLine('e1', 1)])); + const second = jsonl([editLine('f1', 5)]); + syncForward(store, second, undefined, MAIN_SOURCE, 'conv-2'); + expect(store.getTranscriptId()).toBe('conv-2'); + expect(ids(store)).toEqual(['f1', 'e1']); + }); + + it('keeps one cursor per source and the subagent id on its entries', () => { + const store = new EditHistoryStore('s1', { dir }); + const main = jsonl([editLine('m1', 1)]); + const sub = jsonl([editLine('s1', 2, '/work/s.ts', { isSidechain: true, agentId: 'a1' })]); + const subSource = 'subagents/agent-a1.jsonl'; + syncForward(store, main); + syncForward(store, sub, undefined, subSource); + expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(main.length); + expect(store.getCursor(subSource)?.offset).toBe(sub.length); + expect(store.get('s1')?.agentId).toBe('a1'); + }); + + it('records agent meta from meta.json text', () => { + const store = new EditHistoryStore('s1', { dir }); + store.setAgentMeta( + 'a1', + JSON.stringify({ + agentType: 'coding', + description: 'Impl: fix', + worktreePath: '/w/.claude/worktrees/agent-a1', + worktreeBranch: 'worktree-agent-a1', + toolUseId: 'toolu_x', + }) + ); + store.setAgentMeta('a2', 'not json'); + expect(store.getAgents()).toEqual({ + a1: { + description: 'Impl: fix', + agentType: 'coding', + worktreePath: '/w/.claude/worktrees/agent-a1', + worktreeBranch: 'worktree-agent-a1', + }, + }); + expect(store.hasAgentMeta('a2')).toBe(false); + expect(parseAgentMeta('{"description":"d"}')).toEqual({ description: 'd', agentType: '' }); + }); +}); + +describe('EditHistoryStore: older blocks', () => { + it('loads older lines block by block down to the start, without duplicates at the seam', () => { + const store = new EditHistoryStore('s1', { dir }); + const file = jsonl(Array.from({ length: 30 }, (_, i) => editLine(`e${i}`, i))); + const budget = Math.floor(file.length / 4); + syncForward(store, file, budget); + const before = store.list().length; + expect(before).toBeLessThan(30); + let rounds = 0; + while (syncOlder(store, file, budget) && rounds < 20) rounds++; + expect(rounds).toBeGreaterThan(1); + expect(store.list()).toHaveLength(30); + expect(store.getCursor(MAIN_SOURCE)?.floor).toBe(0); + expect(store.backfillTruncated).toBe(false); + expect(store.olderRange(MAIN_SOURCE, budget)).toBeNull(); + }); + + it('skips a line longer than the budget going back and reaches the lines before it', () => { + const store = new EditHistoryStore('s1', { dir }); + const huge = JSON.stringify({ type: 'user', message: { content: 'x'.repeat(6000) } }); + const older = Array.from({ length: 10 }, (_, i) => editLine(`old${i}`, i)); + const newer = Array.from({ length: 5 }, (_, i) => editLine(`new${i}`, 100 + i)); + const file = jsonl([...older, huge, ...newer]); + const budget = 4000; + syncForward(store, file, budget); + expect(ids(store).sort()).toEqual(newer.map((_, i) => `new${i}`).sort()); + let rounds = 0; + while (syncOlder(store, file, budget) && rounds < 30) rounds++; + expect(store.list()).toHaveLength(15); + expect(store.getCursor(MAIN_SOURCE)).toMatchObject({ floor: 0 }); + expect(store.getCursor(MAIN_SOURCE)?.floorMidLine).toBeUndefined(); + expect(store.backfillTruncated).toBe(false); + }); + + it('going back, reads the line before a skipped one whole, wherever it falls against the block grid', async () => { + const budget = 1000; + const huge = JSON.stringify({ type: 'user', message: { content: 'h'.repeat(2500) } }); + const lost: number[] = []; + for (let pad = 0; pad < budget; pad++) { + const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 }); + const lines = [editLine('p0', 0), editLine('p1', 1), editLine('before', 2), huge]; + for (let i = 0; i < 6; i++) + lines.push(editLine(`t${i}`, 10 + i, `/work/t${i}.ts`, i === 0 ? { pad: 'p'.repeat(pad) } : {})); + const file = jsonl(lines); + syncForward(store, file, budget); + for (let i = 0; i < 50 && syncOlder(store, file, budget); i++); + if (!store.get('before') || !store.get('p0')) lost.push(pad); + await store.remove(); + } + expect(lost).toEqual([]); + }); + + it('a source whose file is gone (dropSource) offers no older lines and keeps its entries', () => { + const store = new EditHistoryStore('s1', { dir }); + const sub = 'subagents/agent-a1.jsonl'; + const file = jsonl(Array.from({ length: 10 }, (_, i) => editLine(`s${i}`, i, `/work/s${i}.ts`, { agentId: 'a1' }))); + syncForward(store, file, 400, sub); + expect(store.sourcesWithOlder()).toEqual([sub]); + const kept = ids(store); + store.dropSource(sub); + expect(store.getCursor(sub)).toBeUndefined(); + expect(store.backfillTruncated).toBe(false); + expect(ids(store)).toEqual(kept); + }); + + it('refuses an older chunk that does not end at the floor', () => { + const store = new EditHistoryStore('s1', { dir }); + const file = jsonl(Array.from({ length: 10 }, (_, i) => editLine(`e${i}`, i))); + syncForward(store, file, 300); + const res = store.applyOlder({ + transcriptId: 'conv-1', + source: MAIN_SOURCE, + startOffset: 0, + chunk: file.subarray(0, 10), + size: file.length, + }); + expect(res.added).toBe(0); + }); +}); + +describe('EditHistoryStore: Bash images', () => { + it('pairs a Bash call and its result across a chunk boundary', () => { + const store = new EditHistoryStore('s1', { dir }); + const part1 = jsonl([bashUse('b1', 1, 'python render.py -o out/frame.png')]); + const file = Buffer.concat([part1, jsonl([bashResult('b1', 4)])]); + expect(syncForward(store, part1)?.candidates).toEqual([]); + const res = syncForward(store, file)!; + expect(res.candidates).toEqual([ + { toolUseId: 'b1', start: T0 + 1000, end: T0 + 4000, paths: ['/work/out/frame.png'] }, + ]); + }); + + it('adds confirmed images as entries without a diff, once', () => { + const store = new EditHistoryStore('s1', { dir }); + const img = { toolUseId: 'b1', path: '/work/out/frame.png', ts: T0 + 4000, agentId: 'a1' }; + expect(store.addImageEntries([img, img])).toBe(1); + expect(store.addImageEntries([img])).toBe(0); + expect(store.list()[0]).toEqual({ + id: 'b1:/work/out/frame.png', + ts: T0 + 4000, + path: '/work/out/frame.png', + kind: 'image', + tool: 'bash', + created: false, + added: 0, + removed: 0, + hunks: [], + truncated: false, + agentId: 'a1', + }); + }); +}); + +describe('EditHistoryStore: cap', () => { + it(`keeps the newest ${MAX_ENTRIES} entries`, () => { + const store = new EditHistoryStore('s1', { dir }); + const file = jsonl(Array.from({ length: MAX_ENTRIES + 20 }, (_, i) => editLine(`e${i}`, i))); + const res = syncForward(store, file, file.length); + expect(res?.added).toBe(MAX_ENTRIES); + expect(store.list()).toHaveLength(MAX_ENTRIES); + expect(store.get('e19')).toBeUndefined(); + expect(store.get('e20')).toBeDefined(); + // Older than everything kept: counts as nothing added. + expect(store.addImageEntries([{ toolUseId: 'old', path: '/a.png', ts: T0 - 1000 }])).toBe(0); + }); + + it('offers no older block once the cap is full (it would be dropped on read)', () => { + const store = new EditHistoryStore('s1', { dir }); + const lines = Array.from({ length: MAX_ENTRIES + 20 }, (_, i) => editLine(`e${i}`, i)); + const file = jsonl(lines); + const tail = jsonl(lines.slice(-MAX_ENTRIES)).length; + syncForward(store, file, tail + 10); + expect(store.list()).toHaveLength(MAX_ENTRIES); + expect(store.getCursor(MAIN_SOURCE)!.floor).toBeGreaterThan(0); + expect(store.backfillTruncated).toBe(false); + }); + + it(`keeps at most ${MAX_STORE_BYTES} bytes of diff text, dropping the oldest`, () => { + const store = new EditHistoryStore('s1', { dir }); + const n = 300; + const file = jsonl(Array.from({ length: n }, (_, i) => bigEditLine(`e${i}`, i, 20_000))); + syncForward(store, file, file.length); + const kept = store.list(); + expect(kept.length).toBeLessThan(n); + expect(kept.reduce((sum, e) => sum + entryBytes(e), 0)).toBeLessThanOrEqual(MAX_STORE_BYTES); + expect(store.get(`e${n - 1}`)).toBeDefined(); + expect(store.get('e0')).toBeUndefined(); + }); + + it('offers no older block once the byte cap is full', () => { + const store = new EditHistoryStore('s1', { dir }); + const file = jsonl(Array.from({ length: 300 }, (_, i) => bigEditLine(`e${i}`, i, 20_000))); + syncForward(store, file, 5_000_000); + expect(store.list().length).toBeLessThan(MAX_ENTRIES); + expect(store.getCursor(MAIN_SOURCE)!.floor).toBeGreaterThan(0); + expect(store.backfillTruncated).toBe(false); + }); + + it('caps the agent meta strings', () => { + const meta = parseAgentMeta(JSON.stringify({ description: 'd'.repeat(5000), agentType: 'coding' })); + expect(meta?.description).toHaveLength(MAX_AGENT_META_CHARS); + expect(meta?.agentType).toBe('coding'); + }); + + it(`keeps at most ${MAX_AGENTS} subagent labels, dropping the oldest no entry uses`, () => { + const store = new EditHistoryStore('s1', { dir }); + syncForward(store, jsonl([editLine('e1', 1, '/work/a.ts', { agentId: 'a0' })])); + for (let i = 0; i <= MAX_AGENTS + 4; i++) store.setAgentMeta(`a${i}`, `{"description":"d${i}"}`); + const agents = store.getAgents(); + expect(Object.keys(agents)).toHaveLength(MAX_AGENTS); + expect(agents.a0).toBeDefined(); // used by an entry + expect(agents[`a${MAX_AGENTS + 4}`]).toBeDefined(); // the newest + expect(agents.a1).toBeUndefined(); + }); +}); + +describe('EditHistoryStore: persistence', () => { + it('reloads entries, cursors, pending Bash calls and agents from disk', async () => { + const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 }); + const file = jsonl([editLine('e1', 1), bashUse('b1', 2, 'render > a.png')]); + syncForward(store, file); + store.setAgentMeta('a1', '{"description":"d","agentType":"coding"}'); + await store.flush(); + const loaded = await EditHistoryStore.load('s1', { dir }); + expect(ids(loaded)).toEqual(['e1']); + expect(loaded.getCursor(MAIN_SOURCE)).toEqual(store.getCursor(MAIN_SOURCE)); + expect(loaded.getTranscriptId()).toBe('conv-1'); + expect(loaded.getAgents()).toEqual({ a1: { description: 'd', agentType: 'coding' } }); + const done = Buffer.concat([file, jsonl([bashResult('b1', 3)])]); + expect(syncForward(loaded, done)?.candidates[0]?.paths).toEqual(['/work/a.png']); + }); + + it('writes the file 0600 in a 0700 directory, also over a wider existing file', async () => { + const sub = join(dir, 'edit-history'); + const store = new EditHistoryStore('s1', { dir: sub, debounceMs: 60_000 }); + syncForward(store, jsonl([editLine('e1', 1)])); + await store.flush(); + expect(statSync(sub).mode & 0o777).toBe(0o700); + expect(statSync(join(sub, 's1.json')).mode & 0o777).toBe(0o600); + writeFileSync(join(sub, 's1.json'), '{}', { mode: 0o644 }); + await store.flush(); + expect(statSync(join(sub, 's1.json')).mode & 0o777).toBe(0o600); + }); + + it('applies the caps and drops malformed entries when loading', async () => { + const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 }); + syncForward(store, jsonl(Array.from({ length: MAX_ENTRIES }, (_, i) => editLine(`e${i}`, i)))); + await store.flush(); + const data = JSON.parse(readFileSync(join(dir, 's1.json'), 'utf-8')); + const extra = JSON.parse(editLine('late', MAX_ENTRIES + 1)); + data.entries.push( + { ...store.get('e0')!, id: 'newest', ts: T0 + 10_000_000 }, + { id: 'broken', path: 7, hunks: [] }, + { id: 'nohunks', path: '/a.ts' }, + extra + ); + writeFileSync(join(dir, 's1.json'), JSON.stringify(data)); + const loaded = await EditHistoryStore.load('s1', { dir }); + expect(loaded.list()).toHaveLength(MAX_ENTRIES); + expect(loaded.get('newest')).toBeDefined(); + expect(loaded.get('e0')).toBeUndefined(); + expect(loaded.get('broken')).toBeUndefined(); + expect(loaded.get('nohunks')).toBeUndefined(); + }); + + it('drops broken cursors, pending Bash calls and agent labels when loading (a damaged file)', async () => { + writeFileSync( + join(dir, 's1.json'), + JSON.stringify({ + version: 1, + sessionId: 's1', + transcriptId: 'conv-1', + cursors: { + main: { offset: 1.5, floor: 0 }, + 'subagents/agent-x.jsonl': { offset: -5, floor: 0 }, + 'subagents/agent-y.jsonl': { offset: 10, floor: 20 }, + 'subagents/agent-z.jsonl': { offset: 10, floor: 5, floorMidLine: true }, + }, + pendingBash: [null, 7, { id: 'b1', ts: 1, command: 'render > a.png', cwd: '/work' }], + agents: { x: null, y: 'text', z: { description: 'd', agentType: 'coding' } }, + entries: [{ id: 'e1', ts: 1, path: '/a.ts', hunks: [null] }], + }) + ); + const loaded = await EditHistoryStore.load('s1', { dir }); + expect(loaded.knownSources()).toEqual(['subagents/agent-z.jsonl']); + expect(loaded.getCursor('subagents/agent-z.jsonl')).toEqual({ offset: 10, floor: 5, floorMidLine: true }); + expect(loaded.getAgents()).toEqual({ z: { description: 'd', agentType: 'coding' } }); + expect(loaded.list()).toEqual([]); + // The surviving pending call still pairs with its result, and nothing throws. + expect(syncForward(loaded, jsonl([bashResult('b1', 3)]))?.candidates[0]?.paths).toEqual(['/work/a.png']); + }); + + it('rebuilds loaded entries under the same bounds: known fields only, string diff lines, the per-edit cap', async () => { + const good = { + ts: 1, + path: '/a.ts', + kind: 'code', + tool: 'Edit', + created: false, + added: 1, + removed: 0, + truncated: false, + }; + writeFileSync( + join(dir, 's1.json'), + JSON.stringify({ + version: 1, + sessionId: 's1', + transcriptId: 'conv-1', + cursors: {}, + pendingBash: [ + { id: 'b1', ts: 1, command: 'ls', cwd: '/work' }, + { id: 'b2', ts: 1, command: 'ls', cwd: '/' + 'c'.repeat(5000) }, + { id: 'p'.repeat(5000), ts: 1, command: 'ls', cwd: '/work' }, + ], + agents: { ['a'.repeat(MAX_AGENT_META_CHARS + 1)]: { description: 'd', agentType: 't' } }, + entries: [ + { ...good, id: 'extra', hunks: [], blob: 'x'.repeat(1000) }, + { ...good, id: 'objlines', hunks: [{ oldStart: 1, lines: [{ k: 'y' }, '+kept'] }] }, + { ...good, id: 'bighunk', hunks: [{ lines: ['+' + 'q'.repeat(MAX_HUNK_BYTES)] }] }, + { ...good, id: 'badtool', tool: 'Rm', hunks: [] }, + ], + }) + ); + const loaded = await EditHistoryStore.load('s1', { dir, debounceMs: 60_000 }); + expect( + loaded + .list() + .map((e) => e.id) + .sort() + ).toEqual(['bighunk', 'extra', 'objlines']); + expect(loaded.get('extra')).not.toHaveProperty('blob'); + expect(loaded.get('objlines')!.hunks[0].lines).toEqual(['+kept']); + expect(loaded.get('bighunk')).toMatchObject({ truncated: true, hunks: [{ lines: [] }] }); + expect(loaded.getAgents()).toEqual({}); + await loaded.flush(); + expect( + JSON.parse(readFileSync(join(dir, 's1.json'), 'utf-8')).pendingBash.map((p: { id: string }) => p.id) + ).toEqual(['b1']); + }); + + it("counts an entry's id against the byte cap", () => { + const e = { + id: 'i'.repeat(1000), + ts: 1, + path: '/a.ts', + kind: 'code', + tool: 'Edit', + created: false, + added: 0, + removed: 0, + hunks: [], + truncated: false, + } as const; + expect(entryBytes(e) - entryBytes({ ...e, id: 'i' })).toBe(999); + }); + + it(`does not parse a store file larger than ${MAX_FILE_BYTES} bytes`, async () => { + const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 }); + syncForward(store, jsonl([editLine('e1', 1)])); + await store.flush(); + const text = readFileSync(join(dir, 's1.json'), 'utf-8'); + expect((await EditHistoryStore.load('s1', { dir })).get('e1')).toBeDefined(); + writeFileSync(join(dir, 's1.json'), text + ' '.repeat(MAX_FILE_BYTES)); // still valid JSON + expect((await EditHistoryStore.load('s1', { dir })).list()).toEqual([]); + }); + + it('sweep() deletes the histories of sessions that are gone and stale tmp files, nothing else', async () => { + for (const name of [ + 'live.json', + 'gone.json', + `gone.json.${process.pid + 1}.tmp`, + // An earlier run that had this process's pid (a container's pid 1): stale as well. + `live.json.${process.pid}.tmp`, + `live.json.${process.pid}-0a1b2c3d.tmp`, + `live.json.${TMP_TAG}.tmp`, + 'notes.txt', + ]) { + writeFileSync(join(dir, name), '{}'); + } + expect(await EditHistoryStore.sweep((id) => id === 'live', { dir })).toBe(1); + expect(readdirSync(dir).sort()).toEqual([`live.json.${TMP_TAG}.tmp`, 'live.json', 'notes.txt'].sort()); + expect(await EditHistoryStore.sweep(() => false, { dir: join(dir, 'missing') })).toBe(0); + }); + + it('takes only a plain transcript id from the file (the sync builds a path from it)', async () => { + const cursors = { main: { offset: 10, floor: 0 } }; + for (const transcriptId of ['../../etc/passwd', 'a/b', '', 7]) { + writeFileSync(join(dir, 's1.json'), JSON.stringify({ version: 1, transcriptId, cursors, entries: [] })); + const store = await EditHistoryStore.load('s1', { dir }); + expect(store.getTranscriptId()).toBeNull(); + expect(store.knownSources()).toEqual([]); // offsets into no known conversation + } + writeFileSync(join(dir, 's1.json'), JSON.stringify({ version: 1, transcriptId: 'conv-1.a_b', cursors })); + const store = await EditHistoryStore.load('s1', { dir }); + expect(store.getTranscriptId()).toBe('conv-1.a_b'); + expect(store.knownSources()).toEqual(['main']); + }); + + it('settle() writes pending changes and nothing for a store without any', async () => { + const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 }); + syncForward(store, jsonl([editLine('e1', 1)])); + await store.settle(); + expect((await EditHistoryStore.load('s1', { dir })).get('e1')).toBeDefined(); + const loaded = await EditHistoryStore.load('s1', { dir }); + rmSync(join(dir, 's1.json')); + await loaded.settle(); + await new EditHistoryStore('empty', { dir }).settle(); + expect(readdirSync(dir)).toEqual([]); + }); + + it('delete() removes a stored history without parsing it', async () => { + writeFileSync(join(dir, 's1.json'), 'not json'); + await EditHistoryStore.delete('s1', { dir }); + await EditHistoryStore.delete('missing', { dir }); + expect(readdirSync(dir)).toEqual([]); + }); + + it('ignores a file of an unknown version', async () => { + writeFileSync(join(dir, 'v9.json'), JSON.stringify({ version: 9, entries: [{ id: 'x' }] })); + expect((await EditHistoryStore.load('v9', { dir })).list()).toEqual([]); + }); + + it('writes debounced', async () => { + const store = new EditHistoryStore('s1', { dir, debounceMs: 20 }); + syncForward(store, jsonl([editLine('e1', 1)])); + const path = join(dir, 's1.json'); + expect(existsSync(path)).toBe(false); + await new Promise((r) => setTimeout(r, 80)); + expect(JSON.parse(readFileSync(path, 'utf-8')).entries).toHaveLength(1); + }); + + it('starts empty from a missing or corrupt file', async () => { + expect((await EditHistoryStore.load('none', { dir })).list()).toEqual([]); + const store = new EditHistoryStore('bad', { dir }); + await store.flush(); + writeFileSync(join(dir, 'bad.json'), '{broken'); + expect((await EditHistoryStore.load('bad', { dir })).list()).toEqual([]); + }); + + it('remove() deletes the file and stops later writes', async () => { + const store = new EditHistoryStore('s1', { dir, debounceMs: 10 }); + syncForward(store, jsonl([editLine('e1', 1)])); + await store.flush(); + await store.remove(); + expect(existsSync(join(dir, 's1.json'))).toBe(false); + syncForward(store, jsonl([editLine('e1', 1), editLine('e2', 2)])); + await new Promise((r) => setTimeout(r, 40)); + await store.flush(); + expect(existsSync(join(dir, 's1.json'))).toBe(false); + expect(store.list()).toEqual([]); // the content is forgotten too + }); + + it('remove() during a write leaves nothing behind (the write cannot rename over the deleted file)', async () => { + const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 }); + syncForward(store, jsonl([editLine('e1', 1)])); + const writing = store.flush(); + // Let the write get past its first check, so remove() meets it under way. + await new Promise((r) => setImmediate(r)); + await Promise.all([writing, store.remove()]); + expect(readdirSync(dir)).toEqual([]); + }); + + it('defaults to the data directory', () => { + const store = new EditHistoryStore('s1'); + expect((store as unknown as { file: string }).file).toMatch(/edit-history[\\/]s1\.json$/); + }); +}); diff --git a/test/edit-history-sync.test.ts b/test/edit-history-sync.test.ts new file mode 100644 index 00000000..60cd745b --- /dev/null +++ b/test/edit-history-sync.test.ts @@ -0,0 +1,699 @@ +/** + * Edit history sync: reader, store, image confirmation, single-flight, failure states and the + * "reads only on request" wiring. Reader and probe are fakes; the store is real (temp dir). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { EditHistoryStore } from '../src/edit-history-store.js'; +import type { + EditSourceReadOptions, + EditSourceRequest, + EditSources, + PathProbe, +} from '../src/edit-history-local-source.js'; +import { + confirmImages, + EditHistorySync, + MAX_LOADED_STORES, + MAX_OLDER_SOURCES, + NO_TRANSCRIPT, + REMOVED, + type EditHistorySession, + type EditHistorySyncDeps, + unsupportedReason, +} from '../src/web/edit-history-sync.js'; + +let dir: string; +/** Stores the tests opened; removed before the temp dir, so no late save recreates it. */ +const opened: EditHistoryStore[] = []; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'edit-sync-')); +}); +afterEach(async () => { + await Promise.all(opened.splice(0).map((s) => s.remove())); + rmSync(dir, { recursive: true, force: true }); +}); + +const T0 = Date.parse('2026-10-15T20:00:00.000Z'); + +function editLine(id: string, ts: number, path: string, extra: Record<string, unknown> = {}): string { + return ( + JSON.stringify({ + type: 'user', + timestamp: new Date(ts).toISOString(), + cwd: '/w', + message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] }, + toolUseResult: { + filePath: path, + oldString: 'a', + newString: 'b', + structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }], + }, + ...extra, + }) + '\n' + ); +} + +function bashPair(id: string, start: number, end: number, command: string): string { + const use = JSON.stringify({ + type: 'assistant', + timestamp: new Date(start).toISOString(), + cwd: '/w', + message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] }, + }); + const res = JSON.stringify({ + type: 'user', + timestamp: new Date(end).toISOString(), + cwd: '/w', + message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: '' }] }, + toolUseResult: { stdout: '', stderr: '', interrupted: false, isImage: false }, + }); + return use + '\n' + res + '\n'; +} + +/** An in-memory transcript: files by relPath, served with the reader contract. */ +class FakeHost { + transcriptId = 'conv1'; + files = new Map<string, Buffer>(); + metas: Record<string, string> = {}; + calls: EditSourceRequest[][] = []; + paths: string[] = []; + /** What `transcriptPath` answers (the transcript watcher's path); null = none yet. */ + get path(): string | null { + return this.files.size > 0 ? `/p/${this.transcriptId}.jsonl` : null; + } + read = async ( + mainPath: string, + requests: readonly EditSourceRequest[], + budget: number, + opts: EditSourceReadOptions = {} + ): Promise<EditSources | null> => { + this.calls.push([...requests]); + this.paths.push(mainPath); + if (this.files.size === 0) return null; + let remaining = budget; + const out: EditSources = { transcriptId: this.transcriptId, files: [], metas: {} }; + for (const [relPath, buf] of this.files) { + const req = requests.find((r) => r.relPath === relPath); + if (!req && opts.onlyRequested) continue; + let start: number; + let len: number; + if (req?.len !== undefined) { + const blockEnd = Math.min(req.offset + req.len, buf.length); + len = Math.min(blockEnd - Math.min(req.offset, buf.length), remaining); + start = blockEnd - len; + } else if (req) { + start = Math.min(req.offset, buf.length); + len = Math.min(remaining, buf.length - start); + } else { + len = Math.min(buf.length, remaining); + start = buf.length - len; + const agent = /^subagents\/agent-(.+)\.jsonl$/.exec(relPath)?.[1]; + if (agent && this.metas[agent]) out.metas[agent] = this.metas[agent]; + } + remaining -= len; + out.files.push({ relPath, size: buf.length, startOffset: start, chunk: buf.subarray(start, start + len) }); + } + return out; + }; + append(relPath: string, text: string): void { + this.files.set(relPath, Buffer.concat([this.files.get(relPath) ?? Buffer.alloc(0), Buffer.from(text)])); + } +} + +function setup(over: Partial<EditHistorySyncDeps> = {}) { + const host = new FakeHost(); + const probes = new Map<string, PathProbe>(); + const lookups: string[] = []; + /** Session ids in the order their stores were loaded. */ + const loads: string[] = []; + const deps: EditHistorySyncDeps = { + transcriptPath: async (session) => { + lookups.push(session.id); + return host.path; + }, + readSources: (path, req, budget, opts) => host.read(path, req, budget, opts), + probe: async (paths) => paths.map((p) => probes.get(p) ?? null), + loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 0 }), + deleteStore: (id) => EditHistoryStore.delete(id, { dir }), + sweepStores: async () => 0, + ...over, + }; + const load = deps.loadStore; + deps.loadStore = async (id) => { + loads.push(id); + const store = await load(id); + opened.push(store); + return store; + }; + return { host, probes, lookups, loads, deps, sync: new EditHistorySync(deps) }; +} + +const local: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'conv1' }; +const remote: EditHistorySession = { + id: 's2', + mode: 'claude', + remote: { host: 'huff' } as EditHistorySession['remote'], + claudeSessionId: 'conv1', +}; + +describe('unsupportedReason', () => { + it('accepts a local claude session, rejects remote, docker and non-claude CLIs', () => { + expect(unsupportedReason(local)).toBeUndefined(); + expect(unsupportedReason(remote)).toBe('remote'); + expect(unsupportedReason({ ...local, docker: { image: 'x' } })).toBe('docker'); + expect(unsupportedReason({ ...local, mode: 'shell' })).toBe('mode'); + }); +}); + +describe('EditHistorySync', () => { + it('reads new edits forward from the stored offset', async () => { + const { host, sync } = setup(); + host.append('main', editLine('t1', T0, '/w/a.ts')); + expect(await sync.sync(local)).toEqual({ state: 'ok' }); + host.append('main', editLine('t2', T0 + 1000, '/w/b.ts')); + await sync.sync(local); + expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t2', 't1']); + expect(host.calls[1]).toEqual([ + { relPath: 'main', offset: host.files.get('main')!.length - editLine('t2', T0 + 1000, '/w/b.ts').length }, + ]); + expect(host.paths).toEqual(['/p/conv1.jsonl', '/p/conv1.jsonl']); + }); + + it('reads subagent transcripts and records their meta', async () => { + const { host, sync } = setup(); + host.append('main', editLine('t1', T0, '/w/a.ts')); + host.append('subagents/agent-a1.jsonl', editLine('t9', T0 + 5, '/w/sub.ts', { isSidechain: true, agentId: 'a1' })); + host.metas.a1 = '{"description":"Fix it","agentType":"coding"}'; + await sync.sync(local); + const store = await sync.getStore('s1'); + expect(store.get('t9')?.agentId).toBe('a1'); + expect(store.getAgents().a1).toEqual({ description: 'Fix it', agentType: 'coding' }); + }); + + it('reads a new conversation (/clear, resume) from its end, keeping the entries', async () => { + const { host, sync } = setup(); + host.append('main', editLine('t1', T0, '/w/a.ts')); + await sync.sync(local); + host.transcriptId = 'conv2'; + host.files.set('main', Buffer.from(editLine('n1', T0 + 9, '/w/n.ts'))); + await sync.sync(local); + expect(host.paths.at(-1)).toBe('/p/conv2.jsonl'); + expect(host.calls.at(-1)).toEqual([]); // no offsets of the old file + const store = await sync.getStore('s1'); + expect(store.getTranscriptId()).toBe('conv2'); + expect(store.list().map((e) => e.id)).toEqual(['n1', 't1']); + }); + + it('keeps reading in passes when a file grew by more than one budget', async () => { + const { host, sync } = setup({ budget: 600 }); + host.append('main', editLine('t0', T0, '/w/z.ts')); + await sync.sync(local); + for (let i = 1; i <= 3; i++) host.append('main', editLine(`t${i}`, T0 + i, `/w/${i}.ts`)); + await sync.sync(local); + expect((await sync.getStore('s1')).list()).toHaveLength(4); + }); + + it('turns a Bash image into an entry only when its mtime lies in the call window', async () => { + const { host, probes, sync } = setup(); + host.append('main', bashPair('b1', T0, T0 + 5000, 'render out/new.png old.png ~/x.png')); + probes.set('/w/out/new.png', { realPath: '/w/out/new.png', kind: 'file', size: 9, mtimeMs: T0 + 4000 }); + probes.set('/w/old.png', { realPath: '/w/old.png', kind: 'file', size: 9, mtimeMs: T0 - 60_000 }); + probes.set('~/x.png', { realPath: '/home/u/x.png', kind: 'file', size: 9, mtimeMs: T0 + 6500 }); + await sync.sync(local); + expect((await sync.getStore('s1')).list().map((e) => [e.path, e.tool, e.kind])).toEqual([ + ['/w/out/new.png', 'bash', 'image'], + ['/home/u/x.png', 'bash', 'image'], + ]); + }); + + it('a removal during a read stops it: no further parsing, no probe of printed paths', async () => { + const probe = vi.fn(async (paths: readonly string[]) => paths.map(() => null)); + let removal: Promise<void> | undefined; + const ctx = setup({ + probe, + readSources: async (path, req, budget, opts) => { + const res = await ctx.host.read(path, req, budget, opts); + removal = ctx.sync.remove('s1'); // the session is deleted while this read is out + return res; + }, + }); + ctx.host.append('main', bashPair('b1', T0, T0 + 5000, 'render out/a.png')); + ctx.host.append('subagents/agent-a1.jsonl', bashPair('b2', T0, T0 + 5000, 'render out/b.png')); + await ctx.sync.sync(local); + await removal; + expect(probe).not.toHaveBeenCalled(); + }); + + it('keeps the edits when only the image probe fails', async () => { + const { host, sync } = setup({ + probe: async () => { + throw new Error('probe failed'); + }, + }); + host.append('main', editLine('t1', T0, '/w/a.ts') + bashPair('b1', T0, T0 + 1, 'x out.png')); + expect(await sync.sync(local)).toEqual({ state: 'ok' }); + expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t1']); + }); + + it('reports a session without a transcript yet, then reads it once there is one', async () => { + const { host, sync } = setup(); + expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: NO_TRANSCRIPT }); + expect(host.calls).toEqual([]); + host.append('main', editLine('t1', T0, '/w/a.ts')); + expect(await sync.sync(local)).toEqual({ state: 'ok' }); + expect((await sync.getStore('s1')).list()).toHaveLength(1); + }); + + it('reports a transcript that vanished (watcher path stale) the same way', async () => { + const { host, sync } = setup({ transcriptPath: async () => '/p/gone.jsonl' }); + expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: NO_TRANSCRIPT }); + expect(host.paths).toEqual(['/p/gone.jsonl']); + }); + + it('reports a read error as unavailable and keeps the stored entries', async () => { + const { host, sync, deps } = setup(); + host.append('main', editLine('t1', T0, '/w/a.ts')); + await sync.sync(local); + deps.readSources = async () => { + throw new Error('EACCES: permission denied'); + }; + const status = await sync.sync(local); + expect(status).toEqual({ state: 'unavailable', reason: 'EACCES: permission denied' }); + expect((await sync.getStore('s1')).list()).toHaveLength(1); + }); + + it('marks remote, docker and non-claude sessions unsupported without looking for a transcript', async () => { + const { host, lookups, sync } = setup(); + host.append('main', editLine('t1', T0, '/w/a.ts')); + expect(await sync.sync({ ...local, mode: 'shell' })).toEqual({ state: 'unsupported', reason: 'mode' }); + expect(await sync.sync(remote)).toEqual({ state: 'unsupported', reason: 'remote' }); + expect(await sync.sync({ ...local, docker: {} })).toEqual({ state: 'unsupported', reason: 'docker' }); + expect(lookups).toEqual([]); + expect(host.calls).toEqual([]); + }); + + it('runs one sync per session at a time and folds occasions into one more run', async () => { + const { host, sync } = setup(); + host.append('main', editLine('t1', T0, '/w/a.ts')); + let release!: () => void; + const gate = new Promise<void>((r) => (release = r)); + const orig = host.read; + host.read = async (...a) => { + await gate; + return orig(...a); + }; + const runs = [sync.sync(local), sync.sync(local), sync.sync(local)]; + release(); + await Promise.all(runs); + expect(host.calls).toHaveLength(2); // the first run + one folded follow-up + }); + + it('loads older blocks on request', async () => { + const { host, sync } = setup({ budget: 400 }); + for (let i = 0; i < 4; i++) host.append('main', editLine(`t${i}`, T0 + i, `/w/${i}.ts`)); + await sync.sync(local); + const store = await sync.getStore('s1'); + const before = store.list().length; + expect(store.backfillTruncated).toBe(true); + await sync.sync(local, { older: true }); + expect(store.list().length).toBeGreaterThan(before); + }); + + it('flushAll writes pending saves (shutdown) so a reload keeps the entries', async () => { + const { host, sync } = setup({ loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 60_000 }) }); + host.append('main', editLine('t1', T0, '/w/a.ts')); + await sync.sync(local); + await sync.flushAll(); + const reloaded = await EditHistoryStore.load('s1', { dir }); + expect(reloaded.list().map((e) => e.id)).toEqual(['t1']); + }); + + it('remove deletes the stored history', async () => { + const { host, sync } = setup(); + host.append('main', editLine('t1', T0, '/w/a.ts')); + await sync.sync(local); + await (await sync.getStore('s1')).flush(); + await sync.remove('s1'); + expect(() => readFileSync(join(dir, 's1.json'))).toThrow(); + }); + + it('neither a run in flight nor a request during the removal writes the deleted history again', async () => { + const { host, sync } = setup(); + host.append('main', editLine('t1', T0, '/w/a.ts')); + let release!: () => void; + const gate = new Promise<void>((r) => (release = r)); + let entered!: () => void; + const reading = new Promise<void>((r) => (entered = r)); + const orig = host.read; + host.read = async (...a) => { + entered(); + await gate; + return orig(...a); + }; + const runs = [sync.sync(local), sync.sync(local)]; // one running, one queued + await reading; + const removing = sync.remove('s1'); + // The removal waits for the read under way; a request meanwhile reads nothing. + expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: REMOVED }); + release(); + await Promise.all([...runs, removing]); + await new Promise((r) => setTimeout(r, 20)); // past the store's debounce + expect(readdirSync(dir)).toEqual([]); + expect(host.calls).toHaveLength(1); // the read under way; the queued run never read + }); + + it('the same conversation under an unnormalized path reads on from its cursors, nothing else', async () => { + const path = '/p/./conv1.jsonl'; + const { host, sync } = setup({ transcriptPath: async () => path }); + host.append('main', editLine('t1', T0, '/w/a.ts')); + await sync.sync(local); + host.append('main', editLine('t2', T0 + 1, '/w/a.ts')); + await sync.sync(local); + expect(host.paths).toEqual([path, path]); + expect(host.calls[1]).toEqual([ + { relPath: 'main', offset: host.files.get('main')!.length - editLine('t2', T0 + 1, '/w/a.ts').length }, + ]); + }); + + it('a session that comes back with the same id (reboot restore) reads its history afresh', async () => { + const { host, sync } = setup(); + host.append('main', editLine('t1', T0, '/w/a.ts')); + await sync.sync(local); + // Boot: the unpinned record is pruned as stale; then "restore" brings the session back. + await sync.remove('s1'); + expect(await sync.sync(local)).toEqual({ state: 'ok' }); + expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t1']); + }); + + it('remove deletes a history it has not loaded without loading it', async () => { + const { sync, loads } = setup(); + const other = new EditHistoryStore('s1', { dir }); + other.addImageEntries([{ toolUseId: 'b1', path: '/w/a.png', ts: T0 }]); + await other.flush(); + await sync.remove('s1'); + expect(loads).toEqual([]); + expect(existsSync(join(dir, 's1.json'))).toBe(false); + }); + + it(`keeps at most ${MAX_LOADED_STORES} stores loaded: a dropped one is written and loads again whole`, async () => { + const { host, sync, loads } = setup({ loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 60_000 }) }); + host.append('main', editLine('t1', T0, '/w/a.ts')); + const ids = Array.from({ length: MAX_LOADED_STORES + 2 }, (_, i) => `s${i}`); + for (const id of ids) await sync.sync({ ...local, id }); + expect(loads).toEqual(ids); + // The two least recently used went: written (no debounced save ran), not kept in memory. + await sync.flushAll(); + expect(existsSync(join(dir, 's0.json'))).toBe(true); + expect((await sync.getStore('s0')).list().map((e) => e.id)).toEqual(['t1']); + expect(loads.slice(ids.length)).toEqual(['s0']); + // The most recently used ones stay loaded. + await sync.getStore(ids[ids.length - 1]); + expect(loads.slice(ids.length)).toEqual(['s0']); + }); + + it('a dropped store that never changed writes nothing (no file for a session without edits)', async () => { + const { sync } = setup(); // no transcript: nothing to read + for (let i = 0; i <= MAX_LOADED_STORES; i++) await sync.sync({ ...local, id: `s${i}` }); + await sync.flushAll(); + expect(readdirSync(dir)).toEqual([]); + }); + + it('a store whose run is in flight is not dropped, and loads once', async () => { + let release!: () => void; + const gate = new Promise<void>((r) => (release = r)); + const { host, sync, loads } = setup(); + host.append('main', editLine('t1', T0, '/w/a.ts')); + const orig = host.read; + let first = true; + let inRead!: () => void; + const reachedRead = new Promise<void>((r) => (inRead = r)); + host.read = async (...a) => { + if (first) { + first = false; + inRead(); + await gate; + } + return orig(...a); + }; + const slow = sync.sync(local); + // Only once s1 holds the gate: the sessions load their stores independently, so a later one + // could reach the read first, take the gate and hang the loop below before `release()`. + await reachedRead; + for (let i = 1; i <= MAX_LOADED_STORES + 1; i++) await sync.sync({ ...local, id: `s1-${i}` }); + release(); + expect(await slow).toEqual({ state: 'ok' }); + expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t1']); + expect(loads.filter((id) => id === 's1')).toEqual(['s1']); + }); + + it('concurrent first callers share one store', async () => { + const { sync } = setup(); + const [a, b] = await Promise.all([sync.getStore('s1'), sync.getStore('s1')]); + expect(a).toBe(b); + }); + + it('a forward request queued together with an older one still reads forward', async () => { + const { host, sync } = setup(); + host.append('main', editLine('t1', T0, '/w/a.ts')); + let release!: () => void; + const gate = new Promise<void>((r) => (release = r)); + const orig = host.read; + host.read = async (...a) => { + const res = await orig(...a); // the first run's bytes are taken before the append + await gate; + return res; + }; + const first = sync.sync(local); + await new Promise((r) => setTimeout(r, 0)); + host.append('main', editLine('t2', T0 + 1, '/w/b.ts')); + const queued = [sync.sync(local), sync.sync(local, { older: true })]; + release(); + await Promise.all([first, ...queued]); + expect((await sync.getStore('s1')).get('t2')).toBeDefined(); + }); + + it('load older moves every source back, reading only the blocks it asked for', async () => { + const { host, sync } = setup({ budget: 2000 }); + for (let i = 0; i < 10; i++) host.append('main', editLine(`m${i}`, T0 + i, `/w/m${i}.ts`)); + for (let i = 0; i < 10; i++) { + host.append('subagents/agent-ab.jsonl', editLine(`s${i}`, T0 + 100 + i, `/w/s${i}.ts`, { agentId: 'ab' })); + } + await sync.sync(local); + await sync.sync(local); // the subagent gets its first block once main is up to date + const store = await sync.getStore('s1'); + const floors = () => [store.getCursor('main')!.floor, store.getCursor('subagents/agent-ab.jsonl')!.floor]; + const [main0, sub0] = floors(); + expect(main0).toBeGreaterThan(0); + expect(sub0).toBeGreaterThan(0); + await sync.sync(local, { older: true }); + const [main1, sub1] = floors(); + expect(main1).toBeLessThan(main0); + expect(sub1).toBeLessThan(sub0); + expect( + host.calls + .at(-1)! + .map((r) => r.relPath) + .sort() + ).toEqual(['main', 'subagents/agent-ab.jsonl']); + for (let i = 0; i < 20 && store.backfillTruncated; i++) await sync.sync(local, { older: true }); + expect(store.list()).toHaveLength(20); + }); + + it('a file the shared budget cut short is read in the next pass, not skipped as one long line', async () => { + const budget = 4000; + const { host, sync } = setup({ budget }); + const sub = 'subagents/agent-a1.jsonl'; + host.append('main', editLine('m0', T0, '/w/m0.ts')); + host.append(sub, editLine('s0', T0 + 1, '/w/s0.ts', { agentId: 'a1' })); + await sync.sync(local); + // main grows by almost a whole budget, leaving the subagent 50 bytes of this pass + const filler = JSON.stringify({ type: 'user', message: { content: '' } }); + host.append( + 'main', + JSON.stringify({ type: 'user', message: { content: 'z'.repeat(budget - 51 - filler.length) } }) + '\n' + ); + host.append(sub, editLine('s1', T0 + 2, '/w/s1.ts', { agentId: 'a1', pad: 'p'.repeat(100) })); + host.append(sub, editLine('s2', T0 + 3, '/w/s2.ts', { agentId: 'a1' })); + await sync.sync(local); + const store = await sync.getStore('s1'); + expect( + store + .list() + .map((e) => e.id) + .sort() + ).toEqual(['m0', 's0', 's1', 's2']); + expect(store.getCursor(sub)?.midLine).toBeUndefined(); + }); + + it('a first read reaches the subagents in a further pass when main fills the budget', async () => { + const { host, sync } = setup({ budget: 3000 }); + for (let i = 0; i < 20; i++) host.append('main', editLine(`m${i}`, T0 + i, `/w/m${i}.ts`)); + host.append('subagents/agent-a1.jsonl', editLine('s0', T0 + 100, '/w/s0.ts', { agentId: 'a1' })); + await sync.sync(local); + const store = await sync.getStore('s1'); + expect(store.get('s0')?.agentId).toBe('a1'); + expect(store.getCursor('subagents/agent-a1.jsonl')).toBeDefined(); + }); + + it('load older reads a full budget per source: a line longer than the budget split over the sources is kept', async () => { + const budget = 2000; + const { host, sync } = setup({ budget }); + host.append('main', editLine('m0', T0, '/w/m0.ts')); + host.append('main', editLine('big', T0 + 1, '/w/big.ts', { pad: 'p'.repeat(800) })); // > budget / 3 + for (let i = 2; i < 12; i++) host.append('main', editLine(`m${i}`, T0 + i, `/w/m${i}.ts`)); + for (const a of ['a1', 'a2']) { + for (let i = 0; i < 12; i++) { + host.append( + `subagents/agent-${a}.jsonl`, + editLine(`${a}-${i}`, T0 + 100 + i, `/w/${a}${i}.ts`, { agentId: a }) + ); + } + } + for (let i = 0; i < 3; i++) await sync.sync(local); + const store = await sync.getStore('s1'); + expect(store.sourcesWithOlder()).toHaveLength(3); + for (let i = 0; i < 20 && store.backfillTruncated; i++) await sync.sync(local, { older: true }); + expect(store.get('big')).toBeDefined(); + expect(store.list()).toHaveLength(36); + }); + + it(`load older reads at most ${MAX_OLDER_SOURCES} sources per click, taking turns`, async () => { + const { host, sync } = setup({ budget: 600 }); + const subs = ['a1', 'a2', 'a3', 'a4'].map((a) => `subagents/agent-${a}.jsonl`); + for (const relPath of ['main', ...subs]) { + for (let i = 0; i < 8; i++) host.append(relPath, editLine(`${relPath}-${i}`, T0 + i, `/w/${i}.ts`)); + } + for (let i = 0; i < 3; i++) await sync.sync(local); + const store = await sync.getStore('s1'); + expect(store.sourcesWithOlder()).toHaveLength(5); + const olderCall = () => host.calls.at(-1)!.map((r) => r.relPath); + await sync.sync(local, { older: true }); + expect(olderCall()).toEqual(['main', subs[0], subs[1]]); + await sync.sync(local, { older: true }); + expect(olderCall()).toEqual([subs[2], subs[3], 'main']); + for (let i = 0; i < 30 && store.backfillTruncated; i++) await sync.sync(local, { older: true }); + expect(store.list()).toHaveLength(40); + }); + + it('a subagent transcript that vanished loses its cursor (no endless "Load older"), not its entries', async () => { + const { host, sync } = setup({ budget: 600 }); + const sub = 'subagents/agent-a1.jsonl'; + host.append('main', editLine('m0', T0, '/w/m0.ts')); + for (let i = 0; i < 8; i++) host.append(sub, editLine(`s${i}`, T0 + 10 + i, `/w/s${i}.ts`, { agentId: 'a1' })); + for (let i = 0; i < 2; i++) await sync.sync(local); + const store = await sync.getStore('s1'); + expect(store.backfillTruncated).toBe(true); + const kept = store.list().length; + host.files.delete(sub); + await sync.sync(local, { older: true }); + expect(store.getCursor(sub)).toBeUndefined(); + expect(store.backfillTruncated).toBe(false); + expect(store.list()).toHaveLength(kept); + }); + + it('sweep keeps the sessions in use here and those that exist', async () => { + const asked: Record<string, boolean> = {}; + const { sync } = setup({ + sweepStores: async (keep) => { + for (const id of ['s1', 'kept', 'gone']) asked[id] = keep(id); + return 1; + }, + }); + await sync.getStore('s1'); + expect(await sync.sweep((id) => id === 'kept')).toBe(1); + expect(asked).toEqual({ s1: true, kept: true, gone: false }); + }); +}); + +describe('confirmImages', () => { + it('ignores directories and missing files', () => { + const c = [{ toolUseId: 'b', start: T0, end: T0 + 1, paths: ['/d.png', '/m.png'] }]; + const out = confirmImages(c, (p) => + p === '/d.png' ? { realPath: p, kind: 'directory', size: 0, mtimeMs: T0 } : null + ); + expect(out).toEqual([]); + }); + + it('lists a file sent to the user outside any window, once per file version', () => { + const png = { realPath: '/w/p.png', kind: 'file' as const, size: 9, mtimeMs: T0 }; + const sent = { toolUseId: 's', start: T0 + 9000, end: T0 + 9000, paths: ['/w/p.png'], sent: true }; + expect(confirmImages([sent], () => png)).toEqual([ + { toolUseId: 's', path: '/w/p.png', ts: T0 + 9000, tool: 'sent' }, + ]); + // The Bash call that wrote it was already listed: same version, not again. + expect( + confirmImages( + [sent], + () => png, + () => T0 + 500 + ) + ).toEqual([]); + expect(confirmImages([sent], () => null)).toEqual([]); + }); + + it('skips a file version already recorded: the ls right after the render, a listing after a Write', () => { + const png = { realPath: '/w/p.png', kind: 'file' as const, size: 9, mtimeMs: T0 + 900 }; + const render = { toolUseId: 'r', start: T0, end: T0 + 1000, paths: ['/w/p.png'] }; + const ls = { toolUseId: 'l', start: T0 + 1500, end: T0 + 1600, paths: ['/w/p.png'] }; + expect(confirmImages([ls, render], () => png).map((i) => i.toolUseId)).toEqual(['r']); + // A Write entry for the same path at T0 + 950 already covers it. + expect( + confirmImages( + [ls], + () => png, + () => T0 + 950 + ) + ).toEqual([]); + // A later re-render (newer mtime, outside the slack) is a new version. + const again = { toolUseId: 'r2', start: T0 + 10_000, end: T0 + 11_000, paths: ['/w/p.png'] }; + const newer = { ...png, mtimeMs: T0 + 10_500 }; + expect( + confirmImages( + [again], + () => newer, + () => T0 + 1000 + ).map((i) => i.toolUseId) + ).toEqual(['r2']); + }); +}); + +describe('reads only on request (wiring guard)', () => { + const read = (file: string) => readFileSync(join(__dirname, '..', file), 'utf-8'); + + it('the server never starts a sync itself: it only hands the reader to the routes', () => { + const server = read('src/web/server.ts'); + const uses = [...server.matchAll(/this\.editHistorySync\b(\.\w+)?/g)].map((m) => m[1] ?? ''); + expect(uses.sort()).toEqual(['', '.flushAll', '.remove', '.sweep']); + expect(server).toMatch(/registerEditHistoryRoutes\(this\.app, ctx, this\.editHistorySync\)/); + }); + + it('no other module holds the reader, and the edit history modules run no timer or watcher', () => { + const root = join(__dirname, '..'); + const holders = readdirSync(join(root, 'src'), { recursive: true, encoding: 'utf-8' }) + .filter((f) => /\.(ts|js)$/.test(f)) + .map((f) => join('src', f).split('\\').join('/')) + .filter((f) => /EditHistorySync|editHistorySync/.test(read(f))); + expect(holders.sort()).toEqual([ + 'src/web/edit-history-sync.ts', + 'src/web/routes/edit-history-routes.ts', + 'src/web/server.ts', + ]); + for (const file of [ + 'src/web/edit-history-sync.ts', + 'src/edit-history-store.ts', + 'src/edit-history-local-source.ts', + 'src/edit-history-parse.ts', + 'src/web/routes/edit-history-routes.ts', + ]) { + const src = read(file); + expect(src, file).not.toMatch(/setInterval|\bwatch\(|chokidar/); + } + // The store's only timer is its debounced save. + expect(read('src/web/edit-history-sync.ts')).not.toMatch(/setTimeout/); + }); +}); + +vi.setConfig({ testTimeout: 10_000 }); diff --git a/test/edit-history.browser.test.ts b/test/edit-history.browser.test.ts new file mode 100644 index 00000000..d23fa953 --- /dev/null +++ b/test/edit-history.browser.test.ts @@ -0,0 +1,483 @@ +/** + * @fileoverview Edit history button and panel, end to end in a real browser: real server, real + * Chromium. The edits API is mocked with `page.route` (the UI is built against its contract); the + * thumbnails (workspace `file-raw`, the attachment route outside it) and the transcript SSE + * events the open panel follows go through the real server. + */ +import { homedir } from 'node:os'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { chromium, type Browser, type Page, type Route } from 'playwright'; +import { WebServer } from '../src/web/server.js'; + +/** A 2×2 red PNG, so the thumbnail has real pixels to decode. */ +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEElEQVR4nGP4z8AARAwQCgAf7gP9i18U1AAAAABJRU5ErkJggg==', + 'base64' +); + +type Entry = { + id: string; + ts: number; + path: string; + relPath: string; + kind: 'code' | 'image'; + tool: string; + created: boolean; + added: number; + removed: number; + agentId?: string; +}; + +describe('Edit history panel in a real browser', () => { + let server: WebServer; + let browser: Browser; + let page: Page; + let root: string; + /** A directory outside the session's workspace, for the attachment-route thumbnail. */ + let ext: string; + let sessionA: string; + let sessionB: string; + const settingsPutStatuses: number[] = []; + /** Bodies of POST /api/sessions/:id/attachments (the file preview registering an external image). */ + const attachmentPosts: unknown[] = []; + /** Every request to the mocked API: `METHOD url`. */ + const calls: string[] = []; + /** The mocked server state per session id. */ + const state = new Map<string, Record<string, unknown>>(); + let diffs: Record<string, { diff: string; truncated: boolean }> = {}; + let olderEntries: Entry[] = []; + + const entry = (over: Partial<Entry> & { id: string; relPath: string }): Entry => ({ + ts: Date.now(), + path: join(root, over.relPath), + kind: 'code', + tool: 'Edit', + created: false, + added: 1, + removed: 1, + ...over, + }); + + const json = (route: Route, data: unknown) => + route.fulfill({ contentType: 'application/json', body: JSON.stringify({ success: true, data }) }); + + const buttonVisible = () => + page.evaluate(() => getComputedStyle(document.getElementById('editHistoryBtn')!).display !== 'none'); + const bodyText = async () => (await page.textContent('#editHistoryBody')) ?? ''; + const setSetting = async (on: boolean) => { + await page.evaluate(() => (window as any).app.openAppSettings()); + if ((await page.isChecked('#appSettingsShowEditHistory')) !== on) + await page.click('label.set-chip:has(#appSettingsShowEditHistory)'); + await page.evaluate(() => (window as any).app.saveAppSettings()); + await page.waitForTimeout(300); + await page.evaluate(() => (window as any).app.closeAppSettings()); + }; + const createSession = (dir: string) => + page.evaluate(async (workingDir) => { + const res = await fetch('/api/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workingDir, mode: 'shell' }), + }); + const id = (await res.json()).data.session.id; + await fetch(`/api/sessions/${id}/shell`, { method: 'POST' }); + return id as string; + }, dir); + const select = (id: string) => + page.evaluate(async (sid) => { + const app = (window as any).app; + for (let i = 0; i < 100 && !app.sessions.has(sid); i++) await new Promise((r) => setTimeout(r, 100)); + await app.selectSession(sid); + }, id); + const reload = () => page.evaluate(() => (window as any).app.refreshEditHistory()); + const syncReads = (sid: string) => calls.filter((c) => c === `GET /api/sessions/${sid}/edits?sync=1`).length; + const toolEnd = (sessionId: string, toolName: string) => + (server as any).broadcast('transcript:tool_end', { sessionId, toolName, isError: false, timestamp: Date.now() }); + const rows = () => page.locator('#editHistoryBody .edit-history-entry'); + + beforeAll(async () => { + root = mkdtempSync(join(homedir(), 'edit-history-ui-')); + writeFileSync(join(root, 'shot.png'), PNG); + ext = mkdtempSync(join(homedir(), 'edit-history-ext-')); + writeFileSync(join(ext, 'render.png'), PNG); + + server = new WebServer(0, false, true); + await server.start(); + browser = await chromium.launch({ headless: true }); + page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + page.on('response', (r) => { + if (r.request().method() === 'PUT' && r.url().endsWith('/api/settings')) settingsPutStatuses.push(r.status()); + }); + page.on('request', (r) => { + if (r.method() === 'POST' && /\/api\/sessions\/[^/]+\/attachments$/.test(r.url())) + attachmentPosts.push(r.postDataJSON()); + }); + await page.route('**/api/sessions/*/edits**', async (route) => { + const req = route.request(); + const url = new URL(req.url()); + calls.push(`${req.method()} ${url.pathname}${url.search}`); + const m = url.pathname.match(/\/api\/sessions\/([^/]+)\/edits(?:\/(.+))?$/)!; + const sid = decodeURIComponent(m[1]); + const rest = m[2] ? decodeURIComponent(m[2]) : ''; + const s = state.get(sid) ?? { state: 'unsupported', backfillTruncated: false, agents: {}, entries: [] }; + if (req.method() === 'POST' && rest === 'older') { + const entries = [...(s.entries as Entry[]), ...olderEntries]; + state.set(sid, { ...s, entries, backfillTruncated: false }); + return json(route, state.get(sid)); + } + if (req.method() === 'GET' && rest) { + const d = diffs[rest]; + if (!d) return route.fulfill({ status: 404, contentType: 'application/json', body: '{"success":false}' }); + return json(route, d); + } + return json(route, s); + }); + await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 }); + sessionA = await createSession(root); + sessionB = await createSession(root); + await select(sessionA); + + const t0 = Date.now(); + state.set(sessionA, { + state: 'ok', + backfillTruncated: true, + agents: { + agent1: { description: 'Refactor the parser', agentType: 'general-purpose', worktreeBranch: 'wt/parser' }, + }, + entries: [ + entry({ id: 'e4', ts: t0, relPath: 'src/a.ts', added: 3, removed: 1 }), + entry({ id: 'e3', ts: t0 - 1000, relPath: 'shot.png', kind: 'image', tool: 'bash', created: true }), + entry({ + id: 'e2', + ts: t0 - 2000, + relPath: 'src/b.ts', + created: true, + tool: 'Write', + added: 10, + removed: 0, + agentId: 'agent1', + }), + entry({ id: 'e1', ts: t0 - 3000, relPath: 'src/a.ts', added: 2, removed: 2 }), + ], + }); + diffs = { + e4: { diff: '@@ -1,3 +1,3 @@\n context\n-old line\n+new line\n+another\n', truncated: true }, + e1: { diff: '@@ -5 +5 @@\n-x\n+y\n', truncated: false }, + }; + olderEntries = [entry({ id: 'e0', ts: t0 - 86_400_000, relPath: 'src/old.ts', added: 7, removed: 0 })]; + }, 120000); + + afterAll(async () => { + if (browser) await browser.close(); + if (server) await server.stop(); + rmSync(root, { recursive: true, force: true }); + rmSync(ext, { recursive: true, force: true }); + }, 60000); + + it('is off by default: no button, and nothing asks the edits API', async () => { + await page.waitForTimeout(1000); + expect(await buttonVisible()).toBe(false); + expect(calls).toEqual([]); + }); + + it('turning it on through Settings shows the button and saves cleanly (the key never reaches the strict PUT)', async () => { + await setSetting(true); + expect(settingsPutStatuses.length).toBeGreaterThan(0); + expect(settingsPutStatuses.every((s) => s === 200)).toBe(true); + expect(await buttonVisible()).toBe(true); + // In the header next to Attachments, where the panel opens; no longer in the bottom bar. + expect(await page.evaluate(() => !!document.querySelector('#headerRight #editHistoryBtn'))).toBe(true); + expect(await page.evaluate(() => !!document.querySelector('.toolbar #editHistoryBtn'))).toBe(false); + expect(calls).toEqual([]); // no poll: nothing is read until the panel opens + }); + + it('opens a panel listing the entries newest first, with the subagent chip and an image thumbnail', async () => { + await page.click('#editHistoryBtn'); + await page.waitForSelector('#editHistoryPanel.visible'); + await page.waitForSelector('#editHistoryBody .edit-history-entry'); + expect(calls[0]).toBe(`GET /api/sessions/${sessionA}/edits?sync=1`); + expect(await page.getAttribute('#editHistoryBtn', 'aria-expanded')).toBe('true'); + expect(await rows().count()).toBe(4); + const paths = await page.locator('#editHistoryBody .edit-history-path').allTextContents(); + expect(paths).toEqual(['src/a.ts', 'shot.png', 'src/b.ts', 'src/a.ts']); + expect(await rows().nth(0).locator('.edit-history-badge').textContent()).toBe('edit'); + expect(await rows().nth(2).locator('.edit-history-badge').textContent()).toBe('new'); + expect(await rows().nth(0).locator('.edit-history-stats').textContent()).toBe('+3−1'); + // The image row: a symbol instead of a word, no +/− counts. + expect(await rows().nth(1).locator('.edit-history-badge--image svg').count()).toBe(1); + expect(await rows().nth(1).locator('.edit-history-stats').count()).toBe(0); + // The subagent chip: its description, and type + branch on hover. + const chip = rows().nth(2).locator('.edit-history-agent'); + expect(await chip.textContent()).toBe('Refactor the parser'); + expect(await chip.getAttribute('title')).toBe('general-purpose · wt/parser'); + expect(await page.locator('#editHistoryBody .edit-history-agent').count()).toBe(1); + // The thumbnail is served by the real file-raw route and decodes. + const thumb = page.locator('#editHistoryBody img.edit-history-thumb'); + expect(await thumb.count()).toBe(1); + expect(await thumb.getAttribute('src')).toBe( + `/api/sessions/${sessionA}/file-raw?path=${encodeURIComponent(join(root, 'shot.png'))}` + ); + await page.waitForFunction( + () => (document.querySelector('img.edit-history-thumb') as HTMLImageElement)?.naturalWidth === 2 + ); + expect(await page.textContent('#editHistoryCount')).toBe('4'); + }); + + it('a code entry opens its diff inline with the Git panel line classes, and closes again', async () => { + await rows().nth(0).locator('.edit-history-spacer').click(); + await page.waitForSelector('#editHistoryBody .edit-history-diff pre.git-diff'); + expect(calls).toContain(`GET /api/sessions/${sessionA}/edits/e4`); + const lines = await page.$$eval('#editHistoryBody pre.git-diff .git-diff-line', (els) => + els.map((e) => [e.className.replace('git-diff-line', '').trim(), e.textContent]) + ); + expect(lines).toEqual([ + ['git-diff-line--hunk', '@@ -1,3 +1,3 @@\n'], + ['', ' context\n'], + ['git-diff-line--del', '-old line\n'], + ['git-diff-line--add', '+new line\n'], + ['git-diff-line--add', '+another\n'], + ]); + expect(await page.textContent('#editHistoryBody .edit-history-diff')).toContain('Diff cut short'); + expect(await rows().nth(0).getAttribute('aria-expanded')).toBe('true'); + // A re-read (what an SSE update does) keeps the open diff without fetching it again. + const before = calls.filter((c) => c.endsWith('/edits/e4')).length; + await reload(); + expect(await page.locator('#editHistoryBody pre.git-diff').count()).toBe(1); + expect(calls.filter((c) => c.endsWith('/edits/e4')).length).toBe(before); + await rows().nth(0).locator('.edit-history-spacer').click(); + expect(await page.locator('#editHistoryBody pre.git-diff').count()).toBe(0); + }); + + it('a path and an image open the shared file preview with the absolute path', async () => { + await page.evaluate(() => { + const w = window as any; + w.__previewed = []; + w.__openFilePreview = w.app.openFilePreview; + w.app.openFilePreview = (p: string, sid: string) => w.__previewed.push([p, sid]); + }); + await rows().nth(2).locator('.edit-history-path').click(); + await page.locator('#editHistoryBody img.edit-history-thumb').click(); + expect(await page.evaluate(() => (window as any).__previewed)).toEqual([ + [join(root, 'src/b.ts'), sessionA], + [join(root, 'shot.png'), sessionA], + ]); + // Clicking the path never toggles the row's diff. + expect(await page.locator('#editHistoryBody .edit-history-diff').count()).toBe(0); + await page.evaluate(() => { + const w = window as any; + w.app.openFilePreview = w.__openFilePreview; + }); + }); + + it('"Main agent only" hides the subagent edits; "Group by file" puts edits under their file', async () => { + await page.check('#editHistoryMainOnlyToggle'); + expect(await rows().count()).toBe(3); + expect(await page.locator('#editHistoryBody .edit-history-agent').count()).toBe(0); + expect(await bodyText()).not.toContain('src/b.ts'); + await page.uncheck('#editHistoryMainOnlyToggle'); + expect(await rows().count()).toBe(4); + + await page.check('#editHistoryGroupToggle'); + const groups = page.locator('#editHistoryBody .edit-history-group'); + expect(await groups.count()).toBe(3); + expect(await groups.nth(0).locator('.edit-history-group-head .edit-history-path').textContent()).toBe('src/a.ts'); + expect(await groups.nth(0).locator('.edit-history-entry').count()).toBe(2); + expect(await groups.nth(0).locator('.edit-history-group-head .edit-history-stats').textContent()).toBe('+5−3'); + await groups.nth(0).locator('.edit-history-entry').nth(1).locator('.edit-history-spacer').click(); + await page.waitForSelector('#editHistoryBody .git-diff-line--add'); + expect(await page.textContent('#editHistoryBody .git-diff-line--add')).toBe('+y\n'); + await page.uncheck('#editHistoryGroupToggle'); + }); + + it('"Load older" asks the server with a POST and shows what it returns', async () => { + const older = page.locator('#editHistoryBody button.edit-history-older'); + expect(await older.count()).toBe(1); + await older.click(); + await page.waitForFunction(() => + /src\/old\.ts/.test(document.getElementById('editHistoryBody')!.textContent ?? '') + ); + expect(calls).toContain(`POST /api/sessions/${sessionA}/edits/older`); + expect(await rows().count()).toBe(5); + expect(await page.locator('#editHistoryBody button.edit-history-older').count()).toBe(0); + }); + + it('while open it follows the session: an edit-capable tool end or a turn end re-reads (debounced), other tools, sessions and keep-alive turns do not', async () => { + const before = syncReads(sessionA); + const s = state.get(sessionA)!; + state.set(sessionA, { + ...s, + entries: [entry({ id: 'e5', relPath: 'src/fresh.ts', added: 4, removed: 0 }), ...(s.entries as Entry[])], + }); + toolEnd(sessionA, 'Read'); + toolEnd(sessionA, 'Grep'); + toolEnd('someone-else', 'Edit'); + await page.waitForTimeout(1000); + expect(syncReads(sessionA)).toBe(before); + toolEnd(sessionA, 'Edit'); + toolEnd(sessionA, 'Bash'); + await page.waitForFunction(() => + /src\/fresh\.ts/.test(document.getElementById('editHistoryBody')!.textContent ?? '') + ); + await page.waitForTimeout(800); + expect(syncReads(sessionA)).toBe(before + 1); + // The end of a turn re-reads too (a parallel tool call reports one tool end only, a background + // subagent writes later): `session:idle`, since claude's transcript has no `result` line. + // Not another session's, and not the end of Codeman's own keep-alive turn (it edits nothing). + (server as any).broadcast('session:idle', { id: 'someone-else' }); + (server as any).broadcast('session:idle', { id: sessionA, keepAlive: true }); + await page.waitForTimeout(1200); + expect(syncReads(sessionA)).toBe(before + 1); + (server as any).broadcast('session:idle', { id: sessionA }); + await page.waitForTimeout(1200); + expect(syncReads(sessionA)).toBe(before + 2); + }); + + it('while the panel is closed, transcript events read nothing; opening reads once', async () => { + await page.click('#editHistoryBtn'); + await page.waitForSelector('#editHistoryPanel:not(.visible)', { state: 'attached' }); + const before = syncReads(sessionA); + toolEnd(sessionA, 'Edit'); + (server as any).broadcast('session:idle', { id: sessionA }); + await page.waitForTimeout(1200); + expect(syncReads(sessionA)).toBe(before); + expect(await page.$('#editHistoryUnread')).toBeNull(); // no unread badge: closed means idle + await page.click('#editHistoryBtn'); + await page.waitForSelector('#editHistoryPanel.visible'); + await page.waitForFunction(() => document.querySelectorAll('#editHistoryBody .edit-history-entry').length > 0); + expect(syncReads(sessionA)).toBe(before + 1); + }); + + it('an image outside the workspace registers nothing until clicked, then previews through the attachment route quietly', async () => { + const s = state.get(sessionA)!; + const outside = join(ext, 'render.png'); + state.set(sessionA, { + ...s, + entries: [ + entry({ id: 'ext', path: outside, relPath: outside, kind: 'image', tool: 'bash', added: 0, removed: 0 }), + ...(s.entries as Entry[]), + ], + }); + await reload(); + const row = page.locator('#editHistoryBody .edit-history-entry', { hasText: 'render.png' }); + await row.waitFor(); + // No thumbnail for it (that would mint an attachment id per render), and no registration. + expect( + await page.evaluate(() => + [...document.querySelectorAll('#editHistoryBody img.edit-history-thumb')].map((i) => i.getAttribute('src')) + ) + ).not.toContainEqual(expect.stringContaining('render.png')); + await reload(); + expect(attachmentPosts).toEqual([]); + // A click opens the shared preview, which registers the file once, without a notification. + await row.click(); + await page.waitForSelector('#filePreviewOverlay.visible'); + expect(attachmentPosts).toEqual([{ path: outside, notify: false }]); + await page.evaluate(() => (window as any).app.closeFilePreview()); + state.set(sessionA, s); + }); + + it('a long path outside the workspace keeps its file name readable, the folder is cut at the start', async () => { + const s = state.get(sessionA)!; + const long = + '/tmp/claude-1000/-mnt-caches-ComfyUI/07e259af-d4d2-4ac3-8a7e-95972e3547df/scratchpad/icons/bremse_alt1_pedal.png'; + state.set(sessionA, { + ...s, + entries: [ + entry({ id: 'long', relPath: long, kind: 'image', tool: 'sent', added: 0, removed: 0 }), + ...(s.entries as Entry[]), + ], + }); + await reload(); + const row = rows().nth(0); + await row.waitFor(); + expect(await row.locator('.edit-history-path').textContent()).toBe(long); + const name = row.locator('.edit-history-path-name'); + expect(await name.textContent()).toBe('bremse_alt1_pedal.png'); + // The name is shown whole; only the folder part is shortened. + expect(await name.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true); + expect(await row.locator('.edit-history-path-dir').evaluate((el) => el.scrollWidth > el.clientWidth)).toBe(true); + state.set(sessionA, s); + }); + + it('renders a hostile path as text, never as markup', async () => { + const s = state.get(sessionA)!; + const evil = '<script>window.__pwned=1</script><img src=x onerror="window.__pwned=2">.ts'; + state.set(sessionA, { + ...s, + agents: { agent1: { description: '<b>bold</b>', agentType: 'x', worktreeBranch: '' } }, + entries: [entry({ id: 'evil', relPath: evil, agentId: 'agent1' }), ...(s.entries as Entry[])], + }); + await reload(); + await page.waitForFunction(() => /__pwned/.test(document.getElementById('editHistoryBody')!.textContent ?? '')); + expect(await rows().nth(0).locator('.edit-history-path').textContent()).toBe(evil); + expect(await rows().nth(0).locator('.edit-history-agent').textContent()).toBe('<b>bold</b>'); + expect(await page.$('#editHistoryBody script')).toBeNull(); + expect(await page.$('#editHistoryBody b')).toBeNull(); + expect( + await page.$$eval( + '#editHistoryBody img', + (els) => els.filter((e) => !e.classList.contains('edit-history-thumb')).length + ) + ).toBe(0); + expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined(); + }); + + it('unavailable: the saved entries stay, with the reason on top', async () => { + const s = state.get(sessionA)!; + state.set(sessionA, { ...s, state: 'unavailable', reason: 'EACCES: permission denied' }); + await reload(); + await page.waitForSelector('#editHistoryBody .edit-history-note--warn'); + expect(await page.textContent('#editHistoryBody .edit-history-note--warn')).toBe( + 'Showing saved entries only: EACCES: permission denied' + ); + expect(await rows().count()).toBeGreaterThan(0); + state.set(sessionA, { ...s, state: 'ok' }); + }); + + it('no transcript yet: says it comes with the next prompt, and names CLAUDE_CONFIG_DIR', async () => { + const s = state.get(sessionA)!; + state.set(sessionA, { ...s, state: 'unavailable', reason: 'no-transcript', entries: [] }); + await reload(); + await page.waitForSelector('#editHistoryBody .edit-history-note--warn'); + const note = (await page.textContent('#editHistoryBody .edit-history-note--warn')) ?? ''; + expect(note).toContain('No transcript found for this session yet'); + expect(note).toContain('CLAUDE_CONFIG_DIR'); + state.set(sessionA, s); + await reload(); + }); + + it('follows the active session, and says when a session is not a Claude session', async () => { + await select(sessionB); + await page.waitForFunction(() => + /only available for local Claude sessions/.test(document.getElementById('editHistoryBody')!.textContent ?? '') + ); + expect(calls).toContain(`GET /api/sessions/${sessionB}/edits?sync=1`); + expect(await rows().count()).toBe(0); + await select(sessionA); + await page.waitForSelector('#editHistoryBody .edit-history-entry'); + }); + + it('drags by the header', async () => { + const before = await page.evaluate(() => document.getElementById('editHistoryPanel')!.getBoundingClientRect().left); + const box = (await page.locator('.edit-history-header').boundingBox())!; + await page.mouse.move(box.x + 40, box.y + 10); + await page.mouse.down(); + await page.mouse.move(box.x - 140, box.y + 70, { steps: 5 }); + await page.mouse.up(); + const after = await page.evaluate(() => document.getElementById('editHistoryPanel')!.getBoundingClientRect().left); + expect(after).toBeLessThan(before - 100); + }); + + it('turning the setting off hides the button and closes the panel; SSE then reads nothing', async () => { + await setSetting(false); + expect(await buttonVisible()).toBe(false); + expect(await page.isVisible('#editHistoryPanel')).toBe(false); + const before = calls.length; + toolEnd(sessionA, 'Edit'); + await page.waitForTimeout(1200); + expect(calls.length).toBe(before); + }); +}); diff --git a/test/empty-api-post-server.test.ts b/test/empty-api-post-server.test.ts new file mode 100644 index 00000000..e483123b --- /dev/null +++ b/test/empty-api-post-server.test.ts @@ -0,0 +1,50 @@ +/** Verify parser installation through the real server, including API aliases and base paths. */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { WebServer } from '../src/web/server.js'; + +describe('empty POST: production server wiring', () => { + let server: WebServer; + let app: FastifyInstance; + beforeAll(async () => { + server = new WebServer(0, false, true, '127.0.0.1', undefined, false, '/codeman'); + await server.start(); + app = (server as unknown as { app: FastifyInstance }).app; + }); + afterAll(async () => { + await server?.stop(); + }); + + it('accepts empty chunked POSTs to a real API through each supported prefix', async () => { + for (const prefix of ['/api', '/api/v1', '/codeman/api', '/codeman/api/v1']) { + const res = await app.inject({ + method: 'POST', + url: `${prefix}/approvals/session/nonexistent/viewed`, + headers: { 'transfer-encoding': 'chunked' }, + }); + // The real handler ran and found no session, instead of the parser returning 415. + expect(res.statusCode, res.body).toBe(404); + expect(res.json().errorCode).toBe('NOT_FOUND'); + } + }); + + it('still rejects a nonempty untyped POST before reaching the same handler', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/approvals/session/nonexistent/viewed', + headers: { 'transfer-encoding': 'chunked' }, + payload: '{}', + }); + expect(res.statusCode).toBe(415); + }); + + it('preserves the API 404 envelope for an unknown empty chunked POST', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/nonexistent', + headers: { 'transfer-encoding': 'chunked' }, + }); + expect(res.statusCode).toBe(404); + expect(res.json().errorCode).toBe('NOT_FOUND'); + }); +}); diff --git a/test/file-path-link-forms.test.ts b/test/file-path-link-forms.test.ts index e601bc9e..2f3b7a50 100644 --- a/test/file-path-link-forms.test.ts +++ b/test/file-path-link-forms.test.ts @@ -52,13 +52,13 @@ const { findFilePathLinks, resolveLinkedFilePath, terminalPathLine } = loadHelpe const paths = (text: string) => findFilePathLinks(text).map((f) => f.path); /** An xterm buffer stand-in: hard-broken rows (no `isWrapped`), as Claude Code emits them. */ -function buffer(lines: string[], cols = 80) { +function buffer(lines: string[], cols = 80, wrappedRows = new Set<number>()) { return { length: lines.length, getLine: (row: number) => row >= 0 && row < lines.length ? { - isWrapped: false, + isWrapped: wrappedRows.has(row), translateToString: (trim?: boolean) => (trim ? lines[row].replace(/\s+$/, '') : lines[row].padEnd(cols)), } : undefined, @@ -157,6 +157,10 @@ describe('terminalPathLine', () => { // A path that already ended, and a next line opening a new rooted path. expect(terminalPathLine(buffer(['see /tmp/a/b.png', 'c/d.png']), 0, 80)).toMatchObject({ endRow: 0 }); expect(terminalPathLine(buffer(['/home/joe/dir/', '/home/x.png']), 0, 80)).toMatchObject({ endRow: 0 }); + expect(terminalPathLine(buffer(['see (/mnt/raid1/folder)', ' file.conf)']), 0, 80)).toMatchObject({ endRow: 0 }); + expect(terminalPathLine(buffer(['see (/mnt/raid1/folder', ' /mnt/other/file.conf)']), 0, 80)).toMatchObject({ + endRow: 0, + }); }); // `git ls-files`, `find` and `ls -R` print one path per row. An extensionless @@ -237,13 +241,15 @@ function loadApp() { app.sessions = new Map([['s1', { workingDir: '/mnt/build/neon_getaway-mgr-hebel' }]]); app.openFilePreview = vi.fn(); app.openLogViewerWindow = vi.fn(); + app._registerExternalPreview = vi.fn(async () => ({ attachmentId: 'att-fixture' })); + app.showToast = vi.fn(); app._isExternalPreviewPath = (p: string) => p.startsWith('~/'); return app; } -function linksOn(lines: string[], row: number) { +function linksOn(lines: string[], row: number, cols = 80, wrappedRows = new Set<number>()) { const app = loadApp(); - app.terminal = { cols: 80, registerLinkProvider: vi.fn(), buffer: { active: buffer(lines) } }; + app.terminal = { cols, registerLinkProvider: vi.fn(), buffer: { active: buffer(lines, cols, wrappedRows) } }; const provider = app.registerFilePathLinkProvider() as Provider; let out: Link[] = []; provider.provideLinks(row, (links) => { @@ -253,6 +259,169 @@ function linksOn(lines: string[], row: number) { } describe('terminal link provider', () => { + it.each(['svg', 'markdown', 'avif', 'bmp', 'ico', 'zip'])( + 'keeps the workspace opener for a hard-wrapped .%s rejected by the attachment registry', + async (extension) => { + for (const row of [1, 2]) { + const path = `/home/me/proj/out/system_architecture.${extension}`; + const { app, links } = linksOn( + ['Saved the diagram (/home/me/proj/out/system_archi', ` tecture.${extension}).`], + row, + 120 + ); + app.sessions = new Map([['s1', { workingDir: '/home/me/proj' }]]); + app._registerExternalPreview.mockResolvedValue({ status: 400, error: 'Unsupported attachment type' }); + await links[0].activate({}, links[0].text); + const opener = extension === 'zip' ? app.openLogViewerWindow : app.openFilePreview; + expect(opener).toHaveBeenCalledWith(path, 's1'); + expect(app._registerExternalPreview.mock.calls).toEqual([[path, 's1']]); + expect(app.showToast).not.toHaveBeenCalled(); + } + } + ); + + it.each([ + [ + ['Saved the screenshot (/home/me/proj/out/ghost1b_game', ' play.png).'], + '/home/me/proj/out/ghost1b_gameplay.png', + ], + [ + [' ⎿ Wrote (/mnt/build/neon/builds/captures/ghost1b_gamepl', ' ay.png) ok'], + '/mnt/build/neon/builds/captures/ghost1b_gameplay.png', + ], + [ + ['Saved the screenshot (/home/me/proj/out/ghost1b_', ' very_long_game', ' play.png).'], + '/home/me/proj/out/ghost1b_very_long_gameplay.png', + ], + ])('preserves a parenthesised filename without spaces across hard wraps: %s', async (rows, path) => { + for (let row = 1; row <= rows.length; row++) { + const { app, links } = linksOn(rows, row, 120); + expect(links.map((link) => link.text)).toEqual([path]); + await links[0].activate({}, links[0].text); + expect(app.openFilePreview).toHaveBeenCalledWith(path, 's1'); + expect(app._registerExternalPreview.mock.calls).toEqual([[path, 's1']]); + } + }); + + it('uses the spaced filename only when the unspaced hard-wrap target is missing', async () => { + const path = '/mnt/user/Scratch/Kobo/Set-KoboCovers/Kobo eReader.conf'; + const rows = [ + 'Found it: I prepared a corrected configuration (/mnt/user/Scratch/Kobo/Set-KoboCovers/Kobo', + ' eReader.conf).', + ]; + for (const row of [1, 2]) { + const { app, links } = linksOn(rows, row, 120); + const unspaced = path.replace('Kobo eReader', 'KoboeReader'); + expect(links.map((link) => link.text)).toEqual([unspaced]); + expect(links[0].range.end).toEqual({ x: ' eReader.conf'.length + 1, y: 2 }); + app._registerExternalPreview.mockImplementation(async (candidate: string) => + candidate === path ? { attachmentId: 'att-fixture' } : { status: 404, error: 'File not found' } + ); + await links[0].activate({}, links[0].text); + expect(app.openLogViewerWindow).toHaveBeenCalledWith(path, 's1'); + expect(app._registerExternalPreview.mock.calls).toEqual([ + [unspaced, 's1'], + [path, 's1'], + ]); + } + }); + + it.each([403, 500, 503])('does not try another path after a guarded lookup returns HTTP %s', async (status) => { + const { app, links } = linksOn(['Saved (/mnt/data/Kobo', ' eReader.conf).'], 1, 120); + app._registerExternalPreview.mockResolvedValue({ status, error: 'Lookup refused' }); + await links[0].activate({}, links[0].text); + expect(app._registerExternalPreview).toHaveBeenCalledTimes(1); + expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1'); + expect(app.showToast).not.toHaveBeenCalled(); + }); + + it('keeps the original opener when the lookup cannot reach the server', async () => { + const { app, links } = linksOn(['Saved (/mnt/data/Kobo', ' eReader.conf).'], 1, 120); + app._registerExternalPreview.mockResolvedValue({ error: 'Connection lost' }); + await links[0].activate({}, links[0].text); + expect(app._registerExternalPreview).toHaveBeenCalledTimes(1); + expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1'); + }); + + it('never looks up a spaced name for a hard-wrapped Bash( row, which holds a command', async () => { + const { app, links } = linksOn(['Bash(/mnt/data/Kobo', ' eReader.conf)'], 1, 120); + await links[0].activate({}, links[0].text); + expect(app._registerExternalPreview).not.toHaveBeenCalled(); + expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1'); + }); + + it('uses the original opener when both hard-wrap filenames are missing', async () => { + for (const row of [1, 2]) { + const { app, links } = linksOn(['Saved (/mnt/data/Kobo', ' eReader.conf).'], row, 120); + app._registerExternalPreview.mockResolvedValue({ status: 404, error: 'Attachment file not found' }); + await links[0].activate({}, links[0].text); + expect(app._registerExternalPreview.mock.calls).toEqual([ + ['/mnt/data/KoboeReader.conf', 's1'], + ['/mnt/data/Kobo eReader.conf', 's1'], + ]); + expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1'); + expect(app.showToast).not.toHaveBeenCalled(); + } + }); + + it.each(['Path: ', '- ', '- Path: ', ' Path: '])( + 'resolves the filename without its parenthesised prefix %j', + async (prefix) => { + for (const row of [1, 2]) { + const { app, links } = linksOn([`Saved the notes (${prefix}/home/me/proj/out/My`, ' Notes.md).'], row, 120); + app._registerExternalPreview + .mockResolvedValueOnce({ status: 404, error: 'Attachment file not found' }) + .mockResolvedValueOnce({ attachmentId: 'att-spaced' }); + expect(links.map((link) => link.text)).toEqual(['/home/me/proj/out/MyNotes.md']); + await links[0].activate({}, links[0].text); + expect(app._registerExternalPreview.mock.calls).toEqual([ + ['/home/me/proj/out/MyNotes.md', 's1'], + ['/home/me/proj/out/My Notes.md', 's1'], + ]); + expect(app.openFilePreview).toHaveBeenCalledWith('/home/me/proj/out/My Notes.md', 's1'); + } + } + ); + + it.each([400, 403, 500, 503])('does not open an unregistered spaced alternative after HTTP %s', async (status) => { + const { app, links } = linksOn(['Saved (/mnt/data/Kobo', ' eReader.conf).'], 1, 120); + app._registerExternalPreview + .mockResolvedValueOnce({ status: 404, error: 'File not found' }) + .mockResolvedValueOnce({ status, error: 'Alternative unavailable' }); + await links[0].activate({}, links[0].text); + expect(app._registerExternalPreview.mock.calls).toEqual([ + ['/mnt/data/KoboeReader.conf', 's1'], + ['/mnt/data/Kobo eReader.conf', 's1'], + ]); + expect(app.openFilePreview).not.toHaveBeenCalled(); + expect(app.openLogViewerWindow).not.toHaveBeenCalled(); + expect(app.showToast).toHaveBeenCalledWith('Alternative unavailable', 'error'); + }); + + it('keeps the session selected at click time while a path lookup is pending', async () => { + const { app, links } = linksOn(['Saved (/mnt/data/Kobo', ' eReader.conf).'], 1, 120); + const pending = links[0].activate({}, links[0].text); + app.activeSessionId = 'another-session'; + await pending; + expect(app._registerExternalPreview).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1'); + expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1'); + }); + + it('opens the complete parenthesised configuration path from either wrapped row', () => { + const path = '/mnt/user/Scratch/Kobo/Set-KoboCovers/Kobo eReader.conf'; + const text = `Found it: I prepared a corrected configuration (${path}).`; + const split = text.indexOf(' eReader') + 1; + const rows = [text.slice(0, split), text.slice(split)]; + for (const row of [1, 2]) { + const { app, links } = linksOn(rows, row, split, new Set([1])); + expect(links.map((link) => link.text)).toEqual([path]); + expect(links[0].range.start).toEqual({ x: text.indexOf(path) + 1, y: 1 }); + expect(links[0].range.end).toEqual({ x: 'eReader.conf'.length + 1, y: 2 }); + links[0].activate({}, links[0].text); + expect(app.openLogViewerWindow).toHaveBeenCalledWith(path, 's1'); + } + }); + it('links a broken path once, across both rows, and opens the whole path', () => { const { app, links } = linksOn(BROKEN, 2); expect(links.map((l) => l.text)).toEqual([BROKEN_PATH]); diff --git a/test/file-preview-window.test.ts b/test/file-preview-window.test.ts index bd9347dd..d69a1cf0 100644 --- a/test/file-preview-window.test.ts +++ b/test/file-preview-window.test.ts @@ -14,7 +14,9 @@ * 9. stepping onto a folder-grid page moves the grid's own index with it, * 10. Alt+←/→ steps, ignores other chords and text fields, and is swallowed at the ends, * 11. the corner grip doubles the pointer delta and clamps the size, - * 12. phones ignore Expand and the grip (mobile.css). + * 12. phones ignore Expand and the grip (mobile.css), + * 13. the rendered document carries no width cap, so it fills the window the + * grip and Expand give it (styles.css). * * Loaded via `vm` against a stub app, same harness style as file-preview-detach.test.ts. */ @@ -27,6 +29,7 @@ import { describe, expect, it, vi } from 'vitest'; const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8'); const indexHtml = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'); +const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8'); function btn() { const attrs: Record<string, string> = {}; @@ -376,6 +379,24 @@ describe('file viewer resize grip', () => { }); }); +describe('file viewer rendered markdown width', () => { + it('carries no fixed reading column, so the document fills the window the grip and Expand give it', () => { + const mdRule = stylesCss.match(/\.file-preview-body > \.file-preview-md \{[^}]*\}/); + expect(mdRule, 'the rule the rendered document lives in').not.toBeNull(); + // Any cap here reflows nothing once the window is wider than it, which is + // what the shipped 960px column did. The window width is the user's choice, + // so nothing in this rule may cap it. + expect(mdRule![0], 'no width cap on the document').not.toMatch(/max-width/); + // The shared .rv-text prose rule must not reintroduce one either. + const rvBase = stylesCss.match(/\.rv-text,\s*\.response-viewer-body > :not\(\.rv-message\) \{[^}]*\}/); + expect(rvBase, 'the shared prose rule').not.toBeNull(); + expect(rvBase![0], 'no width cap on the shared prose rule').not.toMatch(/max-width/); + // ASCII-diagram blocks cap themselves at var(--rv-content-max, 720px), which only the + // Response Viewer sets; the document lifts it so a diagram follows the window too. + expect(mdRule![0], 'diagram blocks follow the window').toMatch(/--rv-content-max:\s*none;/); + }); +}); + describe('file viewer on phones', () => { it('overrides Expand and any dragged size, and hides both controls, under 600px', () => { const css = readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8'); diff --git a/test/fixtures/codex-rollout.ts b/test/fixtures/codex-rollout.ts new file mode 100644 index 00000000..9f90f5c5 --- /dev/null +++ b/test/fixtures/codex-rollout.ts @@ -0,0 +1,125 @@ +/** + * @fileoverview Codex rollout lines shaped like real ones, for the offline + * history tests. The structure (keys, their order, the event types around a + * fork boundary) was taken from real rollouts written by codex 0.120 to 0.163 + * and from codex-rs `SessionMeta` (`protocol/src/protocol.rs`); no + * conversation content was copied. + * + * A forked or spawned thread's file, as `record_initial_history` writes it for + * `ForkPersistence::Copied`: + * + * own session_meta, parent session_meta, copied parent records, + * thread_settings_applied, the child's own records + * + * In paginated history every line also carries an `ordinal`, and the child's + * own `session_meta` declares `subagent_history_start_ordinal`. + */ +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +/** Codex's base instructions make every real session_meta line about 20 KiB. */ +export const BASE_INSTRUCTIONS_BYTES = 19 * 1024; + +export interface MetaOptions { + id: string; + /** Spawn parent: sets parent_thread_id, forked_from_id and source.subagent.thread_spawn. */ + parent?: string; + /** Shared root (`session_id`); defaults to `parent ?? id`, as Codex writes it. */ + root?: string; + mode?: 'legacy' | 'paginated'; + historyStart?: number; + timestamp?: string; + baseInstructionsBytes?: number; +} + +export function realMeta(options: MetaOptions): Record<string, unknown> { + const { id, parent, mode = 'legacy', timestamp = '2026-10-24T01:00:00.000Z' } = options; + const payload: Record<string, unknown> = { + session_id: options.root ?? parent ?? id, + id, + ...(parent ? { forked_from_id: parent, parent_thread_id: parent } : {}), + timestamp, + cwd: '/work/repo', + originator: 'codex_exec', + cli_version: '0.163.0', + source: parent + ? { + subagent: { + thread_spawn: { + parent_thread_id: parent, + depth: 1, + agent_path: null, + agent_nickname: 'Kepler', + agent_role: null, + }, + }, + } + : 'exec', + thread_source: parent ? 'subagent' : 'user', + ...(parent ? { agent_nickname: 'Kepler' } : {}), + model_provider: 'openai', + base_instructions: { text: 'b'.repeat(options.baseInstructionsBytes ?? BASE_INSTRUCTIONS_BYTES) }, + history_mode: mode, + ...(options.historyStart !== undefined ? { subagent_history_start_ordinal: options.historyStart } : {}), + ...(parent ? { multi_agent_version: 'v1' } : {}), + context_window: { window_id: `${id.slice(0, 8)}-window` }, + }; + return { timestamp, type: 'session_meta', payload }; +} + +export const taskStarted = () => ({ + timestamp: '2026-10-24T01:00:00.000Z', + type: 'event_msg', + payload: { type: 'task_started' }, +}); +export const settingsApplied = () => ({ + timestamp: '2026-10-24T01:00:00.000Z', + type: 'event_msg', + payload: { type: 'thread_settings_applied', thread_settings: { model: 'gpt-5.5', cwd: '/work/repo' } }, +}); +export const developer = () => ({ + timestamp: '2026-10-24T01:00:00.000Z', + type: 'response_item', + payload: { type: 'message', role: 'developer', content: [{ type: 'input_text', text: 'policy' }] }, +}); +export const injected = (tag = 'environment_context') => ({ + timestamp: '2026-10-24T01:00:00.000Z', + type: 'response_item', + payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: `<${tag}>\n ctx\n</${tag}>` }] }, +}); +/** A real user turn: the response_item Codex sent plus its user_message event twin. */ +export const userTurn = (text: string, timestamp = '2026-10-24T01:00:00.000Z') => [ + { + timestamp, + type: 'response_item', + payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text }] }, + }, + { timestamp, type: 'event_msg', payload: { type: 'user_message', message: text, images: [] } }, +]; +/** An assistant message: the event and the response_item, both carrying Codex's phase. */ +export const assistantTurn = (text: string, phase: 'commentary' | 'final_answer' = 'final_answer') => [ + { + timestamp: '2026-10-24T01:00:01.000Z', + type: 'event_msg', + payload: { type: 'agent_message', message: text, phase }, + }, + { + timestamp: '2026-10-24T01:00:01.000Z', + type: 'response_item', + payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }], phase }, + }, +]; + +/** Number every line, as paginated history does. */ +export function withOrdinals(lines: Array<Record<string, unknown>>): Array<Record<string, unknown>> { + return lines.map((line, ordinal) => { + const { timestamp, type, payload, ...rest } = line; + return { timestamp, ordinal, type, payload, ...rest }; + }); +} + +export async function writeRollout(dir: string, name: string, lines: unknown[]): Promise<string> { + const path = join(dir, name); + await writeFile(path, lines.map((line) => JSON.stringify(line)).join('\n') + '\n'); + return path; +} diff --git a/test/folder-grid-ui.test.ts b/test/folder-grid-ui.test.ts index 53f84859..131bcdf1 100644 --- a/test/folder-grid-ui.test.ts +++ b/test/folder-grid-ui.test.ts @@ -212,6 +212,59 @@ function loadApp(fetchImpl: (url: string, init?: { body?: string }) => unknown) return { app, elements, keyHandlers, fetch: context.fetch as ReturnType<typeof vi.fn> }; } +describe('guarded attachment registration results', () => { + it.each(['svg', 'markdown', 'avif', 'bmp', 'ico', 'zip'])( + 'names a rejected workspace .%s path without describing it as external', + async (extension) => { + const { app } = loadApp(async () => ({ + ok: false, + status: 400, + json: async () => ({ error: 'Unsupported attachment type' }), + })); + const path = `/home/joe/wiki/out/system architecture.${extension}`; + const result = await Object.getPrototypeOf(app)._registerExternalPreview.call(app, path, 's1'); + expect(result.status).toBe(400); + expect(result.error).toContain(path); + expect(result.error).not.toContain('outside the session workspace'); + } + ); + + it('retains the external-file explanation for a rejected external type', async () => { + const { app } = loadApp(async () => ({ + ok: false, + status: 400, + json: async () => ({ error: 'Unsupported attachment type' }), + })); + const result = await Object.getPrototypeOf(app)._registerExternalPreview.call( + app, + '/mnt/data/system architecture.svg', + 's1' + ); + expect(result.error).toBe( + 'Cannot preview .svg from outside the session workspace (images, video, audio, PDF, Office documents and text files only).' + ); + }); + + it.each([404, 403, 500, 503])('preserves HTTP %s for the activation decision', async (status) => { + const { app, fetch } = loadApp(async () => ({ + ok: false, + status, + json: async () => ({ error: 'Cannot read file' }), + })); + const result = await Object.getPrototypeOf(app)._registerExternalPreview.call(app, '/mnt/data/a.conf', 's1'); + expect(result).toEqual({ error: 'Cannot read file', status }); + expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ path: '/mnt/data/a.conf', notify: false }); + }); + + it('does not turn a connection failure into a missing file', async () => { + const { app } = loadApp(async () => { + throw new Error('Connection lost'); + }); + const result = await Object.getPrototypeOf(app)._registerExternalPreview.call(app, '/mnt/data/a.conf', 's1'); + expect(result).toEqual({ error: 'Connection lost' }); + }); +}); + const LISTING = { folderId: 'fld_1', path: '/mnt/raid1/styleframes', diff --git a/test/git-fetch-i18n.test.ts b/test/git-fetch-i18n.test.ts new file mode 100644 index 00000000..71ecdbef --- /dev/null +++ b/test/git-fetch-i18n.test.ts @@ -0,0 +1,101 @@ +/** + * @fileoverview The Git fetch interval rows (App Settings → Bottom bar, Session Options → Git) in Chinese + * (zh-CN): each label and description runs through the real translator over the real index.html in JSDOM and + * reads in Chinese with no English left beyond the product name and the technical names Git / git fetch, and + * every new key (the save toast in session-ui.js included) is in the dictionary exactly once (a repeated key + * in the object literal silently replaces the first). + * + * The Git tab's Status row is not here: its text is written at runtime by updateGitFetchStatus and carries + * data-i18n-skip, so translating it would rewrite the stamp the code builds. 'sec' is not a key either: only + * whole unit words are ('hours'), so 'sec' stays as written. + * + * Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { afterAll, describe, expect, it } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8'); +const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'); +// Keys that contain a quote are written escaped in the object literal, so the dictionary is counted unescaped. +const DICT = I18N.replace(/\\'/g, "'").replace(/\\"/g, '"'); + +type Api = { configure(o: Record<string, string>): void; start(): void }; + +const ROWS = [ + 'appSettingsGitFetchEnabled', + 'appSettingsGitFetchIntervalSeconds', + 'modalGitFetchFollow', + 'modalGitFetchEnabled', + 'modalGitFetchInterval', +]; + +/** What may stay Latin: the product names, and Git / git fetch as technical names. */ +const leftover = (text: string) => + text.replace(/\b(Claude Code|Claude|Codeman|Ralph|Git|git|fetch)\b/g, '').match(/[A-Za-z]+/g) ?? []; + +function rowStrings(doc: Document, id: string): { label: string; description: string } { + const row = doc.getElementById(id)!.closest('.set-row')!; + const label = row.querySelector('.set-row-label')!; + // The row's own scope marker ('synced') rides inside the label; the dictionary key is the label without it. + const scope = label.querySelector('.set-scope')?.textContent ?? ''; + return { + label: label.textContent!.replace(scope, '').trim(), + description: row.querySelector('.set-row-desc')!.textContent!.trim(), + }; +} + +/** The Git section's blurb, which is not a row. */ +function blurb(doc: Document): string { + const section = doc.getElementById('git-tab')!; + return section.querySelector('.set-section-blurb')!.textContent!.trim(); +} + +const source = new JSDOM(INDEX); +const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' }); +vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' }); +const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n; +api.start(); +api.configure({ language: 'zh-CN' }); + +afterAll(() => { + source.window.close(); + dom.window.close(); +}); + +describe('the Git fetch rows in zh-CN', () => { + it.each(ROWS)('translates the %s row, with no English left', (id) => { + const english = rowStrings(source.window.document, id); + const chinese = rowStrings(dom.window.document, id); + expect(english.label.length).toBeGreaterThan(0); + for (const key of ['label', 'description'] as const) { + expect(chinese[key]).not.toBe(english[key]); + expect(leftover(chinese[key])).toEqual([]); + } + }); + + it('translates the Git section blurb too, which is not a row', () => { + expect(blurb(dom.window.document)).not.toBe(blurb(source.window.document)); + expect(leftover(blurb(dom.window.document))).toEqual([]); + }); + + it("leaves the Status row's runtime text to the code that writes it", () => { + // updateGitFetchStatus builds this row's sentence; a dictionary key for it would overwrite the stamp. + expect(INDEX).toContain('id="gitFetchStatus" data-i18n-skip hidden'); + }); + + it('adds each key to the dictionary once, the save toast included', () => { + const texts = ROWS.flatMap((id) => Object.values(rowStrings(source.window.document, id))); + texts.push(blurb(source.window.document), 'Git fetch settings saved'); + // 'sec' is deliberately not a key: only whole unit words are, so the unit stays as the markup writes it. + expect(DICT).not.toMatch(/\n sec: /); + for (const text of texts) { + const quoted = DICT.split(`'${text}'`).length + DICT.split(`"${text}"`).length - 2; + const bare = /^\w+$/.test(text) ? DICT.split(`\n ${text}: `).length - 1 : 0; + expect(quoted + bare, text).toBe(1); + } + }); +}); diff --git a/test/git-fetch-scheduler.test.ts b/test/git-fetch-scheduler.test.ts new file mode 100644 index 00000000..3c7add06 --- /dev/null +++ b/test/git-fetch-scheduler.test.ts @@ -0,0 +1,550 @@ +/** + * @fileoverview The Git fetch interval scheduler (src/git-fetch-scheduler.ts): which sessions a tick runs, + * and which it must not. Every rule is checked by calling `tick(now)` with an explicit clock, so nothing here + * depends on fake timers or on how long a real tick takes: the scheduler owns no timers itself. + * + * The run belongs to the FOLDER, so these tests put two sessions on one folder and expect one fetch that + * both see, and they check the tick's two bounds separately: one shared bound for the fetches of the whole + * tick, and the folders bounded by a bound that cannot be the same semaphore. A folder is due when ANY session + * open on it is due, so the order the sessions were read cannot decide it, and that is checked in both orders. + * A run carries the trigger that caused it, and what is published says the run's, not the tick's. + * + * Port: N/A. + */ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { + GitFetchScheduler, + effectiveGitFetch, + type GitFetchPorts, + type GitFetchSessionView, +} from '../src/git-fetch-scheduler.js'; +import { + FETCH_CONCURRENCY, + type GitFetchOptions, + type GitFetchReport, + type GitFetchRun, + type GitFetchSettings, + type GitFetchStatus, + type GitFetchTrigger, +} from '../src/git-fetch.js'; +import { DEFAULT_GIT_TIMEOUT_MS, MAX_REPOS } from '../src/git-workspace-status.js'; +import type { PathProbeKind } from '../src/utils/bounded-path-probe.js'; +import { readGitFetchSettings, readKeepWarmSettingsOrThrow } from '../src/hooks-config.js'; +import { SETTINGS_PATH } from '../src/web/route-helpers.js'; + +const SETTINGS: GitFetchSettings = { enabled: true, intervalSeconds: 300 }; +const VIEW = (id: string, extra: Partial<GitFetchSessionView> = {}): GitFetchSessionView => ({ + id, + workingDir: `/w/${id}`, + ...extra, +}); + +/** An empty report, in the shape a run produces. */ +const report = (cwd: string, name: string): GitFetchReport => ({ + state: 'ok', + repos: [{ name, path: '.', ok: true }], + reposTruncated: false, + fetchedAt: Date.now(), +}); + +/** A run: the report plus the trigger that caused it, which is what the published row has to name. */ +const run = (cwd: string, name: string, trigger: GitFetchTrigger = 'interval'): GitFetchRun => ({ + report: report(cwd, name), + trigger, +}); + +type Harness = { + scheduler: GitFetchScheduler; + ports: GitFetchPorts; + /** The folders the tick actually ran, in the order they were picked, with the trigger the tick passed. */ + calls: Array<{ cwd: string; opts: GitFetchOptions; trigger: GitFetchTrigger }>; + published: Array<{ id: string; status: GitFetchStatus }>; + /** Every path the tick probed, in order: the probe has to happen before anything is fetched. */ + probed: string[]; + settings: GitFetchSettings; + dockerReads: number; + /** + * A session that closed: the server calls `remove()` when the session is gone, so this takes it out of the + * ports as well as out of the scheduler. Otherwise a test that removes a session still sees it on the next + * tick, and the folder it was on looks still open. + */ + remove(id: string): void; +}; + +/** A run the test decides when to let finish, so a slow repository can be held open across ticks. */ +function deferred<T>(value: T) { + let resolve = (v: T) => {}; + const promise = new Promise<T>((r) => (resolve = r)); + return { promise, resolve }; +} + +/** + * A scheduler over an injected set of ports: nothing here touches a settings file, a repository or the + * server, so what is being pinned is the scheduler's own decisions. + */ +function harness( + sessions: GitFetchSessionView[], + opts: { + settings?: GitFetchSettings; + fetch?: (cwd: string, portOpts: GitFetchOptions, trigger: GitFetchTrigger) => Promise<GitFetchRun>; + /** Default: every path answers as a folder, so a test that does not care about the probe stays quiet. */ + probe?: (path: string) => Promise<PathProbeKind>; + } = {} +): Harness { + const customFetch = opts.fetch; + const settings = opts.settings ?? { ...SETTINGS }; + const gone = new Set<string>(); + const h: Harness = { + scheduler: undefined as unknown as GitFetchScheduler, + ports: undefined as unknown as GitFetchPorts, + calls: [], + published: [], + probed: [], + settings, + dockerReads: 0, + remove: (id) => { + gone.add(id); + h.scheduler.remove(id); + }, + }; + h.ports = { + sessionIds: () => sessions.filter((s) => !gone.has(s.id)).map((s) => s.id), + getSession: (id) => (gone.has(id) ? undefined : sessions.find((s) => s.id === id)), + readSettings: async () => { + if (h.settings === null) throw new Error('settings.json is not valid JSON'); + return h.settings; + }, + dockerWorkspaces: async () => { + h.dockerReads++; + return []; + }, + probePath: async (path) => { + h.probed.push(path); + return opts.probe ? await opts.probe(path) : 'directory'; + }, + fetch: async (cwd, portOpts, trigger) => { + h.calls.push({ cwd, opts: portOpts, trigger }); + return customFetch ? customFetch(cwd, portOpts, trigger) : run(cwd, cwd.slice(3), trigger); + }, + publish: (id, status) => h.published.push({ id, status }), + }; + h.scheduler = new GitFetchScheduler(h.ports); + return h; +} + +describe('a quiet tick', () => { + it('costs one settings read and nothing else, because nothing is on', async () => { + const h = harness([VIEW('a'), VIEW('b')], { settings: { enabled: false, intervalSeconds: 300 } }); + expect(await h.scheduler.tick(1000)).toBe(0); + expect(h.calls).toEqual([]); + // The probe and the Docker case list are only worth reading once something is actually due. + expect(h.probed).toEqual([]); + expect(h.dockerReads).toBe(0); + }); + + it('reads settings FRESH every tick, so a live toggle takes effect on the next tick and not at boot', async () => { + const h = harness([VIEW('a')], { settings: { enabled: false, intervalSeconds: 30 } }); + await h.scheduler.tick(0); + expect(h.calls.length).toBe(0); + + h.settings = { enabled: true, intervalSeconds: 30 }; + await h.scheduler.tick(1000); + expect(h.calls.length).toBe(1); + + // Turning it off has to stop the fetching, not just stop it starting again later. + h.settings = { enabled: false, intervalSeconds: 30 }; + await h.scheduler.tick(2000); + expect(h.calls.length).toBe(1); + }); + + it('skips this tick only when a settings read fails, rather than deciding from defaults', async () => { + const h = harness([VIEW('a')]); + h.settings = null as unknown as GitFetchSettings; + const logged = vi.fn(); + h.ports.log = logged; + expect(await h.scheduler.tick(0)).toBe(0); + expect(h.calls).toEqual([]); + expect(logged).toHaveBeenCalled(); + + h.settings = SETTINGS; + // Nothing was recorded for the failed tick, so the folder is still due at once. + expect(await h.scheduler.tick(1000)).toBe(1); + }); + + /** + * The tick runs every 15 s whether or not the feature is on, so an unreadable settings.json would be logged + * every 15 s, and V8's parse message quotes the text around the fault: a hand edit with single quotes put + * the API key it held into the log on every tick. + */ + it('logs an unreadable settings.json once until it reads again, and never with the text of the file', async () => { + const secret = 'sk-leak42'; + const broken = `{"voiceSettings":{"apiKey":'${secret}'}}`; + await mkdir(dirname(SETTINGS_PATH), { recursive: true }); + await writeFile(SETTINGS_PATH, broken); + try { + // The real readers: what they reject with is what gets logged. + await expect(readGitFetchSettings()).rejects.toThrow(/not valid JSON/); + await expect(readGitFetchSettings()).rejects.not.toThrow(secret); + await expect(readKeepWarmSettingsOrThrow()).rejects.not.toThrow(secret); + + const h = harness([VIEW('a')]); + h.ports.readSettings = readGitFetchSettings; + const logged = vi.fn(); + h.ports.log = logged; + for (const now of [0, 15_000, 30_000]) expect(await h.scheduler.tick(now)).toBe(0); + expect(h.calls).toEqual([]); + expect(logged).toHaveBeenCalledTimes(1); + + // A read that succeeds in between re-arms the warning for the next failure. + await writeFile(SETTINGS_PATH, JSON.stringify({ gitFetchEnabled: true })); + expect(await h.scheduler.tick(45_000)).toBe(1); + await writeFile(SETTINGS_PATH, broken); + expect(await h.scheduler.tick(400_000)).toBe(0); + expect(await h.scheduler.tick(415_000)).toBe(0); + expect(logged).toHaveBeenCalledTimes(2); + expect(JSON.stringify(logged.mock.calls)).not.toContain(secret); + } finally { + await rm(SETTINGS_PATH, { force: true }); + } + }); +}); + +describe('the bounded probe', () => { + it('probes the folder before fetching it, and skips anything that is not a directory', async () => { + const kinds = new Map<string, PathProbeKind>([ + ['/w/file', 'file'], + ['/w/unknown', 'unknown'], + ['/w/absent', 'absent'], + ]); + const h = harness([VIEW('file'), VIEW('unknown'), VIEW('absent'), VIEW('ok')], { + probe: async (path) => kinds.get(path) ?? 'directory', + }); + const logged = vi.fn(); + h.ports.log = logged; + expect(await h.scheduler.tick(0)).toBe(1); + expect(h.calls.map((c) => c.cwd)).toEqual(['/w/ok']); + // The folders that answered badly were probed and left alone, never fetched. + expect(h.probed).toEqual(['/w/file', '/w/unknown', '/w/absent', '/w/ok']); + expect(logged).toHaveBeenCalledTimes(3); + // `unknown` is never read as `absent`: the log says what it answered rather than what it was assumed. + expect(logged.mock.calls.join(' ')).toContain('unknown'); + }); +}); + +describe('who runs', () => { + it('runs every session the app setting covers, once per interval', async () => { + const h = harness([VIEW('a'), VIEW('b')], { settings: { enabled: true, intervalSeconds: 60 } }); + await h.scheduler.tick(0); + expect(h.calls.map((c) => c.cwd).sort()).toEqual(['/w/a', '/w/b']); + + await h.scheduler.tick(30_000); // not due yet + expect(h.calls.length).toBe(2); + + await h.scheduler.tick(60_000); + expect(h.calls.length).toBe(4); + }); + + it('runs a session that turned itself on even while the app-wide switch is off: that is what a per-session switch has to mean', async () => { + const h = harness([VIEW('plain'), VIEW('own', { gitFetchEnabled: true, gitFetchIntervalSeconds: 30 })], { + settings: { enabled: false, intervalSeconds: 300 }, + }); + await h.scheduler.tick(0); + expect(h.calls.map((c) => c.cwd)).toEqual(['/w/own']); + + // Its own interval, not the app's: due at 30 s, not at 300. + await h.scheduler.tick(30_000); + expect(h.calls.length).toBe(2); + }); + + it('never fetches a remote or Docker session, exactly as the Git panel excludes them', async () => { + const h = harness([ + VIEW('ssh', { remote: { host: 'x' } }), + VIEW('docker', { docker: { container: 'c' } }), + VIEW('local'), + ]); + await h.scheduler.tick(0); + expect(h.calls.map((c) => c.cwd)).toEqual(['/w/local']); + }); + + it('re-checks the Docker rule every tick, so a session whose case became a Docker case stops fetching', async () => { + const sessions = [VIEW('a')]; + const h = harness(sessions); + await h.scheduler.tick(0); + expect(h.calls.length).toBe(1); + + sessions[0] = { ...sessions[0], docker: { container: 'c' } }; + await h.scheduler.tick(400_000); + expect(h.calls.length).toBe(1); + }); + + /** + * The Docker case list is rewritten in place, so a read can land on a half-written file. Read as "no Docker + * workspaces", a tick would run git (and the repository's hooks) inside a folder a container can write to. + */ + it('fetches nothing when the Docker case list cannot be read, keeps the folders due, and logs it once', async () => { + const h = harness([VIEW('a'), VIEW('b')]); + let unreadable = true; + h.ports.dockerWorkspaces = async () => { + h.dockerReads++; + if (unreadable) throw new Error('docker-cases.json is not a valid JSON array'); + return []; + }; + const logged = vi.fn(); + h.ports.log = logged; + expect(await h.scheduler.tick(0)).toBe(0); + expect(await h.scheduler.tick(15_000)).toBe(0); + expect(h.calls).toEqual([]); + expect(h.dockerReads).toBe(2); // the picks were undone, so the next tick picked them again + expect(logged).toHaveBeenCalledTimes(1); + expect(h.scheduler.tracked()).toBe(0); // nothing left in flight or stamped + + // The list reads again: both folders run at once, not an interval after the failed ticks. + unreadable = false; + expect(await h.scheduler.tick(30_000)).toBe(2); + expect(h.calls.map((c) => c.cwd).sort()).toEqual(['/w/a', '/w/b']); + // And their interval is timed from that run. + expect(await h.scheduler.tick(30_000 + 299_000)).toBe(0); + expect(await h.scheduler.tick(30_000 + 300_000)).toBe(2); + }); + + it('is wired to the strict Docker reader the Fetch button uses', () => { + const server = readFileSync(resolve(import.meta.dirname, '../src/web/server.ts'), 'utf8'); + const block = server.slice(server.indexOf('new GitFetchScheduler(')); + // defaultDockerWorkspaces is the strict one: test/routes/git-status-routes.test.ts pins that it rejects. + expect(block.slice(0, block.indexOf('});'))).toContain('dockerWorkspaces: defaultDockerWorkspaces,'); + }); + + it('uses the module defaults for the limit and the timeout, because those settings are per device and never reach the server', async () => { + const h = harness([VIEW('a')]); + await h.scheduler.tick(0); + expect(h.calls[0].opts).toMatchObject({ maxRepos: MAX_REPOS, timeoutMs: DEFAULT_GIT_TIMEOUT_MS }); + }); +}); + +describe('one folder, one run', () => { + it('fetches a shared folder once and publishes the result to every session open on it', async () => { + const h = harness([VIEW('a'), { ...VIEW('b'), workingDir: '/w/a' }]); + expect(await h.scheduler.tick(0)).toBe(1); // one folder, not two runs + expect(h.calls.length).toBe(1); + expect(h.published.map((p) => p.id).sort()).toEqual(['a', 'b']); + // Both see the same run, so the panel's row says the same thing in both tabs. + expect(new Set(h.published.map((p) => p.status.failed)).size).toBe(1); + }); + + it('is due when ANY session open on the folder is due, in either read order, because the clock belongs to the folder and not to whichever session was read first', async () => { + const short = { ...VIEW('short'), workingDir: '/w/shared', gitFetchIntervalSeconds: 30 }; + const long = { ...VIEW('long'), workingDir: '/w/shared', gitFetchIntervalSeconds: 300 }; + + for (const order of [ + [short, long], + [long, short], + ]) { + const h = harness(order); + + // Nothing has run yet, so both are due and both are published from the one run. The folder is probed + // once, not once per session open on it. + expect(await h.scheduler.tick(0)).toBe(1); + expect(h.probed).toEqual(['/w/shared']); + expect(h.published.map((p) => p.id).sort()).toEqual(['long', 'short']); + + // Before the short interval elapses neither is due, and the order they were read in cannot change that. + h.published.length = 0; + expect(await h.scheduler.tick(15_000)).toBe(0); + expect(h.published).toEqual([]); + + // The short session is past its own interval, so the folder is fetched (once, for both). The run that + // happens covers the long session's interval too, and both still get it because both are open on the + // folder: publishing only the session that made the folder due would leave the other tab on a stale row. + h.published.length = 0; + expect(await h.scheduler.tick(30_000)).toBe(1); + expect(h.calls.length).toBe(2); + expect(h.published.map((p) => p.id).sort()).toEqual(['long', 'short']); + + // The long one's own interval comes round, so the folder is due again. + h.published.length = 0; + expect(await h.scheduler.tick(300_000)).toBe(1); + expect(h.published.map((p) => p.id).sort()).toEqual(['long', 'short']); + } + }); + + it('does not pick a folder again while its run is going, and does not run a second tick over the same selection', async () => { + const ids = Array.from({ length: FETCH_CONCURRENCY + 2 }, (_, i) => `s${i}`); + const gate = deferred<GitFetchRun>(run('/w/first', 'first')); + const h = harness( + ids.map((id) => VIEW(id)), + { + settings: { enabled: true, intervalSeconds: 30 }, + fetch: () => gate.promise, + } + ); + + const first = h.scheduler.tick(0); + await new Promise((resolve) => setImmediate(resolve)); // the first runs are going, the rest are queued + const picked = h.calls.length; + expect(picked).toBe(FETCH_CONCURRENCY); + + // The server fires the next tick without waiting. The schedule was stamped at selection, so the folders + // already picked are not picked again, and this tick is skipped while the previous one is still going. + expect(await h.scheduler.tick(15_000)).toBe(0); + expect(h.calls.length).toBe(picked); + + gate.resolve(run('/w/first', 'first')); + await first; + // Every folder ran exactly once, and no folder ran twice. + expect(h.calls.length).toBe(ids.length); + expect(new Set(h.calls.map((c) => c.cwd)).size).toBe(ids.length); + }); + + it('shares one bound across the whole tick instead of giving each folder its own, and bounds the folders with a bound of its own', async () => { + const ids = Array.from({ length: FETCH_CONCURRENCY + 1 }, (_, i) => `s${i}`); + let fetchesLive = 0; + let fetchesPeak = 0; + let foldersLive = 0; + let foldersPeak = 0; + const hold = () => new Promise((resolve) => setImmediate(resolve)); + const h = harness( + ids.map((id) => VIEW(id)), + { + // Every folder is handed the SAME limiter for its work, so that work cannot get its own bound: a + // per-call bound would let 5 folders × 6 items run at once. The folders themselves are bounded by a + // DIFFERENT bound, because one semaphore cannot serve both levels: a folder holding a slot and then + // waiting for one for its own fetch is waiting on itself, and the folders that fill the bound are + // exactly the ones blocking it. If the tick used one semaphore for both, this test would hang and fail + // as a timeout rather than as an assertion. + fetch: async (cwd, opts) => { + foldersLive++; + foldersPeak = Math.max(foldersPeak, foldersLive); + await opts.limiter?.([0, 1, 2, 3, 4, 5], async () => { + fetchesLive++; + fetchesPeak = Math.max(fetchesPeak, fetchesLive); + await hold(); + fetchesLive--; + }); + foldersLive--; + return run(cwd, cwd.slice(3)); + }, + } + ); + await h.scheduler.tick(0); + expect(fetchesPeak).toBe(FETCH_CONCURRENCY); // the tick's ceiling, not each folder's own + expect(foldersPeak).toBe(FETCH_CONCURRENCY); + expect(h.calls.length).toBe(ids.length); // every folder ran, none was left waiting forever + }); + + it('times the interval from the START of a run, so a slow repository does not push the next one out', async () => { + const slow = deferred<GitFetchRun>(run('/w/a', 'a')); + const h = harness([VIEW('a')], { settings: { enabled: true, intervalSeconds: 30 }, fetch: () => slow.promise }); + + const first = h.scheduler.tick(0); + await new Promise((resolve) => setImmediate(resolve)); + expect(h.calls.length).toBe(1); + + // The run takes 200 s, longer than its own interval. Once it lands, the folder is already past due: the + // next tick runs it again instead of waiting another 30 s from when the slow one finished. + slow.resolve(run('/w/a', 'a')); + await first; + expect(await h.scheduler.tick(200_000)).toBe(1); + expect(h.calls.length).toBe(2); + }); +}); + +describe('what a run reports', () => { + it("publishes the run as the interval's own, so the panel can say the number was not asked for", async () => { + const h = harness([VIEW('a')]); + await h.scheduler.tick(0); + expect(h.published).toEqual([ + { + id: 'a', + status: { ranAt: expect.any(Number), trigger: 'interval', state: 'ok', repos: 1, failed: 0 }, + }, + ]); + }); + + it('publishes the trigger of the run that actually happened rather than the trigger the tick asked for, because a tick that joined a click did not fetch anything', async () => { + const h = harness([VIEW('a')], { + // The port is `fetchWorkspaceOnce`, so it can hand back a run a click had already started for this + // folder. The tick asked for its own; the row has to name the run. + fetch: (cwd, _opts, trigger) => { + expect(trigger).toBe('interval'); + return Promise.resolve({ report: report(cwd, 'a'), trigger: 'click' }); + }, + }); + await h.scheduler.tick(0); + expect(h.published[0].status.trigger).toBe('click'); + }); + + it("reports a failure instead of retrying harder, and keeps the folder's next run at its own interval", async () => { + const h = harness([VIEW('a')], { + settings: { enabled: true, intervalSeconds: 30 }, + fetch: () => Promise.reject(new Error('spawn git ENOENT')), + }); + const logged = vi.fn(); + h.ports.log = logged; + await h.scheduler.tick(0); + expect(logged).toHaveBeenCalled(); + expect(h.published).toEqual([]); + // Still remembered, so the next run is on schedule rather than immediate. + expect(h.scheduler.tracked()).toBe(1); + }); +}); + +describe('the effective choice', () => { + it("a session's own override wins over the app setting", () => { + const settings = { enabled: false, intervalSeconds: 600 }; + expect(effectiveGitFetch(VIEW('a', { gitFetchEnabled: true, gitFetchIntervalSeconds: 45 }), settings)).toEqual({ + enabled: true, + intervalSeconds: 45, + }); + // Half a choice still follows the app for the other half. + expect(effectiveGitFetch(VIEW('b', { gitFetchEnabled: true }), settings)).toEqual({ + enabled: true, + intervalSeconds: 600, + }); + }); + + it('re-clamps a persisted interval that can predate the schema bounds', () => { + expect(effectiveGitFetch(VIEW('a', { gitFetchIntervalSeconds: 5 }), SETTINGS)).toEqual({ + enabled: true, + intervalSeconds: 30, + }); + expect(effectiveGitFetch(VIEW('b', { gitFetchIntervalSeconds: 9000 }), SETTINGS)).toEqual({ + enabled: true, + intervalSeconds: 3600, + }); + }); +}); + +describe('a session that goes away', () => { + it('takes its own choice with it, but keeps the folder while another session is still open on it', async () => { + const sessions = [VIEW('a'), { ...VIEW('b'), workingDir: '/w/a' }]; + const h = harness(sessions); + await h.scheduler.tick(0); + expect(h.scheduler.tracked()).toBe(1); + + h.remove('a'); + // The run belongs to the clone, so 'b' keeps the folder's schedule and its next run is still on time: + // the choice that got the folder going was 'a's, but the folder is not 'a's. + expect(h.scheduler.tracked()).toBe(1); + await h.scheduler.tick(1000); // not due yet, and 'b' is still open on the folder + expect(h.calls.length).toBe(1); + + // The last session open on the folder goes, so the folder is forgotten: a session coming back on it is due + // at once rather than on the schedule the closed one left behind. + h.remove('b'); + expect(h.scheduler.tracked()).toBe(0); + sessions.push({ ...VIEW('c'), workingDir: '/w/a' }); + await h.scheduler.tick(2000); + expect(h.calls.length).toBe(2); // remembered again, from now + }); + + it('stops everything after stop(), including a folder that was already due', async () => { + const h = harness([VIEW('a')]); + h.scheduler.stop(); + expect(await h.scheduler.tick(0)).toBe(0); + expect(h.calls).toEqual([]); + expect(h.scheduler.tracked()).toBe(0); + }); +}); diff --git a/test/git-fetch-session-options-ui.test.ts b/test/git-fetch-session-options-ui.test.ts new file mode 100644 index 00000000..ec2ec44a --- /dev/null +++ b/test/git-fetch-session-options-ui.test.ts @@ -0,0 +1,155 @@ +/** + * @fileoverview The Session Options → Git rows (session-ui.js `updateGitFetchStatus`) as the page drives them: the + * real index.html in JSDOM with the real app.js and session-ui.js loaded into it, and every session update + * delivered through app.js `_onSessionUpdated`, the path a `session:updated` SSE event takes. + * + * 1. A working session sends `session:updated` on each working/idle change, and that path refreshes the open Git + * tab every time, so the control the user is in is left alone: rewriting it put a half-typed "12" back to "300" + * before `onchange` could save it. Enter saves with the field still focused, so the save writes the clamped + * number back itself. + * 2. The route takes one field at a time, so a session can hold half an override. The half it did not choose + * shows the app's value, which is what the scheduler runs it with, never a default. + * + * Port: N/A (no server; the one save is answered by a stub). + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const SID = 'session-git-rows'; +/** The app setting every case here runs against: on, every 900 s. */ +const APP = { gitFetchEnabled: true, gitFetchIntervalSeconds: 900 }; + +type Update = Record<string, unknown> & { id: string }; +type TestApp = Record<string, unknown> & { + _onSessionUpdated(data: Update): void; + autoSaveGitFetch(): Promise<void>; + _apiJson: ReturnType<typeof vi.fn>; +}; + +let dom: JSDOM; +let CodemanApp: { prototype: object }; + +beforeAll(async () => { + dom = new JSDOM(readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'), { + runScripts: 'outside-only', + url: 'http://localhost/', + }); + // app.js starts the app on DOMContentLoaded. Loading it once the document is complete means that never fires + // here, so the real constructor (SSE, WebSocket, timers) never runs in a window with no server behind it. + await new Promise<void>((done) => + dom.window.document.readyState === 'complete' ? done() : dom.window.addEventListener('load', () => done()) + ); + const context = dom.getInternalVMContext(); + // app.js's last line publishes MobileDetection (mobile-handlers.js), which nothing here otherwise needs. + vm.runInContext('var MobileDetection = { isTouchDevice: () => false };', context); + for (const file of ['constants.js', 'app.js', 'session-ui.js']) { + vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file }); + } + CodemanApp = vm.runInContext('CodemanApp', context); +}); + +afterAll(() => dom.window.close()); + +beforeEach(() => { + (dom.window.document.activeElement as HTMLElement | null)?.blur?.(); +}); + +const el = <T extends HTMLElement = HTMLInputElement>(id: string) => dom.window.document.getElementById(id) as T; + +/** An app with the Git tab open on SID and everything the update path draws besides it stubbed. */ +function openGitTab(): TestApp { + const app = Object.create(CodemanApp.prototype) as TestApp; + Object.assign(app, { + sessions: new Map(), + _closingSessions: new Set(), + activeSessionId: null, + editingSessionId: SID, + renderSessionTabs: vi.fn(), + updateCost: vi.fn(), + updateSubagentParentNames: vi.fn(), + showToast: vi.fn(), + loadAppSettingsFromStorage: () => ({ ...APP }), + }); + return app; +} + +describe('Session Options → Git, under session updates', () => { + it('leaves the interval the user is typing alone when a session update arrives, and still refreshes the run line', () => { + const app = openGitTab(); + app._onSessionUpdated({ id: SID, status: 'idle', gitFetchEnabled: true, gitFetchIntervalSeconds: 300 }); + const num = el('modalGitFetchInterval'); + expect(num.disabled).toBe(false); + expect(num.value).toBe('300'); + + num.focus(); + expect(dom.window.document.activeElement).toBe(num); + num.value = '12'; + // The working/idle flip that used to put the field back to 300, carrying a scheduled run's result this time. + app._onSessionUpdated({ + id: SID, + status: 'busy', + gitFetchEnabled: true, + gitFetchIntervalSeconds: 300, + gitFetch: { ranAt: Date.now(), trigger: 'interval', state: 'ok', repos: 2, failed: 0 }, + }); + expect(num.value).toBe('12'); + expect(el('gitFetchStatus').textContent).toBe('Last run just now on the interval · 2 repositories'); + + // Only the control the user is in is spared: once they leave it, an update shows the stored value again. + num.blur(); + app._onSessionUpdated({ id: SID, status: 'idle', gitFetchEnabled: true, gitFetchIntervalSeconds: 300 }); + expect(num.value).toBe('300'); + }); + + it('writes the clamped interval back when Enter saves with the field still focused', async () => { + const app = openGitTab(); + app._apiJson = vi.fn(async (_url: string, opts: { body: unknown }) => ({ gitFetchSettings: opts.body })); + app._onSessionUpdated({ id: SID, status: 'idle', gitFetchEnabled: true, gitFetchIntervalSeconds: 300 }); + const num = el('modalGitFetchInterval'); + num.focus(); + num.value = '12'; + + await app.autoSaveGitFetch(); + expect(app._apiJson).toHaveBeenCalledWith(`/api/sessions/${SID}/git-fetch-settings`, { + method: 'POST', + body: { enabled: true, intervalSeconds: 30 }, + }); + expect(dom.window.document.activeElement).toBe(num); + expect(num.value).toBe('30'); + }); +}); + +describe('Session Options → Git, with half an override', () => { + it("shows the app's switch beside an interval-only override, because the scheduler runs it with that switch", () => { + const app = openGitTab(); + app._onSessionUpdated({ id: SID, status: 'idle', gitFetchIntervalSeconds: 120 }); + expect(el('modalGitFetchFollow').checked).toBe(false); + expect(el('modalGitFetchEnabled').checked).toBe(true); + expect(el('modalGitFetchInterval').value).toBe('120'); + // Not following, so both rows are live, and touching either saves what they show. + expect(el('modalGitFetchEnabled').disabled).toBe(false); + expect(el('modalGitFetchInterval').disabled).toBe(false); + }); + + it("shows the app's interval beside a switch-only override, because the scheduler runs it at that interval", () => { + const app = openGitTab(); + app._onSessionUpdated({ id: SID, status: 'idle', gitFetchEnabled: true }); + expect(el('modalGitFetchFollow').checked).toBe(false); + expect(el('modalGitFetchEnabled').checked).toBe(true); + expect(el('modalGitFetchInterval').value).toBe('900'); + }); + + it('follows the app on both rows, inert, when the session holds no choice of its own', () => { + const app = openGitTab(); + app._onSessionUpdated({ id: SID, status: 'idle' }); + expect(el('modalGitFetchFollow').checked).toBe(true); + expect(el('modalGitFetchEnabled').checked).toBe(true); + expect(el('modalGitFetchInterval').value).toBe('900'); + expect(el('modalGitFetchEnabled').disabled).toBe(true); + expect(el('modalGitFetchInterval').disabled).toBe(true); + }); +}); diff --git a/test/git-fetch.test.ts b/test/git-fetch.test.ts new file mode 100644 index 00000000..26e6dbdd --- /dev/null +++ b/test/git-fetch.test.ts @@ -0,0 +1,770 @@ +/** + * @fileoverview Tests for the Git panel's Fetch button (src/git-fetch.ts): the only git command the Git panel + * runs that writes, and the only one that can run without being asked, which is why it is a click, or an + * interval the user turned on, and why the writing and the hooks it can run are pinned here. Cloning and + * worktree management write too, but they write when the user asked. + * + * Four halves, mirroring the module: + * + * 1. The PURE half: the fixed argv, the `-c` overrides the runner adds ahead of it, the ssh environment the + * runner refuses to override (`fetchEnvFor`), the failure classification, and the two reads the interval + * scheduler depends on: what the settings say (`gitFetchSettingsFrom`) and what a run did + * (`gitFetchStatusFrom`). No spawning. + * 2. The IO half: a REAL git fetching a REAL local bare repo, so that "remote-tracking refs only" is + * proven against git's actual behavior: HEAD, the index and the working tree must be exactly what they + * were while the remote-tracking ref moves. The rest of the half uses an injected runner over real + * repositories on disk, because what is being pinned is WHICH repositories git is allowed to touch. + * 3. The process group: a timed-out fetch has to take its `git-remote-http` helper with it, against a + * server that accepts and never answers, so the assertion is about what is still RUNNING after the answer + * went out. A test that only checked the returned error would pass with the old `execFile` runner, which + * signals the parent only. + * 4. The join (`fetchWorkspaceOnce`): one run per folder at a time, because two fetches in one clone + * collide on git's ref lock, the bound is ONE limiter shared with the scheduler's tick rather than a bound + * each run gets for itself, and the trigger a join reports is the joined run's, not the joiner's. + * + * Port: N/A (no Codeman server). The process-group test listens on its own loopback socket, on a port the OS + * picks, because the point of that test is a remote that never answers. + */ + +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createServer, type AddressInfo } from 'node:http'; +import type { Socket } from 'node:net'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { classifyGitFailure, isGitAvailable } from '../src/git-clone.js'; +import { + FETCH_CONCURRENCY, + FETCH_CONFIG_ARGS, + FETCH_KILL_ESCALATION_MS, + buildFetchArgs, + clearGitFetchLane, + describeFetchFailure, + fetchEnvFor, + fetchGitWorkspaceOverview, + fetchWorkspaceOnce, + gitFetchSettingsFrom, + gitFetchStatusFrom, + runFetch, + type GitFetchReport, +} from '../src/git-fetch.js'; +import { createLimiter, runGit, type GitRunner } from '../src/git-workspace-status.js'; + +const ENV = { + ...process.env, + // As the production runner: git's messages are matched in English, and nothing reads the host's config. + LC_ALL: 'C', + GIT_AUTHOR_NAME: 'T', + GIT_AUTHOR_EMAIL: 't@example.com', + GIT_COMMITTER_NAME: 'T', + GIT_COMMITTER_EMAIL: 't@example.com', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_NOSYSTEM: '1', +}; +const git = (cwd: string, ...args: string[]) => + execFileSync('git', args, { cwd, env: ENV, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + +// ─── The pure half ─────────────────────────────────────────────────────────── + +describe('buildFetchArgs', () => { + it('pins the argv to a fetch and nothing else', () => { + expect(buildFetchArgs()).toEqual(['fetch', '--quiet']); + // Not just the first word: the whole argv is fixed, so no verb that changes the repository can slip in. + for (const verb of ['commit', 'pull', 'merge', 'checkout', 'reset', 'clean', 'prune', 'gc', 'amend']) { + expect(buildFetchArgs().join(' ')).not.toContain(verb); + } + }); + + it('names no repository, so the repository can only be the process cwd', () => { + // An operand-shaped argument would let a path or a URL choose the repository; the fixed argv cannot. + for (const arg of buildFetchArgs()) { + expect(arg.startsWith('-')).toBe(arg === '--quiet'); + } + }); +}); + +describe('describeFetchFailure', () => { + it("calls a repository with no remote a fact, not a fault, in each of git's wordings", () => { + for (const stderr of [ + 'fatal: no default remote repository -- use --remote or specify a repository', + 'No remote configured', + 'fatal: nothing to fetch from remote repository', + ]) { + const failure = describeFetchFailure({ stderr }); + expect(failure.code).toBe('NO_REMOTE'); + expect(failure.message).toMatch(/no remote/i); + expect(failure.message).not.toMatch(/fail|error|unable/i); + } + }); + + it('writes its own timeout message, because the one to reuse was written for clones', () => { + const failure = describeFetchFailure({ code: 'ETIMEDOUT', stderr: 'fatal: repository not found' }); + expect(failure.code).toBe('TIMEOUT'); + expect(failure.message).toMatch(/Git status: git timeout/); + expect(failure.message).toMatch(/fetch from a shell/i); + expect(failure.message).not.toBe(classifyGitFailure('fatal: repository not found', true).message); + expect(describeFetchFailure({ killed: true, stderr: '' }).code).toBe('TIMEOUT'); + }); + + it('reuses the network reasons it already knows', () => { + const prompt = "fatal: could not read Username for 'https://github.com': terminal prompts disabled"; + expect(describeFetchFailure({ stderr: prompt }).code).toBe('AUTH_REQUIRED'); + expect(describeFetchFailure({ stderr: 'git@github.com: Permission denied (publickey).' }).code).toBe( + 'AUTH_REQUIRED' + ); + expect(describeFetchFailure({ message: 'Error: spawn git ENOENT' }).code).toBe('GIT_MISSING'); + expect(describeFetchFailure({ stderr: 'fatal: unable to access: Could not resolve host: nope.invalid' }).code).toBe( + 'HOST_UNREACHABLE' + ); + }); + + it('hands the client a redacted, control-stripped tail, never what git printed', () => { + const failure = describeFetchFailure({ + stderr: "fatal: unable to access 'https://bob:ghp_secret@github.com/x.git/'\u001b[31m", + }); + expect(failure.stderr).toBe("fatal: unable to access 'https://***:***@github.com/x.git/'[31m"); + }); +}); + +// ─── The two reads the interval feature depends on ─────────────────────────── + +describe('gitFetchSettingsFrom', () => { + it('is off until the user turns it on, and 300 s when nothing was chosen', () => { + // Absent means off: a quiet install must not start running git on a timer nobody asked for. + for (const settings of [undefined, {}, { gitFetchEnabled: false }, { gitFetchEnabled: 'true' }]) { + expect(gitFetchSettingsFrom(settings).enabled).toBe(false); + } + expect(gitFetchSettingsFrom({ gitFetchEnabled: true }).enabled).toBe(true); + expect(gitFetchSettingsFrom({}).intervalSeconds).toBe(300); + }); + + it('clamps a number outside the schema instead of trusting it, because a persisted value can predate the bounds', () => { + expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: 1 }).intervalSeconds).toBe(30); + expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: 99999 }).intervalSeconds).toBe(3600); + expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: 120.6 }).intervalSeconds).toBe(121); + expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: -300 }).intervalSeconds).toBe(30); + // A hand-edited 0 is still a number it can act on, and acting on it means the smallest interval that runs. + expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: 0 }).intervalSeconds).toBe(30); + }); + + it('falls back to the default on a value it cannot act on, which is what a hand-edited settings.json can hold', () => { + for (const raw of ['300', undefined, null, NaN, Infinity, true]) { + expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: raw }).intervalSeconds, String(raw)).toBe(300); + } + }); +}); + +describe('gitFetchStatusFrom', () => { + it('counts what a run covered and what it could not do, and names the first failure', () => { + const report: GitFetchReport = { + state: 'ok', + repos: [ + { name: 'Codeman', path: '.', ok: true }, + { name: 'api', path: 'api', ok: false, failure: { code: 'NO_REMOTE', message: 'no remote', stderr: '' } }, + ], + reposTruncated: false, + fetchedAt: 1700, + }; + expect(gitFetchStatusFrom(report, 'interval')).toEqual({ + ranAt: 1700, + trigger: 'interval', + state: 'ok', + repos: 2, + failed: 1, + firstFailure: { name: 'api', code: 'NO_REMOTE' }, + }); + }); + + it('keeps the trigger, because a number the user did not ask for should read differently from one they did', () => { + const empty: GitFetchReport = { state: 'ok', repos: [], reposTruncated: false, fetchedAt: 1000 }; + expect(gitFetchStatusFrom(empty, 'click').trigger).toBe('click'); + expect(gitFetchStatusFrom(empty, 'interval').trigger).toBe('interval'); + }); + + it('records a state that stopped the run, with nothing to count and no failure to name', () => { + const status = gitFetchStatusFrom( + { state: 'unsupported', reason: 'docker', repos: [], reposTruncated: false, fetchedAt: 5 }, + 'interval' + ); + expect(status.state).toBe('unsupported'); + expect(status.repos).toBe(0); + expect(status.failed).toBe(0); + expect(status.firstFailure).toBeUndefined(); + }); +}); + +describe('FETCH_CONFIG_ARGS', () => { + it('turns off the hooks and the maintenance a fetch can otherwise start, pinned without spawning git', () => { + expect(FETCH_CONFIG_ARGS).toEqual([ + '-c', + 'core.fsmonitor=false', + '-c', + 'maintenance.auto=false', + '-c', + 'gc.auto=0', + ]); + // Read as pairs: a `-c` and the setting it overrides, and every override is one of these three. + for (let i = 0; i < FETCH_CONFIG_ARGS.length; i += 2) { + expect(FETCH_CONFIG_ARGS[i]).toBe('-c'); + expect(FETCH_CONFIG_ARGS[i + 1]).toMatch(/^(core\.fsmonitor|maintenance\.auto|gc\.auto)=/); + } + expect(FETCH_CONFIG_ARGS.filter((a) => a !== '-c').sort()).toEqual([ + 'core.fsmonitor=false', + 'gc.auto=0', + 'maintenance.auto=false', + ]); + }); + + it('is kept apart from the pinned argv so both can be read without spawning, and the runner composes them in that order', () => { + // The argv the recorded calls pin stays the verb alone: the overrides come from the runner, not from here. + for (const arg of buildFetchArgs()) expect(arg).not.toBe('-c'); + // Overrides before the verb, which is where git will take them. + expect([...FETCH_CONFIG_ARGS, ...buildFetchArgs()]).toEqual([...FETCH_CONFIG_ARGS, 'fetch', '--quiet']); + }); +}); + +describe('fetchEnvFor', () => { + const BASE = { HOME: '/home/t', PATH: '/usr/bin' }; + + it('writes the BatchMode default when nothing else already answered', () => { + const env = fetchEnvFor(BASE, null); + expect(env.GIT_SSH_COMMAND).toContain('BatchMode=yes'); + expect(env.GIT_TERMINAL_PROMPT).toBe('0'); + }); + + it('does not override a repository that already chose its own ssh, because that choice is usually the one that works', () => { + const env = fetchEnvFor(BASE, 'ssh -i /home/t/work_key -o ProxyJump=gw'); + // The whole point: nothing is written, so git takes the repository's value from its own config. + expect(env.GIT_SSH_COMMAND).toBeUndefined(); + // Honouring the repository's ssh is not the same as letting it prompt, so the rest still applies. + expect(env.GIT_TERMINAL_PROMPT).toBe('0'); + expect(env.SSH_ASKPASS_REQUIRE).toBe('never'); + }); + + it("the user's own GIT_SSH_COMMAND still beats the repository's, because that is git's rule and not this module's to second-guess", () => { + const env = fetchEnvFor({ ...BASE, GIT_SSH_COMMAND: 'ssh -i /home/t/personal' }, 'ssh -i /repo/key'); + expect(env.GIT_SSH_COMMAND).toBe('ssh -i /home/t/personal'); + }); + + it("leaves a GIT_SSH in the server's environment to git, which reads it last and would never see it under a written default", () => { + const env = fetchEnvFor({ ...BASE, GIT_SSH: '/home/t/bin/work-ssh' }, null); + expect(env.GIT_SSH_COMMAND).toBeUndefined(); + expect(env.GIT_SSH).toBe('/home/t/bin/work-ssh'); + expect(env.GIT_TERMINAL_PROMPT).toBe('0'); + }); +}); + +// ─── Real git, real local repository ───────────────────────────────────────── + +const gitPresent = isGitAvailable(); + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'git-fetch-')); +}); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +function initRepo(path: string, remote?: string) { + mkdirSync(path, { recursive: true }); + git(path, 'init', '-q', '-b', 'main'); + // So a `file://` remote stays allowed for the production runner too, not only for this test's own git calls. + git(path, 'config', 'protocol.file.allow', 'always'); + writeFileSync(join(path, 'f.txt'), '1\n'); + git(path, 'add', '-A'); + git(path, 'commit', '-q', '-m', 'init'); + if (remote) git(path, 'remote', 'add', 'origin', remote); + return path; +} + +/** Records what each repository was asked to do, and lets real git answer the rest. */ +function recordingRunner() { + const calls: Array<[string, string[]]> = []; + const runner: GitRunner = async (cwd, args, opts) => { + calls.push([cwd, args]); + return runGit(cwd, args, opts); + }; + return { calls, runner }; +} + +describe.skipIf(!gitPresent)('fetchGitWorkspaceOverview (real git)', () => { + let work: string; + + const snapshot = (path: string) => ({ + head: git(path, 'rev-parse', 'HEAD').trim(), + tree: readdirSync(path, { withFileTypes: true }) + .filter((e) => e.name !== '.git' && e.isFile()) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((e) => `${e.name}=${readFileSync(join(path, e.name), 'utf8')}`), + // The global option has to come before the verb, exactly as the read-only status module runs it. + index: git(path, '--no-optional-locks', 'status', '--porcelain=v2').trim(), + }); + + beforeEach(() => { + const origin = join(dir, 'origin.git'); + mkdirSync(origin); + git(origin, 'init', '--bare', '--quiet', '-b', 'main'); + work = initRepo(join(dir, 'work'), origin); + git(work, 'push', '-q', 'origin', 'main'); + + // A commit made elsewhere and pushed: work's origin/main cannot hear about it until a fetch runs. + // This has to be a clone of origin, not a fresh init: a commit on its own root commit is no + // descendant of what origin already holds, so pushing it is rejected as non-fast-forward. + const other = join(dir, 'other'); + git(dir, 'clone', '-q', origin, 'other'); + git(other, 'config', 'protocol.file.allow', 'always'); + writeFileSync(join(other, 'g.txt'), '2\n'); + git(other, 'add', '-A'); + git(other, 'commit', '-q', '-m', 'second'); + git(other, 'push', '-q', 'origin', 'main'); + + // Left dirty on purpose, so the snapshot has something proving it was not touched. + writeFileSync(join(work, 'untracked.txt'), 'u\n'); + writeFileSync(join(work, 'f.txt'), 'modified\n'); + }); + + it('fetches through the production runner, moving remote-tracking refs and writing nothing else', async () => { + // A push does not update the remote-tracking ref, so the fetch is what has to move it: 0 before, 1 after. + expect(git(work, 'rev-list', '--count', 'main..origin/main').trim()).toBe('0'); + const before = snapshot(work); + + const report = await fetchGitWorkspaceOverview(work); + expect(report.state).toBe('ok'); + expect(report.repos).toHaveLength(1); + expect(report.repos[0]).toMatchObject({ name: 'work', path: '.', ok: true }); + expect(report.fetchedAt).toBeGreaterThan(0); + + expect(snapshot(work)).toEqual(before); // HEAD, the working tree and the index are exactly as they were + expect(git(work, 'rev-list', '--count', 'main..origin/main').trim()).toBe('1'); + }); + + it('fetches exactly the repositories the panel would show, in the panel order', async () => { + const folder = join(dir, 'folder'); + for (const name of ['api', 'web']) initRepo(join(folder, name)); + mkdirSync(join(folder, 'node_modules', 'pkg'), { recursive: true }); // never a project the panel lists + const { calls, runner } = recordingRunner(); + const report = await fetchGitWorkspaceOverview(folder, { git: runner }); + expect(report.state).toBe('ok'); + expect(report.repos.map((r) => [r.name, r.path])).toEqual([ + ['api', 'api'], + ['web', 'web'], + ]); + // Only the fetches, not the resolver's own `rev-parse`: the repositories touched are the point. + // A bounded run does not promise which one starts first, so compare the set. + expect(calls.filter(([, args]) => args[0] === 'fetch').sort()).toEqual([ + [join(folder, 'api'), ['fetch', '--quiet']], + [join(folder, 'web'), ['fetch', '--quiet']], + ]); + }); + + it('never reaches an unrelated repository above the workspace', async () => { + const home = join(dir, 'home'); + initRepo(home); + const caseDir = join(home, 'case'); + mkdirSync(caseDir); + const { calls, runner } = recordingRunner(); + const report = await fetchGitWorkspaceOverview(caseDir, { git: runner, home }); + expect(report.state).toBe('not-a-repo'); + expect(calls.filter(([, args]) => args[0] === 'fetch')).toEqual([]); + }); + + it('refuses a repository at or inside a Docker case workspace before any git runs', async () => { + initRepo(join(dir, 'repo')); + const { calls, runner } = recordingRunner(); + const report = await fetchGitWorkspaceOverview(dir, { git: runner, dockerWorkspaces: [dir] }); + expect(report).toMatchObject({ state: 'unsupported', reason: 'docker' }); + expect(calls).toEqual([]); + }); + + it('keeps a repository that could not be fetched, with its reason', async () => { + for (const name of ['ok', 'private']) initRepo(join(dir, 'folder', name)); + const runner: GitRunner = async (cwd) => { + if (basename(cwd) === 'private') { + throw Object.assign(new Error('Command failed'), { + stderr: "fatal: could not read Username for 'https://github.com': terminal prompts disabled", + }); + } + return ''; + }; + const report = await fetchGitWorkspaceOverview(join(dir, 'folder'), { git: runner }); + expect(report.state).toBe('ok'); + const priv = report.repos.find((r) => r.name === 'private'); + expect(priv?.ok).toBe(false); + expect(priv?.failure?.code).toBe('AUTH_REQUIRED'); + expect(priv?.failure?.message).toMatch(/authentication/i); + expect(report.repos.find((r) => r.name === 'ok')?.ok).toBe(true); + }); + + it("runs at most FETCH_CONCURRENCY fetches at a time, with the setting's timeout", async () => { + const folder = join(dir, 'folder'); + for (let i = 0; i < FETCH_CONCURRENCY + 2; i++) initRepo(join(folder, `r${i}`)); + let inFlight = 0; + let max = 0; + const seenTimeouts = new Set<number | undefined>(); + const runner: GitRunner = async (cwd, args, opts) => { + seenTimeouts.add(opts?.timeoutMs); + inFlight++; + max = Math.max(max, inFlight); + await new Promise((r) => setTimeout(r, 10)); + inFlight--; + return ''; + }; + const report = await fetchGitWorkspaceOverview(folder, { git: runner, timeoutMs: 7000 }); + expect(report.repos).toHaveLength(FETCH_CONCURRENCY + 2); + expect(report.repos.every((r) => r.ok)).toBe(true); + expect(max).toBe(FETCH_CONCURRENCY); + expect(seenTimeouts).toEqual(new Set([7000])); + }); + + it('answers not-a-repo for a folder that is not a repository, running no fetch', async () => { + const plain = join(dir, 'plain'); + mkdirSync(plain); + const { calls, runner } = recordingRunner(); + expect(await fetchGitWorkspaceOverview(plain, { git: runner })).toMatchObject({ state: 'not-a-repo', repos: [] }); + expect(calls.filter(([, args]) => args[0] === 'fetch')).toEqual([]); + }); +}); + +// ─── The whole tree, not just the git parent ───────────────────────────────── + +/** One process as /proc reports it. `null` means it is gone, which is what the last assertion is about. */ +interface Proc { + pid: number; + comm: string; + ppid: number; + /** The PROCESS GROUP. `detached: true` puts the fetch in its own, so the negative pid reaches the children. */ + pgrp: number; + cmdline: string; +} + +/** + * Every process on the machine, as /proc reports it, so this half can only run on Linux, and the fields are + * counted from the closing parenthesis because `comm` can hold spaces. + * + * Membership of the GROUP is what is checked, not parentage: a helper that outlives its parent stops being a + * child of this test process, and a test that looked for children would pass for the wrong reason. + */ +function scanProc(): Proc[] { + const out: Proc[] = []; + for (const entry of readdirSync('/proc')) { + if (!/^\d+$/.test(entry)) continue; + const pid = Number(entry); + try { + const stat = readFileSync(join('/proc', entry, 'stat'), 'utf8'); + const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' '); + out.push({ + pid, + comm: readFileSync(join('/proc', entry, 'comm'), 'utf8').trim(), + ppid: Number(fields[1]), + pgrp: Number(fields[2]), + cmdline: readFileSync(join('/proc', entry, 'cmdline'), 'utf8') + .replace(/\0/g, ' ') + .trim(), + }); + } catch { + /* gone between the listing and the read */ + } + } + return out; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe.skipIf(!gitPresent)('runFetch (the whole tree, not just the git parent)', () => { + it.skipIf(!existsSync('/proc/self/environ'))( + 'kills the helper processes too, so nothing is left running after the timeout', + async () => { + const server = createServer(() => { + // Accept and never answer: git's http helper connects, waits, and stays. + }); + const sockets = new Set<Socket>(); + server.on('connection', (s) => { + sockets.add(s); + s.on('close', () => sockets.delete(s)); + }); + await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + + try { + const repo = initRepo(join(dir, 'hang'), `http://127.0.0.1:${port}/x.git`); + // The handler goes on the moment the promise is made, not when it is read: the timeout fires while the + // tree is still being looked at, and a rejection with nothing listening to it becomes an unhandled + // rejection instead of this test's answer. + const answered = runFetch(repo, buildFetchArgs(), { timeoutMs: 1_500 }).catch((e) => e); + + // The tree has to be a tree before the timeout, or nothing is being proven: git itself, and the helper + // it spawns for an http remote. The helper is a grandchild (`git fetch` runs `git remote-http`, which + // spawns `git-remote-http`), so the group, not the parentage, is what proves it. Each process is + // remembered once found rather than re-scanned: after the kill the group is empty, and a test that kept + // looking would report a tree it had already destroyed. The `git config` read the runner does first is + // a `git` too, so the fetch is found by what it was asked to do. + let parent: Proc | null = null; + let helper: Proc | null = null; + for (let i = 0; i < 60 && !(parent && helper); i++) { + const live = scanProc(); + parent = + parent ?? + live.find((p) => p.comm === 'git' && p.cmdline.includes('fetch') && p.ppid === process.pid) ?? + null; + helper = + helper ?? + (parent + ? live.find((p) => p.pgrp === parent!.pgrp && p.pid !== parent!.pid && p.cmdline.includes('remote-http')) + : null); + if (!helper) await sleep(25); + } + expect(parent, 'the fetch was never running').not.toBeNull(); + expect(helper, 'nothing else in the fetch group, so there was no tree to kill').not.toBeNull(); + // `detached: true` puts the fetch in a group of its own, which is why the negative pid is the right + // thing to signal: a helper outside that group would survive a `child.kill()`. + expect(parent!.pgrp).toBe(parent!.pid); + expect(helper!.pgrp).toBe(parent!.pgrp); + + const err = (await answered) as { code?: string; killed?: boolean }; + expect(err.code).toBe('ETIMEDOUT'); + expect(err.killed).toBe(true); + + // The answer went out before the tree died: that is the whole point of the fix, and it is why the + // escalation is given its own time here instead of being cancelled when the promise settles. + const deadline = Date.now() + FETCH_KILL_ESCALATION_MS + 2_000; + while (Date.now() < deadline && scanProc().some((p) => p.pgrp === parent!.pgrp)) await sleep(50); + expect(scanProc().some((p) => p.pgrp === parent!.pgrp)).toBe(false); + + // Nothing of this test's own is left running either. + await sleep(200); + expect(scanProc().filter((p) => p.ppid === process.pid)).toEqual([]); + } finally { + for (const s of sockets) s.destroy(); + server.close(); + } + } + ); + + it('rejects on a non-zero exit instead of resolving empty stdout, so a failure keeps a reason to be read', async () => { + // A remote git cannot read. A repository with no remote is not usable for this: `git fetch --quiet` exits 0 + // there on the git this was checked against (2.47.3), so resolving empty stdout would have been the right + // answer and the test would prove nothing. A remote pointing at a path that is not a repository exits 128 + // and says why, which is the case the row needs a reason for. + const repo = initRepo(join(dir, 'unreadable-remote'), 'file:///tmp/definitely-not-a-repo-xyz'); + let err: unknown = null; + try { + await runFetch(repo, buildFetchArgs()); + } catch (e) { + err = e; + } + expect(err, 'a fetch that failed resolved instead of rejecting').not.toBeNull(); + const e = err as { code?: unknown; stderr?: string; killed?: boolean }; + // The exit code, not the timeout marker, so a caller can still tell a failure from a stall. + expect(e.code).toBe(128); + expect(e.killed).toBe(false); + expect(e.stderr).toMatch(/does not appear to be a git repository/); + expect(describeFetchFailure(err).code).toBe('NOT_FOUND'); + }); +}); + +// ─── One bound for the config read and the fetch ───────────────────────────── + +describe.skipIf(process.platform === 'win32')('runFetch (one bound for the config read and the fetch)', () => { + it('answers at the timeout when git never answers, not at twice it, because the core.sshCommand read spends part of the bound', async () => { + // A `git` that never answers anything, first on PATH, so the read ahead of the fetch hangs exactly as long as + // the fetch would. Both spawns take PATH from process.env at call time. It records its arguments, so the test + // can tell which of the two it was asked to run. + const bin = join(dir, 'bin'); + const calls = join(dir, 'calls'); + mkdirSync(bin); + writeFileSync(join(bin, 'git'), `#!/bin/sh\necho "$*" >> '${calls}'\nexec sleep 30\n`, { mode: 0o755 }); + const savedPath = process.env.PATH; + process.env.PATH = `${bin}:${savedPath ?? ''}`; + try { + const started = Date.now(); + const err = (await runFetch(dir, buildFetchArgs(), { timeoutMs: 1_000 }).catch((e) => e)) as { + code?: unknown; + killed?: boolean; + }; + const elapsed = Date.now() - started; + + expect(err.code).toBe('ETIMEDOUT'); + expect(err.killed).toBe(true); + expect(describeFetchFailure(err).code).toBe('TIMEOUT'); + // One bound, not one per spawn: the read used all of it, so the fetch got nothing and was not started + // (2019 ms here before the fix, when the fetch was armed with a fresh timeout of its own). + expect(elapsed).toBeGreaterThanOrEqual(950); + expect(elapsed).toBeLessThan(1_600); + expect(readFileSync(calls, 'utf8').trim().split('\n')).toEqual(['config --get core.sshCommand']); + } finally { + if (savedPath === undefined) delete process.env.PATH; + else process.env.PATH = savedPath; + } + }); +}); + +// ─── One run per folder, and one bound for the whole tick ──────────────────── + +describe.skipIf(!gitPresent)('fetchWorkspaceOnce (the join)', () => { + // The resolver still runs real git, so these need real repositories on disk; what is injected is only the + // fetch, because what is being pinned here is the joining, not git's answer. + beforeEach(() => { + // The lane is module state: a run left in it by the previous test would be joined by the next one. + clearGitFetchLane(); + }); + + it('joins a run already going for the same folder instead of starting a second git fetch in the same clone', async () => { + const folder = join(dir, 'folder'); + initRepo(join(folder, 'api')); + + let releaseRun = () => {}; + const held = new Promise<void>((resolve) => (releaseRun = resolve)); + let running = () => {}; + const runStarted = new Promise<void>((resolve) => (running = resolve)); + let firstRunFetches = 0; + const firstRunner: GitRunner = async (cwd, args) => { + if (args[0] === 'fetch') { + firstRunFetches++; + running(); + await held; + } + return ''; + }; + const first = fetchWorkspaceOnce(folder, { git: firstRunner }, 'interval'); + await runStarted; // the run is going, and still going + + // A click while that run is working the folder: it joins, so it never touches git at all, not even the + // resolver's own probe, because it does not resolve anything for itself. + let joinerCalls = 0; + const joinerRunner: GitRunner = async () => { + joinerCalls++; + return ''; + }; + const joiner = fetchWorkspaceOnce(folder, { git: joinerRunner }, 'click'); + + releaseRun(); + const answer = await first; + // The joiner gets that same run's answer, including its limits: the point is not to fetch twice. + expect(await joiner).toBe(answer); + expect(joinerCalls).toBe(0); + expect(firstRunFetches).toBe(1); + }); + + it('a join reports the trigger of the run it joined, not the trigger that made it ask, because a click that fetched nothing cannot claim the fetch', async () => { + const folder = join(dir, 'folder'); + initRepo(join(folder, 'api')); + + let releaseRun = () => {}; + const held = new Promise<void>((resolve) => (releaseRun = resolve)); + let running = () => {}; + const runStarted = new Promise<void>((resolve) => (running = resolve)); + const runner: GitRunner = async (cwd, args) => { + if (args[0] === 'fetch') { + running(); + await held; + } + return ''; + }; + + // The tick got there first; the click is the joiner. + const intervalRun = fetchWorkspaceOnce(folder, { git: runner }, 'interval'); + await runStarted; + const joinerRun = fetchWorkspaceOnce(folder, { git: async () => '' }, 'click'); + releaseRun(); + + const run = await intervalRun; + expect(await joinerRun).toBe(run); // one run, not two answers + expect(run.trigger).toBe('interval'); + // The row the panel shows is built from the run that ran, so it names the interval. + expect(gitFetchStatusFrom(run.report, run.trigger).trigger).toBe('interval'); + }); + + it('keys the join on the resolved folder, so the same folder written two ways is one run', async () => { + const folder = join(dir, 'folder'); + initRepo(join(folder, 'api')); + + let releaseRun = () => {}; + const held = new Promise<void>((resolve) => (releaseRun = resolve)); + let running = () => {}; + const runStarted = new Promise<void>((resolve) => (running = resolve)); + let fetches = 0; + const firstRunner: GitRunner = async (cwd, args) => { + if (args[0] === 'fetch') { + fetches++; + running(); + await held; + } + return ''; + }; + const first = fetchWorkspaceOnce(folder, { git: firstRunner }, 'click'); + await runStarted; + // Two sessions on one folder reach it by different spellings; the scheduler keys by `resolve`. + let joinerCalls = 0; + const joinerRunner: GitRunner = async () => { + joinerCalls++; + return ''; + }; + const second = fetchWorkspaceOnce(join(dir, 'folder', '.'), { git: joinerRunner }, 'click'); + releaseRun(); + expect(await second).toBe(await first); + expect(joinerCalls).toBe(0); + expect(fetches).toBe(1); + }); + + it('releases the folder when a run lands, so the next click is a new run and not an old answer', async () => { + const folder = join(dir, 'folder'); + initRepo(join(folder, 'api')); + let fetches = 0; + const runner: GitRunner = async (cwd, args) => { + if (args[0] === 'fetch') fetches++; + return ''; + }; + const firstRun = await fetchWorkspaceOnce(folder, { git: runner }, 'interval'); + expect(fetches).toBe(1); + const secondRun = await fetchWorkspaceOnce(folder, { git: runner }, 'click'); + expect(fetches).toBe(2); + // A run that is its own reports its own trigger; only a joiner borrows the trigger of the run it joined. + expect(firstRun.trigger).toBe('interval'); + expect(secondRun.trigger).toBe('click'); + }); + + it('bounds a whole tick with the limiter it is handed, because two runs given a bound each would get twice the bound', async () => { + const left = join(dir, 'left'); + const right = join(dir, 'right'); + for (let i = 0; i < FETCH_CONCURRENCY + 1; i++) { + initRepo(join(left, `l${i}`)); + initRepo(join(right, `r${i}`)); + } + const held = () => new Promise((resolve) => setTimeout(resolve, 10)); + // Only the fetches go through the limiter, so only they are counted here: the resolver's own probe is + // not a bounded git process. + const counting = + (state: { live: number; peak: number }): GitRunner => + async (cwd, args) => { + if (args[0] !== 'fetch') return ''; + state.live++; + state.peak = Math.max(state.peak, state.live); + await held(); + state.live--; + return ''; + }; + + // The scheduler hands every folder in the tick the SAME limiter, so the bound is the tick's, not the run's. + const shared = { live: 0, peak: 0 }; + const sharedRunner = counting(shared); + const limiter = createLimiter(FETCH_CONCURRENCY); + await Promise.all([ + fetchGitWorkspaceOverview(left, { git: sharedRunner, limiter }), + fetchGitWorkspaceOverview(right, { git: sharedRunner, limiter }), + ]); + expect(shared.peak).toBe(FETCH_CONCURRENCY); + + // Without a shared limiter each run bounds only its own call, which is the failure the scheduler is there + // to prevent: two folders at FETCH_CONCURRENCY each, so twice as many git processes as the rule says. + const alone = { live: 0, peak: 0 }; + const aloneRunner = counting(alone); + await Promise.all([ + fetchGitWorkspaceOverview(left, { git: aloneRunner }), + fetchGitWorkspaceOverview(right, { git: aloneRunner }), + ]); + expect(alone.peak).toBe(2 * FETCH_CONCURRENCY); + }); +}); diff --git a/test/git-status.browser.test.ts b/test/git-status.browser.test.ts index e8c06e9b..1522e4be 100644 --- a/test/git-status.browser.test.ts +++ b/test/git-status.browser.test.ts @@ -29,6 +29,7 @@ describe('Git status indicator in a real browser', () => { let repoSession: string; let plainSession: string; const gitStatusRequests: string[] = []; + const gitFetchRequests: string[] = []; const settingsPutStatuses: number[] = []; const write = (rel: string, text = 'x\n') => writeFileSync(join(repo, rel), text); @@ -91,6 +92,9 @@ describe('Git status indicator in a real browser', () => { page.on('request', (r) => { // The API route only: the page also loads /git-status-ui.js, whose URL contains the same words. if (/\/api\/sessions\/[^/]+\/git-status/.test(r.url())) gitStatusRequests.push(r.url()); + // Anchored: the per-case Git settings route (/git-fetch-settings) is a different route, and a bare + // /git-fetch test would count its PUTs as fetches. The fetch route carries the limits as a query. + if (/\/api\/sessions\/[^/]+\/git-fetch(\?|$)/.test(r.url())) gitFetchRequests.push(r.url()); }); page.on('response', (r) => { if (r.request().method() === 'PUT' && r.url().endsWith('/api/settings')) settingsPutStatuses.push(r.status()); @@ -514,6 +518,147 @@ describe('Git status indicator in a real browser', () => { await refresh(); }, 40000); + it('puts Fetch in the header row, between Refresh and Close', async () => { + const order = await page.evaluate(() => + [...document.querySelectorAll('.git-status-actions button')].map((b) => b.getAttribute('aria-label')) + ); + expect(order).toEqual(['Refresh git status', 'Fetch git status', 'Close git status']); + // A repository the panel can read is a repository Fetch can fetch, so the button is live here. + expect(await page.evaluate(() => document.getElementById('gitStatusFetchBtn')!.disabled)).toBe(false); + }); + + it('Fetch fetches for real, moves the behind number, and reports what it brought', async () => { + // A second clone pushes a commit this repository has not fetched, so its remote-tracking ref is stale: + // the panel cannot show a `behind` number until a fetch runs. That is what the footer means by + // "as of the last fetch", and the number only becomes current by the button. + const other = join(root, 'other-clone'); + git(root, 'clone', '-q', join(root, 'origin.git'), other); + writeFileSync(join(other, 'from-elsewhere.txt'), 'y\n'); + git(other, 'add', '-A'); + git(other, 'commit', '-q', '-m', 'from elsewhere'); + git(other, 'push', '-q'); + await refresh(); + expect(await page.textContent('#gitStatusBody')).not.toMatch(/behind/); + + const before = gitFetchRequests.length; + await page.click('#gitStatusFetchBtn'); + await page.waitForFunction( + () => /↓ 1 behind/.test(document.getElementById('gitStatusBody')?.textContent ?? ''), + null, + { timeout: 20000 } + ); + expect(gitFetchRequests.length).toBe(before + 1); + // The same limits the status route gets go with the fetch, so the two agree on which repositories count. + expect(gitFetchRequests.at(-1)).toMatch(/maxRepos=12/); + expect(await page.locator('.git-status-chip', { hasText: 'behind' }).textContent()).toBe('↓ 1 behind'); + expect(await page.textContent('#gitStatusBody .git-status-fetch-summary')).toMatch( + /^Fetched .+: 1 of 1 repository\.$/ + ); + expect(await page.locator('#gitStatusBody .git-status-fetch-row').count()).toBe(0); + + // The 15 s poll rebuilds the whole body, so the note has to come from the renderer, not from the click. + await refresh(); + expect(await page.locator('#gitStatusBody .git-status-fetch-summary').count()).toBe(1); + const foot = (await page.textContent('#gitStatusFooter')) ?? ''; + expect(foot).toMatch(/Read-only apart from Fetch/); + // No longer "only when you click": an interval the user turned on fetches too, and the footer says so. + expect(foot).toMatch(/interval/); + // The chip itself says how it got current, so the number is not read as live. + expect(await page.locator('.git-status-chip', { hasText: 'behind' }).getAttribute('title')).toMatch(/interval/); + rmSync(other, { recursive: true, force: true }); + }, 30000); + + it('a repository Fetch could not fetch keeps its row and shows why', async () => { + await page.route('**/api/sessions/*/git-fetch*', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + success: true, + data: { + state: 'ok', + reposTruncated: false, + fetchedAt: Date.now(), + repos: [ + { name: 'fetchable', path: 'fetchable', ok: true }, + { + name: 'private', + path: '/srv/private', + ok: false, + failure: { code: 'AUTH', message: 'could not read the remote' }, + }, + ], + }, + }), + }) + ); + await page.click('#gitStatusFetchBtn'); + await page.waitForFunction(() => + /could not read the remote/.test(document.getElementById('gitStatusBody')?.textContent ?? '') + ); + // The summary counts what landed, and the failure is named rather than folded into the count. The + // summary is the panel's first note line, so the count is read off it, not off the whole body. + expect(await page.textContent('#gitStatusBody .git-status-fetch-summary')).toMatch( + /^Fetched .+: 1 of 2 repositories\.$/ + ); + const row = (await page.textContent('#gitStatusBody .git-status-fetch-row')) ?? ''; + expect(row).toContain('private'); + expect(row).toContain('⚠ could not read the remote'); + expect(await page.locator('#gitStatusBody .git-status-fetch-row').count()).toBe(1); + // Git text is untrusted: the row is text, never markup. + expect(await page.$('#gitStatusBody img')).toBeNull(); + await page.unroute('**/api/sessions/*/git-fetch*'); + }, 30000); + + it('one click means one fetch: the button is busy while it runs and a second call is ignored', async () => { + let slow = true; + await page.route('**/api/sessions/*/git-fetch*', async (route) => { + if (slow) await new Promise((r) => setTimeout(r, 1500)); + await route.continue(); + }); + page.setDefaultTimeout(6000); + await page.click('#gitStatusFetchBtn'); + await page.waitForFunction(() => (window as any).app._gitFetchInFlight === true); + const busy = await page.evaluate(() => { + const b = document.getElementById('gitStatusFetchBtn')!; + return { ariaBusy: b.getAttribute('aria-busy'), disabled: b.disabled }; + }); + expect(busy.ariaBusy).toBe('true'); + // Disabled is what stops a second click reaching the handler, so one click is one fetch per repository. + expect(busy.disabled).toBe(true); + const during = gitFetchRequests.length; + await page.evaluate(() => void (window as any).app.fetchGitStatusNow()); + expect(gitFetchRequests.length).toBe(during); + slow = false; + // The flag goes false before the re-render that follows it, so wait for the button itself, not the flag: + // reading it right at the flip catches the panel mid-render. + await page.waitForFunction( + () => { + const b = document.getElementById('gitStatusFetchBtn')!; + return (window as any).app._gitFetchInFlight === false && !b.disabled && !b.getAttribute('aria-busy'); + }, + null, + { timeout: 15000 } + ); + // The handler is still asleep: unrouting without waiting fails the run as an unhandled rejection. + await page.unrouteAll({ behavior: 'wait' }); + page.setDefaultTimeout(30000); + }, 30000); + + it('Fetch is inert for a session nothing can be fetched for', async () => { + await select(plainSession); + await page.waitForFunction( + () => /No git repository here/.test(document.getElementById('gitStatusBody')?.textContent ?? ''), + null, + { timeout: 15000 } + ); + expect(await page.evaluate(() => document.getElementById('gitStatusFetchBtn')!.disabled)).toBe(true); + await select(repoSession); + await page.waitForFunction(() => document.getElementById('gitStatusFetchBtn')!.disabled === false, null, { + timeout: 15000, + }); + await page.waitForSelector('#gitStatusPanel.visible'); + }, 30000); + it('closing the panel resets it; turning the setting off hides the button, closes the panel and stops polling', async () => { await page.click('.git-status-actions button[aria-label="Close git status"]'); expect(await page.isVisible('#gitStatusPanel')).toBe(false); diff --git a/test/guarded-lease-held-input.test.ts b/test/guarded-lease-held-input.test.ts new file mode 100644 index 00000000..31191fcb --- /dev/null +++ b/test/guarded-lease-held-input.test.ts @@ -0,0 +1,450 @@ +/** + * @fileoverview Input that lands while a guarded submit holds the input lease is held + * and applied in arrival order once the lease is released, never refused and lost. + * + * Shaped like the review repro: a real `Session`, a mux that runs `beforeDispatch` and + * then awaits the Enter the way `TmuxManager.sendInput` does, and the real WebSocket + * route driven through a fake socket. The browser's WebSocket path does not wait for + * ACKs, so the client model below sends every frame at once and redelivers whatever + * is still unACKed afterwards, in seq order, the way `_redeliverSweep` does. + */ +import { EventEmitter } from 'node:events'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { formatPaneSnapshot } from '../src/tmux-manager.js'; +import { GuardedInputBusyError, Session } from '../src/session.js'; +import { registerWsRoutes } from '../src/web/routes/ws-routes.js'; +import { registerSessionRoutes } from '../src/web/routes/session-routes.js'; +import type { SessionPort } from '../src/web/ports/session-port.js'; +import { createRouteTestHarness } from './routes/_route-test-utils.js'; + +type Frame = { t: 'i'; d: string; seq?: number; cid?: string }; + +class FakeSocket extends EventEmitter { + readyState = 1; + sent: Array<Record<string, unknown>> = []; + closedWith: number | null = null; + send(data: string): void { + this.sent.push(JSON.parse(data)); + } + close(code?: number): void { + this.closedWith = code ?? 1000; + this.readyState = 3; + } + ping(): void {} + terminate(): void { + this.readyState = 3; + } + frame(msg: Frame): void { + this.emit('message', Buffer.from(JSON.stringify(msg))); + } + acks(): number[] { + return this.sent.filter((m) => m.t === 'ia').map((m) => m.seq as number); + } +} + +interface Harness { + session: Session; + pty: string[]; + /** Resolve the in-flight Enter (true = tmux accepted it, false = it failed). */ + finishEnter(ok?: boolean | Error): void; + enterDispatched(): boolean; + connect(cid: string): FakeSocket; + stop(): Promise<void>; +} + +const sockets: FakeSocket[] = []; + +function harness(awaitBeforeGuard = false): Harness { + const pty: string[] = []; + let finish: ((ok: boolean | Error) => void) | undefined; + let dispatched = false; + const mux = { + isAvailable: () => true, + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 7, + cursorY: 0, + }); + }, + // Like TmuxManager.sendInput's Enter-only branch: the guard runs synchronously, + // then the `send-keys Enter` child process is awaited with the lease still held. + sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { + if (options?.beforeDispatch && text !== '\r') return false; + const settle = () => + new Promise<boolean | Error>((resolve) => { + finish = resolve; + }); + // A transport that awaits before its guard leaves a window in which held + // keystrokes must still invalidate the Enter. + const early = awaitBeforeGuard ? await settle() : undefined; + if (options?.beforeDispatch && !options.beforeDispatch()) return false; + dispatched = true; + const outcome = early ?? (await settle()); + if (outcome instanceof Error) throw outcome; + if (outcome) pty.push(text); + return outcome; + }, + }; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux, + muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters<typeof Session>[0]); + const access = session as unknown as { ptyProcess: { write(data: string): void } | null }; + access.ptyProcess = { write: (data: string) => pty.push(data) }; + + let handler: ((socket: FakeSocket, req: unknown) => void) | undefined; + const app = { + get: (_path: string, _opts: unknown, h: (socket: FakeSocket, req: unknown) => void) => { + handler = h; + }, + } as unknown as FastifyInstance; + const ctx = { sessions: new Map([[session.id, session]]) } as unknown as SessionPort; + registerWsRoutes(app, ctx, () => ({ bindHost: '127.0.0.1', allowedHosts: [] })); + + return { + session, + pty, + finishEnter: (ok = true) => finish!(ok), + enterDispatched: () => dispatched, + connect: (cid: string) => { + const socket = new FakeSocket(); + sockets.push(socket); + handler!(socket, { headers: { host: 'localhost:3000' }, params: { id: session.id }, query: { cid } }); + return socket; + }, + stop: async () => { + access.ptyProcess = null; + await session.stop(false); + }, + }; +} + +/** Redeliver every still-unACKed frame in seq order, as the browser's sweep does. */ +function redeliverUnacked(socket: FakeSocket, frames: Frame[]): void { + const acked = new Set(socket.acks()); + for (const f of frames) if (f.seq !== undefined && !acked.has(f.seq)) socket.frame(f); +} + +afterEach(() => { + for (const s of sockets.splice(0)) s.emit('close', 1000, Buffer.from('')); +}); + +describe('input during a guarded submit lease', () => { + it('delivers a keystroke that landed during the lease before the one typed after it (review repro)', async () => { + const h = harness(); + try { + const socket = h.connect('c1'); + const a: Frame = { t: 'i', d: 'a', seq: 1, cid: 'c1' }; + const b: Frame = { t: 'i', d: 'b', seq: 2, cid: 'c1' }; + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + expect(h.enterDispatched()).toBe(true); + expect(h.session.guardedSubmitInFlight).toBe(true); + socket.frame(a); + // Held, not refused: no ACK yet and nothing written while the lease is held. + expect(socket.acks()).toEqual([]); + expect(h.pty).toEqual([]); + h.finishEnter(true); + await expect(submit).resolves.toBe(true); + socket.frame(b); + redeliverUnacked(socket, [a, b]); + expect(h.pty).toEqual(['\r', 'a', 'b']); + expect(socket.acks()).toEqual([1, 2]); + expect(socket.sent.filter((m) => m.dup)).toEqual([]); + } finally { + await h.stop(); + } + }); + + it('keeps arrival order for several frames during the lease and one after it', async () => { + const h = harness(); + try { + const socket = h.connect('c1'); + const frames: Frame[] = ['a', 'b', 'c', 'd'].map((d, i) => ({ t: 'i', d, seq: i + 1, cid: 'c1' })); + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + for (const f of frames.slice(0, 3)) socket.frame(f); + expect(h.pty).toEqual([]); + h.finishEnter(true); + await submit; + socket.frame(frames[3]); + redeliverUnacked(socket, frames); + expect(h.pty).toEqual(['\r', 'a', 'b', 'c', 'd']); + expect(socket.acks()).toEqual([1, 2, 3, 4]); + } finally { + await h.stop(); + } + }); + + it('interleaves a second client by arrival, each with its own seq space', async () => { + const h = harness(); + try { + const one = h.connect('c1'); + const two = h.connect('c2'); + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + one.frame({ t: 'i', d: 'a', seq: 1, cid: 'c1' }); + two.frame({ t: 'i', d: 'x', seq: 1, cid: 'c2' }); + one.frame({ t: 'i', d: 'b', seq: 2, cid: 'c1' }); + h.finishEnter(true); + await submit; + two.frame({ t: 'i', d: 'y', seq: 2, cid: 'c2' }); + expect(h.pty).toEqual(['\r', 'a', 'x', 'b', 'y']); + expect(one.acks()).toEqual([1, 2]); + expect(two.acks()).toEqual([1, 2]); + } finally { + await h.stop(); + } + }); + + it('flushes held input when the lease ends in failure, refused Enter or thrown', async () => { + for (const outcome of [false, new Error('tmux send-keys failed')]) { + const h = harness(); + try { + const socket = h.connect('c1'); + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + socket.frame({ t: 'i', d: 'a', seq: 1, cid: 'c1' }); + h.finishEnter(outcome); + if (outcome instanceof Error) await expect(submit).rejects.toThrow('tmux send-keys failed'); + else await expect(submit).resolves.toBe(false); + socket.frame({ t: 'i', d: 'b', seq: 2, cid: 'c1' }); + expect(h.pty).toEqual(['a', 'b']); + expect(socket.acks()).toEqual([1, 2]); + expect(h.session.guardedSubmitInFlight).toBe(false); + } finally { + await h.stop(); + } + } + }); + + it('holds untagged frames too, which have no redelivery to fall back on', async () => { + const h = harness(); + try { + const socket = h.connect('c1'); + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + socket.frame({ t: 'i', d: 'a' }); + h.finishEnter(true); + await submit; + socket.frame({ t: 'i', d: 'b' }); + expect(h.pty).toEqual(['\r', 'a', 'b']); + } finally { + await h.stop(); + } + }); + + it('a frame held for a socket that closed is still applied once; its redelivery is a duplicate', async () => { + const h = harness(); + try { + const first = h.connect('c1'); + const a: Frame = { t: 'i', d: 'a', seq: 1, cid: 'c1' }; + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + first.frame(a); + first.close(); + first.emit('close', 1006, Buffer.from('')); + h.finishEnter(true); + await submit; + expect(first.sent).toEqual([]); + const second = h.connect('c1'); + second.frame(a); + second.frame({ t: 'i', d: 'b', seq: 2, cid: 'c1' }); + expect(h.pty).toEqual(['\r', 'a', 'b']); + expect(second.sent.filter((m) => m.t === 'ia')).toEqual([ + { t: 'ia', seq: 1, dup: true, last: 1 }, + { t: 'ia', seq: 2 }, + ]); + } finally { + await h.stop(); + } + }); + + it('closes the socket instead of holding past the cap, and the reconnect resends in order', async () => { + const h = harness(); + try { + const socket = h.connect('c1'); + const cap = Session.MAX_GUARDED_HELD_INPUTS; + const frames: Frame[] = Array.from({ length: cap + 3 }, (_, i) => ({ + t: 'i', + d: `k${i}`, + seq: i + 1, + cid: 'c1', + })); + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + for (const f of frames) socket.frame(f); + // Frames past the cap are not held, not applied and not ACKed; the socket is + // closed so the browser reconnects and resends everything still unACKed. + expect(socket.closedWith).toBe(1013); + h.finishEnter(true); + await submit; + expect(h.pty.length).toBe(1 + cap); + expect(socket.acks()).toEqual([]); + socket.emit('close', 1013, Buffer.from('')); + const again = h.connect('c1'); + for (const f of frames) again.frame(f); + expect(h.pty).toEqual(['\r', ...frames.map((f) => f.d)]); + expect(again.acks()).toEqual(frames.map((f) => f.seq)); + } finally { + await h.stop(); + } + }); + + it('holds managed writes and named keys behind the lease, and refuses them only when the hold queue is full', async () => { + const h = harness(); + try { + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + const order: string[] = []; + const key = h.session.performAuxiliaryInput(async () => { + order.push('key'); + }); + await new Promise<void>((resolve) => setImmediate(resolve)); + expect(order).toEqual([]); + for (let i = 1; i < Session.MAX_GUARDED_HELD_INPUTS; i++) { + expect(h.session.holdForGuardedLease(() => order.push('frame'))).toBe('held'); + } + expect(h.session.holdForGuardedLease(() => order.push('lost'))).toBe('full'); + await expect(h.session.performAuxiliaryInput(async () => order.push('lost'))).rejects.toBeInstanceOf( + GuardedInputBusyError + ); + await expect(h.session.writeViaMux('lost')).resolves.toBe(false); + h.finishEnter(true); + await submit; + await key; + expect(order[0]).toBe('key'); + expect(order.filter((o) => o === 'frame')).toHaveLength(Session.MAX_GUARDED_HELD_INPUTS - 1); + expect(order).not.toContain('lost'); + } finally { + await h.stop(); + } + }); + + it('caps held bytes as well as held frames', async () => { + const h = harness(); + try { + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + expect(h.session.holdForGuardedLease(() => {}, Session.MAX_GUARDED_HELD_BYTES)).toBe('held'); + expect(h.session.holdForGuardedLease(() => {}, 1)).toBe('full'); + h.finishEnter(true); + await submit; + expect(h.session.holdForGuardedLease(() => {}, Session.MAX_GUARDED_HELD_BYTES + 1)).toBe('free'); + } finally { + await h.stop(); + } + }); + + it('a frame arriving while a held named key is still being sent waits for it', async () => { + const h = harness(); + try { + const socket = h.connect('c1'); + let keySent!: () => void; + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + const key = h.session.performAuxiliaryInput(async () => { + await new Promise<void>((resolve) => { + keySent = resolve; + }); + h.pty.push('<newline>'); + }); + socket.frame({ t: 'i', d: 'a', seq: 1, cid: 'c1' }); + h.finishEnter(true); + await submit; + // The lease is over, but the held key is still in flight: input stays held. + expect(h.session.guardedSubmitInFlight).toBe(false); + expect(h.session.guardedInputHeld).toBe(true); + socket.frame({ t: 'i', d: 'b', seq: 2, cid: 'c1' }); + expect(h.pty).toEqual(['\r']); + keySent(); + await key; + await new Promise<void>((resolve) => setImmediate(resolve)); + expect(h.pty).toEqual(['\r', '<newline>', 'a', 'b']); + expect(socket.acks()).toEqual([1, 2]); + expect(h.session.guardedInputHeld).toBe(false); + } finally { + await h.stop(); + } + }); + + it('holds an untagged raw POST .../input during the lease and writes it after the Enter', async () => { + const h = harness(); + const route = await createRouteTestHarness(registerSessionRoutes); + try { + (route.ctx.sessions as unknown as Map<string, Session>).set(h.session.id, h.session); + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + expect(h.session.guardedSubmitInFlight).toBe(true); + // The Stop button's Ctrl+C: an untagged POST whose answer nobody reads. + const res = await route.app.inject({ + method: 'POST', + url: `/api/sessions/${h.session.id}/input`, + payload: { input: '\x03' }, + }); + expect(res.statusCode).toBe(200); + expect(h.pty).toEqual([]); + h.finishEnter(true); + await expect(submit).resolves.toBe(true); + expect(h.pty).toEqual(['\r', '\x03']); + } finally { + await route.app.close(); + await h.stop(); + } + }); + + it('a held untagged raw send-and-wait reports the held write as delivered', async () => { + const h = harness(); + const route = await createRouteTestHarness(registerSessionRoutes); + try { + (route.ctx.sessions as unknown as Map<string, Session>).set(h.session.id, h.session); + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + const answer = route.app.inject({ + method: 'POST', + url: `/api/sessions/${h.session.id}/input`, + payload: { input: '\x03', useMux: false, wait: 'exit', waitTimeout: 1000 }, + }); + // Private, but the only proof the route reached the hold before the lease ends. + const queue = (h.session as unknown as { _guardedHeld: unknown[] })._guardedHeld; + await vi.waitFor(() => expect(queue).toHaveLength(1)); + expect(h.pty).toEqual([]); + h.finishEnter(true); + await submit; + const res = await answer; + expect(res.statusCode).toBe(200); + expect(res.json().data).toMatchObject({ delivered: true, duplicate: false }); + expect(h.pty).toEqual(['\r', '\x03']); + } finally { + await route.app.close(); + await h.stop(); + } + }); + + it('a keystroke held before the guard runs still invalidates the Enter, then lands', async () => { + const h = harness(true); + try { + const socket = h.connect('c1'); + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + socket.frame({ t: 'i', d: 'a', seq: 1, cid: 'c1' }); + h.finishEnter(true); + await expect(submit).resolves.toBe(false); + expect(h.enterDispatched()).toBe(false); + expect(h.pty).toEqual(['a']); + expect(socket.acks()).toEqual([1]); + } finally { + await h.stop(); + } + }); +}); diff --git a/test/image-paste-trap.test.ts b/test/image-paste-trap.test.ts index 93370ae3..f117f539 100644 --- a/test/image-paste-trap.test.ts +++ b/test/image-paste-trap.test.ts @@ -33,11 +33,30 @@ interface FakeTrap { addEventListener: (ev: string, fn: TrapListener) => void; } +interface SentInput { + sessionId: string; + data: string; +} + +interface TargetTerminal { + input: (data: string) => void; + paste?: (text: string) => void; + focus?: () => void; +} + interface Harness { /** Fire a paste event on the trap the last _handleImagePaste() call created. */ firePaste: (payload: { text?: string; images?: string[] }) => void; /** Text handed to xterm's terminal.paste(), one entry per call. */ pastedText: string[]; + /** Frames handed to the primary terminal's input(), one entry per call. */ + inputted: string[]; + /** Frames handed to _sendInputAsync(), in order. The trap must not deliver text this way. */ + sentInputs: SentInput[]; + /** How many times predictive-echo predictions were cleared by the trap. */ + predictionsCleared: () => number; + /** Unsent overlay text still held after the paste. Empty when local echo was not set up. */ + pendingEcho: () => string; /** Image batches handed to _uploadAndInsertImages(), one entry per call. */ uploadedBatches: Array<Array<{ type: string }>>; /** Messages handed to showToast(), one entry per call. */ @@ -47,7 +66,16 @@ interface Harness { runTimers: () => void; } -function loadPasteHarness(): Harness { +interface PasteHarnessOptions { + /** Session the paste trap is aimed at. Defaults to the active session. */ + sessionId?: string; + /** Unsent overlay text. The trap must leave this for onData to flush. */ + localEchoPending?: string; + /** Terminal the Ctrl+V came from, when it is not the primary pane. */ + terminal?: TargetTerminal; +} + +function loadPasteHarness(options: PasteHarnessOptions = {}): Harness { const traps: FakeTrap[] = []; const listeners: TrapListener[] = []; const attached = new Set<FakeTrap>(); @@ -99,20 +127,68 @@ function loadPasteHarness(): Harness { const CodemanApp = vm.runInContext('CodemanApp', context) as new () => Record<string, unknown>; const pastedText: string[] = []; + const inputted: string[] = []; + const sentInputs: SentInput[] = []; const uploadedBatches: Array<Array<{ type: string }>> = []; + let predictionsCleared = 0; + let pendingEcho = ''; const app = new CodemanApp(); app.activeSessionId = 'session-1'; + // Replay leaves xterm's DECSET 2004 mirror off. Text must still go out bracketed, + // and must not ask terminal.paste() to consult that mirror (#716). input() is + // what a real xterm fires onData from, with wasUserInput (scroll + clear selection). app.terminal = { paste: (text: string) => pastedText.push(text), + input: (data: string) => inputted.push(data), focus: () => {}, + modes: { bracketedPasteMode: false }, + }; + app._sendInputAsync = (sessionId: string, data: string) => { + sentInputs.push({ sessionId, data }); + }; + app._predictiveEcho = { + clearPredictions: () => { + predictionsCleared += 1; + }, }; app._uploadAndInsertImages = (files: Array<{ type: string }>) => { uploadedBatches.push(Array.from(files)); }; + if (options.localEchoPending !== undefined) { + pendingEcho = options.localEchoPending; + app._localEchoEnabled = true; + app._pendingInput = ''; + app._localEchoOverlay = { + get pendingText() { + return pendingEcho; + }, + set pendingText(value: string) { + pendingEcho = value; + }, + clear() { + this.pendingText = ''; + }, + suppressBufferDetection() {}, + }; + app._flushedOffsets = new Map(); + app._flushedTexts = new Map(); + app._flushLocalEchoPending = function (this: Record<string, any>) { + const pending = this._localEchoOverlay?.pendingText || ''; + this._localEchoOverlay.clear(); + this._localEchoOverlay.suppressBufferDetection(); + this._flushedOffsets?.delete(this.activeSessionId); + this._flushedTexts?.delete(this.activeSessionId); + if (pending) this._pendingInput += pending; + }; + } const toasts: string[] = []; app.showToast = (message: string) => toasts.push(message); - (app._handleImagePaste as () => void).call(app); + const target = + options.sessionId || options.terminal + ? { terminal: options.terminal ?? app.terminal, sessionId: options.sessionId || 'session-1' } + : undefined; + (app._handleImagePaste as (t?: object) => void).call(app, target); return { firePaste({ text = '', images = [] }) { @@ -128,6 +204,10 @@ function loadPasteHarness(): Harness { for (const fn of listeners) fn(event); }, pastedText, + inputted, + sentInputs, + predictionsCleared: () => predictionsCleared, + pendingEcho: () => pendingEcho, uploadedBatches, toasts, attachedTraps: () => attached.size, @@ -154,12 +234,82 @@ function loadImageInputApp() { } describe('Ctrl+V paste trap', () => { - it('sends clipboard text to the terminal once for a single paste event', () => { + it('hands clipboard text to terminal.input as one bracketed paste, not through paste() or the session path', () => { const h = loadPasteHarness(); h.firePaste({ text: 'hello world' }); - expect(h.pastedText).toEqual(['hello world']); + // terminal.paste() would drop the markers: the harness mirror is off, which + // is what a replay leaves behind. input() is the public entry that fires + // onData with wasUserInput, so the pane scrolls and the selection clears. + // Delivery belongs to that onData handler, not a second write from here. + expect(h.pastedText).toEqual([]); + expect(h.inputted).toEqual(['\x1b[200~hello world\x1b[201~']); + expect(h.sentInputs).toEqual([]); + expect(h.predictionsCleared()).toBe(0); + }); + + it('brackets a multi-line paste and strips nested paste markers', () => { + const h = loadPasteHarness(); + + h.firePaste({ text: 'line one\nline two\r\n\x1b[200~nested\x1b[201~\n' }); + + expect(h.pastedText).toEqual([]); + expect(h.inputted).toEqual(['\x1b[200~line one\rline two\rnested\r\x1b[201~']); + expect(h.sentInputs).toEqual([]); + }); + + it('strips a marker hidden inside another, so the clipboard cannot end the frame early', () => { + const h = loadPasteHarness(); + + // One pass over this leaves `ESC[201~` between `ok` and the command. + h.firePaste({ text: 'ok\x1b[20\x1b[201~1~\nrm -rf ~/x\n' }); + + expect(h.inputted).toEqual(['\x1b[200~ok\rrm -rf ~/x\r\x1b[201~']); + }); + + it('addresses the bracketed paste to the pane that received Ctrl+V', () => { + const paneInputs: string[] = []; + const pane: TargetTerminal = { + input: (data) => paneInputs.push(data), + paste: () => { + throw new Error('paste() must not run'); + }, + focus: () => {}, + }; + const h = loadPasteHarness({ sessionId: 'pane-b', terminal: pane }); + + h.firePaste({ text: 'into pane b' }); + + expect(paneInputs).toEqual(['\x1b[200~into pane b\x1b[201~']); + expect(h.inputted).toEqual([]); + expect(h.sentInputs).toEqual([]); + expect(h.pastedText).toEqual([]); + }); + + it('leaves unsent local-echo text for onData, instead of flushing it itself', () => { + const h = loadPasteHarness({ localEchoPending: 'hi' }); + + h.firePaste({ text: 'world\n' }); + h.runTimers(); + + // terminal.input() fires the main pane's onData, whose bracketed-paste + // branch flushes the overlay and delays the frame (#219). Flushing here + // as well would put the prefix on the wire twice. + expect(h.pendingEcho()).toBe('hi'); + expect(h.inputted).toEqual(['\x1b[200~world\r\x1b[201~']); + expect(h.sentInputs).toEqual([]); + expect(h.pastedText).toEqual([]); + }); + + it('keeps a clipboard that looks like a query reply or a mouse report inside the frame', () => { + const h = loadPasteHarness(); + + h.firePaste({ text: '\x1b[?1;2c\n\x1b[<0;10;5M\n\x1b[I' }); + + expect(h.inputted).toEqual(['\x1b[200~\x1b[?1;2c\r\x1b[<0;10;5M\r\x1b[I\x1b[201~']); + expect(h.sentInputs).toEqual([]); + expect(h.pastedText).toEqual([]); }); it('ignores a second paste event for the same Ctrl+V', () => { @@ -170,7 +320,9 @@ describe('Ctrl+V paste trap', () => { h.firePaste({ text: 'hello world' }); h.firePaste({ text: 'hello world' }); - expect(h.pastedText).toEqual(['hello world']); + expect(h.inputted).toEqual(['\x1b[200~hello world\x1b[201~']); + expect(h.sentInputs).toEqual([]); + expect(h.pastedText).toEqual([]); }); it('uploads a pasted image once when the trap sees two paste events', () => { @@ -182,6 +334,7 @@ describe('Ctrl+V paste trap', () => { expect(h.uploadedBatches).toHaveLength(1); expect(h.uploadedBatches[0]).toEqual([{ type: 'image/png' }]); expect(h.pastedText).toEqual([]); + expect(h.sentInputs).toEqual([]); }); it('a paste mixing an image with a refused file uploads the image and says what it skipped', () => { @@ -199,7 +352,9 @@ describe('Ctrl+V paste trap', () => { // Copying from a document can carry both: the text is what the user gets. h.firePaste({ text: 'echo hi', images: ['application/x-sh'] }); - expect(h.pastedText).toEqual(['echo hi']); + expect(h.inputted).toEqual(['\x1b[200~echo hi\x1b[201~']); + expect(h.sentInputs).toEqual([]); + expect(h.pastedText).toEqual([]); expect(h.uploadedBatches).toEqual([]); expect(h.toasts).toEqual([]); }); diff --git a/test/image-watcher-remote.test.ts b/test/image-watcher-remote.test.ts new file mode 100644 index 00000000..5b3871b2 --- /dev/null +++ b/test/image-watcher-remote.test.ts @@ -0,0 +1,190 @@ +/** + * The image watcher must never watch a REMOTE session's working directory. + * + * A remote-SSH session's `workingDir` is a path on the remote host. The watcher + * (chokidar) watches it on the LOCAL disk: where nothing exists there it watches + * a missing folder, and where a same-named local folder does exist it reports that + * folder's images as the remote session's. Three places start a watch, and all + * three skip a remote session: + * - `WebServer.setupSessionListeners()` (create, respawn, restore, recovery); + * - `POST /api/sessions/:id/image-watcher` (the per-session toggle), which refuses + * to turn it on, the way the Git fetch settings route refuses a remote session; + * - `PUT /api/settings` turning the app-wide watcher on, which re-watches every + * session that has it enabled. + * + * Spies on the real `imageWatcher` singleton, so all three modules see the spy. + * Port: none (app.inject(), and a WebServer that is never started). + */ +import { mkdirSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { safeRmHomeTree } from './mocks/test-helpers.js'; +import { createRouteTestHarness, type RouteTestHarness } from './routes/_route-test-utils.js'; + +import { imageWatcher } from '../src/image-watcher.js'; +import { WebServer } from '../src/web/server.js'; +import { Session } from '../src/session.js'; +import { TmuxManager } from '../src/tmux-manager.js'; +import { registerSessionRoutes } from '../src/web/routes/session-routes.js'; +import { registerSystemRoutes } from '../src/web/routes/system-routes.js'; + +const WORKSPACE = join(homedir(), '.codeman-test-image-watcher-remote'); +// A path on the remote host: nothing exists there locally. +const REMOTE_PATH = '/nonexistent-remote-host-path/homelab-infrastructure'; +const REMOTE = { + hostId: 'proxx', + label: 'proxx', + host: 'proxx.example.lan', + username: 'user', + remotePath: REMOTE_PATH, + owned: true, +}; + +let watchSession: ReturnType<typeof vi.spyOn>; + +beforeEach(() => { + watchSession = vi.spyOn(imageWatcher, 'watchSession').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); + imageWatcher.stop(); +}); + +describe('setupSessionListeners()', () => { + interface ServerInternals { + registerSessionWithLayout(session: Session): Promise<void>; + setupSessionListeners(session: Session): Promise<void>; + discardPartiallyBuiltSession(sessionId: string): Promise<void>; + isImageWatcherEnabled(): Promise<boolean>; + } + const SESSION_ID = 'a1b2c3d4e5f60718'; + let server: WebServer; + let internals: ServerInternals; + let mux: TmuxManager; + + beforeAll(() => { + mkdirSync(WORKSPACE, { recursive: true }); + server = new WebServer(0, false, true); + internals = server as unknown as ServerInternals; + mux = new TmuxManager(); + }); + + afterEach(async () => { + await internals.discardPartiallyBuiltSession(SESSION_ID).catch(() => {}); + }); + + afterAll(async () => { + await server.stop().catch(() => {}); + safeRmHomeTree(WORKSPACE); + }); + + async function wire(remote: boolean) { + const session = new Session({ + id: SESSION_ID, + workingDir: remote ? REMOTE_PATH : WORKSPACE, + mode: 'claude', + name: remote ? 'remote session' : 'local session', + mux, + useMux: true, + ...(remote ? { remote: REMOTE } : {}), + }); + session.imageWatcherEnabled = true; + vi.spyOn(internals, 'isImageWatcherEnabled').mockResolvedValue(true); + await internals.registerSessionWithLayout(session); + await internals.setupSessionListeners(session); + } + + it('does not watch a remote session working directory', async () => { + await wire(true); + expect(watchSession).not.toHaveBeenCalled(); + }); + + it('still watches a local session working directory', async () => { + await wire(false); + expect(watchSession).toHaveBeenCalledWith(SESSION_ID, WORKSPACE); + }); +}); + +describe('POST /api/sessions/:id/image-watcher', () => { + let harness: RouteTestHarness; + + beforeEach(async () => { + harness = await createRouteTestHarness(registerSessionRoutes); + }); + + afterEach(async () => { + await harness.app.close(); + }); + + const post = (enabled: boolean) => + harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/image-watcher`, + payload: { enabled }, + }); + + it('refuses to turn it on for a remote session, storing nothing', async () => { + const session = harness.ctx._session as unknown as Record<string, unknown>; + session.remote = REMOTE; + session.workingDir = REMOTE_PATH; + const res = await post(true); + expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' }); + expect(watchSession).not.toHaveBeenCalled(); + expect(session.imageWatcherEnabled).not.toBe(true); + expect(harness.ctx.persistSessionState).not.toHaveBeenCalled(); + }); + + it('still turns it off for a remote session', async () => { + (harness.ctx._session as unknown as Record<string, unknown>).remote = REMOTE; + const unwatchSession = vi.spyOn(imageWatcher, 'unwatchSession'); + const res = await post(false); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toMatchObject({ success: true, data: { imageWatcherEnabled: false } }); + expect(unwatchSession).toHaveBeenCalledWith(harness.ctx._sessionId); + expect(harness.ctx.persistSessionState).toHaveBeenCalled(); + }); + + it('still turns it on for a local session', async () => { + const res = await post(true); + expect(res.statusCode).toBe(200); + expect(watchSession).toHaveBeenCalledWith(harness.ctx._sessionId, harness.ctx._session.workingDir); + }); +}); + +describe('PUT /api/settings turning the app-wide watcher on', () => { + let harness: RouteTestHarness; + + beforeEach(async () => { + harness = await createRouteTestHarness(registerSystemRoutes); + }); + + afterEach(async () => { + await harness.app.close(); + }); + + it('re-watches the local sessions that have it enabled, never a remote one', async () => { + const local = harness.ctx._session as unknown as Record<string, unknown>; + local.imageWatcherEnabled = true; + harness.ctx.sessions.set('remote-session', { + ...harness.ctx._session, + id: 'remote-session', + workingDir: REMOTE_PATH, + imageWatcherEnabled: true, + remote: REMOTE, + } as unknown as typeof harness.ctx._session); + expect(imageWatcher.isRunning()).toBe(false); + + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/settings', + payload: { imageWatcherEnabled: true }, + }); + + expect(res.statusCode).toBe(200); + expect(imageWatcher.isRunning()).toBe(true); + expect(watchSession).toHaveBeenCalledTimes(1); + expect(watchSession).toHaveBeenCalledWith(harness.ctx._sessionId, harness.ctx._session.workingDir); + }); +}); diff --git a/test/image-watcher.test.ts b/test/image-watcher.test.ts index 267157c6..22a14305 100644 --- a/test/image-watcher.test.ts +++ b/test/image-watcher.test.ts @@ -97,6 +97,7 @@ describe('ImageWatcher', () => { watcher.watchSession('session-1', '/home/user/project'); const [, opts] = vi.mocked(watch).mock.calls.at(-1) as unknown as [string, { ignored: (p: string) => boolean }]; expect(opts.ignored('/home/user/project/.codeman-uploads/paste-1-ab.pdf')).toBe(true); + expect(opts.ignored('/home/user/project/.codeman-discord/1234-report.pdf')).toBe(true); expect(opts.ignored('/home/user/project/.claude-images/paste-1-ab.png')).toBe(true); expect(opts.ignored('/home/user/project/docs/report.pdf')).toBe(false); }); diff --git a/test/install-sh-invariants.test.ts b/test/install-sh-invariants.test.ts index 2d1faebf..0fd588be 100644 --- a/test/install-sh-invariants.test.ts +++ b/test/install-sh-invariants.test.ts @@ -531,3 +531,8 @@ describe('install.sh review fixes for #460', () => { expect(drive('--tailscale')).toContain('host=127.0.0.1 pw=s3cret ack=0'); }); }); + +it('install.sh never contains the unauthenticated tunnel opt-in', () => { + expect(SOURCE).not.toContain('CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL'); + expect(SOURCE).not.toContain('--allow-unauthenticated-tunnel'); +}); diff --git a/test/keep-warm-launch.test.ts b/test/keep-warm-launch.test.ts index 03eb819b..1c08aa6c 100644 --- a/test/keep-warm-launch.test.ts +++ b/test/keep-warm-launch.test.ts @@ -159,8 +159,8 @@ describe('readKeepWarmSettings', () => { /** * The service keeps what it reads until the next keep-warm settings change, so its reader must - * never hand back a fallback for a file it could not read: settings.json is rewritten in place - * by several routes, and defaults kept from a mid-write read would hold keep-warm off. + * never hand back a fallback for a file it could not read: defaults kept from a file that does + * not parse (a hand edit; writes are atomic) would hold keep-warm off after it is fixed. */ it('the service reader rejects an unreadable file instead of falling back, and the server wires it', async () => { expect(await readKeepWarmSettingsOrThrow()).toEqual({ keepWarmDefault: false, maxHours: 2 }); diff --git a/test/keyboard-held-rows.test.ts b/test/keyboard-held-rows.test.ts new file mode 100644 index 00000000..a01f1485 --- /dev/null +++ b/test/keyboard-held-rows.test.ts @@ -0,0 +1,619 @@ +// Port: none (pure logic and a real headless xterm in vm contexts: no browser, +// no server). +// +// Rows held under the on-screen keyboard (`keyboardHoldsRows`, per-device, +// default ON). Every PTY height change makes Claude Code clear the screen and +// reprint only the transcript tail that fits, and the keyboard opening and +// closing used to be such a change each time (measured on the dev tier: tmux +// went 62x38 -> 62x15 -> 62x38 with a SIGWINCH per transition). Now the rows +// captured at the show transition are held, columns still follow the box, and +// the over-tall screen is scrolled up instead (keyboardClipPx). +// +// Outside test/mobile/ on purpose: that suite is not in the CI gate. +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; +import xtermHeadless from '@xterm/headless'; + +type HeadlessBuffer = { + viewportY: number; + baseY: number; + cursorY: number; + length: number; + getLine(y: number): { translateToString(trim?: boolean): string } | undefined; +}; +const { Terminal: HeadlessTerminal } = xtermHeadless as unknown as { + Terminal: new (opts: Record<string, unknown>) => { + rows: number; + write(data: string, cb?: () => void): void; + buffer: { active: HeadlessBuffer }; + }; +}; + +const read = (file: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', file), 'utf8'); + +type Geometry = { + keyboardClipPx(input: Record<string, number | boolean>): number; + keyboardVisibleRowLimit(clipPx: number, viewHeight: number, cellHeight: number): number; +}; + +function loadGeometry(): Geometry { + const window: Record<string, unknown> = {}; + vm.runInContext(read('constants.js'), vm.createContext({ console, window, URLSearchParams })); + return window.CodemanTerminalGeometry as Geometry; +} + +describe('keyboardClipPx', () => { + const g = loadGeometry(); + // 38 held rows of 17px in a 255px box (15 rows visible): 391px of overflow. + const base = { rows: 38, cellHeight: 17, viewHeight: 255, following: true, viewportY: 100, baseY: 100 }; + + it('puts the last content row at the bottom of the box', () => { + // Claude: composer and footer run to the last row, cursor in the composer. + expect(g.keyboardClipPx({ ...base, cursorRow: 34, lastContentRow: 37 })).toBe(38 * 17 - 255); + // Content ends at row 20: rows 6..20 are in view. + expect(g.keyboardClipPx({ ...base, cursorRow: 20, lastContentRow: 20 })).toBe(21 * 17 - 255); + }); + + it('counts a cursor just below the content (an empty prompt line)', () => { + expect(g.keyboardClipPx({ ...base, cursorRow: 22, lastContentRow: 20 })).toBe(23 * 17 - 255); + }); + + it('ignores a cursor parked far below the content (codex dialogs)', () => { + // Measured on the dev tier: codex's update dialog on rows 1-8 of 42, its + // cursor on row 38 of an otherwise blank screen. Anchoring to the cursor + // scrolled the dialog out of view; the top stays in view instead. + expect(g.keyboardClipPx({ ...base, cursorRow: 37, lastContentRow: 8 })).toBe(0); + }); + + it('shows the top for a prompt near the top (a fresh shell), and uses the cursor on a blank screen', () => { + expect(g.keyboardClipPx({ ...base, cursorRow: 1, lastContentRow: 1, viewportY: 0, baseY: 0 })).toBe(0); + expect(g.keyboardClipPx({ ...base, cursorRow: 37, lastContentRow: -1 })).toBe(38 * 17 - 255); + }); + + it('follows the scroll position while reading history, top of scrollback included', () => { + const max = 38 * 17 - 255; + expect(g.keyboardClipPx({ ...base, following: false, viewportY: 0, cursorRow: 37, lastContentRow: 37 })).toBe(0); + expect(g.keyboardClipPx({ ...base, following: false, viewportY: 50, cursorRow: 37, lastContentRow: 37 })).toBe( + Math.round(max / 2) + ); + }); + + it('is 0 when nothing overflows or the terminal cannot be measured', () => { + expect(g.keyboardClipPx({ ...base, viewHeight: 38 * 17, cursorRow: 37, lastContentRow: 37 })).toBe(0); + expect(g.keyboardClipPx({ ...base, cellHeight: 0, cursorRow: 37, lastContentRow: 37 })).toBe(0); + }); + + it('bounds the overlay to the fully visible rows', () => { + expect(g.keyboardVisibleRowLimit(0, 255, 17)).toBe(15); + expect(g.keyboardVisibleRowLimit(38 * 17 - 255, 255, 17)).toBe(38); + expect(g.keyboardVisibleRowLimit(10, 255, 17)).toBe(15); + }); +}); + +type Proto = Record<string, (this: Record<string, unknown>, ...args: unknown[]) => unknown>; + +function loadTerminalProto(keyboard: Record<string, unknown> = { keyboardVisible: true }) { + const windowStub: Record<string, unknown> = { addEventListener: vi.fn(), removeEventListener: vi.fn() }; + const context = vm.createContext({ + console, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn(), + HTMLCanvasElement: class HTMLCanvasElement {}, + WebSocket: { OPEN: 1 }, + fetch: vi.fn(), + URLSearchParams, + document: { addEventListener: vi.fn(), documentElement: { dataset: {} }, getElementById: () => null }, + localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() }, + window: windowStub, + MobileDetection: { isTouchDevice: () => true, isHandheldDevice: () => false, getDeviceType: () => 'mobile' }, + KeyboardHandler: keyboard, + }); + vm.runInContext( + `${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\nglobalThis.__CodemanApp = CodemanApp;`, + context + ); + const proto = (context as unknown as { __CodemanApp: { prototype: Proto } }).__CodemanApp.prototype; + return { proto, keyboard, windowStub }; +} + +describe('getTerminalDimensions holds the rows while the keyboard is up', () => { + const { proto, keyboard } = loadTerminalProto(); + const fake = (held: number) => ({ + _keyboardHeldRows: held, + _keyboardHeldRowsActive: proto._keyboardHeldRowsActive, + fitAddon: { proposeDimensions: () => ({ cols: 62, rows: 15 }) }, + }); + + it('reports the held rows and the container columns', () => { + keyboard.keyboardVisible = true; + expect(proto.getTerminalDimensions.call(fake(38))).toEqual({ cols: 62, rows: 38 }); + }); + + it('follows the container once the keyboard is gone, or with no hold', () => { + keyboard.keyboardVisible = false; + expect(proto.getTerminalDimensions.call(fake(38))).toEqual({ cols: 62, rows: 15 }); + keyboard.keyboardVisible = true; + expect(proto.getTerminalDimensions.call(fake(0))).toEqual({ cols: 62, rows: 15 }); + }); + + it('never holds fewer rows than the box offers', () => { + keyboard.keyboardVisible = true; + expect(proto.getTerminalDimensions.call(fake(12))).toEqual({ cols: 62, rows: 15 }); + }); + + it('skips the raw fit while held, so xterm never visits the short size', () => { + keyboard.keyboardVisible = true; + const fit = vi.fn(); + const resizes: unknown[] = []; + const self = { + ...fake(38), + fitAddon: { fit, proposeDimensions: () => ({ cols: 62, rows: 15 }) }, + terminal: { cols: 62, rows: 38, resize: (c: number, r: number) => resizes.push([c, r]) }, + getTerminalDimensions: proto.getTerminalDimensions, + _resizeTerminalTo: proto._resizeTerminalTo, + _scheduleOverflowAffordanceSync: () => {}, + }; + expect(proto.syncTerminalGeometry.call(self)).toEqual({ cols: 62, rows: 38 }); + expect(fit).not.toHaveBeenCalled(); + expect(resizes).toEqual([]); + }); +}); + +describe('only inline renderers hold, and a hold is released when the session stops qualifying', () => { + const { proto, windowStub } = loadTerminalProto(); + windowStub.__codemanKeyboardHoldModes = ['claude']; + const appWith = (session: Record<string, unknown> | null, settings: Record<string, unknown> = {}) => ({ + activeSessionId: session ? 's1' : null, + sessions: new Map(session ? [['s1', session]] : []), + loadAppSettingsFromStorage: () => settings, + _keyboardHoldEligible: proto._keyboardHoldEligible, + _releaseKeyboardHold: proto._releaseKeyboardHold, + }); + const eligible = (session: Record<string, unknown> | null, settings?: Record<string, unknown>) => + proto._keyboardHoldEligible.call(appWith(session, settings)); + + it('holds for an inline renderer (the keyboardHold capability), mode defaulting to claude', () => { + expect(eligible({ mode: 'claude' })).toBe(true); + expect(eligible({})).toBe(true); + }); + + it('never holds a full-height CLI, fullscreen Claude, a remote or docker pane, or with the setting off', () => { + expect(eligible({ mode: 'codex' })).toBe(false); + expect(eligible({ mode: 'shell' })).toBe(false); + expect(eligible({ mode: 'claude', cliMouseTracking: true })).toBe(false); + expect(eligible({ mode: 'claude', remote: { hostId: 'h' } })).toBe(false); + expect(eligible({ mode: 'claude', docker: { caseName: 'c' } })).toBe(false); + expect(eligible({ mode: 'claude' }, { keyboardHoldsRows: false })).toBe(false); + expect(eligible(null)).toBe(false); + }); + + it('holds nothing when the server sent no list', () => { + const saved = windowStub.__codemanKeyboardHoldModes; + delete windowStub.__codemanKeyboardHoldModes; + try { + expect(eligible({ mode: 'claude' })).toBe(false); + } finally { + windowStub.__codemanKeyboardHoldModes = saved; + } + }); + + const reconcile = (session: Record<string, unknown>, opts?: Record<string, unknown>) => { + const app = { + ...appWith(session), + _keyboardHeldRows: 38, + _applyKeyboardClip: vi.fn(), + sendResize: vi.fn(() => Promise.resolve(true)), + }; + proto._reconcileKeyboardHold.call(app, opts); + return app; + }; + + it('releases and resizes once the active session no longer qualifies', () => { + const app = reconcile({ mode: 'claude', cliMouseTracking: true }); + expect(app._keyboardHeldRows).toBe(0); + expect(app._applyKeyboardClip).toHaveBeenCalled(); + expect(app.sendResize).toHaveBeenCalledWith('s1'); + }); + + it('leaves the resize to a caller that does its own (selectSession)', () => { + const app = reconcile({ mode: 'codex' }, { resize: false }); + expect(app._keyboardHeldRows).toBe(0); + expect(app.sendResize).not.toHaveBeenCalled(); + }); + + it('keeps a hold the session still qualifies for', () => { + const app = reconcile({ mode: 'claude' }); + expect(app._keyboardHeldRows).toBe(38); + expect(app.sendResize).not.toHaveBeenCalled(); + }); + + // session:updated is how a running Claude session reports entering its + // fullscreen renderer (cliMouseTracking), so the shipped handler must release. + const updatedApp = () => ({ + ...appWith({ mode: 'claude' }), + _keyboardHeldRows: 38, + _applyKeyboardClip: vi.fn(), + sendResize: vi.fn(() => Promise.resolve(true)), + _reconcileKeyboardHold: proto._reconcileKeyboardHold, + renderSessionTabs: vi.fn(), + updateCost: vi.fn(), + updateSubagentParentNames: vi.fn(), + }); + + it('a session:updated that turns mouse tracking on releases the hold and resizes', () => { + const app = updatedApp(); + app.sendResize.mockClear(); + proto._onSessionUpdated.call(app, { session: { id: 's1', mode: 'claude', cliMouseTracking: true } }); + expect(app._keyboardHeldRows).toBe(0); + expect(app.sendResize).toHaveBeenCalledWith('s1'); + }); + + it('a session:updated for another session, or one that still qualifies, keeps it', () => { + const app = updatedApp(); + app.sessions.set('s2', { id: 's2', mode: 'claude' }); + proto._onSessionUpdated.call(app, { session: { id: 's2', mode: 'claude', cliMouseTracking: true } }); + proto._onSessionUpdated.call(app, { session: { id: 's1', mode: 'claude' } }); + expect(app._keyboardHeldRows).toBe(38); + expect(app.sendResize).not.toHaveBeenCalled(); + }); +}); + +// The composer must stay on screen with the rows held. The COLUMNS ONLY rule in +// reconcilePtyGeometry exists because a screen taller than its box once hid the +// CLI's input line with nothing able to reach it; the hold makes the screen +// taller on purpose, and this pins that the clip puts the composer back in view. +// A real headless xterm holds Claude's inline frame at the held 38 rows, and the +// shipped getTerminalDimensions and _applyKeyboardClip run against it. +describe('the composer row stays visible with the rows held', () => { + const ROWS = 38; + const COLS = 62; + const CELL = 17; + const BOX = 15 * CELL; // the box left above the keyboard: 15 rows + + async function heldApp(transcriptLines: number) { + const { proto, keyboard } = loadTerminalProto(); + const term = new HeadlessTerminal({ cols: COLS, rows: ROWS, scrollback: 1000, allowProposedApi: true }); + // Claude's inline renderer: the transcript, then its composer between two + // rules with the footer under it, the cursor parked after the prompt glyph. + const rule = '\u2500'.repeat(COLS); + const transcript = Array.from({ length: transcriptLines }, (_, i) => `KBTEST-${String(i + 1).padStart(3, '0')}`); + const frame = [...transcript, rule, '\u276f ', rule, ' ? for shortcuts'].join('\r\n'); + await new Promise<void>((done) => term.write(`${frame}\x1b[2A\x1b[3G`, () => done())); + const style = { setProperty: vi.fn(), removeProperty: vi.fn() }; + const container = { clientHeight: BOX, style }; + const app: Record<string, unknown> = { + _keyboardHeldRows: 0, + fitAddon: { proposeDimensions: () => ({ cols: COLS, rows: 15 }) }, + terminal: { + rows: term.rows, + buffer: term.buffer, + element: { parentElement: container }, + _core: { _renderService: { dimensions: { css: { cell: { height: CELL } } } } }, + }, + _localEchoOverlay: { setRowLimit: vi.fn() }, + _keyboardHeldRowsActive: proto._keyboardHeldRowsActive, + getTerminalDimensions: proto.getTerminalDimensions, + _applyKeyboardClip: proto._applyKeyboardClip, + }; + const buffer = term.buffer.active; + const screenRow = (pattern: RegExp) => { + for (let row = 0; row < ROWS; row++) { + if (pattern.test(buffer.getLine(buffer.baseY + row)?.translateToString(true) ?? '')) return row; + } + return -1; + }; + return { app, keyboard, style, buffer, screenRow }; + } + + /** The --kb-clip offset last written to the container, 0 when removed or never set. */ + function clipOf(style: { setProperty: ReturnType<typeof vi.fn>; removeProperty: ReturnType<typeof vi.fn> }) { + const set = style.setProperty.mock.calls.at(-1); + return set ? parseFloat(String(set[1])) : 0; + } + + /** Is screen row `row` wholly inside the box once the screen is translated up by `clip` px? */ + const fullyVisible = (row: number, clip: number) => row * CELL >= clip && (row + 1) * CELL <= clip + BOX; + + it('a long transcript: the composer and the footer under it sit at the bottom of the box', async () => { + const { app, keyboard, style, buffer, screenRow } = await heldApp(100); + keyboard.keyboardVisible = true; + app._keyboardHeldRows = ROWS; // what KeyboardHandler captures on the show transition + + // The PTY keeps its rows while the box shrinks to 15; columns still follow the box. + expect((app.getTerminalDimensions as () => unknown).call(app)).toEqual({ cols: COLS, rows: ROWS }); + + (app._applyKeyboardClip as () => void).call(app); + const composer = screenRow(/^\u276f/); + const footer = screenRow(/\? for shortcuts/); + expect([composer, footer, buffer.cursorY]).toEqual([35, 37, 35]); + const clip = clipOf(style); + expect(clip).toBe(ROWS * CELL - BOX); + expect(fullyVisible(composer, clip)).toBe(true); + expect(fullyVisible(footer, clip)).toBe(true); + // The local-echo overlay may draw down to the last visible row, the composer's included. + const limit = (app._localEchoOverlay as { setRowLimit: ReturnType<typeof vi.fn> }).setRowLimit.mock.calls.at( + -1 + )![0]; + expect(limit).toBe(ROWS); + }); + + it('a fresh session whose frame ends near the top: no offset, the composer already in view', async () => { + const { app, keyboard, style, screenRow } = await heldApp(5); + keyboard.keyboardVisible = true; + app._keyboardHeldRows = ROWS; + (app._applyKeyboardClip as () => void).call(app); + const composer = screenRow(/^\u276f/); + expect(composer).toBe(6); + expect(clipOf(style)).toBe(0); + expect(fullyVisible(composer, 0)).toBe(true); + // Rows 0..14 are the visible ones: the overlay must grow upward from row 14, + // not from row 37 under the keyboard. + expect((app._localEchoOverlay as { setRowLimit: ReturnType<typeof vi.fn> }).setRowLimit).toHaveBeenLastCalledWith( + 15 + ); + }); + + it('keyboard closed: the offset and the overlay bound are both removed', async () => { + const { app, keyboard, style } = await heldApp(100); + keyboard.keyboardVisible = true; + app._keyboardHeldRows = ROWS; + (app._applyKeyboardClip as () => void).call(app); + expect(clipOf(style)).toBeGreaterThan(0); + + keyboard.keyboardVisible = false; + const overlay = app._localEchoOverlay as { setRowLimit: ReturnType<typeof vi.fn> }; + overlay.setRowLimit.mockClear(); + style.setProperty.mockClear(); + (app._applyKeyboardClip as () => void).call(app); + expect(style.removeProperty).toHaveBeenCalledWith('--kb-clip'); + expect(style.setProperty).not.toHaveBeenCalled(); + expect(overlay.setRowLimit).toHaveBeenCalledWith(null); + expect(app._keyboardClipPx).toBe(0); + }); +}); + +type ManualTimers = { setTimeout(fn: () => void): number; clearTimeout(id: number): void; run(): void }; + +/** setTimeout/clearTimeout whose callbacks run only when the test says so. */ +function manualTimers(): ManualTimers { + const pending = new Map<number, () => void>(); + let next = 0; + return { + setTimeout(fn) { + pending.set(++next, fn); + return next; + }, + clearTimeout(id) { + pending.delete(id); + }, + run() { + for (let round = 0; pending.size && round < 10; round++) { + for (const [id, fn] of [...pending]) { + pending.delete(id); + fn(); + } + } + }, + }; +} + +function loadKeyboardHandler(enabled: boolean, opts: { app?: Record<string, unknown>; timers?: ManualTimers } = {}) { + const viewport = { width: 390, height: 664, offsetTop: 0, addEventListener: () => {}, removeEventListener: () => {} }; + const layout = { height: 664 }; + const app = opts.app ?? { + terminal: { rows: 38 }, + _keyboardHeldRows: 0, + _keyboardHoldEligible: () => enabled, + _applyKeyboardClip: vi.fn(), + // The shipped release, so a hide or a shape change goes through the real helper. + _releaseKeyboardHold: loadTerminalProto().proto._releaseKeyboardHold, + relayoutMobileSubagentWindows: () => {}, + }; + const context = vm.createContext({ + console, + app, + navigator: { userAgent: 'iPhone', maxTouchPoints: 5 }, + window: { + get innerWidth() { + return viewport.width; + }, + get innerHeight() { + return layout.height; + }, + visualViewport: viewport, + addEventListener: () => {}, + removeEventListener: () => {}, + matchMedia: () => ({ matches: true }), + scrollTo: () => {}, + }, + document: { + body: { classList: { add: () => {}, remove: () => {} } }, + documentElement: { style: { setProperty: () => {} } }, + addEventListener: () => {}, + removeEventListener: () => {}, + getElementById: () => null, + querySelector: () => null, + }, + setTimeout: opts.timers ? opts.timers.setTimeout : () => 1, + clearTimeout: opts.timers ? opts.timers.clearTimeout : () => {}, + }); + vm.runInContext(`${read('mobile-handlers.js')}\nglobalThis.__KH = KeyboardHandler;`, context); + const handler = (context as unknown as { __KH: { init(): void; handleViewportResize(): void } }).__KH; + handler.init(); + const resizeTo = (width: number, height: number, layoutHeight = height) => { + viewport.width = width; + viewport.height = height; + layout.height = layoutHeight; + handler.handleViewportResize(); + }; + return { app: app as typeof app & Record<string, any>, handler, resizeTo }; +} + +describe('KeyboardHandler captures and releases the hold', () => { + it('captures the keyboard-free rows on show and releases them on hide', () => { + const { app, resizeTo } = loadKeyboardHandler(true); + resizeTo(390, 364, 664); + expect(app._keyboardHeldRows).toBe(38); + app._applyKeyboardClip.mockClear(); + resizeTo(390, 664); + expect(app._keyboardHeldRows).toBe(0); + expect(app._applyKeyboardClip).toHaveBeenCalledTimes(1); + }); + + it('holds nothing for a session that does not qualify (setting off, a full-height CLI)', () => { + const { app, resizeTo } = loadKeyboardHandler(false); + resizeTo(390, 364, 664); + expect(app._keyboardHeldRows).toBe(0); + }); + + it('releases on a shape change with the keyboard still up', () => { + const { app, resizeTo } = loadKeyboardHandler(true); + resizeTo(390, 364, 664); + resizeTo(664, 90, 390); // rotated, keyboard still up + expect(app._keyboardHeldRows).toBe(0); + }); +}); + +// A rotation (or fold) with the keyboard still up releases the hold, since its +// rows belong to the display that is gone. The keyboard is still visible, so +// throttledResize stands aside and only the viewport settle can refit: the +// release has to arm it, or xterm keeps the held rows with no clip and the +// composer sits under the keyboard until it closes. The real KeyboardHandler +// drives the real terminal-ui methods over a headless xterm, with the timers +// run by hand so the opening settle finishes before the rotation. +describe('a rotation with the keyboard up', () => { + const CELL = 17; + + async function rotationSetup(eligible: boolean) { + const box = { cols: 44, rows: 38 }; + const term = new HeadlessTerminal({ cols: box.cols, rows: box.rows, scrollback: 1000, allowProposedApi: true }); + const rule = '\u2500'.repeat(40); + const transcript = Array.from({ length: 100 }, (_, i) => `KBTEST-${String(i + 1).padStart(3, '0')}`); + const frame = [...transcript, rule, '\u276f ', rule, ' ? for shortcuts'].join('\r\n'); + await new Promise<void>((done) => term.write(`${frame}\x1b[2A\x1b[3G`, () => done())); + const style = { setProperty: vi.fn(), removeProperty: vi.fn() }; + const container = { + get clientHeight() { + return box.rows * CELL; + }, + style, + }; + const sent: unknown[] = []; + const app: Record<string, any> = { + activeSessionId: 's1', + sessions: new Map([['s1', { id: 's1', mode: eligible ? 'claude' : 'codex' }]]), + _keyboardHeldRows: 0, + loadAppSettingsFromStorage: () => ({}), + relayoutMobileSubagentWindows: () => {}, + isTerminalAtBottom: () => true, + _scheduleOverflowAffordanceSync: () => {}, + _syncMobileHelperTextareaToCursor: () => {}, + fitAddon: { proposeDimensions: () => ({ ...box }), fit: () => term.resize(box.cols, box.rows) }, + terminal: { + get rows() { + return term.rows; + }, + get cols() { + return term.cols; + }, + get buffer() { + return term.buffer; + }, + resize: (cols: number, rows: number) => term.resize(cols, rows), + scrollToBottom: () => {}, + element: { parentElement: container }, + _core: { _renderService: { dimensions: { css: { cell: { height: CELL } } } } }, + }, + sendResize: vi.fn(() => { + sent.push(app.getTerminalDimensions()); + return Promise.resolve(true); + }), + }; + const timers = manualTimers(); + const { handler, resizeTo } = loadKeyboardHandler(eligible, { app, timers }); + const { proto, windowStub } = loadTerminalProto(handler as unknown as Record<string, unknown>); + windowStub.__codemanKeyboardHoldModes = ['claude']; + // Every method not stubbed above is the shipped one. + Object.setPrototypeOf(app, proto); + const buffer = term.buffer.active; + const composerRow = () => { + for (let row = 0; row < term.rows; row++) { + if (/^\u276f/.test(buffer.getLine(buffer.baseY + row)?.translateToString(true) ?? '')) return row; + } + return -1; + }; + // Keyboard up over a portrait display, and the opening settle runs to the end. + box.rows = 15; + resizeTo(390, 364, 664); + timers.run(); + return { app, box, term, style, sent, timers, resizeTo, composerRow }; + } + + it('refits to the new display and tells the PTY, so the composer is back in the box', async () => { + const { app, box, term, style, sent, timers, resizeTo, composerRow } = await rotationSetup(true); + // The hold is in place after the opening settle: 38 rows in a 15-row box, clipped. + expect([term.cols, term.rows, app._keyboardHeldRows]).toEqual([44, 38, 38]); + expect(app._keyboardClipPx).toBe((38 - 15) * CELL); + + // Rotate to landscape with the keyboard still up: a 12-row, 76-column box. + box.cols = 76; + box.rows = 12; + app.sendResize.mockClear(); + sent.length = 0; + style.removeProperty.mockClear(); + resizeTo(664, 120, 390); + timers.run(); + + expect(app._keyboardHeldRows).toBe(0); + expect([term.cols, term.rows]).toEqual([76, 12]); + expect(app.sendResize).toHaveBeenCalledWith('s1'); + expect(sent).toEqual([{ cols: 76, rows: 12 }]); + // No clip left, and with the rows fitted to the box the composer is inside it. + expect(style.removeProperty).toHaveBeenCalledWith('--kb-clip'); + expect(app._keyboardClipPx).toBe(0); + // Shrinking the rows, xterm keeps the cursor row (the composer) on the last + // screen row; Claude redraws its footer under it after the SIGWINCH. + const composer = composerRow(); + expect(composer).toBe(11); + expect((composer + 1) * CELL).toBeLessThanOrEqual(box.rows * CELL); + }); + + it('with no hold, a rotation arms nothing, as before', async () => { + const { app, box, term, timers, resizeTo } = await rotationSetup(false); + // Not eligible: the opening settle fitted the short box, as before the hold existed. + expect([term.rows, app._keyboardHeldRows]).toEqual([15, 0]); + box.cols = 76; + box.rows = 12; + app.sendResize.mockClear(); + resizeTo(664, 120, 390); + timers.run(); + expect(app.sendResize).not.toHaveBeenCalled(); + expect([term.cols, term.rows]).toEqual([44, 15]); + }); +}); + +describe('keyboardHoldsRows is a per-device setting', () => { + const settingsUi = read('settings-ui.js'); + const html = read('index.html'); + const schemas = readFileSync(resolve(import.meta.dirname, '../src/web/schemas.ts'), 'utf8'); + + it('is a display key, stripped from the PUT and absent from the strict schema', () => { + const start = settingsUi.indexOf('const displayKeys = new Set(['); + expect(settingsUi.slice(start, settingsUi.indexOf('])', start))).toContain("'keyboardHoldsRows'"); + expect(settingsUi).toContain('keyboardHoldsRows: _khr,'); + expect(schemas).not.toContain('keyboardHoldsRows'); + }); + + it('defaults ON and loads/saves by id', () => { + expect(html).toContain('id="appSettingsKeyboardHoldsRows"'); + expect(settingsUi).toContain( + "document.getElementById('appSettingsKeyboardHoldsRows').checked = settings.keyboardHoldsRows !== false;" + ); + expect(settingsUi).toContain("keyboardHoldsRows: document.getElementById('appSettingsKeyboardHoldsRows').checked,"); + }); +}); diff --git a/test/keyboard-hold-i18n.test.ts b/test/keyboard-hold-i18n.test.ts new file mode 100644 index 00000000..f41ab057 --- /dev/null +++ b/test/keyboard-hold-i18n.test.ts @@ -0,0 +1,67 @@ +/** + * @fileoverview The "Keep Size Under Keyboard" App Settings row (#672) in Chinese (zh-CN): + * its label and description run through the real translator over the real index.html in + * JSDOM, read in Chinese with no English left beyond `Claude` and `Codex`, and each key is + * in the dictionary once (a repeated key in the object literal silently replaces the first). + * + * Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { afterAll, describe, expect, it } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8'); +const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'); + +type Api = { configure(o: Record<string, string>): void; start(): void }; + +/** What may stay Latin: the two CLI names. */ +const leftover = (text: string) => text.replace(/\b(Claude|Codex)\b/g, '').match(/[A-Za-z]+/g) ?? []; + +function rowStrings(doc: Document): { label: string; description: string } { + const row = doc.getElementById('appSettingsKeyboardHoldsRows')!.closest('.set-row')!; + return { + label: row.querySelector('.set-row-label')!.textContent!.trim(), + description: row.querySelector('.set-row-desc')!.textContent!.trim(), + }; +} + +const source = new JSDOM(INDEX); +const english = rowStrings(source.window.document); + +const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' }); +vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' }); +const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n; +api.start(); +api.configure({ language: 'zh-CN' }); +const chinese = rowStrings(dom.window.document); + +afterAll(() => { + source.window.close(); + dom.window.close(); +}); + +describe('the Keep Size Under Keyboard row in zh-CN', () => { + it('finds the row (the check is not vacuous)', () => { + expect(english.label).toBe('Keep Size Under Keyboard'); + expect(english.description).toMatch(/^For local Claude sessions, opening the on-screen keyboard/); + }); + + it('translates the label and the description, with no English left', () => { + expect(chinese).toEqual({ + label: '键盘弹出时保持尺寸', + description: + '对于本地 Claude 会话,打开屏幕键盘时会保持会话尺寸并改为滚动视图,这样 Claude 不会重绘屏幕而丢失对话记录行。全屏界面(Codex、Claude 的全屏模式)仍像以前一样调整尺寸。', + }); + for (const key of ['label', 'description'] as const) expect(leftover(chinese[key])).toEqual([]); + }); + + it('adds each key to the dictionary once', () => { + for (const text of [english.label, english.description]) { + expect(I18N.split(`'${text}'`).length + I18N.split(`"${text}"`).length - 2).toBe(1); + } + }); +}); diff --git a/test/link-provider-regex.test.ts b/test/link-provider-regex.test.ts index 7b896097..7eaeb1f8 100644 --- a/test/link-provider-regex.test.ts +++ b/test/link-provider-regex.test.ts @@ -17,6 +17,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'fs'; import { join } from 'path'; +import vm from 'node:vm'; const publicFile = (name: string) => readFileSync(join(__dirname, '..', 'src', 'web', 'public', name), 'utf-8'); @@ -99,8 +100,9 @@ describe('terminal link-provider regexes (shipped source)', () => { // Excluding `&` truncated every real query string: a WordPress edit link // resolved to `?post=1479` and opened the wrong page, and Claude Code's OAuth // login URL (many `&` params) was not clickable at all. - const url = shippedPattern('urlPattern'); - const strip = (u: string) => u.replace(/[.,;:!?)&]+$/, ''); + const context = vm.createContext({ console, window: undefined }); + vm.runInContext(`${CONSTANTS_SOURCE}\nglobalThis.findUrls = findTerminalUrls;`, context); + const findUrls = context.findUrls as (text: string) => Array<{ url: string }>; const cases: Array<[string, string]> = [ [ 'updated in place: https://bio-hacking.blog/wp-admin/post.php?post=1479&action=edit', @@ -115,10 +117,7 @@ describe('terminal link-provider regexes (shipped source)', () => { ['trailing https://x.com/a?b=1& next', 'https://x.com/a?b=1'], ]; for (const [line, want] of cases) { - url.lastIndex = 0; - const m = url.exec(line); - expect(m, line).not.toBeNull(); - expect(strip(m![0]), line).toBe(want); + expect(findUrls(line).map((match) => match.url), line).toEqual([want]); } }); diff --git a/test/local-history-live.test.ts b/test/local-history-live.test.ts new file mode 100644 index 00000000..850afdab --- /dev/null +++ b/test/local-history-live.test.ts @@ -0,0 +1,157 @@ +/** + * @fileoverview The offline history reader against rollouts that change while + * it reads them: a running Codex appending to its own rollout (or a child's), + * an unrelated rollout rewritten mid-scan, and a selected rollout replaced + * outright. `node:fs/promises` is wrapped so a test can act at an exact point + * of the read (after the open, after a read call) without timing races. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { appendFile, mkdtemp, rename, rm, truncate, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +type Hook = (path: string, openCount: number, handle: import('node:fs/promises').FileHandle) => Promise<void> | void; +const hooks: { onOpen?: Hook } = {}; +const opens = new Map<string, number>(); + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal<typeof import('node:fs/promises')>(); + return { + ...actual, + open: async (...args: Parameters<typeof actual.open>) => { + const handle = await actual.open(...args); + const path = String(args[0]); + const count = (opens.get(path) ?? 0) + 1; + opens.set(path, count); + await hooks.onOpen?.(path, count, handle); + return handle; + }, + }; +}); + +const history = await import('../src/local-history.js'); +const { assistantTurn, realMeta, settingsApplied, userTurn, writeRollout } = + await import('./fixtures/codex-rollout.js'); + +const ROOT = 'aaaaaaaa-1111-4111-8111-111111111111'; +const CHILD = 'bbbbbbbb-1111-4111-8111-111111111111'; +let dir: string; +const line = (value: unknown) => `${JSON.stringify(value)}\n`; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'local-history-live-')); + opens.clear(); + hooks.onOpen = undefined; +}); +afterEach(async () => { + hooks.onOpen = undefined; + await rm(dir, { recursive: true, force: true }); +}); + +describe('rollouts that change while they are read', () => { + it('reads a snapshot of a rollout a running Codex appends to after attribution', async () => { + const path = await writeRollout(dir, 'rollout-root.jsonl', [realMeta({ id: ROOT }), ...userTurn('question')]); + hooks.onOpen = async (opened, count) => { + // The second open is the transcript read; Codex writes a whole record and starts the next. + if (opened === path && count === 2) { + await appendFile(path, assistantTurn('answer').map(line).join('')); + await appendFile(path, '{"timestamp":"2026-10-24T01:00:02.000Z","type":"event_'); + } + }; + const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir }); + expect(result.records.map((r) => r.message)).toEqual(['question', 'answer']); + // The record cut mid-write is reported, not parsed and not fatal. + expect(result.scan.partialRecords).toBe(1); + expect(result.scan.malformedRecords).toBe(0); + expect(result.complete).toBe(false); + }); + + it('reads a family whose child is still running', async () => { + await writeRollout(dir, 'rollout-root.jsonl', [realMeta({ id: ROOT }), ...userTurn('root question')]); + const child = await writeRollout(dir, 'rollout-child.jsonl', [ + realMeta({ id: CHILD, parent: ROOT, mode: 'legacy' }), + ...userTurn('child task'), + ]); + hooks.onOpen = async (opened, count) => { + // Grows during the metadata scan and again before its transcript read. + if (opened === child) + await appendFile(child, assistantTurn(`child progress ${count}`, 'commentary').map(line).join('')); + }; + const result = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir }); + expect(result.records.map((r) => r.message)).toEqual([ + 'root question', + 'child task', + 'child progress 1', + 'child progress 2', + ]); + expect(result.complete).toBe(true); + }); + + it('counts an unrelated rollout that shrinks during its head read as unattributed', async () => { + await writeRollout(dir, 'rollout-root.jsonl', [realMeta({ id: ROOT }), ...userTurn('question')]); + const other = await writeRollout(dir, 'rollout-other.jsonl', [realMeta({ id: CHILD }), ...userTurn('other')]); + hooks.onOpen = async (opened, count, handle) => { + if (opened !== other || count !== 1) return; + const read = handle.read.bind(handle) as (...args: unknown[]) => Promise<unknown>; + (handle as unknown as { read: (...args: unknown[]) => Promise<unknown> }).read = async (...args) => { + const result = await read(...args); + await truncate(other, 10); + return result; + }; + }; + const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir }); + expect(result.records.map((r) => r.message)).toEqual(['question']); + expect(result.scan.unattributedFiles).toBe(1); + expect(result.complete).toBe(false); + }); + + it('still refuses a selected rollout replaced by another file after attribution', async () => { + const path = await writeRollout(dir, 'rollout-root.jsonl', [realMeta({ id: ROOT }), ...userTurn('question')]); + hooks.onOpen = async (opened, count) => { + if (opened !== path || count !== 1) return; + const replacement = join(dir, 'replacement.tmp'); + await writeFile(replacement, [realMeta({ id: ROOT }), ...userTurn('rewritten')].map(line).join('')); + await rename(replacement, path); + }; + await expect(history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir })).rejects.toThrow( + /changed after attribution/ + ); + }); + + it('still refuses a selected rollout truncated after attribution', async () => { + const path = await writeRollout(dir, 'rollout-root.jsonl', [realMeta({ id: ROOT }), ...userTurn('question')]); + hooks.onOpen = async (opened, count) => { + if (opened === path && count === 2) await truncate(path, 100); + }; + await expect(history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir })).rejects.toThrow( + /changed after attribution/ + ); + }); + it('leaves a child readable, with an unproven cut, when its parent is replaced after attribution', async () => { + const parentSettings = [settingsApplied(), ...userTurn('parent second question')]; + const parent = await writeRollout(dir, 'rollout-root.jsonl', [ + realMeta({ id: ROOT }), + ...userTurn('parent question'), + ...parentSettings, + ]); + const child = await writeRollout(dir, 'rollout-child.jsonl', [ + realMeta({ id: CHILD, parent: ROOT }), + realMeta({ id: ROOT }), + ...userTurn('parent question'), + ...parentSettings, + settingsApplied(), + ...userTurn('child task'), + ]); + hooks.onOpen = async (opened, count) => { + // The child's transcript read comes before the parent's proof read: replace the parent in between. + if (opened !== child || count !== 2) return; + const replacement = join(dir, 'replacement.tmp'); + await writeFile(replacement, [realMeta({ id: ROOT }), ...userTurn('rewritten')].map(line).join('')); + await rename(replacement, parent); + }; + const result = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir }); + expect(result.records.map((r) => r.message)).toEqual(['child task']); + expect(result.scan.unverifiedPrefixes).toBe(1); + expect(result.complete).toBe(false); + }); +}); diff --git a/test/local-history.test.ts b/test/local-history.test.ts new file mode 100644 index 00000000..15989c6c --- /dev/null +++ b/test/local-history.test.ts @@ -0,0 +1,853 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { chmod, mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import * as history from '../src/local-history.js'; +import { Command } from 'commander'; +import { registerHistoryCommands } from '../src/cli-history.js'; +import { + assistantTurn, + developer, + injected, + realMeta, + settingsApplied, + taskStarted, + userTurn, + withOrdinals, + writeRollout, +} from './fixtures/codex-rollout.js'; + +const ROOT = 'aaaaaaaa-1111-4111-8111-111111111111'; +const CHILD = 'bbbbbbbb-1111-4111-8111-111111111111'; +const GRANDCHILD = 'cccccccc-1111-4111-8111-111111111111'; +let dir: string; +const meta = (id: string, parent?: string) => ({ + type: 'session_meta', + payload: { + id, + timestamp: '2026-10-24T01:00:00Z', + ...(parent ? { source: { subagent: { thread_spawn: { parent_thread_id: parent } } } } : {}), + }, +}); +const row = (role: string, text: string, timestamp = '2026-10-24T01:00:00Z') => ({ + type: 'response_item', + timestamp, + payload: { type: 'message', role, content: [{ type: role === 'user' ? 'input_text' : 'output_text', text }] }, +}); +async function fixture(id: string, records: unknown[], parent?: string) { + const path = join(dir, `rollout-${id}.jsonl`); + await writeFile(path, [meta(id, parent), ...records].map((r) => JSON.stringify(r)).join('\n') + '\n'); + return path; +} +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'local-history-')); +}); +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe('offline native history', () => { + it('reads a native transcript without a server and preserves identity and timestamps', async () => { + const file = await fixture(ROOT, [row('user', 'question'), row('assistant', 'answer')]); + const result = await history.readLocalHistory({ file }); + expect(result.records.map((r) => r.message)).toEqual(['question', 'answer']); + expect(result.records[0]).toMatchObject({ + sessionId: ROOT, + rootSessionId: ROOT, + timestamp: '2026-10-24T01:00:00Z', + }); + expect(result.complete).toBe(true); + }); + it('resolves metadata identities rather than filenames', async () => { + await fixture(ROOT, [row('assistant', 'yes')]); + const result = await history.readLocalHistory({ sessionId: ROOT.slice(0, 8), sessionsRoot: dir }); + expect(result.records[0].sessionId).toBe(ROOT); + }); + it('rejects ambiguous native prefixes', async () => { + await fixture(ROOT, [row('assistant', 'one')]); + await fixture('aaaaaaaa-2222-4222-8222-222222222222', [row('assistant', 'two')]); + await expect(history.readLocalHistory({ sessionId: 'aaaaaaaa', sessionsRoot: dir })).rejects.toThrow(/ambiguous/); + }); + it('gathers transitive children and retains a whole topic/date matching turn', async () => { + await fixture(ROOT, [row('user', 'other'), row('assistant', 'unrelated')]); + await fixture( + CHILD, + [row('user', 'topic question'), row('assistant', 'answer after midnight', '2026-10-25T00:01:00Z')], + ROOT + ); + await fixture(GRANDCHILD, [row('user', 'topic child'), row('assistant', 'child answer')], CHILD); + const result = await history.readLocalHistory({ + rootSessionId: ROOT, + sessionsRoot: dir, + topic: 'TOPIC', + before: '2026-10-24', + }); + expect(result.sessions).toHaveLength(3); + expect(result.records.map((r) => r.message)).toEqual([ + 'topic question', + 'answer after midnight', + 'topic child', + 'child answer', + ]); + expect(result.records.every((r) => r.rootSessionId === ROOT)).toBe(true); + }); + it('resolves the real native root when querying a child by id', async () => { + await fixture(ROOT, [row('assistant', 'root')]); + await fixture(CHILD, [row('assistant', 'child')], ROOT); + await fixture(GRANDCHILD, [row('assistant', 'grandchild')], CHILD); + const result = await history.readLocalHistory({ sessionId: GRANDCHILD, sessionsRoot: dir }); + expect(result.records[0].rootSessionId).toBe(ROOT); + expect(result.sessions[0]).toMatchObject({ parentSessionId: CHILD, rootSessionId: ROOT }); + }); + it('labels a file-only child root unknown rather than inventing an ancestor', async () => { + const file = await fixture(CHILD, [row('assistant', 'child')], ROOT); + const result = await history.readLocalHistory({ file }); + expect(result.records[0].rootSessionId).toBeNull(); + expect(result.sessions[0]).toMatchObject({ parentSessionId: ROOT, rootSessionId: null }); + }); + it('labels missing ancestors unknown and refuses cyclic ancestry', async () => { + await fixture(CHILD, [row('assistant', 'child')], ROOT); + expect( + (await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir })).records[0].rootSessionId + ).toBeNull(); + await fixture(ROOT, [row('assistant', 'root')], CHILD); + await expect(history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir })).rejects.toThrow(/cyclic/); + }); + it('supports legacy shared session_id genealogy', async () => { + await fixture(ROOT, [row('assistant', 'root')]); + await writeFile( + join(dir, 'legacy.jsonl'), + [{ type: 'session_meta', payload: { id: CHILD, session_id: ROOT } }, row('assistant', 'legacy child')] + .map((r) => JSON.stringify(r)) + .join('\n') + '\n' + ); + expect((await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir })).sessions).toHaveLength(2); + }); + it('parses modern events and removes adjacent duplicate representations without erasing repeated turns', async () => { + const file = await fixture(ROOT, [ + row('user', 'question'), + { + type: 'event_msg', + payload: { + type: 'item_completed', + item: { type: 'UserMessage', content: [{ type: 'Text', text: 'question' }] }, + }, + }, + row('assistant', 'answer'), + { + type: 'event_msg', + payload: { + type: 'item_completed', + item: { type: 'AgentMessage', phase: 'final_answer', content: [{ type: 'Text', text: 'answer' }] }, + }, + }, + { type: 'event_msg', payload: { type: 'user_message', message: 'question' } }, + { type: 'event_msg', payload: { type: 'agent_message', message: 'answer' } }, + ]); + expect((await history.readLocalHistory({ file })).records.map((r) => r.message)).toEqual([ + 'question', + 'answer', + 'question', + 'answer', + ]); + }); + it('preserves genuinely repeated identical messages from the same representation', async () => { + const file = await fixture(ROOT, [row('user', 'repeat'), row('user', 'repeat'), row('assistant', 'answer')]); + expect((await history.readLocalHistory({ file })).records.map((r) => r.message)).toEqual([ + 'repeat', + 'repeat', + 'answer', + ]); + }); + it('labels unreadable metadata as incomplete rather than hiding possible children', async () => { + await fixture(ROOT, [row('assistant', 'root')]); + await writeFile(join(dir, 'unattributed.jsonl'), '{bad metadata}\n'); + const result = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir }); + expect(result.complete).toBe(false); + expect(result.scan.unattributedFiles).toBe(1); + }); + it('refuses a later session_meta whose id is not a declared ancestor', async () => { + // ROOT declares no parent, fork source or shared root, so CHILD can only be another conversation. + const file = await fixture(ROOT, [row('user', 'root question'), meta(CHILD), row('assistant', 'child answer')]); + await expect(history.readLocalHistory({ file })).rejects.toThrow(/conflicting.*metadata/); + // A fork child is refused too when the inherited metadata names a thread it does not descend from. + const forked = await writeRollout(dir, 'unrelated-fork.jsonl', [ + realMeta({ id: CHILD, parent: ROOT, mode: 'legacy' }), + realMeta({ id: GRANDCHILD, mode: 'legacy' }), + row('assistant', 'not ours'), + settingsApplied(), + row('assistant', 'child answer'), + ]); + await expect(history.readLocalHistory({ file: forked })).rejects.toThrow(/conflicting.*metadata/); + }); + it('accepts repeated metadata only when native identity and genealogy agree', async () => { + const file = await fixture(ROOT, [row('user', 'question'), meta(ROOT), row('assistant', 'answer')]); + expect((await history.readLocalHistory({ file })).records.map((record) => record.message)).toEqual([ + 'question', + 'answer', + ]); + await fixture(CHILD, [meta(CHILD, GRANDCHILD), row('assistant', 'child answer')], ROOT); + await expect(history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir })).rejects.toThrow( + /conflicting.*metadata/ + ); + }); + it('labels skipped malformed and pending partial records', async () => { + const file = await fixture(ROOT, [row('assistant', 'complete')]); + const { appendFile } = await import('node:fs/promises'); + await appendFile(file, 'not json\n{"type":'); + const result = await history.readLocalHistory({ file }); + expect(result.complete).toBe(false); + expect(result.scan.malformedRecords).toBe(1); + expect(result.scan.partialRecords).toBe(1); + expect(result.records[0].message).toBe('complete'); + }); + it('rejects invalid UTF8, nonregular inputs and symlinks', async () => { + const file = await fixture(ROOT, [row('assistant', 'answer')]); + const linked = join(dir, 'linked.jsonl'); + await symlink(file, linked); + await expect(history.readLocalHistory({ file: linked })).rejects.toThrow(/regular|symlink/); + await expect(history.readLocalHistory({ file: dir })).rejects.toThrow(/regular/); + await writeFile(file, Buffer.from([0xff, 0x0a])); + await expect(history.readLocalHistory({ file })).rejects.toThrow(/UTF8/); + }); + it('fails closed for byte budgets and preserves valid JSON for successful output', async () => { + const file = await fixture(ROOT, [row('assistant', '😀'.repeat(80))]); + await expect(history.readLocalHistory({ file, scanBytes: 32 })).rejects.toThrow(/scan.*budget/); + const result = await history.readLocalHistory({ file }); + expect(() => history.formatLocalHistory(result, 'json', 40)).toThrow(/output.*budget/); + const output = history.formatLocalHistory(result, 'json', 8192); + expect(Buffer.byteLength(output)).toBeLessThanOrEqual(8192); + expect(JSON.parse(output).records[0].message).toBe('😀'.repeat(80)); + }); + it.each([{ after: '2026-02-30' }, { before: 'bad' }, { after: '2026-10-25', before: '2026-10-24' }, { topic: ' ' }])( + 'rejects invalid filters %j', + async (filter) => { + const file = await fixture(ROOT, [row('assistant', 'answer')]); + await expect(history.readLocalHistory({ file, ...filter })).rejects.toThrow(/date|topic/); + } + ); + it('rejects missing selectors, multiple selectors, empty matches and absent native ids', async () => { + const file = await fixture(ROOT, [row('assistant', 'answer')]); + await expect(history.readLocalHistory({})).rejects.toThrow(/exactly one/); + await expect(history.readLocalHistory({ file, sessionId: ROOT })).rejects.toThrow(/exactly one/); + await expect(history.readLocalHistory({ file, topic: 'absent' })).rejects.toThrow(/evidence/); + await expect(history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir })).rejects.toThrow(/missing/); + }); + it('rejects exhausted directory traversal rather than returning an incomplete family', async () => { + await fixture(ROOT, [row('assistant', 'root')]); + await mkdir(join(dir, 'nested')); + await fixture(CHILD, [row('assistant', 'child')], ROOT); + await expect(history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir, maxEntries: 1 })).rejects.toThrow( + /scan.*budget/ + ); + }); + it('executes the offline CLI with complete JSON and no internal parser fields', async () => { + const file = await fixture(ROOT, [row('assistant', 'offline')]); + let output = ''; + const program = new Command().exitOverride().configureOutput({ + writeOut: (text) => { + output += text; + }, + writeErr: () => {}, + }); + registerHistoryCommands(program); + await program.parseAsync(['history', 'read', '--file', file, '--json'], { from: 'user' }); + const result = JSON.parse(output); + expect(result.records[0].message).toBe('offline'); + expect(result.records[0]).not.toHaveProperty('representation'); + await expect( + program.parseAsync(['history', 'read', '--file', file, '--session-id', ROOT], { from: 'user' }) + ).rejects.toThrow(/exactly one/); + }); + it('registers offline commands and reports invalid selectors without process exit', async () => { + const program = new Command().exitOverride(); + registerHistoryCommands(program); + expect(program.commands.find((c) => c.name() === 'history')?.commands.map((c) => c.name())).toEqual(['read']); + }); +}); + +const messages = (result: history.LocalHistoryResult) => + result.records.map((r) => `${r.sessionId.slice(0, 1)}:${r.message}`); + +/** A parent R, and a legacy child C whose file copies R's history before its own, as Codex writes it. */ +async function legacyFamily(root: string, parentExtra: unknown[] = [], copiedExtra: unknown[] = []) { + await mkdir(root, { recursive: true }); + const parentHistory = [ + taskStarted(), + developer(), + injected(), + ...userTurn('parent question'), + ...assistantTurn('parent commentary', 'commentary'), + ...assistantTurn('parent answer'), + ]; + await writeRollout(root, `rollout-${ROOT}.jsonl`, [ + realMeta({ id: ROOT }), + ...parentHistory, + ...parentExtra, + ...userTurn('parent later question'), + ...assistantTurn('parent later answer'), + ]); + return writeRollout(root, `rollout-${CHILD}.jsonl`, [ + realMeta({ id: CHILD, parent: ROOT }), + realMeta({ id: ROOT }), + ...parentHistory, + ...copiedExtra, + settingsApplied(), + taskStarted(), + ...userTurn('child task'), + ...assistantTurn('child commentary', 'commentary'), + ...assistantTurn('child answer'), + ]); +} + +describe('forked and spawned threads', () => { + it('drops the parent history a legacy fork copies, so the child reads as itself', async () => { + const childFile = await legacyFamily(dir); + const child = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir }); + expect(messages(child)).toEqual(['b:child task', 'b:child commentary', 'b:child answer']); + expect(child.complete).toBe(true); + expect(child.scan.inheritedPrefixes).toBe(1); + expect(child.sessions[0]).toMatchObject({ sessionId: CHILD, parentSessionId: ROOT, rootSessionId: ROOT }); + // The whole family: every message once, the copy not repeated under the child. + const family = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir }); + expect(messages(family)).toEqual([ + 'a:parent question', + 'a:parent commentary', + 'a:parent answer', + 'a:parent later question', + 'a:parent later answer', + 'b:child task', + 'b:child commentary', + 'b:child answer', + ]); + expect(family.complete).toBe(true); + // An explicit file needs no store: the copy ends at the one thread_settings_applied. + const file = await history.readLocalHistory({ file: childFile }); + expect(messages(file)).toEqual(['b:child task', 'b:child commentary', 'b:child answer']); + expect(file.complete).toBe(true); + }); + + it('finds the end of a legacy copy that carries the parent own thread_settings_applied events', async () => { + // The parent compacted (or checkpointed its first turn) before the fork, so the copy holds that event too. + const parentSettings = [ + settingsApplied(), + ...userTurn('parent second question'), + ...assistantTurn('parent second answer'), + ]; + const childFile = await legacyFamily(dir, parentSettings, parentSettings); + const child = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir }); + expect(messages(child)).toEqual(['b:child task', 'b:child commentary', 'b:child answer']); + expect(child.complete).toBe(true); + // Without the parent's file the boundary cannot be proven: nothing of the parent is attributed to the child. + const alone = await history.readLocalHistory({ file: childFile }); + expect(messages(alone)).toEqual(['b:child task', 'b:child commentary', 'b:child answer']); + expect(alone.scan.unverifiedPrefixes).toBe(1); + expect(alone.complete).toBe(false); + }); + + it('keeps the child own later thread_settings_applied events on its side of the cut', async () => { + await legacyFamily(dir); + const path = join(dir, `rollout-${CHILD}.jsonl`); + const { appendFile } = await import('node:fs/promises'); + await appendFile( + path, + [settingsApplied(), ...userTurn('child second task'), ...assistantTurn('child second answer')] + .map((line) => JSON.stringify(line)) + .join('\n') + '\n' + ); + const child = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir }); + expect(messages(child)).toEqual([ + 'b:child task', + 'b:child commentary', + 'b:child answer', + 'b:child second task', + 'b:child second answer', + ]); + expect(child.complete).toBe(true); + }); + + it('accepts the copied metadata of an ancestor that was itself a fork', async () => { + const file = await writeRollout(dir, 'grandchild.jsonl', [ + realMeta({ id: GRANDCHILD, parent: CHILD, root: ROOT }), + realMeta({ id: CHILD, parent: ROOT }), + realMeta({ id: ROOT }), + ...userTurn('root question'), + settingsApplied(), + ...userTurn('child question'), + settingsApplied(), + ...userTurn('grandchild task'), + ...assistantTurn('grandchild answer'), + ]); + const result = await history.readLocalHistory({ file }); + expect(messages(result)).toEqual(['c:grandchild task', 'c:grandchild answer']); + expect(result.records[0].rootSessionId).toBe(ROOT); + }); + + it('marks a legacy copy that never reached its end marker incomplete and keeps none of it', async () => { + const file = await writeRollout(dir, 'cut.jsonl', [ + realMeta({ id: CHILD, parent: ROOT }), + realMeta({ id: ROOT }), + ...userTurn('parent question'), + ]); + await expect(history.readLocalHistory({ file })).rejects.toThrow(/evidence/); + await writeRollout(dir, 'cut.jsonl', [ + realMeta({ id: CHILD, parent: ROOT }), + ...userTurn('child before copy'), + realMeta({ id: ROOT }), + ...userTurn('parent question'), + ]); + const result = await history.readLocalHistory({ file }); + expect(messages(result)).toEqual(['b:child before copy']); + expect(result.scan.incompletePrefixes).toBe(1); + expect(result.complete).toBe(false); + }); + + it('drops the inherited records of a paginated sub-agent by ordinal, as Codex projects them', async () => { + const inherited = [ + realMeta({ id: CHILD, parent: ROOT, mode: 'paginated', historyStart: 7 }), + realMeta({ id: ROOT, mode: 'paginated' }), + taskStarted(), + injected(), + ...userTurn('parent question'), + ...assistantTurn('parent answer').slice(1), + ]; + expect(inherited).toHaveLength(7); + const lines = withOrdinals([ + ...inherited, + settingsApplied(), + taskStarted(), + ...assistantTurn('child commentary', 'commentary'), + ...assistantTurn('child answer'), + ]); + await writeRollout( + dir, + `rollout-${ROOT}.jsonl`, + withOrdinals([realMeta({ id: ROOT, mode: 'paginated' }), ...userTurn('parent question')]) + ); + await writeRollout(dir, `rollout-${CHILD}.jsonl`, lines); + const child = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir }); + expect(messages(child)).toEqual(['b:child commentary', 'b:child answer']); + expect(child.records.map((r) => r.phase)).toEqual(['commentary', 'final_answer']); + expect(child.complete).toBe(true); + const family = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir }); + expect(messages(family)).toEqual(['a:parent question', 'b:child commentary', 'b:child answer']); + }); + + it('reports a paginated copy that ends before its declared start ordinal', async () => { + const file = await writeRollout( + dir, + 'paginated-cut.jsonl', + withOrdinals([ + realMeta({ id: CHILD, parent: ROOT, mode: 'paginated', historyStart: 9 }), + realMeta({ id: ROOT, mode: 'paginated' }), + ...userTurn('parent question'), + ...assistantTurn('parent answer'), + ]) + ); + await expect(history.readLocalHistory({ file })).rejects.toThrow(/evidence/); + // In a family read the parent still answers, and the cut child makes the result incomplete. + await writeRollout( + dir, + `rollout-${ROOT}.jsonl`, + withOrdinals([realMeta({ id: ROOT, mode: 'paginated' }), ...userTurn('parent question')]) + ); + const family = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir }); + expect(messages(family)).toEqual(['a:parent question']); + expect(family.scan.incompletePrefixes).toBe(1); + expect(family.complete).toBe(false); + await rm(join(dir, `rollout-${ROOT}.jsonl`)); + // A paginated record without an ordinal is skipped, visibly, as Codex's projection skips it. + const lines = withOrdinals([ + realMeta({ id: CHILD, parent: ROOT, mode: 'paginated', historyStart: 2 }), + realMeta({ id: ROOT, mode: 'paginated' }), + settingsApplied(), + ...assistantTurn('child answer'), + ]); + await writeRollout(dir, 'paginated-cut.jsonl', [...lines, assistantTurn('stray')[0]]); + const result = await history.readLocalHistory({ file }); + expect(messages(result)).toEqual(['b:child answer']); + expect(result.scan.malformedRecords).toBe(1); + expect(result.complete).toBe(false); + }); +}); + +describe('one read per file, and a parent that cannot be read', () => { + const parentSettings = [ + settingsApplied(), + ...userTurn('parent second question'), + ...assistantTurn('parent second answer'), + ]; + const sizes = async () => { + const { stat } = await import('node:fs/promises'); + const parent = (await stat(join(dir, `rollout-${ROOT}.jsonl`))).size; + const child = (await stat(join(dir, `rollout-${CHILD}.jsonl`))).size; + return { parent, child }; + }; + + it('charges the parent of a legacy copy to --scan-bytes once, even when it proves the boundary', async () => { + await legacyFamily(dir, parentSettings, parentSettings); + const { parent, child } = await sizes(); + // A budget of exactly the two files: the family read selects the parent and proves the child's cut with it. + const family = await history.readLocalHistory({ + rootSessionId: ROOT, + sessionsRoot: dir, + scanBytes: parent + child, + }); + expect(family.scan.bytesRead).toBe(parent + child); + expect(family.complete).toBe(true); + expect(messages(family).filter((m) => m.startsWith('b:'))).toEqual([ + 'b:child task', + 'b:child commentary', + 'b:child answer', + ]); + // One byte less fails closed rather than returning less. + await expect( + history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir, scanBytes: parent + child - 1 }) + ).rejects.toThrow(/scan byte budget/); + // A child read pays for the parent it proves against, once. + const alone = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir, scanBytes: parent + child }); + expect(alone.scan.bytesRead).toBe(parent + child); + expect(alone.complete).toBe(true); + }); + + it('counts a parent record once when the parent is both selected and the proof of a child', async () => { + await legacyFamily(dir, parentSettings, parentSettings); + const { appendFile } = await import('node:fs/promises'); + await appendFile(join(dir, `rollout-${ROOT}.jsonl`), '{not json\n'); + const family = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir }); + expect(family.scan.malformedRecords).toBe(1); + expect(family.scan.inheritedPrefixes).toBe(1); + }); + + it('falls back to the unproven cut when the parent file cannot be read, instead of failing the child', async () => { + await legacyFamily(dir, parentSettings, parentSettings); + const { appendFile } = await import('node:fs/promises'); + await appendFile(join(dir, `rollout-${ROOT}.jsonl`), Buffer.from([0xff, 0x0a])); + const child = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir }); + expect(messages(child)).toEqual(['b:child task', 'b:child commentary', 'b:child answer']); + expect(child.scan.unverifiedPrefixes).toBe(1); + expect(child.complete).toBe(false); + // The parent's own read still fails: its evidence is what is broken. + await expect(history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir })).rejects.toThrow(/invalid UTF8/); + }); + + it('still fails when the parent read runs out of budget', async () => { + await legacyFamily(dir, parentSettings, parentSettings); + const { child } = await sizes(); + await expect( + history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir, scanBytes: child + 1 }) + ).rejects.toThrow(/scan byte budget/); + }); +}); + +describe('compressed rollouts', () => { + const ZSTD = Buffer.from([0x28, 0xb5, 0x2f, 0xfd, 0x04, 0x00, 0x00, 0x00]); + const compressedName = (id: string) => `rollout-2026-10-24T01-00-00-${id}.jsonl.zst`; + + it('counts a .jsonl.zst rollout, so a family with a compressed member is incomplete', async () => { + await legacyFamily(dir); + await mkdir(join(dir, '2026', '10', '24'), { recursive: true }); + await writeFile(join(dir, '2026', '10', '24', compressedName(GRANDCHILD)), ZSTD); + const family = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir }); + expect(family.scan.compressedFiles).toBe(1); + expect(family.scan.unattributedFiles).toBe(0); + expect(family.complete).toBe(false); + }); + + it('names the compressed rollout when the wanted thread is not otherwise found', async () => { + await fixture(ROOT, [row('assistant', 'root')]); + const path = join(dir, compressedName(CHILD)); + await writeFile(path, ZSTD); + const error = await history.readLocalHistory({ sessionId: CHILD.slice(0, 8), sessionsRoot: dir }).catch((e) => e); + expect(error.message).toContain(path); + expect(error.message).toMatch(/compressed rollout/); + // A compressed file whose name does not carry the id is still reported, by count. + const other = await history.readLocalHistory({ sessionId: GRANDCHILD, sessionsRoot: dir }).catch((e) => e); + expect(other.message).toMatch(/1 compressed \.jsonl\.zst rollout\(s\) could not be read/); + }); + + it('skips a compressed rollout whose plain sibling exists, as Codex does', async () => { + const plain = await fixture(ROOT, [row('assistant', 'root')]); + await writeFile(`${plain}.zst`, ZSTD); + const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir }); + expect(result.scan.compressedFiles).toBe(0); + expect(result.complete).toBe(true); + }); + + it('refuses a Zstandard file given to --file, and counts one in the store whatever its name', async () => { + const file = join(dir, 'renamed.jsonl'); + await writeFile(file, ZSTD); + await expect(history.readLocalHistory({ file })).rejects.toThrow(/Zstandard-compressed/); + await fixture(ROOT, [row('assistant', 'root')]); + const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir }); + expect(result.scan.unattributedFiles).toBe(1); + expect(result.complete).toBe(false); + }); +}); + +describe('large and untidy stores', () => { + it('finds one id among thousands of rollouts with real-sized session_meta at the default budgets', async () => { + const count = 3500; + const base = realMeta({ id: ROOT }); + const pad = JSON.stringify(base).length; + expect(pad).toBeGreaterThan(19 * 1024); + const writes: Array<Promise<void>> = []; + for (let i = 0; i < count; i++) { + const day = join(dir, '2026', String(1 + (i % 12)).padStart(2, '0'), String(1 + (i % 28)).padStart(2, '0')); + const id = `${i.toString(16).padStart(8, '0')}-2222-4222-8222-222222222222`; + writes.push( + mkdir(day, { recursive: true }).then(() => + writeFile( + join(day, `rollout-${id}.jsonl`), + [realMeta({ id }), ...userTurn(`question ${i}`), ...assistantTurn(`answer ${i}`)] + .map((line) => JSON.stringify(line)) + .join('\n') + '\n' + ) + ) + ); + } + await Promise.all(writes); + const target = `${(count - 1).toString(16).padStart(8, '0')}-2222-4222-8222-222222222222`; + const result = await history.readLocalHistory({ sessionId: target, sessionsRoot: dir }); + expect(messages(result)).toEqual([`0:question ${count - 1}`, `0:answer ${count - 1}`]); + expect(result.complete).toBe(true); + // Metadata is charged to its own budget; the transcript budget only paid for the one rollout. + expect(result.scan.metadataBytesRead).toBeGreaterThan(history.HISTORY_MAX_SCAN_BYTES / 4); + expect(result.scan.bytesRead).toBeLessThan(64 * 1024); + // The store-wide caps fail closed and are raisable. + await expect( + history.readLocalHistory({ sessionId: target, sessionsRoot: dir, metadataBytes: 1024 * 1024 }) + ).rejects.toThrow(/metadata.*--metadata-bytes/); + await expect(history.readLocalHistory({ sessionId: target, sessionsRoot: dir, maxEntries: 100 })).rejects.toThrow( + /entry budget.*--max-entries/ + ); + }, 60_000); + + it('reads only the head of each rollout to attribute it, not its whole window', async () => { + await writeRollout(dir, 'rollout-big.jsonl', [ + realMeta({ id: ROOT }), + ...Array.from({ length: 400 }, (_, i) => assistantTurn(`${i} ${'x'.repeat(2048)}`)).flat(), + ]); + const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir, scanBytes: 8 * 1024 * 1024 }); + expect(result.records).toHaveLength(400); + // One 32 KiB step holds the ~20 KiB session_meta line; the 128 KiB window is only the ceiling. + expect(result.scan.metadataBytesRead).toBe(32 * 1024); + }); + + it.each([ + ['invalid UTF8 in its head', Buffer.from([0x7b, 0xff, 0xfe, 0x7d, 0x0a])], + [ + 'no session_meta within 128 KiB', + Buffer.from(`${JSON.stringify({ type: 'event_msg', payload: {} })}\n`.repeat(6000)), + ], + ])('counts one unrelated file with %s as unattributed instead of failing the lookup', async (_label, bytes) => { + await fixture(ROOT, [row('user', 'question'), row('assistant', 'answer')]); + await writeFile(join(dir, 'rollout-broken.jsonl'), bytes); + const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir }); + expect(result.records.map((r) => r.message)).toEqual(['question', 'answer']); + expect(result.scan.unattributedFiles).toBe(1); + expect(result.complete).toBe(false); + // The same file named explicitly is still an error. + await expect(history.readLocalHistory({ file: join(dir, 'rollout-broken.jsonl') })).rejects.toThrow(/UTF8|128 KiB/); + }); + + it('counts an unreadable directory instead of failing the lookup', async () => { + await fixture(ROOT, [row('assistant', 'answer')]); + const locked = join(dir, 'locked'); + await mkdir(locked); + await chmod(locked, 0); + try { + const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir }); + expect(result.records[0].message).toBe('answer'); + expect(result.scan.unreadableDirectories).toBe(process.getuid?.() === 0 ? 0 : 1); + } finally { + await chmod(locked, 0o700); + } + }); + + it('follows a sessions root that is itself a symlink, and still skips links below it', async () => { + const real = join(dir, 'real-sessions'); + await mkdir(real); + await writeRollout(real, 'rollout-root.jsonl', [ + realMeta({ id: ROOT }), + ...userTurn('question'), + ...assistantTurn('answer'), + ]); + const linkedRoot = join(dir, 'sessions-link'); + await symlink(real, linkedRoot); + const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: linkedRoot }); + expect(result.records.map((r) => r.message)).toEqual(['question', 'answer']); + expect(result.complete).toBe(true); + await symlink(join(real, 'rollout-root.jsonl'), join(real, 'rollout-link.jsonl')); + const withLink = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: linkedRoot }); + expect(withLink.scan.skippedSymlinks).toBe(1); + await expect( + history.readLocalHistory({ sessionId: ROOT, sessionsRoot: join(real, 'rollout-root.jsonl') }) + ).rejects.toThrow(/must be a directory/); + }); +}); + +describe('records, filters and output', () => { + it('gives user records no phase and keeps the phase Codex recorded on assistant messages', async () => { + const file = await writeRollout(dir, 'phases.jsonl', [ + realMeta({ id: ROOT }), + ...userTurn('question'), + ...assistantTurn('working', 'commentary'), + ...assistantTurn('done'), + row('assistant', 'legacy answer without a phase'), + ]); + const result = await history.readLocalHistory({ file }); + expect(result.records.map((r) => [r.role, r.phase])).toEqual([ + ['user', null], + ['assistant', 'commentary'], + ['assistant', 'final_answer'], + ['assistant', null], + ]); + }); + + it('drops every injected-context wrapper but never a typed prompt that starts with #', async () => { + const file = await writeRollout(dir, 'injected.jsonl', [ + realMeta({ id: ROOT }), + injected('environment_context'), + injected('recommended_plugins'), + injected('subagent_notification'), + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: '# AGENTS.md instructions for /x' }], + }, + }, + ...userTurn('# a heading the user typed'), + ...assistantTurn('answer'), + ]); + const result = await history.readLocalHistory({ file }); + expect(result.records.map((r) => r.message)).toEqual(['# a heading the user typed', 'answer']); + }); + + it('compares date bounds as UTC calendar dates', async () => { + const file = await writeRollout(dir, 'dates.jsonl', [ + realMeta({ id: ROOT }), + // 23:30 at UTC-2 is 01:30 UTC the next day. + ...userTurn('late evening west of UTC', '2026-10-24T23:30:00.000-02:00'), + ...userTurn('plain UTC', '2026-10-24T12:00:00.000Z'), + ]); + const after = await history.readLocalHistory({ file, after: '2026-10-25' }); + expect(after.records.map((r) => r.message)).toEqual(['late evening west of UTC']); + const on = await history.readLocalHistory({ file, before: '2026-10-24' }); + expect(on.records.map((r) => r.message)).toEqual(['plain UTC']); + }); + + it('reads the default store at $CODEX_HOME/sessions, else ~/.codex/sessions', async () => { + const saved = { CODEX_HOME: process.env.CODEX_HOME, HOME: process.env.HOME }; + try { + process.env.CODEX_HOME = join(dir, 'codex-home'); + await mkdir(join(dir, 'codex-home', 'sessions', '2026', '10', '24'), { recursive: true }); + await writeRollout(join(dir, 'codex-home', 'sessions', '2026', '10', '24'), 'rollout-a.jsonl', [ + realMeta({ id: ROOT }), + ...userTurn('from CODEX_HOME'), + ]); + expect((await history.readLocalHistory({ sessionId: ROOT })).records[0].message).toBe('from CODEX_HOME'); + delete process.env.CODEX_HOME; + process.env.HOME = join(dir, 'home'); + await mkdir(join(dir, 'home', '.codex', 'sessions'), { recursive: true }); + await writeRollout(join(dir, 'home', '.codex', 'sessions'), 'rollout-b.jsonl', [ + realMeta({ id: ROOT }), + ...userTurn('from ~/.codex'), + ]); + const result = await history.readLocalHistory({ sessionId: ROOT }); + expect(result.records[0].message).toBe('from ~/.codex'); + expect(result.sessions[0].path).toBe(join(dir, 'home', '.codex', 'sessions', 'rollout-b.jsonl')); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); +}); + +describe('history CLI flags', () => { + const run = async (args: string[]) => { + let output = ''; + const program = new Command().exitOverride().configureOutput({ + writeOut: (text) => { + output += text; + }, + writeErr: () => {}, + }); + registerHistoryCommands(program); + await program.parseAsync(['history', 'read', ...args], { from: 'user' }); + return output; + }; + + it('prints text with --format text, a header line and one block per record', async () => { + const file = await writeRollout(dir, 'text.jsonl', [ + realMeta({ id: ROOT }), + ...userTurn('question'), + ...assistantTurn('answer'), + ]); + const output = await run(['--file', file, '--format', 'text']); + const blocks = output.trimEnd().split('\n\n'); + expect(blocks[0]).toMatch(/^source=codex-jsonl complete=true scannedBytes=\d+ metadataBytes=\d+$/); + expect(blocks[1]).toBe(`[root=${ROOT} session=${ROOT} 2026-10-24T01:00:00.000Z user]\nquestion`); + expect(blocks[2]).toBe(`[root=${ROOT} session=${ROOT} 2026-10-24T01:00:01.000Z assistant final_answer]\nanswer`); + expect(() => JSON.parse(output)).toThrow(); + }); + + it('refuses --json together with --format text, and accepts either alone', async () => { + const file = await writeRollout(dir, 'flags.jsonl', [realMeta({ id: ROOT }), ...assistantTurn('answer')]); + await expect(run(['--file', file, '--json', '--format', 'text'])).rejects.toThrow( + /--json conflicts with --format text/ + ); + expect(JSON.parse(await run(['--file', file, '--json'])).records[0].message).toBe('answer'); + expect(JSON.parse(await run(['--file', file, '--json', '--format', 'json'])).records[0].message).toBe('answer'); + expect(await run(['--file', file, '--format', 'text'])).toMatch(/^source=codex-jsonl/); + }); + + it('validates --max-bytes before any file is read', async () => { + // A missing store would be the first thing a read touches; an invalid --max-bytes must be refused first. + const missing = join(dir, 'no-such-store'); + for (const value of ['0', String(history.HISTORY_MAX_OUTPUT_BYTES + 1), 'lots', '0x10', '1e3', ' 5']) + await expect(run(['--session-id', ROOT, '--sessions-root', missing, '--max-bytes', value])).rejects.toThrow( + /Invalid --max-bytes/ + ); + await expect(run(['--session-id', ROOT, '--sessions-root', missing, '--max-bytes', '65536'])).rejects.toThrow( + /Sessions root not found/ + ); + }); + + it('reads budget flags as plain decimal integers only', async () => { + await fixture(ROOT, [row('assistant', 'answer')]); + await expect(run(['--session-id', ROOT, '--sessions-root', dir, '--scan-bytes', '0x100000'])).rejects.toThrow( + /Invalid --scan-bytes/ + ); + await expect(run(['--session-id', ROOT, '--sessions-root', dir, '--max-entries', '1e3'])).rejects.toThrow( + /Invalid --max-entries/ + ); + expect( + JSON.parse(await run(['--session-id', ROOT, '--sessions-root', dir, '--scan-bytes', '1048576'])).complete + ).toBe(true); + }); + + it('passes the store caps through and validates them', async () => { + await fixture(ROOT, [row('assistant', 'answer')]); + await fixture(CHILD, [row('assistant', 'child')], ROOT); + await expect(run(['--session-id', ROOT, '--sessions-root', dir, '--max-entries', '1'])).rejects.toThrow( + /--max-entries/ + ); + expect(JSON.parse(await run(['--session-id', ROOT, '--sessions-root', dir, '--max-entries', '2'])).complete).toBe( + true + ); + await expect(run(['--session-id', ROOT, '--sessions-root', dir, '--metadata-bytes', '16'])).rejects.toThrow( + /--metadata-bytes/ + ); + await expect(run(['--session-id', ROOT, '--sessions-root', dir, '--metadata-bytes', 'lots'])).rejects.toThrow( + /Invalid --metadata-bytes/ + ); + await expect(run(['--session-id', ROOT, '--sessions-root', dir, '--max-entries', '0'])).rejects.toThrow( + /Invalid --max-entries/ + ); + }); +}); diff --git a/test/mcp-server.test.ts b/test/mcp-server.test.ts index fc552cf2..a6f47e88 100644 --- a/test/mcp-server.test.ts +++ b/test/mcp-server.test.ts @@ -248,6 +248,17 @@ describe('protocol: both eras', () => { expect(result.isError).toBe(true); expect(result.content[0].text).toContain('CODEMAN_API_URL'); }); + + it('a refused connection with an empty AggregateError still names the error code', async () => { + const refused: ApiRequest = async () => { + throw Object.assign(new AggregateError([new Error('connect ECONNREFUSED ::1:9')], ''), { code: 'ECONNREFUSED' }); + }; + const { out, send } = server(config(), { request: refused, calls: [] }); + await send({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'list_sessions', arguments: {} } }); + const result = (out[0] as { result: { isError: boolean; content: Array<{ text: string }> } }).result; + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Cannot reach Codeman at http://127.0.0.1:9: ECONNREFUSED.'); + }); }); describe('tool definitions', () => { diff --git a/test/mobile-header-buttons-policy.test.ts b/test/mobile-header-buttons-policy.test.ts index d2c247fd..90dbe1bc 100644 --- a/test/mobile-header-buttons-policy.test.ts +++ b/test/mobile-header-buttons-policy.test.ts @@ -38,7 +38,15 @@ const MOBILE_VISIBLE_ALLOWLIST = new Set<string>([]); // that removes a hide rule fails loudly (not silently). The attachments button is // NOT here: it's opt-in (default-hidden everywhere via its own --hidden marker), so // it's excluded from the default-visible enumeration rather than mobile-hidden. -const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log', 'btn-session-manager', 'btn-file-viewer']; +// The edit history button is opt-in the same way, but it IS here: its panel is +// desktop-sized, so it stays off phones even when the setting is on. +const KNOWN_PHONE_HIDDEN = [ + 'btn-settings', + 'btn-lifecycle-log', + 'btn-session-manager', + 'btn-file-viewer', + 'btn-edit-history', +]; function attrOf(openTag: string, name: string): string { const m = openTag.match(new RegExp(`${name}="([^"]*)"`)); diff --git a/test/mobile-prompt-composer.test.ts b/test/mobile-prompt-composer.test.ts index 77576db1..fe5bd548 100644 --- a/test/mobile-prompt-composer.test.ts +++ b/test/mobile-prompt-composer.test.ts @@ -725,6 +725,16 @@ describe('mobile shell Paste', () => { expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '\x1b[200~echo safe\rrm -rf ~/x\x1b[201~'); }); + it('drops a paste marker hidden inside another one', () => { + const { app, input, send } = openShellPaste(); + // One pass over this leaves `ESC[201~` between `ok` and the command. + input.value = 'ok\x1b[20\x1b[201~1~\nrm -rf ~/x'; + + send.click(); + + expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '\x1b[200~ok\rrm -rf ~/x\x1b[201~'); + }); + it('refuses a paste longer than one input frame and keeps the text for trimming', () => { const { app, bar, document, input, send } = openShellPaste(); input.value = 'x'.repeat(bar._composerMaxLength + 1); diff --git a/test/mobile-tab-tap-zones.test.ts b/test/mobile-tab-tap-zones.test.ts index 8ac630bd..ecdf38d5 100644 --- a/test/mobile-tab-tap-zones.test.ts +++ b/test/mobile-tab-tap-zones.test.ts @@ -162,6 +162,17 @@ describe('phone tab tap zones', () => { expect(phoneDeclarations('.session-tab .tab-gear').display).toBe('none'); }); + it('with the opt-in "+" switched on, the wider reserve clears all three icons', () => { + // showTabNewButton adds a third icon to the active phone tab (html.tabs-show-new) + // and a larger reserve to go with it; the same arithmetic must still hold. + const plus = phoneDeclarations('html.tabs-show-new .session-tab.active .tab-new'); + const wide = len(phoneDeclarations('html.tabs-show-new .session-tab.active .tab-name')['min-width']); + expect(len(plus.width)).toBeGreaterThanOrEqual(28); + expect(wide).toBeGreaterThanOrEqual(requiredReserve() + len(plus.width) + ROUNDING_ALLOWANCE); + expect(wide).toBeLessThanOrEqual(len(nameShared['max-width'])); + expect(phoneDeclarations('.session-tab .tab-new').display).toBe('none'); + }); + it('the pop-out icon stays out of the cluster on handhelds', () => { // `icons` above counts gear + close only. If detach ever became visible on a // phone the reserve would be ~30px short and the centre would walk back. diff --git a/test/mobile/keyboard.test.ts b/test/mobile/keyboard.test.ts index a6ddb765..1dfd4c97 100644 --- a/test/mobile/keyboard.test.ts +++ b/test/mobile/keyboard.test.ts @@ -323,6 +323,99 @@ describe('Virtual Keyboard', () => { expect(mainPadding).toBe(''); }); + it('holds the PTY rows under the keyboard and keeps the Claude composer inside the visible box', async () => { + // #672: a keyboard-driven height change makes Claude's inline renderer reprint + // only the tail that fits and lose the rest, so the rows are held instead, and + // --kb-clip has to keep the composer in view of a screen now taller than its box. + const modes = await page.evaluate(() => (window as any).__codemanKeyboardHoldModes); + expect(modes).toEqual(['claude']); + const rowsBefore = await page.evaluate(async () => { + (window as any).__resizes = []; + app.activeSessionId = 'mobile-keyboard-hold-test'; + app.sessions.set('mobile-keyboard-hold-test', { + id: 'mobile-keyboard-hold-test', + mode: 'claude', + status: 'running', + }); + app.hideWelcome(); + // Fit the terminal to its box first, as a selected session is: these are + // the keyboard-free rows the hold must keep. + app.syncTerminalGeometry(); + app.sendResize = (id: string) => { + (window as any).__resizes.push({ id, ...app.getTerminalDimensions() }); + return Promise.resolve(true); + }; + app.terminal.reset(); + const cols = app.terminal.cols; + const rule = '\u2500'.repeat(cols); + const transcript = Array.from({ length: 100 }, (_, i) => `KBTEST-${String(i + 1).padStart(3, '0')}`); + const frame = [...transcript, rule, '\u276f ', rule, ' ? for shortcuts'].join('\r\n'); + await new Promise<void>((resolve) => app.terminal.write(`${frame}\x1b[2A\x1b[3G`, resolve)); + return app.terminal.rows; + }); + + await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT); + await page.waitForTimeout(800); + + const held = await page.evaluate(() => { + const term = app.terminal; + const buffer = term.buffer.active; + const rowOf = (pattern: RegExp) => { + for (let row = 0; row < term.rows; row++) { + if (pattern.test(buffer.getLine(buffer.baseY + row)?.translateToString(true) ?? '')) return row; + } + return -1; + }; + const container = document.getElementById('terminalContainer') as HTMLElement; + const box = container.getBoundingClientRect(); + const screen = (container.querySelector('.xterm-screen') as HTMLElement).getBoundingClientRect(); + const cell = term._core._renderService.dimensions.css.cell.height; + const composer = rowOf(/^\u276f/); + const footer = rowOf(/\? for shortcuts/); + return { + rows: term.rows, + heldRows: app._keyboardHeldRows, + clip: container.style.getPropertyValue('--kb-clip'), + composer, + footer, + // Where the rows land on the page with the clip applied. + composerTop: screen.top + composer * cell, + footerBottom: screen.top + (footer + 1) * cell, + // Where they would land without it: below the box, i.e. under the keyboard. + footerBottomUnclipped: box.top + (footer + 1) * cell, + boxTop: box.top, + boxBottom: box.bottom, + viewportBottom: + (window.visualViewport?.offsetTop ?? 0) + (window.visualViewport?.height ?? window.innerHeight), + resizes: (window as any).__resizes, + }; + }); + + expect(held.rows).toBe(rowsBefore); + expect(held.heldRows).toBe(rowsBefore); + // Every resize sent while the keyboard was up carried the held rows. + for (const r of held.resizes) expect(r.rows).toBe(rowsBefore); + expect([held.composer, held.footer]).toEqual([rowsBefore - 3, rowsBefore - 1]); + // Not vacuous: unclipped, the footer would sit below the box. + expect(held.footerBottomUnclipped).toBeGreaterThan(held.boxBottom + 1); + expect(parseFloat(held.clip)).toBeGreaterThan(0); + expect(held.composerTop).toBeGreaterThanOrEqual(held.boxTop - 1); + expect(held.footerBottom).toBeLessThanOrEqual(held.boxBottom + 1); + expect(held.boxBottom).toBeLessThanOrEqual(held.viewportBottom + 1); + + await hideKeyboard(page); + await page.waitForTimeout(800); + // Released: no hold, no offset, and the rows follow the box again (the box + // after a keyboard cycle need not be the one before it, so compare with the fit). + const released = await page.evaluate(() => ({ + heldRows: app._keyboardHeldRows, + clip: (document.getElementById('terminalContainer') as HTMLElement).style.getPropertyValue('--kb-clip'), + rows: app.terminal.rows, + fits: window.CodemanTerminalGeometry.clampTerminalDimensions(app.fitAddon.proposeDimensions()).rows, + })); + expect(released).toEqual({ heldRows: 0, clip: '', rows: released.fits, fits: released.fits }); + }); + 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(), diff --git a/test/mocks/mock-route-context.ts b/test/mocks/mock-route-context.ts index f74dddf2..c1c336f6 100644 --- a/test/mocks/mock-route-context.ts +++ b/test/mocks/mock-route-context.ts @@ -64,6 +64,7 @@ export function createMockRouteContext(options?: { forgetCaseDefaultGroup: vi.fn(async () => {}), }, cleanupSession: vi.fn(async () => {}), + forgetEditHistory: vi.fn(), setupSessionListeners: vi.fn(async () => {}), persistSessionState: vi.fn(), persistSessionStateNow: vi.fn(), @@ -169,6 +170,7 @@ export function createMockRouteContext(options?: { scheduledRuns: new Map(), teamWatcher: { getTeams: vi.fn(() => []), getTeamTasks: vi.fn(() => []), hasActiveTeammates: vi.fn(() => false) }, tunnelManager: null, + allowUnauthenticatedTunnel: false, pushStore: null, startScheduledRun: vi.fn(), stopScheduledRun: vi.fn(), diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts index 665cffbe..cd04cebf 100644 --- a/test/mocks/mock-session.ts +++ b/test/mocks/mock-session.ts @@ -11,6 +11,7 @@ import type { SessionStatus, SessionRemote, } from '../../src/types.js'; +import type { GitFetchStatus } from '../../src/git-fetch.js'; /** * Enhanced mock session for testing RespawnController. @@ -69,6 +70,22 @@ export class MockSession extends EventEmitter { this.keepWarm = status; return changed; } + /** + * Mirrors the Git fetch interval surface on Session (src/git-fetch.ts): the case's own override, `undefined` + * for either half meaning the app-wide setting applies, and what the scheduler last reported. + */ + gitFetchEnabled: boolean | undefined = undefined; + gitFetchIntervalSeconds: number | undefined = undefined; + setGitFetchSettings(enabled: boolean | undefined, intervalSeconds: number | undefined): void { + this.gitFetchEnabled = enabled; + this.gitFetchIntervalSeconds = intervalSeconds; + } + gitFetch: GitFetchStatus | null = null; + setGitFetchStatus(status: GitFetchStatus): boolean { + const changed = JSON.stringify(status) !== JSON.stringify(this.gitFetch); + this.gitFetch = status; + return changed; + } private _activeChildProcesses: { pid: number; command: string }[] = []; ralphTracker: null = null; writeBuffer: string[] = []; @@ -81,6 +98,13 @@ export class MockSession extends EventEmitter { claudeSessionIdIsFirstHand: boolean = false; /** Mirrors Session.claudeSessionChain — oldest first, current last. */ claudeSessionChain: string[] = []; + /** The per-session CLAUDE_CONFIG_DIR override that Session.claudeConfigDir() reads. */ + claudeConfigDirOverride: string | undefined = undefined; + + /** Mirrors Session.claudeConfigDir: the override, trimmed; undefined when unset. */ + claudeConfigDir(): string | undefined { + return this.claudeConfigDirOverride?.trim() || undefined; + } /** Mirrors Session.adoptClaudeSessionId, including the first-hand chain rule. */ adoptClaudeSessionId(newId: string, options: { firstHand?: boolean } = {}): void { @@ -107,6 +131,26 @@ export class MockSession extends EventEmitter { /** Mirrors Session.trackUserInput (the send-key route feeds it around the write path). */ trackUserInput(_data: string): void {} + async performAuxiliaryInput(operation: () => Promise<void>): Promise<void> { + await operation(); + } + + /** Mirrors Session.guardedInputHeld; a route test sets it to stand in for a guarded submit's lease. */ + guardedInputHeld = false; + /** Input held behind that lease, in arrival order (see releaseGuardedLease). */ + heldGuardedInput: Array<() => void | Promise<unknown>> = []; + /** Mirrors Session.holdForGuardedLease, without the cap (a test replaces it to model a full queue). */ + holdForGuardedLease(run: () => void | Promise<unknown>, _bytes = 0, _data?: string): 'free' | 'held' | 'full' { + if (!this.guardedInputHeld) return 'free'; + this.heldGuardedInput.push(run); + return 'held'; + } + /** Ends the stand-in lease and runs the held input in arrival order, as Session does. */ + releaseGuardedLease(): void { + this.guardedInputHeld = false; + for (const run of this.heldGuardedInput.splice(0)) void run(); + } + private _muxName: string | null = null; constructor(id: string = 'mock-session-id') { @@ -130,9 +174,14 @@ export class MockSession extends EventEmitter { /** Write via mux (used by respawn controller) */ async writeViaMux(data: string): Promise<boolean> { - if (this.failWrites) return false; - this.writeBuffer.push(data); - return true; + const write = (): boolean => { + if (this.failWrites) return false; + this.writeBuffer.push(data); + return true; + }; + // Like Session.writeViaMux, it waits its turn behind a stand-in guarded lease. + if (!this.guardedInputHeld) return write(); + return new Promise<boolean>((resolve) => this.heldGuardedInput.push(() => resolve(write()))); } /** @@ -412,6 +461,9 @@ export class MockSession extends EventEmitter { } ); restartCli = vi.fn(async () => true); + /** Mirrors Session.envOverrideValue; no override map here, so the custom-model + * real-home lookup defaults to "session says nothing" (overridable per test). */ + envOverrideValue = vi.fn((_key: string): string | undefined => undefined); getCustomModelForPersist = vi.fn(() => this._mockCustomModel); /** Mirrors Session.noteReportedModel: the statusline route records the CLI's model. */ reportedModel: { model: string; source: 'statusline' | 'screen' } | null = null; diff --git a/test/pane-cwd-tracking.test.ts b/test/pane-cwd-tracking.test.ts new file mode 100644 index 00000000..51c32794 --- /dev/null +++ b/test/pane-cwd-tracking.test.ts @@ -0,0 +1,617 @@ +/** + * @fileoverview A relative path printed in the terminal resolves against the + * directory the pane is actually in, not only the session's `workingDir`. + * + * `workingDir` is recorded once at create time and never follows a `cd`, so in + * a shell session that moved to a subdirectory or a sibling repo every relative + * path resolved against the wrong root, and the preview reported "File not + * found" for a file sitting right there. + * + * Pinned here: + * 1. Parsing: the pane-exit watcher's batched `list-panes -a` read already + * carries `#{pane_current_path}`; `derivePaneCurrentPaths()` turns it into + * one directory per session (single-pane sessions only, absolute paths + * only). + * 2. The manager keeps the last authoritative answer, so a failed read or a + * blank field never blanks a known directory, while a read that lists the + * session as split, or not at all, clears it. + * 3. Scoping: remote and Docker sessions never carry one (their local pane + * runs the ssh / docker-exec CLIENT), and neither does a direct-PTY or a + * rediscovered session. + * 4. Wiring: the reading reaches `toState()` through the existing + * `paneExitsUpdated` event, with no new tmux call and no new SSE event. + * 5. Resolution order in the browser: live cwd, then `workingDir`. The + * resolved path still goes through the same preview routing, so a target + * outside the workspace takes the attachment route like any other. + * 6. Spelling: tmux reports the RESOLVED directory, so a workspace reached + * through a symlink has its reading written back in `workingDir`'s + * spelling, or every relative link would route as out-of-workspace. + * + * Port: ephemeral + */ +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import vm from 'node:vm'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Session, rebasePath } from '../src/session.js'; +import { WebServer } from '../src/web/server.js'; +import type { SessionDocker, SessionRemote } from '../src/types.js'; +import type { MuxSession, TerminalMultiplexer } from '../src/mux-interface.js'; +import { TmuxManager, derivePaneCurrentPaths, parsePaneRows, type PaneRow } from '../src/tmux-manager.js'; + +const remote: SessionRemote = { hostId: 'h1', label: 'box', host: 'box', user: 'dev' } as SessionRemote; +const docker: SessionDocker = { hostId: 'd1', label: 'ctr', containerName: 'ctr' } as SessionDocker; + +describe('derivePaneCurrentPaths', () => { + it("maps each single-pane session to its pane's directory", () => { + const rows = parsePaneRows(['codeman-a|100|0|||/work/proj/sub', 'codeman-b|200|0|||/srv/app'].join('\n')); + const paths = derivePaneCurrentPaths(rows); + expect(paths.get('codeman-a')).toBe('/work/proj/sub'); + expect(paths.get('codeman-b')).toBe('/srv/app'); + expect(paths.size).toBe(2); + }); + + it('keeps a separator that belongs to the directory name', () => { + const paths = derivePaneCurrentPaths(parsePaneRows('codeman-a|100|0|||/home/u/weird|dir/sub')); + expect(paths.get('codeman-a')).toBe('/home/u/weird|dir/sub'); + }); + + it('answers nothing for a session split into several panes', () => { + // Codeman creates one pane per session; which of a hand-split session's + // panes "the" directory belongs to is a guess, so none is made. + const rows = parsePaneRows(['codeman-a|100|0|||/one', 'codeman-a|101|0|||/two'].join('\n')); + expect(derivePaneCurrentPaths(rows).has('codeman-a')).toBe(false); + }); + + it('drops a missing or relative directory', () => { + const rows = parsePaneRows(['codeman-a|100|0|||', 'codeman-b|200|0|||rel/ative'].join('\n')); + expect(derivePaneCurrentPaths(rows).size).toBe(0); + }); + + it('carries the previous directory forward for a blank field on a single-pane session', () => { + // A blank `#{pane_current_path}` is a tmux that could not read the process + // cwd that tick, not a pane that has no directory. + const previous = new Map([['codeman-a', '/work/proj/sub']]); + const paths = derivePaneCurrentPaths(parsePaneRows('codeman-a|100|0|||'), previous); + expect(paths.get('codeman-a')).toBe('/work/proj/sub'); + }); + + it('carries nothing forward for a split session, an unlisted one or a relative field', () => { + // Those are authoritative absences: the read answered, and its answer is + // "no single directory". + const previous = new Map([ + ['codeman-split', '/one'], + ['codeman-gone', '/two'], + ['codeman-rel', '/three'], + ]); + const rows = parsePaneRows( + ['codeman-split|100|0|||', 'codeman-split|101|0|||', 'codeman-rel|300|0|||rel/ative'].join('\n') + ); + expect(derivePaneCurrentPaths(rows, previous).size).toBe(0); + }); +}); + +describe('TmuxManager pane directory bookkeeping', () => { + class TestManager extends TmuxManager { + rows: PaneRow[] = []; + protected override async readPaneRows(): Promise<PaneRow[]> { + return this.rows; + } + } + + const localSession = (sessionId: string): MuxSession => + ({ + sessionId, + muxName: `codeman-${sessionId}`, + pid: 100, + createdAt: 0, + workingDir: '/work/proj', + mode: 'shell', + attached: true, + }) as MuxSession; + + it('records the directory from the same read the pane-exit watcher makes', async () => { + const manager = new TestManager(); + manager.registerSession(localSession('s1')); + manager.rows = parsePaneRows('codeman-s1|100|0|||/work/proj/sub'); + await manager.refreshPaneExits(); + expect(manager.getPaneCurrentPath('codeman-s1')).toBe('/work/proj/sub'); + }); + + it('keeps the last known directory when a read comes back empty', async () => { + const manager = new TestManager(); + manager.registerSession(localSession('s1')); + manager.rows = parsePaneRows('codeman-s1|100|0|||/work/proj/sub'); + await manager.refreshPaneExits(); + manager.rows = []; + await manager.refreshPaneExits(); + expect(manager.getPaneCurrentPath('codeman-s1')).toBe('/work/proj/sub'); + }); + + it('keeps the last directory across a blank field on a single-pane read', async () => { + const manager = new TestManager(); + manager.registerSession(localSession('s1')); + manager.rows = parsePaneRows('codeman-s1|100|0|||/work/proj/sub'); + await manager.refreshPaneExits(); + manager.rows = parsePaneRows('codeman-s1|100|0|||'); + await manager.refreshPaneExits(); + expect(manager.getPaneCurrentPath('codeman-s1')).toBe('/work/proj/sub'); + }); + + it('forgets the directory once the session is split into several panes', async () => { + const manager = new TestManager(); + manager.registerSession(localSession('s1')); + manager.rows = parsePaneRows('codeman-s1|100|0|||/work/proj/sub'); + await manager.refreshPaneExits(); + manager.rows = parsePaneRows(['codeman-s1|100|0|||/work/proj/sub', 'codeman-s1|101|0|||/work/proj'].join('\n')); + await manager.refreshPaneExits(); + expect(manager.getPaneCurrentPath('codeman-s1')).toBeUndefined(); + }); + + it('records the directory even when a respawn races the read', async () => { + // A respawn bumps the generation and discards the read's EXIT observation; + // the directory the pane was in at read time is still the best answer. + class RacingManager extends TestManager { + protected override async readPaneRows(): Promise<PaneRow[]> { + this.clearPaneExit('codeman-s1'); + return this.rows; + } + } + const manager = new RacingManager(); + manager.registerSession(localSession('s1')); + manager.rows = parsePaneRows('codeman-s1|100|1|0||/work/proj/sub'); + await manager.refreshPaneExits(); + expect(manager.getPaneCurrentPath('codeman-s1')).toBe('/work/proj/sub'); + expect(manager.getPaneExit('codeman-s1')).toBeUndefined(); + }); + + it('makes no read, and keeps no directory, when only remote or docker sessions exist', async () => { + const manager = new TestManager(); + const read = vi.spyOn(manager as unknown as { readPaneRows: () => Promise<PaneRow[]> }, 'readPaneRows'); + manager.registerSession({ ...localSession('s1'), remote } as MuxSession); + manager.registerSession({ ...localSession('s2'), docker } as MuxSession); + manager.rows = parsePaneRows('codeman-s1|100|0|||/tmp'); + await manager.refreshPaneExits(); + expect(read).not.toHaveBeenCalled(); + expect(manager.getPaneCurrentPath('codeman-s1')).toBeUndefined(); + }); + + it('forgets a session that an authoritative read no longer lists', async () => { + const manager = new TestManager(); + manager.registerSession(localSession('s1')); + manager.rows = parsePaneRows('codeman-s1|100|0|||/work/proj/sub'); + await manager.refreshPaneExits(); + manager.rows = parsePaneRows('codeman-other|300|0|||/elsewhere'); + await manager.refreshPaneExits(); + expect(manager.getPaneCurrentPath('codeman-s1')).toBeUndefined(); + }); +}); + +const stubMux = () => ({ isAvailable: () => true }) as unknown as TerminalMultiplexer; +const stubMuxSession = (muxName = 'codeman-aaaa') => ({ muxName, sessionId: 'aaaa' }) as unknown as MuxSession; + +function localMuxSession(extra: Record<string, unknown> = {}) { + return new Session({ + workingDir: '/work/proj', + mode: 'shell', + useMux: true, + mux: stubMux(), + muxSession: stubMuxSession(), + ...extra, + }); +} + +describe('Session.setPaneCwd scoping', () => { + it('records an absolute directory for a local pane and reports the change once', () => { + const session = localMuxSession(); + expect(session.setPaneCwd('/work/proj/sub')).toBe(true); + expect(session.paneCwd).toBe('/work/proj/sub'); + expect(session.setPaneCwd('/work/proj/sub')).toBe(false); + expect(session.toState().paneCwd).toBe('/work/proj/sub'); + }); + + it('clears the directory when the reading goes away', () => { + const session = localMuxSession(); + session.setPaneCwd('/work/proj/sub'); + expect(session.setPaneCwd(undefined)).toBe(true); + expect(session.paneCwd).toBeUndefined(); + }); + + it('refuses a relative directory', () => { + const session = localMuxSession(); + expect(session.setPaneCwd('rel/ative')).toBe(false); + expect(session.paneCwd).toBeUndefined(); + }); + + it('refuses a directory for a remote session, whose local pane runs the ssh client', () => { + const session = localMuxSession({ remote }); + expect(session.setPaneCwd('/home/dev')).toBe(false); + expect(session.toState().paneCwd).toBeUndefined(); + }); + + it('refuses a directory for a docker case, whose local pane runs docker exec', () => { + const session = localMuxSession({ docker }); + expect(session.setPaneCwd('/work/proj')).toBe(false); + expect(session.toState().paneCwd).toBeUndefined(); + }); + + it('refuses a directory for a direct-PTY session and a rediscovered one', () => { + const direct = new Session({ workingDir: '/work/proj', mode: 'shell', useMux: false }); + expect(direct.setPaneCwd('/work/proj/sub')).toBe(false); + const discovered = localMuxSession({ discoveredMuxSession: true }); + expect(discovered.setPaneCwd('/work/proj/sub')).toBe(false); + }); +}); + +describe('a pane read reaching the session record', () => { + let server: WebServer | null = null; + + afterEach(async () => { + await server?.stop?.(); + server = null; + }); + + const build = () => { + const web = new WebServer(0, false, true); + server = web; + const mux = (web as unknown as { mux: Record<string, unknown> & { emit: (e: string) => void } }).mux; + const sessions = (web as unknown as { sessions: Map<string, Session> }).sessions; + return { mux, sessions, web }; + }; + + it("publishes the pane's directory on the existing paneExitsUpdated tick", () => { + const { mux, sessions, web } = build(); + const session = localMuxSession(); + sessions.set(session.id, session); + mux.getPaneExit = () => undefined; + mux.getPaneCurrentPath = (muxName: string) => (muxName === 'codeman-aaaa' ? '/work/proj/sub' : undefined); + mux.emit('paneExitsUpdated'); + + expect(session.toState().paneCwd).toBe('/work/proj/sub'); + // What the browser actually receives: a future explicit field list must not drop it. + const state = ( + web as unknown as { getSessionStateWithRespawn: (s: Session) => { paneCwd?: string } } + ).getSessionStateWithRespawn(session); + expect(state.paneCwd).toBe('/work/proj/sub'); + // A resolution base only: the confinement root is untouched. + expect(session.toState().workingDir).toBe('/work/proj'); + }); + + it('broadcasts a directory change once, and nothing on an unchanged tick', () => { + const { mux, sessions } = build(); + const session = localMuxSession(); + sessions.set(session.id, session); + const broadcast = vi.fn(); + (server as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced = + broadcast; + const persist = vi.fn(); + (server as unknown as { persistSessionState: (s: Session) => void }).persistSessionState = persist; + let dir = '/work/proj/sub'; + mux.getPaneExit = () => undefined; + mux.getPaneCurrentPath = () => dir; + + mux.emit('paneExitsUpdated'); + expect(broadcast).toHaveBeenCalledTimes(1); + expect(broadcast).toHaveBeenCalledWith(session.id); + mux.emit('paneExitsUpdated'); + expect(broadcast).toHaveBeenCalledTimes(1); + dir = '/work/proj/other'; + mux.emit('paneExitsUpdated'); + expect(broadcast).toHaveBeenCalledTimes(2); + expect(session.toState().paneCwd).toBe('/work/proj/other'); + // Runtime-only: a directory change alone is never written to state.json. + expect(persist).not.toHaveBeenCalled(); + }); + + /** + * Drive the server's REAL TmuxManager: its parsing, its derivation and its + * `getPaneCurrentPath()`. Only the tmux exec seam (`readPaneRows`) is faked, + * with raw `list-panes -a` output, as the bookkeeping tests above do. + */ + const buildWithRealManager = () => { + const { sessions } = build(); + const mux = (server as unknown as { mux: TmuxManager }).mux; + expect(mux).toBeInstanceOf(TmuxManager); + let output = ''; + (mux as unknown as { readPaneRows: () => Promise<PaneRow[]> }).readPaneRows = async () => parsePaneRows(output); + mux.registerSession({ + sessionId: 'aaaa', + muxName: 'codeman-aaaa', + pid: 100, + createdAt: 0, + workingDir: '/work/proj', + mode: 'shell', + attached: true, + } as MuxSession); + const session = localMuxSession(); + sessions.set(session.id, session); + const broadcast = vi.fn(); + (server as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced = + broadcast; + const tick = async (listPanes: string) => { + output = listPanes; + await mux.refreshPaneExits(); + mux.emit('paneExitsUpdated'); + }; + return { session, broadcast, tick }; + }; + + it('clears the directory once the session is split into several panes', async () => { + const { session, broadcast, tick } = buildWithRealManager(); + await tick('codeman-aaaa|100|0|||/work/proj/sub'); + expect(session.toState().paneCwd).toBe('/work/proj/sub'); + expect(broadcast).toHaveBeenCalledTimes(1); + + await tick(['codeman-aaaa|100|0|||/work/proj/sub', 'codeman-aaaa|101|0|||/work/proj'].join('\n')); + // An absent cwd is what makes the browser fall back to workingDir. + expect(session.toState().paneCwd).toBeUndefined(); + expect(broadcast).toHaveBeenCalledTimes(2); + + // The clear is announced once, not on every later tick that agrees. + await tick(['codeman-aaaa|100|0|||/work/proj/sub', 'codeman-aaaa|101|0|||/work/proj'].join('\n')); + expect(broadcast).toHaveBeenCalledTimes(2); + }); + + it('clears the directory once a read no longer lists the session', async () => { + const { session, broadcast, tick } = buildWithRealManager(); + await tick('codeman-aaaa|100|0|||/work/proj/sub'); + expect(session.toState().paneCwd).toBe('/work/proj/sub'); + + await tick('codeman-other|300|0|||/elsewhere'); + expect(session.toState().paneCwd).toBeUndefined(); + expect(broadcast).toHaveBeenCalledTimes(2); + await tick('codeman-other|300|0|||/elsewhere'); + expect(broadcast).toHaveBeenCalledTimes(2); + }); + + it('keeps the directory across a blank field and across a failed read', async () => { + const { session, broadcast, tick } = buildWithRealManager(); + await tick('codeman-aaaa|100|0|||/work/proj/sub'); + // tmux could not read the process cwd for one tick. + await tick('codeman-aaaa|100|0|||'); + expect(session.toState().paneCwd).toBe('/work/proj/sub'); + // A failed read arrives as no rows at all. + await tick(''); + expect(session.toState().paneCwd).toBe('/work/proj/sub'); + expect(broadcast).toHaveBeenCalledTimes(1); + }); + + it('publishes nothing for a docker case', () => { + const { mux, sessions } = build(); + const session = localMuxSession({ docker }); + sessions.set(session.id, session); + mux.getPaneExit = () => undefined; + mux.getPaneCurrentPath = () => '/work/proj'; + mux.emit('paneExitsUpdated'); + + expect(session.toState().paneCwd).toBeUndefined(); + }); + + it('publishes nothing for a remote session', () => { + const { mux, sessions } = build(); + const session = localMuxSession({ remote }); + sessions.set(session.id, session); + mux.getPaneExit = () => undefined; + mux.getPaneCurrentPath = () => '/home/dev'; + mux.emit('paneExitsUpdated'); + + expect(session.toState().paneCwd).toBeUndefined(); + }); +}); + +describe('browser resolution order: live cwd, then workingDir', () => { + const read = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8'); + + const loadProto = () => { + const context = vm.createContext({ + console, + CodemanApp: class CodemanApp {}, + document: { addEventListener: vi.fn() }, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + }); + vm.runInContext( + `${read('constants.js')}\n${read('terminal-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`, + context + ); + return (context as unknown as { __proto: { _resolveLinkedFilePath: (p: string, id: string) => string } }).__proto; + }; + + const resolveFor = (session: Record<string, unknown>, filePath: string) => { + const proto = loadProto(); + return proto._resolveLinkedFilePath.call({ sessions: new Map([['s1', session]]) }, filePath, 's1'); + }; + + it("resolves against the pane's live directory after a cd into a subdirectory", () => { + expect(resolveFor({ workingDir: '/work/proj', paneCwd: '/work/proj/sub' }, 'note.md')).toBe( + '/work/proj/sub/note.md' + ); + }); + + it('resolves outside the workspace when the pane cd-ed to a sibling repo', () => { + // Routing is unchanged: an absolute target outside the workspace is what + // `_isExternalPreviewPath` already sends through the attachment routes. + expect(resolveFor({ workingDir: '/work/proj', paneCwd: '/work/other' }, 'src/a.js')).toBe('/work/other/src/a.js'); + }); + + it('falls back to workingDir when no live directory is known', () => { + expect(resolveFor({ workingDir: '/work/proj' }, 'note.md')).toBe('/work/proj/note.md'); + }); + + it('ignores a live directory on a remote or docker session', () => { + expect(resolveFor({ workingDir: '/work/proj', paneCwd: '/tmp/x', remote: {} }, 'note.md')).toBe( + '/work/proj/note.md' + ); + expect(resolveFor({ workingDir: '/work/proj', paneCwd: '/tmp/x', docker: {} }, 'note.md')).toBe( + '/work/proj/note.md' + ); + }); + + it('ignores a relative live directory', () => { + expect(resolveFor({ workingDir: '/work/proj', paneCwd: 'sub' }, 'note.md')).toBe('/work/proj/note.md'); + }); + + it('leaves absolute and home paths alone', () => { + const session = { workingDir: '/work/proj', paneCwd: '/work/proj/sub' }; + expect(resolveFor(session, '/etc/hosts.md')).toBe('/etc/hosts.md'); + expect(resolveFor(session, '~/x.md')).toBe('~/x.md'); + }); +}); + +describe('confinement is unchanged by the live cwd', () => { + // The live cwd only picks the base. Whether the preview reads a path through + // the workspace-confined file routes or registers it through the attachment + // routes is still decided against `workingDir` by `_isExternalPreviewPath`. + const read = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8'); + + const loadApp = (session: Record<string, unknown>) => { + const context = vm.createContext({ + console, + CodemanApp: class CodemanApp {}, + document: { addEventListener: vi.fn(), getElementById: () => null }, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} }, + }); + vm.runInContext( + `${read('constants.js')}\n${read('terminal-ui.js')}\n${read('panels-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`, + context + ); + const proto = ( + context as unknown as { + __proto: { + _resolveLinkedFilePath: (p: string, id: string) => string; + _isExternalPreviewPath: (p: string, id: string) => boolean; + }; + } + ).__proto; + const app = { sessions: new Map([['s1', session]]) }; + return { + target: (p: string) => proto._resolveLinkedFilePath.call(app, p, 's1'), + external: (p: string) => proto._isExternalPreviewPath.call(app, p, 's1'), + }; + }; + + it('keeps a target inside the workspace on the workspace route', () => { + const app = loadApp({ workingDir: '/work/proj', paneCwd: '/work/proj/sub' }); + expect(app.external(app.target('note.md'))).toBe(false); + }); + + it('sends a target outside the workspace through the attachment route', () => { + const app = loadApp({ workingDir: '/work/proj', paneCwd: '/work/other' }); + expect(app.target('src/a.js')).toBe('/work/other/src/a.js'); + expect(app.external(app.target('src/a.js'))).toBe(true); + }); +}); + +describe('a workspace reached through a symlink', () => { + // tmux reads `/proc/<pgrp>/cwd`, always the resolved path, while `workingDir` + // keeps the spelling the case was created with. Without the rewrite, the + // browser's lexical routing check would call every relative link external. + let root: string; + let physical: string; + let link: string; + + const read = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8'); + const routingFor = (session: Record<string, unknown>) => { + const context = vm.createContext({ + console, + CodemanApp: class CodemanApp {}, + document: { addEventListener: vi.fn(), getElementById: () => null }, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} }, + }); + vm.runInContext( + `${read('constants.js')}\n${read('terminal-ui.js')}\n${read('panels-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`, + context + ); + const proto = ( + context as unknown as { + __proto: { + _resolveLinkedFilePath: (p: string, id: string) => string; + _isExternalPreviewPath: (p: string, id: string) => boolean; + }; + } + ).__proto; + const app = { sessions: new Map([['s1', session]]) }; + return (p: string) => proto._isExternalPreviewPath.call(app, proto._resolveLinkedFilePath.call(app, p, 's1'), 's1'); + }; + + /** Feed a reading until the background resolution has settled and it is taken. */ + const settle = async (session: Session, reading: string) => { + await vi.waitFor(() => { + session.setPaneCwd(reading); + expect(session.paneCwd).toBeDefined(); + }); + }; + + afterEach(() => rmSync(root, { recursive: true, force: true })); + + const setup = () => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'pane-cwd-link-'))); + physical = join(root, 'data', 'app'); + mkdirSync(join(physical, 'src'), { recursive: true }); + mkdirSync(join(root, 'other'), { recursive: true }); + link = join(root, 'cases-app'); + symlinkSync(physical, link); + }; + + it('writes a reading of the resolved workspace back in workingDir spelling', async () => { + setup(); + const session = localMuxSession({ workingDir: link }); + await settle(session, physical); + expect(session.paneCwd).toBe(link); + expect(session.setPaneCwd(join(physical, 'src'))).toBe(true); + expect(session.paneCwd).toBe(join(link, 'src')); + }); + + it('keeps an in-workspace relative link on the workspace route from the first reading', async () => { + setup(); + const session = localMuxSession({ workingDir: link }); + await settle(session, physical); + const external = routingFor(session.toState() as unknown as Record<string, unknown>); + expect(external('src/index.ts')).toBe(false); + }); + + it('still routes a cd outside the workspace through the attachment route', async () => { + setup(); + const session = localMuxSession({ workingDir: link }); + await settle(session, join(root, 'other')); + expect(session.paneCwd).toBe(join(root, 'other')); + const external = routingFor(session.toState() as unknown as Record<string, unknown>); + expect(external('notes.md')).toBe(true); + }); + + it('withholds an out-of-workspace reading until the resolution settles', () => { + setup(); + const session = localMuxSession({ workingDir: link }); + // The first call starts the resolution; a physical reading cannot be told + // apart from a real cd yet, so it is not stored this tick. + expect(session.setPaneCwd(physical)).toBe(false); + expect(session.paneCwd).toBeUndefined(); + // A reading lexically inside workingDir needs no translation and is taken. + expect(session.setPaneCwd(join(link, 'src'))).toBe(true); + }); + + it('leaves the reading alone when workingDir has no symlink in it', async () => { + setup(); + const session = localMuxSession({ workingDir: physical }); + await settle(session, join(physical, 'src')); + expect(session.paneCwd).toBe(join(physical, 'src')); + }); + + it('takes readings untranslated once a missing workingDir has settled', async () => { + setup(); + const session = localMuxSession({ workingDir: join(root, 'gone') }); + await settle(session, join(root, 'other')); + expect(session.paneCwd).toBe(join(root, 'other')); + }); +}); + +describe('rebasePath', () => { + it('rewrites the prefix itself and paths under it, and nothing else', () => { + expect(rebasePath('/mnt/data/app', '/mnt/data/app', '/home/u/app')).toBe('/home/u/app'); + expect(rebasePath('/mnt/data/app/src', '/mnt/data/app', '/home/u/app')).toBe('/home/u/app/src'); + expect(rebasePath('/mnt/data/application', '/mnt/data/app', '/home/u/app')).toBe('/mnt/data/application'); + expect(rebasePath('/mnt/other', '/mnt/data/app', '/home/u/app')).toBe('/mnt/other'); + }); +}); diff --git a/test/path-picker-new-folder.test.ts b/test/path-picker-new-folder.test.ts new file mode 100644 index 00000000..6c3cc625 --- /dev/null +++ b/test/path-picker-new-folder.test.ts @@ -0,0 +1,179 @@ +/** + * @fileoverview PathPicker "New folder" (opt-in via `allowNewFolder`). Same jsdom harness as + * path-picker-hidden.test.ts (see its header for why the environment is built by hand). + * + * What can silently go wrong: the button showing for a picker that never asked for it (the + * container-workdir picker cannot create on the host), the folder made in the wrong place, the new + * folder not selected afterwards, and Escape closing the whole picker instead of just the form. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { JSDOM } from 'jsdom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const accessoryJs = readFileSync(resolve(PUBLIC, 'keyboard-accessory.js'), 'utf8'); +const sessionUiJs = readFileSync(resolve(PUBLIC, 'session-ui.js'), 'utf8'); + +const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://localhost/' }); +const jsdomWindow = dom.window as unknown as Window & typeof globalThis; +const doc = jsdomWindow.document; + +type Call = { url: string; init?: { method?: string; body?: string } }; + +function loadPathPicker(fetchImpl: (url: string, init?: Call['init']) => Promise<unknown>): any { + const factory = new Function( + 'window', + 'document', + 'localStorage', + 'fetch', + 'MobileDetection', + `${accessoryJs}\nreturn PathPicker;` + ); + return factory(jsdomWindow, doc, jsdomWindow.localStorage, fetchImpl, { isTouchDevice: () => false }); +} + +const listing = (path: string, names: string[]) => ({ + json: async () => ({ + success: true, + data: { + path, + parent: null, + root: '/home/dev', + roots: [{ label: 'Home', path: '/home/dev' }], + entries: names.map((name) => ({ name, type: 'directory', path: `${path}/${name}` })), + truncated: false, + }, + }), +}); + +describe('PathPicker New folder', () => { + let PathPicker: any; + let calls: Call[]; + let names: string[]; + let mkdirReply: () => unknown; + + beforeEach(() => { + jsdomWindow.localStorage.clear(); + doc.body.replaceChildren(); + calls = []; + names = ['src']; + mkdirReply = () => { + names.push('fresh'); + return { json: async () => ({ success: true, data: { path: '/home/dev/project/fresh' } }) }; + }; + PathPicker = loadPathPicker(async (url, init) => { + calls.push({ url, init }); + return url.startsWith('/api/filesystem/mkdir') ? mkdirReply() : listing('/home/dev/project', names); + }); + }); + afterEach(() => { + PathPicker?.close?.(false); + doc.body.replaceChildren(); + }); + + const open = async (options: Record<string, unknown> = {}) => { + PathPicker.open({ onSelect: () => {}, directoriesOnly: true, ...options }); + await vi.waitFor(() => expect(doc.querySelectorAll('.path-picker-item').length).toBeGreaterThan(0)); + }; + const button = () => doc.querySelector('.path-picker-newfolder') as HTMLButtonElement; + const form = () => doc.querySelector('.path-picker-newfolder-form') as HTMLFormElement; + const nameInput = () => doc.querySelector('.path-picker-newfolder-name') as HTMLInputElement; + const submit = (name: string) => { + button().click(); + nameInput().value = name; + form().dispatchEvent(new jsdomWindow.Event('submit', { cancelable: true, bubbles: true })); + }; + + it('is hidden unless the caller opts in', async () => { + await open(); + expect(button().hidden).toBe(true); + expect(form().hidden).toBe(true); + }); + + it('shows the button, and opens the name form on click, when allowed', async () => { + await open({ allowNewFolder: true }); + expect(button().hidden).toBe(false); + expect(form().hidden).toBe(true); + button().click(); + expect(form().hidden).toBe(false); + }); + + it('creates inside the folder being viewed, reloads it and selects the new folder', async () => { + await open({ allowNewFolder: true, sessionId: 'abc123' }); + submit('fresh'); + await vi.waitFor(() => + expect(doc.querySelector('.path-picker-selection-value')?.textContent).toBe('/home/dev/project/fresh') + ); + const post = calls.find((c) => c.url === '/api/filesystem/mkdir'); + expect(post?.init?.method).toBe('POST'); + expect(JSON.parse(post?.init?.body ?? '{}')).toEqual({ + path: '/home/dev/project', + name: 'fresh', + sessionId: 'abc123', + }); + expect(Array.from(doc.querySelectorAll('.path-picker-item-name')).map((e) => e.textContent)).toContain('fresh'); + expect(form().hidden).toBe(true); + expect((doc.querySelector('.path-picker-confirm') as HTMLButtonElement).disabled).toBe(false); + }); + + it('shows the server reason and keeps the form open when the folder cannot be made', async () => { + mkdirReply = () => ({ json: async () => ({ success: false, error: '"fresh" already exists in this folder' }) }); + await open({ allowNewFolder: true }); + submit('fresh'); + await vi.waitFor(() => + expect(doc.querySelector('.path-picker-status')?.textContent).toBe('"fresh" already exists in this folder') + ); + expect(doc.querySelector('.path-picker-status')?.classList.contains('error')).toBe(true); + expect(form().hidden).toBe(false); + }); + + it('forwards showHidden while the picker is showing hidden entries', async () => { + jsdomWindow.localStorage.setItem('codeman:pathPickerShowHidden', '1'); + await open({ allowNewFolder: true }); + submit('fresh'); + await vi.waitFor(() => expect(calls.some((c) => c.url === '/api/filesystem/mkdir')).toBe(true)); + const post = calls.find((c) => c.url === '/api/filesystem/mkdir'); + expect(JSON.parse(post?.init?.body ?? '{}').showHidden).toBe('true'); + }); + + it('disables Create while the request runs, so a double tap sends one', async () => { + let finish: (v: unknown) => void = () => {}; + mkdirReply = () => + new Promise((resolve) => { + finish = resolve; + }); + await open({ allowNewFolder: true }); + const create = doc.querySelector('.path-picker-newfolder-create') as HTMLButtonElement; + submit('fresh'); + await vi.waitFor(() => expect(create.disabled).toBe(true)); + finish({ json: async () => ({ success: true, data: { path: '/home/dev/project/fresh' } }) }); + await vi.waitFor(() => expect(create.disabled).toBe(false)); + }); + + it('sends nothing for a blank name', async () => { + await open({ allowNewFolder: true }); + submit(' '); + await Promise.resolve(); + expect(calls.some((c) => c.url === '/api/filesystem/mkdir')).toBe(false); + }); + + it('Escape in the name field closes the form, not the picker', async () => { + await open({ allowNewFolder: true }); + button().click(); + nameInput().dispatchEvent(new jsdomWindow.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(form().hidden).toBe(true); + expect(doc.querySelector('.path-picker-overlay')).not.toBeNull(); + }); + + it('is on for Link Existing and off for the other session-ui pickers', () => { + const block = (name: string) => { + const start = sessionUiJs.indexOf(`${name}() {`); + return sessionUiJs.slice(start, sessionUiJs.indexOf('PathPicker.open', start) + 400); + }; + expect(block('openLinkCasePathPicker')).toContain('allowNewFolder: true'); + for (const other of ['openDockerWorkspacePathPicker', 'openDockerWorkdirPicker', 'openNewCasePathPicker']) { + expect(block(other), other).not.toContain('allowNewFolder'); + } + }); +}); diff --git a/test/phone-press-feedback.browser.test.ts b/test/phone-press-feedback.browser.test.ts new file mode 100644 index 00000000..d9ba9012 --- /dev/null +++ b/test/phone-press-feedback.browser.test.ts @@ -0,0 +1,161 @@ +/** + * @fileoverview Real-browser coverage for the press feedback on the phone toolbar + * and keyboard accessory keys (mobile.css, the `:active` transform + filter rules). + * + * The feedback is transform + filter because a background `:active` on the + * toolbar buttons lost to the skin block on every skin but og. What this pins is + * the cascade itself, on every skin: a later rule that sets transform or filter + * on these buttons at a higher specificity would silently take the press away + * again, and only a browser computing the cascade can see that. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'; + +const publicDir = resolve(import.meta.dirname, '../src/web/public'); +const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8'); +const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8'); +const mobileStyles = readFileSync(resolve(publicDir, 'mobile.css'), 'utf8'); +const accessorySource = readFileSync(resolve(publicDir, 'keyboard-accessory.js'), 'utf8'); + +const DARK_SKINS = ['og', 'daylight-blue', 'daylight-green'] as const; +const LIGHT_SKINS = ['paper-gray', 'solarized-light', 'catppuccin-latte', 'rose-pine-dawn'] as const; + +/** Every phone bar control the rules cover, pressed where a finger would land. */ +const TARGETS = [ + ['Run', '.toolbar .btn-toolbar.btn-run'], + ['Run gear', '.toolbar .btn-toolbar.btn-run-gear'], + ['Stop', '.toolbar .btn-toolbar.btn-stop'], + ['Enter', '.toolbar .btn-toolbar.btn-enter'], + ['Case', '.toolbar .btn-toolbar.btn-case-mobile'], + ['Voice', '.btn-voice-mobile'], + // The pick is a transparent file input laid over this button, so the press + // lands on the input and only the wrap is :active. + ['Attach', '.btn-attach-mobile'], + ['Settings', '.btn-settings-mobile'], + ['Accessory Tab', '.accessory-btn[data-action="tab"]'], + ['Accessory arrow', '.accessory-btn-arrow[data-action="scroll-up"]'], + ['Accessory Compose', '.accessory-btn-compose'], + ['Accessory dismiss', '.accessory-btn-dismiss'], +] as const; + +const PRESSED_SCALE = 'matrix(0.94, 0, 0, 0.94, 0, 0)'; + +/** The press transition, read at rest: the 250ms hold below would also pass a slower one. */ +const PRESS_TRANSITION = { + property: 'transform, filter, background-color, border-color', + duration: '0.08s, 0.08s, 0.08s, 0.08s', +}; + +function toolbarMarkup(): string { + const start = html.indexOf('<footer class="toolbar">'); + const end = html.indexOf('</footer>', start); + if (start < 0 || end < 0) throw new Error('Toolbar markup not found'); + return html.slice(start, end + '</footer>'.length); +} + +function accessoryMarkup(): string { + const match = accessorySource.match(/_simpleButtons: `([\s\S]*?)`,/); + if (!match) throw new Error('Accessory bar template not found'); + return `<div class="keyboard-accessory-bar visible">${match[1]}</div>`; +} + +describe('phone toolbar and accessory press feedback in Chromium', () => { + let browser: Browser; + + beforeAll(async () => { + browser = await chromium.launch({ headless: true }); + }); + + afterAll(async () => browser.close()); + + async function openBars(skin: string, reducedMotion: 'reduce' | 'no-preference'): Promise<[BrowserContext, Page]> { + // hasTouch makes Chromium match (hover: none) and (pointer: coarse), the gate the rules sit behind. + const context = await browser.newContext({ + viewport: { width: 440, height: 956 }, + isMobile: true, + hasTouch: true, + deviceScaleFactor: 1, + reducedMotion, + }); + const page = await context.newPage(); + await page.setContent(`<!doctype html><html data-skin="${skin}"><head> + <meta name="viewport" content="width=device-width, initial-scale=1"><style>${styles}</style> + <style>@media (max-width: 1023px) { ${mobileStyles} }</style></head> + <body>${toolbarMarkup()}${accessoryMarkup()}</body></html>`); + // Without the viewport meta a mobile context lays out at 980px and never reaches the phone layout. + expect( + await page.evaluate(() => ({ + touch: matchMedia('(hover: none) and (pointer: coarse)').matches, + phone: matchMedia('(max-width: 599px)').matches, + })) + ).toEqual({ touch: true, phone: true }); + return [context, page]; + } + + /** Hold a press on the control and read its transform and filter once the transition has settled. */ + async function pressed(page: Page, selector: string) { + const target = page.locator(selector).first(); + const box = await target.boundingBox(); + if (!box) throw new Error(`${selector} is not rendered in the phone layout`); + const read = () => + target.evaluate((el) => ({ transform: getComputedStyle(el).transform, filter: getComputedStyle(el).filter })); + const rest = await read(); + const transition = await target.evaluate((el) => ({ + property: getComputedStyle(el).transitionProperty, + duration: getComputedStyle(el).transitionDuration, + })); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.waitForTimeout(250); + const held = await read(); + // Release off the control so its click handler never runs. + await page.mouse.move(0, 0); + await page.mouse.up(); + await page.waitForTimeout(250); + return { rest, held, transition }; + } + + for (const [skins, brightness] of [ + [DARK_SKINS, 'brightness(1.25)'], + [LIGHT_SKINS, 'brightness(0.9)'], + ] as const) { + for (const skin of skins) { + it(`scales every control and applies ${brightness} on the ${skin} skin`, async () => { + const [context, page] = await openBars(skin, 'no-preference'); + try { + for (const [name, selector] of TARGETS) { + expect({ name, ...(await pressed(page, selector)) }).toEqual({ + name, + rest: { transform: 'none', filter: 'none' }, + held: { transform: PRESSED_SCALE, filter: brightness }, + transition: PRESS_TRANSITION, + }); + } + } finally { + await context.close(); + } + }); + } + } + + it.each([ + ['daylight-blue', 'brightness(1.25)'], + ['paper-gray', 'brightness(0.9)'], + ])('drops the scale but keeps the tint under prefers-reduced-motion on %s', async (skin, brightness) => { + const [context, page] = await openBars(skin, 'reduce'); + try { + for (const [name, selector] of TARGETS) { + expect({ name, ...(await pressed(page, selector)) }).toEqual({ + name, + rest: { transform: 'none', filter: 'none' }, + held: { transform: 'none', filter: brightness }, + transition: PRESS_TRANSITION, + }); + } + } finally { + await context.close(); + } + }); +}); diff --git a/test/plan-usage-collection-flip.test.ts b/test/plan-usage-collection-flip.test.ts index 776810a5..7b186159 100644 --- a/test/plan-usage-collection-flip.test.ts +++ b/test/plan-usage-collection-flip.test.ts @@ -70,8 +70,11 @@ describe('saveAppSettings wiring', () => { ); // Stripped from serverSettings like the other per-device display keys. expect(save).toMatch(/showPlanUsageLimits: _pul,/); - // Decided once against the device's prior settings, before they are overwritten. - expect(save).toMatch(/const _chipFlip = this\.planUsageCollectionFlip\(_prev, settings\.showPlanUsageLimits\);/); + // Decided once against the device's prior settings, before they are overwritten (or, after a + // save the server did not store, against the settings that save started from). + expect(save).toMatch( + /const _chipFlip = this\.planUsageCollectionFlip\(_prevServer, settings\.showPlanUsageLimits\);/ + ); // And only a real flip reaches the PUT body. expect(save).toMatch(/\.\.\.\(_chipFlip !== undefined \? \{ showPlanUsageLimits: _chipFlip \} : \{\}\),/); }); diff --git a/test/ralph-fix-plan-remote.test.ts b/test/ralph-fix-plan-remote.test.ts new file mode 100644 index 00000000..661b7e5e --- /dev/null +++ b/test/ralph-fix-plan-remote.test.ts @@ -0,0 +1,168 @@ +/** + * The Ralph tracker's `@fix_plan.md` watcher must never be pointed at a REMOTE + * session's working directory. + * + * A remote-SSH session's `workingDir` is a path on the remote host. The watcher + * reads and `fs.watch`es it on the LOCAL disk, where it usually does not exist, + * so every server start logged + * `[RalphFixPlanWatcher] Could not watch @fix_plan.md: Error: ENOENT ...` once per + * remote session (and where a same-named local directory did exist, it watched + * the wrong tree). A remote session therefore only RECORDS the path + * (`setRemoteWorkingDir`): nothing is read or watched locally, and the fix + * plan stays authoritative (`isFileAuthoritative`) exactly as for a local + * session, so the tracker's output-based completion guessers stay off. They + * are off for every session with a working directory; turning them on for + * remote sessions alone would let a prose line such as "All tasks completed" + * mark every todo done and end the loop there and nowhere else. + * + * Drives the real `WebServer.setupSessionListeners()`, like + * discard-partially-built-session.test.ts. + */ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { safeRmHomeTree } from './mocks/test-helpers.js'; + +import { WebServer } from '../src/web/server.js'; +import { Session } from '../src/session.js'; +import { TmuxManager } from '../src/tmux-manager.js'; + +interface ServerInternals { + registerSessionWithLayout(session: Session): Promise<void>; + setupSessionListeners(session: Session): Promise<void>; + discardPartiallyBuiltSession(sessionId: string): Promise<void>; +} + +const WORKSPACE = join(homedir(), '.codeman-test-ralph-remote'); +// A path on the remote host: nothing exists there locally. +const REMOTE_PATH = '/nonexistent-remote-host-path/homelab-infrastructure'; +const SESSION_ID = 'f0e1d2c3b4a59687'; + +let server: WebServer; +let internals: ServerInternals; +let mux: TmuxManager; + +function buildSession(remote: boolean): Session { + return new Session({ + id: SESSION_ID, + workingDir: remote ? REMOTE_PATH : WORKSPACE, + mode: 'claude', + name: remote ? 'remote session' : 'local session', + mux, + useMux: true, + ...(remote + ? { + remote: { + hostId: 'proxx', + label: 'proxx', + host: 'proxx.example.lan', + username: 'user', + remotePath: REMOTE_PATH, + owned: true, + }, + } + : {}), + }); +} + +beforeAll(() => { + mkdirSync(WORKSPACE, { recursive: true }); + server = new WebServer(0, false, true); + internals = server as unknown as ServerInternals; + mux = new TmuxManager(); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await internals.discardPartiallyBuiltSession(SESSION_ID).catch(() => {}); +}); + +afterAll(async () => { + await server.stop().catch(() => {}); + safeRmHomeTree(WORKSPACE); +}); + +describe('Ralph @fix_plan.md watcher and the session location', () => { + it('does not watch a remote session working directory on the local disk', async () => { + const session = buildSession(true); + const setWorkingDir = vi.spyOn(session.ralphTracker, 'setWorkingDir'); + const setRemoteWorkingDir = vi.spyOn(session.ralphTracker, 'setRemoteWorkingDir'); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await internals.registerSessionWithLayout(session); + await internals.setupSessionListeners(session); + + expect(setWorkingDir).not.toHaveBeenCalled(); + expect(setRemoteWorkingDir).toHaveBeenCalledWith(REMOTE_PATH); + expect(log.mock.calls.some((args) => String(args[0]).includes('Could not watch @fix_plan.md'))).toBe(false); + // Same authority as a local session: the output-based completion guessers stay off. + expect(session.ralphTracker.isFileAuthoritative).toBe(true); + }); + + it('never reads a same-named local file for a remote session', async () => { + // The remote path exists locally too (an sshfs mount, a leftover checkout) and holds a plan. + writeFileSync(join(WORKSPACE, '@fix_plan.md'), '- [ ] a local task the remote session never had\n'); + try { + const session = new Session({ + id: SESSION_ID, + workingDir: WORKSPACE, + mode: 'claude', + name: 'remote session over a local twin', + mux, + useMux: true, + remote: { + hostId: 'proxx', + label: 'proxx', + host: 'proxx.example.lan', + username: 'user', + remotePath: WORKSPACE, + owned: true, + }, + }); + vi.spyOn(console, 'log').mockImplementation(() => {}); + + await internals.registerSessionWithLayout(session); + await internals.setupSessionListeners(session); + + expect(await session.ralphTracker.loadFixPlanFromDisk()).toBe(0); + expect(session.ralphTracker.todos).toEqual([]); + } finally { + rmSync(join(WORKSPACE, '@fix_plan.md'), { force: true }); + } + }); + + it('keeps a remote session todo list out of the prose completion guessers', async () => { + const session = buildSession(true); + vi.spyOn(console, 'log').mockImplementation(() => {}); + + await internals.registerSessionWithLayout(session); + await internals.setupSessionListeners(session); + + const tracker = session.ralphTracker; + tracker.enable(); + tracker.startLoop('ALLDONE'); + const completions: string[] = []; + tracker.on('completionDetected', (phrase: string) => completions.push(phrase)); + tracker.processTerminalData('- [ ] Write the parser\n- [ ] Add tests\n'); + const pending = () => tracker.todos.filter((t) => t.status !== 'completed').length; + expect(pending()).toBe(2); + + // Prose, not the configured completion phrase: it must not end the loop. + tracker.processTerminalData('All tasks completed\n'); + + expect(pending()).toBe(2); + expect(tracker.loopState.active).toBe(true); + expect(completions).toEqual([]); + }); + + it('still watches a local session working directory', async () => { + const session = buildSession(false); + const setWorkingDir = vi.spyOn(session.ralphTracker, 'setWorkingDir'); + + await internals.registerSessionWithLayout(session); + await internals.setupSessionListeners(session); + + expect(setWorkingDir).toHaveBeenCalledWith(WORKSPACE); + }); +}); diff --git a/test/render-index-html.test.ts b/test/render-index-html.test.ts index fc81713c..7e0931d4 100644 --- a/test/render-index-html.test.ts +++ b/test/render-index-html.test.ts @@ -181,6 +181,16 @@ describe('WebServer.renderIndexHtml', () => { } }); + it('injects the keyboard-hold modes from the keyboardHold capability: inline Claude, never full-height codex', async () => { + for (const solo of [undefined, 'sess-123']) { + const { server } = makeServer(); + const html = await render(server, solo); + const match = html.match(/window\.__codemanKeyboardHoldModes=(\[[^<]*\]);/); + expect(match).not.toBeNull(); + expect(JSON.parse(match![1])).toEqual(['claude']); + } + }); + it('escapes the solo id so it cannot break out of the inline <script>', async () => { const { server } = makeServer({}); const html = await render(server, 'a</script><b>'); diff --git a/test/request-body-parser.test.ts b/test/request-body-parser.test.ts new file mode 100644 index 00000000..37c5ed32 --- /dev/null +++ b/test/request-body-parser.test.ts @@ -0,0 +1,393 @@ +/** Regression coverage for empty API POSTs reframed as chunked by a reverse proxy. */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import Fastify, { type FastifyInstance, type FastifyRequest, type FastifyServerOptions } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import fastifyMultipart from '@fastify/multipart'; +import { connect } from 'node:net'; +import { Readable } from 'node:stream'; +import { installEmptyApiPostParser } from '../src/web/request-body-parser.js'; +import { registerAuthMiddleware, registerHostGuard } from '../src/web/middleware/auth.js'; +import { registerMuxRoutes } from '../src/web/routes/mux-routes.js'; +import { registerWebviewRoutes } from '../src/web/routes/webview-routes.js'; +import { createMockRouteContext } from './mocks/index.js'; +import { writeWebviews } from '../src/webview-store.js'; +import { webviewCapabilities } from '../src/webview-capabilities.js'; +import { getDataDir } from '../src/config/instance.js'; + +const apps: FastifyInstance[] = []; +function createApp(options: FastifyServerOptions = {}): FastifyInstance { + const app = Fastify({ logger: false, ...options }); + installEmptyApiPostParser(app); + apps.push(app); + return app; +} +afterEach(async () => { + await Promise.all(apps.splice(0).map((app) => app.close())); + webviewCapabilities.revokeWebview('empty-post-preview'); +}); + +const emptyChunked = { 'transfer-encoding': 'chunked' }; +describe('empty API POST parser', () => { + it.each([{}, { 'content-length': '0' }, emptyChunked])( + 'preserves an absent body and the original headers (%j)', + async (headers) => { + const app = createApp(); + app.post('/api/action', async (req) => ({ + absent: req.body === undefined, + contentType: req.headers['content-type'] ?? null, + contentLength: req.headers['content-length'] ?? null, + transferEncoding: req.headers['transfer-encoding'] ?? null, + })); + const res = await app.inject({ method: 'POST', url: '/api/action', headers }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ + absent: true, + contentType: null, + contentLength: 'content-length' in headers ? '0' : null, + transferEncoding: 'transfer-encoding' in headers ? 'chunked' : null, + }); + } + ); + + it.each([' ', '{}', '{"prompt":"hello"}', 'x'.repeat(1024)])( + 'rejects nonempty untyped chunked input (%#)', + async (payload) => { + const app = createApp(); + const handler = vi.fn(async () => ({})); + app.post('/api/action', handler); + const res = await app.inject({ method: 'POST', url: '/api/action', headers: emptyChunked, payload }); + expect(res.statusCode).toBe(415); + expect(res.json().code).toBe('FST_ERR_CTP_INVALID_MEDIA_TYPE'); + expect(handler).not.toHaveBeenCalled(); + } + ); + + it('rejects a positive Content-Length without reading the body', async () => { + const app = createApp(); + let streamRead = false; + app.addHook( + 'preParsing', + async () => + new Readable({ + read() { + streamRead = true; + }, + }) + ); + app.post('/api/action', async () => ({})); + const res = await app.inject({ method: 'POST', url: '/api/action', headers: { 'content-length': '1' } }); + expect(res.statusCode).toBe(415); + expect(streamRead).toBe(false); + }); + + it('waits for the end of an empty stream before executing the handler', async () => { + const app = createApp(); + let started!: () => void; + const reading = new Promise<void>((resolve) => { + started = resolve; + }); + const stream = new Readable({ + read() { + started(); + }, + }); + app.addHook('preParsing', async () => stream); + const handler = vi.fn(async (req: FastifyRequest) => ({ absent: req.body === undefined })); + app.post('/api/action', handler); + const response = app.inject({ method: 'POST', url: '/api/action', headers: emptyChunked }); + await reading; + expect(handler).not.toHaveBeenCalled(); + stream.push(null); + const res = await response; + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ absent: true }); + }); + + it('rejects on the first byte without waiting for an unsupported stream to end', async () => { + const app = createApp({ bodyLimit: 16 }); + const stream = new Readable({ + read() { + this.push(Buffer.alloc(32)); + this._read = () => {}; + }, + }); + app.addHook('preParsing', async () => stream); + app.post('/api/action', async () => ({})); + try { + const res = await app.inject({ method: 'POST', url: '/api/action', headers: emptyChunked }); + expect(res.statusCode).toBe(415); + expect(res.headers.connection).toBe('close'); + expect(stream.readableEnded).toBe(false); + } finally { + stream.destroy(); + } + }); + + it.each(['application/octet-stream', 'application/xml', 'invalid/type/extra', ''])( + 'preserves rejection of an explicit type %j', + async (contentType) => { + const app = createApp(); + app.post('/api/action', async () => ({})); + const res = await app.inject({ method: 'POST', url: '/api/action', headers: { 'content-type': contentType } }); + expect(res.statusCode).toBe(415); + } + ); + + it('preserves JSON parsing, required-body validation, text and body limits', async () => { + const app = createApp({ bodyLimit: 64 }); + app.post('/api/json', async (req) => ({ body: req.body })); + app.post( + '/api/required', + { schema: { body: { type: 'object', required: ['prompt'], properties: { prompt: { type: 'string' } } } } }, + async (req) => req.body + ); + const missing = await app.inject({ method: 'POST', url: '/api/required', headers: emptyChunked }); + expect(missing.statusCode).toBe(400); + expect(missing.json().code).toBe('FST_ERR_VALIDATION'); + const valid = await app.inject({ method: 'POST', url: '/api/required', payload: { prompt: 'hello' } }); + expect(valid.statusCode).toBe(200); + expect(valid.json()).toEqual({ prompt: 'hello' }); + for (const [payload, code] of [ + ['', 'FST_ERR_CTP_EMPTY_JSON_BODY'], + ['{broken', 'FST_ERR_CTP_INVALID_JSON_BODY'], + ]) { + const res = await app.inject({ + method: 'POST', + url: '/api/json', + headers: { 'content-type': 'application/json' }, + payload, + }); + expect(res.statusCode).toBe(400); + expect(res.json().code).toBe(code); + } + const text = await app.inject({ + method: 'POST', + url: '/api/json', + headers: { 'content-type': 'text/plain' }, + payload: '{"admin":true}', + }); + expect(text.json()).toEqual({ body: '{"admin":true}' }); + const oversized = await app.inject({ method: 'POST', url: '/api/json', payload: { value: 'x'.repeat(64) } }); + expect(oversized.statusCode).toBe(413); + }); + + it('leaves other methods and non-API routes outside the compatibility rule', async () => { + const app = createApp(); + app.put('/api/action', async () => ({})); + app.patch('/api/action', async () => ({})); + app.post('/outside-api', async () => ({})); + for (const [method, url] of [ + ['PUT', '/api/action'], + ['PATCH', '/api/action'], + ['POST', '/outside-api'], + ] as const) { + const res = await app.inject({ method, url, headers: emptyChunked }); + expect(res.statusCode).toBe(415); + } + }); + + it('preserves unknown routes and leaves their body stream unread for fallback handlers', async () => { + const app = createApp(); + app.setNotFoundHandler(async (req, reply) => { + const chunks: Buffer[] = []; + for await (const chunk of req.raw) chunks.push(chunk); + return reply.code(404).send({ body: Buffer.concat(chunks).toString(), absent: req.body === undefined }); + }); + const res = await app.inject({ + method: 'POST', + url: '/api/unknown', + headers: emptyChunked, + payload: 'untyped-data', + }); + expect(res.statusCode).toBe(404); + expect(res.json()).toEqual({ body: 'untyped-data', absent: true }); + }); + + it('keeps multipart uploads and an encapsulated raw-stream proxy parser intact', async () => { + const app = createApp(); + await app.register(fastifyMultipart); + app.post('/api/upload', async (req) => { + const file = await req.file(); + return { name: file?.filename, data: (await file?.toBuffer())?.toString('base64') }; + }); + app.register(async (scope) => { + scope.removeAllContentTypeParsers(); + scope.addContentTypeParser('*', (_req, stream, done) => done(null, stream)); + scope.post('/webview/test/*', async (req) => { + const chunks: Buffer[] = []; + for await (const chunk of req.body as Readable) chunks.push(chunk); + return { data: Buffer.concat(chunks).toString('base64') }; + }); + }); + const bytes = Buffer.from([0, 255, 128, 13, 10, 65]); + const boundary = 'empty-post-boundary'; + const multipart = Buffer.concat([ + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="sample.bin"\r\nContent-Type: application/octet-stream\r\n\r\n` + ), + bytes, + Buffer.from(`\r\n--${boundary}--\r\n`), + ]); + const upload = await app.inject({ + method: 'POST', + url: '/api/upload', + headers: { 'content-type': `multipart/form-data; boundary=${boundary}` }, + payload: multipart, + }); + expect(upload.statusCode).toBe(200); + expect(upload.json()).toEqual({ name: 'sample.bin', data: bytes.toString('base64') }); + const proxy = await app.inject({ + method: 'POST', + url: '/webview/test/upload', + headers: { 'content-type': 'application/octet-stream' }, + payload: bytes, + }); + expect(proxy.statusCode).toBe(200); + expect(proxy.json()).toEqual({ data: bytes.toString('base64') }); + }); + + it('exercises real mux operations and the real webview open route without a synthetic JSON body', async () => { + const app = createApp(); + const ctx = createMockRouteContext(); + registerMuxRoutes(app, ctx as never); + registerWebviewRoutes(app, ctx as never); + await writeWebviews(getDataDir(), [ + { + id: 'empty-post-preview', + name: 'Preview', + url: 'http://127.0.0.1:18716/', + embedMode: 'proxy', + trusted: false, + createdAt: 1, + }, + ]); + const stats = await app.inject({ method: 'POST', url: '/api/mux-sessions/stats/start', headers: emptyChunked }); + expect(stats.statusCode).toBe(200); + expect(ctx.mux.startStatsCollection).toHaveBeenCalledOnce(); + const preview = await app.inject({ + method: 'POST', + url: '/api/webviews/empty-post-preview/open', + headers: emptyChunked, + }); + expect(preview.statusCode).toBe(200); + expect(preview.json().data.embedUrl).toMatch(/^\/webview\/.+\/$/); + }); + + it('preserves Host/Origin guards and authentication before parsing', async () => { + vi.stubEnv('CODEMAN_USERNAME', 'bodyless-test-user'); + vi.stubEnv('CODEMAN_PASSWORD', 'isolated-test-password'); + const app = createApp(); + await app.register(fastifyCookie); + registerHostGuard(app, () => ({ bindHost: '127.0.0.1', allowedHosts: [], tunnelHost: null })); + const auth = registerAuthMiddleware(app, false); + app.post('/api/action', async () => ({ ok: true })); + const authorization = 'Basic ' + Buffer.from('bodyless-test-user:isolated-test-password').toString('base64'); + const headers = { host: '127.0.0.1', ...emptyChunked }; + try { + expect((await app.inject({ method: 'POST', url: '/api/action', headers })).statusCode).toBe(401); + expect( + ( + await app.inject({ + method: 'POST', + url: '/api/action', + headers: { ...headers, authorization, origin: 'https://evil.example' }, + }) + ).statusCode + ).toBe(403); + expect( + ( + await app.inject({ + method: 'POST', + url: '/api/action', + headers: { ...headers, authorization, host: 'evil.example' }, + }) + ).statusCode + ).toBe(403); + expect( + (await app.inject({ method: 'POST', url: '/api/action', headers: { ...headers, authorization } })).statusCode + ).toBe(200); + } finally { + for (const map of Object.values(auth)) map?.dispose(); + vi.unstubAllEnvs(); + } + }); +}); + +function sendRaw(port: number, frames: string[]): Promise<string> { + return new Promise((resolve, reject) => { + const socket = connect({ host: '127.0.0.1', port }); + const chunks: Buffer[] = []; + socket.setTimeout(2000, () => socket.destroy(new Error('Raw request timed out'))); + socket.on('error', reject); + socket.on('data', (chunk) => chunks.push(chunk)); + socket.on('end', () => resolve(Buffer.concat(chunks).toString())); + socket.on('connect', () => socket.write(frames.join(''))); + }); +} + +describe('real HTTP/1.1 framing', () => { + it('consumes the complete empty chunk before a second request on the same connection', async () => { + const app = createApp(); + const seen: unknown[] = []; + app.post('/api/action', async (req) => { + seen.push(req.body); + return { ok: true }; + }); + await app.listen({ host: '127.0.0.1', port: 0 }); + const port = (app.server.address() as { port: number }).port; + const res = await sendRaw(port, [ + 'POST /api/action HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n0\r\nX-Test-Trailer: done\r\n\r\n', + 'POST /api/action HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nConnection: close\r\n\r\n', + ]); + expect(res.match(/HTTP\/1.1 200 OK/g)).toHaveLength(2); + expect(seen).toEqual([undefined, undefined]); + }); + + it('returns 415 after the first nonempty chunk, without needing a terminating chunk', async () => { + const app = createApp(); + const handler = vi.fn(async () => ({})); + app.post('/api/action', handler); + await app.listen({ host: '127.0.0.1', port: 0 }); + const port = (app.server.address() as { port: number }).port; + const res = await sendRaw(port, [ + 'POST /api/action HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n1\r\nx\r\n', + ]); + expect(res).toMatch(/^HTTP\/1.1 415 /); + expect(handler).not.toHaveBeenCalled(); + }); + + it('leaves conflicting length and transfer headers to the HTTP parser', async () => { + const app = createApp(); + const handler = vi.fn(async () => ({})); + app.post('/api/action', handler); + await app.listen({ host: '127.0.0.1', port: 0 }); + const port = (app.server.address() as { port: number }).port; + const res = await sendRaw(port, [ + 'POST /api/action HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n', + ]); + expect(res).toMatch(/^HTTP\/1.1 400 /); + expect(handler).not.toHaveBeenCalled(); + }); + + it('does not execute the handler after a client aborts an unfinished chunked request', async () => { + const app = createApp(); + const handler = vi.fn(async () => ({})); + const onError = vi.fn(); + app.addHook('onError', async (_req, _reply, error) => { + onError(error.statusCode); + }); + app.post('/api/action', handler); + await app.listen({ host: '127.0.0.1', port: 0 }); + const port = (app.server.address() as { port: number }).port; + await new Promise<void>((resolve) => { + const socket = connect({ host: '127.0.0.1', port }); + socket.on('connect', () => + socket.end('POST /api/action HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n') + ); + socket.resume(); + socket.on('close', () => resolve()); + }); + await vi.waitFor(() => expect(onError).toHaveBeenCalledWith(400)); + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/test/response-viewer-file-links.test.ts b/test/response-viewer-file-links.test.ts index 85947186..723d233b 100644 --- a/test/response-viewer-file-links.test.ts +++ b/test/response-viewer-file-links.test.ts @@ -53,6 +53,15 @@ function loadCodemanAppClass() { return (context as { __CodemanApp: { prototype: { _linkifyFilePaths(root: unknown): void } } }).__CodemanApp; } +const markedContext = vm.createContext({}); +vm.runInContext( + readFileSync(resolve(import.meta.dirname, '../src/web/public/vendor/marked.min.js'), 'utf8'), + markedContext +); +const marked = markedContext.marked as { parse(text: string, options?: object): string }; +/** The options `_renderMarkdown` passes, so the tests see chat's real markup (a newline becomes `<br>`). */ +const renderChat = (markdown: string) => marked.parse(markdown, { breaks: true, gfm: true }); + const CodemanApp = loadCodemanAppClass(); const APP_SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8'); @@ -69,6 +78,92 @@ function linkify(html: string): HTMLElement { const paths = (root: HTMLElement) => Array.from(root.querySelectorAll('a.rv-path')); describe('response viewer file-path linkifier', () => { + it('links a parenthesised configuration filename with spaces without changing the prose', () => { + const path = '/mnt/user/Scratch/Kobo/Set-KoboCovers/Kobo eReader.conf'; + const text = `Prepared a corrected configuration (${path}).`; + const root = linkify(`<p>${text}</p>`); + expect(paths(root).map((a) => a.dataset.path)).toEqual([path]); + expect(paths(root)[0].dataset.folder).toBeUndefined(); + expect(root.textContent).toBe(text); + }); + + it.each([ + ['- 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx', ['Design/Build-SIPOC.pptx']], + ['- notes/a b/c.md', ['b/c.md']], + [ + '**Saved to:** 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx', + ['03 Attachments/Build SIPOC Design/Build-SIPOC.pptx'], + ], + ['**Saved to:** ~/My Docs/plan.md', ['~/My Docs/plan.md']], + ['- **Path:** notes/a b/c.md', ['notes/a b/c.md']], + ['- /mnt/c/My Docs/a.docx', ['/mnt/c/My Docs/a.docx']], + ['**Saved to:** /mnt/c/My Docs/a.docx', ['/mnt/c/My Docs/a.docx']], + ['**Tests:** 45/45 pass in test/foo.test.ts', ['test/foo.test.ts']], + ['**Error:** I/O failure reading data.json', ['data.json']], + [ + 'Done.\n**Saved to:** 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx', + ['03 Attachments/Build SIPOC Design/Build-SIPOC.pptx'], + ], + [ + '**Size:** 2 MB\n**Saved to:** 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx', + ['03 Attachments/Build SIPOC Design/Build-SIPOC.pptx'], + ], + ])('links rendered Markdown with consumed markers and labels: %s', (markdown, expected) => { + const html = renderChat(markdown); + const original = document.createElement('div'); + original.innerHTML = html; + const root = linkify(html); + expect(paths(root).map((a) => a.getAttribute('data-path'))).toEqual(expected); + expect(root.textContent).toBe(original.textContent); + expect(root.querySelector('strong')?.textContent).toBe(original.querySelector('strong')?.textContent); + }); + + it.each([ + ['**Wrote:** 120 lines to src/foo.ts', ['src/foo.ts']], + ['- **Output:** stored in dist/app.js', ['dist/app.js']], + ['**Created:** new file src/components/Button.tsx', ['src/components/Button.tsx']], + ['**Files:** src/components and utils/x.ts', ['utils/x.ts']], + ['**Wrote:** 3 files to docs/design notes/overview.md', ['notes/overview.md']], + ['**Path:** /home/me/proj/out contains a.md', ['/home/me/proj/out', 'a.md']], + ['- /tmp/out has the logs in run.log', ['/tmp/out', 'run.log']], + ])('keeps prose out of the link in rendered Markdown: %s', (markdown, expected) => { + expect(paths(linkify(renderChat(markdown))).map((a) => a.getAttribute('data-path'))).toEqual(expected); + }); + + // The terminal splits these lines (`Note:` is not a path label, and the path does + // not start the line), so the viewer must split them too. + it.each([ + ['**Note:** /home/me/proj/out report.pdf', ['/home/me/proj/out', 'report.pdf']], + ['Use `--config` /home/me/proj/cfg data.json', ['/home/me/proj/cfg', 'data.json']], + ])('splits a spaced candidate the terminal splits: %s', (markdown, expected) => { + const html = renderChat(markdown); + const original = document.createElement('div'); + original.innerHTML = html; + const root = linkify(html); + expect(paths(root).map((a) => a.getAttribute('data-path'))).toEqual(expected); + expect(root.textContent).toBe(original.textContent); + }); + + it('still links a spaced path inside inline code whole, after another inline element too', () => { + const root = linkify(renderChat('Use `--config` `/home/me/My Docs/cfg data.json`')); + + const links = paths(root); + expect(links.map((a) => a.getAttribute('data-path'))).toEqual(['/home/me/My Docs/cfg data.json']); + expect(links[0].closest('code')).not.toBeNull(); + }); + + it('does not inherit a bold label from earlier prose or another block', () => { + for (const markdown of [ + 'See **Saved to:** 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx', + '**Saved to:**\n\n03 Attachments/Build SIPOC Design/Build-SIPOC.pptx', + 'Done.\nSee **Saved to:** 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx', + ]) { + expect(paths(linkify(renderChat(markdown))).map((a) => a.getAttribute('data-path'))).toEqual([ + 'Design/Build-SIPOC.pptx', + ]); + } + }); + it('links an absolute path written as prose', () => { const path = '/tmp/claude-1000/-home-arkon-default-claudeman/7b3fefd2/scratchpad/probe-run-native.png'; const root = linkify(`<p>Saved the capture to ${path} — have a look.</p>`); diff --git a/test/response-viewer-middle-click.test.ts b/test/response-viewer-middle-click.test.ts new file mode 100644 index 00000000..b2d1c18d --- /dev/null +++ b/test/response-viewer-middle-click.test.ts @@ -0,0 +1,262 @@ +/** + * @fileoverview A middle click on a placeholder link in rendered markdown + * (`CodemanApp._bindResponseViewerInteractions`). + * + * File-path links (`a.rv-path`) and in-document links carry `href="#"` / + * `href="#frag"`: placeholders the click delegate resolves itself. A middle + * click fires `auxclick`, never `click`, so the browser used to follow the + * placeholder and open the dashboard root (`<base href="/">` + `#`) in a new + * tab. A middle click on a placeholder now does exactly what a click does; a + * real URL keeps the browser's own middle-click behaviour. + * + * Both surfaces are covered: the Response Viewer's messages + * (`_buildResponseViewerMessage` + `_linkifyFilePaths`) and the File Viewer's + * rendered markdown, whose rebased links name their own session. + * + * Drives the shipping `constants.js`, `app.js` and `terminal-ui.js` (for + * `_resolveLinkedFilePath`) in a `vm` with a jsdom window injected (the + * technique from response-viewer-file-links.test.ts; a per-file jsdom + * environment would externalize node:fs under vite). + */ +import { readFileSync } from 'node:fs'; +import { performance } from 'node:perf_hooks'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const publicFile = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8'); + +const dom = new JSDOM('<!DOCTYPE html><html><head><base href="/"></head><body></body></html>', { + url: 'http://localhost:3000/', +}); +const win = dom.window; +const { document } = win; +// app.js instantiates the whole app on DOMContentLoaded; let it pass before app.js is loaded. +if (document.readyState === 'loading') await new Promise((r) => document.addEventListener('DOMContentLoaded', r)); + +type App = { + activeSessionId: string; + sessions: Map<string, { workingDir: string }>; + openFilePreview: ReturnType<typeof vi.fn>; + openFolderGrid: ReturnType<typeof vi.fn>; + openLinkThroughWebTabIfLoopback: ReturnType<typeof vi.fn>; + _linkifyFilePaths(root: Element): void; + _bindResponseViewerInteractions(body: Element): void; +}; + +function loadCodemanAppClass(): { prototype: App } { + const context = vm.createContext({ + console, + performance, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn(), + HTMLCanvasElement: class HTMLCanvasElement {}, + fetch: vi.fn(), + document, + NodeFilter: win.NodeFilter, + localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() }, + // The real window, so constants.js publishes CodemanMarkdownAnchors where the delegate reads it. + window: win, + MobileDetection: {}, + }); + vm.runInContext( + `${publicFile('constants.js')}\n${publicFile('app.js')}\n${publicFile('terminal-ui.js')}\nglobalThis.__CodemanApp = CodemanApp;`, + context + ); + return (context as unknown as { __CodemanApp: { prototype: App } }).__CodemanApp; +} + +const CodemanApp = loadCodemanAppClass(); + +let app: App; + +beforeEach(() => { + app = Object.create(CodemanApp.prototype) as App; + app.activeSessionId = 'sess-1'; + app.sessions = new Map([ + ['sess-1', { workingDir: '/home/me/proj' }], + ['sess-2', { workingDir: '/srv/other' }], + ]); + app.openFilePreview = vi.fn(); + app.openFolderGrid = vi.fn(); + // A loopback URL would be handed to a web tab on a left click; a middle click must not reach it. + app.openLinkThroughWebTabIfLoopback = vi.fn(() => true); +}); + +/** A Response Viewer message body: rendered `.rv-text` inside the bound container. */ +function responseViewer(html: string): HTMLElement { + const body = document.createElement('div'); + const text = document.createElement('div'); + text.className = 'rv-text'; + text.innerHTML = html; + app._linkifyFilePaths(text); + body.appendChild(text); + document.body.replaceChildren(body); + app._bindResponseViewerInteractions(body); + return body; +} + +function press(el: Element, type: 'auxclick' | 'click', init: MouseEventInit = {}): MouseEvent { + const ev = new win.MouseEvent(type, { bubbles: true, cancelable: true, ...init }); + el.dispatchEvent(ev); + return ev; +} + +function noneCalled(): void { + expect(app.openFilePreview).not.toHaveBeenCalled(); + expect(app.openFolderGrid).not.toHaveBeenCalled(); + expect(app.openLinkThroughWebTabIfLoopback).not.toHaveBeenCalled(); +} + +describe('middle click on a Response Viewer path link', () => { + it('opens a relative file path in the preview, resolved against the active session', () => { + const body = responseViewer('<p>wrote src/app.ts</p>'); + const link = body.querySelector('a.rv-path')!; + expect(link.getAttribute('href')).toBe('#'); + + const ev = press(link, 'auxclick', { button: 1 }); + + expect(ev.defaultPrevented).toBe(true); + expect(app.openFilePreview.mock.calls).toEqual([['/home/me/proj/src/app.ts', 'sess-1']]); + expect(app.openFolderGrid).not.toHaveBeenCalled(); + expect(app.openLinkThroughWebTabIfLoopback).not.toHaveBeenCalled(); + }); + + it('opens a markdown link whose destination is a path, like a click does', () => { + const body = responseViewer('<p><a href="/home/me/run/summary.json" target="_blank">record</a></p>'); + + const ev = press(body.querySelector('a.rv-path')!, 'auxclick', { button: 1 }); + + expect(ev.defaultPrevented).toBe(true); + expect(app.openFilePreview.mock.calls).toEqual([['/home/me/run/summary.json', 'sess-1']]); + }); + + it('opens a folder path in the folder grid', () => { + const body = responseViewer('<p><a href="/tmp/renders/">renders</a></p>'); + + const ev = press(body.querySelector('a.rv-path')!, 'auxclick', { button: 1 }); + + expect(ev.defaultPrevented).toBe(true); + expect(app.openFolderGrid.mock.calls).toEqual([['/tmp/renders/', 'sess-1']]); + expect(app.openFilePreview).not.toHaveBeenCalled(); + }); + + it('does the same with Ctrl held, as Ctrl+click does', () => { + const body = responseViewer('<p>wrote src/app.ts</p>'); + + const ev = press(body.querySelector('a.rv-path')!, 'auxclick', { button: 1, ctrlKey: true }); + + expect(ev.defaultPrevented).toBe(true); + expect(app.openFilePreview.mock.calls).toEqual([['/home/me/proj/src/app.ts', 'sess-1']]); + }); + + it.each([ + ['right', 2], + ['back', 3], + ['forward', 4], + ])('ignores the %s button', (_name, button) => { + const body = responseViewer('<p>wrote src/app.ts</p>'); + + const ev = press(body.querySelector('a.rv-path')!, 'auxclick', { button }); + + expect(ev.defaultPrevented).toBe(false); + noneCalled(); + }); + + it('opens once per gesture: a left click still goes through click alone', () => { + const body = responseViewer('<p>wrote src/app.ts</p>'); + // Binding is idempotent: a second bind must not add a second auxclick listener. + app._bindResponseViewerInteractions(body); + const link = body.querySelector('a.rv-path')!; + + press(link, 'auxclick', { button: 1 }); + expect(app.openFilePreview).toHaveBeenCalledTimes(1); + + app.openFilePreview.mockClear(); + const click = press(link, 'click', { button: 0 }); + expect(click.defaultPrevented).toBe(true); + expect(app.openFilePreview.mock.calls).toEqual([['/home/me/proj/src/app.ts', 'sess-1']]); + }); +}); + +describe('middle click on an in-document link', () => { + it('scrolls to the heading in place instead of following the fragment', () => { + const body = responseViewer('<h2>Install</h2><p>see <a href="#install">install</a></p>'); + const heading = body.querySelector('h2')! as HTMLElement & { scrollIntoView: ReturnType<typeof vi.fn> }; + heading.scrollIntoView = vi.fn(); + + const ev = press(body.querySelector('a[href="#install"]')!, 'auxclick', { button: 1 }); + + expect(ev.defaultPrevented).toBe(true); + // jsdom has no matchMedia, so reduced motion reads as off. + expect(heading.scrollIntoView.mock.calls).toEqual([[{ block: 'start', behavior: 'smooth' }]]); + noneCalled(); + }); + + it('ignores a right click on it', () => { + const body = responseViewer('<h2>Install</h2><p>see <a href="#install">install</a></p>'); + const heading = body.querySelector('h2')! as HTMLElement & { scrollIntoView: ReturnType<typeof vi.fn> }; + heading.scrollIntoView = vi.fn(); + + const ev = press(body.querySelector('a[href="#install"]')!, 'auxclick', { button: 2 }); + + expect(ev.defaultPrevented).toBe(false); + expect(heading.scrollIntoView).not.toHaveBeenCalled(); + }); +}); + +describe('middle click on a real URL', () => { + it.each(['https://example.com/a.json', 'http://localhost:5173/'])('leaves %s to the browser', (href) => { + const body = responseViewer(`<p><a href="${href}" target="_blank" rel="noopener noreferrer">x</a></p>`); + expect(body.querySelector('a.rv-path')).toBeNull(); + + const ev = press(body.querySelector('a')!, 'auxclick', { button: 1 }); + + expect(ev.defaultPrevented).toBe(false); + noneCalled(); + }); +}); + +describe('middle click in the File Viewer rendered markdown', () => { + it('opens a rebased link in the session the preview was opened for, not the active one', () => { + // The shape _rebaseFilePreviewMarkdownRefs gives a workspace link (panels-ui.js). + const bodyEl = document.createElement('div'); + bodyEl.innerHTML = + '<div class="rv-text file-preview-md"><a class="rv-path" href="#" data-path="/srv/other/notes.md" data-session-id="sess-2">notes</a></div>'; + document.body.replaceChildren(bodyEl); + app._bindResponseViewerInteractions(bodyEl); + + const ev = press(bodyEl.querySelector('a.rv-path')!, 'auxclick', { button: 1 }); + + expect(ev.defaultPrevented).toBe(true); + expect(app.openFilePreview.mock.calls).toEqual([['/srv/other/notes.md', 'sess-2']]); + }); +}); + +describe('a left click keeps its behaviour', () => { + it('opens a path link in the preview', () => { + const body = responseViewer('<p>wrote src/app.ts</p>'); + + const ev = press(body.querySelector('a.rv-path')!, 'click', { button: 0 }); + + expect(ev.defaultPrevented).toBe(true); + expect(app.openFilePreview.mock.calls).toEqual([['/home/me/proj/src/app.ts', 'sess-1']]); + expect(app.openLinkThroughWebTabIfLoopback).not.toHaveBeenCalled(); + }); + + it('hands a loopback URL to the web-tab hook', () => { + const body = responseViewer( + '<p><a href="http://localhost:5173/" target="_blank" rel="noopener noreferrer">dev server</a></p>' + ); + + const ev = press(body.querySelector('a')!, 'click', { button: 0 }); + + expect(ev.defaultPrevented).toBe(true); + expect(app.openLinkThroughWebTabIfLoopback.mock.calls).toEqual([['http://localhost:5173/']]); + expect(app.openFilePreview).not.toHaveBeenCalled(); + }); +}); diff --git a/test/routes/agent-session-routes.test.ts b/test/routes/agent-session-routes.test.ts new file mode 100644 index 00000000..e97e401b --- /dev/null +++ b/test/routes/agent-session-routes.test.ts @@ -0,0 +1,355 @@ +/** @fileoverview Agent inspection and guarded input HTTP contracts (no live ports). */ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + AGENT_COORDINATION_DISABLED_MESSAGE, + registerAgentSessionRoutes, +} from '../../src/web/routes/agent-session-routes.js'; +import { InspectionReader } from '../../src/agent-inspection.js'; +import { SETTINGS_PATH } from '../../src/web/route-helpers.js'; +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; + +let harness: RouteTestHarness; +/** The routes are opt-in (`agentCoordinationEnabled`, default OFF); undefined = key absent. */ +function setEnabled(on: boolean | undefined): void { + mkdirSync(dirname(SETTINGS_PATH), { recursive: true }); + writeFileSync(SETTINGS_PATH, JSON.stringify(on === undefined ? {} : { agentCoordinationEnabled: on })); +} +beforeEach(() => setEnabled(true)); +afterEach(async () => { + await harness?.app.close(); + rmSync(SETTINGS_PATH, { force: true }); +}); + +describe('the agentCoordinationEnabled gate', () => { + const requests = (id: string) => [ + { method: 'GET' as const, url: `/api/sessions/${id}/inspect` }, + { method: 'GET' as const, url: `/api/sessions/${id}/inspect?maxBytes=0` }, + { method: 'POST' as const, url: `/api/sessions/${id}/stage`, payload: { message: 'hello' } }, + { + method: 'POST' as const, + url: `/api/sessions/${id}/submit`, + payload: { stageId: '11111111-1111-4111-8111-111111111111', confirmSha: 'a'.repeat(64) }, + }, + ]; + it.each([ + ['absent', undefined], + ['false', false], + ] as const)('answers 403 naming the setting on every route while it is %s', async (_label, value) => { + setEnabled(value); + harness = await createRouteTestHarness(registerAgentSessionRoutes); + const session = [...harness.ctx.sessions.values()][0]; + // A session id that does not exist is refused the same way: the gate runs before lookup. + for (const request of [...requests(session.id), ...requests('no-such-session')]) { + const result = await harness.app.inject(request); + expect(result.statusCode).toBe(403); + expect(result.json()).toEqual({ + success: false, + error: AGENT_COORDINATION_DISABLED_MESSAGE, + errorCode: 'FORBIDDEN', + }); + } + expect(AGENT_COORDINATION_DISABLED_MESSAGE).toContain('agentCoordinationEnabled'); + expect(session.writeBuffer).toEqual([]); + }); + it('answers on every route once it is on, with no restart', async () => { + setEnabled(false); + harness = await createRouteTestHarness(registerAgentSessionRoutes); + const session = [...harness.ctx.sessions.values()][0]; + expect((await harness.app.inject(requests(session.id)[0])).statusCode).toBe(403); + setEnabled(true); + const [inspect, invalid, stage, submit] = await Promise.all( + requests(session.id).map((request) => harness.app.inject(request)) + ); + expect(inspect.statusCode).toBe(200); + expect(inspect.json().data.source).toBe('pane-snapshot'); + expect(invalid.statusCode).toBe(400); + // The mock session has no characterized composer, so the guards refuse: the route answered. + expect(stage.statusCode).toBe(409); + expect(submit.statusCode).toBe(409); + }); +}); + +it('reports a non-cursor inspection failure as OPERATION_FAILED and logs only the session and error code', async () => { + const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const { Session } = await import('../../src/session.js'); + const root = await mkdtemp(join(tmpdir(), 'agent-inspect-fail-')); + const home = join(root, 'codex'); + await mkdir(join(home, 'sessions'), { recursive: true }); + const session = new Session({ + id: 'inspect-fail', + workingDir: root, + mode: 'codex', + envOverrides: { CODEX_HOME: home }, + }); + const id = '11111111-1111-4111-8111-111111111111'; + const path = join(home, 'sessions', `rollout-${id}.jsonl`); + await writeFile( + path, + JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator: `codeman_${session.id}` } }) + '\n' + ); + harness = await createRouteTestHarness(registerAgentSessionRoutes); + (harness.ctx.sessions as unknown as Map<string, Session>).set(session.id, session); + const read = vi + .spyOn(InspectionReader.prototype, 'read') + .mockRejectedValue(Object.assign(new Error(`EACCES: permission denied, open '${path}'`), { code: 'EACCES' })); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect` }); + expect(read).toHaveBeenCalled(); + expect(result.statusCode).toBe(422); + expect(result.json().errorCode).toBe('OPERATION_FAILED'); + expect(result.body).not.toContain(path); + const logged = warn.mock.calls.map((call) => call.join(' ')).join('\n'); + expect(logged).toContain(session.id); + expect(logged).toContain('EACCES'); + expect(logged).not.toContain(home); + } finally { + read.mockRestore(); + warn.mockRestore(); + await session.stop(false); + await rm(root, { recursive: true, force: true }); + } +}); +describe('agent session routes', () => { + it('returns an explicitly bounded pane snapshot when no attributed transcript exists', async () => { + harness = await createRouteTestHarness(registerAgentSessionRoutes); + const session = [...harness.ctx.sessions.values()][0]; + const result = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?maxBytes=20` }); + expect(result.statusCode).toBe(200); + const body = result.json(); + expect(body.data.source).toBe('pane-snapshot'); + expect(body.data.cursor).toBeNull(); + expect(Buffer.byteLength(body.data.evidence)).toBeLessThanOrEqual(20); + }); + it('refuses guarded staging for an uncharacterized composer', async () => { + harness = await createRouteTestHarness(registerAgentSessionRoutes); + const session = [...harness.ctx.sessions.values()][0]; + const result = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${session.id}/stage`, + payload: { message: 'hello' }, + }); + expect(result.statusCode).toBe(409); + }); +}); + +it('checks ownership before inspection, stage and submit', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + try { + harness = await createRouteTestHarness(registerAgentSessionRoutes, { + authUser: { username: 'alice', role: 'user' }, + }); + const session = [...harness.ctx.sessions.values()][0]; + session.owner = 'bob'; + for (const request of [ + { method: 'GET' as const, url: `/api/sessions/${session.id}/inspect` }, + { method: 'POST' as const, url: `/api/sessions/${session.id}/stage`, payload: { message: 'hello' } }, + { + method: 'POST' as const, + url: `/api/sessions/${session.id}/submit`, + payload: { stageId: '11111111-1111-4111-8111-111111111111', confirmSha: 'a'.repeat(64) }, + }, + ]) + expect((await harness.app.inject(request)).statusCode).toBe(404); + expect(session.writeBuffer).toEqual([]); + } finally { + delete process.env.CODEMAN_MULTIUSER; + } +}); +it('validates output budgets and refuses to resume a pane fallback', async () => { + harness = await createRouteTestHarness(registerAgentSessionRoutes); + const session = [...harness.ctx.sessions.values()][0]; + session.terminalBuffer = '😀é'.repeat(20); + for (const value of ['0', '-1', '65537', '1.5', 'NaN']) + expect( + (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?maxBytes=${value}` })).statusCode + ).toBe(400); + const result = (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?maxBytes=7` })).json(); + expect(result.data.evidence).not.toContain('�'); + expect(Buffer.byteLength(result.data.evidence)).toBeLessThanOrEqual(7); + expect(result.data.truncated).toBe(true); + expect( + ( + await harness.app.inject({ + url: `/api/sessions/${session.id}/inspect?since=11111111-1111-4111-8111-111111111111`, + }) + ).statusCode + ).toBe(409); +}); +it('uses real session writes to stage without Enter and refuses intervening API input', async () => { + const { Session } = await import('../../src/session.js'); + const { formatPaneSnapshot } = await import('../../src/tmux-manager.js'); + harness = await createRouteTestHarness(registerAgentSessionRoutes); + let composer = ''; + const writes: string[] = []; + const session = new Session({ + id: 'guarded-session', + workingDir: '/tmp', + mode: 'codex', + mux: { + isAvailable: () => true, + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot([`› ${composer}`, ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 2 + composer.length, + cursorY: 0, + }); + }, + sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { + if (options?.beforeDispatch && !options.beforeDispatch()) return false; + writes.push(text); + composer += text; + return true; + }, + }, + muxSession: { muxName: 'codeman-test', sessionId: 'guarded-session', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters<typeof Session>[0]); + (harness.ctx.sessions as unknown as Map<string, Session>).set(session.id, session); + try { + const stage = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${session.id}/stage`, + payload: { message: 'hello' }, + }); + expect(stage.statusCode).toBe(200); + expect(writes).toEqual(['hello']); + const { stageId, sha256 } = stage.json().data; + const submit = () => + harness.app.inject({ + method: 'POST', + url: `/api/sessions/${session.id}/submit`, + payload: { stageId, confirmSha: sha256 }, + }); + expect((await submit()).statusCode).toBe(200); + expect(writes).toEqual(['hello', '\r']); + expect((await submit()).statusCode).toBe(409); + composer = ''; + const next = ( + await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${session.id}/stage`, + payload: { message: 'again' }, + }) + ).json().data; + await session.writeViaMux('x'); + composer = 'again'; // Even restoring the visible text cannot erase the input revision. + const stale = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${session.id}/submit`, + payload: { stageId: next.stageId, confirmSha: next.sha256 }, + }); + expect(stale.statusCode).toBe(409); + expect(writes.filter((s) => s === '\r')).toHaveLength(1); + // Codex's command popup opens on `/`; Enter would run the highlighted command. + composer = ''; + writes.length = 0; + const slash = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${session.id}/stage`, + payload: { message: '/stat' }, + }); + expect(slash.statusCode).toBe(409); + expect(slash.json().error).toMatch(/starting with "\/".*codeman agent send/); + expect(writes).toEqual([]); + } finally { + await session.stop(false); + } +}); +it('reads only a strictly attributed Codex rollout and binds HTTP cursors to its owner', async () => { + const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const { Session } = await import('../../src/session.js'); + const root = await mkdtemp(join(tmpdir(), 'agent-inspect-route-')); + const home = join(root, 'codex'); + await mkdir(join(home, 'sessions'), { recursive: true }); + const session = new Session({ + id: 'inspect-session', + workingDir: root, + mode: 'codex', + envOverrides: { CODEX_HOME: home }, + }); + const id = '11111111-1111-4111-8111-111111111111'; + const row = + JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator: `codeman_${session.id}` } }) + '\n'; + await writeFile(join(home, 'sessions', `rollout-${id}.jsonl`), row + '{"text":"hello"}\n'); + harness = await createRouteTestHarness(registerAgentSessionRoutes, { + authUser: { username: 'alice', role: 'admin' }, + }); + (harness.ctx.sessions as unknown as Map<string, Session>).set(session.id, session); + try { + const first = await harness.app.inject({ + url: `/api/sessions/${session.id}/inspect?maxBytes=${Buffer.byteLength(row)}`, + }); + expect(first.statusCode).toBe(200); + const data = first.json().data; + expect(data.source).toBe('codex-jsonl'); + expect(data.evidence).toBe('{\"text\":\"hello\"}\n'); + expect(data.omittedBeforeBytes).toBe(Buffer.byteLength(row)); + expect(data.identity).toEqual({ sessionId: session.id, mode: 'codex', conversationId: id }); + expect(data.cursor).not.toContain(home); + const next = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${data.cursor}` }); + expect(next.json().data.evidence).toBe(''); + const access = session as unknown as { ptyProcess: { resize(cols: number, rows: number): void } | null }; + access.ptyProcess = { resize: () => {} }; + session.resize(80, 24); + access.ptyProcess = null; + expect( + (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${data.cursor}` })).statusCode + ).toBe(200); + session.owner = 'changed-owner'; + expect( + (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${data.cursor}` })).statusCode + ).toBe(409); + } finally { + await session.stop(false); + await rm(root, { recursive: true, force: true }); + } +}); + +it('refuses a stale launch-resume cursor once a new managed rollout conflicts with it', async () => { + const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const { Session } = await import('../../src/session.js'); + const root = await mkdtemp(join(tmpdir(), 'agent-resume-conflict-')); + const home = join(root, 'codex'); + const old = '11111111-1111-4111-8111-111111111111', + fresh = '22222222-2222-4222-8222-222222222222'; + await mkdir(join(home, 'sessions'), { recursive: true }); + const session = new Session({ + id: 'resumed-session', + workingDir: root, + mode: 'codex', + envOverrides: { CODEX_HOME: home }, + codexConfig: { resumeSessionId: old }, + }); + const row = (id: string, originator: string) => + JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator } }) + '\n'; + await writeFile(join(home, 'sessions', `rollout-${old}.jsonl`), row(old, 'original-launch')); + harness = await createRouteTestHarness(registerAgentSessionRoutes); + (harness.ctx.sessions as unknown as Map<string, Session>).set(session.id, session); + try { + const initial = (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect` })).json().data; + expect(initial.source).toBe('codex-jsonl'); + await writeFile(join(home, 'sessions', `rollout-${fresh}.jsonl`), row(fresh, `codeman_${session.id}`)); + const stale = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${initial.cursor}` }); + expect(stale.statusCode).toBe(409); + const fallback = (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect` })).json().data; + expect(fallback.source).toBe('pane-snapshot'); + expect(fallback.cursor).toBeNull(); + } finally { + await session.stop(false); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/routes/case-clone-custom-folder.test.ts b/test/routes/case-clone-custom-folder.test.ts new file mode 100644 index 00000000..39e1ac0d --- /dev/null +++ b/test/routes/case-clone-custom-folder.test.ts @@ -0,0 +1,389 @@ +/** + * @fileoverview POST /api/cases/clone with a `path`: clone INTO that folder (an empty or not yet existing one, + * with no subfolder added) and register it as a linked case. Real filesystem under test/setup.ts's temp + * HOME and a REAL `git` cloning a local bare repo (the path policy itself is in test/case-path.test.ts). + * Port: N/A (app.inject()). + * + * Like case-custom-path-routes.test.ts this suite rewrites the linked-cases registry, so it refuses to + * start outside the sandboxed HOME (a raw `npx vitest` would reach the real ~/.codeman). + */ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createRouteTestHarness } from './_route-test-utils.js'; +import { registerCaseRoutes } from '../../src/web/routes/case-routes.js'; +import { dataPath } from '../../src/config/instance.js'; +import { + classifyGitFailure, + cloneRepository, + getActiveGitOperationCount, + getQueuedGitOperationCount, + isGitAvailable, +} from '../../src/git-clone.js'; +import { isSandboxedHome, safeRmHomeTree } from '../mocks/index.js'; + +const LINKED = () => dataPath('linked-cases.json'); +const linked = (): Record<string, string> => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {}); +const clone = (app: Awaited<ReturnType<typeof createRouteTestHarness>>['app'], payload: Record<string, unknown>) => + app.inject({ method: 'POST', url: '/api/cases/clone', payload }); + +describe.skipIf(!isGitAvailable())('POST /api/cases/clone with a custom folder', () => { + let root: string; + let origin: string; + let workDir = ''; + + const git = (args: string[], cwd: string) => + execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }); + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'codeman-clone-custom-')); + origin = join(root, 'origin.git'); + mkdirSync(origin); + git(['init', '--bare', '--quiet'], origin); + const work = join(root, 'work'); + mkdirSync(work); + git(['init', '--quiet'], work); + git(['config', 'user.email', 'test@example.com'], work); + git(['config', 'user.name', 'Codeman Test'], work); + writeFileSync(join(work, 'README.md'), '# fixture\n'); + git(['add', '.'], work); + git(['commit', '--quiet', '-m', 'initial'], work); + git(['branch', '-M', 'main'], work); + git(['remote', 'add', 'origin', origin], work); + git(['push', '--quiet', 'origin', 'main'], work); + git(['symbolic-ref', 'HEAD', 'refs/heads/main'], origin); + }); + afterAll(() => rmSync(root, { recursive: true, force: true })); + + beforeEach(() => { + if (!isSandboxedHome()) { + throw new Error('case-clone-custom-folder.test.ts rewrites the linked-cases registry: run it via npm test'); + } + safeRmHomeTree(LINKED()); + // Resolved, because the server answers with the symlink-resolved folder. + workDir = realpathSync(mkdtempSync(join(homedir(), 'clone-custom-'))); + }); + afterEach(() => { + delete process.env.CODEMAN_MULTIUSER; + if (workDir) safeRmHomeTree(workDir); + workDir = ''; + if (isSandboxedHome()) safeRmHomeTree(LINKED()); + }); + + it('clones into the chosen folder, scaffolds it and registers it as a linked case', async () => { + const { app, ctx } = await createRouteTestHarness(registerCaseRoutes); + const target = join(workDir, 'my-clone'); + const res = await clone(app, { name: 'my-clone', repository: origin, path: target }); + expect(res.statusCode).toBe(200); + expect(res.json().data.case).toEqual({ name: 'my-clone', path: target }); + expect(existsSync(join(target, 'README.md'))).toBe(true); + expect(existsSync(join(target, '.git'))).toBe(true); + expect(existsSync(join(target, '.claude', 'settings.local.json'))).toBe(true); + expect(linked()).toEqual({ 'my-clone': target }); + expect(ctx.broadcast).toHaveBeenCalledWith('case:created', { name: 'my-clone', path: target }); + // Nothing lands under the cases directory. + expect(existsSync(join(homedir(), 'codeman-cases', 'my-clone'))).toBe(false); + }); + + it('expands ~ in the folder', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const folder = workDir.slice(homedir().length + 1); + const res = await clone(app, { name: 'tilde', repository: origin, path: `~/${folder}/tilde` }); + expect(res.statusCode).toBe(200); + expect(existsSync(join(workDir, 'tilde', 'README.md'))).toBe(true); + }); + + it('refuses a parent folder that does not exist, and creates nothing', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const res = await clone(app, { name: 'orphan', repository: origin, path: join(workDir, 'nope', 'orphan') }); + expect(res.statusCode).toBe(404); + expect(existsSync(join(workDir, 'nope'))).toBe(false); + expect(linked()).toEqual({}); + }); + + it('refuses a target folder that already has files, and leaves them alone', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const target = join(workDir, 'busy'); + mkdirSync(target); + writeFileSync(join(target, 'mine.txt'), 'keep me'); + const res = await clone(app, { name: 'busy', repository: origin, path: target }); + expect(res.statusCode).toBe(409); + expect(res.json().error).toMatch(/already has files/); + expect(readFileSync(join(target, 'mine.txt'), 'utf8')).toBe('keep me'); + expect(linked()).toEqual({}); + }); + + it('clones straight INTO the chosen empty folder, with no subfolder named after the case', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const target = join(workDir, 'picked'); + mkdirSync(target); + const res = await clone(app, { name: 'a-different-name', repository: origin, path: target }); + expect(res.statusCode).toBe(200); + expect(res.json().data.case).toEqual({ name: 'a-different-name', path: target }); + expect(existsSync(join(target, 'README.md'))).toBe(true); + expect(existsSync(join(target, '.git'))).toBe(true); + expect(existsSync(join(target, 'a-different-name'))).toBe(false); + expect(linked()).toEqual({ 'a-different-name': target }); + }); + + it('leaves the empty folder in place, and empty, when the clone fails', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const target = join(workDir, 'stays'); + mkdirSync(target); + const res = await clone(app, { name: 'stays', repository: join(root, 'no-such-repo.git'), path: target }); + expect(res.json().success).toBe(false); + expect(existsSync(target)).toBe(true); + expect(readdirSync(target)).toEqual([]); + expect(linked()).toEqual({}); + }); + + it('two clones into one empty folder at once: exactly one wins and its files survive', async () => { + const target = join(workDir, 'raced'); + mkdirSync(target); + const results = await Promise.all([ + cloneRepository({ repository: origin, destination: target, intoEmptyDir: true }), + cloneRepository({ repository: origin, destination: target, intoEmptyDir: true }), + ]); + expect(results.filter((r) => r.ok)).toHaveLength(1); + const loser = results.find((r) => !r.ok); + expect(loser && !loser.ok && loser.failure.code).toBe('DESTINATION_EXISTS'); + expect(existsSync(join(target, 'README.md'))).toBe(true); + expect(existsSync(join(target, '.git'))).toBe(true); + }); + + it('a name linked while the clone ran is a 409 that keeps the folder and names it', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const [a, b] = await Promise.all([ + clone(app, { name: 'twin', repository: origin, path: join(workDir, 'twin-a') }), + clone(app, { name: 'twin', repository: origin, path: join(workDir, 'twin-b') }), + ]); + const loserDir = a.json().success ? join(workDir, 'twin-b') : join(workDir, 'twin-a'); + const loser = a.json().success ? b : a; + expect([a.json().success, b.json().success].filter(Boolean)).toHaveLength(1); + expect(loser.statusCode).toBe(409); + expect(loser.json().errorCode).toBe('ALREADY_EXISTS'); + expect(loser.json().error).toContain(loserDir); + expect(existsSync(join(loserDir, 'README.md'))).toBe(true); + expect(Object.keys(linked())).toEqual(['twin']); + }); + + it('a direct clone whose git-slot wait times out leaves what was written into the folder meanwhile', async () => { + const target = join(workDir, 'queued'); + mkdirSync(target); + // Two slow ssh clones hold both git slots (no network needed: the ssh command just sleeps). + const prior = process.env.GIT_SSH_COMMAND; + process.env.GIT_SSH_COMMAND = 'sleep 4; true'; + try { + const holders = [0, 1].map((i) => + cloneRepository({ + repository: 'ssh://localhost/x.git', + destination: join(workDir, `hold-${i}`), + timeoutMs: 10_000, + }) + ); + await vi.waitFor(() => expect(getActiveGitOperationCount()).toBe(2)); + const queued = cloneRepository({ + repository: origin, + destination: target, + intoEmptyDir: true, + timeoutMs: 1_000, + }); + await vi.waitFor(() => expect(getQueuedGitOperationCount()).toBe(1)); + // Written during the wait, so not this attempt's: a user copying files in, or another case created there. + writeFileSync(join(target, 'mine.txt'), 'keep me'); + mkdirSync(join(target, 'src')); + const result = await queued; + expect(!result.ok && result.failure.code).toBe('TIMEOUT'); + expect(readFileSync(join(target, 'mine.txt'), 'utf8')).toBe('keep me'); + expect(existsSync(join(target, 'src'))).toBe(true); + await Promise.all(holders); + } finally { + if (prior === undefined) delete process.env.GIT_SSH_COMMAND; + else process.env.GIT_SSH_COMMAND = prior; + } + }, 30_000); + + it('refuses a folder at or inside a Docker case workspace, and creates nothing', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const workspace = join(workDir, 'dockerws'); + mkdirSync(workspace); + writeFileSync( + join(dataPath('docker-cases.json')), + JSON.stringify([{ name: 'box', type: 'docker', hostId: 'h', hostWorkspacePath: workspace, container: 'c' }]) + ); + try { + for (const path of [workspace, join(workspace, 'repo')]) { + const res = await clone(app, { name: 'inside', repository: origin, path }); + expect(res.json().errorCode, path).toBe('INVALID_INPUT'); + expect(res.json().error, path).toMatch(/Docker case workspace/); + } + expect(readdirSync(workspace)).toEqual([]); + expect(linked()).toEqual({}); + // A sibling of the workspace is fine. + const ok = await clone(app, { name: 'beside', repository: origin, path: join(workDir, 'beside') }); + expect(ok.statusCode).toBe(200); + } finally { + safeRmHomeTree(dataPath('docker-cases.json')); + } + }); + + it('fails closed when the Docker case list cannot be read', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + writeFileSync(dataPath('docker-cases.json'), '{not json'); + try { + const res = await clone(app, { name: 'blind', repository: origin, path: join(workDir, 'blind') }); + expect(res.statusCode).toBe(503); + expect(existsSync(join(workDir, 'blind'))).toBe(false); + } finally { + safeRmHomeTree(dataPath('docker-cases.json')); + } + }); + + it.each(['plain', 'openssl', 'rustls', 'does not exist'])( + 'a refused folder named %s keeps what was written meanwhile and reports DESTINATION_EXISTS', + async (folder) => { + const target = join(workDir, folder); + mkdirSync(target); + const prior = process.env.GIT_SSH_COMMAND; + process.env.GIT_SSH_COMMAND = 'sleep 2; true'; + try { + const holders = [0, 1].map((i) => + cloneRepository({ + repository: 'ssh://localhost/x.git', + destination: join(workDir, `hold-${i}`), + timeoutMs: 10_000, + }) + ); + await vi.waitFor(() => expect(getActiveGitOperationCount()).toBe(2)); + // Queues behind the holders but has time to get a slot, so git runs and refuses the folder. + const queued = cloneRepository({ + repository: origin, + destination: target, + intoEmptyDir: true, + timeoutMs: 15_000, + }); + await vi.waitFor(() => expect(getQueuedGitOperationCount()).toBe(1)); + writeFileSync(join(target, 'mine.txt'), 'keep me'); + const result = await queued; + expect(!result.ok && result.failure.code).toBe('DESTINATION_EXISTS'); + expect(readFileSync(join(target, 'mine.txt'), 'utf8')).toBe('keep me'); + await Promise.all(holders); + } finally { + if (prior === undefined) delete process.env.GIT_SSH_COMMAND; + else process.env.GIT_SSH_COMMAND = prior; + } + }, + 30_000 + ); + + it('classifies git stderr by what it says, not by words in a path it quotes', () => { + const refusal = (dir: string) => `fatal: destination path '${dir}' already exists and is not an empty directory.`; + for (const dir of ['/home/u/src/openssl', '/home/u/rustls', '/x/tls-test', '/x/repository not found']) { + expect(classifyGitFailure(refusal(dir), false).code, dir).toBe('DESTINATION_EXISTS'); + } + expect(classifyGitFailure('fatal: unable to access: SSL certificate problem: self signed', false).code).toBe( + 'HOST_UNREACHABLE' + ); + expect(classifyGitFailure('fatal: TLS handshake failure', false).code).toBe('HOST_UNREACHABLE'); + // `_` is a word character, so a bare \bssl\b misses OpenSSL's and LibreSSL's usual flaky-network error. + expect( + classifyGitFailure( + "fatal: unable to access 'https://github.com/o/r/': OpenSSL SSL_connect: SSL_ERROR_SYSCALL in connection to github.com:443", + false + ).code + ).toBe('HOST_UNREACHABLE'); + expect(classifyGitFailure('fatal: could not resolve host: example.invalid', false).code).toBe('HOST_UNREACHABLE'); + expect(classifyGitFailure("fatal: repository 'https://x/y.git/' not found", false).code).toBe('NOT_FOUND'); + }); + + it('refuses a clone without a path into the cases folder when it is a Docker case workspace', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const casesDir = join(homedir(), 'codeman-cases'); + mkdirSync(casesDir, { recursive: true }); + writeFileSync( + dataPath('docker-cases.json'), + JSON.stringify([{ name: 'box', type: 'docker', hostId: 'h', hostWorkspacePath: casesDir, container: 'c' }]) + ); + try { + const res = await clone(app, { name: 'default-inside', repository: origin }); + expect(res.json().errorCode).toBe('INVALID_INPUT'); + expect(res.json().error).toMatch(/Docker case workspace/); + expect(existsSync(join(casesDir, 'default-inside'))).toBe(false); + } finally { + safeRmHomeTree(dataPath('docker-cases.json')); + } + }); + + it('points Clone Repo at itself, not Create New, for the cases folder and a folder directly in it', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const casesDir = join(homedir(), 'codeman-cases'); + mkdirSync(casesDir, { recursive: true }); + for (const path of [casesDir, join(casesDir, 'direct-child')]) { + const res = await clone(app, { name: 'direct-child', repository: origin, path }); + expect(res.statusCode, path).toBe(400); + expect(res.json().error, path).toMatch(/clone there without a custom folder/); + expect(res.json().error, path).not.toMatch(/Create New/); + } + }); + + it('refuses a blocked tree such as ~/.ssh', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + mkdirSync(join(homedir(), '.ssh'), { recursive: true }); + const res = await clone(app, { name: 'sneaky', repository: origin, path: join(homedir(), '.ssh', 'sneaky') }); + expect(res.statusCode).toBe(400); + expect(existsSync(join(homedir(), '.ssh', 'sneaky'))).toBe(false); + }); + + it('refuses a name already linked, before running git', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + writeFileSync(LINKED(), JSON.stringify({ taken: join(workDir, 'elsewhere') })); + const res = await clone(app, { name: 'taken', repository: origin, path: join(workDir, 'taken') }); + // The harness has no preSerialization status mapping, so read the envelope's code. + expect(res.json().errorCode).toBe('ALREADY_EXISTS'); + expect(existsSync(join(workDir, 'taken'))).toBe(false); + }); + + it('leaves no folder behind when the clone fails', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const target = join(workDir, 'ghost'); + const res = await clone(app, { name: 'ghost', repository: join(root, 'no-such-repo.git'), path: target }); + expect(res.json().errorCode).toBe('NOT_FOUND'); + expect(existsSync(target)).toBe(false); + expect(linked()).toEqual({}); + }); + + it('without a path the clone still goes under the cases directory and is not linked', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const res = await clone(app, { name: 'custom-folder-default', repository: origin }); + try { + expect(res.statusCode).toBe(200); + expect(res.json().data.case.path).toBe(join(homedir(), 'codeman-cases', 'custom-folder-default')); + expect(linked()).toEqual({}); + } finally { + safeRmHomeTree(join(homedir(), 'codeman-cases', 'custom-folder-default')); + } + }); + + it('multi-user: a non-admin is refused (403) and nothing is cloned; an admin is allowed', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const user = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } }); + const denied = await clone(user.app, { name: 'bobs', repository: origin, path: join(workDir, 'bobs') }); + expect(denied.statusCode).toBe(403); + expect(existsSync(join(workDir, 'bobs'))).toBe(false); + expect(linked()).toEqual({}); + const admin = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'root', role: 'admin' } }); + const ok = await clone(admin.app, { name: 'roots', repository: origin, path: join(workDir, 'roots') }); + expect(ok.statusCode).toBe(200); + }); +}); diff --git a/test/routes/case-default-group-routes.test.ts b/test/routes/case-default-group-routes.test.ts index 637d4dad..6cbd6ae1 100644 --- a/test/routes/case-default-group-routes.test.ts +++ b/test/routes/case-default-group-routes.test.ts @@ -3,13 +3,16 @@ * read and write it, and the create routes that carry the case into the real * TabLayoutService so a new session lands in its case's group in the creating * write (POST /api/sessions, and POST /api/quick-start for local, remote and - * Docker cases). + * Docker cases). The tab "+" picker (app.js) is driven end to end against the + * same routes, since an ungrouped tab relies on this fallback by design. * * Uses app.inject(), so no real HTTP port is needed. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; import { mkdir, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; +import { JSDOM } from 'jsdom'; import { homedir } from 'node:os'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; @@ -101,6 +104,56 @@ const unwrap = (raw: string) => { return parsed.data ?? parsed; }; +/** + * The shipping frontend in JSDOM, with fetch answered by the route harnesses, so + * a click in the tab "+" picker reaches the real create route and layout service. + */ +async function bootTabApp(route: (url: string, init?: { method?: string; body?: string }) => Promise<unknown>) { + const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { + url: 'https://localhost/', + runScripts: 'outside-only', + }); + if (dom.window.document.readyState !== 'complete') { + await new Promise((resolve) => dom.window.addEventListener('load', resolve)); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const win = dom.window as any; + win.setInterval = () => 0; + win.requestAnimationFrame = () => 0; + win.__codemanCliCatalog = [{ id: 'shell', kind: 'shell', label: 'Shell', enabled: true }]; + const publicDir = join(process.cwd(), 'src/web/public'); + win.eval( + 'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' + + 'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' + + ['constants.js', 'app.js', 'mobile-overview.js', 'session-ui.js'] + .map((file) => readFileSync(join(publicDir, file), 'utf8')) + .join('\n;\n') + + '\n;window.__CdgCodemanApp = CodemanApp;' + ); + win.fetch = async (url: string, init?: { method?: string; body?: string }) => { + const body = await route(url, init); + return { ok: true, status: 200, json: async () => body }; + }; + win.document.body.innerHTML = '<div id="sessionTabs"></div><select id="quickStartCase"></select>'; + const app = Object.create(win.__CdgCodemanApp.prototype); + Object.assign(app, { + $: (id: string) => win.document.getElementById(id), + sessions: new Map(), + activeSessionId: 'tab-1', + terminal: { clear: () => {}, writeln: () => {}, focus: () => {}, cols: 80, rows: 24 }, + _runMinLockMs: 0, + _runMode: 'claude', + getTerminalDimensions: () => null, + selectSession: async () => {}, + loadQuickStartCases: () => {}, + showToast: vi.fn(), + _applyTabLayout: () => {}, + _onSessionCreated: () => {}, + _renderSessionTabsImmediate: () => {}, + }); + return { app, win }; +} + describe('case default group: create routes', () => { let harness: RouteTestHarness; let world: World; @@ -280,6 +333,63 @@ describe('case default group: create routes', () => { // The route's own error path ran cleanupSession for the registered id. expect(harness.ctx.cleanupSession).toHaveBeenCalledTimes(1); }); + + describe('from a tab "+" in the browser', () => { + /** Opens the tab's "+" picker, picks Shell, and returns the create request and the new session's group. */ + async function launchBeside(tabGroupId: string | null) { + const cases = await createRouteTestHarness(registerCaseRoutes); + const creates: Array<Record<string, unknown>> = []; + try { + const { app, win } = await bootTabApp(async (url, init) => { + const payload = init?.body ? JSON.parse(init.body) : undefined; + if (url === '/api/sessions' && init?.method === 'POST') creates.push(payload); + const target = url.startsWith('/api/cases/') ? cases : harness; + const res = await target.app.inject({ method: (init?.method ?? 'GET') as 'GET', url, payload }); + // The envelope server.ts's preSerialization hook adds, which the harness does not install. + const body = res.body ? JSON.parse(res.body) : {}; + return typeof body.success === 'boolean' ? body : { success: true, data: body }; + }); + app.cases = [{ name: CASE, path: caseDir }]; + app.sessions.set('tab-1', { id: 'tab-1', name: 'w1-cdg', workingDir: caseDir }); + app.tabLayout = { + ...world.layouts[OWNER], + groups: world.layouts[OWNER].groups.map((g) => ({ + ...g, + refs: g.id === tabGroupId ? [{ kind: 'session', id: 'tab-1' }] : [], + })), + }; + app.collapsedTabGroupIds = new Set(); + app.toggleTabGroupCollapsed = () => {}; + const plus = win.document.createElement('span'); + win.document.body.appendChild(plus); + app.openTabNewSessionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: plus }, 'tab-1'); + const shell = [...win.document.querySelectorAll('[aria-label="New session here"] button')].find( + (button: Element) => button.textContent === 'Terminal / Shell' + ) as HTMLElement; + shell.click(); + await vi.waitFor(() => expect(creates).toHaveLength(1)); + await vi.waitFor(() => expect(harness.ctx.sessions.size).toBe(1)); + const [id] = [...harness.ctx.sessions.keys()]; + return { create: creates[0], group: groupOf(world.layouts[OWNER], id), toasts: app.showToast.mock.calls }; + } finally { + await cases.app.close(); + } + } + + it('an ungrouped tab sends no group, so the case default group places the session (chosen on purpose)', async () => { + const { create, group, toasts } = await launchBeside(null); + expect(create).toEqual({ workingDir: caseDir, mode: 'shell', name: `s1-${CASE}` }); + expect(group).toBe('g-ops'); + // The launch went through: no error reported along the way. + expect(toasts.filter(([, kind]: [string, string]) => kind === 'error')).toEqual([]); + }); + + it('a grouped tab sends its own group, which wins over the case default', async () => { + const { create, group } = await launchBeside('g-core'); + expect(create).toEqual({ workingDir: caseDir, mode: 'shell', name: `s1-${CASE}`, tabGroupId: 'g-core' }); + expect(group).toBe('g-core'); + }); + }); }); describe('case default group: GET/PUT /api/cases/:name/default-group', () => { diff --git a/test/routes/case-rename-routes.test.ts b/test/routes/case-rename-routes.test.ts index 94a58721..2a28d08a 100644 --- a/test/routes/case-rename-routes.test.ts +++ b/test/routes/case-rename-routes.test.ts @@ -10,6 +10,7 @@ import { existsSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, realpathSync, symlinkSync, @@ -25,6 +26,7 @@ import { SETTINGS_PATH, resolveCasesDir } from '../../src/web/route-helpers.js'; import { caseSessionSettled, claudeProjectDirName } from '../../src/case-rename.js'; import { Session } from '../../src/session.js'; import { safeRmHomeTree } from '../mocks/index.js'; +import { mutateSettingsFile } from '../../src/web/settings-writer.js'; const LINKED = () => dataPath('linked-cases.json'); const CASES_DIR = () => join(homedir(), 'codeman-cases'); @@ -286,6 +288,64 @@ describe('POST /api/cases/:name/rename (a linked case)', () => { expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }); }); + it('puts back only the keys it wrote, keeping a settings write made in between', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const real = join(work, 'my-project'); + mkdirSync(real); + writeFileSync(LINKED(), JSON.stringify({ proj: real })); + writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' })); + chmodSync(LINKED(), 0o444); + // Another writer (a settings save) lands after the rename wrote settings.json and before the + // registry write fails. + const realWriteFile = fs.writeFile; + const spy = vi.spyOn(fs, 'writeFile').mockImplementation(async (file, data, options) => { + if (file === LINKED()) await mutateSettingsFile((current) => ({ ...current, agentSkillEnabled: true })); + return realWriteFile(file, data, options); + }); + try { + const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true }); + expect(res.statusCode).toBe(500); + } finally { + spy.mockRestore(); + } + expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj', agentSkillEnabled: true }); + }); + + it('refuses to rename while settings.json is not a JSON object, leaving the folder and the file alone', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const oldDir = localCase('alpha'); + const torn = '{\n "agentSkillEnabled": true\n}tail"\n}'; + writeFileSync(SETTINGS_PATH, torn); + const res = await rename(app, 'alpha', { newName: 'beta' }); + expect(res.statusCode).toBe(409); + expect(res.json()).toEqual({ + success: false, + errorCode: 'CONFLICT', + error: `${SETTINGS_PATH} is not a valid JSON object, so this change was not saved (saving would have replaced every other setting in it). Fix or delete the file, or restore it from a config backup, then try again.`, + }); + expect(existsSync(oldDir)).toBe(true); + expect(existsSync(join(CASES_DIR(), 'beta'))).toBe(false); + expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(torn); + }); + + it('checks settings.json before it stops any session, so none is ended for a rename that cannot complete', async () => { + const { app, ctx } = await createRouteTestHarness(registerCaseRoutes); + const oldDir = localCase('alpha'); + const torn = '{\n "agentSkillEnabled": true\n}tail"\n}'; + writeFileSync(SETTINGS_PATH, torn); + (ctx.sessions as Map<string, unknown>).set('s1', { id: 's1', name: 'w1', workingDir: oldDir }); + + const res = await rename(app, 'alpha', { newName: 'beta', stopSessions: true }); + + expect(res.statusCode).toBe(409); + expect(res.json().errorCode).toBe('CONFLICT'); + expect(res.json().error).toContain('is not a valid JSON object'); + expect(ctx.cleanupSession).not.toHaveBeenCalled(); + expect(existsSync(oldDir)).toBe(true); + expect(existsSync(join(CASES_DIR(), 'beta'))).toBe(false); + expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(torn); + }); + it('says where the folder is when it cannot be moved back', async () => { const { app } = await createRouteTestHarness(registerCaseRoutes); const real = join(work, 'my-project'); @@ -328,12 +388,24 @@ describe('POST /api/cases/:name/rename (a linked case)', () => { mkdirSync(real); writeFileSync(LINKED(), JSON.stringify({ proj: real, other: join(work, 'x') })); writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['proj'] })); - chmodSync(SETTINGS_PATH, 0o444); - const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true }); - chmodSync(SETTINGS_PATH, 0o644); + // settings.json is replaced by renaming a temp file over it, which a read-only file does not stop, + // so the failure is injected at that rename. + const realRename = fs.rename; + const spy = vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => { + if (to === SETTINGS_PATH) throw Object.assign(new Error('permission denied'), { code: 'EACCES' }); + return realRename(from, to); + }); + let res; + try { + res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true }); + } finally { + spy.mockRestore(); + } expect(res.statusCode).toBe(500); expect(existsSync(real)).toBe(true); expect(linked()).toEqual({ proj: real, other: join(work, 'x') }); + // The failed write leaves no temp file behind. + expect(readdirSync(dirname(SETTINGS_PATH)).filter((n) => n.startsWith('settings.json.'))).toEqual([]); }); it('answers 404 for a linked case a non-admin cannot see in multi-user mode', async () => { diff --git a/test/routes/config-backup-routes.test.ts b/test/routes/config-backup-routes.test.ts index 3eb25547..854b2e6e 100644 --- a/test/routes/config-backup-routes.test.ts +++ b/test/routes/config-backup-routes.test.ts @@ -4,7 +4,7 @@ * * ⚠️ test/setup.ts gives the whole FILE one temp HOME (and so one data dir). Port: N/A (inject()). */ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -120,6 +120,22 @@ describe('/api/config-backups', () => { } }); + it('a restored discord.json reaches the running bridge (it caches its config)', async () => { + const reloadDiscord = vi.fn(async () => undefined); + const { app } = await createRouteTestHarness((a) => registerConfigBackupRoutes(a, { reloadDiscord })); + writeFileSync(join(dirname(SETTINGS_PATH), 'discord.json'), '{"enabled":false}', { mode: 0o600 }); + const id = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id; + writeFileSync(join(dirname(SETTINGS_PATH), 'discord.json'), '{"enabled":true}', { mode: 0o600 }); + const res = await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` }); + expect(res.json().data.restored).toContain('discord.json'); + expect(reloadDiscord).toHaveBeenCalledTimes(1); + // A restore that does not carry the file leaves the bridge alone. + rmSync(join(dirname(SETTINGS_PATH), 'discord.json'), { force: true }); + const id2 = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id; + await app.inject({ method: 'POST', url: `/api/config-backups/${id2}/restore` }); + expect(reloadDiscord).toHaveBeenCalledTimes(1); + }); + it('a restored intents.json is not reverted by the next captured prompt', async () => { const { app } = await createRouteTestHarness(registerConfigBackupRoutes); const dir = mkdtempSync(join(tmpdir(), 'cfg-backup-intent-')); diff --git a/test/routes/custom-model-routes.test.ts b/test/routes/custom-model-routes.test.ts index 80d5a5de..afadff5c 100644 --- a/test/routes/custom-model-routes.test.ts +++ b/test/routes/custom-model-routes.test.ts @@ -11,7 +11,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { registerCustomModelRoutes } from '../../src/web/routes/custom-model-routes.js'; import { webviewFetch } from '../../src/web/webview-egress.js'; -import { createRouteTestHarness } from './_route-test-utils.js'; +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; vi.mock('../../src/web/webview-egress.js', async () => { const actual = await vi.importActual<typeof import('../../src/web/webview-egress.js')>( @@ -417,3 +417,166 @@ describe('apiKey is never handed back to the browser', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); }); + +describe('context-length discovery from the /v1/models listing', () => { + // The gateway path (a Bifrost in front of llama-swap, an OpenRouter-style server): + // no /running, no /props, just a per-model `context_length` or `context_window` in the + // model listing. Distinct ports per test keep the file's shared data dir unambiguous. + afterEach(() => { + fetchMock.mockReset(); + }); + + type StoredHost = { id: string; modelContextLengths?: Record<string, number>; defaultContextLength?: number }; + + type HarnessApp = RouteTestHarness['app']; + + async function endpointWith(id: string, port: number, extra: Record<string, unknown> = {}): Promise<HarnessApp> { + const { app } = await setup(); + const res = await app.inject({ + method: 'POST', + url: '/api/model-endpoints', + payload: { id, label: 'A', baseUrl: `http://localhost:${port}`, ...extra }, + }); + expect(res.statusCode).toBe(200); + return app; + } + + async function storedHost(app: HarnessApp, id: string): Promise<StoredHost | undefined> { + const list = await app.inject({ method: 'GET', url: '/api/model-endpoints' }); + return (list.json() as StoredHost[]).find((h) => h.id === id); + } + + it('reads context_length (preferred) then context_window from each entry into modelContextLengths', async () => { + const app = await endpointWith('ep-ctx-listed', 8101); + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + data: [ + { id: 'both', context_length: 131072, context_window: 32768 }, + { id: 'window-only', context_window: 8192 }, + { id: 'no-figure' }, + ], + }), + { status: 200 } + ) + ); + + const discover = await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-listed/discover-models' }); + expect(discover.json().success).toBe(true); + + const stored = await storedHost(app, 'ep-ctx-listed'); + expect(stored?.modelContextLengths).toEqual({ both: 131072, 'window-only': 8192 }); + }); + + it('ignores non-numeric, zero, negative and absurd listed values; the schema bound itself is still accepted', async () => { + const app = await endpointWith('ep-ctx-bad', 8102); + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + data: [ + { id: 'string', context_length: '131072' }, + { id: 'zero', context_window: 0 }, + { id: 'negative', context_length: -10 }, + { id: 'over-bound', context_length: 100_000_001 }, + { id: 'fractional', context_window: 16384.5 }, + { id: 'at-bound', context_length: 100_000_000 }, + ], + }), + { status: 200 } + ) + ); + + await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-bad/discover-models' }); + + const stored = await storedHost(app, 'ep-ctx-bad'); + expect(stored?.modelContextLengths).toEqual({ 'at-bound': 100_000_000 }); + }); + + it('prefers the /running launch command over the listed figure for a loaded model', async () => { + const app = await endpointWith('ep-ctx-cmd', 8103); + fetchMock.mockImplementation(async (url: URL) => { + if (url.pathname === '/v1/models') { + return new Response( + JSON.stringify({ data: [{ id: 'cmd-wins', status: { value: 'loaded' }, context_length: 131072 }] }), + { status: 200 } + ); + } + if (url.pathname === '/running') { + return new Response( + JSON.stringify({ running: [{ model: 'cmd-wins', state: 'ready', cmd: 'llama-server --ctx-size 16384' }] }), + { status: 200 } + ); + } + if (url.pathname === '/props') return new Response('nope', { status: 404 }); + throw new Error(`unexpected request in this test: ${url.href}`); + }); + + await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-cmd/discover-models' }); + + const stored = await storedHost(app, 'ep-ctx-cmd'); + expect(stored?.modelContextLengths).toEqual({ 'cmd-wins': 16384 }); + }); + + it('keeps a previously learned value the fresh listing says nothing about, and discovery never touches defaultContextLength', async () => { + const app = await endpointWith('ep-ctx-keep', 8104, { + models: ['re-listed', 'untouched'], + modelContextLengths: { 're-listed': 16384, untouched: 4096 }, + defaultContextLength: 65536, + }); + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ data: [{ id: 're-listed', context_length: 32768 }, { id: 'untouched' }] }), + { status: 200 } + ) + ); + + await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-keep/discover-models' }); + + const stored = await storedHost(app, 'ep-ctx-keep'); + expect(stored?.modelContextLengths).toEqual({ 're-listed': 32768, untouched: 4096 }); + expect(stored?.defaultContextLength).toBe(65536); + }); +}); + +describe('defaultContextLength (the user-set fallback discovery never overwrites)', () => { + afterEach(() => { + fetchMock.mockReset(); + }); + + it('stores it on create, updates it on PUT, and a PUT without it clears it', async () => { + const { app } = await setup(); + const create = await app.inject({ + method: 'POST', + url: '/api/model-endpoints', + payload: { id: 'ep-defctx', label: 'A', baseUrl: 'http://localhost:8105', defaultContextLength: 131072 }, + }); + expect(create.json().data.host.defaultContextLength).toBe(131072); + + const update = await app.inject({ + method: 'PUT', + url: '/api/model-endpoints/ep-defctx', + payload: { label: 'A', baseUrl: 'http://localhost:8105', defaultContextLength: 32768 }, + }); + expect(update.json().data.host.defaultContextLength).toBe(32768); + + const cleared = await app.inject({ + method: 'PUT', + url: '/api/model-endpoints/ep-defctx', + payload: { label: 'A', baseUrl: 'http://localhost:8105' }, + }); + expect(cleared.json().data.host.defaultContextLength).toBeUndefined(); + }); + + it('rejects zero, negative, fractional, over-the-bound and non-numeric values', async () => { + const { app } = await setup(); + for (const defaultContextLength of [0, -1, 1.5, 100_000_001, '131072']) { + const res = await app.inject({ + method: 'POST', + url: '/api/model-endpoints', + payload: { id: 'ep-defctx-bad', label: 'A', baseUrl: 'http://localhost:8106', defaultContextLength }, + }); + expect(res.json().success, String(defaultContextLength)).toBe(false); + expect(res.json().errorCode, String(defaultContextLength)).toBe('INVALID_INPUT'); + } + }); +}); diff --git a/test/routes/discord-last-message-per-cli.test.ts b/test/routes/discord-last-message-per-cli.test.ts new file mode 100644 index 00000000..9926ec43 --- /dev/null +++ b/test/routes/discord-last-message-per-cli.test.ts @@ -0,0 +1,196 @@ +/** + * @fileoverview The last message a CLI wrote, read through the response viewer's reader (the one the + * Discord bridge posts from), with a message that asks SEVERAL questions: every question must come + * back whole, in order, on its own line, and survive the split into Discord posts. + * + * Uses app.inject() with a temporary HOME and CODEX_HOME; no real ports or user transcripts. + * Port: N/A + * + * Covered here: claude (JSONL transcript), codex (rollout), the codex-shaped pane CLIs (opencode, + * gemini, antigravity, pi) and copilot (its own pane dialect). deepseek and omp read their own + * transcript formats and are covered by their reader's tests. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; +import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; +import { splitForDiscord } from '../../src/discord-bridge.js'; +import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; + +const QUESTIONS = [ + 'Should the sweep run daily or hourly?', + 'Do you want archived threads deleted as well?', + 'Is one channel per Codeman acceptable?', +]; +const INTRO = 'I have three questions before I continue:'; +const OUTRO = 'Once you answer, I will apply the change.'; + +/** The message as the CLI wrote it: an intro, a numbered list of questions, an outro. */ +const MESSAGE = [INTRO, '', ...QUESTIONS.map((q, i) => `${i + 1}. ${q}`), '', OUTRO].join('\n'); + +/** Every question present, in order, each on a line of its own. */ +function expectWholeQuestions(text: string): void { + let from = 0; + for (const q of QUESTIONS) { + const at = text.indexOf(q, from); + expect(at, `missing or out of order: ${q}`).toBeGreaterThanOrEqual(from); + from = at + q.length; + } + const lines = text.split('\n').map((l) => l.trim()); + for (const [i, q] of QUESTIONS.entries()) expect(lines.some((l) => l.endsWith(q))).toBe(true); + expect(text).toContain(INTRO); + expect(text).toContain(OUTRO); + // What goes to Discord: the same questions after the split, joined back. + expectWholeQuestionsAfterSplit(text); +} + +function expectWholeQuestionsAfterSplit(text: string): void { + const joined = splitForDiscord(text).join('\n'); + for (const q of QUESTIONS) expect(joined).toContain(q); +} + +async function createHarness(): Promise<{ app: FastifyInstance; ctx: MockRouteContext }> { + const app = Fastify({ logger: false }); + await app.register(fastifyCookie); + const ctx = createMockRouteContext(); + registerSessionRoutes(app, ctx); + app.addHook('preSerialization', (req, _reply, payload: unknown, done) => { + if (!req.url.startsWith('/api') || payload === null || typeof payload !== 'object') return done(null, payload); + const p = payload as { success?: unknown }; + return done(null, p.success === undefined ? { success: true, data: payload } : payload); + }); + installRouteErrorHandler(app); + await app.ready(); + return { app, ctx }; +} + +describe('the last message with several questions, per CLI', () => { + let harness: { app: FastifyInstance; ctx: MockRouteContext }; + let home: string; + let prevHome: string | undefined; + let prevCodexHome: string | undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let session: any; + + async function lastText(): Promise<string> { + const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${session.id}/last-response` }); + return JSON.parse(res.body).data.text as string; + } + + beforeEach(async () => { + home = mkdtempSync(join(tmpdir(), 'codeman-discord-last-')); + prevHome = process.env.HOME; + prevCodexHome = process.env.CODEX_HOME; + process.env.HOME = home; + process.env.CODEX_HOME = join(home, '.codex'); + harness = await createHarness(); + session = harness.ctx._session; + }); + + afterEach(async () => { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = prevCodexHome; + rmSync(home, { recursive: true, force: true }); + await harness.app.close(); + }); + + it('claude: reads the whole message from the JSONL transcript', async () => { + const id = '40568a29-d4eb-4eb6-b671-8401428e4f39'; + session.mode = 'claude'; + session.claudeSessionId = id; + session.adoptClaudeSessionId = () => undefined; + const dir = join(home, '.claude', 'projects', '-workspace'); + mkdirSync(dir, { recursive: true }); + const row = (type: string, text: string, ts: string) => ({ + type, + timestamp: ts, + message: { content: [{ type: 'text', text }] }, + }); + writeFileSync( + join(dir, `${id}.jsonl`), + [row('user', 'plan the cleanup', '2026-10-30T00:00:00Z'), row('assistant', MESSAGE, '2026-10-30T00:00:01Z')] + .map((e) => JSON.stringify(e)) + .join('\n') + ); + const text = await lastText(); + expect(text).toBe(MESSAGE); + expectWholeQuestions(text); + }); + + it('codex: reads the whole message from the rollout', async () => { + const uuid = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + const workdir = join(home, 'work'); + mkdirSync(workdir, { recursive: true }); + session.mode = 'codex'; + session.workingDir = workdir; + session.codexConfig = { resumeSessionId: uuid }; + const dir = join(home, '.codex', 'sessions', '2026', '10', '30'); + mkdirSync(dir, { recursive: true }); + const file = join(dir, `rollout-2026-10-30T00-00-00-${uuid}.jsonl`); + const entries = [ + { + timestamp: '2026-10-30T00:00:00Z', + type: 'session_meta', + payload: { id: uuid, cwd: workdir, originator: 'codex_cli_rs' }, + }, + { + timestamp: '2026-10-30T00:00:01Z', + type: 'event_msg', + payload: { type: 'user_message', message: 'plan the cleanup' }, + }, + { + timestamp: '2026-10-30T00:00:02Z', + type: 'response_item', + payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: MESSAGE }] }, + }, + ]; + writeFileSync(file, entries.map((e) => JSON.stringify(e)).join('\n') + '\n'); + utimesSync(file, 1_750_000_000, 1_750_000_000); + const text = await lastText(); + expectWholeQuestions(text); + }); + + for (const mode of ['opencode', 'gemini', 'antigravity', 'pi'] as const) { + it(`${mode}: reads the whole message from the pane`, async () => { + session.mode = mode; + session.terminalBuffer = [ + '› plan the cleanup', + '', + 'model · project · main · Ready · Context 100% left', + '', + '• Called write_file', + '', + MESSAGE, + ].join('\n'); + expectWholeQuestions(await lastText()); + }); + } + + it('copilot: reads the whole message from the pane screen', async () => { + session.mode = 'copilot'; + const screen = [ + ' ❯ plan the cleanup 10:01', + '', + ` ● ${INTRO}`, + '', + ...QUESTIONS.map((q, i) => ` ${i + 1}. ${q}`), + '', + ` ${OUTRO}`, + '', + ' ~/project Session: 1 AIC used', + '────────────────────────────────', + ' ❯ ', + '────────────────────────────────', + ' ← open sidebar · / commands · ? help', + ].join('\n'); + harness.ctx.mux = { ...(harness.ctx.mux as object), capturePaneText: () => screen } as typeof harness.ctx.mux; + expectWholeQuestions(await lastText()); + }); +}); diff --git a/test/routes/discord-routes.test.ts b/test/routes/discord-routes.test.ts new file mode 100644 index 00000000..48da64e4 --- /dev/null +++ b/test/routes/discord-routes.test.ts @@ -0,0 +1,354 @@ +/** + * @fileoverview /api/discord: the bot token is a bearer secret, so it is never returned and the + * settings routes are admin only in multi-user mode. Port: N/A (app.inject()). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { createRouteTestHarness } from './_route-test-utils.js'; +import { _resetNotifyBuckets, registerDiscordRoutes } from '../../src/web/routes/discord-routes.js'; +import { DiscordBridge, discordConfigPath, readDiscordConfig, type DiscordFetch } from '../../src/discord-bridge.js'; + +const TOKEN = 'MTA1.secret.token-value-1234567890'; +const FULL = { + enabled: true, + token: TOKEN, + guildId: '111111111111111111', + parentChannelId: '222222222222222222', + allowedUserIds: ['333333333333333333'], +}; + +let dir: string; +let fetchImpl: ReturnType<typeof vi.fn<DiscordFetch>>; + +async function harness(authUser?: { username: string; role: 'admin' | 'user' }) { + const bridge = new DiscordBridge({ + configDir: dir, + fetchImpl, + sendInput: async () => true, + socketFactory: () => ({ send() {}, close() {}, on() {} }), + }); + const h = await createRouteTestHarness( + (app, ctx) => registerDiscordRoutes(app, { bridge, configDir: dir, ctx, hostTitle: () => 'codeman:test' }), + authUser ? { authUser } : undefined + ); + return { ...h, bridge }; +} + +beforeEach(() => { + _resetNotifyBuckets(); + dir = mkdtempSync(join(tmpdir(), 'discord-routes-')); + fetchImpl = vi.fn<DiscordFetch>( + async () => new Response(JSON.stringify({ id: '444444444444444444' }), { status: 200 }) + ); +}); +afterEach(() => { + delete process.env.CODEMAN_MULTIUSER; + rmSync(dir, { recursive: true, force: true }); +}); + +describe('/api/discord', () => { + it('starts disabled with no token', async () => { + const { app } = await harness(); + const data = (await app.inject({ method: 'GET', url: '/api/discord' })).json().data; + expect(data).toMatchObject({ enabled: false, hasToken: false, tokenMasked: '', gateway: 'off' }); + expect(data).not.toHaveProperty('token'); + }); + + it('saves, masks the token in every response and writes the file 0600', async () => { + const { app, bridge } = await harness(); + const put = await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL }); + expect(put.statusCode).toBe(200); + expect(put.body).not.toContain('secret'); + expect(put.json().data).toMatchObject({ enabled: true, hasToken: true }); + const get = await app.inject({ method: 'GET', url: '/api/discord' }); + expect(get.body).not.toContain('secret'); + expect(statSync(discordConfigPath(dir)).mode & 0o777).toBe(0o600); + expect((await readDiscordConfig(dir)).token).toBe(TOKEN); + bridge.stop(); + }); + + it('keeps the token when only an option changes, and clears it on an empty string', async () => { + const { app, bridge } = await harness(); + await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL }); + await app.inject({ + method: 'PUT', + url: '/api/discord', + payload: { pingMode: 'all', onSessionDelete: 'delete', staleThreads: 'archive' }, + }); + const cfg = await readDiscordConfig(dir); + expect(cfg).toMatchObject({ token: TOKEN, pingMode: 'all', onSessionDelete: 'delete', staleThreads: 'archive' }); + const cleared = await app.inject({ method: 'PUT', url: '/api/discord', payload: { enabled: false, token: '' } }); + expect(cleared.json().data.hasToken).toBe(false); + bridge.stop(); + }); + + it('stores startupHint (default on) and returns it', async () => { + const { app, bridge } = await harness(); + const first = (await app.inject({ method: 'GET', url: '/api/discord' })).json().data; + expect(first).toMatchObject({ startupHint: true }); + const put = await app.inject({ + method: 'PUT', + url: '/api/discord', + payload: { startupHint: false }, + }); + expect(put.json().data).toMatchObject({ startupHint: false }); + bridge.stop(); + }); + + it('refuses a bad id, an incomplete enable, and unknown fields', async () => { + const { app } = await harness(); + const bad = await app.inject({ method: 'PUT', url: '/api/discord', payload: { guildId: 'not-a-number' } }); + expect(bad.statusCode).toBe(400); + const incomplete = await app.inject({ method: 'PUT', url: '/api/discord', payload: { enabled: true } }); + expect(incomplete.statusCode).toBe(400); + const extra = await app.inject({ method: 'PUT', url: '/api/discord', payload: { nope: 1 } }); + expect(extra.statusCode).toBe(400); + }); + + it('POST /test needs a saved token, then posts to the parent channel', async () => { + const { app, bridge } = await harness(); + expect((await app.inject({ method: 'POST', url: '/api/discord/test' })).statusCode).toBe(400); + await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL }); + const res = await app.inject({ method: 'POST', url: '/api/discord/test' }); + expect(res.json().data.ok).toBe(true); + expect(String(fetchImpl.mock.calls.at(-1)![0])).toContain('/channels/222222222222222222/messages'); + bridge.stop(); + }); + + it('is admin only in multi-user mode', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const { app } = await harness({ username: 'bob', role: 'user' }); + expect((await app.inject({ method: 'GET', url: '/api/discord' })).statusCode).toBe(403); + expect((await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL })).statusCode).toBe(403); + expect((await app.inject({ method: 'POST', url: '/api/discord/test' })).statusCode).toBe(403); + }); +}); + +describe('POST /api/sessions/:id/discord-notify', () => { + const url = '/api/sessions/test-session-1/discord-notify'; + it('is refused while the bridge is off, and posts into the session thread once on', async () => { + const { app, bridge } = await harness(); + const url = `/api/sessions/test-session-1/discord-notify`; + const off = await app.inject({ method: 'POST', url, payload: { message: 'press reset' } }); + expect(off.statusCode).toBe(409); + await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL }); + const on = await app.inject({ method: 'POST', url, payload: { message: 'press reset', urgent: true } }); + expect(on.json().data.ok).toBe(true); + const sent = fetchImpl.mock.calls.map((c) => String(c[1].body)); + expect(sent.some((b) => b.includes('press reset') && b.includes('<@333333333333333333>'))).toBe(true); + bridge.stop(); + }); + + it('accepts a unique 8-character session id prefix (Docker exports it cut), not an ambiguous or short one', async () => { + const { app, bridge, ctx } = await harness(); + await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL }); + const ok = await app.inject({ + method: 'POST', + url: '/api/sessions/test-ses/discord-notify', + payload: { message: 'hi' }, + }); + expect(ok.statusCode).toBe(200); + ctx.sessions.set('test-session-2', ctx.sessions.get('test-session-1')!); + const ambiguous = await app.inject({ + method: 'POST', + url: '/api/sessions/test-ses/discord-notify', + payload: { message: 'hi' }, + }); + expect(ambiguous.statusCode).toBe(404); + const short = await app.inject({ + method: 'POST', + url: '/api/sessions/test/discord-notify', + payload: { message: 'hi' }, + }); + expect(short.statusCode).toBe(404); + bridge.stop(); + }); + + it("does not reveal another user's session through its id prefix (multi-user)", async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const { app, bridge, ctx } = await harness({ username: 'bob', role: 'user' }); + (ctx.sessions.get('test-session-1') as unknown as { owner: string }).owner = 'alice'; + const res = await app.inject({ + method: 'POST', + url: '/api/sessions/test-ses/discord-notify', + payload: { message: 'hi' }, + }); + expect(res.statusCode).toBe(404); + expect(res.json().error).not.toContain('test-session-1'); + bridge.stop(); + }); + + it('lets two different deliberate messages from one tab through within 3 s (neither is lost)', async () => { + const { app, bridge } = await harness(); + await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL }); + const first = await app.inject({ method: 'POST', url, payload: { message: 'first thing' } }); + const second = await app.inject({ method: 'POST', url, payload: { message: 'second thing' } }); + expect(first.statusCode).toBe(200); + expect(second.statusCode).toBe(200); + expect(second.json().data.deduped).toBeUndefined(); + const sent = fetchImpl.mock.calls.map((c) => String(c[1].body)); + expect(sent.filter((b) => b.includes('first thing') || b.includes('second thing'))).toHaveLength(2); + bridge.stop(); + }); + + it('answers 200 with data.ok false when Discord refuses the post, which the CLI turns into exit 1', async () => { + const { app, bridge } = await harness(); + await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL }); + fetchImpl.mockImplementation(async () => new Response(JSON.stringify({ code: 0 }), { status: 401 })); + const res = await app.inject({ method: 'POST', url, payload: { message: 'help' } }); + expect(res.statusCode).toBe(200); + expect(res.json().data).toMatchObject({ ok: false, status: 401 }); + bridge.stop(); + }); + + it('answers 429 RATE_LIMITED once a tab posts more than its share in a minute', async () => { + const { app, bridge } = await harness(); + await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL }); + const codes: number[] = []; + for (let i = 0; i < 8; i++) { + codes.push( + (await app.inject({ method: 'POST', url, payload: { message: `ping ${i}`, urgent: true } })).statusCode + ); + } + expect(codes.slice(0, 6)).toEqual([200, 200, 200, 200, 200, 200]); + expect(codes.slice(6)).toEqual([429, 429]); + const limited = await app.inject({ method: 'POST', url, payload: { message: 'again' } }); + expect(limited.json().errorCode).toBe('RATE_LIMITED'); + expect(limited.headers['retry-after']).toBe('60'); + bridge.stop(); + }); + + it('does not spend rate-limit tokens on a request refused for its file', async () => { + const { app, bridge } = await harness(); + await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL }); + for (let i = 0; i < 8; i++) { + const bad = await app.inject({ method: 'POST', url, payload: { message: 'x', file: 'no-such-file.txt' } }); + expect(bad.statusCode).toBe(400); + } + expect((await app.inject({ method: 'POST', url, payload: { message: 'still allowed' } })).statusCode).toBe(200); + bridge.stop(); + }); + + it('rejects an empty or oversized message', async () => { + const { app } = await harness(); + const url = `/api/sessions/test-session-1/discord-notify`; + expect((await app.inject({ method: 'POST', url, payload: { message: '' } })).statusCode).toBe(400); + expect((await app.inject({ method: 'POST', url, payload: { message: 'x'.repeat(2000) } })).statusCode).toBe(400); + }); +}); + +describe('discord-notify with a file', () => { + let work: string; + let outside: string; + const url = '/api/sessions/test-session-1/discord-notify'; + + async function ready() { + const h = await harness(); + (h.ctx.sessions.get('test-session-1') as unknown as { workingDir: string }).workingDir = work; + await h.app.inject({ method: 'PUT', url: '/api/discord', payload: FULL }); + return h; + } + const post = (app: Awaited<ReturnType<typeof harness>>['app'], file: string) => + app.inject({ method: 'POST', url, payload: { message: 'log attached', file } }); + + beforeEach(() => { + work = mkdtempSync(join(tmpdir(), 'discord-work-')); + outside = mkdtempSync(join(tmpdir(), 'discord-outside-')); + writeFileSync(join(work, 'run.log'), 'hello'); + writeFileSync(join(outside, 'secret.txt'), 'nope'); + }); + afterEach(() => { + rmSync(work, { recursive: true, force: true }); + rmSync(outside, { recursive: true, force: true }); + }); + + it('uploads a file inside the session folder as multipart', async () => { + const { app, bridge } = await ready(); + const res = await post(app, join(work, 'run.log')); + expect(res.json().data.ok).toBe(true); + const form = fetchImpl.mock.calls.map((c) => c[1].body).find((b) => b instanceof FormData) as FormData; + expect((form.get('files[0]') as File).name).toBe('run.log'); + bridge.stop(); + }); + + it.each([ + ['a relative path', () => 'run.log'], + ['a file outside the folder', () => join(outside, 'secret.txt')], + ['the folder itself', () => work], + ['a missing file', () => join(work, 'nope.log')], + ])('refuses %s', async (_n, path) => { + const { app, bridge } = await ready(); + const res = await post(app, path()); + expect(res.statusCode).toBe(400); + expect(fetchImpl.mock.calls.some((c) => c[1].body instanceof FormData)).toBe(false); + bridge.stop(); + }); + + it.each([ + ['a .env file', () => ['.env', 'TOKEN=x']], + ['a private key under .ssh', () => ['.ssh/id_ed25519', 'key']], + ['Codeman state with the bot token', () => ['.codeman/discord.json', '{}']], + ])('refuses %s even inside the session folder, as with a home directory', async (_n, entry) => { + const [rel, body] = entry(); + mkdirSync(dirname(join(work, rel)), { recursive: true }); + writeFileSync(join(work, rel), body); + const { app, bridge } = await ready(); + const res = await post(app, join(work, rel)); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/credentials/); + expect(fetchImpl.mock.calls.some((c) => c[1].body instanceof FormData)).toBe(false); + bridge.stop(); + }); + + it('refuses a symlink inside the folder that points at a blocked file', async () => { + writeFileSync(join(work, '.env'), 'TOKEN=x'); + symlinkSync(join(work, '.env'), join(work, 'notes.txt')); + const { app, bridge } = await ready(); + const res = await post(app, join(work, 'notes.txt')); + expect(res.statusCode).toBe(400); + expect(fetchImpl.mock.calls.some((c) => c[1].body instanceof FormData)).toBe(false); + bridge.stop(); + }); + + it('refuses to send when the session folder is not a directory it can probe', async () => { + const { app, ctx, bridge } = await ready(); + (ctx.sessions.get('test-session-1') as unknown as { workingDir: string }).workingDir = join(work, 'run.log'); + const res = await post(app, join(work, 'run.log')); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/session folder/); + bridge.stop(); + }); + + it('probes the file before realpath: a path that is not a regular file is refused', async () => { + const { app, bridge } = await ready(); + mkdirSync(join(work, 'dir')); + expect((await post(app, join(work, 'dir'))).json().error).toMatch(/Not a regular file/); + bridge.stop(); + }); + + it('refuses a symlink that points outside the folder', async () => { + const { app, bridge } = await ready(); + mkdirSync(join(work, 'sub')); + symlinkSync(join(outside, 'secret.txt'), join(work, 'sub', 'link.txt')); + expect((await post(app, join(work, 'sub', 'link.txt'))).statusCode).toBe(400); + bridge.stop(); + }); + + it('refuses a file over 8 MB', async () => { + const { app, bridge } = await ready(); + writeFileSync(join(work, 'big.bin'), Buffer.alloc(8 * 1024 * 1024 + 1)); + const res = await post(app, join(work, 'big.bin')); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/8 MB/); + bridge.stop(); + }); + + it('refuses a file for a remote session', async () => { + const { app, bridge, ctx } = await ready(); + (ctx.sessions.get('test-session-1') as unknown as { remote: object }).remote = { label: 'box' }; + expect((await post(app, join(work, 'run.log'))).statusCode).toBe(400); + bridge.stop(); + }); +}); diff --git a/test/routes/edit-history-routes.test.ts b/test/routes/edit-history-routes.test.ts new file mode 100644 index 00000000..2b67529b --- /dev/null +++ b/test/routes/edit-history-routes.test.ts @@ -0,0 +1,288 @@ +/** + * @fileoverview The edit history panel's API (GET/POST /api/sessions/:id/edits…): a real + * EditHistorySync and store over a fake transcript reader. Port: N/A (app.inject()). + */ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRouteTestHarness } from './_route-test-utils.js'; +import { hunksToUnified, registerEditHistoryRoutes, relativeTo } from '../../src/web/routes/edit-history-routes.js'; +import { EditHistorySync, NO_TRANSCRIPT, type EditHistorySyncDeps } from '../../src/web/edit-history-sync.js'; +import { EditHistoryStore } from '../../src/edit-history-store.js'; +import type { EditSources } from '../../src/edit-history-local-source.js'; + +const T0 = Date.parse('2026-10-15T20:00:00.000Z'); +let dir: string; +let session: Record<string, unknown>; + +function editLine(id: string, ts: number, path: string, extra: Record<string, unknown> = {}): string { + return ( + JSON.stringify({ + type: 'user', + timestamp: new Date(ts).toISOString(), + cwd: '/w', + message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] }, + toolUseResult: { + filePath: path, + oldString: 'a', + newString: 'b', + structuredPatch: [{ oldStart: 4, oldLines: 1, newStart: 4, newLines: 2, lines: ['-a', '+b', '+c'] }], + }, + ...extra, + }) + '\n' + ); +} + +function sources(main: string, sub?: string): EditSources { + const files = [{ relPath: 'main', size: Buffer.byteLength(main), startOffset: 0, chunk: Buffer.from(main) }]; + const metas: Record<string, string> = {}; + if (sub) { + files.push({ + relPath: 'subagents/agent-a1.jsonl', + size: Buffer.byteLength(sub), + startOffset: 0, + chunk: Buffer.from(sub), + }); + metas.a1 = JSON.stringify({ + description: 'Fix it', + agentType: 'coding', + worktreePath: '/w/.claude/worktrees/agent-a1', + worktreeBranch: 'worktree-agent-a1', + }); + } + return { transcriptId: 'conv1', files, metas }; +} + +async function setup( + deps: Partial<EditHistorySyncDeps> = {}, + opts: { authUser?: { username: string; role: 'admin' | 'user' } } = {} +) { + const read = vi.fn(async () => sources(editLine('t1', T0, '/w/src/a.ts'))); + const sync = new EditHistorySync({ + transcriptPath: async () => '/p/conv1.jsonl', + readSources: read, + probe: async (p) => p.map(() => null), + loadStore: async (id) => { + const store = await EditHistoryStore.load(id, { dir, debounceMs: 0 }); + opened.push(store); + return store; + }, + deleteStore: (id) => EditHistoryStore.delete(id, { dir }), + sweepStores: async () => 0, + ...deps, + }); + const h = await createRouteTestHarness((app, ctx) => registerEditHistoryRoutes(app, ctx, sync), { + authUser: opts.authUser, + }); + session = h.ctx._session as unknown as Record<string, unknown>; + session.workingDir = '/w'; + session.mode = 'claude'; + session.claudeSessionId = 'conv1'; + return { ...h, read, sync }; +} + +/** Stores the tests opened; removed before the temp dir, so no late save recreates it. */ +const opened: EditHistoryStore[] = []; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'edit-routes-')); +}); +afterEach(async () => { + delete process.env.CODEMAN_MULTIUSER; + await Promise.all(opened.splice(0).map((s) => s.remove())); + rmSync(dir, { recursive: true, force: true }); +}); + +const URL = '/api/sessions/test-session-1/edits'; + +describe('GET /api/sessions/:id/edits', () => { + it('syncs on ?sync=1 and lists entries without diffs, paths relative to the workspace', async () => { + const { app } = await setup(); + const res = await app.inject({ method: 'GET', url: `${URL}?sync=1` }); + expect(res.statusCode).toBe(200); + const data = res.json().data; + expect(data).toMatchObject({ state: 'ok', backfillTruncated: false, agents: {} }); + expect(data.entries).toEqual([ + expect.objectContaining({ + id: 't1', + path: '/w/src/a.ts', + relPath: 'src/a.ts', + tool: 'Edit', + added: 2, + removed: 1, + }), + ]); + expect(data.entries[0]).not.toHaveProperty('hunks'); + }); + + it('starts the sync before it loads the store (a deletion right after the session check stops the sync)', async () => { + const { app, sync } = await setup(); + const calls: string[] = []; + const syncFn = sync.sync.bind(sync); + const getStore = sync.getStore.bind(sync); + vi.spyOn(sync, 'sync').mockImplementation((...a) => (calls.push('sync'), syncFn(...a))); + vi.spyOn(sync, 'getStore').mockImplementation((...a) => (calls.push('getStore'), getStore(...a))); + await app.inject({ method: 'GET', url: `${URL}?sync=1` }); + await app.inject({ method: 'POST', url: `${URL}/older` }); + expect(calls).toEqual(['sync', 'getStore', 'sync', 'getStore']); + }); + + it('without sync=1 answers from the store and does not read', async () => { + const { app, read } = await setup(); + const res = await app.inject({ method: 'GET', url: URL }); + expect(res.json().data).toMatchObject({ state: 'ok', entries: [] }); + expect(read).not.toHaveBeenCalled(); + }); + + it('labels subagent entries and shows their paths relative to the worktree', async () => { + const sub = editLine('t9', T0 + 5, '/w/.claude/worktrees/agent-a1/lib/x.ts', { isSidechain: true, agentId: 'a1' }); + const read = vi.fn(async () => sources(editLine('t1', T0, '/w/src/a.ts'), sub)); + const { app } = await setup({ readSources: read }); + const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data; + expect(data.agents).toEqual({ + a1: { description: 'Fix it', agentType: 'coding', worktreeBranch: 'worktree-agent-a1' }, + }); + expect(data.entries.find((e: { id: string }) => e.id === 't9')).toMatchObject({ + agentId: 'a1', + relPath: 'lib/x.ts', + }); + }); + + it('a read error answers unavailable with the stored entries', async () => { + let fail = false; + const read = vi.fn(async () => { + if (fail) throw new Error('EACCES: permission denied'); + return sources(editLine('t1', T0, '/w/src/a.ts')); + }); + const { app } = await setup({ readSources: read }); + await app.inject({ method: 'GET', url: `${URL}?sync=1` }); + fail = true; + const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data; + expect(data).toMatchObject({ state: 'unavailable', reason: 'EACCES: permission denied' }); + expect(data.entries).toHaveLength(1); + }); + + it('hands the reader the session ids and its own CLAUDE_CONFIG_DIR', async () => { + const seen: unknown[] = []; + const { app } = await setup({ + transcriptPath: async (s) => { + seen.push({ id: s.id, claudeSessionId: s.claudeSessionId, claudeConfigDir: s.claudeConfigDir }); + return '/p/conv1.jsonl'; + }, + }); + session.claudeConfigDirOverride = '/cfg/alt '; + await app.inject({ method: 'GET', url: `${URL}?sync=1` }); + expect(seen).toEqual([{ id: 'test-session-1', claudeSessionId: 'conv1', claudeConfigDir: '/cfg/alt' }]); + }); + + it('a session without a transcript yet answers unavailable (no-transcript)', async () => { + const { app, read } = await setup({ transcriptPath: async () => null }); + const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data; + expect(data).toMatchObject({ state: 'unavailable', reason: NO_TRANSCRIPT, entries: [] }); + expect(read).not.toHaveBeenCalled(); + }); + + it.each([ + ['remote', { remote: { host: 'huff' } }], + ['docker', { docker: { image: 'x' } }], + ['mode', { mode: 'shell' }], + ])('answers unsupported (%s) without reading or loading a store', async (reason, patch) => { + const loadStore = vi.fn(async (id: string) => EditHistoryStore.load(id, { dir, debounceMs: 0 })); + const { app, read } = await setup({ loadStore }); + Object.assign(session, patch); + const empty = { state: 'unsupported', reason, backfillTruncated: false, agents: {}, entries: [] }; + for (const url of [URL, `${URL}?sync=1`]) { + expect((await app.inject({ method: 'GET', url })).json().data).toEqual(empty); + } + expect((await app.inject({ method: 'POST', url: `${URL}/older` })).json().data).toEqual(empty); + expect((await app.inject({ method: 'GET', url: `${URL}/t1` })).statusCode).toBe(404); + expect(read).not.toHaveBeenCalled(); + expect(loadStore).not.toHaveBeenCalled(); + }); + + it('404s an unknown session', async () => { + const { app } = await setup(); + expect((await app.inject({ method: 'GET', url: '/api/sessions/nope/edits' })).statusCode).toBe(404); + }); + + it('multi-user: another user’s session is not found and nothing is read', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const { app, read } = await setup({}, { authUser: { username: 'bob', role: 'user' } }); + session.owner = 'alice'; + const res = await app.inject({ method: 'GET', url: `${URL}?sync=1` }); + expect(res.statusCode).toBe(404); + expect(read).not.toHaveBeenCalled(); + }); +}); + +describe('GET /api/sessions/:id/edits/:entryId', () => { + it('returns the diff as unified text', async () => { + const { app } = await setup(); + await app.inject({ method: 'GET', url: `${URL}?sync=1` }); + const res = await app.inject({ method: 'GET', url: `${URL}/t1` }); + expect(res.json().data).toEqual({ diff: '@@ -4,1 +4,2 @@\n-a\n+b\n+c', truncated: false }); + }); + + it('404s an edit the session does not have', async () => { + const { app } = await setup(); + expect((await app.inject({ method: 'GET', url: `${URL}/missing` })).statusCode).toBe(404); + }); + + it('multi-user: another user’s diff is not found', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const { app } = await setup({}, { authUser: { username: 'bob', role: 'user' } }); + await app.inject({ method: 'GET', url: `${URL}?sync=1` }); + session.owner = 'alice'; + expect((await app.inject({ method: 'GET', url: `${URL}/t1` })).statusCode).toBe(404); + }); +}); + +describe('POST /api/sessions/:id/edits/older', () => { + it('reads the next older block and lists the result', async () => { + const lines = [0, 1, 2, 3].map((i) => editLine(`t${i}`, T0 + i, `/w/${i}.ts`)).join(''); + const full = Buffer.from(lines); + const read = vi.fn( + async (_path: string, requests: readonly { relPath: string; offset: number; len?: number }[], budget: number) => { + const req = requests.find((r) => r.relPath === 'main'); + const start = req ? req.offset : full.length - budget; + const len = req?.len ?? full.length - start; + return { + transcriptId: 'conv1', + metas: {}, + files: [{ relPath: 'main', size: full.length, startOffset: start, chunk: full.subarray(start, start + len) }], + }; + } + ); + const { app } = await setup({ readSources: read, budget: 400 }); + const first = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data; + expect(first.backfillTruncated).toBe(true); + const older = (await app.inject({ method: 'POST', url: `${URL}/older` })).json().data; + expect(older.entries.length).toBeGreaterThan(first.entries.length); + }); + + it('answers unsupported for a remote session without reading', async () => { + const { app, read } = await setup(); + session.remote = { host: 'huff' }; + const data = (await app.inject({ method: 'POST', url: `${URL}/older` })).json().data; + expect(data).toMatchObject({ state: 'unsupported', reason: 'remote' }); + expect(read).not.toHaveBeenCalled(); + }); +}); + +describe('helpers', () => { + it('relativeTo strips only a real directory prefix', () => { + expect(relativeTo('/w/a/b.ts', '/w')).toBe('a/b.ts'); + expect(relativeTo('/w/a/b.ts', '/w/')).toBe('a/b.ts'); + expect(relativeTo('/wx/b.ts', '/w')).toBe('/wx/b.ts'); + expect(relativeTo('/w/b.ts', undefined)).toBe('/w/b.ts'); + }); + + it('hunksToUnified writes one header per hunk', () => { + expect( + hunksToUnified([ + { oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-x', '+y'] }, + { oldStart: 9, oldLines: 0, newStart: 9, newLines: 1, lines: ['+z'] }, + ]) + ).toBe('@@ -1,1 +1,1 @@\n-x\n+y\n@@ -9,0 +9,1 @@\n+z'); + }); +}); diff --git a/test/routes/filesystem-mkdir-routes.test.ts b/test/routes/filesystem-mkdir-routes.test.ts new file mode 100644 index 00000000..71c60943 --- /dev/null +++ b/test/routes/filesystem-mkdir-routes.test.ts @@ -0,0 +1,213 @@ +/** + * @fileoverview POST /api/filesystem/mkdir: the path picker's "New folder". Real filesystem under + * test/setup.ts's temp HOME (a root the picker offers), so this proves what is on disk, not a mock. + * Port: N/A (app.inject()). + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { createRouteTestHarness } from './_route-test-utils.js'; +import { registerFileRoutes } from '../../src/web/routes/file-routes.js'; +import { LinkCaseSchema } from '../../src/web/schemas.js'; +import { isSandboxedHome, safeRmHomeTree } from '../mocks/index.js'; + +describe('POST /api/filesystem/mkdir', () => { + let workDir = ''; + + beforeEach(() => { + if (!isSandboxedHome()) throw new Error('filesystem-mkdir-routes.test.ts creates folders: run it via npm test'); + workDir = realpathSync(mkdtempSync(join(homedir(), 'picker-mkdir-'))); + }); + afterEach(() => { + if (workDir) safeRmHomeTree(workDir); + workDir = ''; + }); + + const mkdir = async (payload: Record<string, unknown>) => { + const { app } = await createRouteTestHarness(registerFileRoutes); + return app.inject({ method: 'POST', url: '/api/filesystem/mkdir', payload }); + }; + + it('creates one folder inside the folder being viewed and answers its path', async () => { + const res = await mkdir({ path: workDir, name: 'fresh' }); + expect(res.statusCode).toBe(200); + expect(res.json().data).toEqual({ path: join(workDir, 'fresh') }); + expect(readdirSync(workDir)).toEqual(['fresh']); + }); + + it.each(['Kay app', 'project_v2', 'v1.2', 'my-folder', 'Café 2026'])( + 'accepts %s, and Link Existing then accepts the folder it made', + async (name) => { + const res = await mkdir({ path: workDir, name }); + expect(res.statusCode).toBe(200); + expect(LinkCaseSchema.safeParse({ name: 'x', path: res.json().data.path }).success).toBe(true); + } + ); + + it('refuses the cases directory as the parent (400): a folder there is already a case', async () => { + const casesDir = join(homedir(), 'codeman-cases'); + mkdirSync(casesDir, { recursive: true }); + try { + const res = await mkdir({ path: casesDir, name: 'myproj' }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toContain('Add Case > New Case'); + expect(existsSync(join(casesDir, 'myproj'))).toBe(false); + // A folder inside a case is fine. + mkdirSync(join(casesDir, 'real-case')); + expect((await mkdir({ path: join(casesDir, 'real-case'), name: 'sub' })).statusCode).toBe(200); + } finally { + safeRmHomeTree(casesDir); + } + }); + + it('answers 404 for a parent that is gone, the same answer browse gives', async () => { + const res = await mkdir({ path: join(workDir, 'gone'), name: 'x' }); + expect(res.statusCode).toBe(404); + }); + + it('refuses a parent that is a file (400), like browse', async () => { + writeFileSync(join(workDir, 'afile'), 'x'); + const res = await mkdir({ path: join(workDir, 'afile'), name: 'x' }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('The folder to create in must be a directory'); + }); + + it('creates inside a hidden folder only when the picker is showing hidden entries', async () => { + mkdirSync(join(workDir, '.hid')); + const denied = await mkdir({ path: join(workDir, '.hid'), name: 'x' }); + expect(denied.statusCode).toBe(403); + const ok = await mkdir({ path: join(workDir, '.hid'), name: 'x', showHidden: 'true' }); + expect(ok.statusCode).toBe(200); + expect(existsSync(join(workDir, '.hid', 'x'))).toBe(true); + // A dot name is still refused there. + expect((await mkdir({ path: join(workDir, '.hid'), name: '.y', showHidden: 'true' })).statusCode).toBe(400); + }); + + it('trims the name', async () => { + const res = await mkdir({ path: workDir, name: ' spaced ' }); + expect(res.json().data.path).toBe(join(workDir, 'spaced')); + }); + + it('refuses an existing folder (409) without touching it', async () => { + mkdirSync(join(workDir, 'taken')); + writeFileSync(join(workDir, 'taken', 'keep.txt'), 'mine'); + const res = await mkdir({ path: workDir, name: 'taken' }); + expect(res.statusCode).toBe(409); + expect(res.json().error).toContain('already exists'); + expect(readdirSync(join(workDir, 'taken'))).toEqual(['keep.txt']); + }); + + it.each([ + ['a slash', 'a/b'], + ['a backslash', 'a\\b'], + ['a traversal', '..'], + ['a dot name', '.'], + ['a hidden name', '.secret'], + ['a lone tilde', '~'], + ['a leading tilde', '~tilde'], + ['an empty name', ' '], + ['an apostrophe', "Kay's app"], + ['parentheses', 'project (v2)'], + ['a plus', 'a+b'], + ['a comma', 'notes, 2026'], + ['a hash', 'c#'], + ['brackets', '[draft]'], + ['a double dot inside', 'v1..2'], + ['a tab', 'a\tb'], + ['an escape', 'a\x1b[31mb'], + ['a bell', 'a\x07b'], + ['a delete char', 'a\x7fb'], + ['a name over 255 bytes', 'é'.repeat(200)], + ])('refuses %s in the name (400) and creates nothing', async (_label, name) => { + const res = await mkdir({ path: workDir, name }); + expect(res.statusCode).toBe(400); + expect(readdirSync(workDir)).toEqual([]); + }); + + it('refuses a parent that does not exist (404)', async () => { + const res = await mkdir({ path: join(workDir, 'nope'), name: 'x' }); + expect(res.statusCode).toBe(404); + expect(existsSync(join(workDir, 'nope'))).toBe(false); + }); + + it('refuses a parent outside the browse roots (403)', async () => { + const res = await mkdir({ path: '/etc', name: 'x' }); + expect(res.statusCode).toBe(403); + expect(existsSync('/etc/x')).toBe(false); + }); + + it('refuses a relative or traversing parent (400)', async () => { + expect((await mkdir({ path: 'relative', name: 'x' })).statusCode).toBe(400); + expect((await mkdir({ path: `${workDir}/../x`, name: 'x' })).statusCode).toBe(400); + }); + + describe('multi-user scoping', () => { + let prevMultiUser: string | undefined; + let prevSpaces: string | undefined; + let spaces = ''; + + beforeEach(() => { + prevMultiUser = process.env.CODEMAN_MULTIUSER; + prevSpaces = process.env.CODEMAN_USER_SPACES_DIR; + spaces = join(workDir, 'spaces'); + mkdirSync(join(spaces, 'alice'), { recursive: true }); + mkdirSync(join(spaces, 'bob')); + process.env.CODEMAN_MULTIUSER = '1'; + process.env.CODEMAN_USER_SPACES_DIR = spaces; + }); + afterEach(() => { + if (prevMultiUser === undefined) delete process.env.CODEMAN_MULTIUSER; + else process.env.CODEMAN_MULTIUSER = prevMultiUser; + if (prevSpaces === undefined) delete process.env.CODEMAN_USER_SPACES_DIR; + else process.env.CODEMAN_USER_SPACES_DIR = prevSpaces; + }); + + it("refuses a non-admin creating inside another user's space (403) and creates nothing", async () => { + const { app } = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } }); + const res = await app.inject({ + method: 'POST', + url: '/api/filesystem/mkdir', + payload: { path: join(spaces, 'alice'), name: 'x' }, + }); + expect(res.statusCode).toBe(403); + expect(readdirSync(join(spaces, 'alice'))).toEqual([]); + }); + + it("refuses a non-admin's own cases folder (400) but not the top of their space", async () => { + const { app } = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } }); + const own = join(spaces, 'bob', 'cases'); + mkdirSync(own, { recursive: true }); + const res = await app.inject({ + method: 'POST', + url: '/api/filesystem/mkdir', + payload: { path: own, name: 'myproj' }, + }); + expect(res.statusCode).toBe(400); + expect(existsSync(join(own, 'myproj'))).toBe(false); + }); + + it('lets a non-admin create inside their own space', async () => { + const { app } = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } }); + const res = await app.inject({ + method: 'POST', + url: '/api/filesystem/mkdir', + payload: { path: join(spaces, 'bob'), name: 'mine' }, + }); + expect(res.statusCode).toBe(200); + expect(existsSync(join(spaces, 'bob', 'mine'))).toBe(true); + }); + + it('404s a sessionId owned by another user, and adopts none of its folder', async () => { + const scoped = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } }); + scoped.ctx._session.owner = 'alice'; + const res = await scoped.app.inject({ + method: 'POST', + url: '/api/filesystem/mkdir', + payload: { path: join(spaces, 'bob'), name: 'x', sessionId: scoped.ctx._sessionId }, + }); + expect(res.statusCode).toBe(404); + expect(existsSync(join(spaces, 'bob', 'x'))).toBe(false); + }); + }); +}); diff --git a/test/routes/filesystem-mkdir-unreachable.test.ts b/test/routes/filesystem-mkdir-unreachable.test.ts new file mode 100644 index 00000000..92699899 --- /dev/null +++ b/test/routes/filesystem-mkdir-unreachable.test.ts @@ -0,0 +1,37 @@ +/** + * @fileoverview `POST /api/filesystem/mkdir` probes the folder bounded before the picker's + * resolver runs `realpathSync` on it (#516): a mount that stopped answering is refused, and + * the request never reaches a synchronous call that would freeze the server. + * + * The probe is mocked: a mount that never answers cannot be made here. Port: none (inject). + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/utils/bounded-path-probe.js', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../src/utils/bounded-path-probe.js')>(); + return { ...actual, probePathKind: vi.fn(actual.probePathKind) }; +}); + +import { probePathKind } from '../../src/utils/bounded-path-probe.js'; +import { registerFileRoutes } from '../../src/web/routes/file-routes.js'; +import { createRouteTestHarness } from './_route-test-utils.js'; + +describe('POST /api/filesystem/mkdir on an unresponsive mount', () => { + afterEach(() => vi.mocked(probePathKind).mockClear()); + + it('refuses before the picker resolver touches the path', async () => { + vi.mocked(probePathKind).mockResolvedValueOnce('unknown'); + const { app } = await createRouteTestHarness(registerFileRoutes); + + const res = await app.inject({ + method: 'POST', + url: '/api/filesystem/mkdir', + payload: { path: '/mnt/dead/projects', name: 'fresh' }, + }); + + expect(res.statusCode).toBe(422); + expect(res.json().errorCode).toBe('OPERATION_FAILED'); + expect(res.json().error).toMatch(/not responding|not checked/); + expect(probePathKind).toHaveBeenCalledWith('/mnt/dead/projects', { pastCap: true }); + }); +}); diff --git a/test/routes/git-status-routes.test.ts b/test/routes/git-status-routes.test.ts index 5b1c6036..ec92c81d 100644 --- a/test/routes/git-status-routes.test.ts +++ b/test/routes/git-status-routes.test.ts @@ -9,8 +9,10 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createRouteTestHarness } from './_route-test-utils.js'; -import { registerGitStatusRoutes } from '../../src/web/routes/git-status-routes.js'; +import { defaultDockerWorkspaces, registerGitStatusRoutes } from '../../src/web/routes/git-status-routes.js'; +import { getDataDir } from '../../src/config/instance.js'; import { clearGitStatusCache, runGit, type GitRunner } from '../../src/git-workspace-status.js'; +import { clearGitFetchLane, fetchWorkspaceOnce } from '../../src/git-fetch.js'; const ENV = { ...process.env, @@ -29,14 +31,18 @@ let dir: string; let session: Record<string, unknown>; async function setup( - opts: { git?: GitRunner; authUser?: { username: string; role: 'admin' | 'user' }; dockerWorkspaces?: string[] } = {} + opts: { + git?: GitRunner; + authUser?: { username: string; role: 'admin' | 'user' }; + /** A fixed list, a reader of the test's own, or `'real'` for the route's default (the data dir's file). */ + dockerWorkspaces?: string[] | (() => Promise<string[]>) | 'real'; + } = {} ) { - const h = await createRouteTestHarness( - (app, ctx) => registerGitStatusRoutes(app, ctx, opts.git, async () => opts.dockerWorkspaces ?? []), - { - authUser: opts.authUser, - } - ); + const docker = opts.dockerWorkspaces; + const reader = docker === 'real' ? undefined : typeof docker === 'function' ? docker : async () => docker ?? []; + const h = await createRouteTestHarness((app, ctx) => registerGitStatusRoutes(app, ctx, opts.git, reader), { + authUser: opts.authUser, + }); session = h.ctx._session as unknown as Record<string, unknown>; session.workingDir = dir; return h; @@ -44,6 +50,8 @@ async function setup( beforeEach(() => { clearGitStatusCache(); + // The fetch lane is module state: a run left in it by the previous test would be joined by the next one. + clearGitFetchLane(); dir = mkdtempSync(join(tmpdir(), 'git-status-route-')); }); afterEach(() => { @@ -353,6 +361,313 @@ describe('GET /api/sessions/:id/git-status limits', () => { }); }); +describe('POST /api/sessions/:id/git-fetch', () => { + /** Records what each repository was asked to do, and lets real git answer the rest. */ + function recordingRunner() { + const calls: Array<[string, string[]]> = []; + const seenTimeouts: Array<number | undefined> = []; + const runner: GitRunner = async (cwd, args, opts) => { + calls.push([cwd, args]); + seenTimeouts.push(opts?.timeoutMs); + return execFileSyncGit(cwd, args); + }; + return { calls, seenTimeouts, runner, fetches: () => calls.filter(([, args]) => args[0] === 'fetch') }; + } + + it('runs one fetch per repository the window would show, in the session working directory', async () => { + for (const name of ['api', 'web']) { + mkdirSync(join(dir, name)); + git(join(dir, name), 'init', '-q', '-b', 'main'); + } + const { calls, fetches, runner } = recordingRunner(); + const { app } = await setup({ git: runner }); + const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' }); + expect(res.statusCode).toBe(200); + const data = res.json().data; + expect(data.state).toBe('ok'); + expect(data.repos.map((r: { name: string; path: string }) => [r.name, r.path])).toEqual([ + ['api', 'api'], + ['web', 'web'], + ]); + expect(data.repos.every((r: { ok: boolean }) => r.ok)).toBe(true); + // A bounded run does not promise which repository starts first, so compare the set. + expect(fetches().sort()).toEqual([ + [join(dir, 'api'), ['fetch', '--quiet']], + [join(dir, 'web'), ['fetch', '--quiet']], + ]); + // Every repository git was asked to touch sits under the session folder, and under nothing else. + for (const [cwd] of calls) expect(cwd.startsWith(dir)).toBe(true); + }); + + it('keeps a repository it could not fetch, with its reason, and says which ones it fetched', async () => { + for (const name of ['ok', 'private']) { + mkdirSync(join(dir, name)); + git(join(dir, name), 'init', '-q', '-b', 'main'); + } + const { fetches, runner } = recordingRunner(); + const injected: GitRunner = async (cwd, args, opts) => { + const recorded = runner(cwd, args, opts); // records the call before deciding what git would have said + if (args[0] === 'fetch' && cwd === join(dir, 'private')) { + throw Object.assign(new Error('Command failed'), { + stderr: "fatal: could not read Username for 'https://github.com': terminal prompts disabled", + }); + } + return recorded; + }; + const { app } = await setup({ git: injected }); + const data = (await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' })).json().data; + const priv = data.repos.find((r: { name: string }) => r.name === 'private'); + expect(priv?.ok).toBe(false); + expect(priv?.failure?.code).toBe('AUTH_REQUIRED'); + expect(data.repos.find((r: { name: string }) => r.name === 'ok')?.ok).toBe(true); + // Both repositories are still in the list, so both were attempted: a failure does not cancel the run. + expect(fetches()).toHaveLength(2); + }); + + it('redacts credentials git may have echoed back before the failure reaches the client', async () => { + git(dir, 'init', '-q', '-b', 'main'); + // Only the fetch is faked: the resolver still needs its own `rev-parse` to find the repository at all. + const runner: GitRunner = async (cwd, args) => { + if (args[0] === 'fetch') { + throw Object.assign(new Error('Command failed'), { + stderr: "fatal: unable to access 'https://bob:ghp_secret@github.com/x.git/'\u001b[31m", + }); + } + return execFileSyncGit(cwd, args); + }; + const { app } = await setup({ git: runner }); + const data = (await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' })).json().data; + expect(data.state).toBe('ok'); + expect(data.repos[0].failure.stderr).toBe("fatal: unable to access 'https://***:***@github.com/x.git/'[31m"); + }); + + it.each([ + ['remote', { host: 'h', user: 'u' }], + ['docker', { container: 'c' }], + ])('does not run git for a %s session and says it is unsupported', async (kind, value) => { + git(dir, 'init', '-q', '-b', 'main'); + const { calls, fetches, runner } = recordingRunner(); + const { app } = await setup({ git: runner }); + session[kind] = value; + const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' }); + expect(res.statusCode).toBe(200); + expect(res.json().data).toMatchObject({ state: 'unsupported', reason: kind }); + expect(fetches()).toEqual([]); + expect(calls).toEqual([]); // the resolver never ran either: the refusal comes before any git + }); + + it('refuses a repository inside a Docker case workspace before any git runs', async () => { + git(dir, 'init', '-q', '-b', 'main'); + const { calls, fetches, runner } = recordingRunner(); + const { app } = await setup({ git: runner, dockerWorkspaces: [realpathSync(dir)] }); + const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' }); + expect(res.json().data).toMatchObject({ state: 'unsupported', reason: 'docker' }); + expect(fetches()).toEqual([]); + expect(calls).toEqual([]); + }); + + /** + * docker-cases.json is rewritten in place (truncate, then write), so a read can land on an empty or + * half-written file. Read as "no Docker workspaces", a fetch would run git, and with it the repository's + * hooks, inside a folder a container can write to. + */ + it('fetches nothing when the Docker case list cannot be read, and answers a failed run', async () => { + git(dir, 'init', '-q', '-b', 'main'); + const { calls, runner } = recordingRunner(); + const { app, ctx } = await setup({ + git: runner, + dockerWorkspaces: async () => { + throw new Error('docker-cases.json is not a valid JSON array'); + }, + }); + const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' }); + expect(res.statusCode).toBe(200); + expect(res.json().data).toMatchObject({ state: 'error', repos: [] }); + expect(res.json().data.error).toMatch(/Docker case list/); + expect(calls).toEqual([]); // not even the resolver's rev-parse + expect(session.gitFetch).toMatchObject({ trigger: 'click', state: 'error', repos: 0, failed: 0 }); + expect(ctx.broadcast).toHaveBeenCalled(); + }); + + it('reads the real Docker case list strictly for a fetch, and leaves the status route as it was', async () => { + git(dir, 'init', '-q', '-b', 'main'); + mkdirSync(getDataDir(), { recursive: true }); + const listPath = join(getDataDir(), 'docker-cases.json'); + try { + writeFileSync(listPath, '[{"name":"c","hostWorkspacePath":"/x"'); + await expect(defaultDockerWorkspaces()).rejects.toThrow(/not a valid JSON array/); + const { calls, fetches, runner } = recordingRunner(); + const { app } = await setup({ git: runner, dockerWorkspaces: 'real' }); + const fetched = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' }); + expect(fetched.json().data.state).toBe('error'); + expect(calls).toEqual([]); + // The read-only status route still reads an unreadable list as none, as it always has. + const status = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' }); + expect(status.json().data.state).toBe('ok'); + expect(fetches()).toEqual([]); + + // A readable list that names this folder refuses the fetch as a Docker workspace, so the strict + // reader is the one the route really reads. + writeFileSync(listPath, JSON.stringify([{ name: 'c', hostWorkspacePath: realpathSync(dir) }])); + expect(await defaultDockerWorkspaces()).toEqual([realpathSync(dir)]); + const refused = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' }); + expect(refused.json().data).toMatchObject({ state: 'unsupported', reason: 'docker' }); + expect(fetches()).toEqual([]); + } finally { + rmSync(listPath, { force: true }); + } + }); + + it('answers not-a-repo for a folder that is not a repository, running no fetch', async () => { + const { fetches, runner } = recordingRunner(); + const { app } = await setup({ git: runner }); + const data = (await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' })).json().data; + expect(data.state).toBe('not-a-repo'); + // The resolver looked once and found nothing, so nothing was fetched, and it never guessed a repository. + expect(fetches()).toEqual([]); + }); + + it("publishes what the click got as the session's own fetch state, naming the trigger the user asked for", async () => { + git(dir, 'init', '-q', '-b', 'main'); + const { runner } = recordingRunner(); + const { app, ctx } = await setup({ git: runner }); + const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' }); + expect(res.statusCode).toBe(200); + // The response body is one tab's copy of the run; the state is what every other tab open on the same + // folder sees, and a number the user did not ask for has to read differently from one they did. + expect(session.gitFetch).toMatchObject({ trigger: 'click', state: 'ok', repos: 1, failed: 0 }); + expect(ctx.persistSessionState).toHaveBeenCalled(); + expect(ctx.broadcast).toHaveBeenCalled(); + }); + + it('joins a run already going for the same folder instead of starting a second git process in the same clone', async () => { + git(dir, 'init', '-q', '-b', 'main'); + let releaseRun = () => {}; + const held = new Promise<void>((resolve) => (releaseRun = resolve)); + let running = () => {}; + const runStarted = new Promise<void>((resolve) => (running = resolve)); + const fetches: string[] = []; + const runner: GitRunner = async (cwd, args) => { + if (args[0] === 'fetch') { + fetches.push(cwd); + running(); + await held; + } + return execFileSyncGit(cwd, args); + }; + const { app } = await setup({ git: runner }); + + const first = app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' }); + await runStarted; // the run is going, and still going + // A click from another tab open on the same clone. Two fetches there would collide on git's ref lock, so + // this one joins the run already going rather than starting its own. + const second = app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' }); + // Assert the join BEFORE releasing the run: the lane entry goes away when the run finishes, so releasing + // first would let the run complete before this request reached the lane, and it would then be entitled to + // start its own fetch: a test that passes for the wrong reason. One tick of the loop is enough for both + // handlers to reach the lane while the run is still held open. + await new Promise((resolve) => setImmediate(resolve)); + expect(fetches).toHaveLength(1); // one run, and the second request is waiting on it, not working + + releaseRun(); + const a = (await first).json().data; + const b = (await second).json().data; + expect(b).toEqual(a); // both tabs get the same run's answer, not a partial one + }); + + it('publishes the trigger of the run the click joined, because a click that fetched nothing cannot report itself as the fetch', async () => { + git(dir, 'init', '-q', '-b', 'main'); + let releaseRun = () => {}; + const held = new Promise<void>((resolve) => (releaseRun = resolve)); + let running = () => {}; + const runStarted = new Promise<void>((resolve) => (running = resolve)); + const fetches: string[] = []; + const runner: GitRunner = async (cwd, args) => { + if (args[0] === 'fetch') { + fetches.push(cwd); + running(); + await held; + } + return execFileSyncGit(cwd, args); + }; + const { app, ctx } = await setup({ git: runner }); + + // The interval tick got to this clone first. The click joins it, so the row the other tabs see has to name + // the interval: the user clicked, but nothing this click did was a fetch. The request is left open, not + // awaited: awaiting it here would wait on the run this test is holding, and the test would wait on itself. + const intervalRun = fetchWorkspaceOnce(dir, { git: runner }, 'interval'); + await runStarted; + const res = app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' }); + await new Promise((resolve) => setImmediate(resolve)); + + releaseRun(); + const run = await intervalRun; + const body = (await res).json().data; + expect(body).toEqual(run.report); // the run it joined, not a partial answer of its own + // Asserted after the run, so this can only pass if the click reached the lane and joined: a click that had + // started its own fetch would make this two, and would have nothing to call the interval. + expect(fetches).toHaveLength(1); + expect(session.gitFetch).toMatchObject({ trigger: 'interval', state: 'ok', repos: 1, failed: 0 }); + expect(ctx.broadcast).toHaveBeenCalled(); + }); + + it('404s an unknown session, running no fetch', async () => { + const { calls, fetches, runner } = recordingRunner(); + const { app } = await setup({ git: runner }); + expect((await app.inject({ method: 'POST', url: '/api/sessions/nope/git-fetch' })).statusCode).toBe(404); + expect(fetches()).toEqual([]); + expect(calls).toEqual([]); + }); + + it('multi-user: another user’s session is not found, and no fetch runs', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + git(dir, 'init', '-q', '-b', 'main'); + const { fetches, runner } = recordingRunner(); + const { app } = await setup({ git: runner, authUser: { username: 'bob', role: 'user' } }); + session.owner = 'alice'; + expect((await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' })).statusCode).toBe(404); + expect(fetches()).toEqual([]); + }); + + it('honours maxRepos and timeout (seconds) for a fetch too, clamped, and reports the limit it used', async () => { + for (const n of ['a', 'b', 'c']) { + mkdirSync(join(dir, n)); + git(join(dir, n), 'init', '-q', '-b', 'main'); + } + const { seenTimeouts, fetches, runner } = recordingRunner(); + const { app } = await setup({ git: runner }); + const post = async (query: string) => { + const res = await app.inject({ method: 'POST', url: `/api/sessions/test-session-1/git-fetch${query}` }); + expect(res.statusCode).toBe(200); + return res.json().data as { repoLimit: number; repos: Array<{ name: string }> }; + }; + const fetchCount = () => fetches().length; + const ran = async (query: string, expected: number) => { + const before = fetchCount(); + const data = await post(query); + expect(fetchCount() - before).toBe(expected); + return data; + }; + + const limited = await ran('?maxRepos=2&timeout=7', 2); + expect(limited.repos.map((r) => r.name)).toEqual(['a', 'b']); + expect(limited).toMatchObject({ reposTruncated: true, repoLimit: 2 }); // the window has to say three were found + expect(new Set(seenTimeouts)).toEqual(new Set([7000])); + + const wild = await ran('?maxRepos=9999&timeout=1', 3); + expect(wild.repoLimit).toBe(50); // clamped to MAX_REPOS_LIMIT, so one request cannot scan the whole disk + expect(seenTimeouts.at(-1)).toBe(5000); + + const junk = await ran('?maxRepos=abc&timeout=xyz', 3); + expect(junk.repoLimit).toBe(12); + expect(seenTimeouts.at(-1)).toBe(30_000); + + // A repeated key reaches the route as an array: it means "default", never a 500. + await ran('?timeout=5&timeout=6', 3); + expect(seenTimeouts.at(-1)).toBe(30_000); + }); +}); + function execFileSyncGit(cwd: string, args: string[]): Promise<string> { return Promise.resolve(execFileSync('git', ['--no-optional-locks', ...args], { cwd, env: ENV, encoding: 'utf8' })); } diff --git a/test/routes/quick-start-custom-model.test.ts b/test/routes/quick-start-custom-model.test.ts index 17a0402f..6a5aca2c 100644 --- a/test/routes/quick-start-custom-model.test.ts +++ b/test/routes/quick-start-custom-model.test.ts @@ -21,7 +21,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import Fastify, { type FastifyInstance } from 'fastify'; import fastifyCookie from '@fastify/cookie'; import { rm, readFile } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; +import { existsSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { createMockRouteContext, safeRmHomeTree, type MockRouteContext } from '../mocks/index.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; @@ -122,6 +122,9 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', () const { sessionId } = res.json(); const session = ctx.sessions.get(sessionId) as unknown as Session; expect(session.customModel?.endpointId).toBe('ep1'); + // #750, pinned the same way as the pi/grok/omp one-shot paths below: the FIRST + // launch already carries the selected id, not just the file on disk. + expect(session.getCustomModelForPersist()?.launchModel).toBe('qwen3'); expect(restartSpy).not.toHaveBeenCalled(); const configDir = customModelConfigDir(sessionId); @@ -192,7 +195,9 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', () expect(res.json().errorCode).toBe('OPERATION_FAILED'); }); - it('refuses a model id the CLI cannot carry on its command line, cleaning up any written config dir', async () => { + it('refuses a model id the CLI cannot carry on its command line, before writing anything', async () => { + const dirs = join(getDataDir(), 'custom-model-configs'); + const before = existsSync(dirs) ? readdirSync(dirs).length : 0; const res = await quickStart({ caseName: 'cm-badmodel', mode: 'pi', @@ -200,6 +205,7 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', () }); expect(res.json().success).toBe(false); expect(res.json().errorCode).toBe('INVALID_INPUT'); + expect(existsSync(dirs) ? readdirSync(dirs).length : 0).toBe(before); }); it('refuses customModel for a remote case', async () => { @@ -226,6 +232,21 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', () }); } + it('refuses a model id the CLI cannot launch before asking anyone to evict a model', async () => { + const other = ctx.sessions.get('test-session-1')!; + (other as unknown as { customModel: unknown }).customModel = { endpointId: 'ep1', modelId: 'llama3' }; + mockRunning([{ model: 'llama3', state: 'ready' }]); + + const res = await quickStart({ + caseName: 'cm-badmodel-swap', + mode: 'codex', + customModel: { endpointId: 'ep1', modelId: 'org/model@q4_k_m' }, + }); + + expect(res.json().requiresConfirmation).toBeUndefined(); + expect(res.json().errorCode).toBe('INVALID_INPUT'); + }); + it('asks for confirmation instead of launching when another live session is using the currently loaded model', async () => { const other = ctx.sessions.get('test-session-1')!; (other as unknown as { customModel: unknown }).customModel = { endpointId: 'ep1', modelId: 'llama3' }; @@ -367,6 +388,50 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', () expect(res.statusCode).toBe(200); expect(res.json().requiresContextWarning).toBeUndefined(); }); + + it('warns using the endpoint defaultContextLength when discovery recorded no per-model figure', async () => { + const endpoint: CustomModelHost = { + id: 'ep-default', + label: 'silent gateway', + baseUrl: 'http://192.168.1.53:8080', + apiKey: 'k', + defaultContextLength: 16384, + }; + await writeCustomModelHosts(getDataDir(), [ENDPOINT, endpoint]); + + const res = await quickStart({ + caseName: 'cm-default-ctx', + mode: 'claude', + customModel: { endpointId: 'ep-default', modelId: 'some-unknown-model' }, + }); + + const body = res.json(); + expect(body.requiresContextWarning).toBe(true); + expect(body.contextLength).toBe(16384); + expect(ctx.sessions.size).toBe(1); // nothing was created + }); + + it('a discovered per-model figure wins over the endpoint defaultContextLength', async () => { + const endpoint: CustomModelHost = { + id: 'ep-precedence', + label: 'mixed box', + baseUrl: 'http://192.168.1.54:8080', + apiKey: 'k', + modelContextLengths: { m: 131072 }, + defaultContextLength: 16384, + }; + await writeCustomModelHosts(getDataDir(), [ENDPOINT, endpoint]); + + const res = await quickStart({ + caseName: 'cm-precedence', + mode: 'claude', + customModel: { endpointId: 'ep-precedence', modelId: 'm' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().requiresContextWarning).toBeUndefined(); + expect(ctx.sessions.size).toBe(2); + }); }); describe('triggering the actual llama-swap load (not just watching for it)', () => { diff --git a/test/routes/ralph-routes-remote.test.ts b/test/routes/ralph-routes-remote.test.ts new file mode 100644 index 00000000..91ff2735 --- /dev/null +++ b/test/routes/ralph-routes-remote.test.ts @@ -0,0 +1,184 @@ +/** + * @fileoverview Ralph file routes for a REMOTE (SSH) session. + * + * A remote session's `workingDir` is a path on the remote host. `fix-plan/read` + * used to read `@fix_plan.md` with local `fs`, and `fix-plan/write` and + * `ralph-prompt/write` wrote their files with it, so with a same-named local folder + * (an sshfs mount, a shared home layout) they read and wrote the LOCAL twin. Now: + * - `fix-plan/read` reads the file on the remote host through `src/remote-files.ts` + * (the File Viewer's ssh path), and a missing remote file is the same 404 as a + * missing local one; + * - both writes are refused (remote writes are out of scope, like the File Viewer's + * remote edit). + * + * The workspace path EXISTS locally here, holding a different `@fix_plan.md`, so a + * route that fell back to local `fs` would visibly import or overwrite the wrong file. + * The ssh layer is mocked (no connection is opened); the pure builders and + * `RemoteFileAccessError` stay real. + * + * Port: N/A (app.inject doesn't open ports) + */ + +import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; +import { safeRmHomeTree } from '../mocks/test-helpers.js'; +import { registerRalphRoutes } from '../../src/web/routes/ralph-routes.js'; +import type { RemoteProbe } from '../../src/remote-files.js'; +import type { SessionRemote } from '../../src/types/session.js'; + +vi.mock('../../src/remote-files.js', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../src/remote-files.js')>(); + return { ...actual, remoteProbePaths: vi.fn(), remoteReadFile: vi.fn() }; +}); + +import { remoteProbePaths, remoteReadFile, RemoteFileAccessError } from '../../src/remote-files.js'; + +const mockedProbePaths = vi.mocked(remoteProbePaths); +const mockedReadFile = vi.mocked(remoteReadFile); + +// The local twin of the remote workspace: same absolute path, different file. +const TWIN = join(homedir(), '.codeman-test-ralph-remote-twin'); +const LOCAL_PLAN = '# Fix Plan\n\n- [ ] LOCAL task\n'; +const REMOTE_PLAN = '# Fix Plan\n\n- [ ] REMOTE task\n'; +const remote: SessionRemote = { + hostId: 'host-1', + label: 'testhost', + host: '192.0.2.10', + username: 'j', + remotePath: TWIN, +}; + +function fileProbe(size = REMOTE_PLAN.length): RemoteProbe { + return { realPath: join(TWIN, '@fix_plan.md'), kind: 'file', size, mtimeMs: 1_700_000_000_000 }; +} + +const rootProbe: RemoteProbe = { realPath: TWIN, kind: 'directory', size: 0, mtimeMs: 0 }; + +describe('ralph file routes for a remote (SSH) session', () => { + let harness: RouteTestHarness; + let importFixPlanMarkdown: ReturnType<typeof vi.fn>; + + beforeAll(() => { + mkdirSync(TWIN, { recursive: true }); + }); + + afterAll(() => { + safeRmHomeTree(TWIN); + }); + + beforeEach(async () => { + writeFileSync(join(TWIN, '@fix_plan.md'), LOCAL_PLAN); + harness = await createRouteTestHarness(registerRalphRoutes); + importFixPlanMarkdown = vi.fn(() => 1); + const session = harness.ctx._session as unknown as Record<string, unknown>; + session.workingDir = TWIN; + session.remote = { ...remote }; + session.ralphTracker = { + generateFixPlanMarkdown: vi.fn(() => '# Fix Plan\n\n- [ ] generated\n'), + importFixPlanMarkdown, + todos: [], + }; + mockedProbePaths.mockResolvedValue([fileProbe(), rootProbe]); + mockedReadFile.mockResolvedValue(Buffer.from(REMOTE_PLAN)); + }); + + afterEach(async () => { + await harness.app.close(); + vi.clearAllMocks(); + }); + + const post = (route: string, payload?: unknown) => + harness.app.inject({ method: 'POST', url: `/api/sessions/${harness.ctx._sessionId}/${route}`, payload }); + + describe('POST fix-plan/read', () => { + it('imports the file from the remote host, not the local twin', async () => { + const res = await post('fix-plan/read'); + expect(JSON.parse(res.body)).toMatchObject({ success: true, data: { importedCount: 1 } }); + expect(mockedProbePaths).toHaveBeenCalledWith(remote, [join(TWIN, '@fix_plan.md'), TWIN]); + expect(mockedReadFile).toHaveBeenCalledWith(remote, join(TWIN, '@fix_plan.md'), expect.any(Number)); + expect(importFixPlanMarkdown).toHaveBeenCalledWith(REMOTE_PLAN); + expect(harness.ctx.persistSessionState).toHaveBeenCalled(); + }); + + it('answers the same not-found as a local read when the remote file is missing', async () => { + mockedProbePaths.mockResolvedValue([null, rootProbe]); + const res = await post('fix-plan/read'); + expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'NOT_FOUND' }); + expect(mockedReadFile).not.toHaveBeenCalled(); + expect(importFixPlanMarkdown).not.toHaveBeenCalled(); + }); + + it('treats a @fix_plan.md that resolves outside the workspace as missing', async () => { + // e.g. a symlink to ~/.ssh/id_rsa on the remote host: the File Viewer's confinement applies. + mockedProbePaths.mockResolvedValue([{ ...fileProbe(), realPath: '/home/j/.ssh/id_rsa' }, rootProbe]); + const res = await post('fix-plan/read'); + expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'NOT_FOUND' }); + expect(mockedReadFile).not.toHaveBeenCalled(); + expect(importFixPlanMarkdown).not.toHaveBeenCalled(); + }); + + it('refuses a remote path that is not a regular file, or one over the cap, without reading it', async () => { + for (const probe of [ + { ...fileProbe(), kind: 'directory' as const }, + { ...fileProbe(), kind: 'other' as const }, + fileProbe(64 * 1024 * 1024), + ]) { + mockedProbePaths.mockResolvedValue([probe, rootProbe]); + const res = await post('fix-plan/read'); + expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'OPERATION_FAILED' }); + } + expect(mockedReadFile).not.toHaveBeenCalled(); + expect(importFixPlanMarkdown).not.toHaveBeenCalled(); + }); + + it('reports an unreachable host as a failed read, never a local fallback', async () => { + mockedProbePaths.mockRejectedValue(new RemoteFileAccessError('remote host testhost unreachable: timeout')); + const res = await post('fix-plan/read'); + const body = JSON.parse(res.body); + expect(body).toMatchObject({ success: false, errorCode: 'OPERATION_FAILED' }); + expect(body.error).toContain('unreachable'); + expect(body.error).not.toContain('RemoteFileAccessError'); + expect(importFixPlanMarkdown).not.toHaveBeenCalled(); + }); + }); + + describe('writes', () => { + it('refuses fix-plan/write and leaves the local twin alone', async () => { + const res = await post('fix-plan/write'); + expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' }); + expect(readFileSync(join(TWIN, '@fix_plan.md'), 'utf-8')).toBe(LOCAL_PLAN); + }); + + it('refuses ralph-prompt/write and creates nothing locally', async () => { + const res = await post('ralph-prompt/write', { content: 'do the thing' }); + expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' }); + expect(existsSync(join(TWIN, '@ralph_prompt.md'))).toBe(false); + }); + }); + + describe('a local session is unchanged', () => { + beforeEach(() => { + delete (harness.ctx._session as unknown as Record<string, unknown>).remote; + }); + + it('reads the local file without touching ssh', async () => { + const res = await post('fix-plan/read'); + expect(JSON.parse(res.body)).toMatchObject({ success: true }); + expect(importFixPlanMarkdown).toHaveBeenCalledWith(LOCAL_PLAN); + expect(mockedProbePaths).not.toHaveBeenCalled(); + expect(mockedReadFile).not.toHaveBeenCalled(); + }); + + it('writes the local files', async () => { + expect(JSON.parse((await post('fix-plan/write')).body)).toMatchObject({ success: true }); + expect(readFileSync(join(TWIN, '@fix_plan.md'), 'utf-8')).toContain('generated'); + expect(JSON.parse((await post('ralph-prompt/write', { content: 'do the thing' })).body)).toMatchObject({ + success: true, + }); + expect(readFileSync(join(TWIN, '@ralph_prompt.md'), 'utf-8')).toBe('do the thing'); + }); + }); +}); diff --git a/test/routes/session-custom-model.test.ts b/test/routes/session-custom-model.test.ts index 9c8062d6..d28277f0 100644 --- a/test/routes/session-custom-model.test.ts +++ b/test/routes/session-custom-model.test.ts @@ -9,7 +9,7 @@ import { createRouteTestHarness } from './_route-test-utils.js'; import { createMockSession } from '../mocks/index.js'; import { getDataDir } from '../../src/config/instance.js'; import { writeCustomModelHosts, type CustomModelHost } from '../../src/custom-model-hosts.js'; -import { existsSync, readFileSync, statSync } from 'node:fs'; +import { existsSync, readFileSync, rmSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { webviewFetch } from '../../src/web/webview-egress.js'; @@ -203,6 +203,10 @@ describe('POST /api/sessions/:id/custom-model', () => { const { app, ctx } = await setup(); const session = ctx.sessions.get('test-session-1')!; session.mode = 'pi'; + // Start from no dir at all (a sibling test's successful pi apply may have left one), + // so the existsSync below says something about THIS request. + const dir = join(getDataDir(), 'custom-model-configs', 'test-session-1'); + rmSync(dir, { recursive: true, force: true }); const res = await app.inject({ method: 'POST', @@ -214,8 +218,45 @@ describe('POST /api/sessions/:id/custom-model', () => { expect(res.json().errorCode).toBe('INVALID_INPUT'); expect(session.setCustomModel).not.toHaveBeenCalled(); expect(session.restartCli).not.toHaveBeenCalled(); - // The config dir written before the check is cleaned up again. - expect(existsSync(join(getDataDir(), 'custom-model-configs', 'test-session-1'))).toBe(false); + // The pattern check runs on the pure build BEFORE any write, so a refused id leaves + // no directory behind at all, nothing to clean up. + expect(existsSync(dir)).toBe(false); + }); + + it('codex: a refused forced launch id never touches the live config dir the session runs on as CODEX_HOME', async () => { + const { app, ctx } = await setup(); + const session = ctx.sessions.get('test-session-1')!; + session.mode = 'codex'; + + // First apply succeeds: the session now runs codex on qwen3:32b, whose config.toml + // lives in the per-session dir the running CLI itself uses as CODEX_HOME. + const ok = await app.inject({ + method: 'POST', + url: '/api/sessions/test-session-1/custom-model', + payload: { endpointId: 'ep1', modelId: 'qwen3:32b' }, + }); + expect(ok.statusCode).toBe(200); + const dir = join(getDataDir(), 'custom-model-configs', 'test-session-1'); + const written = join(dir, 'config.toml'); + expect(existsSync(written)).toBe(true); + + // `@` is outside codex's `model-plain-colon` pattern. The pattern check now runs on + // the pure build BEFORE any write, so the refusal must leave the dir, and the file + // the live CLI reads, exactly as they were, rather than replacing and deleting the + // CODEX_HOME of a session that stays on qwen3:32b. + const before = readFileSync(written, 'utf8'); + const res = await app.inject({ + method: 'POST', + url: '/api/sessions/test-session-1/custom-model', + payload: { endpointId: 'ep1', modelId: 'org/model@q4_k_m' }, + }); + + expect(res.json().success).toBe(false); + expect(res.json().errorCode).toBe('INVALID_INPUT'); + expect(session.setCustomModel).toHaveBeenCalledTimes(1); + expect(session.restartCli).toHaveBeenCalledTimes(1); + expect(existsSync(dir)).toBe(true); + expect(readFileSync(written, 'utf8')).toBe(before); }); it('clear removes the previous config dir the session reports', async () => { @@ -298,6 +339,28 @@ describe('POST /api/sessions/:id/custom-model', () => { expect(session.restartCli).not.toHaveBeenCalled(); }); + it('refuses a model id the CLI cannot launch before asking anyone to evict a model', async () => { + const { app, ctx } = await setup(); + const session = ctx.sessions.get('test-session-1')!; + session.mode = 'codex'; + const other = createMockSession('other-session'); + other.name = 'w2-otherbox'; + other.customModel = { endpointId: 'ep1', modelId: 'llama3' }; + ctx.sessions.set('other-session', other); + mockRunning([{ model: 'llama3', state: 'ready' }]); + + const res = await app.inject({ + method: 'POST', + url: '/api/sessions/test-session-1/custom-model', + payload: { endpointId: 'ep1', modelId: 'org/model@q4_k_m' }, + }); + + // Agreeing to the swap first would only lead to this same 400. + expect(res.json().requiresConfirmation).toBeUndefined(); + expect(res.json().errorCode).toBe('INVALID_INPUT'); + expect(session.setCustomModel).not.toHaveBeenCalled(); + }); + describe('multi-user: the confirm dialog must not name a session the caller cannot access', () => { const saved: Record<string, string | undefined> = {}; @@ -449,6 +512,58 @@ describe('POST /api/sessions/:id/custom-model', () => { expect(session.restartCli).not.toHaveBeenCalled(); }); + it('the endpoint defaultContextLength stands in when discovery recorded no per-model figure', async () => { + const { app, ctx } = await setup(); + // A gateway that states nothing per model: only the user's default is known. + const endpoint: CustomModelHost = { + id: 'ep-default', + label: 'silent gateway', + baseUrl: 'http://192.168.1.53:8080', + apiKey: 'k', + defaultContextLength: 16384, + }; + await writeCustomModelHosts(getDataDir(), [CLAUDE_ENDPOINT, endpoint]); + const session = ctx.sessions.get('test-session-1')!; + session.mode = 'claude'; + + const res = await app.inject({ + method: 'POST', + url: '/api/sessions/test-session-1/custom-model', + payload: { endpointId: 'ep-default', modelId: 'some-unknown-model' }, + }); + + const body = res.json(); + expect(body.requiresContextWarning).toBe(true); + expect(body.contextLength).toBe(16384); + expect(session.setCustomModel).not.toHaveBeenCalled(); + }); + + it('a discovered per-model figure wins over the endpoint defaultContextLength', async () => { + const { app, ctx } = await setup(); + // The default is small enough to warn on; the discovered figure for THIS model is + // not, so the fallback must not leak into the per-model path. + const endpoint: CustomModelHost = { + id: 'ep-precedence', + label: 'mixed box', + baseUrl: 'http://192.168.1.54:8080', + apiKey: 'k', + modelContextLengths: { 'qwen3.8-27b-ud-q4_k_xl': 131072 }, + defaultContextLength: 16384, + }; + await writeCustomModelHosts(getDataDir(), [CLAUDE_ENDPOINT, endpoint]); + const session = ctx.sessions.get('test-session-1')!; + session.mode = 'claude'; + + const res = await app.inject({ + method: 'POST', + url: '/api/sessions/test-session-1/custom-model', + payload: { endpointId: 'ep-precedence', modelId: 'qwen3.8-27b-ud-q4_k_xl' }, + }); + + expect(res.json().requiresContextWarning).toBeUndefined(); + expect(session.setCustomModel).toHaveBeenCalledTimes(1); + }); + // llama-server splits its context across --parallel slots, and Claude Code sends several // requests at once, so a context that clears the floor as a whole can still fail per slot. describe('parallel slots share the context', () => { diff --git a/test/routes/session-routes-git-fetch-settings.test.ts b/test/routes/session-routes-git-fetch-settings.test.ts new file mode 100644 index 00000000..a1d40120 --- /dev/null +++ b/test/routes/session-routes-git-fetch-settings.test.ts @@ -0,0 +1,104 @@ +/** + * `POST /api/sessions/:id/git-fetch-settings` (session-routes.ts): the per-case half of the fetch interval + * (src/git-fetch-scheduler.ts): the case's own switch and its own interval. `null` on either half clears + * that half so the app-wide setting applies again, and a body that names one row leaves the other as it was, + * because the Session Options rows save on their own. Refused for remote and Docker cases, the same rule the + * Git panel and the scheduler use. app.inject(), no port. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; +import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; +import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; + +interface Harness { + app: FastifyInstance; + ctx: MockRouteContext; +} + +async function createHarness(): Promise<Harness> { + const app = Fastify({ logger: false }); + await app.register(fastifyCookie); + const ctx = createMockRouteContext(); + registerSessionRoutes(app, ctx); + installRouteErrorHandler(app); + await app.ready(); + return { app, ctx }; +} + +describe('POST /api/sessions/:id/git-fetch-settings', () => { + let harness: Harness; + + beforeEach(async () => { + harness = await createHarness(); + }); + + afterEach(async () => { + await harness.app.close(); + }); + + const post = (payload: unknown) => + harness.app.inject({ method: 'POST', url: `/api/sessions/${harness.ctx._sessionId}/git-fetch-settings`, payload }); + + it('stores the case’s own choice, persists and broadcasts it', async () => { + const res = await post({ enabled: true, intervalSeconds: 120 }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).data.gitFetchSettings).toEqual({ enabled: true, intervalSeconds: 120 }); + expect(harness.ctx._session.gitFetchEnabled).toBe(true); + expect(harness.ctx._session.gitFetchIntervalSeconds).toBe(120); + expect(harness.ctx.persistSessionState).toHaveBeenCalled(); + }); + + it('answers `null` for a case that follows the app, which is what the Session Options row shows', async () => { + const data = JSON.parse((await post({})).body).data.gitFetchSettings; + expect(data).toEqual({ enabled: null, intervalSeconds: null }); + }); + + it('clears the override on null so the app-wide setting applies again', async () => { + await post({ enabled: true, intervalSeconds: 120 }); + const res = await post({ enabled: null, intervalSeconds: null }); + expect(res.statusCode).toBe(200); + expect(harness.ctx._session.gitFetchEnabled).toBeUndefined(); + expect(harness.ctx._session.gitFetchIntervalSeconds).toBeUndefined(); + expect(JSON.parse(res.body).data.gitFetchSettings).toEqual({ enabled: null, intervalSeconds: null }); + }); + + it('leaves the other half as it was when only one row was saved', async () => { + await post({ enabled: true }); + expect(harness.ctx._session.gitFetchIntervalSeconds).toBeUndefined(); // still following the app on that half + const res = await post({ intervalSeconds: 90 }); + expect(JSON.parse(res.body).data.gitFetchSettings).toEqual({ enabled: true, intervalSeconds: 90 }); + }); + + it('rejects an interval outside the schema bounds, storing nothing, because that is what the scheduler acts on', async () => { + // `null` is NOT on this list: it is the schema's way of clearing the override, tested above. + for (const raw of [29, 3601, 12.5, '120', true]) { + const res = await post({ enabled: true, intervalSeconds: raw }); + expect(res.statusCode, String(raw)).toBe(400); + } + expect(harness.ctx._session.gitFetchEnabled).toBeUndefined(); + expect(harness.ctx.persistSessionState).not.toHaveBeenCalled(); + }); + + it.each([ + ['remote', { host: 'h', user: 'u' }], + ['docker', { container: 'c' }], + ])('refuses a %s case, storing nothing', async (kind, value) => { + (harness.ctx._session as unknown as Record<string, unknown>)[kind] = value; + const res = await post({ enabled: true, intervalSeconds: 120 }); + expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' }); + expect(harness.ctx._session.gitFetchEnabled).toBeUndefined(); + expect(harness.ctx.persistSessionState).not.toHaveBeenCalled(); + }); + + it('404s an unknown session, storing nothing', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/nope/git-fetch-settings', + payload: { enabled: true }, + }); + expect(res.statusCode).toBe(404); + expect(harness.ctx.persistSessionState).not.toHaveBeenCalled(); + }); +}); diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index b531756d..30d9c0cd 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -25,7 +25,7 @@ import { tmpdir } from 'node:os'; import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js'; -import { Session } from '../../src/session.js'; +import { GuardedInputBusyError, Session } from '../../src/session.js'; import { agentInbox } from '../../src/web/agent-inbox.js'; // Mock execFile so the send-key route's `tmux` invocation is observable (not run for real). @@ -265,6 +265,33 @@ describe('session-routes', () => { }); }); + it('answers 409 CONFLICT, without an error log, when a guarded submit cannot hold the key', async () => { + execFile.mockReset(); + const session = harness.ctx._session as unknown as { + performAuxiliaryInput: (op: () => Promise<void>) => Promise<void>; + }; + const original = session.performAuxiliaryInput; + session.performAuxiliaryInput = async () => { + throw new GuardedInputBusyError(); + }; + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/test-session-1/send-key', + payload: { key: 'S-Enter' }, + }); + expect(res.statusCode).toBe(409); + expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' }); + expect(res.json().error).toMatch(/guarded submit is in progress.*nothing was written/); + expect(errors).not.toHaveBeenCalled(); + expect(execFile).not.toHaveBeenCalled(); + } finally { + errors.mockRestore(); + session.performAuxiliaryInput = original; + } + }); + it('rejects keys outside the hex allowlist without invoking tmux', async () => { execFile.mockReset(); const res = await harness.app.inject({ @@ -984,6 +1011,16 @@ describe('session-routes', () => { // with no broadcast, so other open tabs kept showing the retired row until // their next unrelated fetch. expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:deleted', { id: 'ghost-session' }); + // Its stored edit history (diffs can hold secrets) goes with the record. + expect(harness.ctx.forgetEditHistory).toHaveBeenCalledWith('ghost-session'); + }); + + it('keeps the edit history of a pinned persisted-only session whose record is only demoted', async () => { + vi.mocked(harness.ctx.store.getSession).mockReturnValueOnce({ id: 'pinned-ghost', owner: undefined } as never); + vi.mocked(harness.ctx.store.demoteOrRemoveSession).mockReturnValueOnce('preserved'); + const res = await harness.app.inject({ method: 'DELETE', url: '/api/sessions/pinned-ghost' }); + expect(res.statusCode).toBe(200); + expect(harness.ctx.forgetEditHistory).not.toHaveBeenCalled(); }); it('a persisted-only delete drops the agent inbox the detach kept', async () => { @@ -1197,6 +1234,93 @@ describe('session-routes', () => { expect(session.writeBuffer).toEqual(['prompt', '\r']); }); + it('refuses tagged input with 409 and gives the seq back while a guarded submit holds the input lease', async () => { + const url = `/api/sessions/${harness.ctx._sessionId}/input`; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any; + session.writeBuffer.length = 0; + const post = (payload: unknown) => harness.app.inject({ method: 'POST', url, payload }); + session.guardedInputHeld = true; + for (const payload of [ + { input: 'tagged', seq: 7, clientId: 'cid-guard' }, + { input: 'prompt\r', useMux: true, seq: 1, clientId: 'cid-guard-mux' }, + ]) { + const refused = await post(payload); + expect(refused.statusCode, JSON.stringify(payload)).toBe(409); + expect(refused.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' }); + expect(refused.json().error).toMatch(/guarded submit is in progress.*nothing was written/); + } + expect(session.writeBuffer).toEqual([]); + expect(session.heldGuardedInput).toEqual([]); + // Once the lease is released the browser's retry of the same seq is applied, not + // rejected as a duplicate. + session.releaseGuardedLease(); + expect((await post({ input: 'tagged', seq: 7, clientId: 'cid-guard' })).statusCode).toBe(200); + expect(session.writeBuffer).toEqual(['tagged']); + }); + + it('holds an untagged raw POST during a guarded submit and writes it once the lease is released', async () => { + // The phone accessory keys and the Stop button's Ctrl+C are untagged, fire-and-forget + // POSTs that never read the answer, so a 409 here silently lost the tap. + const url = `/api/sessions/${harness.ctx._sessionId}/input`; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any; + session.writeBuffer.length = 0; + session.guardedInputHeld = true; + try { + for (const input of ['\x03', '\x1b[A']) { + const res = await harness.app.inject({ method: 'POST', url, payload: { input } }); + expect(res.statusCode, JSON.stringify(input)).toBe(200); + expect(res.json()).toMatchObject({ success: true }); + } + expect(session.writeBuffer).toEqual([]); + session.releaseGuardedLease(); + expect(session.writeBuffer).toEqual(['\x03', '\x1b[A']); + } finally { + session.releaseGuardedLease(); + } + }); + + it('lets untagged mux input wait its own turn behind the lease instead of refusing it', async () => { + const url = `/api/sessions/${harness.ctx._sessionId}/input`; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any; + session.writeBuffer.length = 0; + const post = (payload: unknown) => harness.app.inject({ method: 'POST', url, payload }); + session.guardedInputHeld = true; + try { + // Fire-and-forget `useMux`: answered at once, written in its turn. + expect((await post({ input: 'x', useMux: true })).statusCode).toBe(200); + // A plain prompt's mux write is awaited, so it answers once written. + const prompt = post({ input: 'prompt\r' }); + await vi.waitFor(() => expect(session.heldGuardedInput).toHaveLength(2)); + expect(session.writeBuffer).toEqual([]); + session.releaseGuardedLease(); + expect((await prompt).statusCode).toBe(200); + expect(session.writeBuffer).toEqual(['x', 'prompt\r']); + } finally { + session.releaseGuardedLease(); + } + }); + + it('refuses an untagged raw POST with 409 only when the hold queue is full', async () => { + const url = `/api/sessions/${harness.ctx._sessionId}/input`; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any; + session.writeBuffer.length = 0; + session.guardedInputHeld = true; + session.holdForGuardedLease = () => 'full'; + try { + const res = await harness.app.inject({ method: 'POST', url, payload: { input: '\x03' } }); + expect(res.statusCode).toBe(409); + expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' }); + expect(session.writeBuffer).toEqual([]); + } finally { + delete session.holdForGuardedLease; + session.releaseGuardedLease(); + } + }); + it('always applies untagged input (curl/legacy, no dedup)', async () => { const url = `/api/sessions/${harness.ctx._sessionId}/input`; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/test/routes/system-routes-settings-admin-only.test.ts b/test/routes/system-routes-settings-admin-only.test.ts index 49ea02cf..0980862c 100644 --- a/test/routes/system-routes-settings-admin-only.test.ts +++ b/test/routes/system-routes-settings-admin-only.test.ts @@ -54,6 +54,11 @@ vi.mock('node:fs/promises', () => ({ default: { readFile: vi.fn(async () => JSON.stringify(STORED)), writeFile: vi.fn(async () => undefined), + // settings.json is written to a temp file and renamed over the real one (settings-writer.ts). + realpath: vi.fn(async (p: string) => p), + mkdir: vi.fn(async () => undefined), + rename: vi.fn(async () => undefined), + rm: vi.fn(async () => undefined), }, })); diff --git a/test/routes/system-routes-settings-get-plan-usage-default.test.ts b/test/routes/system-routes-settings-get-plan-usage-default.test.ts index f2496d72..2e6b9ab3 100644 --- a/test/routes/system-routes-settings-get-plan-usage-default.test.ts +++ b/test/routes/system-routes-settings-get-plan-usage-default.test.ts @@ -4,8 +4,8 @@ * * PR #361 once made it reconcile `showPlanUsageLimits` on first read: if the * key was absent, the route persisted `true`. But readJsonConfig() answers - * `{}` for ANY read failure (parse error, EACCES, EMFILE, a read landing inside - * PUT's non-atomic write), not only ENOENT, and every page load calls this + * `{}` for ANY read failure (parse error, EACCES, EMFILE, and at the time a read + * landing inside PUT's non-atomic write), not only ENOENT, and every page load calls this * route, so one unlucky read replaced the whole settings file with a one-key * file. The default now lives in the readers instead: an absent key means ON * to readPlanUsageTelemetryEnabled() (pinned in test/hooks-config.test.ts) and diff --git a/test/routes/system-routes-settings-partial-put.test.ts b/test/routes/system-routes-settings-partial-put.test.ts index 416b1d64..afe22c85 100644 --- a/test/routes/system-routes-settings-partial-put.test.ts +++ b/test/routes/system-routes-settings-partial-put.test.ts @@ -70,6 +70,11 @@ vi.mock('node:fs/promises', () => ({ default: { readFile: vi.fn(async () => JSON.stringify(EXISTING_SETTINGS)), writeFile: vi.fn(async () => undefined), + // settings.json is written to a temp file and renamed over the real one (settings-writer.ts). + realpath: vi.fn(async (p: string) => p), + mkdir: vi.fn(async () => undefined), + rename: vi.fn(async () => undefined), + rm: vi.fn(async () => undefined), }, })); diff --git a/test/routes/system-routes-tunnel-guard.test.ts b/test/routes/system-routes-tunnel-guard.test.ts index 8da2b674..8c46ed7b 100644 --- a/test/routes/system-routes-tunnel-guard.test.ts +++ b/test/routes/system-routes-tunnel-guard.test.ts @@ -1,25 +1,21 @@ /** - * @fileoverview COD-55 — tunnel password guard. - * - * Enabling the Cloudflare tunnel publishes the whole app (full terminal control = - * effectively RCE) to a public *.trycloudflare.com URL. When no CODEMAN_PASSWORD - * is set, requests through that URL are unauthenticated. These tests assert the - * PUT /api/settings tunnel-enable path REFUSES to start the tunnel unless a - * password is set OR the unauthenticated-network opt-in is acknowledged. - * - * Uses app.inject() — no real HTTP ports needed. Port: N/A. + * @fileoverview Issue #706 keeps LAN acknowledgement separate from public tunnel consent. A login, tunnel-specific env opt-in, launch flag or per-request acknowledgement permits enabling. Early refusals persist nothing; late race refusals save unrelated settings without enabling the tunnel. Status reports login configuration independently of exposure opt-ins. */ - import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; import { registerSystemRoutes } from '../../src/web/routes/system-routes.js'; -// Settings are written to disk via fs/promises — stub so the guard test never +// Settings are written to disk via fs/promises; stub so the guard test never // touches the real settings.json, and so we can assert "not persisted on refusal". vi.mock('node:fs/promises', () => ({ default: { readFile: vi.fn(async () => '{}'), writeFile: vi.fn(async () => undefined), + // settings.json is written to a temp file and renamed over the real one (settings-writer.ts). + realpath: vi.fn(async (p: string) => p), + mkdir: vi.fn(async () => undefined), + rename: vi.fn(async () => undefined), + rm: vi.fn(async () => undefined), }, })); @@ -34,129 +30,331 @@ vi.mock('node:fs', async (importOriginal) => { }); import fs from 'node:fs/promises'; +import { SETTINGS_PATH } from '../../src/web/route-helpers.js'; const mockedWriteFile = vi.mocked(fs.writeFile); +const mockedRename = vi.mocked(fs.rename); -/** Build a tunnelManager stub the route's ctx can use. */ -function makeTunnelManager(running = false) { +/** + * settings.json writes as [path, contents]. The writer (settings-writer.ts) writes each one to a + * temp file and renames it over settings.json, so a write counts once its rename lands. Every + * writeFile must be such a temp file and every one must be renamed into place: a direct write to + * settings.json, or any other write that was never renamed, fails here rather than going unseen. + */ +function settingsWrites(): [string, string][] { + const writes = mockedWriteFile.mock.calls.map(([path, contents]) => [String(path), String(contents)]); + const renames = mockedRename.mock.calls.map(([from, to]) => [String(from), String(to)]); + const isTemp = (path: string) => path.startsWith(`${SETTINGS_PATH}.`) && path.endsWith('.tmp'); + const notRenamed = writes.filter(([path]) => !isTemp(path) || !renames.some(([from]) => from === path)); + expect( + notRenamed.map(([path]) => path), + 'writes that were not a temp file renamed into place' + ).toEqual([]); + expect(renames.length, 'one rename per write').toBe(writes.length); + return renames.map(([from, to]) => { + const write = writes.find(([path]) => path === from); + expect(write, `rename of ${from}, which was never written`).toBeDefined(); + return [to, String(write?.[1])]; + }); +} + +const STATUS = { running: false, url: null }; + +function makeTunnelManager() { return { start: vi.fn(), stop: vi.fn(), - isRunning: vi.fn(() => running), + isRunning: vi.fn(() => false), getUrl: vi.fn(() => null), - getStatus: vi.fn(() => ({ running })), + getStatus: vi.fn(() => ({ ...STATUS })), }; } -describe('COD-55 tunnel password guard (PUT /api/settings tunnelEnabled)', () => { +describe('tunnel authentication policy', () => { let harness: RouteTestHarness; let tunnel: ReturnType<typeof makeTunnelManager>; - const savedPassword = process.env.CODEMAN_PASSWORD; - const savedOptIn = process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK; + const envKeys = [ + 'CODEMAN_PASSWORD', + 'CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK', + 'CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL', + 'CODEMAN_MULTIUSER', + ] as const; + let savedEnv: (string | undefined)[]; beforeEach(async () => { - harness = await createRouteTestHarness(registerSystemRoutes); + savedEnv = envKeys.map((key) => process.env[key]); + for (const key of envKeys) delete process.env[key]; + harness = await createRouteTestHarness(registerSystemRoutes, { + authUser: { username: 'root', role: 'admin' }, + }); vi.clearAllMocks(); + vi.mocked(fs.readFile).mockResolvedValue('{}'); mockedWriteFile.mockResolvedValue(undefined); - tunnel = makeTunnelManager(false); - // tunnelManager is null in the default mock ctx — inject our spy. + tunnel = makeTunnelManager(); (harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel; - delete process.env.CODEMAN_PASSWORD; - delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK; + (harness.ctx as unknown as { allowUnauthenticatedTunnel: boolean }).allowUnauthenticatedTunnel = false; }); afterEach(async () => { - await harness.app.close(); - if (savedPassword === undefined) delete process.env.CODEMAN_PASSWORD; - else process.env.CODEMAN_PASSWORD = savedPassword; - if (savedOptIn === undefined) delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK; - else process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK = savedOptIn; + try { + await harness?.app.close(); + } finally { + envKeys.forEach((key, i) => { + if (savedEnv[i] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[i]; + }); + } }); - it('REFUSES tunnel-enable with no password and no opt-in (4xx, start not called)', async () => { - const res = await harness.app.inject({ - method: 'PUT', - url: '/api/settings', - payload: { tunnelEnabled: true }, - }); + const put = (payload: Record<string, boolean> = { tunnelEnabled: true }) => + harness.app.inject({ method: 'PUT', url: '/api/settings', payload }); - expect(res.statusCode).toBeGreaterThanOrEqual(400); - expect(res.statusCode).toBeLessThan(500); - const body = JSON.parse(res.body); - expect(body.success).toBe(false); - // Message should tell the user how to fix it. - expect(body.error).toMatch(/CODEMAN_PASSWORD|CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK/); - // The tunnel must NOT have been started. - expect(tunnel.start).not.toHaveBeenCalled(); - // And tunnelEnabled:true must NOT have been persisted. - expect(mockedWriteFile).not.toHaveBeenCalled(); - }); - - it('ALLOWS tunnel-enable when CODEMAN_PASSWORD is set (start called, 200)', async () => { - process.env.CODEMAN_PASSWORD = 'hunter2'; - - const res = await harness.app.inject({ - method: 'PUT', - url: '/api/settings', - payload: { tunnelEnabled: true }, + function expectRefused(res: Awaited<ReturnType<typeof put>>) { + expect.soft(res.statusCode).toBe(403); + expect.soft(res.json()).toEqual({ + success: false, + errorCode: 'OPERATION_FAILED', + error: expect.any(String), }); + expect.soft(tunnel.start.mock.calls).toEqual([]); + expect.soft(mockedWriteFile.mock.calls).toEqual([]); + } + function expectEnabled(res: Awaited<ReturnType<typeof put>>) { expect(res.statusCode).toBe(200); - expect(tunnel.start).toHaveBeenCalledTimes(1); - }); + expect(tunnel.start.mock.calls).toEqual([[harness.ctx.port, harness.ctx.https]]); + expect(tunnel.stop.mock.calls).toEqual([]); + expect(settingsWrites()).toEqual([[SETTINGS_PATH, JSON.stringify({ tunnelEnabled: true }, null, 2)]]); + } - it('ALLOWS tunnel-enable with the unauthenticated-network opt-in acknowledged (start called, 200)', async () => { + it('refuses the incident: network acknowledgement does not authorize a public tunnel', async () => { process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK = '1'; - - const res = await harness.app.inject({ - method: 'PUT', - url: '/api/settings', - payload: { tunnelEnabled: true }, - }); - - expect(res.statusCode).toBe(200); - expect(tunnel.start).toHaveBeenCalledTimes(1); + const res = await put(); + expectRefused(res); + expect.soft(res.json().error).toContain('CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL'); + expect.soft(res.json().error).toContain('CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK'); + expect.soft(res.json().error).toMatch(/no longer/i); }); - it('ALLOWS tunnel-enable with per-request acknowledgeUnauthTunnel:true (start called, 200, flag not persisted)', async () => { - const res = await harness.app.inject({ - method: 'PUT', - url: '/api/settings', - payload: { tunnelEnabled: true, acknowledgeUnauthTunnel: true }, - }); - - expect(res.statusCode).toBe(200); - expect(tunnel.start).toHaveBeenCalledTimes(1); - // The action flag must NOT be persisted to settings.json. - expect(mockedWriteFile).toHaveBeenCalled(); - const persisted = JSON.parse(mockedWriteFile.mock.calls[0][1] as string); - expect(persisted.acknowledgeUnauthTunnel).toBeUndefined(); - expect(persisted.tunnelEnabled).toBe(true); + it('refuses without credentials or acknowledgement and names the tunnel opt-in', async () => { + const res = await put(); + expectRefused(res); + expect(res.json().error).toContain('CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL'); }); - it('still REFUSES when acknowledgeUnauthTunnel is false (4xx, start not called)', async () => { - const res = await harness.app.inject({ - method: 'PUT', - url: '/api/settings', - payload: { tunnelEnabled: true, acknowledgeUnauthTunnel: false }, - }); + it.each(['1', 'true', 'yes', 'on', ' TRUE '])( + 'allows tunnel env value %j without an acknowledgement', + async (value) => { + process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL = value; + expectEnabled(await put()); + } + ); - expect(res.statusCode).toBeGreaterThanOrEqual(400); - expect(res.statusCode).toBeLessThan(500); - expect(tunnel.start).not.toHaveBeenCalled(); - expect(mockedWriteFile).not.toHaveBeenCalled(); + it('allows the route context flag without an env opt-in or acknowledgement', async () => { + (harness.ctx as unknown as { allowUnauthenticatedTunnel: boolean }).allowUnauthenticatedTunnel = true; + expectEnabled(await put()); }); - it('does not guard tunnel-disable (tunnelEnabled:false always allowed)', async () => { - tunnel = makeTunnelManager(true); - (harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel; + it('allows acknowledgement alone and persists only tunnelEnabled', async () => { + expectEnabled(await put({ tunnelEnabled: true, acknowledgeUnauthTunnel: true })); + }); - const res = await harness.app.inject({ - method: 'PUT', - url: '/api/settings', - payload: { tunnelEnabled: false }, - }); + it('refuses a false acknowledgement', async () => { + expectRefused(await put({ tunnelEnabled: true, acknowledgeUnauthTunnel: false })); + }); + it('allows a configured password without acknowledgement', async () => { + process.env.CODEMAN_PASSWORD = 'test-password'; + expectEnabled(await put()); + }); + + it('allows a multi-user admin without acknowledgement', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + expectEnabled(await put()); + }); + + it.each(['0', 'false', '', 'enabled'])('refuses non-true tunnel env value %j', async (value) => { + process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL = value; + expectRefused(await put()); + }); + + it('does not guard disabling a running tunnel', async () => { + tunnel.isRunning.mockReturnValue(true); + const res = await put({ tunnelEnabled: false }); expect(res.statusCode).toBe(200); - expect(tunnel.stop).toHaveBeenCalledTimes(1); + expect(tunnel.start.mock.calls).toEqual([]); + expect(tunnel.stop.mock.calls).toEqual([[]]); + expect(settingsWrites()).toEqual([[SETTINGS_PATH, JSON.stringify({ tunnelEnabled: false }, null, 2)]]); + }); + + it.each([ + ['password', 'CODEMAN_PASSWORD', 'test-password', false, true], + ['multi-user', 'CODEMAN_MULTIUSER', '1', false, true], + ['neither', undefined, undefined, false, false], + ['empty password', 'CODEMAN_PASSWORD', '', false, false], + ['network opt-in only', 'CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK', '1', false, false], + ['tunnel opt-in only', 'CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL', '1', false, false], + ['tunnel flag only', undefined, undefined, true, false], + ] as const)('status reports loginConfigured for %s', async (_label, key, value, flag, loginConfigured) => { + if (key !== undefined) process.env[key] = value; + (harness.ctx as unknown as { allowUnauthenticatedTunnel: boolean }).allowUnauthenticatedTunnel = flag; + const res = await harness.app.inject({ method: 'GET', url: '/api/tunnel/status' }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ ...STATUS, loginConfigured }); + expect(tunnel.getStatus.mock.calls).toEqual([[]]); + expect(mockedWriteFile.mock.calls).toEqual([]); + }); +}); + +describe('tunnel enable authorization across a concurrent stop', () => { + let harness: RouteTestHarness; + let tunnel: ReturnType<typeof makeTunnelManager>; + let running: boolean; + let stopDuringWrite: boolean; + let afterWrite: (() => void) | undefined; + let storedSettings: Record<string, boolean>; + const envKeys = [ + 'CODEMAN_PASSWORD', + 'CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK', + 'CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL', + 'CODEMAN_MULTIUSER', + ] as const; + let savedEnv: (string | undefined)[]; + + beforeEach(async () => { + savedEnv = envKeys.map((key) => process.env[key]); + for (const key of envKeys) delete process.env[key]; + harness = await createRouteTestHarness(registerSystemRoutes, { + authUser: { username: 'root', role: 'admin' }, + }); + vi.clearAllMocks(); + running = true; + stopDuringWrite = true; + afterWrite = undefined; + storedSettings = { tunnelEnabled: false, subagentTrackingEnabled: false }; + tunnel = makeTunnelManager(); + tunnel.isRunning.mockImplementation(() => running); + (harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel; + (harness.ctx as unknown as { allowUnauthenticatedTunnel: boolean }).allowUnauthenticatedTunnel = false; + vi.mocked(fs.readFile).mockImplementation(async (path) => { + expect(path).toBe(SETTINGS_PATH); + return JSON.stringify(storedSettings); + }); + const pending = new Map<string, string>(); + const store = (contents: string) => { + storedSettings = JSON.parse(contents); + // The stop becomes visible during the awaited write, regardless of isRunning() call count. + if (stopDuringWrite) running = false; + afterWrite?.(); + }; + mockedWriteFile.mockImplementation(async (path, contents) => { + // A direct write to settings.json lands at once, so the stored file is what the route really left; + // settingsWrites() then fails it for not going through a temp file and a rename. + if (path === SETTINGS_PATH) store(contents as string); + else pending.set(String(path), contents as string); + }); + mockedRename.mockImplementation(async (from, to) => { + expect(to).toBe(SETTINGS_PATH); + store(pending.get(String(from)) as string); + }); + }); + + afterEach(async () => { + try { + await harness?.app.close(); + } finally { + envKeys.forEach((key, i) => { + if (savedEnv[i] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[i]; + }); + vi.mocked(fs.readFile).mockResolvedValue('{}'); + mockedWriteFile.mockResolvedValue(undefined); + mockedRename.mockResolvedValue(undefined); + } + }); + + const put = (acknowledgement: Record<string, boolean> = {}) => + harness.app.inject({ + method: 'PUT', + url: '/api/settings', + payload: { tunnelEnabled: true, autoNameSessions: false, ...acknowledgement }, + }); + + function expectWritten(tunnelEnabled: boolean) { + const expected = { tunnelEnabled, subagentTrackingEnabled: false, autoNameSessions: false }; + // Check every write so a transient unauthorized enable followed by a rollback also fails. + expect + .soft(settingsWrites().map(([path, contents]) => ({ path, settings: JSON.parse(contents) }))) + .toEqual([{ path: SETTINGS_PATH, settings: expected }]); + expect.soft(storedSettings).toEqual(expected); + } + + function expectLateRefusal(res: Awaited<ReturnType<typeof put>>) { + expect.soft(res.statusCode).toBe(403); + expect.soft(res.json()).toEqual({ + success: false, + errorCode: 'OPERATION_FAILED', + error: expect.any(String), + }); + expect.soft(res.json().error).toContain('CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL'); + expect.soft(res.json().error).toMatch(/not (re)?start/i); + expect.soft(tunnel.start.mock.calls).toEqual([]); + expect.soft(tunnel.stop.mock.calls).toEqual([]); + expectWritten(false); + } + + function expectAuthorizedRestart(res: Awaited<ReturnType<typeof put>>) { + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({}); + expect(tunnel.start.mock.calls).toEqual([[harness.ctx.port, harness.ctx.https]]); + expect(tunnel.stop.mock.calls).toEqual([]); + expectWritten(true); + } + + it('refuses an unauthorized restart after a concurrent stop while saving other settings', async () => { + expectLateRefusal(await put()); + }); + + it.each(['password', 'tunnel env', 'route flag', 'per-request acknowledgement', 'multi-user admin'] as const)( + 'allows a restart with %s authorization', + async (authorization) => { + if (authorization === 'password') process.env.CODEMAN_PASSWORD = 'test-password'; + if (authorization === 'tunnel env') process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL = '1'; + if (authorization === 'route flag') { + (harness.ctx as unknown as { allowUnauthenticatedTunnel: boolean }).allowUnauthenticatedTunnel = true; + } + if (authorization === 'multi-user admin') process.env.CODEMAN_MULTIUSER = '1'; + expectAuthorizedRestart( + await put(authorization === 'per-request acknowledgement' ? { acknowledgeUnauthTunnel: true } : {}) + ); + } + ); + + it.each([false, true])( + 'preserves stored tunnelEnabled=%s when an unauthorized tunnel stays running', + async (tunnelEnabled) => { + stopDuringWrite = false; + storedSettings.tunnelEnabled = tunnelEnabled; + const res = await put(); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({}); + expect(tunnel.start.mock.calls).toEqual([]); + expect(tunnel.stop.mock.calls).toEqual([]); + expectWritten(tunnelEnabled); + } + ); + + it('does not acquire restart authorization from an env opt-in added during the write', async () => { + afterWrite = () => { + process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL = '1'; + }; + expectLateRefusal(await put()); + }); + + it('retains the request authorization when the env opt-in is removed during the write', async () => { + process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL = '1'; + afterWrite = () => { + delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL; + }; + expectAuthorizedRestart(await put()); }); }); diff --git a/test/routes/system-routes.test.ts b/test/routes/system-routes.test.ts index a5550aed..40cad281 100644 --- a/test/routes/system-routes.test.ts +++ b/test/routes/system-routes.test.ts @@ -22,6 +22,11 @@ vi.mock('node:fs/promises', () => ({ default: { readFile: vi.fn(async () => '{}'), writeFile: vi.fn(async () => undefined), + // settings.json is written to a temp file and renamed over the real one (settings-writer.ts). + realpath: vi.fn(async (p: string) => p), + mkdir: vi.fn(async () => undefined), + rename: vi.fn(async () => undefined), + rm: vi.fn(async () => undefined), }, })); @@ -415,6 +420,19 @@ describe('system-routes', () => { agentInbox.resetForTests(); } }); + + it('deletes the edit history of every session it cleaned (diffs can hold secrets)', async () => { + harness.ctx.tabLayouts.runStaleSessionCleanup = vi.fn(async () => ({ + count: 2, + cleaned: [ + { id: 'stale-1', name: 'a' }, + { id: 'stale-2', name: 'b' }, + ], + })) as never; + const res = await harness.app.inject({ method: 'POST', url: '/api/cleanup-state' }); + expect(JSON.parse(res.body).cleanedSessions).toBe(2); + expect(vi.mocked(harness.ctx.forgetEditHistory).mock.calls).toEqual([['stale-1'], ['stale-2']]); + }); }); // ========== GET /api/subagents ========== diff --git a/test/security-notices.test.ts b/test/security-notices.test.ts index bc099517..236e5866 100644 --- a/test/security-notices.test.ts +++ b/test/security-notices.test.ts @@ -333,8 +333,7 @@ describe('the shipped security-notices.json', () => { const nonBlank = (value: unknown) => typeof value === 'string' && value.trim().length > 0; it('is a list of notices with unique, stable ids', () => { - expect(Array.isArray(notices)).toBe(true); - expect(notices.length).toBeGreaterThan(0); + expect(Array.isArray(notices)).toBe(true); // empty is fine: every notice has been retired const all = notices.map((n) => n.id); expect(new Set(all).size).toBe(all.length); for (const id of all) expect(id).toMatch(/^[a-z0-9][a-z0-9-]*$/); @@ -383,22 +382,10 @@ describe('the shipped security-notices.json', () => { } }); - it('shows the OpenCode notice, with its title, only to OpenCode users', () => { - const breach = notices.find((n) => n.id === 'opencode-console-breach-2026-10'); - expect(breach?.cli).toBe('opencode'); - const [shown] = core.select(notices, { available: { opencode: true }, now: NOW }); - expect(shown).toMatchObject({ - id: 'opencode-console-breach-2026-10', - title: 'OpenCode Console breach: check your API keys', - label: "Read OpenCode's notice", - url: 'https://x.com/opencode/status/2111519637049319818', - }); - expect(core.select(notices, { available: { opencode: true }, now: NOW, lang: 'zh-CN' })[0].title).toBe( - 'OpenCode Console 遭入侵:请检查你的 API 密钥' - ); - expect(core.select(notices, { available: { opencode: false, claude: true }, now: NOW })).toEqual([]); - expect(core.select(notices, { available: {}, now: NOW })).toEqual([]); - expect(core.select(notices, { available: { opencode: true }, now: at(2027, 1, 1) })).toEqual([]); + it('no longer ships the retired OpenCode Console notice, even to a fresh OpenCode install', () => { + // It stayed in the file past its use, and a new install has no seen list, so everyone got it. + expect(notices.map((n) => n.id)).not.toContain('opencode-console-breach-2026-10'); + expect(core.select(notices, { available: { opencode: true }, seen: [], now: NOW })).toEqual([]); }); it('is fetched by the page and never generated by the release script', () => { diff --git a/test/sensitive-path.test.ts b/test/sensitive-path.test.ts index 90b61b10..c24b26ac 100644 --- a/test/sensitive-path.test.ts +++ b/test/sensitive-path.test.ts @@ -89,6 +89,18 @@ describe('isSensitivePath', () => { ['codeman intent profiles', `${HOME}/.codeman/intents.json`], ['codeman intents on a named instance', `${HOME}/.codeman-beta/intents.json`], ['codeman agent inbox snapshot', `${HOME}/.codeman/agent-inbox.json`], + // The edit history stores hold every diff an agent wrote (keys and .env + // contents included): 0600 and kept out of /api/search like intents.json. + ['codeman edit history store', `${HOME}/.codeman/edit-history/s1.json`], + ['codeman edit history on a named instance', `${HOME}/.codeman-beta/edit-history/s1.json`], + // Credentials outside settings.json, and the backups that copy all of these. + ['codeman Discord bot token', `${HOME}/.codeman/discord.json`], + ['codeman Discord token on a named instance', `${HOME}/.codeman-beta/discord.json`], + ['codeman webhook URL', `${HOME}/.codeman/webhook.json`], + ['codeman custom endpoint API keys', `${HOME}/.codeman/custom-model-hosts.json`], + ['a generated custom model config', `${HOME}/.codeman/custom-model-configs/s1/opencode.json`], + ['a backed-up user list', `${HOME}/.codeman/backups/config/cfg-20261104-120000/users.json`], + ['a backed-up settings file', `${HOME}/.codeman/backups/config/cfg-20261104-120000/settings.json`], ]; it.each(blocked)('blocks the %s', (_label, path) => { diff --git a/test/server-index-title.test.ts b/test/server-index-title.test.ts index 726b5f09..b1cb7379 100644 --- a/test/server-index-title.test.ts +++ b/test/server-index-title.test.ts @@ -115,7 +115,9 @@ describe('WebServer index.html <title> templating (#82)', () => { // enabled CLI declares a gutter, so it needs stripping on every machine. .replace(/<script>window\.__codemanTranscriptGutter=\{.*?\};<\/script>\n/, '') // Likewise the page-key mode list: an array, possibly empty, always injected. - .replace(/<script>window\.__codemanTranscriptPageKeys=\[.*?\];<\/script>\n/, ''); + .replace(/<script>window\.__codemanTranscriptPageKeys=\[.*?\];<\/script>\n/, '') + // And the keyboard-hold mode list, built the same way. + .replace(/<script>window\.__codemanKeyboardHoldModes=\[.*?\];<\/script>\n/, ''); const beforeTitle = rawTemplate.split('<title>Codeman')[0]; const afterTitle = rawTemplate.split('Codeman')[1]; expect(html.startsWith(beforeTitle)).toBe(true); diff --git a/test/service-installer.test.ts b/test/service-installer.test.ts index 0115a59f..01cf1ad5 100644 --- a/test/service-installer.test.ts +++ b/test/service-installer.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect } from 'vitest'; import { + resolveServicePlan, buildLaunchAgentPlist, buildServiceEnv, buildServicePath, @@ -177,3 +178,26 @@ describe('detectServiceKind', () => { expect(detectServiceKind()).toBe(expected); }); }); + +describe('resolveServicePlan tunnel opt-in', () => { + it.each(['systemd', 'launchd'] as const)('carries the tunnel flag into %s args', (kind) => { + const options = { + host: '0.0.0.0', + port: 8080, + https: false, + allowUnauthenticatedNetwork: true, + allowUnauthenticatedTunnel: true, + multiuser: true, + }; + expect(resolveServicePlan(kind, options).args).toEqual([ + 'web', + '--host', + '0.0.0.0', + '--port', + '8080', + '--allow-unauthenticated-network', + '--allow-unauthenticated-tunnel', + '--multiuser', + ]); + }); +}); diff --git a/test/session-custom-model-restart.test.ts b/test/session-custom-model-restart.test.ts index 61ee1ef3..391f012d 100644 --- a/test/session-custom-model-restart.test.ts +++ b/test/session-custom-model-restart.test.ts @@ -27,6 +27,8 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { Session } from '../src/session.js'; +import { getCli } from '../src/config/cli-registry/index.js'; +import { buildCustomModelInjection } from '../src/custom-model-injection.js'; import { TmuxManager } from '../src/tmux-manager.js'; import type { MuxSession, SessionMode } from '../src/types.js'; @@ -225,3 +227,29 @@ describe('launchModel reaches the CLI through its own launch param', () => { expect(respawn.mock.calls[0][0].model).toBeUndefined(); }); }); + +describe('Codex custom endpoint model overrides', () => { + it('selects the local model over a native launch default and restores the default on clear', async () => { + const entry = getCli('codex'); + if (!entry) throw new Error('Missing Codex entry'); + const injection = buildCustomModelInjection( + entry, + { id: 'ep', label: 'Local', baseUrl: 'http://box:8080' }, + 'qwen3' + ); + if (injection.kind !== 'configDir') throw new Error('Expected isolated Codex config'); + const { session, respawn } = liveSession('codex', { + codexConfig: { model: 'native-model', reasoningEffort: 'high' }, + }); + session.setCustomModel( + { endpointId: 'ep', modelId: 'qwen3', envKeys: ['CODEX_HOME'], launchModel: injection.launchModel }, + { CODEX_HOME: '/isolated/codex' } + ); + await session.restartCli(); + expect(respawn.mock.calls[0][0].codexConfig).toEqual({ model: 'qwen3', reasoningEffort: 'high' }); + session.setCustomModel(undefined); + respawn.mockClear(); + await session.restartCli(); + expect(respawn.mock.calls[0][0].codexConfig).toEqual({ model: 'native-model', reasoningEffort: 'high' }); + }); +}); diff --git a/test/session-options-structure.test.ts b/test/session-options-structure.test.ts index 9f4f2696..e59ea7c7 100644 --- a/test/session-options-structure.test.ts +++ b/test/session-options-structure.test.ts @@ -34,13 +34,18 @@ function methodBody(signature: string): string { return sessionUi.slice(start, sessionUi.indexOf('\n },', start)); } -const TABS = ['respawn', 'context', 'ralph', 'summary']; +const TABS = ['respawn', 'context', 'ralph', 'summary', 'git']; describe('Session Options modal structure', () => { it('keeps every element openSessionOptions and switchOptionsTab touch by id', () => { const modal = optionsModal(); const ids = new Set(); - for (const sig of ['openSessionOptions(sessionId)', 'switchOptionsTab(tabName)', 'getRalphConfig()']) { + for (const sig of [ + 'openSessionOptions(sessionId)', + 'switchOptionsTab(tabName)', + 'getRalphConfig()', + 'updateGitFetchStatus(sessionId)', + ]) { for (const m of methodBody(sig).matchAll(/getElementById\('([A-Za-z0-9_-]+)'\)/g)) ids.add(m[1]); } // openSessionOptions also drives elements outside this modal (tabs, toasts); @@ -83,6 +88,40 @@ describe('Session Options modal structure', () => { expect(modal.match(/]*data-tab="context"[^>]*>/)?.[0]).not.toContain('data-claude-only'); }); + it('keeps the Git rail entry for every local session, and hides it for the ones nothing is ever fetched for', () => { + const modal = optionsModal(); + // A shell session has repositories too, so Git must not ride the Claude-only flag. + expect(modal.match(/]*data-tab="git"[^>]*>/)?.[0]).not.toContain('data-claude-only'); + const open = methodBody('openSessionOptions(sessionId)'); + expect(open).toContain('.set-rail-item[data-tab="git"]'); + // Remote and Docker sessions answer 'unsupported' on both git routes and the scheduler never fetches them, + // so the entry goes the same way the Claude-only ones do. + expect(open).toMatch(/gitTabBtn\.style\.display = session\.remote \|\| session\.docker \? 'none' : ''/); + }); + + it('makes the Git rows inert while a session follows the app, the way the keep-warm rows go inert', () => { + const modal = optionsModal(); + const update = methodBody('updateGitFetchStatus(sessionId)'); + expect(update).toContain('set-row-disabled'); + expect(update).toContain('el.disabled = follows'); + // Decided from BOTH fields: a half-choice means the session is not following, and the half that still + // follows shows the app's value while the other shows its own; a switch-only check would gray out a live row. + expect(update).toMatch( + /follows = session\.gitFetchEnabled === undefined && session\.gitFetchIntervalSeconds === undefined/ + ); + // Following saves null on both halves, which is what clears the override on the route. + const save = methodBody('async autoSaveGitFetch()'); + expect(save).toContain('{ enabled: null, intervalSeconds: null }'); + // Saves through the helper that says whether the call went through: `_apiPost` hands back the Response + // without checking it, so a body the route refused (a 400) read as a save, and the panel showed a choice + // the server never took. + expect(save).toContain('this._apiJson(`/api/sessions/${this.editingSessionId}/git-fetch-settings`'); + expect(save).toContain('if (!data)'); + expect(save).not.toMatch(/await this\._apiPost\(/); + // The status row is runtime text this method writes, so translating it would rewrite the stamp. + expect(modal).toContain('id="gitFetchStatus" data-i18n-skip hidden'); + }); + it('uses the shared settings surface rather than the modal-tab chrome', () => { const modal = optionsModal(); expect(modal).toContain('class="modal-content modal-lg set-shell"'); diff --git a/test/session-settings-recovery.test.ts b/test/session-settings-recovery.test.ts index 881c64fa..b67c4c4a 100644 --- a/test/session-settings-recovery.test.ts +++ b/test/session-settings-recovery.test.ts @@ -40,6 +40,7 @@ describe('restoreMuxSessions() hands back every persisted user setting', () => { ['auto-compact', /session\.setAutoCompact\(/], ['auto-resume', /session\.restoreAutoResume\(/], ['nice priority', /session\.setNice\(/], + ['git fetch', /gitFetchEnabled:\s*savedState\?\.gitFetchEnabled/], ])('%s', (_label, pattern) => { expect(body).toMatch(pattern); }); @@ -58,6 +59,15 @@ describe('restoreMuxSessions() hands back every persisted user setting', () => { expect(body, `restoreMuxSessions lacks ${call}`).toContain(call); } }); + + it('re-applies the fetch choice only when the case actually chose one', () => { + // `undefined` is what "follows the app" means here, so a restore that called the setter unconditionally + // would hand every case the app default as if it had picked it, and the half-choice rows would read wrong. + const reapply = functionBody('reapplyPersistedSessionState'); + expect(reapply).toMatch( + /if \(saved\.gitFetchEnabled !== undefined \|\| saved\.gitFetchIntervalSeconds !== undefined\)[\s\S]{0,160}session\.setGitFetchSettings\(saved\.gitFetchEnabled, saved\.gitFetchIntervalSeconds\)/ + ); + }); }); describe('the setters round-trip through toState()', () => { @@ -83,4 +93,26 @@ describe('the setters round-trip through toState()', () => { imageWatcherEnabled: true, }); }); + + it('round-trips the fetch choice, and keeps a case that follows the app unchosen', () => { + const chose = new Session({ workingDir: '/tmp', mode: 'shell' }); + chose.setGitFetchSettings(true, 120); + const state = chose.toState(); + expect(state).toMatchObject({ gitFetchEnabled: true, gitFetchIntervalSeconds: 120 }); + + const rebuilt = new Session({ + workingDir: '/tmp', + mode: 'shell', + gitFetchEnabled: state.gitFetchEnabled, + gitFetchIntervalSeconds: state.gitFetchIntervalSeconds, + }); + expect(rebuilt.toState()).toMatchObject({ gitFetchEnabled: true, gitFetchIntervalSeconds: 120 }); + + // Undefined is the whole meaning of "follows the app": a restore that wrote the app default into the + // case would make it look like the case had picked it, and the row would stop following. + const follower = new Session({ workingDir: '/tmp', mode: 'shell' }); + follower.setGitFetchSettings(undefined, undefined); + expect(follower.toState().gitFetchEnabled).toBeUndefined(); + expect(follower.toState().gitFetchIntervalSeconds).toBeUndefined(); + }); }); diff --git a/test/settings-admin-only-multiuser.test.ts b/test/settings-admin-only-multiuser.test.ts index 533007ae..e2407d43 100644 --- a/test/settings-admin-only-multiuser.test.ts +++ b/test/settings-admin-only-multiuser.test.ts @@ -53,7 +53,7 @@ async function send(method: string, path: string, headers: Record }; } -/** The quick-start write of lastUsedCase is fire-and-forget: poll for it briefly. */ +/** Read settings.json until `predicate` holds (briefly), so a write that never comes fails on its value. */ async function settleSettings(predicate: (s: Record) => boolean): Promise> { let last: Record = {}; for (let i = 0; i < 40; i++) { @@ -61,7 +61,7 @@ async function settleSettings(predicate: (s: Record) => boolean last = JSON.parse(await readSettingsBytes()); if (predicate(last)) return last; } catch { - // the write is not atomic: a read that lands between its truncate and its data is empty, so poll again + // not there yet, or not JSON: poll again } await new Promise((r) => setTimeout(r, 25)); } @@ -127,7 +127,7 @@ describe('a non-admin and the server-wide settings.json', () => { const mine = await send('POST', '/api/quick-start', bob, { caseName: 'bobproj' }); expect(mine.status).toBe(200); expect(mine.body.success).toBe(true); - // Give a (wrong) fire-and-forget write every chance to land before checking. + // Give a (wrong) write every chance to land before checking. await new Promise((r) => setTimeout(r, 200)); expect(JSON.parse(await readSettingsBytes()).lastUsedCase).toBe('rootcase'); diff --git a/test/settings-admin-only-ui.test.ts b/test/settings-admin-only-ui.test.ts index 92449171..30bfcc9a 100644 --- a/test/settings-admin-only-ui.test.ts +++ b/test/settings-admin-only-ui.test.ts @@ -95,6 +95,7 @@ const REAL = new Set([ '_keepServerSyncedSettings', '_displaySettingKeySet', '_noteSettingsWriteRefusal', + '_settingsWriteError', '_applyOwnSettings', '_readOwnSettings', '_ownSettingsKey', diff --git a/test/settings-json-write-serialization.test.ts b/test/settings-json-write-serialization.test.ts new file mode 100644 index 00000000..70b283c1 --- /dev/null +++ b/test/settings-json-write-serialization.test.ts @@ -0,0 +1,220 @@ +/** + * @fileoverview Every writer of the server's settings.json is serialized, atomic, and refuses to + * overwrite a file that does not parse (live server, ephemeral port, real file on disk). + * + * The upstream CI flake in test/quick-start.test.ts ("accepts the setting via PUT, then injects") + * was a torn settings.json: `SyntaxError: Unexpected non-whitespace character after JSON at + * position 31`. Quick-start saved `lastUsedCase` with an un-awaited, non-atomic read-modify-write, + * PUT /api/settings ran its own unserialized one, and the two overlapping truncate-then-write + * calls left one write's bytes over the tail of the other. The PUT then read the torn file as `{}` + * and wrote back only its own keys, erasing every other setting. + * + * Writes to settings.json (and its temp files) are slowed down here, which is what a busy CI disk + * does: a writer that answers before its write lands, or two writers that both read before either + * writes, then fail deterministically instead of once in a while. + */ + +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import fsp from 'node:fs/promises'; +import { + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readlinkSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { WebServer } from '../src/web/server.js'; +import { TmuxManager } from '../src/tmux-manager.js'; +import { SETTINGS_PATH } from '../src/web/route-helpers.js'; +import { getCasesDir } from '../src/config/cases-dir.js'; + +vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true); + +/** How long every write to settings.json (or a temp file beside it) is held before it starts. */ +const SLOW_WRITE_MS = 200; +const realWriteFile = fsp.writeFile.bind(fsp); +vi.spyOn(fsp, 'writeFile').mockImplementation((async (file: unknown, data: unknown, options?: unknown) => { + if (typeof file === 'string' && file.startsWith(SETTINGS_PATH)) { + await new Promise((resolve) => setTimeout(resolve, SLOW_WRITE_MS)); + } + return realWriteFile(file as string, data as string, options as undefined); +}) as typeof fsp.writeFile); + +/** The bytes the CI run left behind: the PUT's 31-byte file over the tail of quick-start's longer one. */ +const TORN = '{\n "agentSkillEnabled": true\n}' + 'rt-1761000000000"\n}'; + +const REFUSAL = { + success: false, + errorCode: 'CONFLICT', + error: `${SETTINGS_PATH} is not a valid JSON object, so this change was not saved (saving would have replaced every other setting in it). Fix or delete the file, or restore it from a config backup, then try again.`, +}; + +let server: WebServer; +const url = (p: string) => `http://localhost:${server.boundPort}${p}`; +const seed = (value: unknown) => writeFileSync(SETTINGS_PATH, JSON.stringify(value, null, 2)); +const readSettings = () => JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8')) as Record; + +async function send(method: string, path: string, body: unknown) { + const res = await fetch(url(path), { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return { status: res.status, body: (await res.json()) as Record }; +} + +let caseCounter = 0; +const newCaseName = () => `settings-race-${process.pid}-${++caseCounter}`; + +beforeAll(async () => { + // The boot pass resets a persisted tunnelEnabled (the tunnel only starts from the UI); that write + // goes through the same writer and must keep every other key. + mkdirSync(join(SETTINGS_PATH, '..'), { recursive: true }); + seed({ tunnelEnabled: true, caseOrder: ['boot'] }); + server = new WebServer(0, false, true); + await server.start(); +}, 30000); + +afterAll(async () => { + await server?.stop(); + vi.restoreAllMocks(); +}, 30000); + +describe('settings.json writes', () => { + it('resets tunnelEnabled at boot without dropping the other keys', () => { + expect(readSettings()).toEqual({ tunnelEnabled: false, caseOrder: ['boot'] }); + }); + + describe('with a valid file', () => { + beforeEach(() => seed({ lastUsedCase: 'seeded' })); + + it('answers a quick-start only after its lastUsedCase has landed', async () => { + const caseName = newCaseName(); + const res = await send('POST', '/api/quick-start', { caseName }); + expect(res.body.success).toBe(true); + expect(readSettings()).toEqual({ lastUsedCase: caseName }); + }); + + it('answers a Ralph loop start only after its lastUsedCase has landed', async () => { + const caseName = newCaseName(); + const res = await send('POST', '/api/ralph-loop/start', { caseName, taskDescription: 'x' }); + expect(res.body.success).toBe(true); + expect(readSettings()).toEqual({ lastUsedCase: caseName }); + }); + + it('keeps every key when quick-start, the settings PUT, model config and case order write at once', async () => { + const caseName = newCaseName(); + const [quick, put, model, order] = await Promise.all([ + send('POST', '/api/quick-start', { caseName }), + send('PUT', '/api/settings', { agentSkillEnabled: true }), + send('PUT', '/api/execution/model-config', { defaultModel: 'opus' }), + send('PUT', '/api/cases/order', { order: ['a', 'b'] }), + ]); + expect(quick.body.success).toBe(true); + expect(put).toEqual({ status: 200, body: { success: true, data: {} } }); + expect(model).toEqual({ status: 200, body: { success: true, data: {} } }); + expect(order).toEqual({ status: 200, body: { success: true, data: { order: ['a', 'b'] } } }); + expect(readSettings()).toEqual({ + lastUsedCase: caseName, + agentSkillEnabled: true, + modelConfig: { defaultModel: 'opus' }, + caseOrder: ['a', 'b'], + }); + }); + + it('keeps a settings PUT that lands while a case rename rewrites caseOrder', async () => { + const oldName = newCaseName(); + const newName = newCaseName(); + mkdirSync(join(getCasesDir(), oldName), { recursive: true }); + seed({ lastUsedCase: oldName, caseOrder: [oldName, 'other'] }); + const [rename, put] = await Promise.all([ + send('POST', `/api/cases/${oldName}/rename`, { newName }), + send('PUT', '/api/settings', { agentSkillEnabled: true }), + ]); + expect(rename.body.success).toBe(true); + expect(put).toEqual({ status: 200, body: { success: true, data: {} } }); + expect(readSettings()).toEqual({ lastUsedCase: newName, caseOrder: [newName, 'other'], agentSkillEnabled: true }); + }); + + it('writes the file 0600, since it can hold an API key', async () => { + await send('PUT', '/api/settings', { agentSkillEnabled: true }); + expect(statSync(SETTINGS_PATH).mode & 0o777).toBe(0o600); + }); + }); + + it('starts from {} when the file is missing or empty', async () => { + rmSync(SETTINGS_PATH, { force: true }); + expect(await send('PUT', '/api/settings', { agentSkillEnabled: true })).toEqual({ + status: 200, + body: { success: true, data: {} }, + }); + expect(readFileSync(SETTINGS_PATH, 'utf-8')).toBe('{\n "agentSkillEnabled": true\n}'); + + writeFileSync(SETTINGS_PATH, ' \n'); + await send('PUT', '/api/execution/model-config', { defaultModel: 'opus' }); + expect(readSettings()).toEqual({ modelConfig: { defaultModel: 'opus' } }); + }); + + it('writes through a symlinked settings.json to its target', async () => { + const elsewhere = mkdtempSync(join(tmpdir(), 'codeman-settings-link-')); + const target = join(elsewhere, 'settings.json'); + writeFileSync(target, JSON.stringify({ lastUsedCase: 'linked' })); + rmSync(SETTINGS_PATH, { force: true }); + symlinkSync(target, SETTINGS_PATH); + try { + await send('PUT', '/api/settings', { agentSkillEnabled: true }); + expect(lstatSync(SETTINGS_PATH).isSymbolicLink()).toBe(true); + expect(readlinkSync(SETTINGS_PATH)).toBe(target); + expect(JSON.parse(readFileSync(target, 'utf-8'))).toEqual({ lastUsedCase: 'linked', agentSkillEnabled: true }); + } finally { + rmSync(SETTINGS_PATH, { force: true }); + rmSync(elsewhere, { recursive: true, force: true }); + } + }); + + describe('refuses to overwrite a file that is not a JSON object, and leaves its bytes alone', () => { + for (const [label, bytes] of [ + ['a torn file', TORN], + ['a JSON array', '[1, 2]'], + ['JSON null', 'null'], + ['a JSON string', '"settings"'], + ] as const) { + it(`${label}: every settings write route answers 409 CONFLICT`, async () => { + writeFileSync(SETTINGS_PATH, bytes); + expect(await send('PUT', '/api/settings', { agentSkillEnabled: true })).toEqual({ status: 409, body: REFUSAL }); + expect(await send('PUT', '/api/execution/model-config', { defaultModel: 'opus' })).toEqual({ + status: 409, + body: REFUSAL, + }); + expect(await send('PUT', '/api/cases/order', { order: ['a'] })).toEqual({ status: 409, body: REFUSAL }); + expect(readFileSync(SETTINGS_PATH, 'utf-8')).toBe(bytes); + }); + } + + it('a torn file: quick-start and a Ralph loop start still succeed without touching it', async () => { + writeFileSync(SETTINGS_PATH, TORN); + expect((await send('POST', '/api/quick-start', { caseName: newCaseName() })).body.success).toBe(true); + expect( + (await send('POST', '/api/ralph-loop/start', { caseName: newCaseName(), taskDescription: 'x' })).body.success + ).toBe(true); + expect(readFileSync(SETTINGS_PATH, 'utf-8')).toBe(TORN); + }); + + it('a torn file: a refusal does not wedge later writes once the file is fixed', async () => { + writeFileSync(SETTINGS_PATH, TORN); + expect((await send('PUT', '/api/settings', { agentSkillEnabled: true })).status).toBe(409); + seed({ lastUsedCase: 'fixed' }); + expect(await send('PUT', '/api/settings', { agentSkillEnabled: true })).toEqual({ + status: 200, + body: { success: true, data: {} }, + }); + expect(readSettings()).toEqual({ lastUsedCase: 'fixed', agentSkillEnabled: true }); + }); + }); +}); diff --git a/test/settings-save-failure-ui.test.ts b/test/settings-save-failure-ui.test.ts new file mode 100644 index 00000000..f18fd083 --- /dev/null +++ b/test/settings-save-failure-ui.test.ts @@ -0,0 +1,536 @@ +/** + * @fileoverview A browser write to settings.json that did not land must not say it did. + * + * PUT /api/settings can fail for reasons other than the two refusals the page already handles + * (the multi-user admin-only 403 FORBIDDEN and the tunnel's no-password 403): a 409 CONFLICT when + * settings.json does not parse (settings-writer.ts refuses to overwrite it), a 400, a 500, or a + * dropped connection, which `_apiPut` answers with null instead of throwing. Each of those used + * to end the App Settings save with "Settings saved" and a closed modal, and the run mode and + * respawn preset writes failed with no word at all. Now: + * - App Settings: a warning that carries the server's reason (else its HTTP status, else that the + * server could not be reached), the modal stays open, and nothing that assumes the server saved + * runs (Apply's refresh, the tunnel-starting toast, the gesture reload). A save with the tunnel + * switch on is no longer read as a tunnel refusal unless the server said 403. + * - Run mode and respawn presets: the same warning, naming what was not saved; they stay saved on + * this device as before. + * - The success path and both 403 paths are unchanged. + * - The new toasts read in Chinese under zh-CN. + * + * The real modules run inside a JSDOM window with the real index.html (the App Settings save) or a + * vm context (the background writes); `_apiPut` is the real api-client.js, and only `fetch` is + * mocked. Port: none. + */ + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const PUBLIC = join(process.cwd(), 'src/web/public'); +const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8'); +const INDEX = read('index.html'); +const I18N = read('i18n.js'); +const SETTINGS_UI = read('settings-ui.js'); +const SESSION_UI = read('session-ui.js'); +const RESPAWN_UI = read('respawn-ui.js'); +const API_CLIENT = read('api-client.js'); + +const DESKTOP_KEY = 'codeman-app-settings'; +type Settings = Record; + +/** What settings-writer.ts answers (409 CONFLICT) when settings.json does not parse. */ +const TORN = + '/home/u/.codeman/settings.json is not a valid JSON object, so this change was not saved (saving would have replaced every other setting in it). Fix or delete the file, or restore it from a config backup, then try again.'; +const INVALID = 'Invalid settings: codexModel: Invalid string'; + +/** A fetch answer: a JSON envelope, or a body that is not JSON (`body: null`). */ +type Answer = { status: number; body: Settings | null } | 'unreachable'; +const envelope = (status: number, error: string, errorCode: string): Answer => ({ + status, + body: { success: false, error, errorCode }, +}); +const OK: Answer = { status: 200, body: { success: true, data: {} } }; +const CONFLICT = envelope(409, TORN, 'CONFLICT'); +const BAD_REQUEST = envelope(400, INVALID, 'INVALID_INPUT'); +const SERVER_ERROR: Answer = { status: 500, body: null }; +const FORBIDDEN = envelope(403, 'You do not have permission', 'FORBIDDEN'); + +function respond(answer: Answer) { + if (answer === 'unreachable') return Promise.reject(new TypeError('Failed to fetch')); + const make = (): any => ({ + ok: answer.status >= 200 && answer.status < 300, + status: answer.status, + json: async () => { + if (answer.body === null) throw new SyntaxError('Unexpected token < in JSON'); + return answer.body; + }, + clone: make, + }); + return Promise.resolve(make()); +} + +const warning = (text: string) => [text, 'warning', { duration: 0 }]; +const errorToast = (text: string) => [text, 'error', { duration: 0 }]; + +const windows: { close(): void }[] = []; +afterEach(() => { + for (const win of windows.splice(0)) win.close(); +}); +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +/** Methods that run for real; every other prototype method is a no-op (panels this file is not about). */ +const REAL = new Set([ + 'openAppSettings', + 'saveAppSettings', + 'applyAppSettings', + 'loadAppSettingsFromServer', + 'loadAppSettingsFromStorage', + 'saveAppSettingsToStorage', + 'getSettingsStorageKey', + 'getDefaultSettings', + 'tabCliLogosEnabled', + 'resolveTabArrangement', + 'resolveTabStateOrder', + 'resolveHeaderStatsStyle', + 'resolveSessionSidebarFontSize', + 'planUsageChipEnabled', + 'planUsageCollectionFlip', + 'loadModelConfigForSettings', + 'saveModelConfigFromSettings', + '_modelConfigFromSettingsForm', + '_settingsReadOnly', + '_captureSettingsFormBaseline', + '_keepServerSyncedSettings', + '_displaySettingKeySet', + '_noteSettingsWriteRefusal', + '_settingsWriteError', + '_confirmTunnelEnable', + '_handleTunnelEnableRefusal', + '_abandonTunnelEnable', + '_askUnauthTunnel', + '_webhookPending', + '_api', + '_apiPut', +]); +const ELSEWHERE = [ + '_updateLocalEchoState', + 'renderProjectInsightsPanel', + 'updateSubagentWindowVisibility', + 'showWelcome', +]; + +/** + * Boot index.html with the real modules, signed in as a single-user admin. `put` is what + * PUT /api/settings answers; `tunnelStatus` what GET /api/tunnel/status answers. + */ +async function boot( + put: Answer | Answer[], + tunnelStatus: Answer = { status: 200, body: { success: true, data: {} } }, + confirmAnswer = false +) { + const putAnswers = Array.isArray(put) ? [...put] : null; + const dom = new JSDOM(INDEX, { url: 'http://localhost/', runScripts: 'outside-only' }); + const win = dom.window as unknown as Window & + typeof globalThis & { eval(src: string): void; __App: any; __codemanUser: Settings; fetch: unknown }; + windows.push(win); + if (win.document.readyState !== 'complete') await new Promise((resolve) => win.addEventListener('load', resolve)); + win.setInterval = (() => 0) as unknown as typeof win.setInterval; + win.confirm = vi.fn(() => confirmAnswer); + win.localStorage.setItem(DESKTOP_KEY, JSON.stringify({})); + const fetchCalls: { url: string; method: string; body?: string }[] = []; + win.fetch = vi.fn((url: string, opts: { method?: string; body?: string } = {}) => { + const method = opts.method || 'GET'; + fetchCalls.push({ url, method, body: opts.body }); + if (url === '/api/settings' && method === 'PUT') return respond(putAnswers ? putAnswers.shift()! : (put as Answer)); + if (url === '/api/tunnel/status') return respond(tunnelStatus); + if (url === '/api/execution/model-config' && method === 'GET') { + return respond({ status: 200, body: { success: true, data: { defaultModel: 'sonnet' } } }); + } + return respond(OK); + }); + win.eval( + [ + `var MobileDetection = { + getDeviceType: () => 'desktop', + isHandheldDevice: () => false, + isMobile: () => false, + isTouchDevice: () => false, + }; + var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {}; + var KeyboardAccessoryBar = { setMode() {} }; + var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() }; + var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; }; + var DEFAULT_VOICE_KEYTERMS = '';`, + read('constants.js'), + read('app.js'), + SETTINGS_UI, + API_CLIENT, + 'window.__App = CodemanApp;', + ].join('\n') + ); + win.__codemanUser = { username: 'admin', role: 'admin', multiUser: false }; + const target = Object.create(win.__App.prototype); + target.sessions = new Map(); + target.sessionOrder = []; + target.showToast = vi.fn(); + target.closeAppSettings = vi.fn(); + target._refreshSettingsAfterApply = vi.fn(); + const noop = () => undefined; + for (const name of ELSEWHERE) target[name] = noop; + const app = new Proxy(target, { + get(t, key, receiver) { + const value = Reflect.get(t, key, receiver); + const stubbed = + typeof key === 'string' && typeof value === 'function' && !Object.hasOwn(t, key) && !REAL.has(key); + return stubbed ? noop : value; + }, + }) as any; + await app.loadAppSettingsFromServer(Promise.resolve({})); + app.openAppSettings(); + await tick(); + await tick(); + const doc = win.document; + const box = (id: string) => doc.getElementById(id) as HTMLInputElement; + const storedBlob = () => JSON.parse(win.localStorage.getItem(DESKTOP_KEY) || '{}') as Settings; + const settingsPuts = () => fetchCalls.filter((c) => c.url === '/api/settings' && c.method === 'PUT'); + return { app, target, box, storedBlob, settingsPuts }; +} + +describe('App Settings save whose settings PUT did not land', () => { + const failures: [string, Answer, string][] = [ + ['409 CONFLICT (settings.json does not parse)', CONFLICT, TORN], + ['400 INVALID_INPUT', BAD_REQUEST, INVALID], + ['500 with a body that is not JSON', SERVER_ERROR, 'HTTP 500'], + ['a dropped connection (_apiPut answers null)', 'unreachable', 'the server could not be reached'], + ]; + + for (const [name, put, reason] of failures) { + it(`${name}: warns with the reason, keeps the modal open, keeps the change on this device`, async () => { + const { app, target, box, storedBlob, settingsPuts } = await boot(put); + box('appSettingsTabTwoRows').checked = true; // per-device + box('appSettingsAutoNameSessions').checked = true; // synced + target.showToast.mockClear(); + target.closeAppSettings.mockClear(); + + await app.saveAppSettings(); + + expect(settingsPuts()).toHaveLength(1); + expect(target.showToast.mock.calls).toEqual([warning(`Saved on this device, not on the server: ${reason}`)]); + expect(target.closeAppSettings).not.toHaveBeenCalled(); + expect(storedBlob().tabTwoRows).toBe(true); + expect(storedBlob().autoNameSessions).toBe(true); + }); + } + + it('Apply: the same warning, and the refresh that needs the saved flags does not run', async () => { + const { app, target } = await boot(CONFLICT); + target.showToast.mockClear(); + target._refreshSettingsAfterApply.mockClear(); + await app.applyAppSettings(); + expect(target.showToast.mock.calls).toEqual([warning(`Saved on this device, not on the server: ${TORN}`)]); + expect(target._refreshSettingsAfterApply).not.toHaveBeenCalled(); + expect(target.closeAppSettings).not.toHaveBeenCalled(); + }); + + it('with the tunnel switch on: not read as a tunnel refusal, no "Tunnel starting", the switch stays on', async () => { + for (const put of [CONFLICT, 'unreachable'] as Answer[]) { + const { app, target, box, storedBlob } = await boot(put, { + status: 200, + body: { success: true, data: { running: true, loginConfigured: true } }, + }); + box('appSettingsTunnelEnabled').checked = true; + target.showToast.mockClear(); + target.closeAppSettings.mockClear(); + + await app.saveAppSettings(); + + const reason = put === 'unreachable' ? 'the server could not be reached' : TORN; + expect(target.showToast.mock.calls, JSON.stringify(put)).toEqual([ + warning(`Saved on this device, not on the server: ${reason}`), + ]); + expect(target.closeAppSettings).not.toHaveBeenCalled(); + expect(box('appSettingsTunnelEnabled').checked).toBe(true); + expect(storedBlob().tunnelEnabled).toBe(true); + } + }); + + it('a gesture-control change does not reload the page as if the server had stored it', async () => { + const { app, target, box } = await boot(CONFLICT); + box('appSettingsGestureControl').checked = true; + target.showToast.mockClear(); + await app.saveAppSettings(); + expect(target.showToast.mock.calls).toEqual([warning(`Saved on this device, not on the server: ${TORN}`)]); + }); + + it('a retried Save still sends the plan-usage flip and reloads for gesture control, once the server stores it', async () => { + const { app, target, box, settingsPuts } = await boot([CONFLICT, OK]); + box('appSettingsShowPlanUsageLimits').checked = false; // desktop default ON: a flip + box('appSettingsGestureControl').checked = true; + target.showToast.mockClear(); + await app.saveAppSettings(); + expect(target.showToast.mock.calls).toEqual([warning(`Saved on this device, not on the server: ${TORN}`)]); + + // The user fixes the file and presses Save again; the form still shows the same choices. + target.showToast.mockClear(); + await app.saveAppSettings(); + const [first, second] = settingsPuts().map((p) => JSON.parse(p.body!)); + expect(first.showPlanUsageLimits).toBe(false); + expect(second.showPlanUsageLimits).toBe(false); + expect(target.showToast.mock.calls).toEqual([ + ['Settings saved', 'success'], + ['Enabling gesture control — reloading…', 'info'], + ]); + expect(target.closeAppSettings).toHaveBeenCalledTimes(1); + }); +}); + +describe('App Settings save: the paths that already worked', () => { + it('a saved PUT still says "Settings saved" and closes the modal', async () => { + const { app, target, box } = await boot(OK); + box('appSettingsAutoNameSessions').checked = true; + target.showToast.mockClear(); + await app.saveAppSettings(); + expect(target.showToast.mock.calls).toEqual([['Settings saved', 'success']]); + expect(target.closeAppSettings).toHaveBeenCalledTimes(1); + }); + + it('a 403 FORBIDDEN still ends with the one admin-only toast', async () => { + const { app, target } = await boot(FORBIDDEN); + target.showToast.mockClear(); + await app.saveAppSettings(); + expect(target.showToast.mock.calls).toEqual([ + ['Saved on this device. Server-wide settings can only be changed by an admin.', 'warning'], + ]); + expect(target.closeAppSettings).toHaveBeenCalledTimes(1); + expect(app._settingsReadOnly()).toBe(true); + }); + + it("the tunnel's no-password 403 still goes to the tunnel prompt, and cancelling it turns the switch off", async () => { + // GET /api/tunnel/status unreadable: the PUT goes out unacknowledged and the server refuses it. + const { app, target, box, storedBlob } = await boot( + envelope(403, 'Refusing to start a public tunnel with no login', 'OPERATION_FAILED'), + 'unreachable' + ); + box('appSettingsTunnelEnabled').checked = true; + target.showToast.mockClear(); + await app.saveAppSettings(); + expect(target.showToast.mock.calls).toEqual([['Tunnel not enabled', 'info']]); + expect(target.closeAppSettings).toHaveBeenCalledTimes(1); + expect(box('appSettingsTunnelEnabled').checked).toBe(false); + expect(storedBlob().tunnelEnabled).toBe(false); + }); + + it('an accepted no-password retry sends the whole save again, so "Settings saved" is true', async () => { + const { app, target, box, settingsPuts } = await boot( + [envelope(403, 'Refusing to start a public tunnel with no login', 'OPERATION_FAILED'), OK], + 'unreachable', + true + ); + box('appSettingsTunnelEnabled').checked = true; + box('appSettingsAutoNameSessions').checked = true; + target.showToast.mockClear(); + await app.saveAppSettings(); + + const [first, retry] = settingsPuts().map((p) => JSON.parse(p.body!)); + expect(settingsPuts()).toHaveLength(2); + expect(first.autoNameSessions).toBe(true); + expect(first.acknowledgeUnauthTunnel).toBeUndefined(); + expect(retry).toEqual({ ...first, acknowledgeUnauthTunnel: true }); + expect(target.showToast.mock.calls).toEqual([ + ['Public tunnel enabling — no password set ⚠️', 'warning'], + ['Settings saved', 'success'], + ['Tunnel starting — QR code will appear when ready...', 'info'], + ]); + expect(target.closeAppSettings).toHaveBeenCalledTimes(1); + }); + + it('Apply: an accepted retry refreshes the groups that read the saved flags', async () => { + const { app, target, box } = await boot( + [envelope(403, 'Refusing to start a public tunnel with no login', 'OPERATION_FAILED'), OK], + 'unreachable', + true + ); + box('appSettingsTunnelEnabled').checked = true; + target.showToast.mockClear(); + await app.applyAppSettings(); + expect(target.showToast.mock.calls).toEqual([ + ['Public tunnel enabling — no password set ⚠️', 'warning'], + ['Settings applied', 'success'], + ['Tunnel starting — QR code will appear when ready...', 'info'], + ]); + expect(target._refreshSettingsAfterApply).toHaveBeenCalledTimes(1); + expect(target.closeAppSettings).not.toHaveBeenCalled(); + }); +}); + +// ─── Background writes (vm harness: settings-ui.js + session-ui.js + respawn-ui.js + api-client.js) ─── + +function loadBackground(put: Answer) { + const storage = new Map(); + const elements: Record = { + runModeMenu: { classList: { remove: () => {} } }, + runBtn: { className: '', nextElementSibling: { className: '' } }, + runBtnLabel: { textContent: '' }, + welcomeTunnelBtn: { classList: { contains: (c: string) => c === 'active' }, disabled: false }, + }; + const CodemanApp = function CodemanApp(this: any) {}; + const fetchMock = vi.fn((url: string, opts: { method?: string } = {}) => + url === '/api/settings' && opts.method === 'PUT' ? respond(put) : respond(OK) + ); + const context = vm.createContext({ + CodemanApp, + VoiceInput: {}, + window: { __codemanUser: { username: 'admin', role: 'admin', multiUser: false } }, + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + }, + document: { getElementById: (id: string) => elements[id] ?? null, addEventListener: () => {} }, + fetch: fetchMock, + console, + }); + for (const [src, filename] of [ + [SETTINGS_UI, 'settings-ui.js'], + [SESSION_UI, 'session-ui.js'], + [RESPAWN_UI, 'respawn-ui.js'], + [API_CLIENT, 'api-client.js'], + ]) { + vm.runInContext(src, context, { filename }); + } + const app = new (CodemanApp as any)(); + app.showToast = vi.fn(); + app._dismissTunnelConnecting = vi.fn(); + app._updateWelcomeTunnelBtn = vi.fn(); + app.closeTunnelPanel = vi.fn(); + return { app, fetchMock, storage, elements }; +} + +/** Let the fire-and-forget PUT and its response handling settle. */ +const settle = async () => { + for (let i = 0; i < 5; i++) await tick(); +}; + +describe('the run mode and respawn preset writes', () => { + const failures: [string, Answer, string][] = [ + ['409 CONFLICT', CONFLICT, TORN], + ['500 with a body that is not JSON', SERVER_ERROR, 'HTTP 500'], + ['a dropped connection', 'unreachable', 'the server could not be reached'], + ]; + + for (const [name, put, reason] of failures) { + it(`${name}: run mode warns with the reason and stays on this device`, async () => { + const b = loadBackground(put); + b.app.setRunMode('codex'); + await settle(); + expect(b.fetchMock).toHaveBeenCalledWith('/api/settings', expect.objectContaining({ method: 'PUT' })); + expect(b.app.showToast.mock.calls).toEqual([ + warning(`Run mode saved on this device, not on the server: ${reason}`), + ]); + expect(b.storage.get('codeman_runMode')).toBe('codex'); + }); + + it(`${name}: respawn presets warn with the reason and stay on this device`, async () => { + const b = loadBackground(put); + b.app.saveRespawnPresets([ + { id: 'builtin', builtIn: true }, + { id: 'mine', name: 'mine' }, + ]); + await settle(); + expect(b.app.showToast.mock.calls).toEqual([ + warning(`Respawn presets saved on this device, not on the server: ${reason}`), + ]); + expect(JSON.parse(b.storage.get('codeman-respawn-presets')!)).toEqual([{ id: 'mine', name: 'mine' }]); + }); + } + + it('a saved write says nothing', async () => { + const b = loadBackground(OK); + b.app.setRunMode('codex'); + b.app.saveRespawnPresets([{ id: 'mine' }]); + await settle(); + expect(b.fetchMock).toHaveBeenCalledTimes(2); + expect(b.app.showToast).not.toHaveBeenCalled(); + }); + + it('a 403 FORBIDDEN stays the silent admin-only refusal that makes the page read-only', async () => { + const b = loadBackground(FORBIDDEN); + b.app.setRunMode('codex'); + await settle(); + expect(b.app.showToast).not.toHaveBeenCalled(); + expect(b.app._settingsReadOnly()).toBe(true); + }); +}); + +describe('stopping the tunnel (welcome screen and tunnel panel)', () => { + it('a stop the server did not store says the tunnel was not stopped, and leaves it showing on', async () => { + for (const put of [CONFLICT, SERVER_ERROR]) { + const reason = put === CONFLICT ? TORN : 'HTTP 500'; + const welcome = loadBackground(put); + await welcome.app.toggleTunnelFromWelcome(); + expect(welcome.app.showToast.mock.calls).toEqual([errorToast(`Tunnel not stopped: ${reason}`)]); + expect(welcome.app._updateWelcomeTunnelBtn).not.toHaveBeenCalled(); + expect(welcome.app._dismissTunnelConnecting).not.toHaveBeenCalled(); + expect(welcome.elements.welcomeTunnelBtn.disabled).toBe(false); + + const panel = loadBackground(put); + await panel.app._tunnelPanelToggle(false); + expect(panel.app.showToast.mock.calls).toEqual([errorToast(`Tunnel not stopped: ${reason}`)]); + expect(panel.app.closeTunnelPanel).toHaveBeenCalledTimes(1); + } + }); + + it('a stored stop still says "Tunnel stopped"', async () => { + const welcome = loadBackground(OK); + await welcome.app.toggleTunnelFromWelcome(); + expect(welcome.app.showToast.mock.calls).toEqual([['Tunnel stopped', 'info']]); + expect(welcome.app._updateWelcomeTunnelBtn).toHaveBeenCalledWith(false); + + const panel = loadBackground(OK); + await panel.app._tunnelPanelToggle(false); + expect(panel.app.showToast.mock.calls).toEqual([['Tunnel stopped', 'info']]); + expect(panel.app.closeTunnelPanel).toHaveBeenCalledTimes(1); + }); +}); + +// ─── zh-CN ─── + +describe('the new toasts in zh-CN', () => { + type Api = { t(s: string): string; configure(o: Record): void }; + function translator(language: string) { + const dom = new JSDOM('', { + runScripts: 'outside-only', + url: 'http://localhost/', + }); + windows.push(dom.window); + vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' }); + const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n; + api.configure({ language }); + return api; + } + + it('translates each, keeps the reason as it came, and leaves English as it is', () => { + const zh = translator('zh-CN'); + const en = translator('en'); + const cases: [string, string][] = [ + [`Saved on this device, not on the server: ${TORN}`, `已保存到本设备,但未保存到服务器:${TORN}`], + [ + 'Run mode saved on this device, not on the server: HTTP 500', + '运行模式已保存到本设备,但未保存到服务器:HTTP 500', + ], + [ + 'Respawn presets saved on this device, not on the server: the server could not be reached', + '重生预设已保存到本设备,但未保存到服务器:无法连接到服务器', + ], + [ + 'Saved on this device, not on the server: the server could not be reached', + '已保存到本设备,但未保存到服务器:无法连接到服务器', + ], + [`Tunnel not stopped: ${TORN}`, `隧道未停止:${TORN}`], + ]; + for (const [english, chinese] of cases) { + expect(zh.t(english)).toBe(chinese); + expect(en.t(english)).toBe(english); + } + }); +}); diff --git a/test/spaced-file-path-links.test.ts b/test/spaced-file-path-links.test.ts new file mode 100644 index 00000000..335419dc --- /dev/null +++ b/test/spaced-file-path-links.test.ts @@ -0,0 +1,479 @@ +// Port: none (a pure function in a vm context: no browser, no server). +// +// File paths with spaces in them (`OneDrive - Contoso/…`, `90 Sensitive/…`) were +// cut at the first space by every path pattern, so the terminal and the response +// viewer linked a fragment such as `/mnt/c/proj/My` plus a bogus relative tail +// `Docs/report.docx`, each opening the wrong target. findFilePathLinks() now takes +// a spaced path WHOLE where the line makes its extent unambiguous: the path is the +// whole absolute path, or a relative path after an explicit path label. A spaced line that +// is one path but not a linkable one (unknown root, `..`) links nothing at all, +// rather than a fragment. +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it } from 'vitest'; + +const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'); + +function load(): (text: string) => Array<{ path: string; index: number; folder?: true }> { + const context = vm.createContext({ console, window: undefined }); + vm.runInContext(`${SOURCE}\nglobalThis.__fn = findFilePathLinks;`, context, { filename: 'constants.js' }); + return (context as { __fn: never })['__fn'] as never; +} + +const findFilePathLinks = load(); +const paths = (text: string) => findFilePathLinks(text).map((f) => f.path); + +const LONG = + '/mnt/c/Users/someone/OneDrive - Contoso/01_Projects/Other/kb/out/deliverables/fy27-messaging-market-story.docx'; + +describe('spaced file paths', () => { + it('keeps a parenthesised configuration filename whole inside a sentence', () => { + const path = '/mnt/user/Scratch/Kobo/Set-KoboCovers/Kobo eReader.conf'; + const text = `Prepared a corrected configuration (${path}).`; + expect(findFilePathLinks(text)).toEqual([{ path, index: text.indexOf(path) }]); + }); + + it('keeps separate parenthesised paths and the surrounding prose separate', () => { + const first = '/mnt/data/Kobo/Kobo eReader.conf'; + const second = '/home/me/My Docs/notes.md'; + expect(paths(`Updated (${first}) and (${second}).`)).toEqual([first, second]); + expect(paths('Do not link (/etc/private files/settings.conf).')).toEqual([]); + expect(paths('Do not link (/mnt/data/../private files/settings.conf).')).toEqual([]); + }); + + it('does not take a comma-separated list inside parentheses as one spaced filename', () => { + expect(paths('Updated (/home/me/proj, config.json)')).toEqual(['/home/me/proj', 'config.json']); + }); + + it.each([ + ['/mnt/c/Users/me/Smith, John/report.docx', '/mnt/c/Users/me/Smith, John/report.docx'], + ['- /home/me/Clients/Acme, Inc/contract.pdf', '/home/me/Clients/Acme, Inc/contract.pdf'], + ['Saved to: /home/me/proj/Invoice 12, final.pdf', '/home/me/proj/Invoice 12, final.pdf'], + ])('keeps commas in a non-parenthesised spaced path: %s', (line, path) => { + expect(findFilePathLinks(line)).toEqual([{ path, index: line.indexOf(path) }]); + }); + + it('links an own-line absolute path with spaces whole', () => { + expect(paths(LONG)).toEqual([LONG]); + expect(paths('/mnt/c/proj/My Big Docs/report.docx')).toEqual(['/mnt/c/proj/My Big Docs/report.docx']); + }); + + it('reports where the whole path starts', () => { + const line = ' - Path: /mnt/c/proj/My Big Docs/report.docx'; + expect(findFilePathLinks(line)).toEqual([ + { path: '/mnt/c/proj/My Big Docs/report.docx', index: line.indexOf('/mnt') }, + ]); + }); + + it('leaves a single trailing sentence period out', () => { + expect(paths('/mnt/c/proj/My Big Docs/report.docx.')).toEqual(['/mnt/c/proj/My Big Docs/report.docx']); + }); + + it.each(['Path:', 'File:', 'Saved to:'])('links an absolute spaced path after %s', (label) => { + const p = '/mnt/sync/brain/90 Sensitive/team/training/attendees.xlsx'; + expect(paths(`${label} ${p}`)).toEqual([p]); + }); + + it.each([ + 'Path:', + 'File:', + 'Files:', + 'Saved to:', + 'Saved as:', + 'Wrote:', + 'Written to:', + 'Output:', + 'Created:', + 'Location:', + 'Attachment:', + 'pAtH:', + ])('links a relative spaced path after %s', (label) => { + const p = '03 Attachments/Build SIPOC Design/Build-SIPOC.pptx'; + expect(paths(`${label} ${p}`)).toEqual([p]); + }); + + it.each(['- ', '* ', '• ', '1. ', '2) '])( + 'defers a relative spaced path after a bare list marker %j, links it after a path label', + (marker) => { + const rel = '03 Attachments/Build SIPOC Design/Build-SIPOC.pptx'; + expect(paths(`${marker}${rel}`)).toEqual(['Design/Build-SIPOC.pptx']); + expect(paths(`${marker}Path: ${rel}`)).toEqual([rel]); + expect(paths(` ${marker}${LONG}`)).toEqual([LONG]); + } + ); + + it('works line by line in code-block text, where list markers remain literal', () => { + const text = `Wrote two files:\n- ${LONG}\n- Path: notes/a b/c.md\n`; + expect(paths(text)).toEqual([LONG, 'notes/a b/c.md']); + }); + + it('links nothing for a spaced path outside the known roots (no partial tail)', () => { + expect(paths('/etc/secret files/passwords.txt')).toEqual([]); + expect(paths('/srvx/team share/notes/passwords.txt')).toEqual([]); + }); + + it('links nothing for a spaced path with a `..` or `.` segment', () => { + expect(paths('/mnt/c/proj/../other place/docs/report.docx')).toEqual([]); + expect(paths('File: 03 Attachments/../private/report.xlsx')).toEqual([]); + expect(paths('- File: 03 Attachments/./private/report.xlsx')).toEqual([]); + }); + + it('never merges two space-separated paths into one link', () => { + expect(paths('/mnt/c/proj/a.md /mnt/c/proj/b.pdf')).toEqual(['/mnt/c/proj/a.md', '/mnt/c/proj/b.pdf']); + expect(paths('- docs/a.md docs/b.md')).toEqual(['docs/a.md', 'docs/b.md']); + expect(paths('/mnt/c/proj/a b.md -> /mnt/c/proj/c.md')).not.toContain('/mnt/c/proj/a b.md -> /mnt/c/proj/c.md'); + }); + + it('does not take prose into a path', () => { + // No marker or label: a relative spaced candidate is prose, not a path. + expect(paths('See docs/report.docx for details')).toEqual(['docs/report.docx']); + expect(paths('see the docs/a b.md')).not.toContain('see the docs/a b.md'); + // A long description before a colon is a sentence, not a label: only the path links. + const rel = '.worktrees/feature-x/docs/specs/2026-09-05-design.md'; + expect(paths(`• Design committed in the isolated worktree: ${rel}`)).toEqual([rel]); + }); + + it.each([ + ['- Updated src/foo.ts', 'src/foo.ts'], + ['1. Added a test in test/foo.test.ts', 'test/foo.test.ts'], + ['• Ran npm test -- test/foo.test.ts', 'test/foo.test.ts'], + ['Error: cannot open src/foo.ts', 'src/foo.ts'], + ['Note: see src/foo.ts', 'src/foo.ts'], + ['- Moved ../a/b.ts', '../a/b.ts'], + ])('defers ambiguous relative prose to ordinary detectors: %s', (line, path) => { + expect(paths(line)).toEqual([path]); + }); + + it('requires a path label for relative candidates but retains own-line absolute ambiguity', () => { + expect(paths('- Fixed bug in src/My Docs/a.md')).toEqual(['Docs/a.md']); + // Documented trade-off: a labelled relative path whose only space is in its top + // directory reads like prose after the label, so it splits as on master. + expect(paths('Saved to: 03 Attachments/plan.pptx')).toEqual(['Attachments/plan.pptx']); + expect(paths('Saved to: My Docs/plan.md')).toEqual(['Docs/plan.md']); + // Documented residual: words after an own-line absolute path that hold no prose + // word read like a spaced name (`/home/me/docs/Read Me.txt`), so they stay in. + expect(paths('/home/me/proj/out report.pdf')).toEqual(['/home/me/proj/out report.pdf']); + expect(paths('Saved to: ~/My Docs/plan.md')).toEqual(['~/My Docs/plan.md']); + }); + + it.each([ + ['1. Ran 45/45 tests in test/foo.test.ts', ['test/foo.test.ts']], + ['- Fixed CI/CD pipeline in .github/workflows/ci.yml', ['.github/workflows/ci.yml']], + ['- Added read/write support in config.ts', ['config.ts']], + ['- Tests: 45/45 pass in test/foo.test.ts', ['test/foo.test.ts']], + ['Error: I/O failure reading data.json', ['data.json']], + ['- Moved src/ into lib/a.ts', ['src/', 'lib/a.ts']], + ['- Copied files from ../shared into lib/a.ts', ['lib/a.ts']], + ['- Ran 45/45 tests in ~/proj/foo.test.ts', ['~/proj/foo.test.ts']], + ])('does not swallow slash-containing prose: %s', (line, expected) => { + expect(paths(line)).toEqual(expected); + }); + + it.each(['~/', './', '../', '/tmp/'])('does not merge a later new path starting with %s', (prefix) => { + const line = `Path: notes/a ${prefix}other/file.md`; + expect(paths(line)).not.toContain(line.slice(6)); + }); + + it('does not merge a folder token into a labelled spaced path', () => { + expect(paths('Path: src/ into lib/a.ts')).toEqual(['src/', 'lib/a.ts']); + }); + + it.each([40000, 80000])('stays linear with %i interior spaces', (length) => { + const line = '/home/a' + ' '.repeat(length) + 'x'; + const start = performance.now(); + findFilePathLinks(line); + expect(performance.now() - start).toBeLessThan(500); + }); + + it('leaves unspaced paths to the ordinary detectors', () => { + expect(paths('- /mnt/c/proj/docs/report.docx')).toEqual(['/mnt/c/proj/docs/report.docx']); + expect(paths('• committed as /tmp/x/docs/specs/design.md:1 (e7ae1b4).')).toEqual(['/tmp/x/docs/specs/design.md']); + }); + + it('stays linear on a long spaced line', () => { + const line = '- ' + 'a '.repeat(20000) + '/x.md'; + const t0 = performance.now(); + findFilePathLinks(line); + expect(performance.now() - t0).toBeLessThan(500); + }); +}); + +/** The links master returned for `line`, in order: `[path, folder?]` pairs, each at its first occurrence. */ +const masterLinks = (line: string, ...links: Array<[string, true?]>) => + links.map(([path, folder]) => ({ path, index: line.indexOf(path), ...(folder ? { folder } : {}) })); + +describe('spaced file paths: shapes that keep master links', () => { + // grep -n, rg -n and compiler output: a `:` plus a digit inside an earlier + // word is a line suffix, so the path already ended there. + it.each([ + ['/home/me/proj/README.md:12:See docs/setup.md', '/home/me/proj/README.md', 'docs/setup.md'], + [ + ' /home/me/proj/CLAUDE.md:42:Deep detail lives in docs/architecture-invariants.md', + '/home/me/proj/CLAUDE.md', + 'docs/architecture-invariants.md', + ], + ['/home/me/proj/src/a.ts:42:5 - error in b.ts', '/home/me/proj/src/a.ts', 'b.ts'], + ['/home/me/proj/src/index.ts:10 updated config.json', '/home/me/proj/src/index.ts', 'config.json'], + ])('ends the path at a line suffix: %s', (line, first, second) => { + expect(findFilePathLinks(line)).toEqual(masterLinks(line, [first], [second])); + }); + + // A generic label introduces prose for an absolute path as much as a relative one. + it.each([ + ['Error: /home/me/proj contains a.md', '/home/me/proj', 'a.md'], + ['Warning: /home/me/proj is not tracked, see notes.md', '/home/me/proj', 'notes.md'], + ['Working directory: /home/me/proj, see README.md', '/home/me/proj', 'README.md'], + ['- Note: /tmp/out has the logs in run.log', '/tmp/out', 'run.log'], + ])('defers an absolute path after a generic label: %s', (line, folder, file) => { + expect(findFilePathLinks(line)).toEqual(masterLinks(line, [folder, true], [file])); + }); + + it('documented trade-off: an absolute spaced path after a non-path label splits as on master', () => { + const line = 'Saved the report to: /mnt/c/My Docs/a.docx'; + expect(findFilePathLinks(line)).toEqual(masterLinks(line, ['/mnt/c/My', true], ['Docs/a.docx'])); + }); + + it.each([ + ['/usr/bin/python3 script.py', 'script.py'], + ['/usr/bin/env node build.js', 'build.js'], + ['/review the changes in src/foo.ts', 'src/foo.ts'], + ])('does not reserve a command line that only looks like one path: %s', (line, file) => { + expect(findFilePathLinks(line)).toEqual(masterLinks(line, [file])); + }); + + // A path label vouches that a path follows, not for the prose before it. + it.each([ + ['Wrote: 120 lines to src/foo.ts', 'src/foo.ts'], + ['Created: new file src/components/Button.tsx', 'src/components/Button.tsx'], + ['Output: written to logs/run.log', 'logs/run.log'], + ['Files: 3 changed in src/foo.ts', 'src/foo.ts'], + ['Location: line 42 in src/foo.ts', 'src/foo.ts'], + ['Path: see src/foo.ts', 'src/foo.ts'], + ['- Output: stored in dist/app.js', 'dist/app.js'], + ['1. File: changes in src/a.ts', 'src/a.ts'], + ['Created: 2026-10-24 build/x.json', 'build/x.json'], + ])('keeps label prose out of the path: %s', (line, file) => { + expect(findFilePathLinks(line)).toEqual(masterLinks(line, [file])); + }); + + it('keeps labelled spaced paths whole when a `/` word comes before the last', () => { + for (const p of [ + '03 Attachments/Build SIPOC Design/Build-SIPOC.pptx', + '~/My Docs/plan.md', + './My Docs/plan.md', + 'notes/a b/c.md', + '/mnt/c/My Docs/a.docx', + ]) { + expect(paths(`Saved to: ${p}`)).toEqual([p]); + } + }); + + // A connective or verb before the last word puts the candidate in a sentence, + // whatever the shape: own line, list marker or path label, absolute or relative. + it.each([ + ['/home/me/proj contains a.md', ['/home/me/proj', true], ['a.md']], + ['/home/me/proj in the README.md', ['/home/me/proj', true], ['README.md']], + ['/home/me/proj/src is where the code lives, see README.md', ['/home/me/proj/src', true], ['README.md']], + ['/tmp/a and b.md', ['/tmp/a', true], ['b.md']], + ['/home/me/proj/out vs b.md', ['/home/me/proj/out', true], ['b.md']], + ['- /tmp/out has the logs in run.log', ['/tmp/out', true], ['run.log']], + ['* /home/me/proj/docs contains a.md', ['/home/me/proj/docs', true], ['a.md']], + ['1. /home/me/proj/src then test/foo.test.ts', ['/home/me/proj/src', true], ['test/foo.test.ts']], + ['- /home/me/proj is the root, see README.md', ['/home/me/proj', true], ['README.md']], + ['Path: /home/me/proj/out contains a.md', ['/home/me/proj/out', true], ['a.md']], + ['Saved to: /home/me/out as report.docx', ['/home/me/out', true], ['report.docx']], + ['Files: src/components and utils/x.ts', ['utils/x.ts']], + ['Wrote: 3 files to docs/design notes/overview.md', ['notes/overview.md']], + ['Created: the file src/components/My Button/Button.tsx', ['Button/Button.tsx']], + ['Output: 2 files in dist/my app/a.js', ['app/a.js']], + ['Output: 12 KB written to dist/my app/a.js', ['app/a.js']], + ['/home/me/proj/out = report.pdf', ['/home/me/proj/out', true], ['report.pdf']], + ] as Array<[string, ...Array<[string, true?]>]>)('keeps a sentence out of the path: %s', (line, ...links) => { + expect(findFilePathLinks(line)).toEqual(masterLinks(line, ...links)); + }); + + it('ends the path at an earlier word that holds an extension', () => { + expect(findFilePathLinks('/home/me/proj/src/foo.ts#L12 foo.ts')).toEqual([ + { path: '/home/me/proj/src/foo.ts', index: 0 }, + { path: 'foo.ts', index: 29 }, + ]); + }); + + it('documented trade-off: a name that uses a prose word splits as on master', () => { + const line = '/home/me/docs/Pros and Cons.md'; + expect(findFilePathLinks(line)).toEqual(masterLinks(line, ['/home/me/docs/Pros', true], ['Cons.md'])); + // `at` and `of` are not prose words, so these names stay whole. + for (const p of [ + '/Users/me/Desktop/Screenshot 2026-10-24 at 10.00.00.png', + '/home/me/docs/Copy of report.docx', + '/home/me/docs/Terms of Service.pdf', + ]) { + expect(paths(p)).toEqual([p]); + expect(paths(`Saved to: ${p}`)).toEqual([p]); + } + }); + + // A command whose executable lives under a known root, or one carrying a flag. + it.each([ + ['/opt/homebrew/bin/node server.js', '/opt/homebrew/bin/node', 'server.js'], + ['/home/me/.venv/bin/python run.py', '/home/me/.venv/bin/python', 'run.py'], + [ + '/home/me/proj/node_modules/.bin/vitest run test/foo.test.ts', + '/home/me/proj/node_modules/.bin/vitest', + 'test/foo.test.ts', + ], + ['/home/me/proj/node_modules/.bin/tsc -p tsconfig.json', '/home/me/proj/node_modules/.bin/tsc', 'tsconfig.json'], + ['/home/me/proj/scripts/deploy prod.yml', '/home/me/proj/scripts/deploy', 'prod.yml'], + ['/home/me/scripts/deploy config.yml', '/home/me/scripts/deploy', 'config.yml'], + ['/home/me/proj/tools/lint src/a.ts', '/home/me/proj/tools/lint', 'src/a.ts'], + ])('does not reserve a command under a known root: %s', (line, exe, file) => { + expect(findFilePathLinks(line)).toEqual(masterLinks(line, [exe, true], [file])); + }); + + it.each([ + '/Users/me/Desktop/Screenshot 2026-10-24 at 10.15.32 AM.png', + '/mnt/c/Users/me/Docs - Copy/a.docx', + '- /mnt/c/Users/me/Docs - Copy/a.docx', + ])('keeps a spaced path whole when a word only looks like a flag or a version: %s', (line) => { + expect(paths(line)).toEqual([line.replace(/^- /, '')]); + }); + + it('still refuses an unlinkable spaced path that reads as one path to its end', () => { + expect(paths('/etc/secret files/passwords.txt')).toEqual([]); + }); + + it('does not merge a column gap into one path', () => { + const line = '/home/me/proj modified notes.md'; + expect(findFilePathLinks(line)).toEqual(masterLinks(line, ['/home/me/proj', true], ['notes.md'])); + }); + + it('reads a leading ./ as the workspace, not a segment', () => { + expect(paths('Saved to: ./My Docs/plan.md')).toEqual(['./My Docs/plan.md']); + expect(paths('Saved to: ./notes.md')).toEqual(['./notes.md']); + }); + + it('reads a leading run of ../ as parent directories, not segments', () => { + expect(paths('Saved to: ../My Docs/plan.md')).toEqual(['../My Docs/plan.md']); + expect(paths('Saved to: ../../My Docs/plan.md')).toEqual(['../../My Docs/plan.md']); + expect(paths('Saved to: ../notes.md')).toEqual(['../notes.md']); + // An interior `..` is still a segment, so the span links nothing. + expect(paths('Saved to: My Docs/../plan.md')).toEqual([]); + expect(paths('Saved to: ../My Docs/../x/plan.md')).toEqual([]); + }); + + // A shell prompt that prints its working directory in full (WSL's `/mnt/c/...` is + // outside `$HOME`): a word with no letter or digit, or any earlier word ending in `$` + // (the first, or a later one when the directory has a space in it), is never part of + // a file name, so every earlier command in the scrollback keeps master's links. + it.each([ + ['/mnt/c/proj ❯ code notes.md', ['/mnt/c/proj', true], ['notes.md']], + ['/home/me/proj $ cat notes.md', ['/home/me/proj', true], ['notes.md']], + ['/home/me/proj$ cat notes.md', ['/home/me/proj', true], ['notes.md']], + ['/mnt/c/Users/me/OneDrive - Contoso/proj$ code notes.md', ['/mnt/c/Users/me/OneDrive', true], ['notes.md']], + ['/mnt/c/My Docs$ ls notes.md', ['/mnt/c/My', true], ['notes.md']], + ['/home/me/proj ❯ git add src/a.ts', ['/home/me/proj', true], ['src/a.ts']], + ['/mnt/c/Users/me/OneDrive - Contoso/proj ❯ code notes.md', ['/mnt/c/Users/me/OneDrive', true], ['notes.md']], + ['/home/me/proj/out ✓ report.pdf', ['/home/me/proj/out', true], ['report.pdf']], + ] as Array<[string, ...Array<[string, true?]>]>)('keeps a shell prompt out of the path: %s', (line, ...links) => { + expect(findFilePathLinks(line)).toEqual(masterLinks(line, ...links)); + }); + + it.each([LONG, '/mnt/c/Program Files (x86)/app/readme.txt', '/mnt/c/Users/me/Docs - Copy/a.docx'])( + 'keeps a name whose words hold a letter or digit, or a bare -, whole: %s', + (line) => { + expect(paths(line)).toEqual([line]); + } + ); +}); + +describe('terminal link provider with spaced paths', () => { + type Link = { text: string; activate: (event: unknown, text: string) => void }; + const TERMINAL_UI = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); + + function linksFor(line: string) { + const noop = () => {}; + const context = vm.createContext({ + console: { ...console, debug: noop, log: noop }, + CodemanApp: class CodemanApp {}, + document: { addEventListener: noop, getElementById: noop, documentElement: { dataset: {} } }, + window: { addEventListener: noop, removeEventListener: noop, document: { documentElement: { dataset: {} } } }, + }); + vm.runInContext(`${SOURCE}\n${TERMINAL_UI}\nglobalThis.__proto = CodemanApp.prototype;`, context); + const proto = (context as unknown as { __proto: Record unknown> }).__proto; + let provider: { provideLinks: (row: number, cb: (links?: Link[]) => void) => void } | undefined; + const opened: string[] = []; + const app = { + terminal: { + cols: 200, + registerLinkProvider: (p: typeof provider) => (provider = p), + buffer: { + active: { + length: 1, + getLine: (r: number) => + r === 0 + ? { + isWrapped: false, + translateToString: (trim?: boolean) => (trim === false ? line.padEnd(200) : line), + } + : undefined, + }, + }, + }, + activeSessionId: 's1', + sessions: new Map([['s1', { workingDir: '/workspace/project' }]]), + openFilePreview: (p: string) => opened.push(p), + openLogViewerWindow: (p: string) => opened.push(`log:${p}`), + _isExternalPreviewPath: () => false, + _resolveLinkedFilePath: proto._resolveLinkedFilePath, + registerFilePathLinkProvider: proto.registerFilePathLinkProvider, + }; + app.registerFilePathLinkProvider.call(app); + let links: Link[] = []; + provider!.provideLinks(1, (value) => (links = value ?? [])); + return { links, opened }; + } + + it('links the whole spaced path and opens it, resolved against the workspace', () => { + const rel = '03 Attachments/Build SIPOC Design/Build-SIPOC.pptx'; + const { links, opened } = linksFor(`Saved to: ${rel}`); + expect(links.map((l) => l.text)).toEqual([rel]); + links[0].activate(null, links[0].text); + expect(opened).toEqual([`/workspace/project/${rel}`]); + }); + + it('links a labelled spaced path under ../ and opens it outside the workspace', () => { + const { links, opened } = linksFor('Saved to: ../My Docs/plan.md'); + expect(links.map((l) => l.text)).toEqual(['../My Docs/plan.md']); + links[0].activate(null, links[0].text); + expect(opened).toEqual(['/workspace/My Docs/plan.md']); + }); + + it('links nothing for an unlinkable spaced path', () => { + expect(linksFor('/etc/secret files/passwords.txt').links).toEqual([]); + }); + + // `Bash(` holds a command, so its parentheses bound no spaced path: master links the file + // (and the folder the executable sits in), never the whole command. The path tools do + // hold a path, a spaced filename included. + it.each([ + ['Bash(/tmp/a.out input.txt)', [{ path: 'input.txt' }]], + [ + 'Bash(/home/me/proj/build/mytool convert input.json)', + [{ path: '/home/me/proj/build/mytool', folder: true }, { path: 'input.json' }], + ], + [ + 'Bash(/home/me/proj/target/release/app config.toml)', + [{ path: '/home/me/proj/target/release/app', folder: true }, { path: 'config.toml' }], + ], + ])('links %s the way master does, not as the whole command', (line, linked) => { + const found = findFilePathLinks(line).map(({ path, folder }) => (folder ? { path, folder } : { path })); + expect(found).toEqual(linked); + expect(found.map((hit) => hit.path)).not.toContain(line.slice(line.indexOf('(') + 1, -1)); + }); + + it.each(['Read', 'Update', 'Write', 'Edit', 'NotBash'])('takes a spaced filename in %s( whole', (tool) => { + const path = '/mnt/data/Kobo/Kobo eReader.conf'; + expect(paths(`${tool}(${path})`)).toEqual([path]); + }); +}); diff --git a/test/tab-group-launch.test.ts b/test/tab-group-launch.test.ts index c0ffe846..e2f60e67 100644 --- a/test/tab-group-launch.test.ts +++ b/test/tab-group-launch.test.ts @@ -6,7 +6,8 @@ * /api/sessions, remote/docker and external-CLI /api/quick-start), that the * one-session count overrides the toolbar steppers without touching them, that * the layout returned by the create response is adopted BEFORE the new tab is - * drawn (so it is drawn inside its group), and that an ordinary Run sends no + * drawn (so it is drawn inside its group), that a tab's "+" (`caseName`) starts + * in that case rather than the toolbar's, and that an ordinary Run sends no * group at all. * * Port: none (vm sandbox, stubbed fetch). @@ -36,7 +37,7 @@ function harness(caseData: Record, extra: { mode?: string } = { const body = init?.body ? JSON.parse(init.body) : undefined; requests.push({ url, body }); const json = (value: unknown) => ({ json: async () => value }); - if (url === '/api/cases/proj') return json({ success: true, data: caseData }); + if (url.startsWith('/api/cases/')) return json({ success: true, data: caseData }); if (url.endsWith('/status')) return json({ success: true, data: { available: true, runnable: true } }); if (url === '/api/sessions') { const id = `sess-${++counter}`; @@ -124,6 +125,25 @@ describe('launching into a tab group', () => { expect(app._applyTabLayout).toHaveBeenCalledWith(LAYOUT); }); + // A tab's "+" launches beside that tab: its case, never the toolbar's selected one. + it.each([ + // Claude's create body names no mode: it is the server's default. + ['local Claude', 'claude', '/api/sessions', { workingDir: '/tmp/other' }], + ['local Shell', 'shell', '/api/sessions', { mode: 'shell', workingDir: '/tmp/other' }], + ['an external CLI', 'gemini', '/api/quick-start', { mode: 'gemini', caseName: 'other' }], + ])('%s launches in `caseName` instead of the toolbar case', async (_label, mode, url, where) => { + const { app, elements, requests } = harness({ name: 'other', path: '/tmp/other' }, { mode }); + await app.run({ count: 1, caseName: 'other' }); + const made = creates(requests); + expect(made).toHaveLength(1); + expect(made[0].url).toBe(url); + expect(made[0].body).toMatchObject(where); + expect(JSON.stringify(made[0].body)).not.toContain('proj'); + // The local paths look the case up by the given name, never the toolbar's. + if (url === '/api/sessions') expect(requests[0].url).toBe('/api/cases/other'); + expect(elements.quickStartCase.value).toBe('proj'); + }); + it('an ordinary Run sends no group and keeps the stepper count', async () => { const { app, requests } = harness({ name: 'proj', path: '/tmp/proj' }); await app.run(); diff --git a/test/tab-layout-editing.test.ts b/test/tab-layout-editing.test.ts index 6620133d..b5d703a2 100644 --- a/test/tab-layout-editing.test.ts +++ b/test/tab-layout-editing.test.ts @@ -910,6 +910,22 @@ describe('group menu', () => { expect(app._tabGroupMenuKeydown).toBeNull(); }); + it("Safari's item click (a blur with nowhere to go) keeps the menu open, so the item still runs", () => { + // Safari never focuses a clicked button: the press blurs the focused item + // with relatedTarget null, and closing on that dropped the click. + installFetch(); + const app = makeApp(); + open(app); + app.startTabGroupRename = vi.fn(); + const buttons = document.querySelectorAll('.tab-layout-group-action-menu button'); + const rename = [...buttons].find((button) => button.textContent === 'Rename group')!; + buttons[0].dispatchEvent(new win.FocusEvent('focusout', { bubbles: true, relatedTarget: null })); + expect(isOpen()).toBe(true); + rename.click(); + expect(app.startTabGroupRename).toHaveBeenCalledWith('gx'); + expect(isOpen()).toBe(false); + }); + it('a pointer outside, Tab, focus leaving, a resize, a second open and a re-render all close it', () => { installFetch(); const app = makeApp(); diff --git a/test/tab-layout-settings-i18n.test.ts b/test/tab-layout-settings-i18n.test.ts index 55b68ac4..fd02807c 100644 --- a/test/tab-layout-settings-i18n.test.ts +++ b/test/tab-layout-settings-i18n.test.ts @@ -140,11 +140,13 @@ describe('the Bottom bar settings group in zh-CN', () => { /** What may stay Latin here: the git and Docker names, and the product name. */ const left = (text: string) => text.replace(/\b(Git|git|Docker|Codeman)\b/g, '').match(/[A-Za-z]+/g) ?? []; - it("finds the heading and the four Git status rows, #543's two included (the check is not vacuous)", () => { - expect(englishGroup.size).toBe(9); + it("finds the heading and the six Git rows, #543's two included and the fetch pair among them (the check is not vacuous)", () => { + expect(englishGroup.size).toBe(13); expect(englishGroup.get('heading')).toBe('Bottom bar'); expect(englishGroup.get('appSettingsGitStatusMaxRepos label')).toBe('Git status: max repositories'); expect(englishGroup.get('appSettingsGitStatusTimeout label')).toBe('Git status: git timeout'); + expect(englishGroup.get('appSettingsGitFetchEnabled label')).toBe('Git fetch on an interval'); + expect(englishGroup.get('appSettingsGitFetchIntervalSeconds label')).toBe('Git fetch: how often'); }); it('translates the heading, every label and every description, with no English left', () => { @@ -167,7 +169,8 @@ describe('the Bottom bar settings group in zh-CN', () => { expect([...englishGroup.values()].filter((s) => t.t(s) !== s)).toEqual([]); en.window.close(); const repeated = [...englishGroup.values()].filter((s) => { - const key = s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // A key with an apostrophe is written escaped in the object literal, so the pattern must allow both spellings. + const key = s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/'/g, "(?:\\\\)?'"); return (I18N.match(new RegExp(`^\\s*(?:'${key}'|"${key}"|${key}):`, 'gm')) ?? []).length !== 1; }); expect(repeated).toEqual([]); @@ -179,6 +182,6 @@ describe('the Bottom bar settings group in zh-CN', () => { const labels = [...doc.querySelectorAll('#gitStatusPanel .git-status-actions button')].map((b) => b.getAttribute('aria-label') ); - expect(labels).toEqual(['刷新 Git 状态', '关闭 Git 状态']); + expect(labels).toEqual(['刷新 Git 状态', '拉取 Git 状态', '关闭 Git 状态']); }); }); diff --git a/test/tab-new-session.test.ts b/test/tab-new-session.test.ts new file mode 100644 index 00000000..bf18389d --- /dev/null +++ b/test/tab-new-session.test.ts @@ -0,0 +1,839 @@ +/** + * @fileoverview A session tab's "+" (`showTabNewButton`, per device, off by + * default) and the row menu's "New session here…": the picker of CLIs the + * tab's case can start, and the one-off launch beside that tab. + * + * Driven through the shipping CodemanApp inside JSDOM (app.js, tab-rail-resize.js, + * mobile-overview.js and session-ui.js loaded as the page loads them). Pins: + * - each entry is a Run menu entry (the CLI's logo, then its name), agents in + * catalog order and the shell last, the Run button's mode preselected; + * - the tab's case is found by what the session runs on: a Docker session's + * container, a remote session's host and path, a local session's folder among + * LOCAL cases only (the case records are shaped as GET /api/cases sends them: + * display paths for remote and Docker cases, the raw values nested); + * - which CLIs are offered follows the Run menu's own rule for that case + * (_runModeAvailableForCase): the host's CLIs for a local case, the + * container's for a Docker case (live probe, then the stored list, nothing + * gated for an owned container), only the shell for an unreadable one; the + * picker starts that probe when nothing is cached for a Docker case; + * - a pick runs ONE launch in the tab's case or managed worktree with the tab's + * named group, and an ungrouped tab sends no group (the server's case default + * group then applies, pinned end to end in case-default-group-routes.test.ts); + * - the Run button's mode and the toolbar case are left as they were, also when + * the mode is changed while the launch is pending and when the server has + * moved lastUsedCase to the tab's case, and a later launch that names no case + * (a worktree tab's "+", the toolbar Run, a group's "New session") keeps the + * toolbar's case too; + * - a failed launch reports the server's own error; + * - Enter and Space on the "+" open the picker (the header strip's only + * keyboard path) and stop there; every other key passes on to the strip; a + * held key's repeat cannot also pick the entry the picker focuses; + * - the row menu offers the picker on session rows only while the setting is on; + * - the picker closes on a re-render and gives focus back to the tab on Escape; + * - the setting stays per device and OFF until switched on, and every new + * string has its zh-CN entry. + * + * Port: none (stubbed fetch). + */ + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { JSDOM } from 'jsdom'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SettingsUpdateSchema } from '../src/web/schemas.js'; + +const PUBLIC = join(process.cwd(), 'src/web/public'); +const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8'); + +const CATALOG = [ + { id: 'claude', kind: 'agent', label: 'Claude Code', enabled: true }, + { id: 'shell', kind: 'shell', label: 'Shell', enabled: true }, + { id: 'codex', kind: 'agent', label: 'Codex', enabled: true }, + { id: 'gemini', kind: 'agent', label: 'Gemini CLI', enabled: true }, + { id: 'pi', kind: 'agent', label: 'Pi', enabled: false }, +]; +/** What this host has installed: no gemini. */ +const HOST = new Set(['claude', 'codex']); +/** As GET /api/cases sends them: remote and Docker cases carry a DISPLAY path, the raw values nested. */ +const CASES = [ + { name: 'toolbar-case', path: '/tmp/toolbar-case', location: 'local' }, + // Same container name and same remote path on ANOTHER host, listed first: only the host tells them apart. + { + name: 'box-h2', + path: 'box:/srv/box', + location: 'docker', + docker: { hostId: 'h2', container: 'box', path: '/srv/box', containerWorkdir: '/srv/box' }, + }, + { + name: 'rem2', + path: 'dev@host2:/home/dev/work', + location: 'remote', + remote: { hostId: 'r2', host: 'host2', username: 'dev', path: '/home/dev/work' }, + }, + // Inside proj and listed before it: the deepest enclosing case wins, whatever the order. + { name: 'nested', path: '/tmp/proj/nested', location: 'local' }, + { name: 'proj', path: '/tmp/proj', location: 'local' }, + { + name: 'box', + path: 'box:/srv/box', + location: 'docker', + docker: { + hostId: 'h1', + container: 'box', + path: '/srv/box', + containerWorkdir: '/srv/box', + owned: false, + availableModes: ['gemini', 'shell'], + }, + }, + { + name: 'own', + path: 'own:/srv/own', + location: 'docker', + docker: { hostId: 'h1', container: 'own', path: '/srv/own', containerWorkdir: '/srv/own' }, + }, + // A local case whose folder is the Docker case's workspace: only a LOCAL session belongs to it. + { name: 'clash', path: '/srv/box', location: 'linked-local' }, + { + name: 'rem', + path: 'dev@host1:/home/dev/work', + location: 'remote', + remote: { hostId: 'r1', host: 'host1', username: 'dev', path: '/home/dev/work' }, + }, +]; +const DOCKER_BOX = { hostId: 'h1', containerName: 'box', hostWorkspacePath: '/srv/box', containerWorkdir: '/srv/box' }; +const DOCKER_OWN = { hostId: 'h1', containerName: 'own', hostWorkspacePath: '/srv/own', containerWorkdir: '/srv/own' }; +const REMOTE_R1 = { hostId: 'r1', host: 'host1', username: 'dev', remotePath: '/home/dev/work/api' }; +const SESSIONS: Array<[string, string, Record?]> = [ + ['a', '/tmp/proj/sub'], + ['b', '/tmp/proj'], + ['c', '/srv/box', { docker: DOCKER_BOX }], + ['d', '/srv/own', { docker: DOCKER_OWN }], + ['e', '/home/u/scratch'], + ['f', '/tmp/proj/.claude/worktrees/feat'], + ['g', '/srv/box'], + ['r', '/home/dev/work/api', { remote: REMOTE_R1 }], + ['n', '/tmp/proj/nested/src'], +]; +const LAYOUT = { + version: 4, + updatedAt: '2026-10-01T00:00:00.000Z', + groups: [{ id: 'gx', name: 'Core', refs: [{ kind: 'session', id: 'b' }] }], + ungrouped: ['a', 'c', 'd', 'e', 'f', 'g', 'r', 'n'].map((id) => ({ kind: 'session', id })), +}; + +let CodemanApp: { prototype: Record }; +/** The test's app, so afterEach can close its menus: their document listeners would outlive the DOM. */ +let current: Record | null = null; +let win: any; +let document: Document; + +beforeAll(async () => { + const dom = new JSDOM('', { + url: 'https://localhost/', + runScripts: 'outside-only', + }); + if (dom.window.document.readyState !== 'complete') { + await new Promise((resolve) => dom.window.addEventListener('load', resolve)); + } + win = dom.window; + document = win.document; + win.setInterval = () => 0; + win.requestAnimationFrame = () => 0; + win.CSS = { escape: (value: string) => value }; + win.eval( + 'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' + + 'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' + + [ + 'constants.js', + 'tab-layout-browser.js', + 'app.js', + 'tab-rail-resize.js', + 'api-client.js', + 'webview-tabs.js', + 'mobile-overview.js', + 'session-ui.js', + ] + .map(read) + .join('\n;\n') + + '\n;window.__TabNewCodemanApp = CodemanApp;' + ); + CodemanApp = win.__TabNewCodemanApp; +}); + +function makeApp({ orientation = 'horizontal' } = {}) { + const app = Object.create(CodemanApp.prototype) as Record; + document.documentElement.setAttribute('data-tab-orientation', orientation); + document.documentElement.dataset.tabRailSort = 'manual'; + document.body.innerHTML = + '
' + + '' + + ''; + app.$ = (id: string) => document.getElementById(id); + app.sessions = new Map( + SESSIONS.map(([id, workingDir, extra]) => [id, { id, name: id, status: 'idle', workingDir, ...extra }]) + ); + app.sessionOrder = SESSIONS.map(([id]) => id); + app.webviews = new Map(); + app.webviewOrder = []; + app.activeSessionId = 'a'; + app.activeWebviewId = null; + app.tabLayout = null; + app.collapsedTabGroupIds = new Set(); + app._hiddenTabGroupByRef = new Map(); + app._lastTabGroupStructureKey = null; + app._tabCollapseStorageFailed = false; + app._inlineRenameActive = false; + app.tabAlerts = new Map(); + app.terminalLoadStates = new Map(); + app.minimizedSubagents = new Map(); + app.hasTabDetachOverride = () => false; + app.renderSubagentTabBadge = () => ''; + app.cancelHideSubagentDropdown = () => {}; + app.updateTabOverflowMode = () => {}; + app.updateConnectionLines = () => {}; + app._applyTabEntrances = () => {}; + app._scrollActiveTabIntoView = () => {}; + app.applySidebarFilter = () => {}; + app.isSessionSidebarActive = () => false; + app._startSidebarRichClock = () => {}; + app._stopSidebarRichClock = () => {}; + app.loadAppSettingsFromStorage = () => ({}); + app.openSessionOptions = vi.fn(); + app.requestCloseSession = vi.fn(); + app.selectSession = vi.fn(async () => {}); + app.showToast = vi.fn(); + app.closeAllPanels = vi.fn(); + // What the real run() reads beyond the DOM (the same stubs as tab-group-launch.test.ts). + app._runMinLockMs = 0; + app._runMode = 'codex'; + app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {}, cols: 80, rows: 24 }; + app.cases = CASES; + app.isCliAvailable = (id: string) => HOST.has(id); + app.getCaseSettings = () => ({}); + app.buildEnvOverrides = () => ({}); + app.getEffortSetting = () => undefined; + app.isRalphTrackerEnabledByDefault = () => false; + app.getTerminalDimensions = () => null; + app.loadQuickStartCases = () => {}; + app._fullRenderSessionTabs(); + app._applyTabLayout(LAYOUT); + current = app; + return app; +} + +const tab = (id: string) => document.querySelector(`.session-tab[data-id="${id}"]`)!; +const plus = (id: string) => tab(id).querySelector('.tab-new')!; +const picker = () => document.querySelector('[role="menu"][aria-label="New session here"]'); +/** Each entry as [logo slot classes, visible text, button classes]. */ +const entries = () => + [...(picker()?.querySelectorAll('button') || [])].map((button) => [ + button.querySelector('.run-mode-dot')?.className, + button.textContent, + button.className, + ]); +const openFrom = (app: Record, id: string) => + app.openTabNewSessionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: plus(id) }, id); +const pick = (label: string) => + [...picker()!.querySelectorAll('button')].find((button) => button.textContent === label)!.click(); +const rowMenuLabels = () => + [...document.querySelectorAll('.tab-rail-action-menu:not(.tab-layout-group-action-menu) button')].map( + (button) => button.textContent + ); +const flush = async () => { + for (let i = 0; i < 6; i++) await new Promise((resolve) => setTimeout(resolve, 0)); +}; + +beforeEach(() => { + win.localStorage.clear(); + document.body.innerHTML = ''; + document.documentElement.className = ''; + win.__codemanCliCatalog = CATALOG; + win.__codemanUser = { username: 'admin', role: 'admin', multiUser: false }; +}); + +afterEach(() => { + current?.closeTabGroupMenu(); + current?.closeTabRailActionMenu(); + current = null; +}); + +describe('the tab "+" picker', () => { + it('lists the CLIs the case can start as logo + name, agents in catalog order, the shell last', () => { + const app = makeApp(); + // The markup wires the "+" to this picker for its own tab. + expect(plus('a').getAttribute('onclick')).toBe('event.stopPropagation(); app.openTabNewSessionMenu(event, "a")'); + expect(openFrom(app, 'a')).toBe(true); + // gemini is not on this host and pi is disabled in the catalog. + expect(entries()).toEqual([ + ['run-mode-dot claude', 'Claude Code', 'run-mode-option'], + ['run-mode-dot codex', 'Codex', 'run-mode-option selected'], + ['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option'], + ]); + for (const dot of picker()!.querySelectorAll('.run-mode-dot')) expect(dot.getAttribute('aria-hidden')).toBe('true'); + // The Run button's mode is the one preselected for the keyboard. + expect(document.activeElement?.textContent).toBe('Codex'); + }); + + it("a Docker case offers the container's CLIs through the Run menu's rule, never the host's", () => { + const app = makeApp(); + // Adopted, not probed yet: the list stored at adoption (gemini, which the host lacks; no claude, which it has). + openFrom(app, 'c'); + expect(entries()).toEqual([ + ['run-mode-dot gemini', 'Gemini CLI', 'run-mode-option'], + ['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option'], + ]); + app.closeTabGroupMenu(); + // A live probe wins over the stored list. + app._dockerCaseModes = { box: ['claude', 'shell'] }; + openFrom(app, 'c'); + expect(entries()).toEqual([ + ['run-mode-dot claude', 'Claude Code', 'run-mode-option'], + ['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option'], + ]); + app.closeTabGroupMenu(); + // An unreadable adopted container offers no agent at all. + app._dockerCaseProbeError = { box: 'Could not read container "box".' }; + openFrom(app, 'c'); + expect(entries()).toEqual([['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option']]); + app.closeTabGroupMenu(); + // An owned container with nothing probed is not gated: its image ships every CLI. + openFrom(app, 'd'); + expect(entries()).toEqual([ + ['run-mode-dot claude', 'Claude Code', 'run-mode-option'], + ['run-mode-dot codex', 'Codex', 'run-mode-option selected'], + ['run-mode-dot gemini', 'Gemini CLI', 'run-mode-option'], + ['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option'], + ]); + }); + + it("opening on a Docker tab whose container is not probed yet probes it, so the next open lists the container's CLIs", async () => { + const app = makeApp(); + const probe = vi.spyOn(app, '_probeDockerCaseModes'); + const previousFetch = win.fetch; + // The network boundary: what POST /api/docker-cases/adopt-preflight answers for this container. + win.fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ success: true, data: { ok: true, availableModes: ['codex', 'shell'] } }), + })); + try { + probe.mockClear(); + win.fetch.mockClear(); + openFrom(app, 'c'); + // This open reads what is already known (the list stored at adoption) and starts the probe. + expect(entries()).toEqual([ + ['run-mode-dot gemini', 'Gemini CLI', 'run-mode-option'], + ['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option'], + ]); + expect(probe).toHaveBeenCalledTimes(1); + expect(probe.mock.calls[0][0]).toBe(CASES.find((c) => c.name === 'box')); + expect(probe.mock.calls[0][1]).toBeNull(); + expect(win.fetch).toHaveBeenCalledTimes(1); + const [url, init] = win.fetch.mock.calls[0]; + expect(url).toBe('/api/docker-cases/adopt-preflight'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body)).toStrictEqual({ hostId: 'h1', container: 'box' }); + await flush(); + app.closeTabGroupMenu(); + + // The next open lists what the container answered, and probes no more. + probe.mockClear(); + win.fetch.mockClear(); + openFrom(app, 'c'); + expect(entries()).toEqual([ + ['run-mode-dot codex', 'Codex', 'run-mode-option selected'], + ['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option'], + ]); + expect(probe).not.toHaveBeenCalled(); + expect(win.fetch).not.toHaveBeenCalled(); + app.closeTabGroupMenu(); + + // An owned container with nothing probed is probed as well. + probe.mockClear(); + openFrom(app, 'd'); + expect(probe).toHaveBeenCalledTimes(1); + expect(probe.mock.calls[0][0]).toBe(CASES.find((c) => c.name === 'own')); + expect(probe.mock.calls[0][1]).toBeNull(); + await flush(); + app.closeTabGroupMenu(); + + // Local, remote and worktree tabs have no container to ask. + for (const id of ['a', 'g', 'r', 'f']) { + probe.mockClear(); + win.fetch.mockClear(); + openFrom(app, id); + expect(probe).not.toHaveBeenCalled(); + expect(win.fetch).not.toHaveBeenCalled(); + app.closeTabGroupMenu(); + } + } finally { + win.fetch = previousFetch; + } + }); + + it('finds the case by what the session runs on, never a same-path case elsewhere', async () => { + const app = makeApp(); + const runs: unknown[] = []; + app.run = vi.fn(async (options: unknown) => { + runs.push(structuredClone(options)); + }); + // A Docker session belongs to its container's case, not to the local case at the same path. + openFrom(app, 'c'); + pick('Terminal / Shell'); + // A local session at that path belongs to the local case, never the Docker one. + openFrom(app, 'g'); + expect(entries().map(([, text]) => text)).toEqual(['Claude Code', 'Codex', 'Terminal / Shell']); + pick('Terminal / Shell'); + // A remote session belongs to the case on its own host, by path prefix. + openFrom(app, 'r'); + pick('Terminal / Shell'); + // A local session in a case inside another case belongs to the inner one. + openFrom(app, 'n'); + pick('Terminal / Shell'); + await flush(); + expect(runs).toStrictEqual([ + { count: 1, caseName: 'box', mode: 'shell' }, + { count: 1, caseName: 'clash', mode: 'shell' }, + { count: 1, caseName: 'rem', mode: 'shell' }, + { count: 1, caseName: 'nested', mode: 'shell' }, + ]); + }); + + it('a tab whose folder is no case says so and opens nothing', () => { + const app = makeApp(); + expect(openFrom(app, 'e')).toBe(false); + expect(picker()).toBeNull(); + expect(app.showToast).toHaveBeenCalledWith( + "This tab's folder is not a case, so there is nothing to start beside it.", + 'info' + ); + }); +}); + +describe('the "+" from the keyboard', () => { + /** + * Wire the "+"'s inline keydown handler the way a browser wires an on* attribute + * (the attribute's text as the body of a function of `event`, `this` the + * element, `app` the page's global), since this JSDOM does not run inline handlers. + */ + function wireInlineKeydown(app: Record, el: HTMLElement) { + win.app = app; + const body = el.getAttribute('onkeydown'); + expect(body).not.toBeNull(); + const handler = new win.Function('event', body); + el.addEventListener('keydown', function (this: HTMLElement, event: Event) { + handler.call(this, event); + }); + } + const press = (el: HTMLElement, key: string) => { + const event = new win.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }); + el.dispatchEvent(event); + return event; + }; + + it('Enter and Space on the header strip\'s "+" open its picker, and the key goes no further', () => { + const app = makeApp({ orientation: 'horizontal' }); + const strip = document.getElementById('sessionTabs')!; + // The header strip: a flat tablist, no row menu to fall back on. + expect(strip.getAttribute('role')).toBe('tablist'); + const open = vi.spyOn(app, 'openTabNewSessionMenu'); + const reachedStrip = vi.fn(); + strip.addEventListener('keydown', reachedStrip); + wireInlineKeydown(app, plus('a')); + for (const key of ['Enter', ' ']) { + open.mockClear(); + reachedStrip.mockClear(); + const event = press(plus('a'), key); + expect(open).toHaveBeenCalledTimes(1); + expect(open.mock.calls[0][0]).toBe(event); + expect(open.mock.calls[0][1]).toBe('a'); + expect(event.defaultPrevented).toBe(true); + expect(reachedStrip).not.toHaveBeenCalled(); + expect(entries()).toEqual([ + ['run-mode-dot claude', 'Claude Code', 'run-mode-option'], + ['run-mode-dot codex', 'Codex', 'run-mode-option selected'], + ['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option'], + ]); + // Anchored on the "+" itself; Escape gives focus back to its tab row (the shared menu's design). + expect(app._tabGroupMenuTrigger).toBe(plus('a')); + app.closeTabGroupMenu(); + } + strip.removeEventListener('keydown', reachedStrip); + }); + + it('on a tab whose folder is no case the key still stops at the "+", so Space does not scroll the page', () => { + const app = makeApp({ orientation: 'horizontal' }); + const strip = document.getElementById('sessionTabs')!; + const reachedStrip = vi.fn(); + strip.addEventListener('keydown', reachedStrip); + wireInlineKeydown(app, plus('e')); + app.showToast.mockClear(); + const event = press(plus('e'), ' '); + expect(event.defaultPrevented).toBe(true); + expect(reachedStrip).not.toHaveBeenCalled(); + expect(picker()).toBeNull(); + expect(app.showToast).toHaveBeenCalledWith( + "This tab's folder is not a case, so there is nothing to start beside it.", + 'info' + ); + strip.removeEventListener('keydown', reachedStrip); + }); + + it('every other key leaves the "+" alone and reaches the strip as before', () => { + const app = makeApp({ orientation: 'horizontal' }); + const strip = document.getElementById('sessionTabs')!; + const open = vi.spyOn(app, 'openTabNewSessionMenu'); + const reachedStrip = vi.fn(); + strip.addEventListener('keydown', reachedStrip); + wireInlineKeydown(app, plus('a')); + for (const key of ['a', 'ArrowRight', 'ArrowDown', 'Escape', 'Tab', 'F2']) { + open.mockClear(); + reachedStrip.mockClear(); + const event = press(plus('a'), key); + expect(open).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + expect(reachedStrip).toHaveBeenCalledTimes(1); + expect(picker()).toBeNull(); + } + strip.removeEventListener('keydown', reachedStrip); + }); + + it('a held Enter or Space that opened the picker cannot also pick its focused entry', async () => { + const app = makeApp({ orientation: 'horizontal' }); + const runs: unknown[] = []; + app.run = vi.fn(async (options: unknown) => { + runs.push(structuredClone(options)); + }); + wireInlineKeydown(app, plus('a')); + for (const key of ['Enter', ' ']) { + press(plus('a'), key); + const focused = document.activeElement as HTMLElement; + expect(focused.textContent).toBe('Codex'); + // The key's auto-repeat lands on the entry the picker just focused: its + // native activation is cancelled, so nothing launches and the picker stays. + const repeat = new win.KeyboardEvent('keydown', { key, repeat: true, bubbles: true, cancelable: true }); + focused.dispatchEvent(repeat); + expect(repeat.defaultPrevented).toBe(true); + expect(picker()).not.toBeNull(); + // A fresh press on an entry keeps its native activation. + const fresh = new win.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }); + focused.dispatchEvent(fresh); + expect(fresh.defaultPrevented).toBe(false); + app.closeTabGroupMenu(); + } + // Other repeated keys keep their own handling (ArrowDown still moves). + press(plus('a'), 'Enter'); + const arrow = new win.KeyboardEvent('keydown', { key: 'ArrowDown', repeat: true, bubbles: true, cancelable: true }); + document.activeElement!.dispatchEvent(arrow); + expect(arrow.defaultPrevented).toBe(true); + expect(document.activeElement?.textContent).toBe('Terminal / Shell'); + app.closeTabGroupMenu(); + await flush(); + expect(runs).toStrictEqual([]); + }); +}); + +describe('a pick from the "+"', () => { + function recordRuns(app: Record) { + const runs: Array<{ options: unknown; runMode: string }> = []; + app.run = vi.fn(async (options: unknown) => { + // Cloned into this realm, so toStrictEqual compares keys and values, not JSDOM's prototypes. + runs.push({ options: structuredClone(options), runMode: app._runMode }); + }); + return runs; + } + + it("starts ONE session in the tab's case and named group, then leaves the Run mode and case alone", async () => { + const app = makeApp(); + const runs = recordRuns(app); + app.collapsedTabGroupIds.add('gx'); + openFrom(app, 'b'); + pick('Claude Code'); + await flush(); + // Its group is opened, so the new tab is visible when it arrives. + expect(app.collapsedTabGroupIds.has('gx')).toBe(false); + // The mode rides the launch; the Run button's own mode is never swapped. + expect(runs).toStrictEqual([ + { options: { count: 1, caseName: 'proj', tabGroupId: 'gx', mode: 'claude' }, runMode: 'codex' }, + ]); + expect(picker()).toBeNull(); + expect(app._runMode).toBe('codex'); + expect((document.getElementById('quickStartCase') as HTMLSelectElement).value).toBe('toolbar-case'); + }); + + it('from an ungrouped tab it sends no group at all, so the case default group can place it', async () => { + const app = makeApp(); + const runs = recordRuns(app); + openFrom(app, 'a'); + pick('Terminal / Shell'); + await flush(); + expect(runs).toStrictEqual([{ options: { count: 1, caseName: 'proj', mode: 'shell' }, runMode: 'codex' }]); + }); + + it('from a tab in a managed worktree it starts in that worktree, not the case folder', async () => { + const app = makeApp(); + const runs = recordRuns(app); + openFrom(app, 'f'); + pick('Codex'); + await flush(); + expect(runs).toStrictEqual([ + { + options: { + count: 1, + worktree: { caseName: 'proj', name: 'feat', path: '/tmp/proj/.claude/worktrees/feat' }, + mode: 'codex', + }, + runMode: 'codex', + }, + ]); + }); + + it('refuses while another launch is in flight, and says so', async () => { + const app = makeApp(); + const runs = recordRuns(app); + openFrom(app, 'a'); + app._runInFlight = true; + app.showToast.mockClear(); + pick('Codex'); + await flush(); + expect(runs).toEqual([]); + expect(app.showToast).toHaveBeenCalledWith('A launch is already in progress', 'info'); + expect(app._runMode).toBe('codex'); + }); + + it("a launch the server refuses reports the server's own error", async () => { + const app = makeApp(); + app._runMode = 'claude'; + const posted: Array<{ url: string; body: unknown }> = []; + win.fetch = vi.fn(async (url: string, init?: { body?: string }) => { + const body = init?.body ? JSON.parse(init.body) : undefined; + posted.push({ url, body }); + const json = (value: unknown) => ({ ok: true, status: 200, json: async () => value }); + if (url === '/api/codex/status') return json({ success: true, data: { available: true, runnable: true } }); + if (url === '/api/quick-start') { + return json({ success: false, error: 'Case "proj" has no free session slot', errorCode: 'CONFLICT' }); + } + return json({ success: true, data: {} }); + }); + openFrom(app, 'a'); + app.showToast.mockClear(); + pick('Codex'); + await flush(); + expect(posted.find((req) => req.url === '/api/quick-start')?.body).toMatchObject({ + caseName: 'proj', + mode: 'codex', + }); + expect(app.showToast).toHaveBeenLastCalledWith('Case "proj" has no free session slot', 'error'); + expect(app._runMode).toBe('claude'); + }); + + it('a Run mode chosen while the launch is pending is kept, on screen and on disk', async () => { + const app = makeApp(); + app._applyRunMode = () => {}; + app._apiPut = vi.fn(async () => ({ success: true })); + let finish: () => void = () => {}; + app.runShell = vi.fn(() => new Promise((resolve) => (finish = resolve))); + openFrom(app, 'a'); + pick('Terminal / Shell'); + await flush(); + expect(app.runShell).toHaveBeenCalledTimes(1); + app.setRunMode('claude'); + finish(); + await flush(); + expect(app._runMode).toBe('claude'); + expect(win.localStorage.getItem('codeman_runMode')).toBe('claude'); + expect(app._apiPut).toHaveBeenCalledWith('/api/settings', { runMode: 'claude' }); + }); + + /** + * Readies a Claude launch with the shipping case-list reload and the server's + * lastUsedCase at `lastUsedCase`; the returned wait resolves once that reload ran. + */ + function useShippingCaseReload(app: Record, lastUsedCase: string) { + app._runMode = 'claude'; + app.getAdvisorSetting = () => undefined; + app._onSessionCreated = () => {}; + delete app.loadQuickStartCases; // the shipping reload, not makeApp's no-op + // The UI-only steps of the case list reload. + for (const name of ['updateDirDisplayForCase', 'updateMobileCaseLabel', 'updateCasePickerInput']) + app[name] = () => {}; + for (const name of ['renderCasePickerList', 'closeCasePicker', 'setupQuickStartCasePicker']) app[name] = () => {}; + win.fetch = vi.fn(async (url: string) => { + const json = (value: unknown) => ({ ok: true, status: 200, json: async () => value }); + // quick-start saved the launched case as lastUsedCase (session-routes.ts). + if (url === '/api/settings') return json({ success: true, data: { lastUsedCase } }); + if (url === '/api/cases') return json({ success: true, data: CASES }); + if (url.startsWith('/api/cases/')) { + const record = CASES.find((c) => `/api/cases/${c.name}` === url); + return json(record ? { success: true, data: record } : { success: false, errorCode: 'NOT_FOUND' }); + } + if (url === '/api/quick-start') + return json({ success: true, data: { sessionId: 'new1', session: { id: 'new1' } } }); + if (url === '/api/sessions') return json({ success: true, data: { session: { id: 'new1', name: 'w1' } } }); + return json({ success: true, data: {} }); + }); + return async () => { + await vi.waitFor(() => expect(app.selectSession).toHaveBeenCalledWith('new1')); + // The case list is reloaded after the launch (not awaited by it). + await vi.waitFor(() => expect(win.fetch.mock.calls.map((call: unknown[]) => call[0])).toContain('/api/cases')); + await flush(); + }; + } + + it.each([ + ['a remote Claude launch (quick-start)', 'rem', 'r'], + ['a local Claude launch', 'proj', 'a'], + ])("%s leaves the toolbar on its own case, though the server's lastUsedCase moved", async (_label, tabCase, id) => { + const app = makeApp(); + const reloaded = useShippingCaseReload(app, tabCase); + const select = document.getElementById('quickStartCase') as HTMLSelectElement; + openFrom(app, id); + pick('Claude Code'); + await reloaded(); + expect(select.value).toBe('toolbar-case'); + }); + + // An earlier "+" launch through quick-start left lastUsedCase on the tab's case + // (proj); a later Claude launch that names no case must not follow it there. + it.each([ + [ + 'a "+" Claude launch from a managed worktree tab', + 'horizontal', + (app: Record) => { + openFrom(app, 'f'); + pick('Claude Code'); + }, + ], + ['the toolbar Run with Claude', 'horizontal', (app: Record) => app.run()], + [ + 'a group\'s "New session" with Claude', + 'vertical', + (app: Record) => app.createSessionInTabGroup('gx'), + ], + ])('%s keeps the toolbar on its own case after a "+" moved lastUsedCase', async (_label, orientation, launch) => { + const app = makeApp({ orientation }); + const reloaded = useShippingCaseReload(app, 'proj'); + const select = document.getElementById('quickStartCase') as HTMLSelectElement; + (document.getElementById('tabCount') as HTMLInputElement).value = '1'; + void launch(app); + await reloaded(); + expect(select.value).toBe('toolbar-case'); + }); +}); + +describe('"New session here…" in the row menu', () => { + const rowMenu = (app: Record, trigger: HTMLElement, id: string) => + app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: trigger }, id); + + it('is offered on a session row only while the "+" is switched on, and opens the same picker', () => { + const app = makeApp({ orientation: 'vertical' }); + rowMenu(app, tab('a'), 'a'); + expect(rowMenuLabels()).toEqual([ + 'Session options', + 'Add to Focus', + 'Move down', + 'Move to "Core"', + 'Move to new group', + 'Close session', + ]); + app.closeTabRailActionMenu(); + + document.documentElement.classList.add('tabs-show-new'); + rowMenu(app, tab('a'), 'a'); + expect(rowMenuLabels()).toEqual([ + 'Session options', + 'New session here…', + 'Add to Focus', + 'Move down', + 'Move to "Core"', + 'Move to new group', + 'Close session', + ]); + [...document.querySelectorAll('.tab-rail-action-menu button')] + .find((button) => button.textContent === 'New session here…')! + .click(); + expect(rowMenuLabels()).toEqual([]); + expect(entries().map(([, text]) => text)).toEqual(['Claude Code', 'Codex', 'Terminal / Shell']); + // Escape gives the keyboard back to the tab row. + document.activeElement!.dispatchEvent( + new win.KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + ); + expect(picker()).toBeNull(); + expect(document.activeElement).toBe(tab('a')); + }); + + it("is never offered on a tile's menu, which shares the function", () => { + const app = makeApp({ orientation: 'vertical' }); + document.documentElement.classList.add('tabs-show-new'); + const tileButton = document.createElement('button'); + tileButton.className = 'tile-menu'; + document.body.appendChild(tileButton); + rowMenu(app, tileButton, 'a'); + expect(rowMenuLabels()).toEqual([ + 'Session options', + 'Add to Focus', + 'Move down', + 'Move to "Core"', + 'Move to new group', + 'Close session', + ]); + }); +}); + +describe('picker lifetime', () => { + it('a full re-render closes it and drops its document listeners', () => { + const app = makeApp(); + openFrom(app, 'a'); + expect(picker()).not.toBeNull(); + app.sessions.delete('a'); + app._fullRenderSessionTabs(); + expect(picker()).toBeNull(); + expect(app._tabGroupMenuOutside).toBeNull(); + expect(app._tabGroupMenuKeydown).toBeNull(); + expect(app._tabGroupMenuResize).toBeNull(); + }); + + it('pressing the same "+" again closes it, like the row menu', () => { + const app = makeApp(); + openFrom(app, 'a'); + expect(openFrom(app, 'a')).toBe(false); + expect(picker()).toBeNull(); + }); +}); + +describe('showTabNewButton stays per-device and OFF by default', () => { + const settingsUi = read('settings-ui.js'); + const index = read('index.html'); + + it('is a display key, stripped from the settings PUT, which would refuse it', () => { + const start = settingsUi.indexOf('const displayKeys = new Set(['); + expect(settingsUi.slice(start, settingsUi.indexOf('])', start))).toContain("'showTabNewButton'"); + expect(settingsUi).toContain('showTabNewButton: _tnb,'); + expect(SettingsUpdateSchema.safeParse({ showTabNewButton: true }).success).toBe(false); + }); + + it('loads OFF unless switched on, saves from its switch, and drives the class the CSS reads', () => { + expect(settingsUi).toContain( + "document.getElementById('appSettingsShowTabNewButton').checked = settings.showTabNewButton === true;" + ); + expect(settingsUi).toContain("showTabNewButton: document.getElementById('appSettingsShowTabNewButton').checked,"); + expect(settingsUi).toContain( + "document.documentElement.classList.toggle('tabs-show-new', settings.showTabNewButton === true);" + ); + expect(index).toContain(''); + }); + + it('translates every string it adds', () => { + const i18n = read('i18n.js'); + for (const text of [ + "'New session here'", + "'New session here…'", + '"New session in this tab\'s case"', + '"This tab\'s folder is not a case, so there is nothing to start beside it."', + "'New Session Button on Tabs'", + "'A + on the tab that starts a session in the same case and group, in any installed CLI.'", + ]) { + expect(i18n).toContain(`${text}:`); + } + }); +}); diff --git a/test/tab-strip-corner.browser.test.ts b/test/tab-strip-corner.browser.test.ts new file mode 100644 index 00000000..e70bc4a3 --- /dev/null +++ b/test/tab-strip-corner.browser.test.ts @@ -0,0 +1,342 @@ +/** + * @fileoverview Real-Chromium coverage for the L-shaped header strip (`_syncTabStripCorner()`, app.js). + * + * What DOM emulation cannot answer: whether the float the strip measures really keeps every tab out from under + * the toolbar, whether the rows below it really run the header's full width, and when the strip falls back to + * the plain wrap. The real `
` from index.html, the shipping styles.css, constants.js and app.js are + * loaded into a page; `updateTabOverflowMode()` runs for real on a bare app instance. + * + * Port: none (page.route on a fake origin, no server). + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chromium, type Browser, type Page } from 'playwright'; + +const dir = resolve(import.meta.dirname, '../src/web/public'); +const read = (name: string) => readFileSync(resolve(dir, name), 'utf8'); +const styles = read('styles.css'); +const header = /
[\s\S]*?<\/header>/.exec(read('index.html'))![0]; +const ORIGIN = 'http://app.test/'; + +type Rect = { left: number; right: number; top: number; bottom: number }; + +describe('L-shaped header tab strip (real Chromium)', () => { + let browser: Browser; + let p: Page; + beforeAll(async () => { + browser = await chromium.launch(); + }); + afterAll(async () => { + await browser.close(); + }); + + /** Open the real header with `tabs` classic tabs; `twoRows` is the manual two-rows setting. */ + async function open(options: { tabs: number; width?: number; height?: number; twoRows?: boolean }) { + p = await browser.newPage({ + viewport: { width: options.width ?? 1400, height: options.height ?? 700 }, + deviceScaleFactor: 1, + }); + await p.route(ORIGIN, (route) => + route.fulfill({ + contentType: 'text/html', + body: + '' + + `${header}`, + }) + ); + await p.goto(ORIGIN); + await p.addScriptTag({ + content: + 'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' + + 'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' + + read('constants.js') + + '\n' + + read('app.js') + + '\nwindow.CodemanApp = CodemanApp;', + }); + await p.evaluate( + ({ tabs, twoRows }) => { + const w = window as any; + const app = Object.create(w.CodemanApp.prototype); + app.$ = (id: string) => document.getElementById(id); + app.sessions = new Map(); + app.loadAppSettingsFromStorage = () => ({ tabTwoRows: twoRows }); + app.getDefaultSettings = () => ({}); + app.isSessionSidebarActive = () => false; + app.updateConnectionLines = () => undefined; + const strip = document.getElementById('sessionTabs')!; + if (twoRows) strip.classList.add('tabs-two-rows'); + for (let i = 1; i <= tabs; i++) { + const id = `s${i}`; + app.sessions.set(id, { id, name: `session-${i}` }); + strip.insertAdjacentHTML( + 'beforeend', + `` + ); + } + w.__app = app; + }, + { tabs: options.tabs, twoRows: !!options.twoRows } + ); + await relayout(); + } + + const settled = () => p.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); + const relayout = async () => { + await p.evaluate(() => (window as any).__app.updateTabOverflowMode()); + await settled(); + }; + const cornered = () => p.evaluate(() => document.getElementById('sessionTabs')!.classList.contains('tabs-corner')); + const toolbar = () => + p.evaluate(() => document.getElementById('headerRight')!.getBoundingClientRect().toJSON()) as Promise; + /** The visible part of each tab: the strip clips what it scrolls or overflows. */ + const visibleTabs = () => + p.evaluate(() => { + const strip = document.getElementById('sessionTabs')!.getBoundingClientRect(); + return [...document.querySelectorAll('#sessionTabs > .session-tab')] + .map((el) => { + const r = el.getBoundingClientRect(); + return { + left: Math.max(r.left, strip.left), + right: Math.min(r.right, strip.right), + top: Math.max(r.top, strip.top), + bottom: Math.min(r.bottom, strip.bottom), + }; + }) + .filter((r) => r.right > r.left && r.bottom > r.top); + }) as Promise; + const overlapping = async () => { + const b = await toolbar(); + return (await visibleTabs()).filter( + (r) => r.left < b.right && r.right > b.left && r.top < b.bottom && r.bottom > b.top + ); + }; + + it('puts a wrapped classic desktop strip around the toolbar, with no tab under it', async () => { + await open({ tabs: 40 }); + expect(await p.evaluate(() => document.getElementById('sessionTabs')!.classList.contains('tabs-auto-wrap'))).toBe( + true + ); + expect(await cornered()).toBe(true); + expect(await overlapping()).toEqual([]); + await p.close(); + }); + + it('runs a row below the toolbar past its left edge', async () => { + await open({ tabs: 40 }); + const b = await toolbar(); + const below = (await visibleTabs()).filter((r) => r.top >= b.bottom); + expect(below.length).toBeGreaterThan(0); + expect(Math.max(...below.map((r) => r.right))).toBeGreaterThan(b.left); + await p.close(); + }); + + /** Give tab `n` (1-based) a name long enough to be wider than the room beside the toolbar. */ + const longName = (n: number) => + p.evaluate((n) => { + const tab = document.querySelectorAll('#sessionTabs > .session-tab')[n - 1]; + const name = tab.querySelector('.tab-name')!; + name.textContent = 'a-very-long-session-name '.repeat(12); + }, n); + /** Is the first tab beside the toolbar (not dropped below it), and does it carry the trim? */ + const firstTab = () => + p.evaluate(() => { + const tab = document.querySelector('#sessionTabs > .session-tab')!; + const r = tab.getBoundingClientRect(); + const b = document.getElementById('headerRight')!.getBoundingClientRect(); + return { + besideToolbar: r.top < b.bottom && r.right <= b.left, + trimmed: tab.classList.contains('tab-corner-fit'), + }; + }); + /** Each visible tab's top, in strip order: equal tops share a row. */ + const rowTops = async () => (await visibleTabs()).map((r) => Math.round(r.top)); + + it('trims a first tab too wide for the room beside the toolbar instead of dropping the corner', async () => { + await open({ tabs: 40 }); + await longName(1); + await relayout(); + expect(await cornered()).toBe(true); + expect(await overlapping()).toEqual([]); + // Beside the toolbar, not dropped a row below it. + const [first] = await visibleTabs(); + const b = await toolbar(); + expect(first.right).toBeLessThanOrEqual(b.left); + expect(first.top).toBeLessThan(b.bottom); + // The rows below still run the full width, several tabs to a row. + const tops = await rowTops(); + expect(tops[1]).toBe(tops[2]); + await p.close(); + }); + + it('keeps the corner whichever tab is selected, though selecting widens the first tab past the room', async () => { + await open({ tabs: 40 }); + // Real selection: the gear and close show only on the active tab. The first + // tab's name is grown until it fits the room unselected but not selected. + await p.evaluate(() => { + for (const tab of document.querySelectorAll('#sessionTabs > .session-tab')) { + tab.insertAdjacentHTML('beforeend', '⚙×'); + } + }); + await relayout(); + await p.evaluate(() => { + const strip = document.getElementById('sessionTabs')!; + const room = parseFloat(strip.style.getPropertyValue('--tab-corner-first-max')); + const tab = strip.querySelector(':scope > .session-tab')!; + const name = tab.querySelector('.tab-name')!; + while (tab.offsetWidth < room - 10) name.textContent += 'x'; + }); + const select = async (n: number) => { + await p.evaluate((n) => { + document + .querySelectorAll('#sessionTabs > .session-tab') + .forEach((tab, i) => tab.classList.toggle('active', i === n - 1)); + }, n); + await relayout(); + return { cornered: await cornered(), overlapping: await overlapping(), first: await firstTab() }; + }; + expect(await select(3)).toEqual({ + cornered: true, + overlapping: [], + first: { besideToolbar: true, trimmed: true }, + }); + expect(await select(1)).toEqual({ cornered: true, overlapping: [], first: { besideToolbar: true, trimmed: true } }); + expect(await select(3)).toEqual({ + cornered: true, + overlapping: [], + first: { besideToolbar: true, trimmed: true }, + }); + // selectSession() widens the tab at once and the corner sync is debounced: the + // cap has to be on already, or the first row drops below the toolbar until then. + await p.evaluate(() => { + document + .querySelectorAll('#sessionTabs > .session-tab') + .forEach((tab, i) => tab.classList.toggle('active', i === 0)); + }); + expect(await firstTab()).toEqual({ besideToolbar: true, trimmed: true }); + await p.close(); + }); + + it('re-trims the first tab when the room grows but is still too narrow for it', async () => { + await open({ tabs: 40 }); + await longName(1); + // One pass must get it right: the toolbar's ResizeObserver would run a + // second one and hide a first pass that read the last trim as the width. + await p.evaluate(() => (window as any).__app._tabCornerObserver?.disconnect()); + const toolbarWidth = async (px: string | null) => { + await p.evaluate((px) => { + const root = document.documentElement; + if (px === null) { + root.removeAttribute('data-header-right-width'); + root.style.removeProperty('--header-right-width'); + } else { + root.setAttribute('data-header-right-width', 'set'); + root.style.setProperty('--header-right-width', px); + } + }, px); + await relayout(); + }; + await toolbarWidth('500px'); + expect(await firstTab()).toEqual({ besideToolbar: true, trimmed: true }); + await toolbarWidth('300px'); + expect(await cornered()).toBe(true); + expect(await firstTab()).toEqual({ besideToolbar: true, trimmed: true }); + expect(await overlapping()).toEqual([]); + await p.close(); + }); + + it('keeps the plain wrap when the toolbar leaves no usable room beside it', async () => { + // Few enough that the strip does not scroll: at 40 the scroll fallback turns the + // corner off before the room check is reached, and this would pass at any threshold. + await open({ tabs: 8 }); + await p.evaluate(() => { + const head = document.querySelector('.header')!.getBoundingClientRect(); + const right = document.getElementById('headerRight')!; + right.style.minWidth = `${Math.round(head.width - 150)}px`; + }); + await relayout(); + expect(await cornered()).toBe(false); + expect(await overlapping()).toEqual([]); + await p.close(); + }); + + it('leaves a strip that fits on one row unwrapped and plain', async () => { + await open({ tabs: 3 }); + expect(await p.evaluate(() => document.getElementById('sessionTabs')!.classList.contains('tabs-auto-wrap'))).toBe( + false + ); + expect(await cornered()).toBe(false); + await p.close(); + }); + + for (const arrangement of ['tabs-triage', 'tabs-clusters', 'tabs-ledger']) { + it(`never corners the ${arrangement} arrangement`, async () => { + await open({ tabs: 40 }); + await p.evaluate((cls) => document.getElementById('sessionTabs')!.classList.add(cls), arrangement); + await relayout(); + expect(await cornered()).toBe(false); + await p.close(); + }); + } + + it('never corners a strip outside #sessionTabsHost', async () => { + await open({ tabs: 40 }); + await p.evaluate(() => { + const elsewhere = document.createElement('div'); + elsewhere.style.width = '600px'; + document.body.appendChild(elsewhere); + elsewhere.appendChild(document.getElementById('sessionTabs')!); + document.getElementById('sessionTabs')!.classList.add('tabs-two-rows'); + }); + await relayout(); + expect(await cornered()).toBe(false); + await p.close(); + }); + + it('keeps the plain wrap for a wrapped strip past its max-height, so scrolling never puts tabs under the toolbar', async () => { + await open({ tabs: 50, width: 1100, height: 500 }); + expect(await p.evaluate(() => document.getElementById('sessionTabs')!.classList.contains('tabs-auto-wrap'))).toBe( + true + ); + expect(await cornered()).toBe(false); + await p.evaluate(() => (document.getElementById('sessionTabs')!.scrollTop = 60)); + await settled(); + expect(await overlapping()).toEqual([]); + await p.close(); + }); + + it('keeps the plain wrap for a scrolled two-rows strip across a re-render', async () => { + await open({ tabs: 50, width: 1100, height: 500, twoRows: true }); + await p.evaluate(() => (document.getElementById('sessionTabs')!.scrollTop = 60)); + await relayout(); + expect(await cornered()).toBe(false); + expect(await overlapping()).toEqual([]); + await p.evaluate(() => (document.getElementById('sessionTabs')!.scrollTop = 0)); + await settled(); + expect(await overlapping()).toEqual([]); + await p.close(); + }); + + it('re-sizes the corner when the toolbar is narrowed with --header-right-width', async () => { + await open({ tabs: 40 }); + const width = () => + p.evaluate(() => + parseFloat(document.getElementById('sessionTabs')!.style.getPropertyValue('--tab-corner-width')) + ); + const before = await width(); + await p.evaluate(() => { + const root = document.documentElement; + root.setAttribute('data-header-right-width', 'set'); + root.style.setProperty('--header-right-width', '300px'); + }); + await relayout(); + expect(await cornered()).toBe(true); + const after = await width(); + expect(after).toBeLessThan(before); + expect(Math.abs(after - ((await toolbar()).width + 8))).toBeLessThan(16); + expect(await overlapping()).toEqual([]); + await p.close(); + }); +}); diff --git a/test/terminal-cell-wrapped-paths.test.ts b/test/terminal-cell-wrapped-paths.test.ts new file mode 100644 index 00000000..b2939794 --- /dev/null +++ b/test/terminal-cell-wrapped-paths.test.ts @@ -0,0 +1,319 @@ +// Port: none (pure functions + the link provider in a vm context: no browser, no server). +// +// Absolute file paths codex word-wrapped inside a markdown table cell were not +// clickable: the first fragment has no extension and the continuation has no +// absolute root, so FILE_PATH_LINK_PATTERN matched neither. In a two-column +// table both continuations sit on the same next row, so each path has to be +// followed down its OWN cell (terminalCellWrappedPaths in constants.js). +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +const CONSTANTS = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'); +const TERMINAL_UI = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); + +type Row = { text: string; isWrapped?: boolean }; +type Hit = { path: string; segments: Array<{ row: number; startCol: number; endCol: number }> }; +type Link = { + text: string; + range: { start: { x: number; y: number }; end: { x: number; y: number } }; + activate: (event: unknown, text: string) => void; +}; + +const COLS = 200; + +function fakeBuffer(rows: Row[], cols: number) { + const lines = rows.map((row) => ({ + isWrapped: row.isWrapped ?? false, + translateToString(trimRight?: boolean) { + const padded = row.text.padEnd(cols, ' '); + return trimRight ? padded.trimEnd() : padded; + }, + getCell(index: number) { + const padded = row.text.padEnd(cols, ' '); + if (index >= padded.length) return undefined; + return { getChars: () => (padded[index] === ' ' ? '' : padded[index]), getWidth: () => 1 }; + }, + })); + return { length: lines.length, getLine: (i: number) => lines[i] }; +} + +function loadPure() { + const context = vm.createContext({ console, window: undefined }); + vm.runInContext(`${CONSTANTS}\nglobalThis.__cell = terminalCellWrappedPaths;`, context, { + filename: 'constants.js', + }); + return (context as unknown as { __cell: (buffer: unknown, row: number, maxRows?: number) => Hit[] }).__cell; +} + +// Shape copied from a real codex pane (synthetic paths). +const ROW1 = + ' Before screenshot (/tmp/demo-check/docs/review/ After screenshot (/tmp/demo-check/docs/review/BLDMESH-'; +const ROW2 = ' BLDMESH-243/before-overview.png) 243/after-overview.png)'; +const TABLE: Row[] = [ + { text: ' No Vision selected Reprocessed' }, + { text: ROW1 }, + { text: ROW2 }, + { text: '' }, +]; +const LISTINGS = [ + ['/tmp/x', '/tmp/x/dir', '/tmp/x/dir/a.png'], + [' ⎿ /home/u/proj/src', ' /home/u/proj/src/a.ts'], + ['4.0K /tmp/foo/bar/', '8.0K /tmp/foo/baz.png'], + ['/home/u/proj/docs:', 'guide.md notes.md'], + ['Created /tmp/out', 'report.md written next to it'], +]; + +// A directory that is the whole value of its cell, over the next record's file +// name in the same column. A wrapper breaks a path after `/` or `-`, never inside +// a name, so none of these is one path: each cell keeps its own link. +const WHOLE_VALUE_SHAPES: Array<{ rows: string[]; dir: string; file: string }> = [ + { rows: [' Repo /home/u/proj', ' Config config.yaml'], dir: '/home/u/proj', file: 'config.yaml' }, + { + rows: [' Workspace /home/u/proj/app', ' Entry main.py'], + dir: '/home/u/proj/app', + file: 'main.py', + }, + { + rows: ['│ Workspace │ /home/u/proj/app │', '│ Entry │ main.py │'], + dir: '/home/u/proj/app', + file: 'main.py', + }, + { rows: [' Output dir /tmp/build/out', ' Log build.log'], dir: '/tmp/build/out', file: 'build.log' }, + { + rows: [' Path Purpose', ' /home/u/proj/src sources', ' index.ts entry'], + dir: '/home/u/proj/src', + file: 'index.ts', + }, +]; +// The same records where the directory DOES end on a break character: the label +// on the next row (`Entry`, `│ Helper │`) says it is a new record, since a wrapper +// wraps every column of one table row together and leaves the label cell blank. +const LABELLED_RECORD_SHAPES: Array<{ rows: string[]; dir: string; file: string }> = [ + { + rows: [' Workspace /home/u/proj/app/', ' Entry main.py'], + dir: '/home/u/proj/app/', + file: 'main.py', + }, + { + rows: ['│ Source │ /home/u/proj/src/ │', '│ Helper │ lib/util.ts │'], + dir: '/home/u/proj/src/', + file: 'lib/util.ts', + }, + { rows: [' Prefix /tmp/run-', ' Suffix log.txt'], dir: '/tmp/run-', file: 'log.txt' }, +]; +const SEPARATE_RECORD_SHAPES = [...WHOLE_VALUE_SHAPES, ...LABELLED_RECORD_SHAPES]; +// Known trade-off: with no label to tell them apart, a directory written WITH its +// trailing slash over a relative path in the same column reads exactly like a +// wrap at `/`, so it still joins. +const TRAILING_SLASH_TRADE_OFF = [' /home/u/proj/src/ sources', ' lib/util.ts helpers']; + +const BEFORE = '/tmp/demo-check/docs/review/BLDMESH-243/before-overview.png'; +const AFTER = '/tmp/demo-check/docs/review/BLDMESH-243/after-overview.png'; + +describe('terminalCellWrappedPaths', () => { + const find = loadPure(); + + it('joins each column’s path with its own continuation, from either row', () => { + for (const row of [1, 2]) { + const hits = find(fakeBuffer(TABLE, COLS), row); + expect(hits.map((h) => h.path).sort()).toEqual([AFTER, BEFORE]); + } + }); + + it.each(LISTINGS.map((lines) => [lines]))('does not join independent listing rows: %j', (lines) => { + const rows = lines.map((text) => ({ text })); + for (let row = 0; row < rows.length; row++) expect(find(fakeBuffer(rows, COLS), row)).toEqual([]); + }); + + it.each(SEPARATE_RECORD_SHAPES.map((shape) => [shape.rows, shape]))( + 'does not join a directory cell onto the next record: %j', + (_rows, shape) => { + const rows = shape.rows.map((text) => ({ text })); + for (let row = 0; row < rows.length; row++) expect(find(fakeBuffer(rows, COLS), row)).toEqual([]); + } + ); + + it('does not join a fragment that ends on `_` (codex wraps cells only after `/` or `-`)', () => { + const rows = [{ text: ' shot (/tmp/demo/docs/review_' }, { text: ' before.png)' }]; + expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]); + expect(find(fakeBuffer(rows, COLS), 1)).toEqual([]); + }); + + it('still joins an unlabelled trailing-slash directory over a relative path in its column (known trade-off)', () => { + const rows = TRAILING_SLASH_TRADE_OFF.map((text) => ({ text })); + expect(find(fakeBuffer(rows, COLS), 1).map((h) => h.path)).toEqual(['/home/u/proj/src/lib/util.ts']); + }); + + it('places the segments on the path cells only', () => { + const hits = find(fakeBuffer(TABLE, COLS), 1); + const before = hits.find((h) => h.path === BEFORE)!; + expect(before.segments).toEqual([ + { row: 1, startCol: ROW1.indexOf('/tmp/'), endCol: ROW1.indexOf('/review/') + '/review/'.length }, + { row: 2, startCol: 3, endCol: 3 + 'BLDMESH-243/before-overview.png'.length }, + ]); + const after = hits.find((h) => h.path === AFTER)!; + const afterCell = ROW1.indexOf('After'); + expect(after.segments[0]).toEqual({ row: 1, startCol: ROW1.lastIndexOf('/tmp/'), endCol: ROW1.length }); + expect(after.segments[1]).toEqual({ + row: 2, + startCol: afterCell, + endCol: afterCell + '243/after-overview.png'.length, + }); + }); + + it('ignores a path already complete on its row', () => { + const rows = [{ text: ' see /tmp/demo/report.md' }, { text: ' next.md' }]; + expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]); + }); + + it('does not glue unrelated text onto a path that is not followed by a matching continuation', () => { + const rows = [{ text: ' saved under /tmp/demo/docs/' }, { text: ' then run the build' }]; + expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]); + expect(find(fakeBuffer(rows, COLS), 1)).toEqual([]); + }); + + it('does not join a continuation that is not aligned to the cell', () => { + const rows = [{ text: ' shot (/tmp/demo/docs/review/' }, { text: ' before.png)' }]; + expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]); + }); + + it('does not join across a soft wrap (the logical line owns that)', () => { + const rows = [{ text: ' shot (/tmp/demo/docs/review/' }, { text: ' before.png', isWrapped: true }]; + expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]); + }); + + it('follows a path across three rows of one cell', () => { + const rows = [{ text: ' a (/tmp/demo/very/' }, { text: ' long/nested/' }, { text: ' file.png)' }]; + const hits = find(fakeBuffer(rows, COLS), 1); + expect(hits.map((h) => h.path)).toEqual(['/tmp/demo/very/long/nested/file.png']); + expect(hits[0].segments.map((s) => s.row)).toEqual([0, 1, 2]); + }); + + it('follows a path down a box-drawn table cell (Claude Code renders tables with │ borders)', () => { + const rows = [ + { text: '│ Before │ /tmp/demo/docs/review/ │ After │ /tmp/demo/docs/review/BLD- │' }, + { text: '│ │ before-overview.png │ │ 243/after-overview.png │' }, + ]; + const hits = find(fakeBuffer(rows, COLS), 1); + expect(hits.map((h) => h.path).sort()).toEqual([ + '/tmp/demo/docs/review/BLD-243/after-overview.png', + '/tmp/demo/docs/review/before-overview.png', + ]); + const before = hits.find((h) => h.path.endsWith('before-overview.png'))!; + expect(before.segments).toEqual([ + { row: 0, startCol: rows[0].text.indexOf('/tmp/'), endCol: rows[0].text.indexOf('/review/') + 8 }, + { row: 1, startCol: rows[1].text.indexOf('before'), endCol: rows[1].text.indexOf('.png') + 4 }, + ]); + }); + + it('does not take a box border or the next cell into a path', () => { + const rows = [{ text: '│ /tmp/demo/docs/ │ x │' }, { text: '│ │ notes.md │ y │' }]; + expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]); + }); +}); + +describe('terminal link provider with cell-wrapped paths', () => { + function harness(rows: Row[]) { + const context = vm.createContext({ + console: { ...console, debug: vi.fn(), log: vi.fn() }, + CodemanApp: class CodemanApp {}, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn(), + document: { addEventListener: vi.fn(), getElementById: vi.fn(), documentElement: { dataset: {} } }, + window: { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + open: vi.fn(), + document: { documentElement: { dataset: {} } }, + }, + }); + vm.runInContext(`${CONSTANTS}\n${TERMINAL_UI}\nglobalThis.__proto = CodemanApp.prototype;`, context); + const proto = (context as unknown as { __proto: Record unknown> }).__proto; + let provider: { provideLinks: (line: number, cb: (links?: Link[]) => void) => void } | undefined; + const app = { + terminal: { + cols: COLS, + rows: 24, + registerLinkProvider(value: typeof provider) { + provider = value; + }, + buffer: { active: { viewportY: 0, ...fakeBuffer(rows, COLS) } }, + }, + activeSessionId: 's1', + sessions: new Map([['s1', { workingDir: '/workspace/project' }]]), + openFilePreview: vi.fn(), + openLogViewerWindow: vi.fn(), + _openTerminalUrl: proto._openTerminalUrl, + _resolveLinkedFilePath: proto._resolveLinkedFilePath, + _isExternalPreviewPath: () => false, + registerFilePathLinkProvider: proto.registerFilePathLinkProvider, + }; + app.registerFilePathLinkProvider.call(app); + const provide = (line: number): Link[] => { + let links: Link[] | undefined; + provider!.provideLinks(line, (value) => { + links = value; + }); + return links ?? []; + }; + return { provide, app }; + } + + it.each(LISTINGS.map((lines) => [lines]))('preserves ordinary listing links: %j', (lines) => { + const { provide } = harness(lines.map((text) => ({ text }))); + const expected = [ + ['/tmp/x', '/tmp/x/dir', '/tmp/x/dir/a.png'], + ['/home/u/proj/src', '/home/u/proj/src/a.ts'], + ['/tmp/foo/bar/', '/tmp/foo/baz.png'], + ['/home/u/proj/docs', 'guide.md'], + ['/tmp/out', 'report.md'], + ][LISTINGS.indexOf(lines)]; + for (let row = 0; row < lines.length; row++) { + expect(provide(row + 1).map((link) => link.text)).toContain(expected[row]); + } + }); + + it.each(SEPARATE_RECORD_SHAPES.map((shape) => [shape.rows, shape]))( + 'keeps the folder link and the file link of the next record: %j', + (_rows, shape) => { + const { provide, app } = harness(shape.rows.map((text) => ({ text }))); + const dirLine = shape.rows.findIndex((text) => text.includes(shape.dir)) + 1; + const fileLine = shape.rows.findIndex((text) => text.includes(shape.file)) + 1; + const all = [...provide(dirLine), ...provide(fileLine)].map((link) => link.text); + expect(all.some((text) => text.includes(shape.dir + shape.file))).toBe(false); + const dirLink = provide(dirLine).find((link) => link.text === shape.dir); + expect(dirLink, `${shape.dir} on row ${dirLine}`).toBeDefined(); + expect(dirLink!.range.start.y).toBe(dirLine); + const fileLink = provide(fileLine).find((link) => link.text === shape.file); + expect(fileLink, `${shape.file} on row ${fileLine}`).toBeDefined(); + expect(fileLink!.range.start.y).toBe(fileLine); + fileLink!.activate(null, fileLink!.text); + expect(app.openFilePreview).not.toHaveBeenCalledWith(shape.dir + shape.file, 's1'); + } + ); + + it('makes both screenshot paths clickable from either row and opens the whole path', () => { + const { provide, app } = harness(TABLE); + for (const line of [2, 3]) { + const links = provide(line); + for (const target of [BEFORE, AFTER]) { + const onRow = links.find((l) => l.text === target && l.range.start.y === line); + expect(onRow, `${target} on row ${line}`).toBeDefined(); + onRow!.activate(null, onRow!.text); + expect(app.openFilePreview).toHaveBeenLastCalledWith(target, 's1'); + } + } + }); + + it('leaves no fragment link that would open a partial path', () => { + const { provide } = harness(TABLE); + for (const line of [2, 3]) { + for (const link of provide(line)) expect([BEFORE, AFTER]).toContain(link.text); + } + }); +}); diff --git a/test/terminal-input-classifier.test.ts b/test/terminal-input-classifier.test.ts new file mode 100644 index 00000000..3f5b6bf8 --- /dev/null +++ b/test/terminal-input-classifier.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { isKeyboardTerminalInput } from '../src/terminal-input-classifier.js'; + +describe('isKeyboardTerminalInput', () => { + it.each([ + ['focus in', '\x1b[I'], + ['focus out', '\x1b[O'], + ['SGR press', '\x1b[<0;10;5M'], + ['SGR release', '\x1b[<0;10;5m'], + ['SGR wheel', '\x1b[<64;999;1000M'], + ['X10 mouse', '\x1b[M !!'], + ['coalesced reports', '\x1b[<0;10;5M\x1b[O\x1b[M !!'], + ])('classifies %s as report-only', (_name, data) => { + expect(isKeyboardTerminalInput(data)).toBe(false); + }); + + it.each([ + ['empty', ''], + ['text', 'continue'], + ['paste', 'git status'], + ['Enter', '\r'], + ['Escape', '\x1b'], + ['arrow', '\x1b[A'], + ['control key', '\x03'], + ['truncated SGR', '\x1b[<0;10;5'], + ['truncated X10', '\x1b[M !'], + ['X10 with a control button byte', '\x1b[M\r!!'], + ['X10 with a control coordinate byte', '\x1b[M \x1b!'], + ['X10-shaped navigation input', '\x1b[M\x1b[A'], + ['X10 with a zero coordinate', '\x1b[M !'], + ['report then text', '\x1b[<0;10;5Mx'], + ['text then report', 'x\x1b[<0;10;5M'], + ['report then Enter', '\x1b[O\r'], + ])('classifies %s as keyboard-bearing', (_name, data) => { + expect(isKeyboardTerminalInput(data)).toBe(true); + }); +}); diff --git a/test/terminal-keycode229-recovery.browser.test.ts b/test/terminal-keycode229-recovery.browser.test.ts index c17186b1..e0883b65 100644 --- a/test/terminal-keycode229-recovery.browser.test.ts +++ b/test/terminal-keycode229-recovery.browser.test.ts @@ -40,6 +40,29 @@ describe('orphaned terminal input recovery wiring', () => { baseUrl = `http://localhost:${server.boundPort}`; browser = await chromium.launch({ headless: true }); page = await browser.newPage(); + // Headless Chromium is a fine pointer, and create() then returns null (#716). + // The recovery cases below are the soft-keyboard path, so this page reports + // a coarse pointer. Other media queries stay native. + await page.addInitScript(() => { + const native = window.matchMedia.bind(window); + window.matchMedia = (query: string) => { + if (query === '(pointer: coarse)') { + return { + matches: true, + media: query, + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { + return false; + }, + }; + } + return native(query); + }; + }); await page.goto(baseUrl, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 }); await page.waitForFunction(() => (window as any).app?._keyCode229Recovery, null, { timeout: 30000 }); @@ -50,6 +73,18 @@ describe('orphaned terminal input recovery wiring', () => { if (server) await server.stop(); }, 60000); + it('does not install recovery when the primary pointer is fine', async () => { + const fine = await browser.newPage(); + try { + await fine.goto(baseUrl, { waitUntil: 'domcontentloaded' }); + await fine.waitForFunction(() => (window as any).app?.terminal?.textarea, null, { timeout: 30000 }); + const installed = await fine.evaluate(() => (window as any).app?._keyCode229Recovery ?? null); + expect(installed).toBeNull(); + } finally { + await fine.close(); + } + }); + /** * Drive one keystroke through the real textarea and report what reached the * PTY send path. `dispatchInput` mirrors GBoard: a keydown with no usable key diff --git a/test/terminal-keycode229-recovery.test.ts b/test/terminal-keycode229-recovery.test.ts index 777fc0d7..bf029e36 100644 --- a/test/terminal-keycode229-recovery.test.ts +++ b/test/terminal-keycode229-recovery.test.ts @@ -46,9 +46,19 @@ function inputEvent(data: string, overrides: Record = {}) { return { data, inputType: 'insertText', isComposing: false, ...overrides }; } -function harness({ screenReader = false } = {}) { +function harness({ screenReader = false, coarse = true, matchMedia = true, userAgent = '' } = {}) { const source = readFileSync(new URL('../src/web/public/terminal-keycode229-recovery.js', import.meta.url), 'utf8'); - const exposed: Record = {}; + const exposed: Record = { navigator: { userAgent } }; + if (matchMedia) { + // create() attaches only for a coarse pointer (#716). The recovery cases + // below are the soft-keyboard behaviour, so they opt in. A fine pointer + // (or no matchMedia at all) must get null and no listeners. + exposed.matchMedia = (query: string) => ({ + matches: query === '(pointer: coarse)' && coarse, + addEventListener() {}, + removeEventListener() {}, + }); + } vm.runInNewContext(source, { window: exposed, globalThis: exposed }, { filename: 'terminal-keycode229-recovery.js' }); const textarea = makeTextarea(); @@ -111,6 +121,55 @@ function loadTerminalInput() { } describe('orphaned terminal input recovery', () => { + it('does not attach on a fine pointer, so a desktop dead key is not forwarded twice', () => { + const h = harness({ coarse: false }); + expect(h.controller).toBeNull(); + expect(h.textarea.listenerCount()).toBe(0); + expect(h.textarea.registrations()).toEqual([]); + }); + + it('does not attach when matchMedia is missing', () => { + // Fail closed. Treating "unknown" as coarse would put the recovery back on + // a desktop whose matchMedia threw, which is the doubled backtick. + const h = harness({ matchMedia: false }); + expect(h.controller).toBeNull(); + expect(h.textarea.listenerCount()).toBe(0); + }); + + it('attaches on Android even when the primary pointer reports fine (a mouse or stylus attached)', () => { + // Chromium makes a mouse, a trackpad cover or an S Pen the primary pointer, but the text still comes + // from the soft keyboard, so the #441/#541 recovery must stay on there. + const h = harness({ + coarse: false, + userAgent: + 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36', + }); + expect(h.controller).not.toBeNull(); + h.keydown(); + h.input('`'); + h.flushTimers(); + expect(h.emitted).toEqual(['`']); + }); + + it('stays off on a desktop fine pointer that is not Android', () => { + const h = harness({ + coarse: false, + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36', + }); + expect(h.controller).toBeNull(); + }); + + it('still recovers a dropped insertText on a coarse pointer', () => { + const h = harness({ coarse: true }); + h.keydown(); + h.input('`'); + expect(h.emitted).toEqual([]); + h.flushTimers(); + expect(h.emitted).toEqual(['`']); + expect(h.textarea.listenerCount()).toBeGreaterThan(0); + }); + it('forwards the committed text when xterm stayed silent', () => { const h = harness(); h.keydown(); @@ -432,7 +491,14 @@ describe('terminal-ui wiring: what counts as "xterm spoke for this keystroke"', */ function editSyncHarness() { const source = readFileSync(new URL('../src/web/public/terminal-keycode229-recovery.js', import.meta.url), 'utf8'); - const exposed: Record = {}; + const exposed: Record = { + // Autocorrect recovery is the coarse-pointer (soft keyboard) path. + matchMedia: (query: string) => ({ + matches: query === '(pointer: coarse)', + addEventListener() {}, + removeEventListener() {}, + }), + }; vm.runInNewContext(source, { window: exposed, globalThis: exposed }, { filename: 'terminal-keycode229-recovery.js' }); const textarea = Object.assign(makeTextarea(), { value: '' }); diff --git a/test/terminal-logical-line.test.ts b/test/terminal-logical-line.test.ts index 6832216d..9cb3ca9c 100644 --- a/test/terminal-logical-line.test.ts +++ b/test/terminal-logical-line.test.ts @@ -64,6 +64,12 @@ describe('terminalLogicalLine', () => { const ROW1 = ' ackage/thing'; const HARD = [{ text: ROW0 }, { text: ROW1 }]; + it('keeps full-width dividers out of link and touch-selection text', () => { + const rows = buffer([{ text: '─'.repeat(COLS) }, { text: 'https://example.com/docs' }]); + expect(terminalLogicalLine(rows, 0, COLS)).toMatchObject({ startRow: 0, endRow: 0, text: '─'.repeat(COLS) }); + expect(terminalLogicalLine(rows, 1, COLS)).toMatchObject({ startRow: 1, endRow: 1, text: 'https://example.com/docs' }); + }); + it('stitches a hard wrap and drops the indent the continuation carries', () => { const line = terminalLogicalLine(buffer(HARD), 0, COLS)!; diff --git a/test/terminal-tile-input.test.ts b/test/terminal-tile-input.test.ts index a670228c..64999713 100644 --- a/test/terminal-tile-input.test.ts +++ b/test/terminal-tile-input.test.ts @@ -48,6 +48,17 @@ function loadContext() { // would run against no controller at all. setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms), clearTimeout: (id: ReturnType) => globalThis.clearTimeout(id), + // The real controller attaches only for a coarse pointer (#716). Tile tests + // that exercise soft-keyboard recovery opt in; a test sets this false to + // prove a desktop fine pointer installs nothing. + __coarsePointer: true, + matchMedia(query: string) { + return { + matches: query === '(pointer: coarse)' && windowStub.__coarsePointer !== false, + addEventListener() {}, + removeEventListener() {}, + }; + }, }; const context = vm.createContext({ console: { ...console, log: vi.fn(), debug: vi.fn() }, @@ -151,6 +162,7 @@ afterEach(() => { }); beforeEach(() => { + windowStub.__coarsePointer = true; FakeTerminal.coreFactory = null; FakeFit.proposed = { cols: 80, rows: 24 }; FakeSocket.instances = []; @@ -270,6 +282,27 @@ describe('what xterm generates never enters the durable queue', () => { expect(ws.inputFrames()).toEqual([{ t: 'i', d: '\x1b[I' }]); expect(app._pendingDeliveries.get('s-tile')).toBeUndefined(); }); + + it('queues a Ctrl+V bracketed-paste frame on the durable path', async () => { + // image-input.js hands this shape to terminal.input(). onData must not + // drop it as a query reply or send it once as a mouse/focus report: the + // same two predicates gate the primary pane before its bracketed-paste branch. + const app = makeApp(); + const { ws, term } = await connectTile(app); + ws.open(); + const input = windowStub.CodemanTerminalInput as { + shouldSuppressTerminalQueryResponse(data: string): boolean; + isTerminalFocusOrMouseReport(data: string): boolean; + }; + const framed = '\x1b[200~line one\r\x1b[?1;2c\r\x1b[<0;10;5M\r\x1b[I\x1b[201~'; + + expect(input.shouldSuppressTerminalQueryResponse(framed)).toBe(false); + expect(input.isTerminalFocusOrMouseReport(framed)).toBe(false); + term.type(framed); + + expect(ws.inputFrames().map((f) => [f.d, f.seq, f.cid])).toEqual([[framed, 1, 'c-test']]); + expect(app._pendingDeliveries.get('s-tile')?.map((r) => r.data)).toEqual([framed]); + }); }); describe('TerminalTile leaves the input-socket map', () => { @@ -1183,6 +1216,13 @@ function lineOf(frames: Array<{ d?: string }>) { type ControllerTile = Tile & { _keyCode229Recovery: unknown }; describe("TerminalTile wires the primary pane's soft-keyboard controller (#441, #541)", () => { + it('does not install the controller on a fine pointer', async () => { + windowStub.__coarsePointer = false; + const { tile, term } = await connectTile(makeApp()); + expect((tile as ControllerTile)._keyCode229Recovery).toBeNull(); + expect(term.textareaListeners).toEqual([]); + }); + it("installs on THIS tile's composition helper and textarea, and destroy() restores xterm's own", async () => { const helpers = withCompositionHelpers(); const { tile, term } = await connectTile(makeApp()); diff --git a/test/terminal-url-continuation.test.ts b/test/terminal-url-continuation.test.ts new file mode 100644 index 00000000..fb20f2c3 --- /dev/null +++ b/test/terminal-url-continuation.test.ts @@ -0,0 +1,812 @@ +// Port: none (pure functions + the link provider in a vm context: no browser, no server). +// +// Terminal URL links that stopped short. +// +// 1. `)` ended every URL, so a path like `…/Team-Space(QA)Archive/…` linked only as +// far as `(QA`. The grammar now keeps a `)` that closes a `(` inside the URL and +// still leaves out the closing paren of `(https://…)` / `[text](https://…)`. +// 2. A CLI that word-wraps inside a table cell (codex) breaks a long URL at `/`, `?`, +// `-` well short of the right edge and indents the rest to the cell's column. +// Nothing is `isWrapped` and no row is full width, so each row linked a fragment. +// +// All URLs here are synthetic (example.com); none come from real output. +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +const CONSTANTS = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'); +const TERMINAL_UI = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); + +type UrlRef = { url: string; startIndex: number; endIndex: number; rawEnd: number }; +type Extent = { startRow: number; endRow: number } | null; +type Row = { text: string; isWrapped?: boolean }; +type Link = { + text: string; + range: { start: { x: number; y: number }; end: { x: number; y: number } }; + activate: (event: unknown, text: string) => void; +}; + +function loadPure() { + const context = vm.createContext({ console, window: undefined }); + vm.runInContext( + `${CONSTANTS}\nglobalThis.__urls = findTerminalUrls; globalThis.__extent = terminalUrlContinuationExtent; globalThis.__logical = terminalLogicalLine; globalThis.__linkLine = terminalLinkLine;`, + context, + { filename: 'constants.js' } + ); + const ctx = context as unknown as { + __urls: (text: string) => UrlRef[]; + __extent: (buffer: unknown, row: number, cols: number, maxRows?: number) => Extent; + __logical: (buffer: unknown, row: number, cols: number, maxRows?: number) => Extent; + __linkLine: (buffer: unknown, row: number, cols: number, maxRows?: number) => LinkLine | null; + }; + return { + findTerminalUrls: ctx.__urls, + terminalUrlContinuationExtent: ctx.__extent, + terminalLogicalLine: ctx.__logical, + terminalLinkLine: ctx.__linkLine, + }; +} + +type LinkLine = { + startRow: number; + endRow: number; + text: string; + offsetToCell: (offset: number) => { row: number; col: number }; + cellToOffset: (row: number, col: number) => number; +}; + +function fakeBuffer(rows: Row[], cols: number) { + const lines = rows.map((row) => ({ + isWrapped: row.isWrapped ?? false, + translateToString(trimRight?: boolean) { + const padded = row.text.padEnd(cols, ' '); + return trimRight ? padded.trimEnd() : padded; + }, + getCell(index: number) { + const padded = row.text.padEnd(cols, ' '); + if (index >= padded.length) return undefined; + return { getChars: () => (padded[index] === ' ' ? '' : padded[index]), getWidth: () => 1 }; + }, + })); + return { length: lines.length, getLine: (i: number) => lines[i] }; +} + +// A codex-style markdown table row: number, reason, then a `name (url)` cell whose +// URL the CLI wrapped across three rows, each continuation indented to the cell. +const URL = + 'https://docs.example.com/sites/Team-Space(QA)Archive/_layouts/15/Doc.aspx?' + + 'sourcedoc=%7B11111111-2222-3333-4444-555555555555%7D&file=Quarterly%20Plan%20(Draft).xlsx&action=default&' + + 'mobileredirect=true'; +const CELL = 26; +const TABLE: Row[] = [ + { text: ' 11 Unrelated row plain text only' }, + { + text: ' 12 New to trending Quarterly Plan.xlsx (https://docs.example.com/sites/Team-Space(QA)Archive/_layouts/15/Doc.aspx?', + }, + { + text: + ' '.repeat(CELL) + + 'sourcedoc=%7B11111111-2222-3333-4444-555555555555%7D&file=Quarterly%20Plan%20(Draft).xlsx&action=default&', + }, + { text: ' '.repeat(CELL) + 'mobileredirect=true)' }, + { text: ' 13 Next row another cell' }, +]; +const COLS = 160; +const TABLE_CELL_START = TABLE[1].text.indexOf('Quarterly'); +const TABLE_WITH_TRAILING_COLUMNS: Row[] = [ + TABLE[0], + { + text: TABLE[1].text + ' 2026-09-16 18:00:54 Document Owner', + }, + { text: ' '.repeat(TABLE_CELL_START) + TABLE[2].text.slice(CELL) }, + { text: ' '.repeat(TABLE_CELL_START) + TABLE[3].text.slice(CELL) }, + TABLE[4], +]; +const TABLE_COLS = 220; +// A wrapped URL with another column to its right. The URL's first row is cut at +// its cell to stitch the URL, and the column after it must still link on that +// row (a release-review regression: only the URL's pieces were offered there). +const WIKI_URL = 'https://wiki.example.com/spaces/ENG/pages/12345/Design-Spec-v2'; +const URL_THEN_PATH_COLS = 140; +const URL_THEN_PATH_ROWS: Row[] = [ + { text: ' 1 Design spec https://wiki.example.com/spaces/ENG/pages/12345/ /home/me/proj/docs/design.md' }, + { text: ' Design-Spec-v2' }, + { text: ' 2 Notes n/a /home/me/proj/notes.md' }, +]; +// The same shape with a relative and a home path in the columns after the URL. +const URL_THEN_PATHS_ROWS: Row[] = [ + { + text: ' 1 Design spec https://wiki.example.com/spaces/ENG/pages/12345/ docs/design.md ~/notes/todo.txt', + }, + { text: ' Design-Spec-v2' }, +]; +// A URL in the column after the wrapped one is the row's LAST URL, and only the +// last URL on a row is continued, so this row is never stitched or cut: both URLs +// link on row 1 as single-row links, as before the wrap joining existed. +const URL_THEN_URL_ROWS: Row[] = [ + { + text: ' 1 Design spec https://wiki.example.com/spaces/ENG/pages/12345/ https://other.example.com/notes.md', + }, + { text: ' Design-Spec-v2' }, +]; +const PROSE_ROWS = [ + [ + '⏺ The guide lives at https://example.com/guide/', + ' self-hosted runners need a token first.', + 'https://example.com/guide/', + ], + ['Docs: https://example.com/guide/', 'Self-hosting is covered in chapter 3.', 'https://example.com/guide/'], + ['Open http://localhost:3000/', 'e.g. to check the dashboard', 'http://localhost:3000/'], + ['Endpoint https://api.example.com/v1/', 'user_id is required', 'https://api.example.com/v1/'], + ['Repo: https://github.com/org/repo/', 'well-known issues are tracked', 'https://github.com/org/repo/'], + ['Callback https://example.com/cb?code=', 'Re-run the login', 'https://example.com/cb?code='], + ['Branch https://github.com/o/r/tree/feat-', 'x.y happened', 'https://github.com/o/r/tree/feat-'], +]; +// Rows that carry URL punctuation but are never the rest of the URL above them: +// a shell prompt, an HTTP status line, a home path, and column-0 prose. Each +// third element is the URL, the fourth the path link expected on the second row. +const NOT_CONTINUATION_ROWS: [string, string, string, string | null][] = [ + ['https://registry.npmjs.org/', 'arkon@box:~/proj$', 'https://registry.npmjs.org/', '~/proj'], + ['https://pypi.org/simple/', 'dev@laptop:/srv/app$', 'https://pypi.org/simple/', '/srv/app'], + ['$ curl -sI https://example.com/', 'HTTP/2 200', 'https://example.com/', null], + ['$ curl -i http://localhost:3000/api/', 'HTTP/1.1 200 OK', 'http://localhost:3000/api/', null], + ['Docs: https://example.com/guide/', '~/notes.md has more', 'https://example.com/guide/', '~/notes.md'], + ['fetch https://api.example.com/items/', 'status=200 took=12ms', 'https://api.example.com/items/', null], + ['Status page: https://status.example.com/', '24/7 monitoring is on.', 'https://status.example.com/', null], + ['Did you mean https://example.com/search?q=', 'really? no.', 'https://example.com/search?q=', null], +]; +// The last column of a space-aligned table: the next row opens the next record +// in an earlier column, so its first cell is never the rest of the URL. Each +// row is indented two spaces. The URL on each row, then any path link expected +// on the second row. +const TABLE_LAST_COLUMN_ROWS: [string, string, string, string, string | null][] = [ + [ + ' auth-api https://auth.example.com/', + ' billing-api https://billing.example.com/', + 'https://auth.example.com/', + 'https://billing.example.com/', + null, + ], + [ + ' docs.example.com https://docs.example.com/', + ' api.example.com https://api.example.com/', + 'https://docs.example.com/', + 'https://api.example.com/', + null, + ], + [ + ' Homepage: https://example.com/', + ' Bug-reports: https://github.com/org/repo/issues/', + 'https://example.com/', + 'https://github.com/org/repo/issues/', + null, + ], + [ + ' user_service https://users.example.com/v1/', + ' order_service https://orders.example.com/v1/', + 'https://users.example.com/v1/', + 'https://orders.example.com/v1/', + null, + ], + [ + ' README.md https://example.com/docs/', + ' CHANGELOG.md https://example.com/changes/', + 'https://example.com/docs/', + 'https://example.com/changes/', + 'CHANGELOG.md', + ], + [ + ' my-service- https://svc.example.com/api/', + ' long-name https://svc2.example.com/', + 'https://svc.example.com/api/', + 'https://svc2.example.com/', + null, + ], +]; +// Known trade-offs: a row end counts as a cell end, so a short indented word +// with URL punctuation still continues the URL. Pinned so a change is deliberate. +const TRADE_OFF_ROWS: [string, string, string][] = [ + ['⏺ Docs live at https://example.com/guide/', ' self-hosted', 'https://example.com/guide/self-hosted'], + [ + '⏺ The docs are at https://example.com/guide/', + ' and/or the API reference covers it.', + 'https://example.com/guide/and/or', + ], +]; + +// Indented rows under a URL ending in `/` that carry URL punctuation but are +// code, a label, a `name=value` field, a measurement or a clock time, or a word +// ending a sentence. Each third element is the URL on the first row. +const FIELD_AND_CODE_ROWS: [string, string, string][] = [ + [' └ Listening on http://127.0.0.1:8080/', ' ready.', 'http://127.0.0.1:8080/'], + ['⏺ Deployed to https://app.example.com/', ' Done.', 'https://app.example.com/'], + ['⏺ Docs live at https://example.com/guide/', ' self-hosted.', 'https://example.com/guide/'], + [' Fetching https://example.com/files/', ' done...', 'https://example.com/files/'], + [ + ' ● fetch › handles https://api.example.com/v1/', + ' expect(received).toBe(expected)', + 'https://api.example.com/v1/', + ], + [' See https://example.com/api/', ' client.get()', 'https://example.com/api/'], + [ + '/home/u/.venv/lib/urllib3/__init__.py:35: Warning: see https://github.com/urllib3/urllib3/issues/', + ' warnings.warn(', + 'https://github.com/urllib3/urllib3/issues/', + ], + [ + ' 1) Client fetches https://api.example.com/v1/', + ' Failure/Error: expect(res.code).to eq(200)', + 'https://api.example.com/v1/', + ], + [ + 'Annotations: link.argocd.argoproj.io/external-link: https://grafana.example.com/d/', + ' prometheus.io/scrape: true', + 'https://grafana.example.com/d/', + ], + [' BASE_URL=https://api.example.com/', ' API_KEY=your-key-here', 'https://api.example.com/'], + [' AUTH_URL=https://auth.example.com/', ' LOG_LEVEL=debug', 'https://auth.example.com/'], + [ + ' level=info msg="request" url=https://api.example.com/v1/', + ' level=info msg="done" status=200', + 'https://api.example.com/v1/', + ], + [' url=https://api.example.com/v1/', ' request_id=abc-123', 'https://api.example.com/v1/'], + [ + ' Callback: https://example.com/oauth/callback/', + ' state=abc123 must match', + 'https://example.com/oauth/callback/', + ], + [ + ' Downloading https://huggingface.co/org/model/resolve/main/', + ' 45%|████▌ | 450M/1.00G [00:10<00:12, 45.0MB/s]', + 'https://huggingface.co/org/model/resolve/main/', + ], + [' Downloading https://example.com/files/', ' 12.3MB/45.0MB', 'https://example.com/files/'], + [' GET https://example.com/api/', ' 12.3ms', 'https://example.com/api/'], + [' https://example.com/blog/', ' 10:00:00.123', 'https://example.com/blog/'], +]; +// Continuations at the edge of those rules that are still the rest of the URL: +// a wiki title with parens, a file name before a sentence period, a query +// parameter after `?`, `&` or `_`, and percent-encoding that starts with digits. +const STILL_CONTINUES_ROWS: [string, string, string][] = [ + [' https://en.wikipedia.org/wiki/', ' Mercury_(planet)', 'https://en.wikipedia.org/wiki/Mercury_(planet)'], + ['⏺ The guide is at https://example.com/docs/', ' install.html.', 'https://example.com/docs/install.html'], + [' https://example.com/oauth?client_', ' id=abc&scope=read', 'https://example.com/oauth?client_id=abc&scope=read'], + [' https://example.com/cb?code=abc&', ' state=xyz', 'https://example.com/cb?code=abc&state=xyz'], + [' Docs: https://example.com/guide?', ' x=1&y=2 more text', 'https://example.com/guide?x=1&y=2'], + [' https://example.com/files/', ' 2026%20Plan.xlsx', 'https://example.com/files/2026%20Plan.xlsx'], +]; +// A URL row that soft-wraps at 41 columns with a table column on its wrapped +// part, over an indented row: the column and that row are separate links, never +// one glued `/tmp/report.txtccc/x.html`. +const SOFT_WRAP_COLS = 41; +const SOFT_WRAPPED_URL_ROWS: Row[] = [ + { text: ' Doc https://example.com/aaaaaaaaaaaaaa' }, + { text: 'bbb/ /tmp/report.txt', isWrapped: true }, + { text: ' ccc/x.html' }, +]; + +describe('findTerminalUrls (grammar)', () => { + const { findTerminalUrls } = loadPure(); + const urls = (text: string) => findTerminalUrls(text).map((u) => u.url); + + it('keeps a paren pair that is part of the URL', () => { + expect(urls('see https://example.com/wiki/Widget_(tool) now')).toEqual(['https://example.com/wiki/Widget_(tool)']); + expect(urls('https://example.com/Team-Space(QA)Archive/x')).toEqual([ + 'https://example.com/Team-Space(QA)Archive/x', + ]); + }); + + it('leaves out the paren that wraps the URL', () => { + expect(urls('(https://example.com/a(b)c)')).toEqual(['https://example.com/a(b)c']); + expect(urls('file (https://example.com/doc?x=1).')).toEqual(['https://example.com/doc?x=1']); + }); + + it('leaves out the closing paren of a markdown link', () => { + expect(urls('[the docs](https://example.com/docs?a=1&b=2) for details')).toEqual([ + 'https://example.com/docs?a=1&b=2', + ]); + }); + + it('still keeps query ampersands and stops at the shell &&', () => { + expect(urls('open https://example.com/p?post=1&action=edit&& echo done')).toEqual([ + 'https://example.com/p?post=1&action=edit', + ]); + }); + + it('trims trailing sentence punctuation', () => { + expect(urls('Go to https://example.com/path.')).toEqual(['https://example.com/path']); + expect(urls('Is it https://example.com/q?')).toEqual(['https://example.com/q']); + }); + + it('reports where the raw match ended before trimming', () => { + const [ref] = findTerminalUrls('x https://example.com/a) y'); + expect(ref.url).toBe('https://example.com/a'); + expect(ref.rawEnd).toBe('x https://example.com/a)'.length); + }); +}); + +describe('terminalUrlContinuationExtent (wrap joining)', () => { + const { terminalUrlContinuationExtent, terminalLogicalLine } = loadPure(); + + it('spans all three rows of a URL wrapped inside a table cell, from any of them', () => { + const buffer = fakeBuffer(TABLE, COLS); + for (const row of [1, 2, 3]) { + expect(terminalUrlContinuationExtent(buffer, row, COLS, 12)).toEqual({ startRow: 1, endRow: 3 }); + } + }); + + it('spans a table URL even when date and owner columns follow its first fragment', () => { + const buffer = fakeBuffer(TABLE_WITH_TRAILING_COLUMNS, TABLE_COLS); + for (const row of [1, 2, 3]) { + expect(terminalUrlContinuationExtent(buffer, row, TABLE_COLS, 12)).toMatchObject({ startRow: 1, endRow: 3 }); + } + }); + + it('starts at the URL row when a full-width table divider precedes it', () => { + const rows = [{ text: '─'.repeat(TABLE_COLS) }, ...TABLE_WITH_TRAILING_COLUMNS.slice(1, 4)]; + const buffer = fakeBuffer(rows, TABLE_COLS); + for (const row of [1, 2, 3]) { + expect(terminalUrlContinuationExtent(buffer, row, TABLE_COLS, 12)).toMatchObject({ startRow: 1, endRow: 3 }); + } + }); + + it('does not treat the next numbered table row as a URL continuation', () => { + const rows = [TABLE_WITH_TRAILING_COLUMNS[1], { text: ' 13 New to trending Another document' }]; + const buffer = fakeBuffer(rows, TABLE_COLS); + expect(terminalUrlContinuationExtent(buffer, 0, TABLE_COLS, 12)).toBeNull(); + expect(terminalUrlContinuationExtent(buffer, 1, TABLE_COLS, 12)).toBeNull(); + }); + + it('leaves the neighbouring table rows alone', () => { + const buffer = fakeBuffer(TABLE, COLS); + expect(terminalUrlContinuationExtent(buffer, 0, COLS, 12)).toBeNull(); + expect(terminalUrlContinuationExtent(buffer, 4, COLS, 12)).toBeNull(); + }); + + it.each([ + ['1. https://example.com/one/', '2. https://example.com/two/'], + ['Docs: https://docs.example.com/guide/', '1. Install the package'], + ['Fetching https://registry.example.com/pkg/', 'v1.2.3 is the latest'], + ['GET http://localhost:3000/api/', '200 OK in 12ms'], + ])('keeps list, version and status rows separate after %s', (first, next) => { + const buffer = fakeBuffer([{ text: first }, { text: next }], 100); + for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull(); + }); + + it.each(PROSE_ROWS)('does not join prose after %s', (first, next) => { + const buffer = fakeBuffer([{ text: first }, { text: next }], 100); + for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull(); + }); + + it.each(NOT_CONTINUATION_ROWS)('does not join a prompt, status line, home path or prose after %s', (first, next) => { + const buffer = fakeBuffer([{ text: first }, { text: next }], 100); + for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull(); + }); + + // Indented, so only the prompt / home path / status-line rule can refuse them. + it.each(['arkon@box:~/proj$', 'dev@laptop:/srv/app$', '~/notes.md', 'HTTP/2 200', 'HTTP/1.1 200 OK'])( + 'does not join an indented %s either', + (next) => { + const buffer = fakeBuffer([{ text: 'Docs: https://example.com/guide/' }, { text: ' ' + next }], 100); + for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull(); + } + ); + + it.each(TABLE_LAST_COLUMN_ROWS)('does not join the next table record after %s', (first, next) => { + const buffer = fakeBuffer([{ text: first }, { text: next }], 100); + for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull(); + }); + + it.each(FIELD_AND_CODE_ROWS)( + 'does not join code, a field, a measurement or a sentence end after %s', + (first, next) => { + const buffer = fakeBuffer([{ text: first }, { text: next }], 100); + for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull(); + } + ); + + it.each(STILL_CONTINUES_ROWS)('still joins the continuation after %s', (first, next) => { + const buffer = fakeBuffer([{ text: first }, { text: next }], 100); + for (const row of [0, 1]) + expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 }); + }); + + it('does not join an indented email address', () => { + const buffer = fakeBuffer([{ text: ' https://example.com/' }, { text: ' user@example.com' }], 100); + for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull(); + }); + + it.each(TRADE_OFF_ROWS)('still joins the known trade-off after %s', (first, next) => { + const buffer = fakeBuffer([{ text: first }, { text: next }], 100); + for (const row of [0, 1]) + expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 }); + }); + + it('does not let a URL at the right edge absorb a full-width divider row below it', () => { + const cols = 40; + const url = 'https://example.com/' + 'a'.repeat(cols - 'https://example.com/'.length); + for (const divider of ['─'.repeat(cols), '-'.repeat(cols)]) { + const buffer = fakeBuffer([{ text: url }, { text: divider }, { text: 'next' }], cols); + expect(terminalLogicalLine(buffer, 0, cols, 12)).toMatchObject({ startRow: 0, endRow: 0 }); + expect(terminalLogicalLine(buffer, 1, cols, 12)).toMatchObject({ startRow: 1 }); + } + }); + + // Real continuations are indented (codex cells, Claude's two-space paragraph + // indent); a column-0 row is refused, so these fixtures are indented. + it.each(['my-post-title', 'user_id', 'x.html', 'my-post-title ', 'my-post-title Other column'])( + 'keeps a weak-punctuation continuation that ends its cell: %s', + (next) => { + const buffer = fakeBuffer([{ text: 'Docs: https://example.com/guide/' }, { text: ' ' + next }], 100); + for (const row of [0, 1]) + expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 }); + } + ); + + it.each(['part%20two', 'path/slug', 'query?x=1'])('keeps stronger URL punctuation followed by prose: %s', (token) => { + const buffer = fakeBuffer( + [{ text: 'Docs: https://example.com/guide/' }, { text: ' ' + token + ' more text' }], + 100 + ); + expect(terminalUrlContinuationExtent(buffer, 0, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 }); + }); + + it('does not glue a following prose line onto a URL that ends a line', () => { + const buffer = fakeBuffer( + [{ text: 'Download from https://example.com/files/' }, { text: 'then run the installer' }], + 80 + ); + expect(terminalUrlContinuationExtent(buffer, 0, 80, 12)).toBeNull(); + expect(terminalUrlContinuationExtent(buffer, 1, 80, 12)).toBeNull(); + }); + + it('does not join a second URL on the next row', () => { + const buffer = fakeBuffer([{ text: '- https://example.com/one/' }, { text: ' https://example.com/two' }], 80); + expect(terminalUrlContinuationExtent(buffer, 0, 80, 12)).toBeNull(); + }); + + it('does not join a row indented to the right of where the URL started', () => { + const buffer = fakeBuffer([{ text: 'x https://example.com/a/b?' }, { text: ' q=1&r=2' }], 80); + expect(terminalUrlContinuationExtent(buffer, 0, 80, 12)).toBeNull(); + }); + + it('does not join when the URL line ends on a character a wrapper would not break after', () => { + const buffer = fakeBuffer([{ text: 'see https://example.com/page' }, { text: 'v2-notes.txt follows' }], 80); + expect(terminalUrlContinuationExtent(buffer, 0, 80, 12)).toBeNull(); + }); + + it('stays within the row bound', () => { + const rows: Row[] = [{ text: 'u https://example.com/' }]; + for (let i = 0; i < 20; i++) rows.push({ text: ` seg${i}-` }); + const extent = terminalUrlContinuationExtent(fakeBuffer(rows, 80), 0, 80, 5); + expect(extent).not.toBeNull(); + expect(extent!.endRow - extent!.startRow + 1).toBeLessThanOrEqual(5); + }); + + it('does not continue a URL whose row soft-wraps with a column on the wrapped part', () => { + const buffer = fakeBuffer(SOFT_WRAPPED_URL_ROWS, SOFT_WRAP_COLS); + for (const row of [0, 1, 2]) { + expect(terminalUrlContinuationExtent(buffer, row, SOFT_WRAP_COLS, 12)).toBeNull(); + } + }); +}); + +describe('terminalLinkLine (the text the provider scans)', () => { + const { terminalLinkLine } = loadPure(); + + it('keeps the columns after a wrapped URL, mapped back to their own cells', () => { + const buffer = fakeBuffer(URL_THEN_PATH_ROWS, URL_THEN_PATH_COLS); + const row = URL_THEN_PATH_ROWS[0].text; + const pathCol = row.indexOf('/home/'); + const urlCol = row.indexOf('https://'); + for (const hovered of [0, 1]) { + const line = terminalLinkLine(buffer, hovered, URL_THEN_PATH_COLS, 12)!; + expect(line).toMatchObject({ startRow: 0, endRow: 1 }); + // The URL reads whole, and the path column follows it. + expect(line.text).toContain(WIKI_URL); + const pathOffset = line.text.indexOf('/home/me/proj/docs/design.md'); + expect(pathOffset).toBeGreaterThan(line.text.indexOf(WIKI_URL)); + expect(line.offsetToCell(pathOffset)).toEqual({ row: 0, col: pathCol }); + expect(line.cellToOffset(0, pathCol)).toBe(pathOffset); + expect(line.cellToOffset(0, urlCol)).toBe(line.text.indexOf(WIKI_URL)); + // The end of the URL's last row still maps onto that row, never onto the + // first cell of the column moved after it. + const urlEnd = line.text.indexOf(WIKI_URL) + WIKI_URL.length; + expect(line.offsetToCell(urlEnd)).toEqual({ row: 1, col: URL_THEN_PATH_ROWS[1].text.length }); + } + }); +}); + +describe('native OSC 8 terminal URLs', () => { + it('opens complete HTTP links through the terminal opener and rejects unsafe schemes', () => { + const open = vi.fn(); + const confirm = vi.fn(() => true); + const context = vm.createContext({ + console, + CodemanApp: class CodemanApp {}, + document: { addEventListener: vi.fn() }, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn(), confirm }, + }); + vm.runInContext(`${CONSTANTS}\n${TERMINAL_UI}\nglobalThis.__proto = CodemanApp.prototype;`, context); + const proto = ( + context as unknown as { + __proto: { _terminalOsc8LinkHandler: () => { activate: (event: unknown, url: string) => void } }; + } + ).__proto; + const handler = proto._terminalOsc8LinkHandler.call({ _openTerminalUrl: open }); + const url = 'https://docs.example.com/Doc.aspx?file=Q3%20Plan&action=default&mobileredirect=true'; + handler.activate(null, url); + expect(open).toHaveBeenCalledExactlyOnceWith(url); + expect(confirm).toHaveBeenCalledExactlyOnceWith(`Open this link?\n\n${url}`); + confirm.mockReturnValue(false); + handler.activate(null, 'http://localhost:3000/private'); + expect(confirm).toHaveBeenLastCalledWith('Open this link?\n\nhttp://localhost:3000/private'); + expect(open).toHaveBeenCalledTimes(1); + for (const unsafe of ['javascript:alert(1)', 'data:text/html,hello', 'file:///etc/passwd']) { + handler.activate(null, unsafe); + } + expect(open).toHaveBeenCalledTimes(1); + expect(confirm).toHaveBeenCalledTimes(2); + }); +}); + +describe('terminal link provider with wrapped table URLs', () => { + function harness(rows: Row[], cols: number) { + const openSpy = vi.fn(); + const context = vm.createContext({ + console: { ...console, debug: vi.fn(), log: vi.fn() }, + CodemanApp: class CodemanApp {}, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn(), + document: { addEventListener: vi.fn(), getElementById: vi.fn(), documentElement: { dataset: {} } }, + window: { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + open: openSpy, + document: { documentElement: { dataset: {} } }, + }, + }); + vm.runInContext(`${CONSTANTS}\n${TERMINAL_UI}\nglobalThis.__proto = CodemanApp.prototype;`, context); + const proto = (context as unknown as { __proto: Record unknown> }).__proto; + let provider: { provideLinks: (line: number, cb: (links?: Link[]) => void) => void } | undefined; + const app = { + terminal: { + cols, + rows: 24, + registerLinkProvider(value: typeof provider) { + provider = value; + }, + buffer: { active: { viewportY: 0, ...fakeBuffer(rows, cols) } }, + }, + activeSessionId: 's1', + sessions: new Map([['s1', { workingDir: '/workspace/project' }]]), + openFilePreview: vi.fn(), + openLogViewerWindow: vi.fn(), + _openTerminalUrl: proto._openTerminalUrl, + _resolveLinkedFilePath: proto._resolveLinkedFilePath, + _isExternalPreviewPath: () => false, + registerFilePathLinkProvider: proto.registerFilePathLinkProvider, + }; + app.registerFilePathLinkProvider.call(app); + const provide = (line: number): Link[] => { + let links: Link[] | undefined; + provider!.provideLinks(line, (value) => { + links = value; + }); + return links ?? []; + }; + return { provide, openSpy, app }; + } + + it.each(PROSE_ROWS)('preserves the original clickable URL before prose: %s', (first, next, url) => { + const { provide, openSpy } = harness([{ text: first }, { text: next }], 100); + const links = provide(1).filter((link) => /^https?:/.test(link.text)); + expect(links.map((link) => link.text)).toEqual([url]); + links[0].activate(null, links[0].text); + expect(openSpy).toHaveBeenCalledExactlyOnceWith(url, '_blank', 'noopener,noreferrer'); + expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]); + }); + + it.each(NOT_CONTINUATION_ROWS)('keeps the URL and the second row separate: %s', (first, next, url, pathOnNext) => { + const { provide, openSpy } = harness([{ text: first }, { text: next }], 100); + const links = provide(1).filter((link) => /^https?:/.test(link.text)); + expect(links.map((link) => link.text)).toEqual([url]); + links[0].activate(null, links[0].text); + expect(openSpy).toHaveBeenCalledExactlyOnceWith(url, '_blank', 'noopener,noreferrer'); + const second = provide(2); + expect(second.filter((link) => /^https?:/.test(link.text))).toEqual([]); + if (pathOnNext) expect(second.map((link) => link.text)).toContain(pathOnNext); + }); + + it.each(TABLE_LAST_COLUMN_ROWS)( + 'keeps each last-column URL on its own row: %s', + (first, next, url, nextUrl, pathOnNext) => { + const { provide, openSpy } = harness([{ text: first }, { text: next }], 100); + const links = provide(1).filter((link) => /^https?:/.test(link.text)); + expect(links.map((link) => link.text)).toEqual([url]); + links[0].activate(null, links[0].text); + expect(openSpy).toHaveBeenCalledExactlyOnceWith(url, '_blank', 'noopener,noreferrer'); + const second = provide(2); + expect(second.filter((link) => /^https?:/.test(link.text)).map((link) => link.text)).toEqual([nextUrl]); + if (pathOnNext) expect(second.map((link) => link.text)).toContain(pathOnNext); + } + ); + + it.each(FIELD_AND_CODE_ROWS)( + 'keeps code, a field, a measurement or a sentence end separate: %s', + (first, next, url) => { + const { provide, openSpy } = harness([{ text: first }, { text: next }], 100); + const links = provide(1).filter((link) => /^https?:/.test(link.text)); + expect(links.map((link) => link.text)).toEqual([url]); + links[0].activate(null, links[0].text); + expect(openSpy).toHaveBeenCalledExactlyOnceWith(url, '_blank', 'noopener,noreferrer'); + expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]); + } + ); + + it.each(STILL_CONTINUES_ROWS)('links the whole URL from both rows after %s', (first, next, joined) => { + const { provide, openSpy } = harness([{ text: first }, { text: next }], 100); + for (const line of [1, 2]) { + // One link per row the URL covers, each opening the whole URL. + const links = provide(line).filter((link) => /^https?:/.test(link.text)); + expect(new Set(links.map((link) => link.text))).toEqual(new Set([joined])); + } + const [link] = provide(2).filter((l) => /^https?:/.test(l.text)); + link.activate(null, link.text); + expect(openSpy).toHaveBeenCalledExactlyOnceWith(joined, '_blank', 'noopener,noreferrer'); + }); + + it('keeps an indented email address out of the URL above it', () => { + const { provide } = harness([{ text: ' https://example.com/' }, { text: ' user@example.com' }], 100); + expect( + provide(1) + .filter((link) => /^https?:/.test(link.text)) + .map((link) => link.text) + ).toEqual(['https://example.com/']); + expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]); + }); + + it.each(TRADE_OFF_ROWS)('pins the known trade-off link after %s', (first, next, joined) => { + const { provide } = harness([{ text: first }, { text: next }], 100); + for (const line of [1, 2]) { + const links = provide(line).filter((link) => /^https?:/.test(link.text)); + expect(links.map((link) => link.text)).toContain(joined); + } + }); + + it('keeps a full-width divider row out of a URL that reaches the right edge', () => { + const cols = 40; + const url = 'https://example.com/' + 'a'.repeat(cols - 'https://example.com/'.length); + for (const divider of ['─'.repeat(cols), '-'.repeat(cols)]) { + const { provide } = harness([{ text: url }, { text: divider }], cols); + expect( + provide(1) + .filter((link) => /^https?:/.test(link.text)) + .map((link) => link.text) + ).toEqual([url]); + expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]); + } + }); + + it('opens the whole URL from any of its rows', () => { + const { provide, openSpy } = harness(TABLE, COLS); + for (const line of [2, 3, 4]) { + const urlLinks = provide(line).filter((link) => link.text.startsWith('https://')); + expect(urlLinks.length).toBeGreaterThan(0); + for (const link of urlLinks) expect(link.text).toBe(URL); + const onRow = urlLinks.find((link) => link.range.start.y === line); + expect(onRow).toBeDefined(); + onRow!.activate(null, onRow!.text); + expect(openSpy).toHaveBeenLastCalledWith(URL, '_blank', 'noopener,noreferrer'); + } + }); + + it('opens the whole table URL without including the adjacent date and owner columns', () => { + const { provide, openSpy } = harness(TABLE_WITH_TRAILING_COLUMNS, TABLE_COLS); + for (const line of [2, 3, 4]) { + const links = provide(line).filter((link) => link.text.startsWith('https://')); + expect(links.length).toBeGreaterThan(0); + for (const link of links) expect(link.text).toBe(URL); + links[0].activate(null, links[0].text); + expect(openSpy).toHaveBeenLastCalledWith(URL, '_blank', 'noopener,noreferrer'); + } + const firstRowLink = provide(2).find((link) => link.text === URL && link.range.start.y === 2); + expect(firstRowLink?.range.end.x).toBe(TABLE[1].text.length); + }); + + it('opens every segment after a full-width table divider', () => { + const rows = [{ text: '─'.repeat(TABLE_COLS) }, ...TABLE_WITH_TRAILING_COLUMNS.slice(1, 4)]; + const { provide, openSpy } = harness(rows, TABLE_COLS); + for (const line of [2, 3, 4]) { + const link = provide(line).find((item) => item.text === URL && item.range.start.y === line); + expect(link).toBeDefined(); + link!.activate(null, link!.text); + expect(openSpy).toHaveBeenLastCalledWith(URL, '_blank', 'noopener,noreferrer'); + } + }); + + it('links the path in the column right of a wrapped URL on the URL first row', () => { + const { provide, app } = harness(URL_THEN_PATH_ROWS, URL_THEN_PATH_COLS); + const pathStart = URL_THEN_PATH_ROWS[0].text.indexOf('/home/'); + const links = provide(1); + const path = links.find((link) => link.text === '/home/me/proj/docs/design.md'); + expect(path).toBeDefined(); + expect(path!.range.start).toEqual({ x: pathStart + 1, y: 1 }); + expect(path!.range.end.y).toBe(1); + // The stitched URL keeps one link per row, and its first-row piece ends + // before the path's column. + const urlPieces = links.filter((link) => /^https?:/.test(link.text)); + expect(new Set(urlPieces.map((link) => link.text))).toEqual(new Set([WIKI_URL])); + expect(urlPieces.map((link) => link.range.start.y)).toEqual([1, 2]); + expect(urlPieces[0].range.end.x).toBeLessThan(pathStart + 1); + // Nothing else is linked, so nothing glues the column onto the URL. + expect(links.map((link) => link.text).sort()).toEqual([WIKI_URL, WIKI_URL, '/home/me/proj/docs/design.md'].sort()); + path!.activate(null, path!.text); + expect(app.openFilePreview).toHaveBeenCalledExactlyOnceWith('/home/me/proj/docs/design.md', 's1'); + // The URL's second row still opens the whole URL, and the next record keeps its path. + expect( + new Set( + provide(2) + .filter((link) => /^https?:/.test(link.text)) + .map((link) => link.text) + ) + ).toEqual(new Set([WIKI_URL])); + expect(provide(3).map((link) => link.text)).toContain('/home/me/proj/notes.md'); + }); + + it('links a relative and a home path in the columns right of a wrapped URL', () => { + const { provide } = harness(URL_THEN_PATHS_ROWS, URL_THEN_PATH_COLS); + const row = URL_THEN_PATHS_ROWS[0].text; + const links = provide(1); + const startOf = (text: string) => links.find((link) => link.text === text)?.range.start; + expect(startOf('docs/design.md')).toEqual({ x: row.indexOf('docs/design.md') + 1, y: 1 }); + expect(startOf('~/notes/todo.txt')).toEqual({ x: row.indexOf('~/notes/todo.txt') + 1, y: 1 }); + expect(new Set(links.filter((link) => /^https?:/.test(link.text)).map((link) => link.text))).toEqual( + new Set([WIKI_URL]) + ); + }); + + it('links a URL in the column right of another URL whole, without stitching either', () => { + const { provide } = harness(URL_THEN_URL_ROWS, URL_THEN_PATH_COLS); + const row = URL_THEN_URL_ROWS[0].text; + const links = provide(1).filter((link) => /^https?:/.test(link.text)); + expect(links.map((link) => [link.text, link.range.start.x, link.range.start.y])).toEqual([ + ['https://wiki.example.com/spaces/ENG/pages/12345/', row.indexOf('https://wiki') + 1, 1], + ['https://other.example.com/notes.md', row.indexOf('https://other') + 1, 1], + ]); + expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]); + }); + + it('underlines only the URL cells on each row, never the indent between pieces', () => { + const { provide } = harness(TABLE, COLS); + const segments = provide(3).filter((link) => link.text === URL); + expect(segments.map((link) => link.range.start.y)).toEqual([2, 3, 4]); + for (const link of segments) expect(link.range.start.y).toBe(link.range.end.y); + expect(segments[0].range.start.x).toBe(TABLE[1].text.indexOf('https://') + 1); + expect(segments[0].range.end.x).toBe(TABLE[1].text.length); + expect(segments[1].range.start.x).toBe(CELL + 1); + expect(segments[2].range.start.x).toBe(CELL + 1); + // Ends on `true`, not on the closing paren after it. + expect(segments[2].range.end.x).toBe(TABLE[3].text.length - 1); + }); + + it('links the column on a soft-wrapped URL row and the row below it separately', () => { + const { provide } = harness(SOFT_WRAPPED_URL_ROWS, SOFT_WRAP_COLS); + const texts = [1, 2, 3].flatMap((line) => provide(line).map((link) => link.text)); + expect(texts.filter((text) => text.includes('report.txtccc'))).toEqual([]); + expect(provide(2).map((link) => link.text)).toContain('/tmp/report.txt'); + expect(provide(3).map((link) => link.text)).toContain('ccc/x.html'); + }); + + it('keeps a single-row URL with a paren pair whole', () => { + const line = 'Open https://example.com/wiki/Widget_(tool) please'; + const { provide } = harness([{ text: line }], 80); + const [link] = provide(1).filter((l) => l.text.startsWith('https://')); + expect(link.text).toBe('https://example.com/wiki/Widget_(tool)'); + }); +}); diff --git a/test/tmux-capture-full-history.test.ts b/test/tmux-capture-full-history.test.ts index 9ed86ea3..cc927011 100644 --- a/test/tmux-capture-full-history.test.ts +++ b/test/tmux-capture-full-history.test.ts @@ -10,8 +10,15 @@ */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { formatCursorRestore, formatPaneSnapshot, hasVisibleContent, queryPaneCursor } from '../src/tmux-manager.js'; +import { describe, expect, it, vi } from 'vitest'; +import { + formatCursorRestore, + formatPaneSnapshot, + hasVisibleContent, + queryPaneCursor, + splitFrameAndCursor, + TmuxManager, +} from '../src/tmux-manager.js'; describe('tmux full-history pane capture (COD-47)', () => { const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8'); @@ -265,3 +272,83 @@ describe('queryPaneCursor history depth', () => { expect(source).toContain("'#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'"); }); }); + +describe('the visible frame and its caret come from one tmux invocation', () => { + const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8'); + const methodStart = source.indexOf('capturePaneBuffer(muxName: string'); + const methodBody = source.slice(methodStart, source.indexOf('captureActivePaneBuffer(muxName: string', methodStart)); + + it('chains the capture and the cursor query with `\;` on the visible path only', () => { + // Two separate execs raced a CLI repaint (codex redraws its composer 30-60 ms + // after input): the frame could predate the caret, and guarded stage refused it. + // Two backslashes in the source: one escaped backslash in the shell command, which hands tmux its `;`. + const chained = '${captureCommand} ' + '\\'.repeat(2) + '; ${cursorQuery}'; + expect(methodBody).toContain('exec(`' + chained + '`'); + // Output it cannot split falls back to the two calls the capture always made. + expect(methodBody).toContain('combined?.frame ?? exec(captureCommand, execOpts)'); + expect(methodBody).toContain('combined\n ? combined.cursor'); + }); + it('splits the combined output into the rows and the cursor line tmux prints last', () => { + expect(splitFrameAndCursor('› hello\n footer\n\n7 0 100 20 0 0\n')).toEqual({ + frame: '› hello\n footer\n\n', + cursor: '7 0 100 20 0 0', + }); + expect(splitFrameAndCursor('only\n3 0 80 24\n')).toEqual({ frame: 'only\n', cursor: '3 0 80 24' }); + }); + it('refuses output whose last line is not the cursor query, so a frame row is never taken for it', () => { + expect(splitFrameAndCursor('› hello\n footer\n')).toBeNull(); + expect(splitFrameAndCursor('row\nworld 1 2 3 4\n')).toBeNull(); + expect(splitFrameAndCursor('')).toBeNull(); + }); +}); + +describe('how many tmux calls one visible-frame capture makes', () => { + type Run = (cmd: string, opts: { encoding: 'utf-8'; timeout: number; maxBuffer?: number }) => string; + class ScriptedTmux extends TmuxManager { + calls: string[] = []; + constructor(private readonly answer: (cmd: string) => string) { + super(); + } + protected override captureRunner(): Run { + return (cmd) => { + this.calls.push(cmd.includes(' \\; ') ? 'combined' : cmd.includes('display-message') ? 'cursor' : 'capture'); + return this.answer(cmd); + }; + } + } + const quiet = () => vi.spyOn(console, 'error').mockImplementation(() => {}); + + it('makes ONE call when the combined output splits', () => { + const tmux = new ScriptedTmux(() => '› hello\n footer\n7 0 100 20 0 0\n'); + expect(tmux.capturePaneBuffer('codeman-12345678')).toContain('hello'); + expect(tmux.calls).toEqual(['combined']); + }); + + it('does not rerun a combined call that threw: a hung tmux costs one timeout, not two', () => { + const errors = quiet(); + try { + const tmux = new ScriptedTmux(() => { + throw Object.assign(new Error('spawnSync /bin/sh ETIMEDOUT'), { code: 'ETIMEDOUT' }); + }); + expect(tmux.capturePaneBuffer('codeman-12345678')).toBeNull(); + expect(tmux.calls).toEqual(['combined']); + expect(errors).toHaveBeenCalledTimes(1); + } finally { + errors.mockRestore(); + } + }); + + it('falls back to the two separate calls only for output it cannot split', () => { + const tmux = new ScriptedTmux((cmd) => + cmd.includes(' \\; ') ? '› hello\n footer\n' : cmd.includes('display-message') ? '7 0 100 20 0 0\n' : '› hello\n' + ); + expect(tmux.capturePaneBuffer('codeman-12345678')).toContain('hello'); + expect(tmux.calls).toEqual(['combined', 'capture', 'cursor']); + }); + + it('keeps the full-history path on its two calls', () => { + const tmux = new ScriptedTmux((cmd) => (cmd.includes('display-message') ? '0 0 80 24 0 0\n' : 'row\n')); + tmux.capturePaneBuffer('codeman-12345678', undefined, { fullHistory: true }); + expect(tmux.calls).toEqual(['capture', 'cursor']); + }); +}); diff --git a/test/tmux-submit-paste.test.ts b/test/tmux-submit-paste.test.ts index e0a3fa88..614cb904 100644 --- a/test/tmux-submit-paste.test.ts +++ b/test/tmux-submit-paste.test.ts @@ -229,6 +229,21 @@ describe('sendInput line breaks', () => { return argvOf(setBufferCmd.replace(/^.*? set-buffer /, `${FAKE_TMUX} set-buffer `)).at(-1) as string; } + it('checks a conditional Enter before the actual inputRunner command', async () => { + const manager = managerWithSession(); + try { + expect(await manager.sendInput('s1', '\r', { beforeDispatch: () => false })).toBe(false); + expect(manager.ran).toEqual([]); + expect(await manager.sendInput('s1', '\r', { beforeDispatch: () => true })).toBe(true); + expect(manager.ran).toEqual([expect.stringMatching(/ Enter$/)]); + manager.ran = []; + expect(await manager.sendInput('s1', 'draft\r', { beforeDispatch: () => true })).toBe(false); + expect(manager.ran).toEqual([]); + } finally { + manager.destroy(); + } + }); + it('submits a multi-line prompt as one paste with its line breaks, then Enter', async () => { const manager = managerWithSession(); expect(await manager.sendInput('s1', 'line one\nline two\n\nparagraph three\r')).toBe(true); @@ -311,3 +326,13 @@ describe('sendInput line breaks', () => { expect(answer.ran[1]).toMatch(/ send-keys -t "codeman-abc12345" Enter$/); }); }); + +it('terminates literal operands in both sendInputToPane branches', async () => { + const { readFile } = await import('node:fs/promises'); + const src = await readFile(new URL('../src/tmux-manager.ts', import.meta.url), 'utf8'); + const start = src.indexOf(' sendInputToPane('); + const body = src.slice(start, src.indexOf('\n }\n', start)); + const literal = body.match(/send-keys[^`\n]*-l[^`\n]*/g) ?? []; + expect(literal).toHaveLength(2); + for (const command of literal) expect(command).toContain(' -l -- '); +}); diff --git a/test/tui/tui-switch-guard.test.ts b/test/tui/tui-switch-guard.test.ts index 917fd670..b867e277 100644 --- a/test/tui/tui-switch-guard.test.ts +++ b/test/tui/tui-switch-guard.test.ts @@ -236,7 +236,9 @@ describe.skipIf(!hasTmux)('TUI M-1..M-9 switch only the attaching client', () => await client.unbindSwitchKey('M-2', tty); await client.disarmTuiSwitchGuard(tty); expect(run(['list-keys', '-T', 'root'])).not.toContain(`switch-client -t ${beta}`); - expect(() => run(['show-options', '-gv', '@codeman-tui-pid'])).toThrow(); + // The listing, not `show-options -gv`: an unset user option is an error on + // tmux 3.5 but empty output and exit 0 on 3.2a. + expect(run(['show-options', '-g'])).not.toContain('@codeman-tui-pid'); // A hook armed for a tty this one is a prefix of (pts/4 vs pts/41) is // another TUI's, and this tty's disarm must leave it. diff --git a/test/tunnel-enable-confirm-ui.test.ts b/test/tunnel-enable-confirm-ui.test.ts new file mode 100644 index 00000000..c69ae9c5 --- /dev/null +++ b/test/tunnel-enable-confirm-ui.test.ts @@ -0,0 +1,230 @@ +/** + * @fileoverview Issue #706 browser confirmation policy through the real welcome, tunnel panel and App Settings entry points. HTTP and browser prompts are stubbed; settings and acknowledgement logic run unchanged. + */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { JSDOM } from 'jsdom'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const PUBLIC = join(process.cwd(), 'src/web/public'); +const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8'); +const INDEX = read('index.html'); +const SCRIPTS = ['constants.js', 'app.js', 'settings-ui.js', 'api-client.js'].map(read); +const STORAGE_KEY = 'codeman-app-settings'; +const NO_LOGIN = { running: false, url: null, loginConfigured: false }; +type Status = { running: boolean; url: string | null; loginConfigured?: boolean }; +type Call = { method: string; url: string; body: Record | undefined }; +type Entry = 'welcome' | 'panel' | 'save'; +const windows: JSDOM[] = []; + +afterEach(() => { + for (const dom of windows.splice(0)) dom.window.close(); +}); + +async function boot( + status: Status = NO_LOGIN, + accept = true, + statusFailure?: 'reject' | 'non-ok', + refuseFirstPut = false +) { + const dom = new JSDOM(INDEX, { url: 'http://localhost/', runScripts: 'outside-only' }); + windows.push(dom); + const win = dom.window; + if (win.document.readyState !== 'complete') await new Promise((resolve) => win.addEventListener('load', resolve)); + // Keep status polling and unrelated rendering timers outside this user action. + win.setTimeout = (() => 0) as unknown as typeof win.setTimeout; + win.setInterval = (() => 0) as unknown as typeof win.setInterval; + win.localStorage.setItem(STORAGE_KEY, JSON.stringify({ tunnelEnabled: false })); + const calls: Call[] = []; + const order: string[] = []; + const confirm = vi.fn(() => { + order.push('confirm'); + return accept; + }); + win.confirm = confirm; + let putCount = 0; + const response = (body: unknown, statusCode = 200) => ({ + ok: statusCode >= 200 && statusCode < 300, + status: statusCode, + json: async () => body, + clone: () => response(body, statusCode), + }); + const fetch = vi.fn(async (url: string, opts: { method?: string; body?: string } = {}) => { + const method = opts.method || 'GET'; + calls.push({ method, url, body: opts.body === undefined ? undefined : JSON.parse(opts.body) }); + order.push(`${method} ${url}`); + if (url === '/api/tunnel/status' && method === 'GET') { + if (statusFailure === 'reject') throw new Error('Status unavailable'); + // A failed HTTP response must not be trusted, even if its JSON resembles a status. + return response(status, statusFailure === 'non-ok' ? 503 : 200); + } + if (url === '/api/settings' && method === 'PUT') { + putCount++; + if (refuseFirstPut && putCount === 1) { + return response( + { success: false, error: 'Tunnel acknowledgement required', errorCode: 'OPERATION_FAILED' }, + 403 + ); + } + return response({ success: true, data: {} }); + } + if (url === '/api/execution/model-config' && method === 'PUT') return response({ success: true, data: {} }); + throw new Error(`Unexpected request: ${method} ${url}`); + }); + Object.assign(win, { fetch }); + win.eval( + [ + `var MobileDetection = { + getDeviceType: () => 'desktop', isHandheldDevice: () => false, + isMobile: () => false, isTouchDevice: () => false, + }; + var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {}; + var KeyboardAccessoryBar = { setMode() {} }; + var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() }; + var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; }; + var DEFAULT_VOICE_KEYTERMS = '';`, + ...SCRIPTS, + 'window.__App = CodemanApp;', + ].join('\n') + ); + // Only peripheral rendering is stubbed. All tunnel logic, settings reads/writes and HTTP helpers run for real. + const app = Object.create((win as unknown as { __App: { prototype: object } }).__App.prototype); + app.sessions = new Map(); + app.sessionOrder = []; + for (const name of [ + 'showToast', + 'closeAppSettings', + '_updateLocalEchoState', + 'applyTerminalFontFamily', + 'applyTerminalFontWeights', + '_syncPushPreferences', + 'applyHeaderVisibilitySettings', + 'applySkin', + 'applyLocalization', + 'applySessionListLayout', + 'applyTabOrientation', + 'applyLineageLineSettings', + '_updateTokensImmediate', + 'applyMonitorVisibility', + 'renderApprovals', + 'renderProjectInsightsPanel', + 'updateSubagentWindowVisibility', + '_updateCjkInputState', + 'showWelcome', + ]) + app[name] = vi.fn(); + const box = win.document.getElementById('appSettingsTunnelEnabled') as HTMLInputElement; + const button = win.document.getElementById('welcomeTunnelBtn') as HTMLButtonElement; + // The welcome screen has finished its status-dependent reveal before the user clicks. + button.style.display = ''; + const puts = () => calls.filter((call) => call.method === 'PUT' && call.url === '/api/settings'); + const events = () => + order.filter( + (event) => event === 'confirm' || event.endsWith('/api/settings') || event.endsWith('/api/tunnel/status') + ); + const stored = () => JSON.parse(win.localStorage.getItem(STORAGE_KEY) || '{}'); + const act = async (entry: Entry, enable = true) => { + if (entry === 'save') box.checked = enable; + if (entry === 'welcome') button.classList.toggle('active', !enable); + vi.clearAllMocks(); + calls.length = 0; + order.length = 0; + if (entry === 'welcome') await app.toggleTunnelFromWelcome(); + if (entry === 'panel') await app._tunnelPanelToggle(enable); + if (entry === 'save') await app.saveAppSettings(); + }; + return { act, puts, events, confirm, box, button, stored }; +} + +function expectEnablePut(calls: Call[], entry: Entry, acknowledge: boolean) { + expect(calls).toHaveLength(1); + const expected = { tunnelEnabled: true, ...(acknowledge ? { acknowledgeUnauthTunnel: true } : {}) }; + if (entry === 'save') { + // The spec leaves the other App Settings fields unchanged. + expect(calls[0].body).toMatchObject(expected); + if (!acknowledge) expect(calls[0].body).not.toHaveProperty('acknowledgeUnauthTunnel'); + } else { + expect(calls).toEqual([{ method: 'PUT', url: '/api/settings', body: expected }]); + } +} + +describe.each(['welcome', 'panel', 'save'] as const)('%s tunnel enable', (entry) => { + it('confirms before a successful PUT and sends the acknowledgement', async () => { + const h = await boot(); + await h.act(entry); + expect.soft(h.confirm).toHaveBeenCalledTimes(1); + expect.soft(h.events()).toEqual(['GET /api/tunnel/status', 'confirm', 'PUT /api/settings']); + expectEnablePut(h.puts(), entry, true); + }); + + it('asks again on every enable attempt', async () => { + const h = await boot(); + for (let attempt = 0; attempt < 2; attempt++) { + await h.act(entry); + expect.soft(h.confirm).toHaveBeenCalledTimes(1); + expect.soft(h.events()).toEqual(['GET /api/tunnel/status', 'confirm', 'PUT /api/settings']); + } + }); + + it('cancels without a PUT and leaves the tunnel off', async () => { + const h = await boot(NO_LOGIN, false); + await h.act(entry); + expect.soft(h.confirm).toHaveBeenCalledTimes(1); + expect.soft(h.puts()).toEqual([]); + expect.soft(h.events()).toEqual(['GET /api/tunnel/status', 'confirm']); + expect.soft(h.button.classList.contains('active')).toBe(false); + expect.soft(h.button.disabled).toBe(false); + expect.soft(h.button.style.display).not.toBe('none'); + expect.soft(h.box.disabled).toBe(false); + expect.soft(h.box.checked).toBe(false); + if (entry === 'save') expect.soft(h.stored().tunnelEnabled).toBe(false); + }); + + it('does not confirm or acknowledge when login is configured', async () => { + const h = await boot({ ...NO_LOGIN, loginConfigured: true }); + await h.act(entry); + expect(h.confirm).not.toHaveBeenCalled(); + expectEnablePut(h.puts(), entry, false); + }); + + it('does not confirm or acknowledge when loginConfigured is absent', async () => { + const h = await boot({ running: false, url: null }); + await h.act(entry); + expect(h.confirm).not.toHaveBeenCalled(); + expect(h.events()).toEqual(['GET /api/tunnel/status', 'PUT /api/settings']); + expectEnablePut(h.puts(), entry, false); + }); + + it.each(['reject', 'non-ok'] as const)('sends no acknowledgement when the status read is %s', async (failure) => { + const h = await boot(NO_LOGIN, true, failure); + await h.act(entry); + expect(h.confirm).not.toHaveBeenCalled(); + expect.soft(h.events()).toEqual(['GET /api/tunnel/status', 'PUT /api/settings']); + expectEnablePut(h.puts(), entry, false); + }); +}); + +it('save does not confirm or acknowledge an already running tunnel', async () => { + const h = await boot({ running: true, url: 'https://test.trycloudflare.com', loginConfigured: false }); + await h.act('save'); + expect(h.confirm).not.toHaveBeenCalled(); + expectEnablePut(h.puts(), 'save', false); +}); + +it.each(['reject', 'non-ok'] as const)('welcome keeps the 403 fallback when the status read is %s', async (failure) => { + const h = await boot(NO_LOGIN, true, failure, true); + await h.act('welcome'); + expect.soft(h.confirm).toHaveBeenCalledTimes(1); + expect.soft(h.events()).toEqual(['GET /api/tunnel/status', 'PUT /api/settings', 'confirm', 'PUT /api/settings']); + expect(h.puts()).toEqual([ + { method: 'PUT', url: '/api/settings', body: { tunnelEnabled: true } }, + { method: 'PUT', url: '/api/settings', body: { tunnelEnabled: true, acknowledgeUnauthTunnel: true } }, + ]); +}); + +it.each(['welcome', 'panel'] as const)('%s disables without confirmation', async (entry) => { + const h = await boot(); + await h.act(entry, false); + expect(h.confirm).not.toHaveBeenCalled(); + expect(h.puts()).toEqual([{ method: 'PUT', url: '/api/settings', body: { tunnelEnabled: false } }]); +}); diff --git a/test/tunnel-flag-plumbing.test.ts b/test/tunnel-flag-plumbing.test.ts new file mode 100644 index 00000000..9f7f92e9 --- /dev/null +++ b/test/tunnel-flag-plumbing.test.ts @@ -0,0 +1,101 @@ +/** + * @fileoverview Issue #706 carries the WebServer tunnel opt-in into its real route context. Requests use Fastify injection and settings use the isolated HOME from test/setup.ts; the tunnel process and socket listener are stubbed. + */ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import type { FastifyInstance } from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { startWebServer, WebServer } from '../src/web/server.js'; +import { TunnelManager } from '../src/tunnel-manager.js'; +import { TmuxManager } from '../src/tmux-manager.js'; +import { SETTINGS_PATH } from '../src/web/route-helpers.js'; + +const ENV_KEYS = [ + 'CODEMAN_PASSWORD', + 'CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK', + 'CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL', + 'CODEMAN_MULTIUSER', +] as const; +const INITIAL = { tunnelEnabled: false, subagentTrackingEnabled: false }; +let savedEnv: (string | undefined)[]; +let server: WebServer | undefined; +let startTunnel: ReturnType>; + +beforeEach(async () => { + savedEnv = ENV_KEYS.map((key) => process.env[key]); + for (const key of ENV_KEYS) delete process.env[key]; + vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true); + startTunnel = vi.spyOn(TunnelManager.prototype, 'start').mockImplementation(() => {}); + vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(false); + await mkdir(dirname(SETTINGS_PATH), { recursive: true }); + await writeFile(SETTINGS_PATH, JSON.stringify(INITIAL)); +}); + +afterEach(async () => { + try { + await server?.stop(); + } finally { + server = undefined; + vi.restoreAllMocks(); + ENV_KEYS.forEach((key, i) => { + if (savedEnv[i] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[i]; + }); + } +}); + +async function enableTunnel(instance: WebServer) { + server = instance; + const app = (server as unknown as { app: FastifyInstance }).app; + // Only the transport is replaced: start() still builds the production route context and middleware. + const listen = vi.spyOn(app, 'listen').mockImplementation(async () => { + await app.ready(); + return 'http://127.0.0.1:0'; + }); + await server.start(); + expect(listen.mock.calls).toEqual([[{ port: 0, host: '127.0.0.1' }]]); + startTunnel.mockClear(); + return app.inject({ method: 'PUT', url: '/api/settings', payload: { tunnelEnabled: true } }); +} + +describe('WebServer tunnel flag plumbing', () => { + it('permits an unauthenticated tunnel through the startWebServer factory flag', async () => { + const start = WebServer.prototype.start; + // Keep the real factory and startup path, replacing only the socket transport before startup. + vi.spyOn(WebServer.prototype, 'start').mockImplementation(async function (this: WebServer) { + // eslint-disable-next-line @typescript-eslint/no-this-alias -- Teardown must also stop an instance whose factory startup fails. + server = this; + const app = (this as unknown as { app: FastifyInstance }).app; + const listen = vi.spyOn(app, 'listen').mockImplementation(async () => { + await app.ready(); + return 'http://127.0.0.1:0'; + }); + await start.call(this); + expect(listen.mock.calls).toEqual([[{ port: 0, host: '127.0.0.1' }]]); + }); + server = await startWebServer(0, false, true, '127.0.0.1', undefined, false, '', true); + const app = (server as unknown as { app: FastifyInstance }).app; + startTunnel.mockClear(); + const res = await app.inject({ method: 'PUT', url: '/api/settings', payload: { tunnelEnabled: true } }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ success: true, data: {} }); + expect(startTunnel.mock.calls).toEqual([[server.boundPort, false]]); + expect(JSON.parse(await readFile(SETTINGS_PATH, 'utf8'))).toEqual({ ...INITIAL, tunnelEnabled: true }); + }); + + it('permits an unauthenticated tunnel through the constructor flag', async () => { + const res = await enableTunnel(new WebServer(0, false, true, '127.0.0.1', undefined, false, '', true)); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ success: true, data: {} }); + expect(startTunnel.mock.calls).toEqual([[0, false]]); + expect(JSON.parse(await readFile(SETTINGS_PATH, 'utf8'))).toEqual({ ...INITIAL, tunnelEnabled: true }); + }); + + it('refuses an unauthenticated tunnel when the constructor flag is omitted', async () => { + const res = await enableTunnel(new WebServer(0, false, true, '127.0.0.1', undefined, false, '')); + expect(res.statusCode).toBe(403); + expect(res.json()).toEqual({ success: false, errorCode: 'OPERATION_FAILED', error: expect.any(String) }); + expect(startTunnel.mock.calls).toEqual([]); + expect(await readFile(SETTINGS_PATH, 'utf8')).toBe(JSON.stringify(INITIAL)); + }); +}); diff --git a/test/webserver-bound-port.test.ts b/test/webserver-bound-port.test.ts index e81612b9..020b83a4 100644 --- a/test/webserver-bound-port.test.ts +++ b/test/webserver-bound-port.test.ts @@ -40,6 +40,59 @@ describe('WebServer on an ephemeral port', () => { expect(ctx.port).toBe(socketPort()); }); + it('registers bounded agent routes with the server route modules', async () => { + const { Session } = await import('../src/session.js'); + const session = new Session({ id: 'agent-route-wiring', workingDir: process.env.HOME!, mode: 'shell' }); + const sessions = (server as unknown as { sessions: Map }).sessions; + sessions.set(session.id, session); + const { mkdirSync, rmSync, writeFileSync } = await import('node:fs'); + const { dirname } = await import('node:path'); + const { SETTINGS_PATH } = await import('../src/web/route-helpers.js'); + const setEnabled = (on: boolean) => { + mkdirSync(dirname(SETTINGS_PATH), { recursive: true }); + writeFileSync(SETTINGS_PATH, JSON.stringify({ agentCoordinationEnabled: on })); + }; + try { + const prefix = `http://127.0.0.1:${server.boundPort}/api/v1/sessions/${session.id}`; + // Opt-in: off, the /api/v1 alias of every route refuses with 403 FORBIDDEN. + setEnabled(false); + for (const [method, verb] of [ + ['GET', 'inspect'], + ['POST', 'stage'], + ['POST', 'submit'], + ] as const) { + const refused = await fetch(prefix + '/' + verb, { + method, + ...(method === 'POST' ? { headers: { 'Content-Type': 'application/json' }, body: '{}' } : {}), + }); + expect(refused.status).toBe(403); + const body = await refused.json(); + expect(body.errorCode).toBe('FORBIDDEN'); + expect(body.error).toContain('agentCoordinationEnabled'); + } + setEnabled(true); + const inspected = await fetch(prefix + '/inspect'); + expect(inspected.status).toBe(200); + expect((await inspected.json()).data.source).toBe('pane-snapshot'); + for (const [verb, body] of [ + ['stage', { message: 'hello' }], + ['submit', { stageId: '11111111-1111-4111-8111-111111111111', confirmSha: 'a'.repeat(64) }], + ] as const) { + const response = await fetch(prefix + '/' + verb, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + expect(response.status).toBe(409); + expect((await response.json()).errorCode).toBe('CONFLICT'); + } + } finally { + rmSync(SETTINGS_PATH, { force: true }); + sessions.delete(session.id); + await session.stop(false); + } + }); + it('exports the real port to the panes as CODEMAN_API_URL', () => { expect(process.env.CODEMAN_API_URL).toBe(`http://127.0.0.1:${socketPort()}`); }); diff --git a/test/webview-loopback-links.test.ts b/test/webview-loopback-links.test.ts index c19d3086..3d053971 100644 --- a/test/webview-loopback-links.test.ts +++ b/test/webview-loopback-links.test.ts @@ -299,7 +299,10 @@ describe('openLinkThroughWebTabIfLoopback', () => { describe('callers consult the hook first', () => { it('terminal URL links try the web tab before window.open', () => { - const activate = TERMINAL_UI.indexOf('activate(_event, text) {'); + // URL links (and OSC 8 links) open through _openTerminalUrl(), so the desktop + // click and the mobile touch-tap path both consult the hook. Read that function. + expect(TERMINAL_UI).toContain('self._openTerminalUrl(url);'); + const activate = TERMINAL_UI.indexOf('_openTerminalUrl(text) {'); expect(activate).toBeGreaterThan(-1); const body = TERMINAL_UI.slice(activate, TERMINAL_UI.indexOf('},', activate)); expect(body.indexOf('openLinkThroughWebTabIfLoopback?.(text)')).toBeGreaterThan(-1); diff --git a/test/worktree-ui.test.ts b/test/worktree-ui.test.ts index 4702af30..66681fa2 100644 --- a/test/worktree-ui.test.ts +++ b/test/worktree-ui.test.ts @@ -6,7 +6,7 @@ * - a worktree launch goes through the EXISTING create paths with the worktree's folder: * POST /api/sessions with `workingDir` for claude and shell, quick-start with `worktree` for * every other CLI; never a case lookup or scaffold, and tabs named after the worktree; - * - `_launchInWorktree()` swaps the run mode for the one launch and puts it back. + * - `_launchOnce()` starts the one launch in the chosen mode without touching the Run button's mode. * * Port: N/A. */ @@ -138,15 +138,15 @@ describe('launching in a worktree', () => { }); }); - it('_launchInWorktree runs ONE session of the chosen mode and restores the Run button mode', async () => { + it("_launchOnce runs ONE session of the chosen mode in the worktree and leaves the Run button's mode", async () => { const { app } = load(async () => json({})); app._runMode = 'claude'; const seen: Array<{ mode: string; options: unknown }> = []; app.run = vi.fn(async (options: unknown) => { seen.push({ mode: app._runMode, options }); }); - await app._launchInWorktree(WT, 'codex'); - expect(seen).toEqual([{ mode: 'codex', options: { count: 1, worktree: WT } }]); + await app._launchOnce('codex', { worktree: WT }); + expect(seen).toEqual([{ mode: 'claude', options: { count: 1, worktree: WT, mode: 'codex' } }]); expect(app._runMode).toBe('claude'); }); diff --git a/test/wrapped-file-paths.browser.test.ts b/test/wrapped-file-paths.browser.test.ts new file mode 100644 index 00000000..10b5d4c7 --- /dev/null +++ b/test/wrapped-file-paths.browser.test.ts @@ -0,0 +1,133 @@ +/** Real xterm clicks resolve the complete filename or report its lookup error from either wrapped row. */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chromium, type Browser } from 'playwright'; + +const publicDir = resolve(import.meta.dirname, '../src/web/public'); +const basePath = '/mnt/user/Scratch/Kobo/Set-KoboCovers/Kobo eReader.conf'; + +describe('wrapped configuration file links (Chromium)', () => { + let browser: Browser; + beforeAll(async () => { + browser = await chromium.launch({ headless: true }); + }); + afterAll(async () => { + await browser.close(); + }); + + it.each([ + { wrap: 'soft', originalExists: false, extension: 'conf', registrationStatus: 200, prefix: '' }, + { wrap: 'hard', originalExists: false, extension: 'conf', registrationStatus: 200, prefix: '' }, + { wrap: 'hard', originalExists: true, extension: 'conf', registrationStatus: 200, prefix: '' }, + { wrap: 'hard', originalExists: true, extension: 'svg', registrationStatus: 400, prefix: '' }, + { wrap: 'hard', originalExists: true, extension: 'markdown', registrationStatus: 400, prefix: '' }, + { wrap: 'hard', originalExists: false, extension: 'svg', registrationStatus: 400, prefix: '' }, + { wrap: 'hard', originalExists: false, extension: 'conf', registrationStatus: 404, prefix: '' }, + { wrap: 'hard', originalExists: false, extension: 'markdown', registrationStatus: 200, prefix: 'Path: ' }, + ])( + 'handles .$extension links on both rows: $wrap, original exists: $originalExists, prefix: $prefix', + async ({ wrap, originalExists, extension, registrationStatus, prefix }) => { + const path = basePath.replace('.conf', `.${extension}`); + const original = path.replace('Kobo eReader', 'KoboeReader'); + const expected = wrap === 'hard' && (originalExists || registrationStatus === 404) ? original : path; + const lookupFails = registrationStatus === 400 && !originalExists; + const lookups: string[] = []; + const page = await browser.newPage({ viewport: { width: 1920, height: 600 } }); + try { + await page.route('http://fixture.test/', (route) => + route.fulfill({ + contentType: 'text/html', + body: '
', + }) + ); + await page.route('http://fixture.test/api/sessions/fixture/attachments', async (route) => { + const body = route.request().postDataJSON(); + expect(body.notify).toBe(false); + lookups.push(body.path); + const exists = body.path === path || (originalExists && body.path === original); + const status = exists ? registrationStatus : 404; + await route.fulfill({ + status, + json: + status === 200 + ? { success: true, data: { attachmentId: 'att-fixture' } } + : { success: false, error: status === 400 ? 'Unsupported attachment type' : 'File not found' }, + }); + }); + await page.goto('http://fixture.test/'); + await page.addStyleTag({ path: resolve(publicDir, '../../../node_modules/@xterm/xterm/css/xterm.css') }); + await page.addScriptTag({ path: resolve(publicDir, '../../../node_modules/@xterm/xterm/lib/xterm.js') }); + await page.addScriptTag({ content: 'window.CodemanApp = class CodemanApp {};' }); + for (const file of ['constants.js', 'terminal-ui.js', 'panels-ui.js']) { + await page.addScriptTag({ content: readFileSync(resolve(publicDir, file), 'utf8') }); + } + await page.evaluate( + async ({ path, wrap, workingDir, prefix }) => { + const w = window as any; + const boundary = path.lastIndexOf(' '); + const first = `Found it: prepared a corrected configuration (${prefix}${path.slice(0, boundary)}`; + const terminal = new w.Terminal({ cols: first.length + (wrap === 'soft' ? 1 : 20), rows: 6 }); + terminal.open(document.getElementById('terminal')); + w.opened = []; + w.app = Object.assign(new w.CodemanApp(), { + terminal, + activeSessionId: 'fixture', + sessions: new Map([['fixture', { workingDir }]]), + openFilePreview: (file: string) => w.opened.push(file), + openLogViewerWindow: (file: string) => w.opened.push(`log:${file}`), + openFolderGrid: (file: string) => w.opened.push(`folder:${file}`), + showToast: (message: string) => w.opened.push(`error:${message}`), + }); + w.app.registerFilePathLinkProvider(); + const text = `${first}${wrap === 'soft' ? ' ' : '\r\n '}${path.slice(boundary + 1)}).`; + await new Promise((done) => terminal.write(text, done)); + await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(() => done()))); + }, + { path, wrap, workingDir: registrationStatus === 400 ? '/mnt/user/Scratch/Kobo' : '/workspace', prefix } + ); + + expect(lookups).toEqual([]); + for (const row of [0, 1]) { + const point = await page.evaluate( + async ({ path, row }) => { + const app = (window as any).app; + const terminal = app.terminal; + const links = await new Promise((done) => app._terminalLinkProvider.provideLinks(row + 1, done)); + const link = links.find((candidate) => candidate.text === path); + if (!link) throw new Error(`The complete filename is not linked on row ${row + 1}`); + const text = terminal.buffer.active.getLine(row).translateToString(true); + const col = row === 0 ? link.range.start.x : text.indexOf('eReader') + 1; + const box = terminal.element.querySelector('.xterm-screen').getBoundingClientRect(); + return { + x: box.left + (col - 0.5) * (box.width / terminal.cols), + y: box.top + (row + 0.5) * (box.height / terminal.rows), + }; + }, + { path: wrap === 'hard' ? original : path, row } + ); + await page.mouse.move(point.x, point.y); + await page.waitForFunction(() => (window as any).app._linkHovered === true); + await page.mouse.click(point.x, point.y); + await page.waitForFunction((count) => (window as any).opened.length === count, row + 1); + const opened = await page.evaluate(() => (window as any).opened as string[]); + if (lookupFails) { + for (const message of opened) { + expect(message).toMatch(/^error:/); + expect(message).toContain(path); + expect(message).not.toContain('outside the session workspace'); + } + } else { + expect(opened).toEqual(Array(row + 1).fill(expected)); + } + await page.mouse.move(0, 0); + } + expect(lookups).toEqual( + wrap === 'soft' ? [] : originalExists ? [original, original] : [original, path, original, path] + ); + } finally { + await page.close(); + } + } + ); +});