Compare commits

..
Author SHA1 Message Date
Codeman maintainer 87c656283d feat(codex): let Codex sessions drive other sessions through the codeman skill
Measured on 1.47.0 with the same "spawn a shell worker, echo, read, delete"
task: DeepSeek Harness and OpenCode already pass end to end; Codex 0.162
failed three ways, fixed here.

- Start Codex 0.162+ with --no-daemon (new noDaemonFlag gate, fail-closed).
  The shared app-server daemon stopped every launch on an "incompatible
  feature settings" dialog when another version's daemon was running, and
  ran commands with another session's CODEMAN_* environment.
- Evaluate registry version gates against the gated CLI's own version
  (resolveSessionCliVersion) instead of claude's for every entry.
- Add the opt-in synced setting App Settings > Codex > Allow network access
  (codexNetworkAccess, default off): --config
  sandbox_workspace_write.network_access=true, filled as a launch default
  and clamped off for non-granted owners like the bypass switch.
- Skill: carry on with the curl path when `codeman` is not on PATH, re-run a
  sandbox-blocked call once through the CLI's approval path, name the new
  setting. Wiki and registry docs updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 06:21:38 +01:00
151 changed files with 826 additions and 17890 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"aicodeman": minor
---
**Codex sessions can drive other sessions through the Codeman skill.** A new opt-in Codex setting, **Allow network access** (App Settings, Codex, off by default), starts Codex with `sandbox_workspace_write.network_access=true`, so the skill's calls to this server are no longer blocked by Codex's sandbox; the sandbox and approval prompts stay on, it applies where Codex runs the folder workspace-write, and it is forced off for users without the privileged-commands grant in multi-user mode. Codex 0.162 and later now also starts with `--no-daemon`, so each session runs its own Codex app server: a shared background server (another version's included) no longer stops a launch on an "incompatible feature settings" dialog, and commands inside Codex see their own session's `CODEMAN_*` variables. Version gates in the CLI registry are now measured against that CLI's own version instead of Claude's. The skill falls back to its `curl` path when `codeman` is not on PATH, and asks Codex to re-run a blocked call with approval instead of giving up.
+1 -1
View File
@@ -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.48.0",
"version": "1.47.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
-34
View File
@@ -1,39 +1,5 @@
# 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 "<message>" [--urgent] [--file <path>]`, 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
+9 -13
View File
@@ -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.48.0 (must match `package.json`)
**Version**: 1.47.0 (must match `package.json`)
## Project Overview
@@ -131,8 +131,6 @@ 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 `<tab folder>/.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 "<message>" [--urgent] [--file <path>]` 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,15 +181,15 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**DeepSeek web UI** (`POST`/`GET`/`DELETE /api/deepseek/web`, `deepseek-web-server.ts`): the Run menu's "DeepSeek web UI..." entry supervises ONE background `dsh web` child process, deliberately **NOT a shell session**. ⚠️ Every piece is load-bearing: exactly one server (a second click REUSES it), restarted when the browser authority changes (`--trusted-host`, last asker wins), killed on shutdown via `stopDeepSeekWeb()` (the detached child would otherwise outlive Codeman and hold its port), and failures returned to the caller. ⚠️ Never hardcode the port: search from 3080 across 40, detect free ports by BINDING, then wait for the server to really answer. ⚠️ Both `POST` and `DELETE` must stay behind `canUsernameRunPrivilegedCommands` (booting a profile runs its plugin code; the server is shared). → [architecture-invariants#deepseek-web-ui](docs/architecture-invariants.md#deepseek-web-ui)
**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 `<claude config>/projects/<old>` 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. ⚠️ Every write to `linked-cases.json` (link, unlink, rename, and a create or clone into a custom folder) goes through `mutateLinkedCases()` (`src/web/linked-cases-writer.ts`: serialized, temp file + rename, and a file that does not parse is refused, never read as `{}`), and a check that must still hold when the entry is written (the name is free) runs INSIDE its callback: two clones that claimed one name used to both pass a re-read made outside it and both answer success. Pinned by `test/linked-cases-writer.test.ts`.
**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 `<claude config>/projects/<old>` 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; 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)
**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)
⚠️ **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 `<file>.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md`
**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)
**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` 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)
**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.
@@ -228,13 +226,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 sLine truncated
**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)
**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)
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`. **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)
**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)
**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`
**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`
**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)
@@ -242,13 +240,11 @@ 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 itselLine truncated
**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). ⚠️ 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)
**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)
**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)
@@ -319,7 +315,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), `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`).
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`).
**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.
-3
View File
@@ -17,7 +17,6 @@
<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">
@@ -480,7 +479,6 @@ 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
@@ -977,7 +975,6 @@ 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
```
-1
View File
@@ -21,7 +21,6 @@
<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">
+2 -2
View File
@@ -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.48.0",
"version": "1.47.0",
"websiteUrl": "https://getcodeman.com",
"repository": {
"url": "https://github.com/Ark0N/Codeman",
@@ -13,7 +13,7 @@
{
"registryType": "npm",
"identifier": "aicodeman",
"version": "1.48.0",
"version": "1.47.0",
"transport": {
"type": "stdio"
},
-3
View File
@@ -19,7 +19,6 @@
* 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',
@@ -50,7 +49,6 @@ 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',
@@ -59,7 +57,6 @@ 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',
];
/**
+7 -80
View File
@@ -900,16 +900,6 @@ 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
@@ -979,18 +969,6 @@ 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
@@ -1227,47 +1205,24 @@ 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?, defaultContextLength? }` creates one. `id`
must match
authStyle?, defaultModelId? }` 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); `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
request); `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, also accepting
`defaultContextLength`. An **absent** `apiKey`
- `PUT /api/v1/model-endpoints/:id` updates one. 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. `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
this way. `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) `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
`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
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
@@ -1360,8 +1315,6 @@ 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.
@@ -1393,19 +1346,6 @@ 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`.
@@ -1546,19 +1486,6 @@ 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.
+9 -48
View File
@@ -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`, 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 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 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,16 +409,10 @@ 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 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`.
**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`.
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`.
@@ -435,7 +429,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. **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.
**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.
⚠️ **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.
@@ -474,13 +468,11 @@ 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`). 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 name is checked again inside `mutateLinkedCases()` when the entry is written, so of two clones that claim one name only the first to finish links it; the other answers 409 `ALREADY_EXISTS` and keeps its folder.
**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`).
- ⚠️ **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.
@@ -636,17 +628,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/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.
⚠️ 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.
⚠️ **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.
⚠️ **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.
⚠️ **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), 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.
**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.
⚠️ **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 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.
⚠️ **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.
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.
@@ -750,24 +742,6 @@ 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).
@@ -1073,7 +1047,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 (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).
**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).
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`.
@@ -1390,16 +1364,3 @@ 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`).
+5 -1
View File
@@ -57,7 +57,11 @@ interface CliEntry {
// (where this CLI's own chrome names the model it runs: SessionState.displayModel)
// .launchDefaults?: { [launchParam]: settingsKey }
// (synced App Settings that seed a LOCAL launch's params the caller left unset;
// codex's model and reasoning effort, via src/web/launch-defaults.ts)
// codex's model, reasoning effort and network access, via src/web/launch-defaults.ts)
// .gates: { [name]: { minVersion, failClosed } }
// (version gates a `capabilityGate` launch condition names: claude's `--name`,
// codex's `--no-daemon`. Measured against THAT CLI's own version, never claude's:
// resolveSessionCliVersion, so an unknown version passes no fail-closed gate)
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
}
```
+3 -5
View File
@@ -8,10 +8,8 @@ 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 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).
closest existing precedent is DeepSeek's server-env-sourced
`DEEPSEEK_BASE_URL`, which isn't user-facing.
**Scope note**: this plan originally said "local LLM." It now covers any
OpenAI-compatible endpoint the user configures — local (llama.cpp, Ollama,
@@ -114,7 +112,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` | 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` |
| `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 |
| `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 |
+36 -122
View File
@@ -67,23 +67,15 @@ 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.** 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
**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
`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 launch-command/`/props`
enrichment, only whatever its listings state, rather than guessing. A
model's previously-learned context length survives a
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
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
@@ -200,66 +192,32 @@ 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. Three kinds of knowledge feed that promotion, best
evidence first, and the first one that answers wins:
under your thumb:
- **"Currently loaded"** is a model from this host's own list that
- **"Currently loaded"** — 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.
- **"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
- **"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
`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.
No promotion reorders anything past that one promoted row. The "Default"
Neither tag 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
@@ -328,15 +286,6 @@ 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.
@@ -344,16 +293,14 @@ 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 context length:
the discovered figure (see the discovery section above), falling back to
the endpoint's user-set `defaultContextLength`, whenever one is known.
Without
- `CLAUDE_CODE_MAX_CONTEXT_TOKENS` is set to `modelId`'s discovered context
length (see the discovery section above) 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`. 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
size`. No entry for the model in `modelContextLengths` 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
@@ -494,17 +441,15 @@ 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 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
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
~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 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).
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).
The Run-menu picker shows this as an in-app modal
(`#customModelContextWarningModal`, matching the llama-swap conflict
@@ -581,12 +526,7 @@ 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. ⚠️ 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.
something to build into discovery.
- **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
@@ -601,40 +541,14 @@ 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. 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.
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.
- **Antigravity** — no known custom-endpoint mechanism at all; unsupported.
See the confidence table in `custom-model-endpoints-plan.md` for the full detail behind
-137
View File
@@ -1,137 +0,0 @@
# 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 |
+1 -1
View File
@@ -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. 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.
**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.
-1
View File
@@ -582,7 +582,6 @@ 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.
---
+14 -4
View File
@@ -140,12 +140,22 @@ message a worker directly instead of typing into its terminal.
Two things to know:
- Codex's default sandbox blocks network access, even to this machine, so the skill needs
**Bypass approvals and sandbox** (App Settings, Codex).
- Codex's default sandbox blocks network access, even to this machine, so turn on
**Allow network access** (App Settings, Codex) for a Codex session that should start and
drive others. It applies to Codex sessions started after you switch it on, and it keeps
Codex's sandbox and approval prompts. It opens the network only where Codex runs the
folder in its workspace-write sandbox, which is what Codex does for a project you
trusted. In a folder Codex runs read-only (a new case that is not a git repository, for
one), Codex asks you to approve each call to Codeman instead; `/permissions` in Codex
switches that session to workspace-write. **Bypass approvals and sandbox** works
everywhere, but removes the sandbox and the approval prompts.
- Codex 0.162 runs its commands in a shared background process, whose Codeman variables can
belong to the session that started it.
belong to the session that started it. Codeman starts Codex 0.162 and later with
`--no-daemon`, so each session runs its own; with an older Codex the skill notices and
says so instead of acting.
The skill checks for both and says so instead of acting.
OpenCode and DeepSeek Harness need nothing extra: in their default setup neither keeps its
shell commands off the network, so the skill works there as soon as it is injected.
## What is in the skill
+3 -4
View File
@@ -15,7 +15,6 @@ 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. |
@@ -189,15 +188,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), `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 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 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`, `discord.json`). Each backup folder is mode `0700` and its files `0600`, so the default
`push-keys.json`, `webhook.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.
+5 -5
View File
@@ -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>` (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. |
| 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. |
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.
+15 -26
View File
@@ -27,20 +27,14 @@ 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.** 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.
**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.
## Running a session against one
@@ -54,20 +48,15 @@ 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; 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
small dialog asks which one to use for this launch before starting the session; 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**. 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.
**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.
**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
@@ -197,7 +186,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** | 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`. |
| **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. |
| **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,
-105
View File
@@ -1,105 +0,0 @@
# 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.
-1
View File
@@ -79,7 +79,6 @@ 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? |
-6
View File
@@ -13,7 +13,6 @@ 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 |
@@ -67,11 +66,6 @@ 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.**
+4 -10
View File
@@ -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, Edit history, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor,
Manager, Attachments, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor,
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
Ultracode Windows, Cron.
@@ -106,12 +106,6 @@ 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.
@@ -208,6 +202,7 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
| Default Codex model | Model for new local Codex sessions; empty uses Codex's own config. Letters, digits, `.` `_` `-` `/` only. |
| Default Codex reasoning effort | Reasoning level for new local Codex sessions; empty uses Codex's own config. |
| Bypass approvals and sandbox | Starts new Codex sessions with `--dangerously-bypass-approvals-and-sandbox`. Read [Agent CLIs](Agent-CLIs) before enabling. |
| Allow network access | Starts new Codex sessions with `sandbox_workspace_write.network_access=true`, so commands in Codex's workspace-write sandbox can use the network. The [Codeman Skill](Codeman-Skill) needs it to reach this server from a Codex session. It applies where Codex runs the folder in workspace-write (a project you trusted in Codex); in a folder Codex runs read-only, Codex asks for approval per command instead. The sandbox and approval prompts stay on. Synced, off by default; forced off for users without the privileged-commands grant in multi-user mode. |
| Animated status effects | Cosmetic. |
| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) and GitHub Copilot CLI has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and **Apply** or **Save** (Apply keeps Settings open), then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME` or `COPILOT_HOME` in Codeman's own environment is followed. |
@@ -219,9 +214,8 @@ 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), 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).
webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See
[Notifications And Approvals](Notifications-And-Approvals).
### Voice
+1 -63
View File
@@ -87,10 +87,7 @@ 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 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 terminal**, on any absolute path an agent prints.
- **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.
@@ -171,11 +168,6 @@ 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
@@ -280,60 +272,6 @@ 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
-1
View File
@@ -25,7 +25,6 @@
- [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)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.48.0",
"version": "1.47.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.48.0",
"version": "1.47.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.48.0",
"version": "1.47.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",
+1 -1
View File
@@ -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.48.0",
"version": "1.47.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+16 -4
View File
@@ -60,6 +60,10 @@ land in `~/.claude`; pass `--working-dir ~` to mean it). Directory launches keep
mark the project as agent scratch. If startup fails or is ambiguous, the CLI
reports the exact created ID: inspect it before retrying or cleaning up.
**`codeman: command not found` is not a dead end.** Some installs (a server run from a
source checkout, for one) put no `codeman` on PATH. Do not stop there: §0 and §1 below
are the same verbs over `curl`, and need nothing but `curl` and `jq`.
## 0. Guard and bootstrap
For live API operations, if `CODEMAN_MUX` is not `1`, **stop and say so**.
@@ -68,13 +72,21 @@ Do not guess an API URL; a server you are not part of is not yours to drive.
Two failures come from your CLI, not from Codeman: name them to the user instead of
retrying.
- **Every call fails at once with "couldn't connect" or "connection refused".** Your
CLI's sandbox blocks network access, even to this machine. Codex's default sandbox
does; it needs **Bypass approvals and sandbox** (Codeman App Settings, Codex).
- **Every call fails at once with "couldn't connect", "connection refused" or `connect
EPERM`.** Your CLI's sandbox blocks network access, even to this machine. If your CLI
can ask the user to run a command outside its sandbox (Codex can), re-run **the same
command** once that way, never a different one; if the user declines, stop. For lasting
access in Codex, the user turns on **Allow network access** (Codeman App Settings,
Codex) and starts a new Codex session: it opens the network wherever Codex runs the
folder workspace-write, and keeps the sandbox and approvals. A folder Codex runs
read-only still needs the per-command approval (or `/permissions`). **Bypass approvals
and sandbox** works everywhere but drops the sandbox and approvals.
- **`"${CURL[@]}" -o /dev/null -w '%{http_code}' "$API/api/v1/sessions/$SELF"` prints
`404`.** The commands you run do not carry this session's environment: Codex can run them in a shared background
process another session started, so `$CODEMAN_SESSION_ID` (and with it `$SELF` and
the delete guard) is that session's. Delete nothing; tell the user.
the delete guard) is that session's. Delete nothing; tell the user. Codeman starts
Codex 0.162 and later with `--no-daemon` to prevent this, so seeing it means an older
Codex or a server that predates that.
⚠️ **Your shell state does not survive between tool calls.** Each Bash call starts a
fresh shell, so `$API`, `$SELF`, the `CURL` array and `delete_session` are all gone by
+16 -4
View File
@@ -60,6 +60,10 @@ land in `~/.claude`; pass `--working-dir ~` to mean it). Directory launches keep
mark the project as agent scratch. If startup fails or is ambiguous, the CLI
reports the exact created ID: inspect it before retrying or cleaning up.
**`codeman: command not found` is not a dead end.** Some installs (a server run from a
source checkout, for one) put no `codeman` on PATH. Do not stop there: §0 and §1 below
are the same verbs over `curl`, and need nothing but `curl` and `jq`.
## 0. Guard and bootstrap
For live API operations, if `CODEMAN_MUX` is not `1`, **stop and say so**.
@@ -68,13 +72,21 @@ Do not guess an API URL; a server you are not part of is not yours to drive.
Two failures come from your CLI, not from Codeman: name them to the user instead of
retrying.
- **Every call fails at once with "couldn't connect" or "connection refused".** Your
CLI's sandbox blocks network access, even to this machine. Codex's default sandbox
does; it needs **Bypass approvals and sandbox** (Codeman App Settings, Codex).
- **Every call fails at once with "couldn't connect", "connection refused" or `connect
EPERM`.** Your CLI's sandbox blocks network access, even to this machine. If your CLI
can ask the user to run a command outside its sandbox (Codex can), re-run **the same
command** once that way, never a different one; if the user declines, stop. For lasting
access in Codex, the user turns on **Allow network access** (Codeman App Settings,
Codex) and starts a new Codex session: it opens the network wherever Codex runs the
folder workspace-write, and keeps the sandbox and approvals. A folder Codex runs
read-only still needs the per-command approval (or `/permissions`). **Bypass approvals
and sandbox** works everywhere but drops the sandbox and approvals.
- **`"${CURL[@]}" -o /dev/null -w '%{http_code}' "$API/api/v1/sessions/$SELF"` prints
`404`.** The commands you run do not carry this session's environment: Codex can run them in a shared background
process another session started, so `$CODEMAN_SESSION_ID` (and with it `$SELF` and
the delete guard) is that session's. Delete nothing; tell the user.
the delete guard) is that session's. Delete nothing; tell the user. Codeman starts
Codex 0.162 and later with `--no-daemon` to prevent this, so seeing it means an older
Codex or a server that predates that.
⚠️ **Your shell state does not survive between tool calls.** Each Bash call starts a
fresh shell, so `$API`, `$SELF`, the `CURL` array and `delete_session` are all gone by
-129
View File
@@ -1,129 +0,0 @@
/**
* @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})` : ''}` };
}
-17
View File
@@ -16,7 +16,6 @@ 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 {
@@ -93,22 +92,6 @@ 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')
-2
View File
@@ -53,7 +53,6 @@ export const CONFIG_BACKUP_FILES = [
'clis.json',
'custom-model-hosts.json',
'webhook.json',
'discord.json',
'intents.json',
'linked-cases.json',
'remote-hosts.json',
@@ -795,7 +794,6 @@ 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. */
-5
View File
@@ -21,7 +21,6 @@ export type TokenPattern =
| 'model'
| 'model-claude'
| 'model-plain'
| 'model-plain-colon'
| 'model-pi'
| 'id'
| 'id-dotted'
@@ -48,10 +47,6 @@ 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
+7 -64
View File
@@ -80,7 +80,6 @@ const paramSpecSchema = z.union([
'muxName',
'effortLevel',
'effortSettingsJson',
'appendSystemPrompt',
'codemanPrefixedSessionId',
'launcherDefaultTarget',
]),
@@ -225,7 +224,6 @@ const envExportSchema = z
'muxName',
'effortLevel',
'effortSettingsJson',
'appendSystemPrompt',
'codemanPrefixedSessionId',
'launcherDefaultTarget',
]),
@@ -288,14 +286,6 @@ 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(),
@@ -490,7 +480,11 @@ const capabilitiesSchema = z
launchDefaults: z
.record(
z.string(),
z.enum(['codexModel', 'codexReasoningEffort'] as const satisfies readonly LaunchDefaultSettingKey[])
z.enum([
'codexModel',
'codexReasoningEffort',
'codexNetworkAccess',
] as const satisfies readonly LaunchDefaultSettingKey[])
)
.refine((v) => Object.keys(v).length >= 1 && Object.keys(v).length <= 8, 'launchDefaults takes 1 to 8 params')
.optional(),
@@ -590,61 +584,10 @@ 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', 'deepseek-patch-json']),
template: z.enum(['codex-toml', 'pi-models-json', 'omp-models-yml', 'grok-toml']),
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()
.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'],
});
}
}
}),
.strict(),
z.object({ kind: z.literal('unsupported') }).strict(),
]),
})
+74 -45
View File
@@ -152,7 +152,6 @@ 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' },
},
@@ -180,12 +179,6 @@ 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' },
},
],
},
{
@@ -210,12 +203,6 @@ 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' },
},
],
},
],
@@ -640,11 +627,9 @@ const CODEX: CliEntry = {
launch: {
params: {
bypassApprovals: { type: 'bool' },
networkAccess: { type: 'bool' },
animations: { type: 'bool' },
// `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' },
model: { type: 'token', pattern: 'model' },
reasoningEffort: { type: 'enum', values: [...CODEX_REASONING_EFFORTS] },
resumeId: { type: 'token', pattern: 'id' },
},
@@ -653,7 +638,33 @@ const CODEX: CliEntry = {
id: 'default',
args: [
{ lit: 'codex' },
// Codex 0.162 hands every client's tool commands to ONE shared background
// app-server (`codex app-server --managed-daemon`), started by whichever client
// came first. Two things break inside a Codeman pane, both measured 2026-11-01 on
// 0.162.0: the commands run with the STARTER's environment, so
// `$CODEMAN_SESSION_ID`/`$CODEMAN_API_URL` belong to another session (the codeman
// skill's self-check 404s and its delete guard protects the wrong id), and a daemon
// of another version (a 0.166 one left by a later install) stops every launch on an
// "incompatible feature settings" dialog before the composer appears. `--no-daemon`
// runs this session's own in-process server, which is what a pane per session
// means anyway. Gated at the version it was measured on; an older or unknown
// codex gets nothing (fail-closed), since an unknown flag makes codex exit.
{ flag: '--no-daemon', when: { capabilityGate: 'noDaemonFlag' } },
{ flag: '--dangerously-bypass-approvals-and-sandbox', when: { param: 'bypassApprovals', is: true } },
// Lets commands in codex's workspace-write sandbox open network connections,
// Codeman's own API on loopback included (the codeman skill's curl calls fail
// with "couldn't connect" without it). Narrower than the bypass above: the
// sandbox and the approval prompts stay. Measured on 0.162.0 (`codex sandbox` and
// a live pane): loopback stays blocked under workspace-write until this is set.
// ⚠️ Workspace-write ONLY: a folder codex runs read-only (one it does not trust,
// such as a fresh non-git case) keeps the network closed and asks per command.
// Deliberately not paired with `--sandbox workspace-write`, which would also
// grant write access to a folder codex itself chose not to trust.
{
flag: '--config',
value: 'sandbox_workspace_write.network_access=true',
when: { param: 'networkAccess', is: true },
},
{ flag: '--config', value: 'tui.animations=true', when: { param: 'animations', is: true } },
{ flag: '--config', value: 'tui.animations=false', when: { param: 'animations', is: false } },
{ flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } },
@@ -819,7 +830,14 @@ const CODEX: CliEntry = {
// App Settings → Codex model / reasoning effort (synced), filled into a LOCAL launch's
// codexConfig wherever the caller left the field unset. Launch-only: nothing writes
// codex's own config.toml. Read by applyLaunchDefaults() in src/web/launch-defaults.ts.
launchDefaults: { model: 'codexModel', reasoningEffort: 'codexReasoningEffort' },
// `networkAccess` (App Settings → Codex → Allow network access) rides the same path, so
// a codex session started by the codeman skill, `codeman agent spawn` or the MCP server
// gets it as well as one started from the browser. Off (the default) fills nothing.
launchDefaults: {
model: 'codexModel',
reasoningEffort: 'codexReasoningEffort',
networkAccess: 'codexNetworkAccess',
},
// Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠`
// markers sit in the gutter, prose continuations sit at 2, and a nested YAML block
// the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120,
@@ -844,7 +862,19 @@ const CODEX: CliEntry = {
// does. codex is the entry where the two names differ (`bypassApprovals` here,
// `dangerouslyBypassApprovals` on the wire), so it is the one that would have caught a
// regression; `schema.ts` now rejects a name that is not a declared param.
privilegedParams: [{ param: 'bypassApprovals', clampTo: false }],
//
// `networkAccess` widens the sandbox too (any host, not just Codeman's loopback API), so
// a non-granted owner gets it forced off like the bypass. Only-if-sent is enough here as
// well: an absent value leaves codex's own no-network default, and the launch default
// above is filled BEFORE the clamp runs, so a filled `true` is still clamped.
privilegedParams: [
{ param: 'bypassApprovals', clampTo: false },
{ param: 'networkAccess', clampTo: false },
],
// `--no-daemon` above. Evaluated against codex's OWN version (resolveSessionCliVersion).
gates: {
noDaemonFlag: { minVersion: '0.162.0', failClosed: true },
},
mcpConfig: {
path: '.codex/config.toml',
format: 'codex-toml',
@@ -857,7 +887,6 @@ 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
@@ -1562,30 +1591,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. 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.
// (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.
customModelInjection: {
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',
kind: 'env',
baseUrlVar: 'DEEPSEEK_BASE_URL',
apiKeyVar: 'DEEPSEEK_API_KEY',
modelVars: [],
appendV1Suffix: true,
},
},
overlays: {
@@ -1938,8 +1967,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 via appendV1Suffix below. 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
// the same way deepseek's does. 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: {
+18 -48
View File
@@ -29,8 +29,6 @@ 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'
/**
@@ -112,7 +110,7 @@ export type ModelConfigResolverName = 'deepseek-route';
* arbitrary setting onto a command line; each name must also be a `SettingsUpdateSchema`
* key, which the resolver's typing enforces.
*/
export type LaunchDefaultSettingKey = 'codexModel' | 'codexReasoningEffort';
export type LaunchDefaultSettingKey = 'codexModel' | 'codexReasoningEffort' | 'codexNetworkAccess';
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
export type McpConfigFormat =
@@ -814,11 +812,9 @@ 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), 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.
* 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.
*
* `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,
@@ -860,13 +856,19 @@ 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. 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.
* 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.
*/
customModelInjection:
| {
@@ -886,40 +888,8 @@ export interface CliCapabilities {
kind: 'configDir';
dirEnvVar: string;
fileName: string;
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml' | 'deepseek-patch-json';
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml';
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' };
}
-7
View File
@@ -46,10 +46,3 @@ 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;
+8 -35
View File
@@ -52,22 +52,18 @@ export interface CustomModelHost {
defaultModelId?: string;
/**
* Discovered context-window size (tokens) per model id, keyed by the same strings as
* `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.
* `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.
*/
modelContextLengths?: Record<string, number>;
/**
* Discovered number of parallel server slots per model id, keyed like `modelContextLengths`
* 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
* and populated on the same loaded-models-only basis. 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
@@ -92,29 +88,6 @@ 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 {
+8 -88
View File
@@ -10,9 +10,9 @@
* cli-registry changes" requirement it was written against.
*/
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs';
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs';
import { homedir, platform } from 'node:os';
import { join, dirname, isAbsolute } from 'node:path';
import { join, dirname } from 'node:path';
import { dataPath } from './config/instance.js';
import type { CliEntry } from './config/cli-registry/types.js';
import {
@@ -30,99 +30,25 @@ 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). 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.
* name). Never touches anything outside `baseDir` — the caller is
* responsible for choosing an isolated directory (never the user's real
* `~/.codex`, `~/.pi`, etc.).
*
* 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,
/**
* 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> {
export function applyConfigDirInjection(baseDir: string, injection: ConfigDirInjection): 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()`
@@ -324,13 +250,7 @@ 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,
/**
* 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
workingDir?: string
): AppliedCustomModel | undefined {
const injection = buildCustomModelInjection(entry, endpoint, modelId, contextLength);
if (injection.kind === 'unsupported') return undefined;
@@ -363,6 +283,6 @@ export function applyCustomModelInjection(
};
}
const configDir = customModelConfigDir(sessionId);
const envOverrides = applyConfigDirInjection(configDir, injection, sessionEnvLookup?.(injection.dirEnvVar));
const envOverrides = applyConfigDirInjection(configDir, injection);
return { envOverrides, envKeys: Object.keys(envOverrides), configDir, launchModel: injection.launchModel };
}
+11 -104
View File
@@ -87,12 +87,6 @@ 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 {
@@ -142,22 +136,14 @@ export function buildCustomModelInjection(
}
case 'configContentEnv': {
const content = renderConfigContent(cap.template, endpoint, modelId, apiKey, contextLength);
const content = renderConfigContent(cap.template, endpoint, modelId, apiKey);
return withLaunchModel({ kind: 'env', envOverrides: { [cap.envVar]: content } }, cap.launchModel, modelId);
}
case 'configDir': {
const { content, extraEnv } = renderConfigFile(cap.template, endpoint, modelId, apiKey, contextLength);
const { content, extraEnv } = renderConfigFile(cap.template, endpoint, modelId, apiKey);
return withLaunchModel(
{
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 } : {}),
},
{ kind: 'configDir', dirEnvVar: cap.dirEnvVar, files: [{ relPath: cap.fileName, content }], extraEnv },
cap.launchModel,
modelId
);
@@ -178,114 +164,41 @@ 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,
contextLength?: number
apiKey: string
): string {
switch (template) {
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);
case 'opencode-json':
return JSON.stringify({
$schema: 'https://opencode.ai/config.json',
provider: {
custom: {
options: { baseURL: withV1Suffix(endpoint.baseUrl), apiKey },
models: { [modelId]: limit ? { limit } : {} },
models: { [modelId]: {} },
},
},
model: `custom/${modelId}`,
});
}
}
}
/** 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';
const CODEX_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' | 'deepseek-patch-json',
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml',
endpoint: CustomModelEndpoint,
modelId: string,
apiKey: string,
contextLength?: number
apiKey: string
): { 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
@@ -304,21 +217,15 @@ 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(CUSTOM_MODEL_API_KEY_ENV_VAR)}`,
`env_key = ${quoted(CODEX_API_KEY_ENV_VAR)}`,
`wire_api = "responses"`,
'',
].join('\n');
return { content, extraEnv: { [CUSTOM_MODEL_API_KEY_ENV_VAR]: apiKey } };
return { content, extraEnv: { [CODEX_API_KEY_ENV_VAR]: apiKey } };
}
case 'pi-models-json':
// Verified against pi's OWN bundled docs (models.md): `models` is an ARRAY of
File diff suppressed because it is too large. Load diff
-242
View File
@@ -1,242 +0,0 @@
/**
* @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;
}
-352
View File
@@ -1,352 +0,0 @@
/**
* @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()] };
}
-678
View File
@@ -1,678 +0,0 @@
/**
* @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?.();
}
}
+15 -90
View File
@@ -50,10 +50,9 @@
import { spawn, execFileSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { existsSync } from 'node:fs';
import { lstat, readdir, rename, rm } from 'node:fs/promises';
import { 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 ────────────────────────────────────────────────────────────────
@@ -189,13 +188,8 @@ export interface GitFailure {
export interface CloneOptions {
/** Pre-validated operand from `parseGitRepositoryUrl`. */
repository: string;
/** Absolute destination directory. Must NOT exist (created by git), unless `intoEmptyDir` is set. */
/** Absolute destination directory. Must NOT exist; created by git. */
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. */
@@ -596,11 +590,6 @@ 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',
@@ -636,13 +625,12 @@ export function classifyGitFailure(stderr: string, timedOut: boolean, spawnError
stderr: clean,
};
}
if (
/could not resolve host|connection refused|connection timed out|network is unreachable|\bssl(?:\b|_)|\btls\b|certificate|handshake/.test(
lower
)
) {
if (/could not resolve host|connection refused|connection timed out|network is unreachable|ssl|tls/.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 };
}
@@ -713,8 +701,6 @@ 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;
}
@@ -734,17 +720,10 @@ 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',
started: false,
};
return { stdout: '', stderr: '', code: null, timedOut: false, spawnError: 'EBUSY: git operation queue is full' };
}
if (slot === 'timed-out') {
return { stdout: '', stderr: '', code: null, timedOut: true, started: false };
return { stdout: '', stderr: '', code: null, timedOut: true };
}
const remainingMs = Math.max(1, timeoutMs - (Date.now() - queuedAt));
try {
@@ -757,7 +736,7 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number)
detached: true,
});
} catch (err) {
resolve({ stdout: '', stderr: '', code: null, timedOut: false, started: false, spawnError: String(err) });
resolve({ stdout: '', stderr: '', code: null, timedOut: false, spawnError: String(err) });
return;
}
@@ -804,10 +783,8 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number)
resolve(result);
};
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 }));
child.on('error', (err) => finish({ stdout, stderr, code: null, timedOut, spawnError: String(err) }));
child.on('close', (code) => finish({ stdout, stderr, code, timedOut }));
});
} finally {
releaseGitSlot();
@@ -870,35 +847,6 @@ 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`.
*
@@ -924,33 +872,10 @@ export async function cloneRepository(opts: CloneOptions): Promise<CloneResult>
};
}
if (existsSync(opts.destination)) {
// 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);
}
return {
ok: false,
failure: { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: '' },
};
}
// Sibling of the destination (same filesystem), so the rename is atomic.
+4 -4
View File
@@ -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 { DISCORD_RECEIVED_DIR, UPLOAD_DIR_NAMES } from './web/paste-image-gc.js';
import { 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, by upload or
// from Discord, is not a file the agent produced.
// own upload folders: a pdf the user handed to the agent is not a file
// the agent produced.
if (
path.includes('/node_modules/') ||
path.includes('/.git/') ||
path.includes('/dist/') ||
path.includes('/.next/') ||
[...UPLOAD_DIR_NAMES, DISCORD_RECEIVED_DIR].some((name) => path.includes(`/${name}/`))
UPLOAD_DIR_NAMES.some((name) => path.includes(`/${name}/`))
) {
return true;
}
+1 -11
View File
@@ -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()` (and `getPaneCurrentPath()`) per session
* - `paneExitsUpdated` () - A pane read finished; ask `getPaneExit()` per session
*/
export interface TerminalMultiplexer extends EventEmitter {
/** Which backend this instance uses */
@@ -396,16 +396,6 @@ 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
+20 -17
View File
@@ -17,13 +17,12 @@
* @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';
import { buildAdvisorSettings, buildEffortCliArgs, sanitizeCliSessionName } from './session-cli-builder.js';
import { compareVersions } from './utils/dependency-checker.js';
import { getClaudeCliVersion } from './utils/claude-cli-resolver.js';
import { resolveSessionCliVersion } from './utils/cli-resolver.js';
import { launcherDefaultTarget } from './utils/cli-launcher.js';
import { getCli } from './config/cli-registry/registry.js';
import type {
@@ -184,9 +183,19 @@ function resolveGatesPassed(entry: CliEntry, resolveVersion: () => string | null
export function launchGatePasses(mode: string, gate: string, claudeCliVersion?: string | null): boolean {
const entry = getCli(mode);
if (!entry?.capabilities.gates[gate]) return false;
return resolveGatesPassed(entry, () =>
claudeCliVersion !== undefined ? claudeCliVersion : getClaudeCliVersion()
).has(gate);
return resolveGatesPassed(entry, () => gateVersion(entry, claudeCliVersion)).has(gate);
}
/**
* The version a gate of `entry` is measured against: the caller's explicit value (tests, or
* null for "known unknown"), else THIS entry's own local CLI via resolveSessionCliVersion.
* ⚠️ Never `getClaudeCliVersion()` for every entry, which is what this used to do: codex's
* `noDaemonFlag` gate (0.162.0) would then have passed on any claude 2.x and handed an older
* codex a flag it exits on. resolveSessionCliVersion still routes claude to its own probe
* policy (`retryOnTransientFailure`), so a claude launch is unchanged.
*/
function gateVersion(entry: CliEntry, explicit: string | null | undefined): string | null {
return explicit !== undefined ? explicit : resolveSessionCliVersion(entry.id);
}
/**
@@ -249,18 +258,12 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri
engineValues.effortSettingsJson = JSON.stringify(settingsObj);
}
// The Discord bridge's startup hint: undefined (so the flag is omitted) unless the bridge and its
// `startupHint` setting are on. Claude-only in practice, like the settings object above.
engineValues.appendSystemPrompt = getDiscordStartupHint();
// Preserves buildSpawnCommand's original fallback exactly: an EXPLICIT `undefined` probes
// the local claude CLI (getClaudeCliVersion, null under vitest); an explicit `null` means
// "known to be unresolvable" and must not probe. The probe only ever runs from
// resolveGatesPassed, and only for an entry that actually declares a gate, so this stays
// generic without spawning a stray `claude --version` for every other CLI's launch.
const gatesPassed = resolveGatesPassed(entry, () =>
options.claudeCliVersion !== undefined ? options.claudeCliVersion : getClaudeCliVersion()
);
// 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"
// and must not probe. The probe only ever runs from resolveGatesPassed, and only for an
// entry that actually declares a gate, so a CLI with none never spawns a `--version`.
const gatesPassed = resolveGatesPassed(entry, () => gateVersion(entry, options.claudeCliVersion));
return renderLaunch(entry.launch, params, engineValues, gatesPassed);
}
+4 -146
View File
@@ -30,7 +30,6 @@
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';
@@ -72,8 +71,6 @@ 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';
@@ -401,21 +398,6 @@ 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
@@ -761,21 +743,6 @@ 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
@@ -1363,17 +1330,6 @@ 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;
@@ -1529,99 +1485,6 @@ 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.
@@ -2299,7 +2162,6 @@ 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,
@@ -2830,7 +2692,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) {
@@ -2852,11 +2714,8 @@ export class Session extends EventEmitter {
return options;
}
/**
* 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 {
/** The session's Claude config dir when it has been relocated (#255), else undefined. */
private _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.
@@ -2865,8 +2724,7 @@ export class Session extends EventEmitter {
/**
* Force the custom-model selection's `launchModel` (pi/omp `custom/<id>`, grok's
* `[model.<name>]` block name, codex's bare selected id) onto the CLI's `model`
* launch param. Where that param
* `[model.<name>]` block name) 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
+3 -67
View File
@@ -422,46 +422,6 @@ 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.
@@ -1203,8 +1163,9 @@ export function buildSpawnCommand(options: {
/** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). By default only a user-chosen name; `pinCliSessionName` pins every tab name: see `Session.cliPinnedName`. */
sessionName?: string;
/**
* Claude CLI version for the `--name` gate. Omitted = probe the local CLI
* (getClaudeCliVersion; null under vitest). Tests inject a value here; the
* CLI version for the launch template's version gates (claude's `--name`, codex's
* `--no-daemon`). Omitted = probe the mode's own local CLI (resolveSessionCliVersion;
* null under vitest). The name predates the second gated CLI. Tests inject a value here; the
* docker/remote paths never see this builder's output, which is what keeps the
* gate measuring the RIGHT binary, the local one.
*/
@@ -2045,13 +2006,6 @@ 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;
/**
@@ -3554,19 +3508,6 @@ 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
@@ -3628,11 +3569,6 @@ 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.
-60
View File
@@ -1,60 +0,0 @@
/**
* @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;
};
-2
View File
@@ -25,7 +25,6 @@
* | 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` |
@@ -66,7 +65,6 @@ 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';
+6 -13
View File
@@ -437,6 +437,12 @@ export interface CodexConfig {
resumeSessionId?: string;
/** Bypass approval prompts (passes --dangerously-bypass-approvals-and-sandbox) */
dangerouslyBypassApprovals?: boolean;
/**
* Let commands in codex's workspace-write sandbox use the network, Codeman's loopback API
* included (passes --config sandbox_workspace_write.network_access=true). Filled from the
* synced `codexNetworkAccess` setting when unset; clamped off for a non-granted owner.
*/
networkAccess?: boolean;
/** Enable Codex's decorative TUI animations. Disable to reduce remote terminal redraws. */
animations?: boolean;
/** Browser rendering strategy for Codex sessions. Hybrid TUI is the only supported mode. */
@@ -834,19 +840,6 @@ 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 */
+1 -1
View File
@@ -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. 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.
⚠️ **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.
**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).
+3 -5
View File
@@ -60,8 +60,6 @@ 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 =
@@ -108,10 +106,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}); ${ctx.casesDirAdvice ?? 'create a case inside it with plain Create New (no custom folder)'}`;
return `That is the cases folder itself (${dir}); 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; ${ctx.casesDirAdvice ?? '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; create it there with plain Create New (no custom folder)`;
}
}
return null;
@@ -134,7 +132,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, casesDirAdvice: ctx.casesDirAdvice }, systemRoots];
return [{ home, dataDir, casesDirs }, systemRoots];
}
/**
-497
View File
@@ -1,497 +0,0 @@
/**
* @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)
)
);
}
}
-117
View File
@@ -1,117 +0,0 @@
/**
* @fileoverview The writer of the linked-cases registry (`linked-cases.json` in the data dir): case
* name to the absolute folder it stands for.
*
* Every read-modify-write of that file goes through `mutateLinkedCases()`, the same shape as
* `mutateSettingsFile()` (settings-writer.ts). The one direct writer is config-backup restore
* (`restoreConfigBackup()`), which must work on a file that does not parse. Readers keep reading the
* file directly; a write is atomic, so they never see half of one.
*
* - **Serialized.** Mutations run one at a time on a single promise chain, each one reading, changing
* and writing before the next starts. Link, unlink, rename, a create in a custom folder and a clone
* into one each used to read the file, check it and write it back on their own. Two clones that
* claimed one name and finished together both passed the check, both answered success, and the
* later write replaced the earlier one's entry. Any two writers could lose an entry the same way.
* A check that must hold when the entry is written belongs inside `fn`: throwing there rejects that
* caller only, and nothing is written.
* - **Atomic.** Each write goes to its own temp file beside the target and is renamed over it, so a
* reader never sees a half-written file (quick-start would read one as an empty registry and resolve
* a linked case to the cases dir instead), and a failed write leaves the previous file in place.
* - **Refuses a file it must not trust.** Only a missing (or empty) file starts from `{}`. A file that
* cannot be read or is not a JSON object is left alone and the write throws
* `LinkedCasesWriteRefusedError`: read as `{}`, the next link would write back its own entry alone
* and unlink every other case.
*/
import { randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import { dirname } from 'node:path';
import { dataPath } from '../config/instance.js';
import { getErrorMessage } from '../types.js';
export type LinkedCases = Record<string, string>;
/** Resolved at call time, like every other `dataPath()` caller that tests point at a temp HOME. */
export function linkedCasesPath(): string {
return dataPath('linked-cases.json');
}
/** A write refused because the existing registry must not be overwritten. The message is user-facing. */
export class LinkedCasesWriteRefusedError extends Error {
constructor(message: string) {
super(message);
this.name = 'LinkedCasesWriteRefusedError';
}
}
async function readLinkedCasesForWrite(path: string): Promise<LinkedCases> {
let raw: string;
try {
raw = await fs.readFile(path, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {};
throw new LinkedCasesWriteRefusedError(
`Cannot read ${path} (${(err as Error).message}), so the linked cases were not changed.`
);
}
if (raw.trim() === '') return {};
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
parsed = undefined;
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new LinkedCasesWriteRefusedError(
`${path} is not a valid JSON object, so the linked cases were not changed (saving would have unlinked every other case). Fix or delete the file, or restore it from a config backup, then try again.`
);
}
return parsed as LinkedCases;
}
async function writeLinkedCasesFile(path: string, linked: LinkedCases): Promise<void> {
// Through a symlink, as the plain writeFile this replaces did: the rename lands on the real file.
const target = await fs.realpath(path).catch(() => path);
await fs.mkdir(dirname(target), { recursive: true });
// A plain writeFile kept the file's permissions; a rename would not, so the temp file takes them.
const mode = await fs.stat(target).then(
(s) => s.mode & 0o777,
() => undefined
);
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
try {
await fs.writeFile(tmp, JSON.stringify(linked, null, 2), mode === undefined ? undefined : { mode });
// The creation mode is masked by the umask; the existing file's bits are put back as they were.
if (mode !== undefined) await fs.chmod(tmp, mode);
await fs.rename(tmp, target);
} catch (err) {
await fs.rm(tmp, { force: true }).catch(() => {});
throw err;
}
}
let mutationChain: Promise<unknown> = Promise.resolve();
/**
* Run one change to the linked-cases registry. `fn` receives a copy of the current registry and
* returns the registry to write, or `undefined` to write nothing; it may throw to refuse the change
* (its error rejects this call as thrown). Resolves with the registry before and after the change
* (`after` is `undefined` when nothing was written). A refused or failed mutation rejects its own
* caller only; the chain keeps going.
*/
export function mutateLinkedCases(
fn: (current: LinkedCases) => LinkedCases | undefined
): Promise<{ before: LinkedCases; after: LinkedCases | undefined }> {
const run = mutationChain.then(async () => {
const path = linkedCasesPath();
const before = await readLinkedCasesForWrite(path).catch((err: unknown) => {
console.warn(`[linked-cases] write refused: ${getErrorMessage(err)}`);
throw err;
});
const after = fn({ ...before });
if (after) await writeLinkedCasesFile(path, after);
return { before, after };
});
mutationChain = run.catch(() => {});
return run;
}
-6
View File
@@ -38,12 +38,6 @@ 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
-10
View File
@@ -10,11 +10,6 @@ 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
@@ -24,11 +19,6 @@ 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;
+5 -35
View File
@@ -286,10 +286,6 @@ 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'],
@@ -3591,9 +3587,6 @@ 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.
@@ -7042,8 +7035,6 @@ 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
@@ -7080,33 +7071,14 @@ 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);
// 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) {
// 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) {
off();
return;
}
const firstTab = container.querySelector(':scope > .session-tab');
// Capped every time, not only once it measures too wide: selectSession()
// widens a newly selected tab at once and this pass is debounced, so a cap
// set only here afterwards would leave the first row below the toolbar until
// it runs. The cap changes nothing on a tab narrower than the room.
for (const tab of container.querySelectorAll(':scope > .tab-corner-fit')) {
if (tab !== firstTab) tab.classList.remove('tab-corner-fit');
}
firstTab?.classList.add('tab-corner-fit');
if (container.style.getPropertyValue('--tab-corner-first-max') !== `${room}px`) {
container.style.setProperty('--tab-corner-first-max', `${room}px`);
}
const width = `${floatWidth}px`;
const width = `${Math.max(0, Math.ceil(contentRight - box.left + 8))}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);
@@ -10015,8 +9987,6 @@ 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
+25 -114
View File
@@ -281,11 +281,6 @@ 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
@@ -2111,7 +2106,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|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;
/(\/(?: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;
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
function absoluteFilePathPattern() {
@@ -2164,14 +2159,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|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;
/(^|[\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;
/** 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, including paths bounded by parentheses, even when unlinkable; rooted, `~/`, relative
* reserve their whole span first, 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
@@ -2200,26 +2195,19 @@ 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')) {
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);
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 });
}
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)) {
@@ -2247,29 +2235,14 @@ 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 or a parenthesised group bounds it, so a spaced path is recognised in these shapes:
* the path, so a spaced path is recognised in exactly 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)
@@ -2283,7 +2256,7 @@ function opensBashCall(text, open) {
* it ends on a known extension, a rooted one has a directory before its first
* space, no earlier word holds an extension (`a.md,`, `a.ts#L12`) or ends in `/`,
* and none holds a `:` and a digit (`a.ts:42`, a line suffix ends the path). Later words cannot start a new
* `/`, `~/`, `./` or `../` path, no earlier word ends in `:` (or `,` inside parentheses), is an arrow or has
* `/`, `~/`, `./` or `../` path, no earlier word ends in `:`, 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
@@ -2305,10 +2278,9 @@ function opensBashCall(text, open) {
* Linear: one bounded prefix match, then string scans.
*
* @param {string} line One line, no newline.
* @param {{parenthesised?: boolean}} [options] Refuse comma-separated lists inside parentheses only.
* @returns {{path: string, index: number, link: boolean} | null}
*/
function spacedFilePathOnLine(line, options = {}) {
function spacedFilePathOnLine(line) {
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;
@@ -2332,7 +2304,6 @@ function spacedFilePathOnLine(line, options = {}) {
}
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 `$`
@@ -2426,31 +2397,6 @@ 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
@@ -2611,10 +2557,9 @@ const pathFirstSegment = (path) => path.replace(/^(?:\.?\/)+/, '').split('/')[0]
* top directory the broken token did (the next entry of a listing).
*/
function pathContinuation(prevText, nextText) {
const previous = String(prevText || '').trimEnd();
const next = String(nextText || '').replace(/^\s+/, '');
const tail = /[^\s"'<>|;&]+$/.exec(previous)?.[0] || '';
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 next = String(nextText || '').replace(/^\s+/, '');
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`
@@ -2641,9 +2586,7 @@ 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. 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.
* `maxRows` as the logical line.
*
* @returns Same shape as {@link terminalLogicalLine}; null when the row does not exist.
*/
@@ -2664,7 +2607,6 @@ 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;
@@ -2674,41 +2616,25 @@ 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: parts.slice(0, confirmed), alternatives };
return parts.slice(0, confirmed);
};
// 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);
// 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.
let first = base;
let rows = rowsOf(base);
while (first.startRow > 0) {
const prev = lineAt(first.startRow - 1);
if (!prev || rows + rowsOf(prev) > bound) break;
if (!prev || rows + rowsOf(prev) > bound || !continues(prev, first, prev.text)) 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;
}
const { parts, alternatives } = extended;
let parts = extend(first);
if (parts[parts.length - 1].endRow < base.startRow) parts = extend(base);
if (parts.length === 1) return parts[0];
const segments = [];
@@ -2733,14 +2659,7 @@ function terminalPathLine(buffer, row, cols, maxRows) {
}
return -1;
};
return {
startRow: parts[0].startRow,
endRow: parts[parts.length - 1].endRow,
text,
offsetToCell,
cellToOffset,
...(alternatives.length ? { pathAlternatives: alternatives } : {}),
};
return { startRow: parts[0].startRow, endRow: parts[parts.length - 1].endRow, text, offsetToCell, cellToOffset };
}
/**
@@ -2996,14 +2915,7 @@ 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,
...(base.pathAlternatives ? { pathAlternatives: base.pathAlternatives } : {}),
};
return { startRow, endRow: lastRow, text, offsetToCell, cellToOffset };
}
/**
@@ -4113,7 +4025,6 @@ if (typeof window !== 'undefined') {
absoluteFilePathPattern,
findFilePathLinks,
resolveLinkedFilePath,
relativeResolutionBase,
previewsInFileViewer,
FILE_PREVIEW_EXTENSIONS,
rendersAsPage,
-630
View File
@@ -1,630 +0,0 @@
/**
* @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;
},
});
-56
View File
@@ -553,39 +553,6 @@
'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': '项目洞察',
@@ -648,9 +615,6 @@
'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)',
@@ -660,15 +624,9 @@
'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': '仅活动标签页',
@@ -959,8 +917,6 @@
'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': '推送通知已启用',
@@ -1423,7 +1379,6 @@
[/^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}`],
@@ -1492,17 +1447,6 @@
(_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} 正在终端中等待你的回答`],
+8 -147
View File
@@ -216,13 +216,6 @@
<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>
@@ -640,22 +633,6 @@
<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">&#x21BB;</button>
<button class="btn-icon-sm" onclick="app.closeEditHistoryPanel()" title="Close" aria-label="Close edit history">&times;</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">
@@ -1056,20 +1033,6 @@
</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>
@@ -2228,7 +2191,6 @@
<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>
@@ -2829,10 +2791,6 @@
<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">
@@ -3045,6 +3003,13 @@
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsCodexDangerouslyBypassApprovals"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="codex network access sandbox loopback codeman skill orchestrate spawn">
<div class="set-row-text">
<span class="set-row-label">Allow network access</span>
<span class="set-row-desc">Lets commands in Codex's workspace-write sandbox use the network, which the Codeman skill needs to reach this server. A folder Codex runs read-only (one it does not trust) still asks per command. The sandbox and approvals stay on. Applies to new Codex sessions.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsCodexNetworkAccess"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="codex animations status effects">
<div class="set-row-text">
<span class="set-row-label">Animated status effects</span>
@@ -3276,97 +3241,6 @@
<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 &quot;When a tab is deleted&quot; 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 ════════════════════════════════════════════════════ -->
@@ -3787,19 +3661,7 @@
<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" id="cloneCaseNameHint">Filled in from the URL. Cloned into ~/codeman-cases/&lt;name&gt;, 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&hellip;</button>
</div>
<span class="form-hint" id="cloneCasePathPreview">Pick an empty folder, or one that does not exist yet, to clone into.</span>
<span class="form-hint">Filled in from the URL. Cloned into ~/codeman-cases/&lt;name&gt;, so deleting the case deletes this working tree.</span>
</div>
<div class="form-row">
<label>Branch or Tag (optional)</label>
@@ -4665,7 +4527,6 @@
<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>
+1 -72
View File
@@ -80,7 +80,7 @@ const PathPicker = {
/**
* Open the lazy filesystem browser.
* @param {{sessionId?: string, initialPath?: string, directoriesOnly?: boolean,
* allowNewFolder?: boolean, title?: string, onSelect: (path: string) => void}} options
* title?: string, onSelect: (path: string) => void}} options
*/
open(options) {
this.close(false);
@@ -114,15 +114,9 @@ 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>+&#x1F4C1;</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">&#x21BB;</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
@@ -153,7 +147,6 @@ 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.
@@ -190,70 +183,6 @@ 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';
-43
View File
@@ -586,8 +586,6 @@ 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. */
@@ -3475,47 +3473,6 @@ 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) {
+5 -9
View File
@@ -4195,7 +4195,7 @@ Object.assign(CodemanApp.prototype, {
},
/**
* Register a file path as a live external attachment and return
* Register an out-of-workspace 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, status?: number}>}
* @returns {Promise<{attachmentId?: string, size?: number, error?: string}>}
*/
async _registerExternalPreview(filePath, sessionId) {
try {
@@ -4220,18 +4220,14 @@ 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
// through this lookup instead, using the path's actual workspace scope.
// from outside the workspace instead.
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 {
status: res.status,
error: `Cannot preview ${target} (images, video, audio, PDF, Office documents and text files only).`,
error: `Cannot preview .${ext} from outside the session workspace (images, video, audio, PDF, Office documents and text files only).`,
};
}
return { error: reason, status: res.status };
return { error: reason };
} catch (err) {
return { error: err.message || 'Cannot open this file' };
}
+2 -2
View File
@@ -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; any other failure is toasted.
// role was known) marks the page read-only for the writes after it.
if (this._settingsReadOnly?.()) {
this._rememberOwnSetting?.('respawnPresets', custom);
return;
}
this._apiPut('/api/settings', { respawnPresets: custom })
.then((res) => this._noteSettingsWriteResult?.(res, 'Respawn presets'))
.then((res) => this._noteSettingsWriteRefusal?.(res))
.catch(() => {});
},
+21 -1
View File
@@ -1 +1,21 @@
{ "notices": [] }
{
"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 公告"
}
}
}
]
}
+26 -214
View File
@@ -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); any other failure is toasted.
// read-only for the writes after it (_noteSettingsWriteRefusal).
if (this._settingsReadOnly?.()) {
this._rememberOwnSetting?.('runMode', mode);
} else {
this._apiPut('/api/settings', { runMode: mode })
.then((res) => this._noteSettingsWriteResult?.(res, 'Run mode'))
.then((res) => this._noteSettingsWriteRefusal?.(res))
.catch(() => {});
}
// Close menu
@@ -1002,23 +1002,12 @@ Object.assign(CodemanApp.prototype, {
},
/**
* 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.
* 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.
*
* ⚠️ 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) —
@@ -1035,58 +1024,17 @@ 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 _getCustomModelRunningStatus(host, timeoutMs = 800) {
async _getCustomModelCurrentlyLoaded(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));
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) {
const status = await Promise.race([probe, timeout]);
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,
@@ -1107,17 +1055,15 @@ Object.assign(CodemanApp.prototype, {
const generation = (this._customModelPickGeneration = (this._customModelPickGeneration || 0) + 1);
const isCurrent = () => this._customModelPickGeneration === generation;
// 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);
// 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);
if (!isCurrent()) return; // a newer pick opened (and possibly already rendered) while this probe was in flight
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 lastUsed = currentlyLoaded ? null : this._getCustomModelLastUsed(mode, host.id);
const promoted = currentlyLoaded || lastUsed;
const models = [...(host.models || [])];
if (promoted && models.includes(promoted)) {
models.splice(models.indexOf(promoted), 1);
@@ -1132,91 +1078,23 @@ 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 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;
// 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;
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" data-model-id="${escapeHtml(m)}" onclick="app.chooseCustomModelAndRun(${arg})">
<button class="run-mode-option" 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() {
@@ -4219,15 +4097,6 @@ 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');
@@ -4255,50 +4124,6 @@ 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;
@@ -4447,12 +4272,6 @@ 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', {
@@ -4460,13 +4279,7 @@ 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 } : {}),
...(customFolder ? { path: this._cloneTargetPath() } : {}),
}),
body: JSON.stringify({ name, repository: url, ...(ref ? { ref } : {}), ...(shallow ? { shallow: true } : {}) }),
});
const data = await res.json();
if (!data.success) {
@@ -4502,7 +4315,6 @@ 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');
+18 -214
View File
@@ -570,7 +570,6 @@ 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 =
@@ -610,7 +609,6 @@ 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
@@ -699,6 +697,7 @@ Object.assign(CodemanApp.prototype, {
// that does have it.
document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked =
settings.codexDangerouslyBypassApprovals ?? false;
document.getElementById('appSettingsCodexNetworkAccess').checked = settings.codexNetworkAccess ?? false;
document.getElementById('appSettingsCodexAnimations').checked =
settings.codexAnimationsEnabled ?? false;
document.getElementById('appSettingsCodexModel').value = settings.codexModel ?? '';
@@ -1735,115 +1734,6 @@ 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.
@@ -2411,14 +2301,6 @@ 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
@@ -2780,11 +2662,9 @@ 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, sent = { tunnelEnabled: true }) {
async _handleTunnelEnableRefusal(res) {
if (!res || res.ok) return false;
let message = 'Tunnel refused: set CODEMAN_PASSWORD before exposing Codeman publicly.';
let errorCode = '';
@@ -2816,7 +2696,7 @@ Object.assign(CodemanApp.prototype, {
const retry = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...sent, acknowledgeUnauthTunnel: true }),
body: JSON.stringify({ tunnelEnabled: true, acknowledgeUnauthTunnel: true }),
});
if (retry.ok) {
this.showToast('Public tunnel enabling — no password set ⚠️', 'warning');
@@ -2866,13 +2746,6 @@ 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');
@@ -3091,11 +2964,7 @@ 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();
// 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;
const _prevGestureEnabled = (_prev.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;
@@ -3159,7 +3028,6 @@ 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,
@@ -3205,6 +3073,7 @@ Object.assign(CodemanApp.prototype, {
codexModel: document.getElementById('appSettingsCodexModel').value.trim(),
codexReasoningEffort: document.getElementById('appSettingsCodexReasoningEffort').value,
codexDangerouslyBypassApprovals: document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked,
codexNetworkAccess: document.getElementById('appSettingsCodexNetworkAccess').checked,
codexAnimationsEnabled: document.getElementById('appSettingsCodexAnimations').checked,
// Claude Permissions settings
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
@@ -3415,7 +3284,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(_prevServer, settings.showPlanUsageLimits);
const _chipFlip = this.planUsageCollectionFlip(_prev, settings.showPlanUsageLimits);
const {
localEchoEnabled: _leo,
// Per-device (it is about this screen's keyboard) and absent from
@@ -3467,8 +3336,6 @@ 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
@@ -3490,7 +3357,6 @@ 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
@@ -3499,20 +3365,18 @@ 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 body = {
const res = settingsReadOnly ? null : await this._apiPut('/api/settings', {
...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
@@ -3527,25 +3391,15 @@ 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. 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;
// so it doesn't look enabled.
if (!refusedAsNonAdmin && settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) {
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 (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;
// evidence the server kept the flags the Apply refresh below reads.
serverSaved = !!res?.ok;
// Save model configuration separately. For a non-admin it is not sent either, and the
// answer says whether this save would have changed it.
@@ -3556,9 +3410,7 @@ 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 (saveError) {
this.showToast(`Saved on this device, not on the server: ${saveError}`, 'warning', { duration: 0 });
} else if (webhookError) {
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
@@ -3571,7 +3423,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 && !saveError) {
if (settings.tunnelEnabled && !settingsReadOnly && !refusedAsNonAdmin) {
this.showToast('Tunnel starting — QR code will appear when ready...', 'info');
}
} catch (err) {
@@ -3583,10 +3435,9 @@ 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 && !saveError) {
} else if (!keepOpen) {
this.closeAppSettings();
}
@@ -3597,10 +3448,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 (not after a failed PUT: the reload would render the old one).
// persisted the new value.
// `?? 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 (!saveError && (settings.gestureControlEnabled ?? false) !== _prevGestureEnabled) {
if ((settings.gestureControlEnabled ?? false) !== _prevGestureEnabled) {
this.showToast(
settings.gestureControlEnabled ? 'Enabling gesture control — reloading…' : 'Disabling gesture control — reloading…',
'info'
@@ -3649,35 +3500,6 @@ 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
@@ -3989,9 +3811,6 @@ 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 = '';
},
@@ -4017,18 +3836,6 @@ 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');
@@ -4049,7 +3856,6 @@ Object.assign(CodemanApp.prototype, {
baseUrl,
authStyle,
defaultModelId,
defaultContextLength,
apiKey: apiKeyInput || undefined,
models: existing?.models,
lastDiscoveredAt: existing?.lastDiscoveredAt,
@@ -4830,8 +4636,6 @@ 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.
@@ -5195,7 +4999,7 @@ Object.assign(CodemanApp.prototype, {
'language',
'terminalWheelLocalScrollback',
'autoCopySelection', 'copyStripMargin',
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds', 'showEditHistory',
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds',
'showTabDetachButton',
'showTabNewButton',
'mobileOverviewEnabled',
-384
View File
@@ -651,13 +651,6 @@ 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));
}
@@ -7814,52 +7807,6 @@ 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
@@ -15817,7 +15764,6 @@ 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;
@@ -15842,45 +15788,6 @@ 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;
@@ -22679,297 +22586,6 @@ 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)
+5 -26
View File
@@ -2146,16 +2146,13 @@ Object.assign(CodemanApp.prototype, {
/**
* A clicked path as the preview needs it: relative paths resolved against the
* directory the session's pane is in now, else its working directory
* (`relativeResolutionBase`), absolute and `~/` paths unchanged
* session's working directory, 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;
const session = this.sessions?.get?.(sessionId);
const base = typeof relativeResolutionBase === 'function' ? relativeResolutionBase(session) : session?.workingDir;
return resolveLinkedFilePath(filePath, base);
return resolveLinkedFilePath(filePath, this.sessions?.get?.(sessionId)?.workingDir);
},
/**
@@ -2321,7 +2318,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, verifiedSessionId = null) => {
const activateFilePath = (text, folder = false) => {
// 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
@@ -2339,26 +2336,8 @@ 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 = verifiedSessionId || getSessionId();
if (!sessionId) return;
const sessionId = getSessionId();
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) {
@@ -2393,7 +2372,7 @@ Object.assign(CodemanApp.prototype, {
underline: true,
},
activate(_event, text) {
return activateFilePath(text, folder);
activateFilePath(text, folder);
},
hover() {
setHovered(true);
+8 -9
View File
@@ -1,13 +1,12 @@
{
"version": "1.48.0",
"version": "1.47.0",
"highlights": [
"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)"
"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)"
]
}
+52 -177
View File
@@ -51,8 +51,6 @@ 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 {
@@ -86,7 +84,6 @@ import {
SettingsWriteRefusedError,
settingsWriteErrorResponse,
} from '../settings-writer.js';
import { LinkedCasesWriteRefusedError, linkedCasesPath, mutateLinkedCases } from '../linked-cases-writer.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import type { AuthUser } from '../../types.js';
import { SseEvent } from '../sse-events.js';
@@ -127,6 +124,7 @@ import {
writeRemoteHosts,
} from '../../remote-hosts.js';
const LINKED_CASES_FILE = dataPath('linked-cases.json');
const CODEMAN_CONFIG_DIR = getDataDir();
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
const DOCKER_EXPORTS_DIR = dataPath('docker-exports');
@@ -152,10 +150,6 @@ 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
@@ -212,21 +206,11 @@ function agentCreatedInfo(marker: AgentCaseMarker): NonNullable<CaseInfo['agentC
};
}
/**
* Read and parse linked-cases.json, returning empty object on missing/invalid file. For reads only:
* every write goes through `mutateLinkedCases()`, which re-reads the file inside its own critical
* section and refuses one that does not parse.
*/
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
async function readLinkedCases(): Promise<Record<string, string>> {
return readJsonConfig<Record<string, string>>(linkedCasesPath(), 'linked cases', {});
return readJsonConfig<Record<string, string>>(LINKED_CASES_FILE, 'linked cases', {});
}
/** A conflict thrown from inside a `mutateLinkedCases()` callback: answered 409, nothing written. */
function linkedConflict(message: string): Error {
return Object.assign(new Error(message), { conflict: true });
}
const isLinkedConflict = (err: unknown): boolean => (err as { conflict?: boolean }).conflict === true;
/**
* Resolve a case name to its directory path, checking linked cases first, then the
* user's case space (per-user in multi-user mode, the shared CASES_DIR otherwise).
@@ -563,11 +547,15 @@ export function registerCaseRoutes(
made.push(join(casePath, '.claude')); // before the write, so a half-written one is undone too
await writeHooksConfig(casePath);
// The check above ran before the scaffold; a case linked since then wins and this call is undone.
await mutateLinkedCases((current) => {
if (current[name]) throw linkedConflict(`Case "${name}" was just linked`);
return { ...current, [name]: casePath };
});
const codemanDir = getDataDir();
if (!existsSync(codemanDir)) mkdirSync(codemanDir, { recursive: true });
// Re-read right before writing, so a case linked since the check above is not dropped. This only
// narrows the window: like POST /api/cases/link, the registry write is not serialized, and two
// requests that both read before either writes can still lose one entry.
const fresh = await readLinkedCases();
if (fresh[name]) throw Object.assign(new Error(`Case "${name}" was just linked`), { conflict: true });
fresh[name] = casePath;
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(fresh, null, 2));
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
return { success: true, data: { case: { name, path: casePath } } };
@@ -575,14 +563,10 @@ export function registerCaseRoutes(
// Undo only what this call made. The whole folder if we created it, otherwise the scaffold
// entries inside the empty folder the user picked; never anything else.
for (const p of made.reverse()) await fs.rm(p, { recursive: true, force: true }).catch(() => undefined);
if (isLinkedConflict(err)) {
if ((err as { conflict?: boolean }).conflict) {
reply.code(409);
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, getErrorMessage(err));
}
if (err instanceof LinkedCasesWriteRefusedError) {
reply.code(409);
return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
}
reply.code(500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
@@ -677,8 +661,7 @@ 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`, 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
* this writes only inside the caller's own `resolveCasesDir`. 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`).
@@ -703,7 +686,7 @@ export function registerCaseRoutes(
warnings: string[];
}>
> => {
const { name, repository, ref, shallow, description, path: customPath } = parseBody(CloneCaseSchema, req.body);
const { name, repository, ref, shallow, description } = parseBody(CloneCaseSchema, req.body);
const user = getAuthUser(req);
const parsed = parseGitRepositoryUrl(repository);
@@ -722,59 +705,8 @@ 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);
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);
}
const 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
@@ -783,14 +715,8 @@ 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 (
// A custom folder was judged by prepareNewCasePath (an existing empty one is fine).
(customPath === undefined && existsSync(casePath)) ||
existsSync(casePath) ||
linkedCases[name] ||
dockerCases.some((item) => item.name === name) ||
remoteCases.some((item) => item.name === name)
@@ -798,47 +724,16 @@ export function registerCaseRoutes(
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
}
// 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.
// git creates the leaf, not necessarily the case space above it.
try {
// 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.'
);
mkdirSync(casesDir, { recursive: true });
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
const clone = await cloneRepository({
repository: parsed.repository,
destination: casePath,
...(intoEmptyDir ? { intoEmptyDir: true } : {}),
withoutCredentialHelpers: cloneWithoutCredentialHelpers(req),
...(ref ? { ref } : {}),
...(shallow ? { shallow: true } : {}),
@@ -899,26 +794,6 @@ export function registerCaseRoutes(
warnings.push(`Case scaffolding was incomplete: ${getErrorMessage(err)}`);
}
if (customPath !== undefined) {
// The name was checked before git ran, which can take minutes. Checked again where the entry is
// written, so of two clones that claimed one name only the first to finish links it.
try {
await mutateLinkedCases((current) => {
if (current[name]) throw linkedConflict(`Case "${name}" was linked while the clone ran`);
return { ...current, [name]: casePath };
});
} catch (err) {
// The clone itself succeeded: keep the folder (the user waited for it) and say where it is.
const conflict = isLinkedConflict(err);
const refused = err instanceof LinkedCasesWriteRefusedError;
reply.code(conflict || refused ? 409 : 500);
return createErrorResponse(
conflict ? ApiErrorCode.ALREADY_EXISTS : refused ? ApiErrorCode.CONFLICT : 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,
@@ -1723,17 +1598,28 @@ export function registerCaseRoutes(
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.');
}
// The name check and the write are one step, so two links of one name cannot both succeed.
// Load existing linked cases
const linkedCases = await readLinkedCases();
// Check if name is already linked
if (linkedCases[name]) {
return createErrorResponse(
ApiErrorCode.ALREADY_EXISTS,
`Case "${name}" is already linked to ${linkedCases[name]}`
);
}
// Save the linked case
linkedCases[name] = expandedPath;
try {
await mutateLinkedCases((current) => {
if (current[name]) throw linkedConflict(`Case "${name}" is already linked to ${current[name]}`);
return { ...current, [name]: expandedPath };
});
const codemanDir = getDataDir();
if (!existsSync(codemanDir)) {
mkdirSync(codemanDir, { recursive: true });
}
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
ctx.broadcast(SseEvent.CaseLinked, { name, path: expandedPath });
return { success: true, data: { case: { name, path: expandedPath } } };
} catch (err) {
if (isLinkedConflict(err)) return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, getErrorMessage(err));
if (err instanceof LinkedCasesWriteRefusedError) return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
});
@@ -1814,19 +1700,13 @@ export function registerCaseRoutes(
// must not unlink one either; skip so control falls through to their local delete.
const linkedCases = await readLinkedCases();
if (linkedCases[name] && (!isMultiUserMode() || isAdmin(req))) {
delete linkedCases[name];
try {
// Removes this one entry from the registry as it is now, keeping any linked since the read above.
await mutateLinkedCases((current) => {
if (!Object.hasOwn(current, name)) return undefined;
const next = { ...current };
delete next[name];
return next;
});
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'unlinked' });
await forgetCaseDefault();
return { success: true, data: { name } };
} catch (err) {
if (err instanceof LinkedCasesWriteRefusedError) return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
}
@@ -2012,6 +1892,7 @@ export function registerCaseRoutes(
stopped++;
}
if (moveFolder) await moveCaseFolder(oldPath, newPath);
const originalLinks = isLinked ? await readLinkedCases() : undefined;
// The settings.json values this rename replaced, set only once it wrote them. A failure puts back
// these keys alone, so a settings save that landed in between is kept.
let settingsUndo: Record<string, unknown> | undefined;
@@ -2031,19 +1912,16 @@ export function registerCaseRoutes(
settingsUndo = Object.fromEntries(keys.map((k) => [k, before[k]]));
}
}
if (isLinked) {
// Checked again where the entry is written: the checks above ran before the sessions were
// stopped and the folder moved. A failed write leaves the registry as it was.
await mutateLinkedCases((current) => {
if (current[newName]) throw new CaseRenameError('EXISTS', `A case named "${newName}" already exists`);
if (current[name] !== oldPath) {
throw new CaseRenameError('BUSY', `Case "${name}" was changed while it was being renamed`);
}
const links = { ...current };
delete links[name];
links[newName] = newPath;
return links;
});
if (originalLinks) {
const links = { ...originalLinks };
delete links[name];
links[newName] = newPath;
try {
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(links, null, 2));
} catch (err) {
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(originalLinks, null, 2)).catch(() => {});
throw err;
}
}
} catch (err) {
// The folder moved but the registries did not follow: put settings.json and the folder back
@@ -2088,9 +1966,6 @@ export function registerCaseRoutes(
: ApiErrorCode.OPERATION_FAILED;
return fail(status, code, err.message, { renameCode: err.code, stoppedSessions: stopped });
}
if (err instanceof LinkedCasesWriteRefusedError) {
return fail(409, ApiErrorCode.CONFLICT, err.message, { stoppedSessions: stopped });
}
return fail(500, ApiErrorCode.OPERATION_FAILED, getErrorMessage(err), { stoppedSessions: stopped });
}
});
+1 -8
View File
@@ -150,12 +150,7 @@ export async function runConfigBackup(
});
}
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 {
export function registerConfigBackupRoutes(app: FastifyInstance): void {
const deny = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => {
if (isMultiUserMode() && !isAdmin(req)) {
reply.code(403);
@@ -241,8 +236,6 @@ export function registerConfigBackupRoutes(app: FastifyInstance, deps: ConfigBac
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({
+9 -64
View File
@@ -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, MAX_DISCOVERED_CONTEXT_TOKENS } from '../schemas.js';
import { CustomModelHostSchema } from '../schemas.js';
import { readCustomModelHosts, writeCustomModelHosts, type CustomModelHost } from '../../custom-model-hosts.js';
import type { CliEntry } from '../../config/cli-registry/types.js';
@@ -158,14 +158,9 @@ function authHeaders(host: Pick<CustomModelHost, 'apiKey' | 'authStyle'>): Recor
export interface DiscoveryResult {
models: string[];
/**
* 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.
*/
/** See `CustomModelHost.modelContextLengths` — only ever populated for models already loaded. */
contextLengths: Record<string, number>;
/** See `CustomModelHost.modelSlotCounts`; populated only for models the server reports loaded (launch command or `/props`). No listing states a slot count, unlike `contextLengths`. */
/** See `CustomModelHost.modelSlotCounts`; populated on the same loaded-models-only basis as `contextLengths`. */
slotCounts: Record<string, number>;
/** See `CustomModelHost.modelSizesGB` — populated for every model whose own listing states one. */
sizesGB: Record<string, number>;
@@ -187,20 +182,6 @@ 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
@@ -331,42 +312,25 @@ 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;
context_length?: unknown;
context_window?: unknown;
}>;
data?: Array<{ id?: unknown; status?: { value?: unknown }; description?: 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 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.
// 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.
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')
@@ -400,30 +364,11 @@ async function discoverModels(
// merges onto what was learned before).
if (cmdSlots !== undefined && ctxFromProps) slots = 1;
}
// 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 (ctx !== undefined) contextLengths[id] = ctx;
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 };
}
-228
View File
@@ -1,228 +0,0 @@
/**
* @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 };
});
}
-140
View File
@@ -1,140 +0,0 @@
/**
* @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 } };
});
}
+1 -84
View File
@@ -57,7 +57,6 @@ import {
findSessionOrFail,
getAuthUser,
parseBody,
resolveCasesDir,
validateSessionFilePath,
validateSessionFilePathLexical,
} from '../route-helpers.js';
@@ -75,12 +74,7 @@ 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,
FilesystemMkdirSchema,
FilesystemPreviewQuerySchema,
FileWriteSchema,
} from '../schemas.js';
import { FilesystemBrowseQuerySchema, FilesystemPreviewQuerySchema, FileWriteSchema } from '../schemas.js';
import {
MAX_EDITABLE_BYTES,
applyEol,
@@ -1454,83 +1448,6 @@ 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);
-2
View File
@@ -16,7 +16,6 @@ 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';
@@ -36,7 +35,6 @@ 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,
+50 -106
View File
@@ -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, isAbsolute, resolve as resolvePath } from 'node:path';
import { join, dirname, extname, basename, 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,9 +70,8 @@ import {
SessionWaitOutputQuerySchema,
CustomModelSelectionSchema,
} from '../schemas.js';
import { readCustomModelHosts, resolveCustomModelContextLength } from '../../custom-model-hosts.js';
import { readCustomModelHosts } from '../../custom-model-hosts.js';
import { applyCustomModelInjection, removeConfigDir } from '../../custom-model-injection-apply.js';
import { buildCustomModelInjection } from '../../custom-model-injection.js';
import {
getLlamaSwapStatus,
triggerLlamaSwapLoad,
@@ -127,7 +126,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, CliEntry, NewlineSequence } from '../../config/cli-registry/types.js';
import type { AgentSkillDir, 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';
@@ -231,39 +230,6 @@ 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
@@ -1575,36 +1541,9 @@ export function registerSessionRoutes(
if (!endpoint) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found');
}
// 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 contextLength = endpoint.modelContextLengths?.[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
@@ -1668,18 +1607,33 @@ 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,
await guardSessionConfigDirHome(entry, (envVar) => session.envOverrideValue(envVar))
session.workingDir
);
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(
{
@@ -1729,8 +1683,7 @@ 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);
// The edit history goes with the record (same rule as `cleanupSession`).
if (ctx.store.demoteOrRemoveSession(id) === 'removed') ctx.forgetEditHistory(id);
ctx.store.demoteOrRemoveSession(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);
@@ -2921,14 +2874,16 @@ export function registerSessionRoutes(
return candidates[0] ?? null;
}
// 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) => {
app.get('/api/sessions/:id/last-response', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
// 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') {
return await readCodexLastResponse(session, context === 'full');
const codexQuery = req.query as { context?: string };
return await readCodexLastResponse(session, codexQuery.context === 'full');
}
// DeepSeek Harness writes a real structured transcript under
@@ -2947,7 +2902,8 @@ 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 full = context === 'full';
const deepSeekQuery = req.query as { context?: string };
const full = deepSeekQuery.context === 'full';
const transcript = await readDeepSeekLastResponse(session, { blocks: full });
if (transcript) {
return {
@@ -2965,6 +2921,7 @@ 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).
@@ -2977,7 +2934,7 @@ export function registerSessionRoutes(
text: getLastTranscriptResponse(blocks),
timestamp: '',
hasContext: blocks.length > 0,
messages: context === 'full' ? blocks : undefined,
messages: externalQuery.context === 'full' ? blocks : undefined,
};
}
@@ -3003,10 +2960,11 @@ 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 = context === 'full' || context === 'turn';
const wantsMessages = query.context === 'full' || query.context === 'turn';
const claudeSessionId = session.claudeSessionId || session.id;
const transcript = await findClaudeTranscript(projectsDir, claudeSessionId, session.id);
if (!transcript) {
@@ -3027,7 +2985,7 @@ export function registerSessionRoutes(
try {
const content = await fs.readFile(transcript.path, 'utf8');
const parsed = parseClaudeResponseTranscript(content, wantsMessages);
if (context === 'turn') {
if (query.context === 'turn') {
return {
text: parsed.text,
timestamp: parsed.timestamp,
@@ -3038,13 +2996,6 @@ 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 {
@@ -4496,28 +4447,9 @@ 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');
// 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 cmContextLength = cmEndpoint.modelContextLengths?.[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
@@ -4582,12 +4514,24 @@ export function registerSessionRoutes(
customModel.modelId,
qsCustomModelSessionId,
cmContextLength,
workspacePath,
await guardSessionConfigDirHome(cmEntry, (envVar) => qsGatedEnvOverrides?.[envVar])
workspacePath
);
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;
-1
View File
@@ -856,7 +856,6 @@ 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 };
});
+3 -81
View File
@@ -20,7 +20,6 @@ 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';
@@ -83,35 +82,6 @@ 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(),
@@ -371,6 +341,7 @@ const CodexConfigSchema = z
.regex(/^[a-zA-Z0-9_-]+$/)
.optional(),
dangerouslyBypassApprovals: z.boolean().optional(),
networkAccess: z.boolean().optional(),
animations: z.boolean().optional(),
renderMode: z
.enum(['scrollback', 'hybrid'])
@@ -815,12 +786,6 @@ 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. */
@@ -1721,6 +1686,7 @@ export const SettingsUpdateSchema = z
.optional(),
codexReasoningEffort: z.enum(['', ...CODEX_REASONING_EFFORTS]).optional(),
codexDangerouslyBypassApprovals: z.boolean().optional(),
codexNetworkAccess: z.boolean().optional(),
codexAnimationsEnabled: z.boolean().optional(),
// Terminal history and retention
terminalScrollbackLines: z
@@ -2250,37 +2216,6 @@ 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 */
@@ -2419,14 +2354,6 @@ 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
@@ -2450,16 +2377,11 @@ 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(MAX_DISCOVERED_CONTEXT_TOKENS))
.optional(),
modelContextLengths: z.record(z.string().max(200), z.number().int().positive().max(100_000_000)).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(),
});
/**
-15
View File
@@ -102,21 +102,6 @@ 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\//,
];
/**
+12 -237
View File
@@ -45,8 +45,6 @@ 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';
@@ -82,13 +80,8 @@ 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, resolveCustomModelContextLength } from '../custom-model-hosts.js';
import {
applyCustomModelInjection,
customModelConfigDir,
removeConfigDir,
type AppliedCustomModel,
} from '../custom-model-injection-apply.js';
import { readCustomModelHosts } from '../custom-model-hosts.js';
import { applyCustomModelInjection, customModelConfigDir, removeConfigDir } from '../custom-model-injection-apply.js';
import type { CustomModelBookkeeping } from '../types/session.js';
import { registerGeneratedArtifactAttachment } from '../generated-artifact-attachments.js';
import {
@@ -137,9 +130,6 @@ 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,
@@ -225,7 +215,6 @@ import {
registerRebootRestoreRoutes,
registerReadMyMindRoutes,
registerGitStatusRoutes,
registerEditHistoryRoutes,
registerStatusTelemetryRoutes,
registerSystemRoutes,
registerCaseRoutes,
@@ -249,7 +238,6 @@ import {
registerConfigBackupRoutes,
runConfigBackup,
registerWebhookRoutes,
registerDiscordRoutes,
registerDoctorRoutes,
registerCustomModelRoutes,
refreshAllCustomModelHosts,
@@ -355,24 +343,6 @@ 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();
@@ -536,27 +506,6 @@ 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;
@@ -924,7 +873,6 @@ 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),
@@ -1342,7 +1290,6 @@ 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
@@ -1365,8 +1312,6 @@ 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);
@@ -1379,23 +1324,12 @@ export class WebServer extends EventEmitter {
registerWebviewRoutes(this.app, ctx, this.basePath);
registerTabLayoutRoutes(this.app, ctx);
registerMcpSyncRoutes(this.app);
registerConfigBackupRoutes(this.app, { reloadDiscord: () => this.discordBridge.reload() });
registerConfigBackupRoutes(this.app);
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);
@@ -1520,7 +1454,7 @@ export class WebServer extends EventEmitter {
private async _rebuildCustomModelEnv(
session: Session,
saved: CustomModelBookkeeping
): Promise<AppliedCustomModel | undefined> {
): Promise<Record<string, string> | undefined> {
const entry = getCli(session.mode);
if (!entry) return undefined;
const endpoint = (await readCustomModelHosts(getDataDir())).find((h) => h.id === saved.endpointId);
@@ -1531,56 +1465,19 @@ 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,
resolveCustomModelContextLength(endpoint, saved.modelId)
);
endpoint.modelContextLengths?.[saved.modelId]
)?.envOverrides;
} 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
@@ -1855,8 +1752,7 @@ 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) {
// The edit history goes with the record; a pinned session's demoted record keeps it.
if (this.store.demoteOrRemoveSession(sessionId) !== 'preserved') this.forgetEditHistory(sessionId);
this.store.demoteOrRemoveSession(sessionId);
}
}
@@ -1871,11 +1767,7 @@ 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);
// A real delete only: a detach (killMux=false) leaves the session running and re-attachable.
void this.discordBridge.sessionDeleted(sessionId).catch(() => undefined);
}
if (killMux) agentInbox.drop(sessionId);
sessionWaits.notifySignal(sessionId, 'exit');
sessionWaits.cancelAll(sessionId);
approvalInbox.resolveForSession(sessionId, 'session_ended');
@@ -2698,27 +2590,10 @@ 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.
@@ -2824,23 +2699,6 @@ 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);
}
/**
@@ -2954,24 +2812,13 @@ 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;
// 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;
}
if (!session.setPaneExit(getPaneExit(muxName))) continue;
this.persistSessionState(session);
this.broadcastSessionStateDebounced(session.id);
}
this.closeCleanlyExitedSessions();
@@ -3087,27 +2934,6 @@ 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;
@@ -3189,34 +3015,6 @@ 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}]` : '';
@@ -3358,20 +3156,6 @@ 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
@@ -3820,7 +3604,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) {
await this._restoreCustomModel(session, savedCustomModel);
session.setCustomModel(savedCustomModel, await this._rebuildCustomModelEnv(session, savedCustomModel));
}
if (saved.niceEnabled !== undefined || saved.niceValue !== undefined) {
session.setNice({ enabled: saved.niceEnabled, niceValue: saved.niceValue });
@@ -3956,9 +3740,6 @@ 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 });
@@ -4125,7 +3906,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) {
await this._restoreCustomModel(session, savedCustomModel);
session.setCustomModel(savedCustomModel, await this._rebuildCustomModelEnv(session, savedCustomModel));
}
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
@@ -4531,9 +4312,6 @@ 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();
@@ -4664,9 +4442,6 @@ 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)
-2
View File
@@ -49,8 +49,6 @@ 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;
-141
View File
@@ -1,141 +0,0 @@
// @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
});
});
+60
View File
@@ -0,0 +1,60 @@
/**
* @fileoverview A launch gate is measured against its OWN CLI's version.
*
* `capabilities.gates` used to be resolved with `getClaudeCliVersion()` for every entry,
* because claude was the only CLI with a gate. Codex's `noDaemonFlag` (0.162.0) would then
* have passed on any claude 2.x and handed an older codex `--no-daemon`, which makes codex
* exit on launch. The bridge now asks `resolveSessionCliVersion(entry.id)`; this pins that
* with both resolvers mocked to DIFFERENT versions.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const versions: Record<string, string | null> = {};
vi.mock('../src/utils/cli-resolver.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/utils/cli-resolver.js')>();
return { ...actual, resolveSessionCliVersion: vi.fn((mode: string) => versions[mode] ?? null) };
});
vi.mock('../src/utils/claude-cli-resolver.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/utils/claude-cli-resolver.js')>();
return { ...actual, getClaudeCliVersion: vi.fn(() => '2.1.316') };
});
const { getCli } = await import('../src/config/cli-registry/registry.js');
const { buildSpawnCommandFromRegistry, launchGatePasses } = await import('../src/session-cli-registry-bridge.js');
const SID = '00000000-0000-4000-8000-000000000000';
function codexCommand(): string | undefined {
return buildSpawnCommandFromRegistry(getCli('codex')!, { mode: 'codex', sessionId: SID });
}
describe('launch gates read the gated CLI version', () => {
beforeEach(() => {
for (const key of Object.keys(versions)) delete versions[key];
});
it("does not pass codex's gate on claude's version", () => {
versions.claude = '2.1.316';
versions.codex = '0.161.0';
expect(codexCommand()).toBe('codex');
expect(launchGatePasses('codex', 'noDaemonFlag')).toBe(false);
});
it("passes codex's gate on codex's own version", () => {
versions.codex = '0.162.0';
expect(codexCommand()).toBe('codex --no-daemon');
expect(launchGatePasses('codex', 'noDaemonFlag')).toBe(true);
});
it('fails closed when the codex version is unknown', () => {
versions.claude = '2.1.316';
expect(codexCommand()).toBe('codex');
});
it("still measures claude's gates on claude's version", () => {
versions.claude = '2.1.316';
versions.codex = '0.1.0';
expect(launchGatePasses('claude', 'nameFlag')).toBe(true);
});
});
+1 -60
View File
@@ -55,65 +55,6 @@ 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.
@@ -450,7 +391,7 @@ describe('cross-field integrity', () => {
// shipped entry must be on the param side of that line.
const codex = STOCK_CLIS.find((e) => (e.id as string) === 'codex');
expect(codex).toBeDefined();
expect(codex!.capabilities.privilegedParams.map((c) => c.param)).toEqual(['bypassApprovals']);
expect(codex!.capabilities.privilegedParams.map((c) => c.param)).toEqual(['bypassApprovals', 'networkAccess']);
expect(codex!.launch.legacyConfigAliases?.bypassApprovals).toBe('dangerouslyBypassApprovals');
const wrong = baseEntry('codex');
+24 -9
View File
@@ -171,15 +171,6 @@ 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}`);
@@ -191,6 +182,30 @@ describe('codex', () => {
'codex --model gpt-5 --config model_reasoning_effort=high resume roll_42'
);
});
it('emits the sandbox network-access config only when asked', () => {
expect(cx({ networkAccess: true })).toBe('codex --config sandbox_workspace_write.network_access=true');
expect(cx({ networkAccess: false })).toBe('codex');
});
// The `claudeCliVersion` option is the version every gate of THIS entry is measured
// against (the name predates a second gated CLI); undefined probes codex itself.
const cxv = (version: string | null, codexConfig?: SpawnBridgeOptions['codexConfig']) =>
render({ mode: 'codex', sessionId: SID, codexConfig, claudeCliVersion: version });
it('runs without the shared app-server daemon from 0.162.0, and fails closed below or unknown', () => {
expect(cxv('0.162.0')).toBe('codex --no-daemon');
expect(cxv('0.166.0')).toBe('codex --no-daemon');
expect(cxv('0.161.9')).toBe('codex');
expect(cxv(null)).toBe('codex');
});
it('keeps every flag ahead of the resume subcommand with the daemon flag on', () => {
expect(cxv('0.162.0', { dangerouslyBypassApprovals: true, networkAccess: true, resumeSessionId: 'roll_42' })).toBe(
'codex --no-daemon --dangerously-bypass-approvals-and-sandbox ' +
'--config sandbox_workspace_write.network_access=true resume roll_42'
);
});
});
describe('gemini', () => {
@@ -481,52 +481,6 @@ 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)', () => {
+6 -10
View File
@@ -53,12 +53,9 @@ describe('applyCustomModelInjection: context length', () => {
expect(applied?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBeUndefined();
});
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');
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']);
});
});
@@ -106,12 +103,11 @@ describe('applyCustomModelInjection: CLAUDE_CONFIG_DIR isolation', () => {
expect(applied?.envOverrides.HOME).toBe(customModelConfigDir(sessionId));
});
it('deepseek: creates an isolated profile patch', () => {
it('deepseek: no configDirVar declared, so no config dir is created at all', () => {
const sessionId = 'sess-cfgdir-deepseek';
sessionsToClean.push(sessionId);
const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', sessionId);
expect(applied?.configDir).toBe(customModelConfigDir(sessionId));
expect(existsSync(join(applied!.configDir!, 'cordis.patch.yml'))).toBe(true);
expect(applied?.configDir).toBeUndefined();
expect(existsSync(customModelConfigDir(sessionId))).toBe(false);
});
});
+10 -10
View File
@@ -188,18 +188,18 @@ describe('custom-model-injection contract (mock server)', () => {
expect(mock.requests[0].headers.authorization).toBe('Bearer contract-test-key');
});
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.
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.
const injection = buildCustomModelInjection(entryOrThrow('deepseek'), endpointFor(mock), 'qwen3');
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');
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`);
await callOpenAiCompat(provider.baseURL, injection.extraEnv!.CODEMAN_CUSTOM_MODEL_API_KEY, 'qwen3');
await callOpenAiCompat(injection.envOverrides.DEEPSEEK_BASE_URL, injection.envOverrides.DEEPSEEK_API_KEY, 'qwen3');
expect(mock.requests[0].path).toBe('/v1/chat/completions');
expect(mock.requests[0].headers.authorization).toBe('Bearer contract-test-key');
+17 -72
View File
@@ -8,7 +8,6 @@
*/
import { describe, it, expect } from 'vitest';
import { parse as parseToml } from 'smol-toml';
import { getCli } from '../src/config/cli-registry/index.js';
import {
buildCustomModelInjection,
@@ -113,31 +112,6 @@ 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');
@@ -159,30 +133,6 @@ 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');
@@ -229,32 +179,29 @@ describe('buildCustomModelInjection', () => {
expect(result.extraEnv).toEqual({ XAI_API_KEY: 'my-key' });
});
it('deepseek: isolates the selected model route and passes its key by reference', () => {
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)").
const result = buildCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3');
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');
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',
});
});
it('deepseek: preserves an existing version suffix', () => {
it('deepseek: appending the /v1 suffix is idempotent against a baseUrl that already ends in /v1', () => {
const result = buildCustomModelInjection(
entryOrThrow('deepseek'),
{ ...endpoint, baseUrl: endpoint.baseUrl + '/v1' },
{ ...endpoint, baseUrl: 'http://192.168.1.50:8080/v1' },
'qwen3'
);
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');
if (result.kind !== 'env') throw new Error('unreachable');
expect(result.envOverrides.DEEPSEEK_BASE_URL).toBe('http://192.168.1.50:8080/v1');
});
it('antigravity: unsupported', () => {
@@ -285,9 +232,7 @@ describe('launchModel (the model launch param that selects the injected provider
});
it('CLIs whose config selects the model on its own declare none', () => {
// 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']) {
for (const id of ['claude', 'opencode', 'codex', 'gemini', 'deepseek']) {
const result = buildCustomModelInjection(entryOrThrow(id), endpoint, 'qwen3');
if (result.kind === 'unsupported') throw new Error('unreachable');
expect(result.launchModel, id).toBeUndefined();
@@ -1,91 +0,0 @@
/**
* @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
);
});
});
-102
View File
@@ -1,102 +0,0 @@
/**
* @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);
});
});
+7 -387
View File
@@ -20,10 +20,6 @@ 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');
@@ -44,8 +40,6 @@ 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(
@@ -61,10 +55,6 @@ 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">
@@ -91,10 +81,6 @@ 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
@@ -329,27 +315,6 @@ 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'] }],
@@ -567,349 +532,9 @@ 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);
});
});
/**
* 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', () => {
describe('Custom Model Endpoint Profiles: _getCustomModelCurrentlyLoaded 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()
@@ -924,31 +549,26 @@ describe('Custom Model Endpoint Profiles: _getCustomModelRunningStatus is client
// endpoint that is asleep or firewalled, distinct from one that answers an error.
app._apiJson = () => new Promise(() => {});
const status = await app._getCustomModelRunningStatus({ id: 'llama-box', models: ['qwen3'] }, 5);
const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 5);
// `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();
expect(result).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 status = await app._getCustomModelRunningStatus({ id: 'llama-box', models: ['qwen3'] }, 5);
const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 5);
expect(app._pickReadyRunningModel(status, { id: 'llama-box', models: ['qwen3'] })).toBe('qwen3');
expect(result).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 status = await app._getCustomModelRunningStatus({ id: 'llama-box', models: ['qwen3'] }, 2);
expect(status).toBeNull();
const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 2);
expect(result).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.
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff. Show more