diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3d3df9ae..4fceaf6e 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "codeman", "source": "./plugins/codeman", "description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.", - "version": "1.47.0", + "version": "1.48.0", "author": { "name": "Ark0N", "url": "https://github.com/Ark0N" diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dbc0948..a41256ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # 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 diff --git a/CLAUDE.md b/CLAUDE.md index 3926a6b0..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.47.0 (must match `package.json`) +**Version**: 1.48.0 (must match `package.json`) ## Project Overview @@ -131,6 +131,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **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` +**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) @@ -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, `POST /api/sessions/:id/input` and the experimental guarded stage (`stageGuardedInput()`, `POST /api/sessions/:id/stage`, typed on an agent's behalf like the input route) 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,13 +228,13 @@ 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 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 `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) 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. -**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) +**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping**: it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. **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**: 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` +**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) @@ -240,11 +242,13 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin **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) @@ -315,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 3546ab39..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"> @@ -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 @@ -975,6 +977,7 @@ 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 ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index add70ccf..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"> diff --git a/config/mcp-registry/server.json b/config/mcp-registry/server.json index dd87b908..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.47.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.47.0", + "version": "1.48.0", "transport": { "type": "stdio" }, diff --git a/config/test-suites.ts b/config/test-suites.ts index ae974542..49812b3a 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -19,6 +19,7 @@ * 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', @@ -49,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', @@ -57,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 4a0cb6b7..9a269ad3 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -900,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 @@ -969,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 @@ -1205,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 @@ -1315,6 +1360,8 @@ 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 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. @@ -1346,6 +1393,19 @@ Lists are capped (300 files and 50 commits per repository) while the counts stay 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`. @@ -1486,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 ebba7973..479d279b 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -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, `POST /api/sessions/:id/input` and the experimental guarded stage (`stageGuardedInput()`, behind `POST /api/sessions/:id/stage`) 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. @@ -409,10 +409,16 @@ 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 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 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`. @@ -429,7 +435,7 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin ### 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. @@ -468,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. @@ -628,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. @@ -742,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). @@ -1047,7 +1073,7 @@ 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 also falls back (the whole first line would drop below the toolbar). 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). +**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`. @@ -1364,3 +1390,16 @@ refuses them (`GuardedInputBusyError`, which `send-key` answers as `409 CONFLICT 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/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/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/security-architecture.md b/docs/security-architecture.md index 0a6eb6ea..72c2dcf0 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -582,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. --- 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/Home.md b/docs/wiki/Home.md index 3a0cd566..0efad3c4 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -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/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/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 17cd6555..1a0b3a1e 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -62,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. @@ -106,6 +106,12 @@ nothing waits for a login, and that is a default, never an override: your own `G 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* (default; two pills with a ring beside every value), *Tiles* (label over value with a bar underneath) or *As before* (the bars and the `5H · 7D` chip). Desktop only, per device. @@ -214,8 +220,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 diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md index eb5611b8..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 @@ -272,6 +280,60 @@ data comes from `GET /api/sessions/:id/git-status`, `GET /api/sessions/:id/git-d 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 1d6ed658..ff1b96da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aicodeman", - "version": "1.47.0", + "version": "1.48.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aicodeman", - "version": "1.47.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": { diff --git a/package.json b/package.json index 948c1cba..d468ef30 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "1.47.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/plugins/codeman/.claude-plugin/plugin.json b/plugins/codeman/.claude-plugin/plugin.json index 8cfa4337..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 inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.", - "version": "1.47.0", + "version": "1.48.0", "author": { "name": "Ark0N", "url": "https://github.com/Ark0N" 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 4682aa89..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 { @@ -92,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') 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 461a81fb..ff3f40d5 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(), @@ -584,10 +594,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 feef3864..6e9aa225 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -152,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' }, }, @@ -179,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' }, + }, ], }, { @@ -203,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' }, + }, ], }, ], @@ -629,7 +642,10 @@ const CODEX: CliEntry = { bypassApprovals: { type: 'bool' }, networkAccess: { 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' }, }, @@ -887,6 +903,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 @@ -1591,30 +1608,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: { @@ -1967,8 +1984,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 155e4912..ebf715d8 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' /** @@ -812,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, @@ -856,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: | { @@ -888,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/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/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/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/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/mux-interface.ts b/src/mux-interface.ts index 57fa360e..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 */ @@ -396,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/session-cli-registry-bridge.ts b/src/session-cli-registry-bridge.ts index 56d8ac2a..a617a961 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'; @@ -258,6 +259,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: an EXPLICIT `undefined` probes the // entry's own local CLI (gateVersion: claude's probe for claude, the registry resolver for // the rest, both null under vitest); an explicit `null` means "known to be unresolvable" diff --git a/src/session.ts b/src/session.ts index a2b49565..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'; @@ -71,6 +72,8 @@ import { 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'; @@ -398,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 @@ -743,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 @@ -1330,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; @@ -1485,6 +1529,99 @@ export class Session extends EventEmitter { 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. @@ -2162,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, @@ -2692,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) { @@ -2714,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. @@ -2724,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 diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 3b59fe2e..41392c92 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. @@ -2006,6 +2046,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; /** @@ -3508,6 +3555,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 @@ -3569,6 +3629,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. 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 ce75091a..ca0f6503 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -840,6 +840,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 */ diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index ed210de6..7f5ab0bf 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -84,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/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/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 255132a6..ac04a708 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'], @@ -3587,6 +3591,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. @@ -7035,6 +7042,8 @@ class CodemanApp { 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 @@ -7071,14 +7080,33 @@ class CodemanApp { 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); - // A first tab that cannot fit beside the toolbar would drop the whole first - // line below it and leave an empty band: keep the plain strip instead. - const firstTab = container.querySelector(':scope > .session-tab'); - if (firstTab && box.left - 8 - contentLeft < firstTab.offsetWidth + 2) { + // 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 width = `${Math.max(0, Math.ceil(contentRight - box.left + 8))}px`; + 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); @@ -9987,6 +10015,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 742ac363..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,14 +2164,14 @@ 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`, sorted by position. Spaced candidates - * reserve their whole span first, even when unlinkable; rooted, `~/`, relative + * 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 @@ -2195,19 +2200,26 @@ function findFilePathLinks(text) { // 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')) { - const spaced = spacedFilePathOnLine(line); - if (spaced && !inUrl(lineStart + spaced.index, spaced.path.length)) { - const index = lineStart + spaced.index; - reserved.push({ index, path: spaced.path }); - if (spaced.link) found.push({ path: spaced.path, index }); + 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 isReserved = (index, length) => - reserved.some((r) => index < r.index + r.path.length && r.index < index + length); - const absolute = absoluteFilePathPattern(); while ((match = absolute.exec(value)) !== null) { if (!inUrl(match.index, match[1].length) && !isReserved(match.index, match[1].length)) { @@ -2235,14 +2247,29 @@ function findFilePathLinks(text) { 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, so a spaced path is recognised in exactly these shapes: + * 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) @@ -2256,7 +2283,7 @@ const SPACED_FILE_PATH_LABEL_PATTERN = * 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 `:`, is an arrow or has + * `/`, `~/`, `./` 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 @@ -2278,9 +2305,10 @@ const SPACED_FILE_PATH_LABEL_PATTERN = * 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) { +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; @@ -2304,6 +2332,7 @@ function spacedFilePathOnLine(line) { } 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 `$` @@ -2397,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 @@ -2557,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` @@ -2586,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. */ @@ -2607,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; @@ -2616,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 = []; @@ -2659,7 +2733,14 @@ 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 } : {}), + }; } /** @@ -2915,7 +2996,14 @@ function terminalLinkLine(buffer, row, cols, maxRows) { 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 }; + return { + startRow, + endRow: lastRow, + text, + offsetToCell, + cellToOffset, + ...(base.pathAlternatives ? { pathAlternatives: base.pathAlternatives } : {}), + }; } /** @@ -4025,6 +4113,7 @@ if (typeof window !== 'undefined') { absoluteFilePathPattern, findFilePathLinks, resolveLinkedFilePath, + relativeResolutionBase, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS, rendersAsPage, 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/i18n.js b/src/web/public/i18n.js index d425adf8..9aa28a44 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -553,6 +553,39 @@ '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': '项目洞察', @@ -615,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)', @@ -624,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': '仅活动标签页', @@ -917,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': '推送通知已启用', @@ -1379,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}`], @@ -1447,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/index.html b/src/web/public/index.html index 1839ea6f..4620a557 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> @@ -633,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"> @@ -1033,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> @@ -2191,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> @@ -2791,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"> @@ -3241,6 +3283,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 ════════════════════════════════════════════════════ --> @@ -3661,7 +3794,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> @@ -4527,6 +4672,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 a2596d03..95e7fafa 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'; diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index f4f56b1e..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. */ @@ -3473,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) { diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index fef69c90..e46baa4a 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -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 1e5961d4..fc0d2582 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -735,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 @@ -1002,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) — @@ -1024,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, @@ -1055,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); @@ -1078,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() { @@ -4097,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'); @@ -4124,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; @@ -4272,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', { @@ -4279,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) { @@ -4315,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'); diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 90ffa6ab..56ebcf61 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -570,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 = @@ -609,6 +610,7 @@ Object.assign(CodemanApp.prototype, { // 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 @@ -1734,6 +1736,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. @@ -2301,6 +2412,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 @@ -2662,9 +2781,11 @@ Object.assign(CodemanApp.prototype, { * 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 = ''; @@ -2696,7 +2817,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'); @@ -2746,6 +2867,13 @@ Object.assign(CodemanApp.prototype, { 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'); @@ -2964,7 +3092,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; @@ -3028,6 +3160,7 @@ Object.assign(CodemanApp.prototype, { 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, @@ -3284,7 +3417,7 @@ 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 @@ -3336,6 +3469,8 @@ 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 @@ -3357,6 +3492,7 @@ 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 @@ -3365,18 +3501,20 @@ Object.assign(CodemanApp.prototype, { ? 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 @@ -3391,15 +3529,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 - // so it doesn't look enabled. - if (!refusedAsNonAdmin && settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) { + // 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. @@ -3410,7 +3558,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 @@ -3423,7 +3573,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) { @@ -3435,9 +3585,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(); } @@ -3448,10 +3599,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' @@ -3500,6 +3651,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 @@ -3811,6 +3991,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 = ''; }, @@ -3836,6 +4019,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'); @@ -3856,6 +4051,7 @@ Object.assign(CodemanApp.prototype, { baseUrl, authStyle, defaultModelId, + defaultContextLength, apiKey: apiKeyInput || undefined, models: existing?.models, lastDiscoveredAt: existing?.lastDiscoveredAt, @@ -4636,6 +4832,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. @@ -4999,7 +5197,7 @@ Object.assign(CodemanApp.prototype, { 'language', 'terminalWheelLocalScrollback', 'autoCopySelection', 'copyStripMargin', - 'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds', + 'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds', 'showEditHistory', 'showTabDetachButton', 'showTabNewButton', 'mobileOverviewEnabled', diff --git a/src/web/public/styles.css b/src/web/public/styles.css index cd9039c0..19631492 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -651,6 +651,13 @@ body { 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)); } @@ -7807,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 @@ -15764,6 +15817,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; @@ -15788,6 +15842,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; @@ -22586,6 +22679,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) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 0a37bc46..53985d79 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -2146,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); }, /** @@ -2318,7 +2321,7 @@ 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) => { + 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 @@ -2336,8 +2339,26 @@ Object.assign(CodemanApp.prototype, { // 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 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) { @@ -2372,7 +2393,7 @@ Object.assign(CodemanApp.prototype, { underline: true, }, activate(_event, text) { - activateFilePath(text, folder); + return activateFilePath(text, folder); }, hover() { setHovered(true); diff --git a/src/web/public/whats-new.json b/src/web/public/whats-new.json index f90c00aa..fff23513 100644 --- a/src/web/public/whats-new.json +++ b/src/web/public/whats-new.json @@ -1,12 +1,13 @@ { - "version": "1.47.0", + "version": "1.48.0", "highlights": [ - "The wrapped tab strip uses the room under the toolbar (#721)", - "Bodyless requests work through Cloudflare Tunnel (#725)", - "A desktop dead key is typed once, and Ctrl+V stays a paste after a replay (#726, #716)", - "Remote sessions no longer touch the local disk at their path (#722, #727, #728)", - "Rendered Markdown fills a resized File Viewer (#730)", - "A middle click on a file path opens the file (#731)", - "No more duplicate-key warning on build (#729)" + "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/routes/case-routes.ts b/src/web/routes/case-routes.ts index b0849767..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 { @@ -150,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 @@ -661,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`). @@ -686,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); @@ -705,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 @@ -715,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) @@ -724,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 } : {}), @@ -794,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, diff --git a/src/web/routes/config-backup-routes.ts b/src/web/routes/config-backup-routes.ts index a59d852d..1eadcfbb 100644 --- a/src/web/routes/config-backup-routes.ts +++ b/src/web/routes/config-backup-routes.ts @@ -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 995a15c0..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, @@ -1448,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/index.ts b/src/web/routes/index.ts index ed887306..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, diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 0eb80b82..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'; @@ -70,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, @@ -126,7 +127,7 @@ 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'; @@ -230,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 @@ -1541,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 @@ -1607,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( { @@ -1683,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); @@ -2874,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 @@ -2902,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 { @@ -2921,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). @@ -2934,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, }; } @@ -2960,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) { @@ -2985,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, @@ -2996,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 { @@ -4447,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 @@ -4514,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; diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index aa3515ad..4623d75f 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -856,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 }; }); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index beffb6ec..7a198e28 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(), @@ -786,6 +816,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. */ @@ -2216,6 +2252,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 */ @@ -2354,6 +2421,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 @@ -2377,11 +2452,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(), }); /** 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 ddfc296d..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 { @@ -130,6 +137,9 @@ 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, @@ -215,6 +225,7 @@ import { registerRebootRestoreRoutes, registerReadMyMindRoutes, registerGitStatusRoutes, + registerEditHistoryRoutes, registerStatusTelemetryRoutes, registerSystemRoutes, registerCaseRoutes, @@ -238,6 +249,7 @@ import { registerConfigBackupRoutes, runConfigBackup, registerWebhookRoutes, + registerDiscordRoutes, registerDoctorRoutes, registerCustomModelRoutes, refreshAllCustomModelHosts, @@ -343,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(); @@ -506,6 +536,27 @@ 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; @@ -873,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), @@ -1290,6 +1342,7 @@ 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 @@ -1312,6 +1365,8 @@ 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); @@ -1324,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); @@ -1454,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); @@ -1465,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 @@ -1752,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); } } @@ -1767,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'); @@ -2590,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. @@ -2699,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); } /** @@ -2812,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(); @@ -2934,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; @@ -3015,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}]` : ''; @@ -3156,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 @@ -3604,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 }); @@ -3740,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 }); @@ -3906,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 @@ -4312,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(); @@ -4442,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) diff --git a/test/agent-inbox-server-wiring.test.ts b/test/agent-inbox-server-wiring.test.ts index a0987815..1b8e5a84 100644 --- a/test/agent-inbox-server-wiring.test.ts +++ b/test/agent-inbox-server-wiring.test.ts @@ -49,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/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 256a7f04..1c650852 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. diff --git a/test/cli-registry-spawn-golden.test.ts b/test/cli-registry-spawn-golden.test.ts index 6c51b5b7..569aab69 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/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/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/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/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/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/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/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/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/mocks/mock-route-context.ts b/test/mocks/mock-route-context.ts index 9f75130a..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(), diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts index 3be966a2..cd04cebf 100644 --- a/test/mocks/mock-session.ts +++ b/test/mocks/mock-session.ts @@ -98,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 { @@ -454,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/response-viewer-file-links.test.ts b/test/response-viewer-file-links.test.ts index 0eff0b35..723d233b 100644 --- a/test/response-viewer-file-links.test.ts +++ b/test/response-viewer-file-links.test.ts @@ -78,6 +78,15 @@ 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']], 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/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/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/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.test.ts b/test/routes/session-routes.test.ts index 39940a96..30d9c0cd 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -1011,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 () => { diff --git a/test/routes/system-routes.test.ts b/test/routes/system-routes.test.ts index 7b402fcd..40cad281 100644 --- a/test/routes/system-routes.test.ts +++ b/test/routes/system-routes.test.ts @@ -420,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/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/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-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<string, unknown>; + +/** 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<string, string>(); + const elements: Record<string, any> = { + 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<string, string>): void }; + function translator(language: string) { + const dom = new JSDOM('<!doctype html><html><body></body></html>', { + 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 index ead0aaf9..335419dc 100644 --- a/test/spaced-file-path-links.test.ts +++ b/test/spaced-file-path-links.test.ts @@ -28,6 +28,32 @@ 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']); @@ -426,4 +452,28 @@ describe('terminal link provider with spaced paths', () => { 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-strip-corner.browser.test.ts b/test/tab-strip-corner.browser.test.ts index ba3065aa..e70bc4a3 100644 --- a/test/tab-strip-corner.browser.test.ts +++ b/test/tab-strip-corner.browser.test.ts @@ -133,13 +133,128 @@ describe('L-shaped header tab strip (real Chromium)', () => { await p.close(); }); - it('keeps the plain wrap when the first tab is too wide for the room beside the toolbar', async () => { + /** 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<HTMLElement>('#sessionTabs > .session-tab')[n - 1]; + const name = tab.querySelector<HTMLElement>('.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', '<span class="tab-gear">⚙</span><span class="tab-close">×</span>'); + } + }); + await relayout(); + await p.evaluate(() => { + const strip = document.getElementById('sessionTabs')!; + const room = parseFloat(strip.style.getPropertyValue('--tab-corner-first-max')); + const tab = strip.querySelector<HTMLElement>(':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')!.getBoundingClientRect(); - const first = document.querySelector<HTMLElement>('#sessionTabs > .session-tab')!; - first.style.minWidth = first.style.width = `${Math.round(head.width - right.width / 2)}px`; + const right = document.getElementById('headerRight')!; + right.style.minWidth = `${Math.round(head.width - 150)}px`; }); await relayout(); expect(await cornered()).toBe(false); 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/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: '<div id="terminal"></div>', + }) + ); + 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<void>((done) => terminal.write(text, done)); + await new Promise<void>((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<any[]>((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(); + } + } + ); +});