mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-05 06:59:33 +01:00
Compare commits
113
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ba5134174 | ||
|
|
2aa6016d33 | ||
|
|
1921a47aa8 | ||
|
|
4960bee779 | ||
|
|
eb3b08d58b | ||
|
|
7803d5616a | ||
|
|
345c901e81 | ||
|
|
e68afdd2ac | ||
|
|
4276b53a8f | ||
|
|
126d967ba3 | ||
|
|
f36bea86c7 | ||
|
|
9d04a4fb02 | ||
|
|
7c8d440928 | ||
|
|
91d157cae4 | ||
|
|
4f50af6a9f | ||
|
|
dc6f8d27e8 | ||
|
|
2526fdb0d8 | ||
|
|
76e9f364f9 | ||
|
|
8c317d84c8 | ||
|
|
4c29aedbc5 | ||
|
|
6232dffe03 | ||
|
|
f7dfe091c5 | ||
|
|
3bcb55ce96 | ||
|
|
7b8e295647 | ||
|
|
0dbfc8dc2d | ||
|
|
927c9a461e | ||
|
|
d9d6fad698 | ||
|
|
823013226d | ||
|
|
5e7ec1c522 | ||
|
|
1efe1d40c3 | ||
|
|
9d6f84f446 | ||
|
|
58f7dbc7ad | ||
|
|
7ed660ff11 | ||
|
|
457dd54825 | ||
|
|
de2bfb69c3 | ||
|
|
6670bc64b6 | ||
|
|
109fd98038 | ||
|
|
de43657676 | ||
|
|
4aeab0d9ce | ||
|
|
4623ee0d12 | ||
|
|
e1c792f491 | ||
|
|
1f0823c582 | ||
|
|
884e67d07e | ||
|
|
b177885dd7 | ||
|
|
5c8a557390 | ||
|
|
2cc5f3f9e6 | ||
|
|
181e419644 | ||
|
|
2db0449a54 | ||
|
|
82d251d52b | ||
|
|
d980d70173 | ||
|
|
fa2b81484b | ||
|
|
1464332e91 | ||
|
|
c646613c56 | ||
|
|
aece02da87 | ||
|
|
1b8ac40baf | ||
|
|
dbd1b6e587 | ||
|
|
a97fa0de17 | ||
|
|
5582ec523b | ||
|
|
ed67596387 | ||
|
|
6a23fecb1c | ||
|
|
7a3629e890 | ||
|
|
41186f5ac3 | ||
|
|
869633ae6d | ||
|
|
b549a66aa3 | ||
|
|
d5e4fe0f87 | ||
|
|
086249a386 | ||
|
|
68bd7e4d15 | ||
|
|
0bd15ddc84 | ||
|
|
d5a5468b02 | ||
|
|
c5d0e4b8b4 | ||
|
|
021641911e | ||
|
|
e5d82eb044 | ||
|
|
093f242662 | ||
|
|
d6c7d30bd4 | ||
|
|
97c698b249 | ||
|
|
9f18b78857 | ||
|
|
d044e49635 | ||
|
|
a698729fb6 | ||
|
|
b68792f988 | ||
|
|
72a59de96c | ||
|
|
212989b349 | ||
|
|
dc22519adb | ||
|
|
1ab54b9e9b | ||
|
|
0db9b55961 | ||
|
|
2d4036cf1e | ||
|
|
6d4357502b | ||
|
|
e95d38a944 | ||
|
|
eb0553a645 | ||
|
|
aedefab8b4 | ||
|
|
902d4491e5 | ||
|
|
e3ca07d59d | ||
|
|
dcf51b1715 | ||
|
|
1c4338e8b5 | ||
|
|
61bf26cfdf | ||
|
|
f23e18b1fc | ||
|
|
54c1692350 | ||
|
|
f91cd343be | ||
|
|
ee97daa4f0 | ||
|
|
3847e377d8 | ||
|
|
168c15ae08 | ||
|
|
fd0e52496d | ||
|
|
97d53f6621 | ||
|
|
1607b1e488 | ||
|
|
5ce8213726 | ||
|
|
a1406f1753 | ||
|
|
8d75e6bd15 | ||
|
|
affe91d7ed | ||
|
|
bf2a705705 | ||
|
|
1c87cd2ac7 | ||
|
|
321268754a | ||
|
|
f5ce80dce2 | ||
|
|
dcdeaab98d | ||
|
|
95e2272f89 |
No files matched your search
@@ -10,7 +10,7 @@
|
||||
"name": "codeman",
|
||||
"source": "./plugins/codeman",
|
||||
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.",
|
||||
"version": "1.47.0",
|
||||
"version": "1.48.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -1,5 +1,39 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.48.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 4960bee: ### Thanks
|
||||
- @opticon454 for a big round: the two-way Discord bridge (#741), custom endpoint context limits, Codex and DeepSeek model selection and the model picker (#752, #755, #757), Clone Repo into a custom folder (#744), the New folder button (#745), and the wrapped `.conf` file links (#735).
|
||||
- @Randalix for the Edit history panel (#693).
|
||||
- @aakhter for relative links that follow your `cd` (#740).
|
||||
- @JDProfresh for settings saves that say when they failed (#736), a visible press on the phone keys (#737), and two test harness fixes (#738, #742).
|
||||
- @timkjr for a tab strip that keeps its shape whichever tab is selected (#748).
|
||||
- @Hugo-DDT for the parallel fix to the same wrapped file link bug (#734), whose `Bash(` test rows landed with the guard below.
|
||||
|
||||
**Talk to your agents from Discord (#741).** An opt-in two-way bridge gives each tab its own thread: alerts go out, and a reply from an allowlisted account is typed into that tab. An agent that needs a person can run `codeman notify "<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
|
||||
|
||||
@@ -52,7 +52,7 @@ Uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`) via `@chan
|
||||
|
||||
When the user says **COM** (`COM` = patch, `COM minor`, `COM major`): **Read [`docs/releasing.md`](docs/releasing.md) and follow every step in order**; never improvise a release from memory. It covers the changeset, `npm run version-packages`, the `**Version**` line below, the branch check and explicit-path commit, deploy, the getcodeman.com badge, watching BOTH CI workflows, the MCP registry publish, and the Discussions announcement.
|
||||
|
||||
**Version**: 1.47.0 (must match `package.json`)
|
||||
**Version**: 1.48.0 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -131,6 +131,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked `agent-session-routes.ts` module (experimental, behind `agentCoordinationEnabled`, SYNCED, default OFF: all three routes answer `403 FORBIDDEN` naming it while off, and the CLI shows that verbatim); the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), while `spawn` without a case (cwd or `--working-dir`) is `POST /api/v1/sessions` plus `/interactive` or `/shell` without that header, so no agent-case marker, and refuses (exit 4, before any request) an empty `--working-dir` and a bare spawn from a HOME cwd, since it always sends an explicit `workingDir` and the server's no-`workingDir` hooks skip cannot apply (test/cli-agent-project-spawn.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md`
|
||||
|
||||
**Discord bridge (`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`)**: opt-in two-way Discord (a bot, not the one-way webhook), one thread per tab. Split like `webhook-notify.ts`: pure helpers, a 0600 store (`discord.json` holds the bot token, is never returned by these routes and is on the sensitive-path list; `discord-bridge.json` maps sessionId to thread id), then IO (`DiscordApi` REST with injected fetch, `DiscordGateway` inbound WebSocket with injected socket, `DiscordBridge` rules). Outbound hangs off `WebServer.sendPushNotifications` beside the webhook (so it works with zero push subscriptions) and tab events off `WebServer.broadcast` (`bridgeSessionEvent`); a finished turn (Claude's idle and stop hooks, DeepSeek's stop; the other CLIs raise no turn end, so theirs arrive with an error, a blocked respawn, a crash loop or `codeman notify`) or an error posts the CLI's WHOLE last message as markdown, read from its transcript through the response viewer's reader (`WebServer.lastResponseFor`, set by the session routes: the CLI's own transcript, or the pane segmented into turns for the CLIs that keep none; never for a shell), split over posts of at most 1900 characters and not posted twice (keyed on the text plus its turn, recorded only after the first post succeeds), while a dialog and every CLI without a transcript post the RENDERED tmux screen (`screenTail`, with the composer row and the status lines under it cut), never the raw stream, which a full-screen UI repaints in place; `staleThreads` (default `keep`) archives or deletes threads the bot owns that no tab maps to, after boot and then daily, never one under an hour old, never with an empty map and at most 25 per run. Inbound replies are typed through `session.writeViaMux`. ⚠️ Guards that live in code and tests pin: only `allowedUserIds`, in `guildId`, inside a thread the bridge created, are acted on, everything else is dropped unread; a reply is one printable line plus Enter (a lone digit while a permission or question is pending goes as the key alone, since the dialog acts on it), capped and rate limited, and `discord-notify` is a per-session token bucket (6 a minute, 429 `RATE_LIMITED`; `fileProblem` probes the folder with the bounded probe first); posts send `allowed_mentions` limited to the allowlisted ids; received files go only from Discord's own HTTPS hosts (max 5 x 8 MB, names reduced to `[A-Za-z0-9._-]`) into `<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,13 +185,13 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `<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. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key). ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config; the one exception is a recipe's `linkFromRealHome` (deepseek's `profiles`, `sessions`, `settings.yaml`), which symlinks those named entries back out of the real home so they survive the redirect. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key). ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
|
||||
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. The floor also divides that context by the model's parallel slot count (`modelSlotCounts`: `--parallel`/`-np` from `cmd`, else `/props` `total_slots`; `perSlotContext()`), and `/props` only ever RAISES that warning: it supplies the context only when `cmd` states none and the count only when `cmd` has no `--parallel`, never a larger pool. `/props` is probed once per launch command (it refreshes llama-swap's last-use time, so a `ttl` would never expire), and a `/props` context beside an explicit `--parallel N` is already per slot, so it is not divided again. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
|
||||
**MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `<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` and the experimental guarded stage (`stageGuardedInput()`, `POST /api/sessions/:id/stage`, typed on an agent's behalf like the input route) ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: by default only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title and a pinned one stops Claude generating its own; the opt-in synced `pinCliSessionName` (default OFF, re-read at every spawn and respawn) pins every tab name, after which a Codeman rename holds only until the next turn; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
|
||||
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path, `POST /api/sessions/:id/input`, the experimental guarded stage (`stageGuardedInput()`, `POST /api/sessions/:id/stage`, typed on an agent's behalf like the input route) and a Discord reply (`DiscordBridge.onGatewayMessage`, through the bridge's `sendInput`) ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: by default only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title and a pinned one stops Claude generating its own; the opt-in synced `pinCliSessionName` (default OFF, re-read at every spawn and respawn) pins every tab name, after which a Codeman rename holds only until the next turn; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
|
||||
|
||||
**Maintainer bot (external)**: the Telegram bot that reviews open PRs and triages discussion threads in Codeman sessions used to live at `scripts/pr-bot/`. It moved OUT of this repository on 2026-09-14, to `~/codeman-cases/prbot/` (its own private git repo, systemd unit `codeman-pr-bot`, guide + agent rules in its own `README.md` and `CLAUDE.md`). It is a CLIENT of Codeman's HTTP API like any other, so nothing here depends on it and it is not part of the server, the CLI or the npm package. ⚠️ It spawns real sessions named `prbot-<n>` / `dscbot-<n>` on the local Codeman and holds clones under `~/.codeman/pr-bot/`, so those session names and that data dir are taken; it also fetches PR heads into `refs/pr-bot/*` of this checkout and must never check out, reset or clean it. The CHANGELOG entries for 1.25.0 and earlier still describe it, which is history rather than drift.
|
||||
|
||||
@@ -226,13 +228,13 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Prompt uploads** (`POST /api/sessions/:id/paste-image`: clipboard paste, drop, the Compose Attach key and the phone toolbar's Attach button; client wiring in `image-input.js` and `keyboard-accessory.js`): a pick is written to `<workspace>/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `<workspace>/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. Images are buffered in memory under `MAX_PASTE_IMAGE_BYTES` (50MB) after a client-side canvas re-encode; a DOCUMENT (pdf, docx, pptx, xlsx, zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml; the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`, which carry sh/py/js/html) is admitted by its name's extension alone, buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (`%PDF-`, `PK\x03\x04` for the OOXML trio and zip, UTF-8 without a NUL byte for text: a SIGNATURE or ENCODING check, never a structure or content check, so generic zip bytes pass as `.docx` and a script renamed `.txt` is text; the sh/py/js/html exclusion is a SCOPE choice by extension, keeping the server from storing what it would serve or run, not a content filter: the agent can already write any of those) and saved as `paste-<ts>-<hex>-<stem><ext>` with a sanitized stem (`[A-Za-z0-9._-]`, 40 chars, a wholly non-ASCII name leaves no stem; the `paste-` prefix is what the GC matches). A VIDEO is never buffered: `?kind=video` streams it to disk under `MAX_PASTE_VIDEO_BYTES` (1GB, env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), judges the container on the first 12 bytes as they arrive (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv; a mislabeled body is drained unwritten from there, never parked on disk first) and unlinks on any failure, a dead transfer included. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced: mp4/webm/mov/m4v/ogv, so an uploaded video is one the file viewer can also play; 3gp/mkv are out, since a phone's camera roll produces neither and neither previews), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. Busboy takes its size limit before the part is seen, which is why the choice rides the URL and not the file. ⚠️ A video draws on its OWN per-(IP, session) rate bucket (`consumeVideoToken`, 3/min) beside the image bucket (`consumePasteToken`, 30/min): at 1GB each, 30/min would be 30GB of disk a minute, so the lower rate bounds it to 3GB; the image path keeps the 30/min bucket and is unchanged without the flag. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it (Docker is fine, the workspace is bind-mounted at the same absolute path). ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (`probePathKind(session.workingDir, { pastCap: true })`, the #516 rule) before the first disk touch and refuses an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). ⚠️ Dispatch mirrors the client: an `image/*` MIME takes the IMAGE path whatever the name says (a GIF named `animation.txt` stays a GIF), a document extension takes the document branch, and anything else takes the image path exactly as before, where the HEIC sniff still recovers a HEIC under a generic name; a foreign extension with no image MIME gets its `Unsupported file type` 400 only AFTER that 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 `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view/<cap>/...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
|
||||
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which reserves spaced-path spans first (an unlinkable one only while its last word holds a `/`; any label, before an absolute or relative path, must be an explicit path label, prose after that label stays out, and an earlier connective or verb such as `and` or `has` (`SPACED_PATH_PROSE_WORD`) makes any candidate prose; rules and trade-offs in the architecture-invariants paragraph linked below), then adds rooted, `~/`, relative and folder paths without overlapping a reservation, earlier match or URL; a relative path resolves against the pane's live directory `SessionState.paneCwd` when known, else `workingDir` (`relativeResolutionBase()` + `resolveLinkedFilePath`; `paneCwd` is only a BASE, never a confinement root: routing and the file routes still judge against `workingDir`, and remote/docker/direct-PTY/rediscovered sessions never get one, scoped by `paneExitApplies`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. An ambiguous parenthesised hard wrap is checked only on activation, original first: only a 404 permits a spaced-path lookup, and only a registered alternative is opened; every other original result uses the normal opener, never an attachment-policy gate on workspace files, and hover performs no lookup. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view/<cap>/...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
|
||||
|
||||
The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens, whole email addresses, labels ending in `:`, measurements and clock times (`45%`, `12.3ms`, `10:00:00`) and a `name=value` field under a URL ending in `/` never continue a URL; a token with only `-`, `_` or `.` is also refused when it carries a call paren (`client.get()`) or ends a sentence (`Done.`). A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs.
|
||||
|
||||
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
|
||||
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping**: it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing has it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > New Case makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
|
||||
|
||||
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
|
||||
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
|
||||
|
||||
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
|
||||
|
||||
@@ -240,11 +242,13 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
|
||||
|
||||
**Git fetch** (`src/git-fetch.ts`, `src/git-fetch-scheduler.ts`, `routes/git-status-routes.ts`, the panel's **Fetch** button): `POST /api/sessions/:id/git-fetch` is the ONLY git command the Git PANEL runs that WRITES (cloning and worktree management write too, but only when the user asked for them at that moment), and it is the only one that can run later by itself, on a timer, and the only one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config commands such as `credential.helper`), which is why it is opt-in and never runs on a timer until the user turns it on. ⚠️ Fetch updates remote-tracking refs only; it must never commit, check out, merge or touch the working tree. ⚠️ It is not as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref`, hence one folder is fetched by at most one run at a time (`fetchWorkspaceOnce` joins a run already going), and `FETCH_CONFIG_ARGS` (`-c core.fsmonitor=false -c maintenance.auto=false -c gc.auto=0`) keeps an unattended fetch from starting a gc in a repository the agent is using. ⚠️ `buildFetchArgs()` is `['fetch', '--quiet']` and must stay verb-invariant (`test/git-fetch.test.ts`); those `-c` overrides are added by the runner, not by `buildFetchArgs()`. ⚠️ The git timeout must reach the whole process TREE, not just the `git` parent: `execFile` signals the parent only, so `runFetch` spawns `detached` and signals the negative pid (SIGTERM, then SIGKILL `FETCH_KILL_ESCALATION_MS` later), and the caller is answered AT the timeout while the group is still dying: that is the point. `test/git-fetch.test.ts` proves it against a local server that never answers, by group membership rather than parentage. A non-zero exit must still reject the way `execFile` would, or a real failure reads as a clean run with no reason. ⚠️ The same Docker rule as above applies BEFORE any git runs (a repository at or inside a Docker case workspace is refused), and remote sessions answer `unsupported`. ⚠️ Keep it non-interactive (`gitNonInteractiveEnv()`: `GIT_TERMINAL_PROMPT=0` + BatchMode ssh) or a fetch hangs on an invisible login prompt, but that BatchMode ssh is a DEFAULT, never an override: `fetchEnvFor` must not overwrite the repository's `core.sshCommand`, the user's own `GIT_SSH_COMMAND` or a `GIT_SSH` (`readRepoSshCommand` reads it inside the same limiter slot and the same wall-clock bound as the fetch it serves, and the spawn gets only what the read left), and `StrictHostKeyChecking=accept-new` writes an unknown host key into `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. Keep it bounded: `MAX_REPOS`/`MAX_REPOS_LIMIT` (12/50), `MAX_FETCH_OUTPUT_BYTES` (2 MB), the git timeout per spawn, and `FETCH_CONCURRENCY` (4) as ONE `Limiter` shared by the fetches of the whole tick: `createLimiter` keeps its count in the closure, so a limiter that delegated to `mapLimited` per call bounds each call, and 4 folders × 4 repositories would start 16 git processes. ⚠️ One semaphore cannot bound both the folders and the fetches: a folder holding a slot would then wait for a slot its own fetch needs, so the tick bounds its folders with `mapLimited` and hands the shared bound to every run. ⚠️ Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`) and the browser writes it with `textContent`. Scheduler semantics: settings are read fresh each tick (`GIT_FETCH_TICK_INTERVAL_MS`, 15 s), a per-session override wins even while the app-wide switch is off, the interval is timed from the run START, state is keyed by FOLDER (sessions sharing one folder are the same clone, so one run serves them all, the schedule is stamped at selection, and every session on a folder is remembered, a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins (both orders are tested), and the folder is probed once for the whole group rather than once per session on it: closing the one that holds the override must not stop the folder's fetching while another session is still open on it), a tick is skipped while the previous one is still going, and a user-chosen folder is probed BOUNDED with the default options before anything runs (rule #516: `unknown` is never read as `absent`). ⚠️ The two settings (`gitFetchEnabled`, `gitFetchIntervalSeconds`, off/300 by default, clamped 30–3600) are **synced**, not per-device: they must stay out of `displayKeys` and out of the strip destructure in settings-ui.js, or the scheduler reads nobody's value. Per-session state rides `SessionUpdated` (no new SSE event) as `gitFetch`, and the click route publishes the run's trigger, not the request's: `trigger: 'click'` only when the click was the run, because a click that joined a tick already going fetched nothing and cannot report 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). → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case)
|
||||
**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). ⚠️ A `path` is the folder cloned INTO (no case-name subfolder) and registers a linked case: admin-only in multi-user mode, judged by `prepareNewCasePath()`, empty or new only; an empty one is cloned into directly and never removed, and emptied after a failure only when git really ran and did not refuse it. ⚠️ A destination at or inside a Docker case workspace is refused `400` on both branches: git would run the new repository's hooks and filters on the host. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case)
|
||||
|
||||
**Git worktrees of a case** (case picker → **Git worktrees…**; `src/git-worktrees.ts`, `routes/worktree-routes.ts`): a local repository case keeps worktrees at `<repo>/.claude/worktrees/<name>` on `worktree-<name>`, Claude Code's own `--worktree` layout. ⚠️ Only that managed path is ever created or removed, every component `lstat`ed and realpath-checked (a symlinked `.claude` is refused). ⚠️ git never runs for a Docker or remote case, nor at or inside a Docker workspace (`worktree add` runs hooks and filters on the host). ⚠️ Remove is `git worktree remove` WITHOUT `--force`, refused while a live session is inside; branch delete is `-d`, never `-D`. ⚠️ Sessions start through the existing create paths (quick-start `worktree`, or `POST /api/sessions` `workingDir`), never a new spawn path. → [architecture-invariants#git-worktrees-of-a-case](docs/architecture-invariants.md#git-worktrees-of-a-case)
|
||||
|
||||
@@ -315,7 +319,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
|
||||
|
||||
## State Files
|
||||
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/<id>.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
|
||||
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<a href="https://github.com/Ark0N/Codeman/stargazers"><img src="https://img.shields.io/github/stars/Ark0N/Codeman?style=flat-square&color=eab308" alt="GitHub stars"></a>
|
||||
<a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></a>
|
||||
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
||||
<a href="https://github.com/NipunaRanasinghe/awesome-ai-agents"><img src="https://img.shields.io/badge/Featured_in-Awesome_AI_Agents-2ea44f?style=flat-square" alt="Featured in Awesome AI Agents"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -479,6 +480,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
||||
- **OSC 52 clipboard**: a program in a session that sets the clipboard with OSC 52 (Claude Code's `/copy`, a tmux yank, vim) puts the text on your browser's clipboard, in the main terminal and in every tile. Clipboard read requests are refused, and replaying a buffer never copies
|
||||
- **MCP server sync** _(opt-in)_: copies the user-level MCP servers each installed CLI has (Claude, Codex, Gemini, OpenCode, Antigravity, and GitHub Copilot CLI) into the others' own config files. Additive only, a server you switched off is never copied, **Preview** shows the changes first, and the previous file is kept as `.codeman-bak`. App Settings → Agents & CLIs; admin only in multi-user mode
|
||||
- **CJK / IME input** — full composition support for Chinese / Japanese / Korean, with Ctrl- and Alt-modified navigation keys passed through to the CLI
|
||||
- **Discord bridge**: opt-in and two-way: Codeman posts to a private Discord server when an agent needs you, in a thread per tab with what is on its screen, and types your reply (and any file you attach) into that tab, so a blocked agent can be answered from your phone off-network. Agents can ask for you with `codeman notify`. Outbound connections only; least-privilege bot setup in [docs/discord-bridge.md](docs/discord-bridge.md). See the [wiki page](docs/wiki/Discord-Bridge.md)
|
||||
- **Config backups** — timestamped copies of the files that hold your customization (settings, CLI overrides, custom endpoints, webhook, linked cases, remote and Docker hosts, saved dashboards and, in multi-user mode, accounts), made at startup and whenever one changes. On by default into `~/.codeman/backups/config`; the folder, how many to keep (2–500) and how long (days, 0 = no limit) are settings, with **Back up now** and a verified **Restore** in Settings → System. Retention only ever deletes folders Codeman made and never the newest. See the [wiki page](docs/wiki/Config-Backups.md)
|
||||
- **Plan usage in the header** — live Claude subscription usage (the 5-hour and weekly windows) from a statusline exporter Codeman hands to `claude` at spawn and never writes into your settings files, plus Codex limits from its own app-server and the GitHub Copilot monthly premium-request quota (the server asks `api.github.com` for it every 10 minutes with Copilot's own sign-in token: on by default while Copilot CLI is installed and signed in, and turning off Plan Usage Limits stops it); per device, on for desktops and off for phones
|
||||
- **Prompt-cache readout**: each Claude session shows whether its prompt cache is warm (`cache:until HH:MM`) or cold, in the footer (when you have no statusline of your own) and in Session Options → Respawn, with the cost of a re-cache and a note after a compaction. Display only; it survives a restart
|
||||
@@ -975,6 +977,7 @@ codeman session list # list sessions
|
||||
codeman session logs <id> # tail output
|
||||
codeman task add "fix the failing test" # (t) queue a task
|
||||
codeman attach <path> # show an attachment card for a local file
|
||||
codeman notify "<message>" [--urgent] [--file <path>] # post to this tab's Discord thread (see the Discord bridge)
|
||||
codeman tui --list # numbered session list (plain text when piped)
|
||||
codeman tui 3 # attach to session 3 of that list
|
||||
```
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<a href="https://github.com/Ark0N/Codeman/stargazers"><img src="https://img.shields.io/github/stars/Ark0N/Codeman?style=flat-square&color=eab308" alt="GitHub stars"></a>
|
||||
<a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></a>
|
||||
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
||||
<a href="https://github.com/NipunaRanasinghe/awesome-ai-agents"><img src="https://img.shields.io/badge/Featured_in-Awesome_AI_Agents-2ea44f?style=flat-square" alt="Featured in Awesome AI Agents"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "io.github.Ark0N/codeman",
|
||||
"title": "Codeman",
|
||||
"description": "Start, prompt and read AI coding agent sessions (Claude Code, Codex, Gemini CLI and more) in Codeman",
|
||||
"version": "1.47.0",
|
||||
"version": "1.48.0",
|
||||
"websiteUrl": "https://getcodeman.com",
|
||||
"repository": {
|
||||
"url": "https://github.com/Ark0N/Codeman",
|
||||
@@ -13,7 +13,7 @@
|
||||
{
|
||||
"registryType": "npm",
|
||||
"identifier": "aicodeman",
|
||||
"version": "1.47.0",
|
||||
"version": "1.48.0",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
* why these are a runnable suite (`npm run test:browser`) rather than skipped.
|
||||
*/
|
||||
export const BROWSER_TEST_GLOBS = [
|
||||
'test/wrapped-file-paths.browser.test.ts',
|
||||
'test/command-palette-agent-arrows.browser.test.ts',
|
||||
'test/tmux-capture-sgr.browser.test.ts',
|
||||
'test/tab-rail-resize.browser.test.ts',
|
||||
@@ -49,6 +50,7 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/case-custom-path.browser.test.ts',
|
||||
'test/doctor-settings.browser.test.ts',
|
||||
'test/git-status.browser.test.ts',
|
||||
'test/edit-history.browser.test.ts',
|
||||
'test/split-pane-orchestration.browser.test.ts',
|
||||
'test/split-pane-auto-collapse.browser.test.ts',
|
||||
'test/spreadsheet-preview.browser.test.ts',
|
||||
@@ -57,6 +59,7 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/markdown-anchor-links.browser.test.ts',
|
||||
'test/fullscreen-tab-switch-capture.browser.test.ts',
|
||||
'test/terminal-touch-swipe-axis.browser.test.ts',
|
||||
'test/phone-press-feedback.browser.test.ts',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+80
-7
@@ -900,6 +900,16 @@ already serves non-media text inert (`text/plain`, `nosniff`), so this just
|
||||
keeps the server from storing what it would serve or an agent could run, and
|
||||
html and svg are the ones deliberately kept out.
|
||||
|
||||
## New folder in the path picker
|
||||
|
||||
`POST /api/filesystem/mkdir` with `{ path, name, sessionId?, showHidden? }` makes one
|
||||
folder directly inside `path`, which must be a folder the path picker may browse, and
|
||||
answers `{ path }`. `name` is one path segment (letters, numbers, spaces and `_ - . ~`, not
|
||||
starting with a dot or `~`). `400` for a cases folder (use Add Case → New Case) or a `path`
|
||||
that is not a directory, `403` for a blocked tree or a `path` outside the browse roots,
|
||||
`404` for a `path` that does not exist, `409` when the folder already exists, and `422`
|
||||
when the mount under `path` does not answer.
|
||||
|
||||
## Rendered HTML files
|
||||
|
||||
An `.html`/`.htm` file renders as a page in a sandbox, in two steps
|
||||
@@ -969,6 +979,18 @@ also pure decoration: it confers no permission, and a child is unaffected by its
|
||||
parent exiting. It appears on session state as `parentSessionId` (absent when
|
||||
unresolved) and survives a server restart.
|
||||
|
||||
## Pane directory (`paneCwd`)
|
||||
|
||||
Session state (`GET /api/v1/sessions`, the `session:updated` event) may carry `paneCwd`,
|
||||
the directory the session's local tmux pane is in now (`#{pane_current_path}`), which
|
||||
the web UI uses as the base for a relative path printed in the terminal. It is
|
||||
never restored after a restart (it is written to `state.json` with the rest of the
|
||||
session state but not read back), absent for remote,
|
||||
Docker, direct-PTY and rediscovered sessions and for a session split into several panes,
|
||||
and never a confinement root: file routes stay confined to `workingDir`.
|
||||
tmux reports the resolved directory, so for a workspace reached through a symlink a
|
||||
directory at or under the resolved workspace is reported in `workingDir`'s spelling.
|
||||
|
||||
## Session model (`displayModel`)
|
||||
|
||||
Session state (`GET /api/v1/sessions`, the `session:updated` event) carries the model a
|
||||
@@ -1205,24 +1227,47 @@ data}` envelope on the wire — unwrap it the same way). Answers `[]` for a
|
||||
boolean` reports whether one is stored, so a client can render "unchanged
|
||||
if left blank" without ever holding the real value.
|
||||
- `POST /api/v1/model-endpoints` with `{ id, label, baseUrl, apiKey?,
|
||||
authStyle?, defaultModelId? }` creates one. `id` must match
|
||||
authStyle?, defaultModelId?, defaultContextLength? }` creates one. `id`
|
||||
must match
|
||||
`^[a-zA-Z0-9_-]+$`; `authStyle` is `bearer` (default) or `api-key`, never
|
||||
both (a real server hung indefinitely when sent both headers on one
|
||||
request); `baseUrl` must be `http(s)`, carry no embedded credentials, and
|
||||
request); `defaultContextLength` is a user-set fallback used when
|
||||
assigning a model that discovery has no context length for (discovery
|
||||
itself never writes it); `baseUrl` must be `http(s)`, carry no embedded
|
||||
credentials, and
|
||||
is refused if it points at (or resolves to) a link-local or
|
||||
cloud-metadata address. `409 ALREADY_EXISTS` on a duplicate id.
|
||||
- `PUT /api/v1/model-endpoints/:id` updates one. An **absent** `apiKey`
|
||||
- `PUT /api/v1/model-endpoints/:id` updates one, also accepting
|
||||
`defaultContextLength`. An **absent** `apiKey`
|
||||
keeps the stored one rather than clearing it — the client never receives
|
||||
the real value to resend deliberately unchanged, so omission is the only
|
||||
way to say "leave it alone"; there is no way to clear a key back to unset
|
||||
this way. `defaultModelId`, when set, must be one of that endpoint's own
|
||||
this way. `defaultContextLength` behaves the opposite way: a PUT without
|
||||
it clears the stored value, so send the field back to keep it.
|
||||
`defaultModelId`, when set, must be one of that endpoint's own
|
||||
`models` (`400 INVALID_INPUT` otherwise).
|
||||
- `DELETE /api/v1/model-endpoints/:id` removes one.
|
||||
- `POST /api/v1/model-endpoints/:id/discover-models` fetches the endpoint's
|
||||
own `GET /v1/models` and stores the result as `models`, updating
|
||||
`lastDiscoveredAt`, plus (best-effort, only for a model llama-swap's own
|
||||
response already reports loaded) `modelContextLengths` and `modelSizesGB`.
|
||||
A `defaultModelId` that no longer appears in the fresh list is dropped
|
||||
`lastDiscoveredAt`, plus (best-effort) `modelContextLengths`,
|
||||
`modelSlotCounts` and `modelSizesGB`: every listing entry is read for a
|
||||
stated context figure (`context_length`, then `context_window`) and a
|
||||
size, BEFORE any loaded check; only the launch-command/`/props`
|
||||
enrichment needs a model llama-swap's own response reports loaded. A
|
||||
loaded model's context figure comes from llama-swap's own launch command
|
||||
(`--fit-ctx` / `-c` / `--ctx-size`), falling back to `/props` (`n_ctx`,
|
||||
`total_slots`) for whatever the command did not answer, and OVERWRITES the
|
||||
listed figure for that model; the model's own listing
|
||||
(`context_length`, then `context_window`) only ever fills the gap when
|
||||
neither probe does, and is recorded as a single-slot figure:
|
||||
llama-swap lists the PER-SLOT window, so whenever the listed figure is
|
||||
the one taken the stored slot count is 1. A server with no `status`
|
||||
field on any entry (plain llama.cpp, a non-llama-swap gateway) gets no
|
||||
launch-command/`/props` enrichment at all, only whatever its listings
|
||||
state. `modelContextLengths` falls back to the
|
||||
endpoint's `defaultContextLength` when a session is assigned a model
|
||||
discovery never learned a window for. A `defaultModelId` that no longer
|
||||
appears in the fresh list is dropped
|
||||
rather than carried forward invalid. Failures answer `422 OPERATION_FAILED`
|
||||
with the underlying connection error, or a named egress refusal if the
|
||||
resolved address turned out to be blocked. The same refresh also runs
|
||||
@@ -1315,6 +1360,8 @@ The target is judged before anything is written:
|
||||
|
||||
Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes outside the cases directory and into the shared, ownerless registry. If anything fails after the first write, what this call created is removed (the whole folder if it created it, otherwise only the scaffold inside the empty folder you picked) and the response is `500`.
|
||||
|
||||
`POST /api/cases/clone` takes the same optional `path`, with `{ name, repository, ref?, shallow?, description?, path? }`. Without `path` the repository is cloned to `<cases dir>/<name>` as always. With `path` the repository is cloned **into that exact folder** (no `<name>` subfolder is added) and registered as a linked case, judged by the same rules as above: `400` for an invalid or blocked path (or a symlink or file at the target), `404` for a missing parent, `422` for an unreachable parent, and `409 ALREADY_EXISTS` for a folder that has files, a name already in use or a folder that is already a case. Admin only in multi-user mode (`403`). An existing empty folder is cloned into directly and is kept, emptied again, if the clone fails; a second clone into the same folder while one is running is refused with `409`. A destination at or inside a Docker case workspace is refused `400 INVALID_INPUT` (git would run the new repository's hooks and filters on the host, and a container can write there), and `503` when the Docker case list cannot be read. If the clone succeeds but the registry write fails (or the name was linked while git ran, which is `409`), the cloned folder is kept and the error names it: `500 OPERATION_FAILED`, then use Link Existing.
|
||||
|
||||
## Git status
|
||||
|
||||
`GET /api/sessions/:id/git-status` is what the bottom-bar Git indicator and its panel read (Settings → Header & Panels → Bottom bar, per-device, default off). It reports what the session's workspace has not committed or pushed. **Read-only and offline:** it never fetches, pulls, commits or writes (it runs `git status` with `--no-optional-locks`, so it does not even refresh the index), which is why `behind` is as of the last `git fetch` (the panel's Fetch button is a separate route, below). The session is resolved like every session route (ownership via `findSessionOrFail`; another user's session is `404`). A repository whose root is, or is inside, a Docker case workspace is dropped (from the walk-up, the scan below a folder, and the diff route): a container can write there, and a repository's own clean filter or signature program would run on the host. When a branch's upstream does not exist on the remote (deleted and pruned, or never pushed, as after cloning an empty repository and committing), `upstreamGone` is `true` and the unpushed list falls back to commits on no remote-tracking ref at all.
|
||||
@@ -1346,6 +1393,19 @@ Lists are capped (300 files and 50 commits per repository) while the counts stay
|
||||
|
||||
The interval is two **synced** settings in `PUT /api/settings` (`src/git-fetch-scheduler.ts`): `gitFetchEnabled` (default `false`) and `gitFetchIntervalSeconds` (30 to 3600, default 300, the gap between runs timed from the start of a run so a slow repository does not push the next one out). Synced, not per-device, because the server is what acts on them; the scheduler reads them fresh on each 15 s tick, so a change works without a restart. A session can override both, and the override wins even while the app-wide switch is off: `POST /api/sessions/:id/git-fetch-settings` takes `{ "enabled": true | false | null, "intervalSeconds": number | null }` (`null` on a field clears that half so the app value applies again, a field left out of the body leaves the other half as it was), is `400 INVALID_INPUT` for a remote or Docker session, and answers `200` with `{ gitFetchSettings: { enabled, intervalSeconds } }` where `null` means the session follows the app. What the last run did rides the session state as `gitFetch` (`{ ranAt, trigger, state, repos, failed, firstFailure? }`, `trigger` is `click` or `interval`), on the existing `SessionUpdated` event: no new event. Sessions on the same folder share one run and both see its result, the schedule is keyed by folder, and a folder is fetched by at most one run at a time; a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins, and the folder is probed once for the whole group rather than once per session on it; every session open on a folder is remembered, so closing the one that holds the override does not stop that folder's fetching while another session is still open on it. The tick skips anything its bounded probe does not confirm is a directory, stamps each folder's schedule when it picks it, and does not run a second tick over a selection still going. Remote and Docker sessions are never fetched on a timer, and the Docker rule is re-checked against the CURRENT Docker roots every tick. Both fetch paths fail closed on a Docker case list (`docker-cases.json`) that exists but cannot be read or parsed: a click answers `200` with `state: 'error'` and publishes that as its `gitFetch`, and a tick fetches nothing and leaves its folders due for the next tick, since without the list nothing tells which folders a container can write to (the read-only status and diff routes keep reading such a list as no Docker workspaces).
|
||||
|
||||
|
||||
## Edit history
|
||||
|
||||
What the Edit history panel reads (Settings → Header & Panels → Header, per-device, default off): the edits a **local claude session** made, main agent and subagents, read from its transcripts. Read-only. Every route resolves the session through `findSessionOrFail` (another user's session is `404`). Remote, Docker and non-claude sessions answer `state: 'unsupported'` and are never read.
|
||||
|
||||
`GET /api/sessions/:id/edits[?sync=1]` lists the stored edits, newest first, without diffs: `{ state, reason?, backfillTruncated, agents, entries }`. With `sync=1` it first reads what the transcripts gained since the last read (after `/clear` or a resume, the previous conversation's transcripts to their end first); without it the server reads nothing. `state` is `ok`, `unavailable` (stored entries only; `reason` is `no-transcript` before the session has a transcript Codeman can find, `removed` while the session's history is being deleted, or the read error; the transcript is the one the session's hooks report, else the newest `<id>.jsonl` under the session's `CLAUDE_CONFIG_DIR` override or the server's config dir) or `unsupported` (`reason`: `remote`, `docker`, `mode`). Each entry is `{ id, ts, path, relPath, kind: 'code'|'image', tool, created, added, removed, truncated, agentId? }`, where `tool` is the edit tool, `bash` for an image a Bash call produced or `sent` for one the agent sent; `relPath` is relative to the workspace (a subagent's worktree for its edits). `agents` maps a subagent id to `{ description, agentType, worktreeBranch? }`. `backfillTruncated: true` means older edits can still be loaded.
|
||||
|
||||
`GET /api/sessions/:id/edits/:entryId` returns one edit's diff as unified text: `{ diff, truncated }` (each edit's diff is capped at 64 KiB). `404` for an edit the session does not have.
|
||||
|
||||
`POST /api/sessions/:id/edits/older` reads the next older block, within the current conversation, of up to three transcripts, a full read budget each (with more transcripts, main and subagents take turns), and answers like the list.
|
||||
|
||||
The edits are stored per session in `~/.codeman/edit-history/<id>.json` (mode 0600, at most 500 edits and 4 MiB of diff text, the oldest dropped), deleted when the session's record is removed (a killed pinned session keeps it; at startup, a file whose session no longer exists is deleted too) and never fed into `/api/v1/search`. No route here serves a file: the panel shows thumbnails through `file-raw` inside the workspace only; an image outside it opens in the file preview, which registers it through the attachment route.
|
||||
|
||||
## Git worktrees of a case
|
||||
|
||||
A local case whose folder is the top of a git repository can keep **worktrees** at `<case>/.claude/worktrees/<name>`, each on its own branch, so several agents on one repository never share a checkout. This is the layout Claude Code's own `claude --worktree <name>` uses (branch `worktree-<name>`), so either tool finds the other's worktrees. Remote (SSH) and Docker cases are refused with `400 INVALID_INPUT` before git runs, and so is a local case whose folder is at or inside a Docker case workspace: `git worktree add` runs the repository's post-checkout hook and checkout filters on this machine, and a container can write there. A case folder that is not a repository, is a bare repository, or sits below the repository's top level is `400` too. The case resolves the way quick-start resolves it (a linked case is admin-only in multi-user mode, everything else is the caller's own case space; another user's case is `404`), and a case folder on a mount that does not answer is `422 OPERATION_FAILED`, never `404`.
|
||||
@@ -1486,6 +1546,19 @@ Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Setting
|
||||
|
||||
Delivery goes through the same egress guard as web tabs (refused on the resolved address too), does not follow redirects, times out after 5 s, sends the same event for the same session at most once per 3 s, and has at most 5 requests in flight. Error text never contains the URL.
|
||||
|
||||
## Discord bridge
|
||||
|
||||
One thread per tab in a private Discord channel, alerts out and replies typed back in. Setup (bot, least-privilege permissions, IDs) is in [discord-bridge.md](discord-bridge.md). The bot token is a bearer secret: stored in `~/.codeman/discord.json` (0600), never returned, and the three settings routes are admin only in multi-user mode.
|
||||
|
||||
| Method | Path | Body | Notes |
|
||||
| ------ | ------ | ------ | ------ |
|
||||
| `GET` | `/api/discord` | none | Config without the token, plus `hasToken`, `tokenMasked`, `gateway` (`off` \| `connecting` \| `connected` \| `error`), `gatewayError` and `lastResult`. |
|
||||
| `PUT` | `/api/discord` | `{ enabled?, token?, guildId?, parentChannelId?, allowedUserIds?, threadCreation?, onSessionDelete?, staleThreads?, pingMode?, scope?, startupHint? }` (strict) | `threadCreation`: `lazy` \| `onSessionStart`. `onSessionDelete`: `keep` \| `archive` \| `delete`. `pingMode`: `off` \| `critical` \| `all`. `scope`: `attention` \| `all`. An absent `token` keeps the saved one; `""` clears it. `400` for a non-numeric id or enabling without a token, guild, channel and at least one allowed user. |
|
||||
| `POST` | `/api/discord/test` | none | Posts one message to the parent channel with the saved config, even while disabled. `200` with `data.ok`; `400` if no token or channel is saved. |
|
||||
| `POST` | `/api/sessions/:id/discord-notify` | `{ message, urgent?, file? }` (strict, message 1 to 1500 characters) | A CLI asks for the user: posts into that tab's thread, pinging when `urgent`. `file` is an absolute path inside the tab's working folder (real path, regular file, at most 8 MB, not on the attachment blocklist, local sessions only) uploaded with the post; `400` otherwise. A `200` carries the delivery result: `data.ok` is `false` (with `data.error`) when Discord refused or failed the post. `409` while the bridge is disabled, `429` past 6 posts a minute per tab. The `codeman notify "message" [--urgent] [--file path]` command wraps this route. |
|
||||
|
||||
Only replies from `allowedUserIds`, in `guildId`, inside a thread the bridge created are typed into a session, as one printable line plus Enter (a lone digit while a permission or question is pending goes as the key alone), at most one per second per tab. Attached files are saved under `.codeman-discord/` in the tab's folder and their paths appended to that line (see [discord-bridge.md](discord-bridge.md)).
|
||||
|
||||
## Diagnostics
|
||||
|
||||
`GET /api/doctor[?category=core|office|other]` returns the `codeman doctor --json` report (`platform`, `summary`, `tools[]` with `status` `ok` \| `missing` \| `outdated` \| `skipped` \| `error`, `version`, `path`, `installHint`). The probe engine is synchronous, so it runs in a child process of the same entry script, never on the server's event loop (30 s timeout). It names install paths and versions, so it is admin only in multi-user mode (`403`). `400` for an unknown category, `500` if the child produces no report.
|
||||
|
||||
@@ -205,7 +205,7 @@ History: before the tree, every pair got a U-bridge bezier hanging below the str
|
||||
|
||||
**Three owners, one setter.** `SessionState.nameSource` is `placeholder` | `auto` | `manual`. The constructor infers a missing value from the name (`isGeneratedSessionName()` = `w<n>-<case>` / `s<n>-<case>`, or no name at all, is a placeholder; anything else was a person's), the create routes pass none, the boot restore passes the persisted one. The `name` setter is the manual path and the ONLY thing that produces `manual` after construction; `applyAutoName()` is the only thing that produces `auto`, and it does so whether or not the string changed, which is what makes "first prompt" mean the first. A prompt whose title is null (`/clear`, `! npm test`, blank) never reaches it, so the session stays eligible: the first REAL prompt names the tab.
|
||||
|
||||
**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path, `POST /api/sessions/:id/input` and the experimental guarded stage (`stageGuardedInput()`, behind `POST /api/sessions/:id/stage`) set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` newline (always a line feed for Ctrl+Enter; for Shift+Enter a line feed unless the CLI declares another `capabilities.newline` chord) never passes through the session.
|
||||
**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path, `POST /api/sessions/:id/input`, the experimental guarded stage (`stageGuardedInput()`, behind `POST /api/sessions/:id/stage`) and a Discord reply set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` newline (always a line feed for Ctrl+Enter; for Shift+Enter a line feed unless the CLI declares another `capabilities.newline` chord) never passes through the session.
|
||||
|
||||
**The tracker is a best-effort transcript with explicit per-key rules**, not a byte filter. Mirrored: printable text, backspace, Ctrl+W, Ctrl+U/Ctrl+C (composer emptied), `\n` and Alt+Enter (a newline IN the composer, joined with a space), bracketed paste (newlines inside it likewise). Ignored: cursor keys, Home/End/Delete, Shift+Tab, Tab, SGR mouse and focus reports, Alt chords, OSC/DCS, the rest of C0. Tainting: Up/Down (CSI and SS3), Ctrl+P/N/R, Ctrl+_, because the composer then holds a history line the tracker never saw and Enter must submit nothing rather than a fragment. A bare Esc is resolved at the END of the chunk it arrives in, since xterm hands each key's whole sequence to one write and the programmatic senders send Esc alone; a CSI split across chunks still resumes. The draft keeps its HEAD past 8192 code points (the title is the first sentence, so keeping the tail would title a long paste by its last line) and an escape sequence is abandoned past 64 bytes.
|
||||
|
||||
@@ -409,10 +409,16 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli
|
||||
|
||||
⚠️ **The terminal routes an out-of-workspace path to the preview, not the log viewer.** The log viewer spawns `tail -f` and allows only the workspace, `/var/log` and `~/logs`, so an external `.log`/`.json`/code path answered `Path must be within working directory or allowed log directories` while the SAME path clicked in the response viewer previewed fine. `activate()` now checks `_isExternalPreviewPath` alongside `previewsInFileViewer`. In-workspace text keeps the tail viewer, which is the point of it (live follow), except markdown (`.md`/`.markdown`, in `FILE_PREVIEW_EXTENSIONS` since the owner's 2026-10-13 decision, rendered as a document) and `.html`/`.htm` (`rendersAsPage()`, rendered as a page, see below), which always go to the preview; nothing widened `file-stream-manager`'s allowlist, so no `tail -f` is spawned on an arbitrary host path.
|
||||
|
||||
**Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it reserves spaced candidates first (including unlinkable spans), then detects rooted matches, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`.
|
||||
**Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it reserves spaced candidates first (including unlinkable spans), then detects rooted matches, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against a base (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`.
|
||||
|
||||
**The relative base is the pane's directory.** The base is `relativeResolutionBase(session)`: the pane's live directory `SessionState.paneCwd` when known, else `workingDir`, which is fixed at create time and never follows a `cd`. `paneCwd` is `#{pane_current_path}` from the pane-exit watcher's existing batched `list-panes -a` read (`derivePaneCurrentPaths()`, single-pane sessions and absolute paths only; no new tmux call and no new SSE event: the server applies it on the `paneExitsUpdated` tick and broadcasts `session:updated` only on a change). tmux reports the cwd of the pane's FOREGROUND process-group leader, so a shell's `cd` moves it, while an agent CLI's tool subprocesses (Claude's Bash tool, codex's exec) do not: for those sessions it stays the CLI's own directory. ⚠️ `paneCwd` is a resolution BASE only, never a confinement root: `_isExternalPreviewPath`, `file-content`/`file-raw` and the log viewer's `tail -f` allowlist all still judge against `workingDir`, so a pane that `cd`s outside the workspace sends its relative links through the attachment routes like any other external path, and what can be opened is unchanged (an absolute path to the same file already opened). ⚠️ `Session.setPaneCwd()` uses the `paneExitApplies` scoping rule and fails closed the same way: a remote session's local pane runs the ssh client and a docker case's runs `docker exec`, so their pane directory is a host directory the agent was never in; direct-PTY and rediscovered sessions get none either, and the browser re-checks `remote`/`docker`. ⚠️ tmux reads the directory from `/proc/<pgrp>/cwd`, always the RESOLVED path, while `workingDir` keeps the spelling the case was created with (a symlinked `~/codeman-cases`, a case linked through a symlink, macOS `/tmp`), and `_isExternalPreviewPath` compares lexically, so `setPaneCwd()` writes a reading at or under the workspace's resolved path back in `workingDir`'s spelling. The resolution runs once per session in the background, bounded (`probePathKind()` first, then a `realpath()` raced against the same timeout, never on the watcher tick and never `realpathSync`); an `unknown` probe or a failed or slow `realpath()` leaves the reading untranslated and retries after `WORKSPACE_REALPATH_RETRY_MS`. Until it settles, only a reading lexically inside `workingDir` is taken, so the first tick cannot mis-route links. ⚠️ Never restored after a restart: written to `state.json` by `toState()` like the rest of the session state but never read back, and a directory change alone never persists. A failed or empty read keeps every last directory, and a blank `#{pane_current_path}` on a single-pane session keeps that session's (`derivePaneCurrentPaths()` carries it forward); an authoritative read that lists the session as split into several panes, or not at all, clears it, so a hand-split session falls back to `workingDir`. The server applies the manager's answer as given, `undefined` included, and `setPaneCwd()` reports a clear once. Trade-off: the base is the pane's directory at CLICK time, so a relative path printed before a `cd` resolves against the new directory. Tests: `test/pane-cwd-tracking.test.ts`.
|
||||
|
||||
Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths keep the own-line and marker forms plus the same path labels; any other label (`Error:`, `Note:`, `Saved the report to:`) defers an absolute path too. A rooted candidate needs a directory before its first space, so `/review the changes in src/a.ts` is prose. An earlier word holding a known extension anywhere (`a.ts#L12 foo.ts`) ends the path at that word, a `:` followed by a digit inside an earlier word is a line suffix (`a.ts:42:See b.md`, grep and compiler output) and ends the path there, a run of spaces is a column gap rather than a name, and a leading run of `./` or `../` is not a segment (`Saved to: ../My Docs/plan.md` links whole; an interior `..` is still refused). An unlinkable candidate reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` keeps its ordinary links while `/etc/secret files/passwords.txt` links nothing. A path label vouches that a path follows, not for the words before it: a relative candidate whose only `/` word is its last is prose after the label (`Wrote: 120 lines to src/foo.ts`, `Path: see src/foo.ts`) and defers to the ordinary detectors. The cost is a labelled relative path whose only space is in its top directory, `Saved to: My Docs/plan.md` (and `Saved to: 03 Attachments/plan.pptx`), which splits as on master; `03 Attachments/Build SIPOC Design/Build-SIPOC.pptx`, `notes/a b/c.md`, `~/`, `./` and `../` forms and absolute paths still link whole. An earlier word that is a lowercase connective or verb (`SPACED_PATH_PROSE_WORD`: `and`, `or`, `to`, `in`, `the`, `is`, `has`, `contains`, `then`, `as` and a few more) or a bare `=` makes the candidate a sentence, in every shape: `/home/me/proj contains a.md`, `- /tmp/out has the logs in run.log`, `Path: /home/me/proj/out contains a.md`, `Files: src/components and utils/x.ts`, `Wrote: 3 files to docs/design notes/overview.md`. The cost is a name that uses one of those words (`Pros and Cons.md`, `How to deploy.md`, `Documents and Settings`), which splits as on master; `at` (`Screenshot 2026-10-24 at 10.00.00.png`) and `of` (`Copy of report.docx`) are left out of the list on purpose. What remains ambiguous is an own-line absolute path followed by words that hold none of them (`/home/me/proj/out report.pdf` reads like `/home/me/docs/Read Me.txt`), which links as one path. A command line is refused too: a first word that is an executable under `bin/`, `.bin/`, `scripts/` or `tools/` (`/opt/homebrew/bin/node server.js`, `/home/me/proj/scripts/deploy prod.yml`) or any `-x`/`--x` flag word (`/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`); a bare `-` (`Docs - Copy`, `OneDrive - Contoso`) is not a flag. So is a shell prompt that prints an absolute working directory (WSL's `/mnt/c/...` is outside `$HOME`, so every earlier command in a shell's scrollback has one): a first word ending in `$` (`/home/me/proj$ cat notes.md`) and any earlier word with no letter or digit other than a bare `-` (`/mnt/c/proj ❯ code notes.md`, `/home/me/proj $ cat notes.md`, `/home/me/proj/out ✓ report.pdf`) refuse the candidate, while `Program Files (x86)` and `Docs - Copy` still link whole. The response viewer carries any leading `<strong>` label in a paragraph or list item, or one starting a later line of a paragraph (after a `<br>`, since chat renders with `breaks: true`), into detection without changing the label or displayed text, and `spacedFilePathOnLine()` judges it as it judges the same line in the terminal (`**Note:** /home/me/proj/out report.pdf` splits). A text node that follows any other inline element starts mid-line, so a placeholder word stands in for what precedes it (``Use `--config` /home/me/proj/cfg data.json`` splits as in the terminal), while a path inside inline code still links whole; paths split across inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap.
|
||||
|
||||
Parentheses also bound an inline spaced path, such as `Updated (/mnt/c/My Docs/report.conf)`. The whole candidate is reserved before fragment detectors run; existing root, prose and traversal guards still apply. Inside parentheses only, a comma at the end of an earlier word marks a list, not one filename. Own-line, list-marker and labelled paths retain commas in filenames and directory names. ⚠️ `Bash(` is the one call shape left out (`opensBashCall()`, both here and for the hard-wrap alternative below): it holds a command, so `Bash(/tmp/a.out input.txt)` is a program and its argument and keeps master's links, while the path tools (`Read(`, `Update(`, `Write(`) link a spaced name whole.
|
||||
|
||||
Hard-wrapped filenames keep the existing concatenation without inserted spaces. A parenthesised final filename broken between words also carries a spaced alternative without changing its cell mapping. On activation only, the terminal checks the original path through the existing guarded attachment registration route (`notify: false`), tries the alternative only after HTTP 404, and retains the clicked session throughout. If both exist, the original wins. Every original result other than HTTP 404 goes to the normal opener, preserving workspace previews and log viewing even for extensions the attachment registry rejects; for an external path that opener retains the same attachment guards and errors. After an original 404, a spaced alternative opens only if it registers successfully; if it also returns 404, the original goes to the normal opener. Path labels, list markers and leading indentation inside parentheses are excluded from the alternative’s lookup key. Hover performs no lookup. Text alone cannot distinguish the two filenames, so this uses the filesystem result rather than a spacing guess.
|
||||
|
||||
The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A row end counts as a cell end, so `⏺ Docs live at https://example.com/guide/` followed by ` self-hosted` still links `.../guide/self-hosted`, and an indented `and/or the API reference covers it.` still links `.../guide/and/or`; both are pinned as known trade-offs. Such a token is still refused when it is code with a call paren (`client.get()`, `expect(x).toBe(y)`; a wiki title like `Mercury_(planet)` keeps its paren) or a word ending a sentence (`ready.`, `Done.`, `self-hosted.`; a file name keeps its period, so `install.html.` still joins). Whatever its punctuation, a token never continues a URL when it ends with a colon (a label such as `Failure/Error:` or `prometheus.io/scrape:`), when it is a measurement or clock time (`45%`, `45%|####`, `12.3MB/45.0MB`, `12.3ms`, `10:00:00`; percent-encoding such as `2026%20Plan` still joins), or when it is a `name=value` field (`API_KEY=...`, `level=info`) under a URL ending in `/`, since a query needs a `?` first (after `?`, `&` or `_` it is a query parameter and joins). A continuation must be indented (a column-0 row is new output, which keeps `status=200`, `24/7` and `really?` out), and a shell prompt (`user@host:`), a home path (`~/`), a whole email address or an HTTP status line never continues a URL. A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. When other columns follow a wrapped URL on its first row (a codex table's path or date column), the URL is stitched without them, and they still link on that row: `terminalLinkLine()` scans them after the stitched rows as a segment of their own, mapped back to their cells. A URL in such a column is the row's last URL, and only the last URL on a row is continued, so that row is never stitched. A divider row never continues the row above it, so a URL that reaches the right edge does not absorb a full-width `────` row below. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs.
|
||||
|
||||
**Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`.
|
||||
@@ -429,7 +435,7 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
|
||||
|
||||
### Filesystem path picker
|
||||
|
||||
**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command.
|
||||
**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing has it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > New Case makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag.
|
||||
|
||||
⚠️ **This is a second file-serving surface, so it carries the same confinement burden as [Attachments](#attachments) and does not inherit it automatically.** Traversal is allowlisted to Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured via `CODEMAN_FILE_PICKER_ROOTS`; sensitive trees are blocked and symlink escapes are rejected after `realpath` resolution rather than before. Without the realpath step a symlink inside an allowed root would walk straight out of it. `preview` reuses the shared conversion cache and the **global** `document-conversion-limiter`, which is what stops N concurrent large-document previews from forking N multi-minute converter processes. Content types are pinned: images and PDF inline, DOCX/PPTX through the converters, and Markdown/TXT/JSON as inert `text/plain` (never `text/html`, which would be stored XSS on our own origin). Size caps are 2MB for text and 50MB for binary/document previews.
|
||||
|
||||
@@ -468,11 +474,13 @@ Tests: `test/file-preview-markdown.test.ts` (jsdom-in-vm, pins every rule above)
|
||||
|
||||
### Clone a repository as a case
|
||||
|
||||
**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`).
|
||||
**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`). An optional `path` (the **Clone into a custom folder** checkbox) is the folder the repository is cloned INTO, exactly as given (the case name adds no subfolder), and it registers a LINKED case, the same way `POST /api/cases` with a `path` does: it is judged by `prepareNewCasePath()` (`web/case-path.ts`: blocked trees, symlinks, parent must exist), is admin-only in multi-user mode (it writes an absolute path into the shared linked-cases registry), and must be empty or not exist yet. An existing empty folder is cloned into directly (`CloneOptions.intoEmptyDir`: no temporary sibling and no rename, so a mount point works; a failed attempt empties it again but never removes it); a folder with anything in it is refused. Deleting such a case unlinks it and never removes the files. If the registry write fails after git finished, the folder is kept and the error says where it is.
|
||||
|
||||
- ⚠️ **The URL is a code-execution surface, which is why it is parsed rather than forwarded.** `ext::sh -c <cmd>` makes git run an arbitrary command as its transport, and ANY `<name>::<payload>` dispatches to a `git-remote-<name>` helper, so every `::` form is refused outright. A repository starting with `-` is read by git as a flag; that is rejected AND every spawn puts `--` before the operands, because either defence alone is one edit away from being a hole. Spawns are argv arrays, never a shell (unlike `remote-hosts.ts`, which does build a shell line and must `shellescape`). The Zod schema deliberately only length-bounds `repository` — a weaker regex duplicate of `parseGitRepositoryUrl` would be the copy that drifts.
|
||||
- ⚠️ **Non-interactive or it hangs the request.** The clone is synchronous by design (no job store, no polling, no cancellation surface), so an invisible credential prompt would pin an open HTTP request until the timeout. `gitNonInteractiveEnv()` closes all four prompt paths at once: `GIT_TERMINAL_PROMPT=0`, empty `GIT_ASKPASS`/`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=never` + empty `DISPLAY`, `GCM_INTERACTIVE=never`, and `ssh -oBatchMode=yes`. `HOME`/`PATH` are inherited on purpose — a user whose own agent or credential helper already works keeps working (so a private repo may well clone; Codeman just never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ In multi-user mode a NON-ADMIN's clone and preflight run with `git -c credential.helper=` (`cloneWithoutCredentialHelpers`), because the helpers belong to the one server account every user shares; admins and single-user mode keep them. The Docker server image relies on exactly this: when built with the opt-in `CODEMAN_INSTALL_GH`/`CODEMAN_INSTALL_AZ` args it configures `gh` (github.com) and an `az`-backed helper (`docker/git-credential-azure-cli`, Azure DevOps) in its SYSTEM gitconfig, both silent until the user signs the CLI in, so a private clone there still fails fast rather than prompting.
|
||||
- ⚠️ **Bounded in time, output and concurrency.** Timeout → SIGTERM → SIGKILL, signalled to the whole process GROUP (`detached: true`, negative pid) because `git clone` fans out into `git-remote-https`/`index-pack` children that a polite signal to the parent leaves running. stderr is kept as a bounded, credential-redacted, control-stripped TAIL; `ls-remote` stdout is capped and refs are capped at 500 each. A small global pool (default 2, `CODEMAN_MAX_GIT_OPERATIONS`) caps concurrent git network ops, same reasoning as `document-conversion-limiter.ts`.
|
||||
- ⚠️ **A direct clone's cleanup has a premise.** One direct (`intoEmptyDir`) clone per folder runs at a time, and a failed one empties the folder only when git actually ran (`GitRun.started`: a full queue or a slot wait that timed out wrote nothing) and did not refuse the folder. That refusal is read from git's own `already exists and is not an empty directory` text, tested directly and never through the classifier's code: the message quotes the path, and a folder named `openssl` once classified as unreachable. A folder whose mount stopped answering is left alone too (`probePathKind()`), so no libuv worker is held on it.
|
||||
- ⚠️ **No clone at or inside a Docker case workspace, with or without a `path`.** Git would run the new repository's hooks and filters on the host. Refused `400` on both branches.
|
||||
- **Repository contents beat scaffolding.** An existing `CLAUDE.md` is kept (a generated one is written only when absent) and hooks are MERGED into whatever `.claude/settings.local.json` the repo shipped. A repo that ships its own `.claude/settings*.json` is reported back as a warning, because repo-supplied hooks run on the user's machine as soon as a session starts there.
|
||||
- **Failure leaves nothing behind.** The destination is removed only when it did not exist before the attempt, and a pre-existing directory is refused rather than cloned into, so a failed clone never squats on a case name and never touches an existing tree.
|
||||
- ⚠️ **Error detail comes from the LAST diagnostic line, not the first.** `git clone` opens with `Cloning into '<dest>'…`, so a first-line pick reported the destination path as the reason a bad branch failed (observed against a real remote). `NOT_FOUND` wording must also say "or private": GitHub answers "Repository not found" for a private repo and a typo alike when unauthenticated.
|
||||
@@ -628,17 +636,17 @@ Adding the header changed the preamble, so `CODEMAN_PREAMBLE` was bumped (1.22.0
|
||||
|
||||
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`, design doc `docs/custom-model-endpoints-plan.md`; full stack — settings-panel CRUD + the Run-menu picker, on top of the backend below): points a session at a user-configured custom OpenAI-compatible endpoint — local (llama.cpp, DGX Spark, Strix Halo) or cloud (Azure AI Foundry, OpenRouter) — instead of its harness's native cloud backend. Endpoints are a read/write-array store (`custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`) discovered via `GET <baseUrl>/v1/models`; `CustomModelHost.authStyle` is `'bearer'` (default, `Authorization: Bearer`) or `'api-key'` (Azure's convention) — **never both**, live-tested against a real server: sending both headers on one request reliably hangs it indefinitely, reproduced 3×.
|
||||
|
||||
⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/deepseek, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp — writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config — and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source — the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.<name>]` block redirected via `GROK_HOME` — its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified.
|
||||
⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/copilot, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp/deepseek, writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config/dsh home; deepseek's redirect is a per-session `DSH_HOME` holding only the generated `cordis.patch.yml`, whose `linkFromRealHome` recipe field symlinks `profiles`/`sessions`/`settings.yaml` back out of the real dsh home (resolved session-`DSH_HOME`-first, lstat-guarded so a dangling link survives re-apply) instead of stranding them, and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source, the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.<name>]` block redirected via `GROK_HOME`, its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified.
|
||||
|
||||
⚠️ **Two launch paths, chosen by mechanism, not preference**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()` — a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share — because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` relaunches a CLI in an existing pane, so a CLI whose launch declares a `fallback` chain (claude) gets a conversation pinned as `resumeSessionId` for that one respawn: `--session-id <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <id>` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. The dead-pane respawn shares this pin, see [Dead-pane respawn: the resume pin](#dead-pane-respawn-the-resume-pin). ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/<id>` for pi/omp, grok's `[model.codeman-custom]` block name): that is the registry's `customModelInjection.launchModel` template, applied onto the respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id, and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model; never the values, which carry the API key and are re-derived from the endpoint store on recovery), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600.
|
||||
⚠️ **Two launch paths, chosen by mechanism, not preference**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()`, a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share, because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` relaunches a CLI in an existing pane, so a CLI whose launch declares a `fallback` chain (claude) gets a conversation pinned as `resumeSessionId` for that one respawn: `--session-id <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <id>` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. The dead-pane respawn shares this pin, see [Dead-pane respawn: the resume pin](#dead-pane-respawn-the-resume-pin). ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/<id>` for pi/omp, grok's `[model.codeman-custom]` block name), and codex gets one too (`--model <bare modelId>`, its pattern changed to `model-plain-colon`, which admits qwen3-style colon ids and requires a letter or digit first, for the native App Settings Codex model too): that is the registry's `customModelInjection.launchModel` template, applied onto the respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id, and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model; never the values, which carry the API key and are re-derived from the endpoint store on recovery), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600.
|
||||
|
||||
⚠️ **Security**: every env var this feature can redirect (`ANTHROPIC_BASE_URL`, `GOOGLE_GEMINI_BASE_URL`, `CODEX_HOME`, `GROK_HOME`, `HOME` for pi/omp, `OPENCODE_CONFIG_CONTENT`, etc.) is in that CLI's `privilegedEnvKeys` — several of these were reachable via the generic `envOverrides` field's prefix allowlist BEFORE this feature existed (the env allowlist is global and prefix-based, not per-CLI-scoped), so building this surfaced and closed a pre-existing gap rather than opening a new one. `ANTHROPIC_*` is deliberately NOT in claude's `allowedPrefixes` at all — Anthropic-traffic redirection can only happen through this feature's own admin-configured, SSRF-guarded route, never a plain client-supplied `envOverrides`.
|
||||
|
||||
**Confidence, verified end-to-end against a real llama-swap server via the DYNAMIC `scripts/test-local-llm-harnesses.ts`** (reads the live CLI registry, so a registry change needs zero script edits): claude/opencode/pi/grok/omp **PASS**; codex config structure is correct, and codex only speaks the Responses API since Feb 2026 (`wire_api = "responses"`) — re-verified live against a llama-swap deployment that DOES answer `/v1/responses` (an earlier test's harder failure against a different deployment does not reproduce everywhere): a plain, no-tool-call chat turn gets a real reply, but a real tool-call attempt came back as `agent_message` TEXT (the tool-call JSON printed as the answer) rather than an executable `function_call` item — confirmed via `codex exec --json`'s raw event stream. Tool execution is what makes codex a coding agent, so it remains not usable for real work either way, just with a more precise failure mode than a flat protocol break; gemini fails with `Invalid auth method selected` (an undocumented `GATEWAY` AuthType gemini-cli selects once `GOOGLE_GEMINI_BASE_URL` is set — unresolved after real investigation); deepseek's originally-reported `HTTP_404` is root-caused and fixed — its bundled `@deepseek-ai/dsh-llm-deepseek` module builds `${DEEPSEEK_BASE_URL}/chat/completions` with no `/v1` of its own (confirmed by installing the real package and reading its source), so a new `appendV1Suffix` flag on its registry entry (alone — claude/gemini must not get it) runs `endpoint.baseUrl` through `withV1Suffix()` before writing it, live-confirmed against llama-swap (`.../chat/completions` 404s, `.../v1/chat/completions` succeeds) though not yet re-run through an actual `dsh` binary, which isn't installable in this environment; antigravity has no known mechanism at all. See the confidence table in `docs/custom-model-endpoints-plan.md` for the full detail on each.
|
||||
**Confidence, verified end-to-end against a real llama-swap server via the DYNAMIC `scripts/test-local-llm-harnesses.ts`** (reads the live CLI registry, so a registry change needs zero script edits): claude/opencode/pi/grok/omp **PASS**; codex config structure is correct, and codex only speaks the Responses API since Feb 2026 (`wire_api = "responses"`), re-verified live against a llama-swap deployment that DOES answer `/v1/responses` (an earlier test's harder failure against a different deployment does not reproduce everywhere): a plain, no-tool-call chat turn gets a real reply, but a real tool-call attempt came back as `agent_message` TEXT (the tool-call JSON printed as the answer) rather than an executable `function_call` item, confirmed via `codex exec --json`'s raw event stream. Tool execution is what makes codex a coding agent, so it remains not usable for real work either way, just with a more precise failure mode than a flat protocol break; gemini fails with `Invalid auth method selected` (an undocumented `GATEWAY` AuthType gemini-cli selects once `GOOGLE_GEMINI_BASE_URL` is set, unresolved after real investigation); deepseek's originally-reported `HTTP_404` is root-caused and fixed, its bundled `@deepseek-ai/dsh-llm-deepseek` module builds `${DEEPSEEK_BASE_URL}/chat/completions` with no `/v1` of its own (confirmed by installing the real package and reading its source), live against llama-swap (`.../chat/completions` 404s, `.../v1/chat/completions` succeeds), and the env-var redirect that first fixed it has since been REPLACED by a `configDir` recipe: a per-session `DSH_HOME` carrying only a generated `cordis.patch.yml` that declares a `codeman-custom` `openai-completions` provider over the installed profile, with the `/v1` fix living in `renderConfigFile`'s shared `withV1Suffix()` (every file template applies it; the `env` kind's `appendV1Suffix` flag now belongs to copilot alone) and the session's state symlinked back from the real dsh home; a real "hello world" reply through an actual `dsh` binary has since been verified live by the maintainer (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1: `--dump-config` shows the patched rows, the TUI boots on the chosen model, and a prompt reaches the endpoint as `POST /v1/chat/completions` with the reply rendering in the TUI), so this is verified end-to-end like the harnesses above; antigravity has no known mechanism at all. See the confidence table in `docs/custom-model-endpoints-plan.md` for the full detail on each.
|
||||
|
||||
⚠️ **The Run-menu picker generates entries from `window.__codemanCustomModelClis`** (`server.ts`, injected at page render from `enabledClis().filter(kind==='agent' && customModelInjection.kind!=='unsupported')`, JSON-escaped against a literal `</script>` via the exported `escapeScriptJson()` since `label` is a user-`clis.json`-settable string unlike the neighbouring booleans-only `__codemanCliAvailable`), never a hardcoded per-CLI id list in the frontend — the same "no branching on CLI id outside stock.ts" discipline the registry itself enforces. One entry per (capable, INSTALLED CLI, saved endpoint) pair, e.g. "Claude Code (llama.cpp)", filtered through `isCliAvailable()` like the stock entries. Clicking one calls `selectCustomModelEntry(mode, endpointId)` (`session-ui.js`), which re-fetches the endpoint (never trusts anything cached from the dropdown's render — the 5-minute sweep below or a settings edit may have changed it since) and decides the model: exactly one discovered model launches straight away, two or more open `#customModelPickModal` to ask, with `defaultModelId` marked but never auto-chosen (asking exists so ONE launch can deliberately differ from the saved default).
|
||||
|
||||
⚠️ **The picker promotes exactly one row to the top**: whichever model llama-swap reports `ready` right now (tagged "Currently loaded", queried via `GET /api/model-endpoints/:id/running-status`, client-side bounded to ~800ms via `Promise.race` so a sleeping/firewalled endpoint cannot leave the modal invisible for the route's own 5s server-side timeout) beats a merely remembered choice, and — only when nothing is currently loaded — the model actually launched last for this exact (harness, endpoint) pair (tagged "Last used", read from the per-device `codeman:customModelLastUsed:<mode>:<endpointId>` localStorage key). Neither tag reorders past the top, and the "Default" pill is a SEPARATE span rather than a third value of the same slot, so a promoted row that is also the endpoint's `defaultModelId` shows both (on a single-purpose GPU box that is the common case; an exclusive slot silently dropped the Default marking for exactly that row). "Last used" is written by `_runCustomModelEntryViaRestart` (claude) and `_quickStartWithCustomModelConfirm` (every one-shot launch; `runCustomModelEntry` itself only dispatches between the two) only once the model is actually applied — never on the mere click — because a context-window-warning decline means this exact model cannot work with this CLI at all, and promoting a model that cannot launch would be actively wrong, not just premature.
|
||||
⚠️ **The picker promotes exactly one row to the top**: whichever model llama-swap reports `ready` right now (tagged "Currently loaded", queried via `GET /api/model-endpoints/:id/running-status`, client-side bounded to ~800ms via `Promise.race` so a sleeping/firewalled endpoint cannot leave the modal invisible for the route's own 5s server-side timeout) beats a live session's pin (tagged "In use by a session": `_getCustomModelInUseBySession` walks `this.sessions` for one of your own sessions whose `customModel.endpointId` is that host, newest `createdAt` wins), which applies only when nothing is currently loaded and beats a merely remembered choice, the model actually launched last for this exact (harness, endpoint) pair (tagged "Last used", read from the per-device `codeman:customModelLastUsed:<mode>:<endpointId>` localStorage key). ⚠️ The pin reads only `customModel`, never `displayModel` (screen-scraped display text: a terminal that merely printed a matching string is not evidence), and never counts a session with `status: 'stopped'`, a null `pid` (its PTY exited) or a recorded `paneExit`, the same live-pane test keep-warm applies. ⚠️ When nothing was promoted and the probe answered `isLlamaSwap: false` (the shape `getLlamaSwapStatus` gives for EVERY failure it catches, a down or refused endpoint included, so the line claims only that nothing was reported), the static `#customModelPickUnknown` line says so out loud; its `[hidden]` rule must keep `display: none !important` scoped to `#customModelPickModal`, because `.form-hint`'s `display: block` outranks the UA `[hidden]` rule. Neither tag reorders past the top, and the "Default" pill is a SEPARATE span rather than a third value of the same slot, so a promoted row that is also the endpoint's `defaultModelId` shows both (on a single-purpose GPU box that is the common case; an exclusive slot silently dropped the Default marking for exactly that row). "Last used" is written by `_runCustomModelEntryViaRestart` (claude) and `_quickStartWithCustomModelConfirm` (every one-shot launch; `runCustomModelEntry` itself only dispatches between the two) only once the model is actually applied, never on the mere click, because a context-window-warning decline means this exact model cannot work with this CLI at all, and promoting a model that cannot launch would be actively wrong, not just premature.
|
||||
|
||||
Either way the actual launch (`runCustomModelEntry`) routes through `run()` itself via a temporary `_runMode` swap — never `setRunMode()`, which would persist it as the user's new default — rather than a parallel dispatch table, which is what gives a custom-model launch the same `_runInFlight` lock every other Run click gets and means a CLI whose injection recipe lands later needs no update here. It then GETs `/api/sessions/:id/wait?until=idle&timeout=20000` on that session BEFORE applying — measured live, a freshly launched CLI reports itself `busy` for its own startup (boot spinner, workspace-trust check) well before the apply call would otherwise reach it, and the apply route's `isBusy()` guard correctly can't tell that apart from a real turn in progress, so every fresh launch failed with `SESSION_BUSY` until this wait was added. A timeout there is a normal 200 per the wait endpoint's own contract, never an error, so a session still busy after 20s just reaches the apply call anyway and gets that route's own honest error. It then calls `POST /api/sessions/:id/custom-model` on the session `run()` produced, guarded by snapshotting `activeSessionId` before the call and requiring it to have actually changed after — every `run*()` handles its own failure internally and returns normally rather than throwing, so a declined/failed launch must not silently re-point and restart whatever session was already open.
|
||||
|
||||
@@ -742,6 +750,24 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
|
||||
|
||||
⚠️ Closing the preview must **pause and unload** the media (`_stopFilePreviewMedia` in panels-ui.js): dropping the overlay's `visible` class is `display:none` and nothing else, and a DETACHED `HTMLMediaElement` keeps playing, which is how the X button used to leave a video audible with no player to pause.
|
||||
|
||||
### Edit history panel
|
||||
|
||||
**Edit history panel** (issue #643; `showEditHistory`, per-device, default OFF): which files a local claude session edited (main agent and subagents), each edit's diff, and images a Bash call produced, read from the session's transcripts by byte offset, never from git. Parser `src/edit-history-parse.ts` (pure), store `src/edit-history-store.ts`, reader `src/edit-history-local-source.ts`, sync `src/web/edit-history-sync.ts`, routes `src/web/routes/edit-history-routes.ts` (`GET /api/sessions/:id/edits[?sync=1]`, `GET …/edits/:entryId`, `POST …/edits/older`, all through `findSessionOrFail` WITH `req`), panel `edit-history-ui.js`. User guide: `docs/wiki/Working-With-Files.md` § Edit history.
|
||||
|
||||
⚠️ **Reads only on request, opt-in all the way down.** `server.ts` builds the `EditHistorySync` and hands it to the routes; its only own calls are `remove` (through `forgetEditHistory`, when the session's persisted record goes), `sweep` (startup) and `flushAll` (shutdown). No hook, no timer, no second watcher. The panel drives every read: `?sync=1` when it opens, and while it is open on that session a debounced (600 ms) re-read after a `transcript:tool_end` of an edit-capable tool (Edit, Write, MultiEdit, NotebookEdit, Bash, Agent, Task, SendUserFile), which the existing `TranscriptWatcher` already broadcasts, or the session's `session:idle` (the end of a turn, via `_onSessionIdle`; it also catches a background subagent that writes after the tool end that started it). Not `transcript:complete`: it fires on a `result` line, which claude's interactive transcript never has. A closed panel reads nothing and keeps no badge. Guarded by the "reads only on request" wiring test in `test/edit-history-sync.test.ts`.
|
||||
|
||||
⚠️ **Which transcript** (`resolveClaudeTranscript`). The main transcript is the path the session's `TranscriptWatcher` follows (the hook-reported `transcript_path`), so a session is read from where its claude really writes, whatever its `CLAUDE_CONFIG_DIR`. Without a watcher (server restarted, no hook since), `findClaudeTranscript()` looks for `<claudeSessionId|id>.jsonl` under `claudeProjectsDir(session.claudeConfigDir())`: the session's own override (#255, the same dir the relaunch pin checks), else the server's. A config dir set outside Codeman (a shell profile, a wrapper) is never guessed: until the next hook such a session answers `unavailable` / `no-transcript`, and the panel says so. Subagents are `<main path without .jsonl>/subagents/agent-<id>.jsonl`. A different main file (`/clear`, resume): the previous one, next to it in the same directory, is first read to its end with its subagents (over as many syncs as that takes), then the new one from its end with fresh cursors; the stored entries stay. "Load older" reaches back within the current conversation only, and reads the stored conversation's transcript while the switch is still pending.
|
||||
|
||||
⚠️ **Bounded reads.** A sync is at most `MAX_FORWARD_PASSES` (3) reads, each at most `READ_BUDGET_BYTES` (8 MiB) shared by the main transcript and all subagents (main first), plus up to 8 KiB of `meta.json` per subagent seen for the first time; a first read starts that far before the end. A file whose chunk the shared budget cut short is not applied but read in a later pass (the store would take a chunk with no line end for one line longer than the budget). "Load older" reads a full-budget block of up to `MAX_OLDER_SOURCES` (3) transcripts per click; with more, they take turns. A line longer than the budget is skipped, going forward and going back, and the line after (before) it is read whole. A subagent transcript that vanished loses its cursor, not its entries. Parsing happens in the request, line by line; nothing runs on the PTY data path. One sync per session at a time, and requests during a run fold into one more run. At most `MAX_LOADED_STORES` (3) stores stay in memory: past that the least recently used one without a run in flight is written (only if it changed) and dropped, and a later request loads it again after that write.
|
||||
|
||||
⚠️ **The store holds secrets.** Every diff an agent wrote ends up in `dataPath('edit-history')/<sessionId>.json`, API keys and `.env` contents included. It is written 0600 via tmp + rename (a fresh file each time, so an existing store never keeps wider bits; a new directory is created 0700) and bounded per session: `MAX_ENTRIES` (500) and `MAX_STORE_BYTES` (4 MiB of diff text, paths and ids), oldest dropped, also on load. Each edit's diff is capped at `MAX_HUNK_BYTES` (64 KiB); the file also holds one cursor per transcript file of the current conversation, at most `MAX_PENDING_BASH` (200) unanswered Bash calls of at most 8,192 characters of command text each (a call with an id, cwd or agent id over 4,096 characters is not kept, and dropped on load) and `MAX_AGENTS` (501) subagent labels of at most 1,024 characters per string. `load()` takes the file as untrusted: one over `MAX_FILE_BYTES` (32 MiB) is not parsed, and the rest is rebuilt from its known fields under the same bounds (unknown fields and non-string diff lines dropped, the per-edit cap applied again, a transcript id with anything but `[A-Za-z0-9._-]` dropped, since the previous transcript's path is built from it). Writes are serialized. The store lives as long as the session's persisted record: `forgetEditHistory` runs when `cleanupSession` with `killMux` removes the record (a pinned session's record is only demoted and keeps its history), on the persisted-only `DELETE /api/sessions/:id`, for every record `cleanupStaleSessions` drops at startup and every one `POST /api/cleanup-state` drops. After that startup cleanup, `sweep` deletes the `edit-history/*.json` files whose session is neither live nor recorded (left behind by a crash between removing the record and deleting the file, or by an older build) and tmp files of earlier runs (named per process start, so a pid used again, a container's pid 1, does not keep one). While `remove()` runs, nothing writes the file again: a sync in flight stops after its current read, and a request meanwhile reads nothing (`unavailable` / `removed`). The routes start a sync in the same tick as their session check, so a request that passed the check before the deletion cannot start one afterwards. Then the session's slot goes, and a session that comes back with the same id (reboot restore re-creates it) starts afresh. A store that is not loaded is deleted without being parsed. The store is blocked in `isSensitivePath` (`/.codeman*/edit-history/`, same reasoning as `intents.json`) and never fed to `/api/search`.
|
||||
|
||||
⚠️ **Images only through existing file surfaces.** A Bash image candidate counts only when the file's mtime lies in the call's window (start − 2 s … end + 2 s) and no entry already covers that file version; `SendUserFile` images need no window. The panel shows a thumbnail only inside the workspace, through the confined `file-raw` route. An image outside it gets no thumbnail (each would mint an attachment id per render); a click opens the shared file preview, which registers it once through `POST /api/sessions/:id/attachments` with `notify: false` (its extension allowlist and `isSensitivePath` apply). SVGs get no thumbnail. No route of this feature serves a file, least of all a transcript path.
|
||||
|
||||
⚠️ **Transcript paths are agent-printed, so they are probed BOUNDED and scanned in linear time** (#516; #693 review). `probeLocalPaths()` sends each candidate through `probePathKind()` (no `pastCap`: a bulk scan), one at a time, and only a path that answered `'file'` gets `realpath` + `stat`; a path on a mount that stopped answering costs one stalled stat, never a sync, a queued sync or `remove()` that cannot finish (`test/edit-history-probe-stall.test.ts`). `PATH_TOKEN_RE` runs synchronously in the request, over Bash output of up to 30,000 characters: an unquoted token may start only at a token boundary (line start or a token-ending character, `:` excluded so URLs stay ignored), and the `file://` branch stops at the next `file://` (`:` stays allowed for timestamped names). A lookbehind that merely excludes some characters lets a match start at every character of a long token and rescan it, quadratic in the line (2.7 s for 30,000 `#`). Time-budget regression tests in `test/edit-history-parse.test.ts`. Forward reads and "Load older" yield to the event loop between the files they parse (up to 8 MiB each); a store removed meanwhile ends the read before its images are probed. Remote, Docker and non-claude sessions never load a store on any route.
|
||||
|
||||
Paths, agent descriptions and diff lines render via `textContent` only (browser test with a hostile path). The header button uses the marker-class pattern (`btn-edit-history--hidden`) and stays off phones (`mobile.css`, locked in `test/mobile-header-buttons-policy.test.ts`). Remote, Docker and non-claude sessions answer `unsupported` and are never read.
|
||||
|
||||
### Config backups
|
||||
|
||||
**Config backups** (`src/config-backup.ts`, pure over the directories it is handed; `routes/config-backup-routes.ts`; the Config backups group in App Settings → System, `settings-ui.js`; contributed in #661, user guide `docs/wiki/Config-Backups.md`, endpoints in `docs/api-reference.md`): timestamped snapshots of the files that hold what a user customized, so a bad write can be undone. Settings (SYNCED): `configBackupEnabled` (absent = ON, since it protects against an event nobody schedules), `configBackupDir` (absolute, `~` expanded; empty, relative or the data dir itself = `<data dir>/backups/config`), `configBackupKeepCount` (2 to 500, default 20), `configBackupKeepDays` (0 to 3650, default 30, `0` = no age limit). A snapshot is a `cfg-YYYYMMDD-HHMMSS` folder (server local time, `-2`, `-3`… on a same-second collision; mode 0700) holding the tracked files under their own names plus a `manifest.json` (all 0600): `id`, `createdAt`, `reason` (`startup`/`auto`/`manual`/`pre-restore`), `appVersion`, the change-check `hash`, the `origin`, the `keepCount`/`keepDays` in force, and a SHA-256 and size per file. It is built in a `.tmp-cfg-*` folder and renamed into place; a pass sweeps temp folders older than an hour. Passes run at startup and every 5 minutes (`CONFIG_BACKUP_INTERVAL_MS` in `server.ts`, not in test mode), and on **Back up now** (forced).
|
||||
@@ -1047,7 +1073,7 @@ Tests: `test/terminal-touch-tap.test.ts`, `test/session-swipe-gesture.test.ts` (
|
||||
|
||||
**Ledger.** CSS only, on `.tabs-ledger` inside `@media (min-width: 768px)` and the header host: the flat list on an auto-fill column grid with equal cells, mono type and a 3px status bar (`--ledger-bar`: yellow for a waiting alert, red for an action alert or an error, muted for stopped, exited and web tabs) instead of the dot. ⚠️ It must not change the markup (pinned: the ledger's innerHTML equals classic's); the rail, the sidebar and narrower strips show the plain list.
|
||||
|
||||
**Classic, wrapped: the L-shaped strip.** Once a classic desktop strip in the header host wraps (`tabs-auto-wrap`, or the manual `tabs-two-rows`), `_syncTabStripCorner()` (app.js) adds `.tabs-corner`: `#headerRight` goes `position: absolute` over the strip's top-right corner and the strip runs the header's full width, so only the row(s) beside the toolbar stop short of it and every later row uses the width under it. A flex line cannot be shortened on its own, so in that state the strip is `display: block` with `inline-flex` tabs flowing around a `::before` float sized from the toolbar's box (`--tab-corner-width` / `--tab-corner-height`); the old `row-gap` (lineage's `--lineage-row-gap` included) becomes the tabs' bottom margin and the bottom padding shrinks by the same amount, so row spacing and lineage routing room are unchanged. ⚠️ `updateTabOverflowMode()` removes `.tabs-corner` BEFORE it measures the wrap (the corner takes the toolbar out of the header's flow, which widens the strip), so the wrap is still judged against the one-row layout and an unwrapped strip looks exactly as before. ⚠️ Flex-only rules do not apply to the strip in that state. ⚠️ A strip that already scrolls (past `--tab-strip-max-height`) keeps the plain wrap, checked on the plain strip before the class goes on: the float scrolls with the tabs while the toolbar stays put, and toggling the class on a scrolled strip lets scroll anchoring move it; a strip pushed past the max-height by the float itself falls back too. A first tab too wide for the room beside the toolbar also falls back (the whole first line would drop below the toolbar). A `ResizeObserver` on the toolbar re-decides wrap and corner and redraws the connection lines when a reading or the header divider resizes it without a tab render. Scope: classic arrangement, desktop, header host only; state/case/ledger, the sidebar and the vertical rail never get the class (`applySessionListLayout()` drops it when the strip moves). Tests: `test/tab-strip-corner.browser.test.ts` (browser suite).
|
||||
**Classic, wrapped: the L-shaped strip.** Once a classic desktop strip in the header host wraps (`tabs-auto-wrap`, or the manual `tabs-two-rows`), `_syncTabStripCorner()` (app.js) adds `.tabs-corner`: `#headerRight` goes `position: absolute` over the strip's top-right corner and the strip runs the header's full width, so only the row(s) beside the toolbar stop short of it and every later row uses the width under it. A flex line cannot be shortened on its own, so in that state the strip is `display: block` with `inline-flex` tabs flowing around a `::before` float sized from the toolbar's box (`--tab-corner-width` / `--tab-corner-height`); the old `row-gap` (lineage's `--lineage-row-gap` included) becomes the tabs' bottom margin and the bottom padding shrinks by the same amount, so row spacing and lineage routing room are unchanged. ⚠️ `updateTabOverflowMode()` removes `.tabs-corner` BEFORE it measures the wrap (the corner takes the toolbar out of the header's flow, which widens the strip), so the wrap is still judged against the one-row layout and an unwrapped strip looks exactly as before. ⚠️ Flex-only rules do not apply to the strip in that state. ⚠️ A strip that already scrolls (past `--tab-strip-max-height`) keeps the plain wrap, checked on the plain strip before the class goes on: the float scrolls with the tabs while the toolbar stays put, and toggling the class on a scrolled strip lets scroll anchoring move it; a strip pushed past the max-height by the float itself falls back too. A first tab too wide for the room beside the toolbar (the whole first line would drop below it) is trimmed to the room instead (`tab-corner-fit`, `max-width: var(--tab-corner-first-max)`, its name ellipsized): a selected tab is wider (gear and close show), so a fallback here made the whole layout follow the selection. Only a room under `TAB_CORNER_MIN_FIRST_TAB` keeps the plain wrap. ⚠️ The cap goes on the first tab on every pass, never only once it measures too wide: `selectSession()` widens a newly selected tab at once and this sync is debounced, so a cap set afterwards leaves the first row below the toolbar until it runs. It changes nothing on a tab narrower than the room. A `ResizeObserver` on the toolbar re-decides wrap and corner and redraws the connection lines when a reading or the header divider resizes it without a tab render. Scope: classic arrangement, desktop, header host only; state/case/ledger, the sidebar and the vertical rail never get the class (`applySessionListLayout()` drops it when the strip moves). Tests: `test/tab-strip-corner.browser.test.ts` (browser suite).
|
||||
|
||||
With `classic` nothing is left behind: no headings, no breaks, no inline order, no boxes, no name split, no arrangement class. A stale cached mobile-overview.js degrades every grouping to the flat strip. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`.
|
||||
|
||||
@@ -1364,3 +1390,16 @@ refuses them (`GuardedInputBusyError`, which `send-key` answers as `409 CONFLICT
|
||||
not edits. Never retry ambiguous Enter delivery or clear an unverified composer.
|
||||
The ordinary SubmitVerifier uses the shared write boundary, so each re-press
|
||||
updates `lastSubmitAt` just like any other Enter.
|
||||
|
||||
## Discord bridge
|
||||
|
||||
`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`. Opt-in, two-way, one thread per tab. User docs: [discord-bridge](discord-bridge.md).
|
||||
|
||||
- **Who can type into a tab.** Only `allowedUserIds`, in `guildId`, inside a thread the bridge created. Everything else is dropped unread. A reply is one printable line plus Enter (a lone digit while a permission or question is pending goes as the key alone, since the dialog acts on it), capped and rate limited, and typed with `fromUser: true`. The bridge's own startup hint is typed with `fromUser: false`.
|
||||
- **A Discord failure never reaches a session.** `notify()` never throws; a failed thread creation is returned by `threadFor` as a `DiscordResult`, not read back from shared state.
|
||||
- **`null` means the bridge is off.** A deduped automatic event answers `{ ok: true, deduped: true }`; a deliberate `codeman notify` is never deduped. A 200 from `discord-notify` carries the delivery result in `data.ok`, and `codeman notify` exits 1 when it is false.
|
||||
- **Threads belong to a parent channel.** `discord-bridge.json` is `{ parentChannelId, threads }`; `reload()` drops the map when the channel changes, and a creation that finishes after the change is discarded.
|
||||
- **Deletes come from the cleanup path** (`killMux` true), never from `session:deleted`, which a detach also fires.
|
||||
- **Files.** Received files come only from Discord's HTTPS hosts into `<tab folder>/.codeman-discord/` (bounded probe, refused if a symlink, self-ignoring). A sent file's real path must lie inside the tab's folder; both the folder and the file are probed with the bounded probe first.
|
||||
- **The whole last message.** A finished turn or an error posts the CLI's whole last message, not the screen: `WebServer.lastResponseFor` is the response viewer's reader (the body of `GET /api/sessions/:id/last-response`, registered on the route context by `registerSessionRoutes`), so the source is the same per-CLI transcript, or the pane split into turns where a CLI keeps none; a dialog and a shell keep the screen tail (its composer row and the status lines under it cut, never a `❯ 1.` option row). The read is bounded to 4 s and any failure or empty read falls back to the screen. It is split into at most 8 posts of 1,900 characters, a cut code fence closed and reopened, and Markdown tables become aligned code blocks (the separator test strips whitespace first: a regex over a run of spaces is quadratic, and the text is steerable by whatever the agent read). The repeat check is keyed on the text plus the turn it came from (the transcript stamp, else the last submit) and is recorded only after the first post succeeds, so a failed post is sent again and a later turn ending on the same words is not dropped. Only some CLIs raise a turn end (`/api/hook-event`: Claude's `stop` and `idle_prompt`, DeepSeek's `stop`), so the others reach this path through errors, a blocked respawn or a crash loop.
|
||||
- **Cost guards.** The alert tail is a function called only once a post will happen (no tmux exec while off), and `discord-notify` is a per-session token bucket (6 a minute, `429 RATE_LIMITED`).
|
||||
@@ -8,8 +8,10 @@ against a **cloud** OpenAI-compatible endpoint too (e.g. Azure AI Foundry's
|
||||
OpenAI-compatible inference endpoint, OpenRouter, a self-hosted gateway).
|
||||
Right now every Codeman session mode defaults to its native cloud backend
|
||||
with no way to redirect a session at any other endpoint from the UI — the
|
||||
closest existing precedent is DeepSeek's server-env-sourced
|
||||
`DEEPSEEK_BASE_URL`, which isn't user-facing.
|
||||
closest existing precedent was DeepSeek's server-env-sourced
|
||||
`DEEPSEEK_BASE_URL`, which wasn't user-facing (that env redirect has since
|
||||
been superseded by deepseek's per-session patch recipe, see the table
|
||||
below).
|
||||
|
||||
**Scope note**: this plan originally said "local LLM." It now covers any
|
||||
OpenAI-compatible endpoint the user configures — local (llama.cpp, Ollama,
|
||||
@@ -112,7 +114,7 @@ declared capability, never an `if (mode === 'claude')` branch.
|
||||
| `gemini` | Env vars `GOOGLE_GEMINI_BASE_URL` + `GEMINI_API_KEY` + `GEMINI_MODEL`; CLI needs a restart to pick them up | **Confirmed BROKEN against llama.cpp/llama-swap, unresolved after real investigation.** Setting `GOOGLE_GEMINI_BASE_URL` makes gemini-cli internally select an `AuthType.GATEWAY` auth path (undocumented — inferred from behaviour) with validation requirements distinct from every normal auth mode; a real run against llama-swap fails with `Invalid auth method selected` regardless of what key/format is supplied. Tried and all failed: a Google-format dummy API key, `GOOGLE_GENAI_USE_VERTEXAI=false`, a `GEMINI_DEFAULT_AUTH_TYPE` override, and hand-writing `settings.json` directly. `--skip-trust` was a real, separate fix (without it a trust-folder check silently overrides `--approval-mode yolo` back to `default`) but does not touch this auth failure. Documented as an open gap, not shipped as working — the registry entry and injection code exist and are exercised by the test script, but end-to-end gemini support needs upstream investigation of `GATEWAY` AuthType before it can be called done |
|
||||
| `pi` | Config file `~/.pi/agent/models.json` with a custom provider whose `models` is an **array** of `{id}` objects (not an object keyed by id) plus `authHeader: true`. Redirected via the child process's own `HOME` env var, isolated per test/session — **not** `PI_CONFIG_DIR`, which does nothing for pi (grepped pi's entire bundled JS source: the string appears nowhere) | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back. Two real bugs found and fixed before this worked: (1) `PI_CONFIG_DIR` is not read by pi at all — pi hardcodes `~/.pi/agent/models.json` with no dedicated override, so the actual redirect has to be the child process's `HOME`; (2) `models` must be an array of `{id}` objects per pi's own bundled `docs/models.md`, not an object keyed by model id (silently loaded zero models). Also requires an explicit `--model custom/<id>` on invocation — without it pi falls back to its own default provider and fails with "No API key found for the selected model" |
|
||||
| `grok` | TOML `config.toml`: a fixed `[model.codeman-custom]` block (`base_url`, `env_key` naming an env var the key rides in, never a literal TOML field) written to an isolated dir via `GROK_HOME`. Invoked with `-m codeman-custom` | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back. The ORIGINAL recipe in this table (env vars `GROK_BASE_URL`/`XAI_API_KEY`/`GROK_MODEL`) was flat-out **wrong**, not just unverified: it produced "Not signed in" against a real binary. Grok's real mechanism, confirmed against xAI's own docs and a live binary, is a `config.toml` with a `[model.<name>]` block, redirected via `GROK_HOME`; the key still rides as an env var (`XAI_API_KEY` via `env_key`), just referenced from the TOML rather than read directly |
|
||||
| `deepseek` | Reuse the **existing** `DEEPSEEK_BASE_URL` + `DEEPSEEK_API_KEY` keys (already declared in `stock.ts`), now with `appendV1Suffix: true` (see confidence). Only `DEEPSEEK_BASE_URL` is in `privilegedEnvKeys` — `DEEPSEEK_API_KEY` deliberately stays clamp-exempt, since a non-granted owner supplying their OWN key removes privilege rather than granting it (adding it to the clamp list was a real regression, caught by `test/deepseek-mode.test.ts` and fixed before merge). No model-selection var — dsh model is a profile composition entry, not a flag/env var | **Root cause of the original `HTTP_404` found and fixed, by reading dsh's own bundled source — the same bar pi/grok's fixes were held to.** Installed `@deepseek-ai/dsh` (all its real published dependencies) into a scratch directory purely to read `@deepseek-ai/dsh-llm-deepseek/lib/index.js`: it builds its request as `fetch(\`${connection.baseURL}/chat/completions\`, ...)`with`baseURL`read straight from`DEEPSEEK_BASE_URL`(or defaulting to DeepSeek's real public API root,`https://api.deepseek.com`, which also carries no `/v1`) — no `/v1` insertion of dsh's own, unlike the OpenAI-SDK convention this recipe originally assumed. llama-swap/llama.cpp only ever serves the OpenAI-conventional `/v1/chat/completions`. Confirmed live: `POST <baseUrl>/chat/completions` → `404`, `POST <baseUrl>/v1/chat/completions` → `200`, on the exact same endpoint — and dsh's own error-message template, `DeepSeek API error (HTTP ${status})`, reproduces the originally reported `dsh: HTTP_404: DeepSeek API error (HTTP 404)` precisely. Fixed by adding `appendV1Suffix` (env kind only, deepseek's entry alone — claude/gemini must NOT get it, since claude was already confirmed working against the unmodified `baseUrl`), which runs `endpoint.baseUrl` through the same `withV1Suffix()` helper `configDir`-kind CLIs already use. ⚠️ Not yet re-run end-to-end with a real `dsh` binary — no install available in this environment (no npm-installed CLI binary in `PATH`, and the `codeman-test-picker` container doesn't bundle it either); the fix is source-confirmed and live-verified at the HTTP level, but a genuine "hello world" reply through `dsh` itself is the remaining step before promoting this to **verified** alongside claude/opencode/pi/grok/omp |
|
||||
| `deepseek` | Per-session **`DSH_HOME` configDir recipe** (supersedes the original `DEEPSEEK_BASE_URL` + `appendV1Suffix` env redirect): an isolated dir holding only a generated `cordis.patch.yml`, a `codeman-custom` `openai-completions` provider (the base URL gets the `/v1` suffix from the shared `renderConfigFile` step), plus home-level `dsh-tui`/`agent-default-model` rows that select the exact model; the key is referenced by name via `apiKeyEnv: CODEMAN_CUSTOM_MODEL_API_KEY`, a dedicated var, because dsh's own `llm-deepseek`/`web-search-deepseek` patch rows read `DEEPSEEK_API_KEY`, and an in-session `/model` switch back to the official route must not carry the custom key to api.deepseek.com. `linkFromRealHome` + `linkFromRealHomeDirs` symlink `profiles`/`sessions` (directories, created if the real home never ran a turn) and `settings.yaml` back out of the real dsh home. `DSH_HOME` and `CODEMAN_CUSTOM_MODEL_API_KEY` are in `privilegedEnvKeys`; the operator's own `DEEPSEEK_API_KEY` handling (clamp-exempt, server-key forwarding) is unchanged | **Verified end-to-end against a real dsh install by the maintainer** (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1): `--dump-config` shows the three patched rows, the TUI boots on the chosen model, and a prompt reaches the endpoint as `POST /v1/chat/completions` with the right model, the bearer key and `max_tokens: 8192`, the reply renders and the context window shows in the footer. The original `HTTP_404` root cause stays documented in docs/custom-model-endpoints.md: dsh concatenates `/chat/completions` onto the raw base URL, which is why the env recipe needed `/v1` |
|
||||
| `omp` | Config file `~/.omp/agent/models.yml` with the same array-shaped `models` + `authHeader: true` fix as pi. Redirected via `HOME`, same reasoning as pi (`PI_CONFIG_DIR` does not relocate omp's config either, despite an earlier CLAUDE.md note claiming it does) | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back, after applying the same two fixes as pi (array-shaped `models`, `HOME`-redirect instead of `PI_CONFIG_DIR`) plus an explicit `--model custom/<id>` on invocation. Unverified against omp's own official docs (none are bundled in the install), but empirically confirmed working live |
|
||||
| `antigravity` | No CLI/env/config mechanism found — Antigravity's docs describe only a GUI settings panel, and explicitly say a custom endpoint "cannot currently" become the core reasoning model. **Not implemented**; toolbar entry stays disabled for this mode with an explanatory tooltip | No known mechanism |
|
||||
|
||||
|
||||
+122
-36
@@ -67,15 +67,23 @@ configured, `PUT`/`DELETE /api/model-endpoints/:id` update or remove one.
|
||||
Endpoint management is admin-only in multi-user mode, same as remote/docker
|
||||
hosts — these are machine-level infra, not per-user settings.
|
||||
|
||||
**Context length is discovered too, opportunistically and safely.** The plain
|
||||
`GET /v1/models` response has no context-window field. Discovery only ever
|
||||
looks for one for a model llama-swap's own response already reports
|
||||
**Context length is discovered too, opportunistically and safely.** Older
|
||||
servers state no context-window field in the plain `GET /v1/models`
|
||||
response at all; current llama-swap states one per model (both
|
||||
`context_length` and `context_window`) and gateways like Bifrost pass
|
||||
`context_window` through: that listed figure is the *lowest*-precedence
|
||||
source, used only when neither probe below answers, and because llama-swap
|
||||
documents it as the PER-SLOT window, a model whose figure comes from the
|
||||
listing is recorded with a slot count of 1 so the slot check never divides a
|
||||
per-slot figure a second time. The probes are only ever looked for on a model
|
||||
llama-swap's own response already reports
|
||||
`status.value === "loaded"` for — never for an unloaded one, because
|
||||
llama-swap treats `?model=` as a routing hint and asking about a model that
|
||||
isn't loaded risks triggering an actual (slow, GPU-swapping) load as a side
|
||||
effect of what should be read-only discovery. A server with no `status` field
|
||||
on any entry at all (not llama-swap) gets no context-length enrichment,
|
||||
rather than guessing. A model's previously-learned context length survives a
|
||||
on any entry at all (not llama-swap) gets no launch-command/`/props`
|
||||
enrichment, only whatever its listings state, rather than guessing. A
|
||||
model's previously-learned context length survives a
|
||||
later cycle where it wasn't the loaded one; it's dropped only once the model
|
||||
disappears from the endpoint's list entirely. Stored per model in
|
||||
`modelContextLengths` and applied automatically (see "Applying a model to a
|
||||
@@ -192,32 +200,66 @@ saved default, not just confirming it.
|
||||
|
||||
The modal promotes exactly one row to the top of the list rather than
|
||||
always showing raw discovery order, so the zero-wait choice is the one
|
||||
under your thumb:
|
||||
under your thumb. Three kinds of knowledge feed that promotion, best
|
||||
evidence first, and the first one that answers wins:
|
||||
|
||||
- **"Currently loaded"** — a model from this host's own list that
|
||||
- **"Currently loaded"** is a model from this host's own list that
|
||||
llama-swap reports `ready` right now, queried via
|
||||
`GET /api/model-endpoints/:id/running-status`. Bounded client-side to
|
||||
~800ms (`Promise.race`), on top of the route's own 5s server-side
|
||||
timeout, so an endpoint that is asleep or firewalled cannot leave the
|
||||
modal invisible for the full 5s after the Run menu has already closed.
|
||||
- **"Last used"** — shown only when nothing is currently loaded: the model
|
||||
actually launched last for this exact (harness, endpoint) pair, read
|
||||
from the per-device `codeman:customModelLastUsed:<mode>:<endpointId>`
|
||||
localStorage key. Written by `_runCustomModelEntryViaRestart` (claude)
|
||||
and `_quickStartWithCustomModelConfirm` (every one-shot launch; the
|
||||
- **"In use by a session"** is the fallback for every endpoint that
|
||||
cannot report its own loaded model, which is every server except
|
||||
llama-swap: a bare llama.cpp, Ollama, vLLM, and a gateway such as
|
||||
Bifrost fronting a whole fleet all answer `isLlamaSwap: false`.
|
||||
`_getCustomModelInUseBySession` walks `this.sessions` for one of your own
|
||||
sessions whose `customModel.endpointId` is this host, newest
|
||||
`createdAt` wins, and promotes its model. A live session is first-hand
|
||||
evidence of a model in use on the endpoint, because Codeman put it
|
||||
there, so it earns the promotion and the tag says where the knowledge
|
||||
came from. Two fences keep it honest: it reads only `customModel`,
|
||||
never `displayModel` (display text scraped off a CLI footer, so a
|
||||
terminal that merely printed a similar string must not mark a row),
|
||||
and a session with `status: 'stopped'`, a null `pid` (its PTY exited,
|
||||
the same live-pane test keep-warm applies) or a recorded `paneExit`
|
||||
does not count, since nothing is on the endpoint any more. Sessions on other
|
||||
endpoints, and models the host no longer discovers, are skipped.
|
||||
- **"Last used"** appears only when neither of the above answers: the
|
||||
model actually launched last for this exact (harness, endpoint) pair,
|
||||
read from the per-device localStorage key
|
||||
`codeman:customModelLastUsed:<mode>:<endpointId>`. That key is per CLI
|
||||
on purpose, so it can only
|
||||
ever promote the CLI that launched there before, which is why a gateway
|
||||
box with no session running shows plain discovery order for the other
|
||||
CLIs. Written by `_runCustomModelEntryViaRestart` (claude) and
|
||||
`_quickStartWithCustomModelConfirm` (every one-shot launch; the
|
||||
`runCustomModelEntry` entry point itself only dispatches between the
|
||||
two) only once the model is actually applied, never on the mere click —
|
||||
two) only once the model is actually applied, never on the mere click:
|
||||
declining the context-window warning means this exact model cannot work
|
||||
with this CLI at all, so promoting it next time would be actively wrong,
|
||||
not just premature.
|
||||
with this CLI at all, so promoting it next time would be actively
|
||||
wrong, not just premature.
|
||||
|
||||
Neither tag reorders anything past that one promoted row. The "Default"
|
||||
No promotion reorders anything past that one promoted row. The "Default"
|
||||
pill is a separate span, not a third value of the same slot: a promoted
|
||||
row that is also the endpoint's `defaultModelId` shows both tags (on a
|
||||
single-purpose GPU box that is the common case, and an exclusive slot
|
||||
silently dropped the Default marking for exactly that row), and a row
|
||||
with neither promotion nor default shows no tag at all.
|
||||
|
||||
When nothing is known about any row, `#customModelPickUnknown` says so
|
||||
out loud instead of leaving an alphabetical wall that reads as the app
|
||||
ignoring the question. It shows when the probe answered
|
||||
`isLlamaSwap: false`, which the route gives for every failure it
|
||||
catches, so one answer covers a server that cannot report and an
|
||||
endpoint that is down or refused alike; the line's wording therefore
|
||||
claims only that nothing was reported. A probe that never got any
|
||||
answer (`_getCustomModelRunningStatus` resolving `null`, Codeman's own
|
||||
request failed or the client-side bound won) keeps the line hidden.
|
||||
Like its sibling no-matches line it needs an
|
||||
explicit `[hidden]` rule scoped to `#customModelPickModal`, because
|
||||
`.form-hint`'s `display: block` outranks the UA `[hidden]` rule.
|
||||
|
||||
**How the launch itself applies the endpoint depends on the harness.** For
|
||||
opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP (`runCustomModelEntry` →
|
||||
`_runCustomModelEntryOneShot`), the endpoint/model is folded into the SAME
|
||||
@@ -286,6 +328,15 @@ hot-swap. A Claude session is relaunched with `--resume <conversation> ||
|
||||
grok are relaunched with the `--model` value that selects the injected
|
||||
provider (`custom/<modelId>` for pi and omp, `codeman-custom` for grok),
|
||||
since for those three the config file alone does not switch the model.
|
||||
**Codex is relaunched with `--model <modelId>` too**, the bare selected id
|
||||
(not a provider selector): the launch param forces the session onto the
|
||||
model its generated `config.toml` already selected, so a launch-time model
|
||||
carried over from App Settings can never override the injected selection.
|
||||
That launch arg must satisfy the codex entry's `model-plain-colon` token
|
||||
pattern (a leading letter or digit, then letters, digits and `._-:/`, per
|
||||
`stock.ts`), so an id outside that shape, e.g. LM Studio's
|
||||
`org/model@q4_k_m`, is refused with a 400 at apply time now, where a
|
||||
`config.toml` alone used to carry it.
|
||||
**Remote (SSH) and Docker sessions are refused** (400) for now: their restart
|
||||
reattaches the durable remote/in-container tmux rather than relaunching the
|
||||
agent, so the selection would report success and change nothing.
|
||||
@@ -293,14 +344,16 @@ agent, so the selection would report success and change nothing.
|
||||
**Claude gets two more env vars when known/applicable, both declared on its
|
||||
registry entry (`contextLengthVar`/`configDirVar`), not hardcoded here:**
|
||||
|
||||
- `CLAUDE_CODE_MAX_CONTEXT_TOKENS` is set to `modelId`'s discovered context
|
||||
length (see the discovery section above) whenever one is known. Without
|
||||
- `CLAUDE_CODE_MAX_CONTEXT_TOKENS` is set to `modelId`'s context length:
|
||||
the discovered figure (see the discovery section above), falling back to
|
||||
the endpoint's user-set `defaultContextLength`, whenever one is known.
|
||||
Without
|
||||
it, Claude Code assumes a large (200k) window for any unrecognized custom
|
||||
model id and never compacts, which reliably overflows a much smaller real
|
||||
local context — confirmed live: a stock ~33.7K-token system prompt against
|
||||
a 16384-token llama-swap model failed with `exceeds the available context
|
||||
size`. No entry for the model in `modelContextLengths` means the var is
|
||||
simply omitted, never a guess. ⚠️ **This var only affects when Claude
|
||||
size`. Neither a `modelContextLengths` entry nor a `defaultContextLength`
|
||||
for the model means the var is simply omitted, never a guess. ⚠️ **This var only affects when Claude
|
||||
Code compacts conversation _history_ — it cannot fix a model whose real
|
||||
context is smaller than Claude Code's own fixed per-turn overhead**
|
||||
(system prompt + tool schemas, empirically ~36.4K tokens, confirmed live
|
||||
@@ -441,15 +494,17 @@ Both apply routes (the restart route and the one-shot `POST
|
||||
/api/quick-start`) now check for this **before** launching or restarting
|
||||
anything, gated on the CLI's registry entry declaring a `contextLengthVar`
|
||||
(currently only claude — the check is a no-op for every other CLI by
|
||||
construction, never a hardcoded mode check). If the model's discovered
|
||||
context (`modelContextLengths`, from discovery above) is below
|
||||
`CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000, comfortably above the measured
|
||||
construction, never a hardcoded mode check). If the model's context (the
|
||||
discovered figure from `modelContextLengths`, or the endpoint's
|
||||
`defaultContextLength` when discovery never learned one for that model) is
|
||||
below `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000, comfortably above the measured
|
||||
~36.4K overhead), the response is `{requiresContextWarning: true, modelId,
|
||||
contextLength, minSafeContextTokens}` (plus `slotCount` when the model runs more than one parallel slot) instead of applying — nothing is
|
||||
restarted or created yet. A context length that was never discovered at
|
||||
all skips the check entirely (nothing to compare, so it fails open rather
|
||||
than warning on every model an endpoint hasn't reported a size for).
|
||||
The same warning fires when the context divided across the model's parallel slots (`contextLength / slotCount`) is below the floor, because Claude Code sends several 40K-token requests at once and the slots share one pool. Retrying with `confirmedContext: true` launches anyway (the legacy `confirmed: true` still means both questions).
|
||||
restarted or created yet. A model with neither a discovered figure nor a
|
||||
`defaultContextLength` skips the check entirely (nothing to compare, so it
|
||||
fails open rather than warning on every model an endpoint hasn't reported a
|
||||
size for).
|
||||
The same warning fires when the context divided across the model's parallel slots (`contextLength / slotCount`) is below the floor, because Claude Code sends several 40K-token requests at once and the slots share one pool. ⚠️ `defaultContextLength` is the model's whole window, never a per-slot figure, so a default small enough to split under the slot count trips that slot division exactly like a discovered figure does. Retrying with `confirmedContext: true` launches anyway (the legacy `confirmed: true` still means both questions).
|
||||
|
||||
The Run-menu picker shows this as an in-app modal
|
||||
(`#customModelContextWarningModal`, matching the llama-swap conflict
|
||||
@@ -526,7 +581,12 @@ for '<id>' not found. Defaulting to fallback metadata...` on launch —
|
||||
and fabricating a fake catalog entry would mean copying the _shape_ of
|
||||
OpenAI's own proprietary schema (their per-model system-prompt content
|
||||
included) for a warning that doesn't otherwise affect behavior — not
|
||||
something to build into discovery.
|
||||
something to build into discovery. ⚠️ The fallback-metadata warning is
|
||||
NOT about the context window going un-overridden. Codex does now get a
|
||||
real context-window override: when a figure is known the generated
|
||||
`config.toml` sets the top-level `model_context_window` to it and
|
||||
`model_auto_compact_token_limit` to 75% of it (floored), so Codex
|
||||
compacts at that point instead of trusting its fallback guess.
|
||||
- **Gemini** — fails with `Invalid auth method selected`, traced to an
|
||||
undocumented `GATEWAY` auth path gemini-cli selects once
|
||||
`GOOGLE_GEMINI_BASE_URL` is set. Unresolved after real investigation
|
||||
@@ -541,14 +601,40 @@ for '<id>' not found. Defaulting to fallback metadata...` on launch —
|
||||
`POST <baseUrl>/chat/completions` 404s against llama-swap while
|
||||
`POST <baseUrl>/v1/chat/completions` succeeds; the harness's own error
|
||||
template (`DeepSeek API error (HTTP ${status})`) matches the originally
|
||||
reported symptom exactly. `customModelInjection`'s new `appendV1Suffix`
|
||||
(deepseek's entry only — claude/gemini must NOT get it, since claude was
|
||||
already confirmed working against the raw `baseUrl`) fixes it by writing
|
||||
`DEEPSEEK_BASE_URL` with `/v1` appended. Not yet re-run end-to-end with a
|
||||
real `dsh` binary (no install available in this environment) — the fix
|
||||
is source-confirmed and live-verified at the HTTP level, but a real
|
||||
"hello world" reply through `dsh` itself is still outstanding before
|
||||
calling this fully verified like the harnesses above.
|
||||
reported symptom exactly. The fix is no longer the original env redirect:
|
||||
deepseek's `customModelInjection` is a `configDir` recipe: a per-session
|
||||
`DSH_HOME` holding only a generated `cordis.patch.yml` that declares a
|
||||
`codeman-custom` `openai-completions` provider (the base URL gets the `/v1`
|
||||
suffix from the shared `renderConfigFile` step every file template goes
|
||||
through), so the installed profile keeps supplying the transport; the
|
||||
recipe's `linkFromRealHome` field symlinks `profiles`, `sessions` and
|
||||
`settings.yaml` back out of the real dsh home so installed bundles,
|
||||
transcripts and user settings all survive the redirect (missing
|
||||
directory entries are created first: a home that has never run a
|
||||
turn would otherwise get a dangling link whose every write through
|
||||
it failed with ENOENT). **Verified end-to-end against a real dsh
|
||||
install by the maintainer** (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1):
|
||||
`--dump-config` shows the three patched rows, the TUI boots on the
|
||||
chosen model, and a prompt reaches the endpoint as
|
||||
`POST /v1/chat/completions` with the right model, the bearer key and
|
||||
`max_tokens: 8192`; the reply renders and the context window shows in
|
||||
the footer. What such a session SHARES with the real home is
|
||||
exactly the linked entries: `profiles/`, `sessions/`,
|
||||
`settings.yaml`. The last is shared only until the session writes
|
||||
it: dsh saves settings with a temp file and a rename
|
||||
(`writeFileAtomic` in `@deepseek-ai/dsh-atomic-write`), which replaces
|
||||
the symlink, so the first `/settings` change inside such a session
|
||||
never reaches `~/.dsh/settings.yaml` and is deleted with the session's
|
||||
directory on clear or close. Everything else in the generated `DSH_HOME` is
|
||||
isolated: `storages/` (dsh-TUI's persisted `/model` choice and
|
||||
workspace state), `.agent-presets` and `.env`. Two consequences of
|
||||
the generated home-level file follow: the user's own home-level
|
||||
`cordis.patch.yml` does NOT apply in such a session (the generated
|
||||
file replaces it; profile-level patch files reached through the
|
||||
linked `profiles/` still compose), and the generated patch REPLACES
|
||||
the `llm-pi-ai` provider row's whole `config` for the session. Note
|
||||
too that it likewise REPLACES the `dsh-tui` row's whole `config`, so
|
||||
a profile-level `effort`/`preset` does not apply.
|
||||
- **Antigravity** — no known custom-endpoint mechanism at all; unsupported.
|
||||
|
||||
See the confidence table in `custom-model-endpoints-plan.md` for the full detail behind
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# Discord bridge
|
||||
|
||||
Codeman can post to a private Discord server when a session needs you, give every tab its own thread, and type your thread reply into that tab. It only makes outbound connections, so Codeman is not exposed to the internet and it works when you are away from your network.
|
||||
|
||||
> [!WARNING]
|
||||
> A reply from an allowed Discord account is typed into agents that can run shell commands. Treat that Discord account like a login to the machine: turn on 2FA and use a strong, unique password.
|
||||
|
||||
## What it does
|
||||
|
||||
- Posts when a session asks for permission, asks a question, goes idle or errors. Which of these a CLI can raise depends on the hooks it has: Claude Code sends a turn end (`stop`, an `info` event that posts only when **Which events** is set to Everything, and `idle_prompt`, which posts by default after the turn has waited a minute). DeepSeek Harness sends only `stop`. Codex, OMP, OpenCode, Gemini, Antigravity, Pi, Grok and Copilot send no turn end, so they reach Discord through an error, a blocked respawn, a stopped crash loop or their own `codeman notify`; Codex's permission and question dialogs are also read off its screen.
|
||||
- Posts a finished turn, and an error or blocked alert, as the CLI's whole last message, with its bold, lists and numbered questions kept (a Markdown table, which Discord cannot draw, becomes an aligned code block), split over several posts if it is long. It reads the CLI's transcript where it keeps one (Claude Code, Codex, DeepSeek Harness, OMP) and otherwise splits the screen into turns (OpenCode, Gemini, Antigravity, Pi, Grok, Copilot), which can start mid-message when a long answer has scrolled off. A shell, and a permission or question dialog, show the last lines of the screen instead, without the prompt row and status lines under them.
|
||||
- Creates one thread per tab in a single parent channel, so a reply always reaches exactly one session.
|
||||
- Types your reply into that session as a single line followed by Enter. A single digit sent while a permission or question is waiting goes as the key alone, without Enter, because the dialog acts on the keypress.
|
||||
- Lets a CLI post a deliberate message (for example "press the reset button on the case") with the `codeman notify` command, which calls `POST /api/sessions/:id/discord-notify`. Each tab may post 6 a minute; past that the command says "too many messages", so a looping agent cannot ping your phone without end.
|
||||
- Saves files you attach in a thread into that tab's working folder and tells the CLI the path. A CLI can send a file back with `codeman notify "message" --file <path>`.
|
||||
- Tells each CLI about `codeman notify` (see [Telling the CLIs](#telling-the-clis)).
|
||||
|
||||
## Setup
|
||||
|
||||
Do these once, in order. Never paste the bot token into a chat or a session.
|
||||
|
||||
### 1. Create a private server
|
||||
|
||||
1. In Discord choose **Add a Server**, **Create My Own**, **For me and my friends**.
|
||||
2. Invite nobody and create no invite links.
|
||||
3. Create one text channel, for example `#codeman-cli`.
|
||||
|
||||
### 2. Copy the IDs
|
||||
|
||||
1. Open **User Settings**, **Advanced** and turn on **Developer Mode**.
|
||||
2. Right-click the server, the channel and your own avatar, and choose **Copy ID** for each. You need the guild ID, the parent channel ID and your user ID.
|
||||
|
||||
### 3. Create the application
|
||||
|
||||
1. Go to <https://discord.com/developers/applications> and choose **New Application**.
|
||||
2. Name it `Codeman`.
|
||||
|
||||
### 4. Configure the bot
|
||||
|
||||
On the **Bot** tab:
|
||||
|
||||
1. Choose **Reset Token** and copy it. It is shown once.
|
||||
2. Turn **Public Bot** off, so nobody else can add it. Discord refuses this while the app has a default install link, so first open the **Installation** tab, set **Install Link** to **None** (untick Guild Install and User Install if it will not save) and save, then come back here.
|
||||
3. Leave **Requires OAuth2 Code Grant** off.
|
||||
4. Under **Privileged Gateway Intents** turn **Message Content Intent** on. Leave **Server Members Intent** and **Presence Intent** off.
|
||||
|
||||
### 5. Invite the bot with minimal permissions
|
||||
|
||||
On **OAuth2**, **URL Generator**:
|
||||
|
||||
1. Tick the scope `bot` only. Do not tick `applications.commands` or anything else.
|
||||
2. Tick only these permissions (Attach Files and Embed Links are optional: without them, files cannot be sent from a CLI and links show no preview):
|
||||
|
||||
| Permission | Why |
|
||||
| ------------------------ | ------------------------------------ |
|
||||
| View Channels | See the parent channel |
|
||||
| Send Messages | Post in the parent channel |
|
||||
| Send Messages in Threads | Post in each tab's thread |
|
||||
| Create Public Threads | Make a thread per tab |
|
||||
| Read Message History | Read your replies |
|
||||
| Add Reactions | Acknowledge a reply that was typed |
|
||||
| Attach Files | Send a file from a CLI to the thread |
|
||||
| Embed Links | Show previews for links in posts |
|
||||
|
||||
3. Open the generated URL, choose your private server and authorise.
|
||||
4. Check the **Installation** tab still shows **Install Link: None**, so there is no public way to add the bot.
|
||||
|
||||
The ticked boxes are the source of truth. As a cross-check, these eight permissions add up to the permissions integer `309237763136`.
|
||||
|
||||
### 6. Optional: allow deleting threads
|
||||
|
||||
Only needed for `onSessionDelete: delete`. Give the bot role **Manage Threads** on the parent channel only, as a channel permission override and not server-wide. Skip this if you are happy to keep or archive threads.
|
||||
|
||||
### 7. Never grant
|
||||
|
||||
Administrator, Manage Channels, Manage Roles, Manage Messages, Mention Everyone, or any voice permission.
|
||||
|
||||
### 8. Configure Codeman
|
||||
|
||||
1. Open **Settings**, **Notifications**, then the **Discord** group.
|
||||
2. Enter the bot token, guild ID, parent channel ID and your user ID in the allowlist.
|
||||
3. Choose the options below and press **Send test**. A test message should appear in the channel.
|
||||
|
||||
The token is stored in `~/.codeman/discord.json` with mode 0600. It is never returned by the API, written to logs or sent over SSE.
|
||||
|
||||
### 9. Phone alerts
|
||||
|
||||
In the Discord mobile app set the server to **All Messages**. Discord's own push notification is what reaches you off-network.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Values | Default | Effect |
|
||||
| ----------------- | --------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `threadCreation` | `lazy`, `onSessionStart` | `lazy` | `lazy` makes a thread on the first notification. `onSessionStart` makes one per new tab |
|
||||
| `onSessionDelete` | `keep`, `archive`, `delete` | `archive` | What happens to the thread when its tab is deleted. `delete` needs Manage Threads |
|
||||
| `staleThreads` | `keep`, `archive`, `delete` | `keep` | What happens to threads this bot made that no tab uses any more, checked after startup and then daily. A deleted tab's thread leaves the map, so this also archives or deletes threads that `onSessionDelete` kept or archived. Skips threads under an hour old, other people's threads and other channels, does nothing while the thread map is empty, and closes at most 25 per run. `delete` needs Manage Threads. Use one channel per Codeman |
|
||||
| `pingMode` | `off`, `critical`, `all` | `critical` | `critical` pings you for permission prompts, questions, errors and urgent posts |
|
||||
| `scope` | `attention`, `all` | `attention` | `attention` skips the quiet "response complete" event. `all` sends it too |
|
||||
| `startupHint` | on, off | on | Tell Claude about `codeman notify` in its system prompt at launch |
|
||||
|
||||
## Telling the CLIs
|
||||
|
||||
A CLI only posts deliberately if it knows it can. While the bridge is on:
|
||||
|
||||
- **Claude** (`startupHint`, on by default) is launched with `--append-system-prompt`, so nothing shows in the conversation. This reaches local tabs only: remote (ssh) and Docker launches are built outside the CLI registry, and have no `codeman` command anyway.
|
||||
- **Every other CLI** is sent nothing: there is no silent way to tell it, and typing the note as a message would cost a model turn per tab. It can still run `codeman notify` if you tell it to.
|
||||
|
||||
The text explains `codeman notify "message" --urgent`. `codeman` is on the PATH of every session from the installer or an npm install (a git checkout run without the installer has no `codeman` command) and reads `CODEMAN_SESSION_ID` and `CODEMAN_API_URL`, so no other setup is needed. Without a bridge it prints that the bridge is off and exits cleanly. Tabs started before the bridge was enabled are not told; use the skill or `CLAUDE.md` for those.
|
||||
|
||||
## Files
|
||||
|
||||
- **Received:** files you attach to a reply from an allowed user are saved as `<working folder>/.codeman-discord/<message id>-<name>` (mode 0600) and the one-line reply the CLI sees ends with `[attached: <paths>]`. At most 5 files of 8 MB each; names are reduced to letters, digits, `.`, `_` and `-`; only Discord's own HTTPS addresses are fetched. Anything skipped is named in the thread. The folder ignores itself (it holds a `.gitignore` of `*`), so nothing in it is committed. Remote (ssh) tabs have no local folder, so no file is saved for them.
|
||||
- **Sent:** `codeman notify "message" --file <path>` uploads a regular file of at most 8 MB. The real path (symlinks followed) must be inside the tab's working folder, and a file on the attachment blocklist (`.env`, keys under `.ssh`, Codeman's own state) is refused even there, which stops an upload of something sensitive by mistake. The agent runs as your user, so this is a guard against accidents, not a sandbox. Remote tabs cannot send files.
|
||||
- A downloaded file is untrusted content: only allowed users can send one, but a CLI that opens it is exposed to anything written inside it.
|
||||
|
||||
## Security model
|
||||
|
||||
- Only messages from IDs in `allowedUserIds`, in the configured guild, inside a thread Codeman created, are acted on. Everything else is dropped.
|
||||
- A reply is reduced to one line, stripped of control characters and capped in length before it is typed.
|
||||
- Replies are rate limited per session.
|
||||
- A Discord outage or a bad token never affects sessions. The bridge backs off, and the last error shows in Settings.
|
||||
|
||||
## Rotating and revoking
|
||||
|
||||
- **Rotate:** in the Developer Portal choose **Bot**, **Reset Token**, paste the new token into Codeman and press **Send test**.
|
||||
- **Revoke:** kick the bot from the server or delete the application, then choose **Remove token** in Settings. Deleting `~/.codeman/discord.json` alone does not reach a running bridge, which keeps the token it loaded until Codeman restarts.
|
||||
- If the token is ever pasted somewhere it should not be, reset it straight away.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
| ----------------------------------- | ------------------------------------------------------------------------ |
|
||||
| Test fails with 401 | The token is wrong or was reset. Paste the current one |
|
||||
| Test fails with 403 or 50001 | The bot cannot see the parent channel. Check the role and the channel ID |
|
||||
| Posts arrive but replies do nothing | Message Content Intent is off, or your user ID is not in the allowlist |
|
||||
| Thread is not deleted | The bot lacks Manage Threads on the parent channel |
|
||||
| Nothing arrives on the phone | Check the Discord app's notification setting for the server |
|
||||
+1
-1
@@ -59,4 +59,4 @@ A security notice is a sticky warning card about something outside Codeman that
|
||||
- `title` (optional) is the card's bold line; keep it short. `link.url` must be https. `i18n` is optional; a missing string falls back to the English one.
|
||||
- Run `npx prettier --write src/web/public/security-notices.json` and `npm test -- test/security-notices.test.ts`, which validates the file (unique ids, real dates, https links, every `cli` a stock registry id, a zh-CN title wherever there is an English one).
|
||||
|
||||
**Retiring one**: let it expire, or delete the entry once it has. Removing it early stops it on the next release only; browsers already showing it keep the card until they close it or reload.
|
||||
**Retiring one**: let it expire, or delete the entry once it has. Removing it early stops it on the next release only; browsers already showing it keep the card until they close it or reload. Until then a fresh install gets it too, because a new browser has no seen list, so give an `expires` no later than the warning stays useful. An empty list (`{ "notices": [] }`) is a valid file.
|
||||
@@ -582,6 +582,7 @@ Opt-in and off by default: the server POSTs the Web Push events (permission prom
|
||||
|
||||
- **A second server-side outbound channel through the web-tab egress guard (§10b).** Delivery goes through `webviewFetch`, so link-local and cloud-metadata targets are refused at save time and again on the RESOLVED address at connect time; redirects are not followed (`redirect: 'manual'`) and each send is bounded by a 5 s timeout. Loopback and RFC1918 stay allowed on purpose (a self-hosted ntfy is the point), so **Send test** works as a blind reachability probe (status, refused or timed out, never a response body) for whoever may call it. Web tabs already give that caller full LAN reach with bodies, so nothing new is exposed.
|
||||
- **The URL is a bearer secret** (anyone holding a Slack or Discord webhook URL can post as it). It lives in `~/.codeman/webhook.json` (0600, tmp+rename), is kept out of `settings.json` (which every logged-in user reads through `GET /api/settings`), is never returned (`GET /api/webhook` gives scheme + host only), and never appears in a log line, a delivery result or an error message.
|
||||
- **Discord bridge** (`src/discord-bridge.ts`, [setup](discord-bridge.md)): the bot token lives in `~/.codeman/discord.json` (0600, tmp+rename) and is never returned by the Discord routes, logged or put in an error, and the attachment, folder and download routes refuse the file. A session whose folder is the home directory can still read it through its own file viewer, which serves that workspace without the blocklist. Inbound replies are typed into a session only from `allowedUserIds`, in the configured guild, inside a thread the bridge created; anything else is dropped unread. A reply becomes one printable line plus Enter, capped at 1000 characters and one per second per tab. Posts send `allowed_mentions` limited to the allowlisted ids, so agent output cannot ping a server. **Files:** an attachment is fetched only from Discord's own HTTPS hosts (no redirects followed), at most 5 per reply and 8 MB each, saved 0600 under `<tab folder>/.codeman-discord/` with the name reduced to `[A-Za-z0-9._-]`; a file a CLI sends must be a regular file whose real path (symlinks followed) lies inside its own tab's folder and is not on the attachment blocklist (`isBlockedAttachmentPath`, since a tab's folder can be the home directory), which stops a sensitive file being uploaded by mistake, and remote sessions send and receive none. A received file is untrusted content to whatever opens it. **What the checks do not stop:** the agent runs as the same OS user, so the path check prevents accidental uploads, not a determined agent that pastes a file's contents into the message. **What leaves the machine:** a permission or question alert sends the rendered screen (whatever the agent printed), and a finished turn or an error sends the CLI's whole last message, read from its transcript (up to 8 posts of 1,900 characters). `idle_prompt` is a warning, so with the default scope every Claude turn left unanswered for a minute is posted, and with **Which events** set to Everything every turn end is; treat the Discord server as holding that output. **Multi-user:** the bridge is one allowlist for the whole server, so an allowlisted Discord account can type into every user's sessions; only an admin can configure it. **Startup hint:** a fixed text (no user input) is added to Claude's system prompt; no other CLI is sent it. The bridge only dials out (REST and the gateway WebSocket), so it needs no inbound exposure. A Discord account on the allowlist can drive agents that run shell commands: it needs 2FA.
|
||||
- **It carries session data to a third party.** Titles and bodies include session names, tool names and error text, all agent- or user-controlled, so Discord gets `allowed_mentions: { parse: [] }` and Slack's `& < >` are escaped: agent output cannot ping a channel. In multi-user mode all three routes are admin-only and the channel is instance-wide: it receives every user's session events, the same reach an admin's own Web Push has, which means non-admins' session details leave the box at the admin's choice.
|
||||
|
||||
---
|
||||
|
||||
@@ -15,6 +15,7 @@ A fixed list of files from the data directory (`~/.codeman/`, or the directory o
|
||||
| `clis.json` | CLI enable/disable and custom CLI entries. |
|
||||
| `custom-model-hosts.json` | Saved custom model endpoints. **Holds API keys.** |
|
||||
| `webhook.json` | Webhook notification target. |
|
||||
| `discord.json` | Discord bridge settings. **Holds a bot token.** |
|
||||
| `intents.json` | Read My Mind per-case profiles. |
|
||||
| `linked-cases.json` | Registered cases. |
|
||||
| `remote-hosts.json`, `remote-cases.json`, `docker-hosts.json`, `docker-cases.json` | Remote SSH and Docker overlay configuration. |
|
||||
@@ -188,15 +189,15 @@ that are the same are listed on one line. A file that is not JSON shows only tha
|
||||
a file with more than 200 changes is cut short, so use the files themselves for a full diff. The
|
||||
self-rewritten fields listed under "When a backup is made" are not counted as changes.
|
||||
|
||||
**Credential values are never shown.** `.env`, `users.json`, `push-keys.json`, `webhook.json` (the
|
||||
URL is a bearer secret) and `intents.json` (captured prompts) list only the names that changed, and in every other file a key that looks like a token, secret, password,
|
||||
**Credential values are never shown.** `.env`, `users.json`, `push-keys.json`, `webhook.json` (the URL is a bearer secret), `discord.json` (the
|
||||
bot token) and `intents.json` (captured prompts) list only the names that changed, and in every other file a key that looks like a token, secret, password,
|
||||
credential or key is shown as "changed" without its value. URL values lose any `user:password@`,
|
||||
query and fragment.
|
||||
|
||||
## Security
|
||||
|
||||
- Some tracked files carry credentials (`.env`, `users.json`, `custom-model-hosts.json`,
|
||||
`push-keys.json`, `webhook.json`). Each backup folder is mode `0700` and its files `0600`, so the default
|
||||
`push-keys.json`, `webhook.json`, `discord.json`). Each backup folder is mode `0700` and its files `0600`, so the default
|
||||
location exposes nothing the data directory does not already.
|
||||
- **A custom folder is your decision.** Putting it on a shared drive, a synced folder or a
|
||||
network share copies those credentials there, with whatever permissions that location has.
|
||||
|
||||
@@ -22,11 +22,11 @@ Codeman-side configuration:
|
||||
Three ways to get one, all under **New or link a case…** at the bottom of the case picker
|
||||
(the case dropdown in the bottom toolbar; on a phone, the case sheet's **Create New Case**):
|
||||
|
||||
| How | Result |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. |
|
||||
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` and registered as a case. Private repos need this machine's own git credentials (see below). |
|
||||
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
|
||||
| How | Result |
|
||||
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. |
|
||||
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` (or straight into a folder you choose, which must be empty or not exist yet) and registered as a case. Private repos need this machine's own git credentials (see below). |
|
||||
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
|
||||
|
||||
Linked cases keep living where they are. Deleting a case in Codeman removes the
|
||||
registration, and for a linked case that is all it removes.
|
||||
|
||||
@@ -27,14 +27,20 @@ hosts — these are machine-level infra, not a per-user setting.
|
||||
shows up without another manual click of **Discover**. One endpoint being unreachable on a
|
||||
given cycle (powered off, wrong network) never blocks the others from refreshing.
|
||||
|
||||
**Context length is picked up automatically where it can be, safely.** Against a
|
||||
llama.cpp/llama-swap server, discovery also learns each _currently loaded_ model's real
|
||||
context window and applies it to the launched session (Claude Code today — see below), so
|
||||
the harness stops assuming a large default window for a model name it doesn't recognise and
|
||||
overflowing a much smaller real one. It's deliberately never probed for a model that isn't
|
||||
already loaded, since asking a llama-swap server about an unloaded model can trigger an
|
||||
actual, slow model swap as a side effect — a model just not currently loaded keeps whatever
|
||||
context length an earlier cycle already learned for it instead.
|
||||
**Context length is picked up automatically where it can be, safely.** Every model's own
|
||||
listing entry is read for a stated context figure (`context_length`, then `context_window`,
|
||||
which current llama.cpp, llama-swap and gateway-style servers state to varying degrees);
|
||||
against a llama-swap server the stronger figures from its launch command and from llama.cpp's
|
||||
`/props` probe win where they exist, and those probes only ever run for a model the server
|
||||
already reports loaded, since asking about an unloaded model can trigger an actual, slow model
|
||||
swap as a side effect. A model that states no figure keeps whatever an earlier cycle learned:
|
||||
the stored figure always beats the endpoint's **Default context length** field
|
||||
(`modelContextLengths[id] ?? defaultContextLength`), which only applies where no figure was
|
||||
ever learned (that number is the model's whole window, never a per-slot figure). A model
|
||||
neither covers simply gets no override, never a guess. The window is applied to the launched session by the harnesses
|
||||
that can take it: Claude Code today (see below), OpenCode as the model's context limit, and
|
||||
Codex as a `model_context_window` entry in its generated config file, which also sets Codex's
|
||||
own auto-compact point via `model_auto_compact_token_limit` at 75% of the window.
|
||||
|
||||
## Running a session against one
|
||||
|
||||
@@ -48,15 +54,20 @@ pointed at.
|
||||
|
||||
**Which model it uses depends on how many the endpoint has discovered.** With exactly one,
|
||||
the session launches straight away on that model — nothing to choose. With two or more, a
|
||||
small dialog asks which one to use for this launch before starting the session; the
|
||||
small dialog asks which one to use for this launch before starting the session; a search box
|
||||
at the top narrows a long list with a case-insensitive substring match, so a server hosting
|
||||
hundreds of models stays usable. The
|
||||
endpoint's default model, if set, is marked but not auto-picked, so a launch can deliberately
|
||||
use a different one without changing the saved default. The list is not raw discovery order
|
||||
either: the model llama-swap reports loaded and ready is moved to the top and tagged
|
||||
**Currently loaded**, and when nothing is loaded, the model you last launched on this harness
|
||||
and endpoint pair is moved up instead and tagged **Last used** (a per-device browser value, so
|
||||
another device starts from its own history). The default model keeps its own **Default** pill
|
||||
in both cases, and nothing is ever auto-chosen: the promoted row is simply the one under your
|
||||
thumb.
|
||||
**Currently loaded**. When nothing is reported loaded, the model one of your own running
|
||||
sessions is using on that endpoint is moved up instead and tagged **In use by a session** (it
|
||||
beats **Last used**, never **Currently loaded**), and failing that the model you last launched
|
||||
on this harness and endpoint pair is moved up and tagged **Last used** (a per-device browser
|
||||
value, so another device starts from its own history). With none of those, a line under the
|
||||
search box says nothing is marked, so the plain list is not mistaken for an answer. The default
|
||||
model keeps its own **Default** pill in every case, and nothing is ever auto-chosen: the
|
||||
promoted row is simply the one under your thumb.
|
||||
|
||||
**For opencode, Codex, Gemini, Pi, Grok, DeepSeek, OMP and GitHub Copilot, picking an entry launches
|
||||
straight onto the endpoint** — no restart, because the endpoint is applied before the
|
||||
@@ -186,7 +197,7 @@ read is treated as one slot, never guessed.
|
||||
| **Claude Code, opencode, Pi, Grok, OMP** | Verified end-to-end against a real local server. |
|
||||
| **Codex** | Config is correct, and plain chat can work against a server that speaks the Responses API — but a real tool-call attempt comes back as inert text instead of running, so it's still not usable for real coding work. |
|
||||
| **Gemini** | Fails with an auth error gemini-cli raises once redirected. Unresolved; don't rely on it yet. |
|
||||
| **DeepSeek** | The original 404 is root-caused and fixed (DeepSeek Harness's own code was missing a `/v1` most local servers require) — not yet re-run against a real `dsh` install to confirm end-to-end. |
|
||||
| **DeepSeek** | Verified end-to-end against a real `dsh` install (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1): the original 404 is root-caused and fixed, and the redirect is now a generated per-session patch file whose prompt reaches the endpoint as `POST /v1/chat/completions`. Such a session does not carry over your home-level `cordis.patch.yml`, your own `llm-pi-ai` providers, or a profile-level `effort`/`preset`. |
|
||||
| **Antigravity** | No known custom-endpoint mechanism at all. Not offered. |
|
||||
|
||||
Which harnesses show up in the Run-menu picker is read live off Codeman's own CLI registry,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Discord Bridge
|
||||
|
||||
Answer a blocked agent from your phone, from anywhere. Codeman posts to a private Discord
|
||||
server when a session needs you, gives every tab its own thread, and types your reply in that
|
||||
thread into that tab. It only makes outbound connections, so nothing is exposed to the internet
|
||||
and it works when you are away from your network.
|
||||
|
||||
**Opt-in, off by default. One server, one channel, threads per tab.**
|
||||
|
||||
> **Treat the Discord account like a login to the machine.** A reply from an allowed account is
|
||||
> typed into agents that can run shell commands. Turn on 2FA and use a strong, unique password.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Alerts** for permission prompts, questions, idle sessions, errors and a stopped crash loop. A
|
||||
question or permission prompt shows what is on the tab's screen right now, so you can see what is
|
||||
being asked; a finished turn or an error carries the CLI's whole last message.
|
||||
- **One thread per tab** in a parent channel, so a reply always reaches exactly one session.
|
||||
- **Replies** from an allowed user are typed into that tab as one line plus Enter (a single digit answering a waiting permission or question goes as the key alone, without Enter).
|
||||
- **Files** you attach are saved in the tab's folder and the agent is given the path. An agent can
|
||||
send a file back.
|
||||
- **Agents can ask for you.** `codeman notify "Press the reset button on the case" --urgent`
|
||||
posts to the tab's thread. Sessions from the installer or an npm install have the command (a git checkout run without the installer does not); see [below](#telling-the-agents).
|
||||
|
||||
## Set it up
|
||||
|
||||
The full least-privilege walkthrough (private server, bot, the exact permissions, IDs, token
|
||||
handling) is in [docs/discord-bridge.md](https://github.com/Ark0N/Codeman/blob/master/docs/discord-bridge.md).
|
||||
In short:
|
||||
|
||||
1. Make a private Discord server with one text channel, and copy the server, channel and your user IDs.
|
||||
2. Create an application with a bot. Turn **Public Bot** off, turn **Message Content Intent** on, and
|
||||
invite it with the `bot` scope and only the permissions the guide lists (no Administrator).
|
||||
3. In **App Settings → Notifications → Discord**, paste the bot token and the three IDs, press **Save**,
|
||||
then **Send test**, then turn **Talk to your sessions from Discord** on and **Save** again.
|
||||
|
||||
The token is a secret: it is kept in its own file (`~/.codeman/discord.json`, readable by its owner
|
||||
only), never in the shared settings, and never shown again once saved.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Choices | Default |
|
||||
| ---------------------------------------- | ------------------------------------------- | --------------- |
|
||||
| Create threads | On the first alert, or for every new tab | First alert |
|
||||
| When a tab is deleted | Keep, archive or delete the thread | Archive |
|
||||
| Stale threads | Keep, archive or delete threads no tab uses | Keep |
|
||||
| @ ping | Never, critical events only, every post | Critical only |
|
||||
| Which events | Needs attention, or everything | Needs attention |
|
||||
| Tell Claude how to reach you | On or off | On |
|
||||
|
||||
A finished turn, and an error, is posted as the CLI's whole last message, with its formatting and questions
|
||||
kept, split over several posts if it is long. A shell, and permission or question dialogs, show the last lines
|
||||
of the screen instead.
|
||||
|
||||
Only some CLIs raise a turn end. Claude Code does (an idle alert by default, and a stop alert when **Which
|
||||
events** is Everything), and DeepSeek Harness sends a stop. The others (Codex, OMP, OpenCode, Gemini,
|
||||
Antigravity, Pi, Grok, Copilot) send none, so their messages reach Discord with an error or `codeman notify`;
|
||||
Codex's dialogs are read off its screen.
|
||||
|
||||
The stale-thread setting also archives or deletes threads that **When a tab is deleted** kept or archived, because a
|
||||
deleted tab's thread leaves the bridge's map.
|
||||
|
||||
Deleting a thread needs the **Manage Threads** permission on the channel. "Critical" means a
|
||||
permission prompt, a question, an error, or a post an agent marked urgent.
|
||||
|
||||
## Telling the agents
|
||||
|
||||
An agent only posts deliberately if it knows it can:
|
||||
|
||||
- **Claude** is launched with the instruction as part of its system prompt (**Tell Claude how to reach you**, on by default), so nothing shows in the chat. This reaches local tabs only (remote and Docker tabs have no `codeman` command).
|
||||
- **Every other CLI** is not told. It can still run `codeman notify` if you tell it to.
|
||||
|
||||
Tabs that were already open when you turned the bridge on are not told. The alerts still reach
|
||||
Discord for them; only the agent-initiated `codeman notify` habit is missing.
|
||||
|
||||
## Files
|
||||
|
||||
- **You to the agent:** attach a file to a reply. It is saved as
|
||||
`<tab folder>/.codeman-discord/<message id>-<name>` and the line the agent sees ends with
|
||||
`[attached: <path>]`. At most 5 files of 8 MB each. The folder ignores itself, so nothing in it is committed.
|
||||
- **Agent to you:** `codeman notify "message" --file <path>` uploads a file of at most 8 MB that lies
|
||||
inside the tab's folder. Credential files such as `.env` are refused.
|
||||
- Remote (ssh) tabs have no local folder, so they neither send nor receive files.
|
||||
- A file you send is untrusted content to the agent that opens it.
|
||||
|
||||
## Safety
|
||||
|
||||
- Only user IDs on the allowlist, in your server, inside a thread Codeman made, are acted on.
|
||||
Everything else is dropped.
|
||||
- A reply is cut to one printable line (1000 characters) and limited to one per second per tab.
|
||||
- Posts can ping only the allowlisted users, so agent output cannot ping the whole server.
|
||||
- A Discord outage or a bad token never affects a session; it backs off, and the last error shows in Settings.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
| ------------------------------------------------ | -------------------------------------------------------------- |
|
||||
| Send test says 401 | The token is wrong or was reset. Paste the current one. |
|
||||
| Send test says the bot cannot see it | Check the bot's role and the channel ID. |
|
||||
| Posts arrive but replies do nothing | Message Content Intent is off, or your user ID is not allowed. |
|
||||
| Status line says "Message Content Intent is off" | Turn it on in the Developer Portal, Bot tab. |
|
||||
| A thread is not deleted | The bot lacks Manage Threads on the channel. |
|
||||
|
||||
See also: [Notifications And Approvals](Notifications-And-Approvals) for the other ways to hear
|
||||
from an agent, and [Security](Security) for how secrets are handled.
|
||||
@@ -79,6 +79,7 @@ codeman web # then open http://localhost:3000
|
||||
| ------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| [Keeping Agents Running](Keeping-Agents-Running) | How does it run unattended overnight? |
|
||||
| [Notifications And Approvals](Notifications-And-Approvals) | How do I know an agent needs me, and answer from my phone? |
|
||||
| [Discord Bridge](Discord-Bridge) | How do I answer a blocked agent from Discord, in a thread per tab? |
|
||||
| [Cron Jobs](Cron-Jobs) | How do I run an agent on a schedule? |
|
||||
| [Autonomous Loops](Autonomous-Loops) | What are the Ralph and Orchestrator loops for? |
|
||||
| [Watching Agents Work](Watching-Agents-Work) | How do I see what the subagents are doing? |
|
||||
|
||||
@@ -13,6 +13,7 @@ opening the session.
|
||||
| Desktop notification | Another window on the same machine | Opt-in |
|
||||
| Push notification | Anywhere, even with no tab open | Opt-in |
|
||||
| Webhook (ntfy, Slack, Discord) | Anywhere, with no browser or subscription at all | Opt-in |
|
||||
| Discord bridge (two-way) | Anywhere, and you can answer in a thread per tab | Opt-in |
|
||||
| Approvals Inbox | One queue for Claude, Codex and DeepSeek prompts | Opt-in |
|
||||
| Phone overview | Phone home screen, NEEDS YOU section | On |
|
||||
| Away Digest | Afterwards, as a summary | Opt-in |
|
||||
@@ -66,6 +67,11 @@ Setup:
|
||||
|
||||
Once subscribed, a blocking prompt reaches your phone even from a locked screen.
|
||||
|
||||
## Discord bridge: answer from your phone
|
||||
|
||||
A webhook is one-way. To also **reply** to a blocked agent, use the [Discord Bridge](Discord-Bridge): a
|
||||
thread per tab, your reply typed into that tab, files in both directions.
|
||||
|
||||
## Webhooks: ntfy, Slack, Discord
|
||||
|
||||
**Opt-in, off by default. One channel for the whole server.**
|
||||
|
||||
@@ -62,7 +62,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
|
||||
Chips for every optional header control, with a live preview of the resulting header:
|
||||
|
||||
Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Away Digest, Session
|
||||
Manager, Attachments, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor,
|
||||
Manager, Attachments, Edit history, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor,
|
||||
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
|
||||
Ultracode Windows, Cron.
|
||||
|
||||
@@ -106,6 +106,12 @@ nothing waits for a login, and that is a default, never an override: your own `G
|
||||
repository's `core.sshCommand`, is used when either is set. ⚠️ The default's `StrictHostKeyChecking=accept-new`
|
||||
records an unknown host key in `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write.
|
||||
|
||||
**Edit history** (per device, off by default, never on phones) adds a header button for the
|
||||
active session's edit history: every edit the agent and its subagents made, with its diff, and
|
||||
images it produced. Local Claude sessions only. Nothing is read until you open the window, and it
|
||||
stores those diffs (secrets included) under `~/.codeman/edit-history/`; see
|
||||
[Working With Files](Working-With-Files#edit-history).
|
||||
|
||||
Most default to off. The stock desktop header is system stats, File Viewer, Tiles, and the gear.
|
||||
**Header Stats Style** picks how the system stats and plan usage are drawn: *Compact*
|
||||
(default; two pills with a ring beside every value), *Tiles* (label over value with a bar underneath) or *As before* (the bars and the `5H · 7D` chip). Desktop only, per device.
|
||||
@@ -213,8 +219,9 @@ how long a desktop notification stays up before Codeman closes it (**Browser
|
||||
notification display time**, default 8; both per device, and your OS may close a
|
||||
desktop notification sooner), the idle
|
||||
threshold that decides when a quiet session counts as needing you, and the server-wide
|
||||
webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See
|
||||
[Notifications And Approvals](Notifications-And-Approvals).
|
||||
webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode), and the
|
||||
Discord group for the two-way bot (token, IDs, thread and ping options; see
|
||||
[Discord Bridge](Discord-Bridge)). See [Notifications And Approvals](Notifications-And-Approvals).
|
||||
|
||||
### Voice
|
||||
|
||||
|
||||
@@ -87,7 +87,10 @@ detected at once would fork ten multi-minute converter processes.
|
||||
|
||||
File paths in a session are links. That works in two places:
|
||||
|
||||
- **In the terminal**, on any absolute path an agent prints.
|
||||
- **In the terminal**, on any path an agent or a command prints. A relative path (`src/app.ts`,
|
||||
`../notes.md`) resolves against the directory the session's pane is in now, so after a `cd`
|
||||
in a shell it opens the file there; until the pane reports a directory, and always for remote
|
||||
SSH and Docker cases, it resolves against the session's working directory.
|
||||
- **In the response viewer**, where paths are usually written as prose or in backticks. They
|
||||
render as underlined monospace links. A Markdown link whose target is a path
|
||||
(`[report](/tmp/run/report.json)`, the way Codex cites files) opens the same way.
|
||||
@@ -168,6 +171,11 @@ choice is remembered per device. The picker inserts the path into your prompt
|
||||
**without** pressing Enter, so nothing is submitted by accident. Its sibling **⌫ All**
|
||||
key clears the unsent prompt, and never sends the agent's `/clear` command.
|
||||
|
||||
In **Link Existing**'s picker, **+📁** (New folder) makes a folder inside the folder you
|
||||
are viewing and selects it. Names use letters, numbers, spaces and `_ - . ~` (not starting
|
||||
with a dot or `~`). The cases folder is refused, since every folder there is already a
|
||||
case: use **Add Case → New Case** instead.
|
||||
|
||||
This is a separate file-serving surface from the viewer, with its own rules: it allowlists
|
||||
your home directory, the cases directory, and anything in `CODEMAN_FILE_PICKER_ROOTS`, and
|
||||
blocks sensitive trees. In multi-user mode a non-admin gets only their own user space as a
|
||||
@@ -272,6 +280,60 @@ data comes from `GET /api/sessions/:id/git-status`, `GET /api/sessions/:id/git-d
|
||||
click or the interval you turned on, `POST /api/sessions/:id/git-fetch`
|
||||
(see the [API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)).
|
||||
|
||||
## Edit history
|
||||
|
||||
The Git panel shows what is different now. The edit history shows what the agent did, one edit
|
||||
at a time, including edits that were later undone or committed. Turn on **App Settings → Header &
|
||||
Panels → Header → Edit history** (per device, off by default) and a clock button appears in the
|
||||
header next to Attachments. It is not shown on phones.
|
||||
|
||||
Click it for a draggable window listing the active session's edits, newest first:
|
||||
|
||||
- One row per Edit or Write, with `new` for a file the agent created, `edit` otherwise,
|
||||
and the lines added and removed. **Click a row** to see that edit's diff inline, coloured like the
|
||||
Git panel; a diff over 64 KB is cut short. **Click the path** to open the file in the preview.
|
||||
- Edits made by subagents carry a chip with the subagent's task; hover it for the agent type and
|
||||
its worktree branch. **Main agent only** hides them. **Group by file** puts the edits under
|
||||
their file, with the totals on the file's row.
|
||||
- Images an agent produced with a shell command (a render, a plot, a screenshot) appear as rows with
|
||||
a thumbnail: a `.png`, `.jpg`, `.gif`, `.webp`, `.avif` or `.svg` path named in the command or
|
||||
its output counts when the file's modification time falls within that command's run. Images the
|
||||
agent sent you count as well. Images outside the workspace and SVGs show no thumbnail. Click one
|
||||
to open it in the preview.
|
||||
- **Load older** reads further back in the conversation, in the main agent's transcript and the
|
||||
subagents' alike (up to three transcripts per click; with more, they take turns).
|
||||
- After `/clear` or a resume, the edits of the earlier conversation stay listed, and the window
|
||||
first catches up on the ones it had not read yet. **Load older** then goes back within the new
|
||||
conversation.
|
||||
|
||||
It works for local Claude sessions only. Remote (SSH), Docker and other CLIs' sessions say so instead.
|
||||
|
||||
How it reads:
|
||||
|
||||
- **Only while you look.** The history is read from the session's Claude transcript when you open
|
||||
the window, and again after each tool call that can change files and at the end of each turn,
|
||||
while it stays open on that session. A closed window reads nothing, and Codeman does no work in
|
||||
the background for sessions nobody looks at.
|
||||
- **The transcript is found through the session's hooks.** Each prompt tells Codeman where the
|
||||
session's transcript is. Right after a server restart, before the next prompt, Codeman looks in
|
||||
the session's `CLAUDE_CONFIG_DIR` if you set one for it in Codeman, else in the default config
|
||||
directory. A `CLAUDE_CONFIG_DIR` set outside Codeman (in a shell profile or a wrapper script)
|
||||
is only known after the next prompt; until then the window says "No transcript found for this
|
||||
session yet".
|
||||
- **The history is stored, and it holds secrets.** Every diff an agent wrote is copied to
|
||||
`~/.codeman/edit-history/<session-id>.json`, including API keys or `.env` contents it edited. The file is
|
||||
readable by your user only, capped at 500 edits and 4 MiB of diff text per session (the oldest
|
||||
are dropped), never part of search, blocked from every file route, and deleted when you delete
|
||||
the session (a file left behind, by a crash for instance, goes at the next start). A pinned session you kill keeps its record and its history until you unpin and
|
||||
delete it.
|
||||
- **Images go through the existing file routes.** A thumbnail inside the workspace uses the File
|
||||
Viewer's route. An image outside it opens like any clicked path outside the workspace (no
|
||||
notification, same file-type allowlist and sensitive-file blocklist). No new route serves a file.
|
||||
|
||||
The data comes from `GET /api/sessions/:id/edits`, `GET /api/sessions/:id/edits/:entryId` and
|
||||
`POST /api/sessions/:id/edits/older` (see the
|
||||
[API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **The viewer follows the active session's workspace.** Switching tabs changes what you are
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
- [Unattended Runs](Keeping-Agents-Running)
|
||||
- [Notifications & Approvals](Notifications-And-Approvals)
|
||||
- [Discord Bridge](Discord-Bridge)
|
||||
- [Cron Jobs](Cron-Jobs)
|
||||
- [Autonomous Loops](Autonomous-Loops)
|
||||
- [Watching Agents Work](Watching-Agents-Work)
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.47.0",
|
||||
"version": "1.48.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.47.0",
|
||||
"version": "1.48.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.47.0",
|
||||
"version": "1.48.0",
|
||||
"description": "Self-hosted mission control for AI coding agents: run Claude Code, Codex, OpenCode, Gemini, DeepSeek, Grok and more 24/7 in tmux, from any device.",
|
||||
"mcpName": "io.github.Ark0N/codeman",
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
dist/
|
||||
Generated
+3134
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,87 @@
|
||||
[package]
|
||||
name = "codeman-desktop"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.90"
|
||||
description = "Codeman Desktop: a small native window for a Codeman server"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "codeman-desktop"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# Window + event loop (tao), the system web view (wry: WKWebView on macOS,
|
||||
# WebView2 on Windows, WebKitGTK on Linux) and native menus (muda). Tauri is
|
||||
# built on these three; using them directly keeps the app small and gives us
|
||||
# `load_url_with_headers`, which Tauri's navigate() does not have (see README).
|
||||
tao = "0.37"
|
||||
wry = { version = "0.57", features = ["devtools"] }
|
||||
muda = "0.21"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
url = "2"
|
||||
base64 = "0.22"
|
||||
dirs = "6"
|
||||
# The reachability probe. platform-verifier = the OS trust store, the same one
|
||||
# WKWebView uses, so the probe and the window agree on which certificates pass.
|
||||
ureq = { version = "3", features = ["platform-verifier"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
security-framework = "3"
|
||||
# JavaScript dialogs, HTTP Basic challenges, clearing cookies and the window's
|
||||
# content size (src/macos.rs). The versions wry already pulls in, so no new
|
||||
# crate is added.
|
||||
objc2 = { version = "0.6.5", default-features = false, features = ["std"] }
|
||||
block2 = { version = "0.6.2", default-features = false, features = ["std"] }
|
||||
objc2-foundation = { version = "0.3.2", default-features = false, features = [
|
||||
"std",
|
||||
"objc2-core-foundation",
|
||||
"block2",
|
||||
"NSDate",
|
||||
"NSGeometry",
|
||||
"NSObject",
|
||||
"NSOperation",
|
||||
"NSRange",
|
||||
"NSSet",
|
||||
"NSString",
|
||||
"NSURL",
|
||||
"NSURLAuthenticationChallenge",
|
||||
"NSURLCredential",
|
||||
"NSURLProtectionSpace",
|
||||
"NSURLRequest",
|
||||
"NSURLResponse",
|
||||
"NSURLSession",
|
||||
] }
|
||||
objc2-app-kit = { version = "0.3.2", default-features = false, features = [
|
||||
"std",
|
||||
"objc2-core-foundation",
|
||||
"NSAlert",
|
||||
"NSApplication",
|
||||
"NSButton",
|
||||
"NSControl",
|
||||
"NSResponder",
|
||||
"NSTextField",
|
||||
"NSView",
|
||||
"NSWindow",
|
||||
] }
|
||||
objc2-web-kit = { version = "0.3.2", default-features = false, features = [
|
||||
"std",
|
||||
"block2",
|
||||
"objc2-app-kit",
|
||||
"WKFrameInfo",
|
||||
"WKNavigationDelegate",
|
||||
"WKSecurityOrigin",
|
||||
"WKUIDelegate",
|
||||
"WKWebView",
|
||||
"WKWebsiteDataRecord",
|
||||
"WKWebsiteDataStore",
|
||||
] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,272 @@
|
||||
# Codeman Desktop
|
||||
|
||||
A small native app that opens Codeman in its own window. Double-click it, and
|
||||
it runs Codeman on this Mac (installing it first if it is not there) or
|
||||
connects to Codeman on another machine (installing it there over SSH if
|
||||
needed), and Codeman fills the window. `⌃⌘F` puts it in full screen.
|
||||
|
||||
macOS first: the app runs on 11.3 or later, and Codeman's own page needs the
|
||||
WebKit of Safari 16.4 (see Known limits). The same code is meant to build for
|
||||
Windows and Linux later.
|
||||
|
||||
- **Size:** 4.6 MB for the universal `Codeman.app` (Apple Silicon + Intel), 3 MB as a `.dmg` (measured on the first build).
|
||||
- **Stack:** Rust with [tao](https://crates.io/crates/tao) (window and event loop),
|
||||
[wry](https://crates.io/crates/wry) (the system web view: WKWebView on macOS) and
|
||||
[muda](https://crates.io/crates/muda) (native menus), plus [objc2](https://crates.io/crates/objc2)
|
||||
for the two things wry leaves out on macOS (JavaScript dialogs, HTTP Basic login).
|
||||
No bundled browser and no Node in the app.
|
||||
- **Server:** unchanged. The app is a client of Codeman's web UI, like a browser tab.
|
||||
|
||||
## Why it is cheap
|
||||
|
||||
Codeman is already a web app, so the desktop app is a native window around the
|
||||
operating system's own web view. An Electron build of the same thing would ship
|
||||
a whole Chromium (around 150 MB). The native side only adds what a browser tab
|
||||
cannot do.
|
||||
|
||||
## What it does (v0.1)
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **This Mac** | The connect screen's first tab looks for Codeman on this Mac and offers what fits: *Open Codeman* when it runs, *Start Codeman* when it is installed but stopped, *Install Codeman on this Mac* when it is not there. The address comes from Codeman's LaunchAgent (port, sub-path, HTTPS). When nothing answers there but one of your own Codeman servers runs (found with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, say), the tab opens that one, or says where it listens; it never offers to start a second server, which would take over the first one's sessions, and Start refuses while one runs. Start asks launchd to run the LaunchAgent (`kickstart`, or `bootstrap` when it is not loaded), so the server keeps its own settings and supervision; without a LaunchAgent it runs `codeman web -d`. The login comes from where Codeman keeps it: the LaunchAgent's `CODEMAN_USERNAME`/`CODEMAN_PASSWORD`, else `~/.codeman/.env` (what Codeman's command-line clients read), `admin` by default. It goes only to `127.0.0.1` on that Codeman's port, only while one of your own Codeman processes holds that port, is never saved to the Keychain, and never reaches the page. Whether Codeman is installed is looked up in its files and processes, then the login shell; the interactive profile (nvm in `.zshrc`, say) only on *Check again*. Install opens Terminal with Codeman's installer (`install.sh --local --service --port N`): it may ask for the Mac password (Homebrew, Node.js, tmux) and which AI coding tools to set up, then Codeman runs as a LaunchAgent, so it keeps running when the app quits and starts at login. *Also reach it from my phone and other computers* drops `--local`, and the installer asks how (Tailscale, or the local network with a password). Port 3000 is used unless something else answers there, then the next free one. While the installer runs, the app looks every 3 seconds and opens Codeman as soon as it answers; it stops looking after the first answer, after 30 minutes, or 90 seconds after a finished install whose server never answered (then it offers Start). An install still running in Terminal is recognized by its process, so the tab waits for it instead of offering Install twice. |
|
||||
| **Another machine** | The second tab connects to a server by address (below). *Codeman is not installed there yet?* opens Terminal, connects with `ssh -t` and runs Codeman's installer on that machine, which asks its questions there; the app then suggests `http://<host>:3000` and the installer prints the real address at the end. The SSH login is checked to the characters of a user and host name (no option can be slipped in) and may carry a `:port`. The remote command runs under `bash -o pipefail`, so a failed download fails the install instead of reporting success. The same tab shows the one-line install command, with a Copy button, to run on a machine by hand. |
|
||||
| **Connect screen** | Server address, recent servers, a login for password-protected servers (password kept in the macOS Keychain), auto-connect at launch. It explains each failure: nothing listening; wrong password; too many failed logins (the password field stays open, since a single-user Codeman accepts the right password even then); unknown host name; an address this Mac cannot reach (with a pointer to the Local Network setting); an `http://` address on a port that only speaks HTTPS; an untrusted certificate; a redirect, with a button for the suggested `https://` address; not a Codeman server. A multi-user account that must change its password connects, and Codeman's own dialog asks for the new one. If the server page has not started showing within 15 seconds, or you press Cancel while it opens, the connect screen comes back with a notice. On a Mac whose WebKit is too old for Codeman it says so and Connect stays off (see Known limits). |
|
||||
| **Start Codeman** | On the This Mac tab, and when `http://127.0.0.1:<port>` or `http://localhost:<port>` answers nothing, one click runs `codeman web -d` (or `~/.codeman/app`) with your login shell's environment, so the server gets your real PATH (Homebrew, nvm) for the agents it spawns. zsh, bash and other POSIX shells run the start themselves; fish, nu, xonsh, tcsh and other shells only report their environment, and the start runs under `/bin/sh` with it. Offered for those two addresses only: `codeman web -d` listens on 127.0.0.1. |
|
||||
| **Login** | The app answers the server's HTTP Basic login requests itself, with the login you connected with, so the page keeps working after the server restarts (a restart forgets every session cookie). It answers only for Codeman's own pages under the server's base path: a web tab or a rendered HTML file that asks for a Basic login of its own is left to WebKit and never gets your Codeman password. WebKit keeps no copy of the login, so every request for it gets the current one. Each connect first clears the app's cookies, so connecting to the same server as another user really switches users (local storage, where Codeman keeps its per-device settings, stays; a dashboard in a web tab that keeps its own login in a cookie may ask for it again). A page that still gets a 401 reloads once. If the server refuses the login (the password was changed, say), the app does not keep sending it: it checks it with a single probe, and only if the server refuses that too does the connect screen come back with a notice. That does not take focus from the app you are in: if Codeman is in the background, its Dock icon bounces. |
|
||||
| **Dialogs** | Codeman's `confirm()`, `prompt()` and `alert()` (Delete case, Kill all sessions, Update Codeman, discarding unsaved edits, ...) show as native macOS dialogs. wry implements none of them, and without them WKWebView answers Cancel without showing anything. |
|
||||
| **Full screen** | `⌃⌘F` or the green button. The last state is restored at launch; *Always Open in Full Screen* forces it. Leaving full screen returns to the saved window size and position. |
|
||||
| **Menus** | A real Edit menu (without one, `⌘C`/`⌘V` do nothing in a WKWebView), Reload `⌘R`, Switch Server `⇧⌘O`, Developer Tools `⌥⌘I`. The page sees every key first, so Codeman's own shortcuts keep working. If the main window ends up showing a raw file instead of Codeman, `⌘R` takes it back to Codeman. |
|
||||
| **Pop-outs** | Implements Codeman's existing native-wrapper bridge, `window.CodemanHost` (`openInHostWindow` in `src/web/public/app.js`), so popping out a session or a file preview opens a real app window, and the pop-out icon is on by default. Session and rendered-HTML pop-outs come only from Codeman's own pop-out button. A `window.open` from page script opens only Codeman's file and download windows, without taking keyboard focus, at most 3 at once and then 1 per second. Only Codeman's own pop-out pages become app windows (see Decisions); when the app refuses one, Codeman shows a toast. A web tab's *Open in new tab* opens the dashboard in your default browser, as it would from a browser tab. |
|
||||
| **Links** | On Codeman's own pages (the dashboard and session windows), a link to another site opens in your default browser, also on `⌘`-click or a middle click. Pages can send at most 3 links to the browser at once, then 1 per second. Links to other sites inside rendered HTML previews and web tabs do not open (see Known limits). |
|
||||
| **Downloads** | Codeman's Download links, and downloads Codeman opens in a new window, save the file to `~/Downloads` whatever its type, and a toast in Codeman names the file. Files the web view cannot display are saved the same way. |
|
||||
| **Window** | Size, position and full-screen state are remembered. A saved window that hangs off the screen it opens on (it was last on a larger display that is gone, say) is pulled back on and shrunk to fit; one whose title bar is on no screen at all opens at the default size instead. Clicking the Dock icon brings back a minimized window. |
|
||||
|
||||
## Build (on a Mac)
|
||||
|
||||
Needs Rust 1.90 or newer ([rustup](https://rustup.rs); `rustup update` for an older
|
||||
toolchain) and the Xcode Command Line Tools (`xcode-select --install`).
|
||||
|
||||
```bash
|
||||
cd packages/desktop
|
||||
cargo test # unit tests
|
||||
./scripts/bundle-macos.sh # dist/Codeman.app for this Mac's architecture
|
||||
./scripts/bundle-macos.sh --universal --dmg # Apple Silicon + Intel, plus dist/Codeman-<version>.dmg
|
||||
open dist/Codeman.app
|
||||
```
|
||||
|
||||
The script builds with `--locked` against the committed `Cargo.lock`, targets
|
||||
the macOS version in `macos/Info.plist` (`LSMinimumSystemVersion`, 11.3), and
|
||||
takes the binary from where cargo says it wrote it, so `CARGO_TARGET_DIR` and a
|
||||
cargo config's `build.target-dir` work.
|
||||
|
||||
Settings live in `~/Library/Application Support/Codeman/desktop.json`; passwords
|
||||
in the Keychain under *Codeman Desktop*, one item per username and server. A
|
||||
`desktop.json` the app cannot read or parse is moved aside, never overwritten:
|
||||
to `desktop.json.corrupt`, or to the first free name of `desktop.json.corrupt.1`
|
||||
to `.corrupt.9` when that one is taken, so an earlier copy is kept. If all ten
|
||||
names are taken, or the file cannot be moved, the app starts with default
|
||||
settings and saves nothing, which leaves the file where it is. When a server's
|
||||
username changes (a change of letter case counts), or a server drops off the
|
||||
recent list (it keeps 8), the Keychain item of the old username is deleted.
|
||||
|
||||
## Decisions, and why
|
||||
|
||||
**tao + wry + muda directly, not Tauri.** Tauri is built on the same three crates.
|
||||
The deciding detail is login: Codeman's password protection is HTTP Basic, and
|
||||
a WKWebView shows no login dialog of its own (wry 0.57 implements no
|
||||
authentication-challenge handler). So the app sends the `Authorization` header
|
||||
itself on the first load, with wry's `load_url_with_headers` (Tauri 2.12's
|
||||
`navigate()` takes only a URL), and from then on answers the HTTP Basic
|
||||
challenges of Codeman's own pages with the current login. Codeman keeps its
|
||||
session cookies in memory, so after a server restart that answer is what keeps
|
||||
the page logged in. Using the crates directly is also smaller.
|
||||
|
||||
**What wry leaves out is added at runtime, not patched in.** `src/macos.rs` adds
|
||||
the JavaScript dialog methods and the authentication-challenge method to the
|
||||
classes of wry's own delegates, so wry stays an unmodified crates.io dependency.
|
||||
|
||||
**What a page may ask of the app** (all in `src/policy.rs`, unit-tested):
|
||||
|
||||
- Codeman shows content it did not write on its own origin: rendered HTML files
|
||||
(`/html-view/`) and proxied dashboards (`/webview/`). Only Codeman's own app
|
||||
shell (the dashboard and a session pop-out) may use `window.CodemanHost`.
|
||||
That is an allowlist on the raw path, and a path holding any `%` fails it, so
|
||||
an encoded spelling or a route Codeman adds later stays out.
|
||||
- An app window opens only for Codeman's own pop-out pages under the server's
|
||||
base path, checked after decoding the path once, and which ones depends on
|
||||
who asks. `window.CodemanHost` (the app shell only) may open sessions,
|
||||
rendered HTML views, the raw, preview, attachment and folder file routes, and
|
||||
`api/download`. A `window.open` or `target=_blank` link, which WebKit passes
|
||||
on from any frame without saying which, gets only the file routes and
|
||||
`api/download`, the pages Codeman itself opens that way; otherwise a rendered
|
||||
HTML file could open copies of itself as top-level windows that pass for the
|
||||
app's own. Such a window does not take keyboard focus, and these windows
|
||||
have a budget of their own (3 at once, then 1 per second), apart from the
|
||||
one links draw on. Any other page on the server's origin is refused rather
|
||||
than sent to the browser: a top-level `/webview/` load would escape the web
|
||||
tab's iframe sandbox in either place, so it never opens anywhere.
|
||||
`window.open('')` is refused too, since WebKit's blank window carries none of
|
||||
the app's handlers.
|
||||
- wry's navigation handler sees every frame and does not say which one. So a
|
||||
navigation to another site is blocked rather than opened in the browser;
|
||||
otherwise a page full of external iframes could open browser tabs. A real
|
||||
link click is sent out by the injected script in the main frame only.
|
||||
- Only `http(s)` URLs ever reach the default browser, at most 3 at once and
|
||||
then 1 per second. macOS lets a page call `window.open` without a click, so
|
||||
this keeps a script from opening a browser tab for every call.
|
||||
- The `Authorization` header is only sent to URLs under the server's base path.
|
||||
The login answers a Basic challenge only when it is Codeman's own: the
|
||||
server's scheme, host and port (never a proxy's), a URL below the base path,
|
||||
and not a web tab (`webview/`) or a rendered HTML file (`html-view/`), which
|
||||
relay 401s from content Codeman did not write. Only such a challenge can
|
||||
send the user back to the connect screen. WebKit is handed the login without
|
||||
persistence, so it keeps no copy: every challenge gets the current login,
|
||||
and one from before a Switch Server is never replayed.
|
||||
- Each connect starts by clearing the app's cookies (never local storage).
|
||||
Codeman checks its session cookie before the `Authorization` header, so an
|
||||
earlier user's cookie would otherwise win over the new login.
|
||||
- Microphone and camera requests prompt per page. wry's default without a
|
||||
handler is to grant them to every frame, sandboxed previews included.
|
||||
- The bundled connect page keeps working until a server page commits in the
|
||||
main window (it is still the live page while the server loads), and only
|
||||
the main window may talk to it.
|
||||
- `codeman web -d` gets the port and base path as separate arguments, never as
|
||||
shell text.
|
||||
|
||||
**The installer runs in Terminal, not inside the app.** `install.sh` asks for
|
||||
the administrator password on a terminal (Homebrew, its sudo keep-alive), the
|
||||
Xcode tools arrive as a system dialog, and ssh asks about host keys and
|
||||
passwords on a terminal too, so an install without one fails on the first
|
||||
fresh Mac. The app writes a short script to the per-user temporary folder
|
||||
(mode 0700, created fresh, deleted by the script as it starts) and opens it
|
||||
with `open -b com.apple.Terminal`, which needs no permission; scripting
|
||||
Terminal with osascript would ask for Automation access. The script prints
|
||||
what it runs. A This Mac install downloads the installer to a temporary file
|
||||
and runs it from there, so the installer's input is the terminal. Everything in
|
||||
the scripts is built and unit-tested in `src/setup.rs`; the page sends only
|
||||
which install it wants, whether other devices should reach it, and the SSH
|
||||
login, which is checked before it is quoted.
|
||||
|
||||
**This Mac's own login comes from where Codeman keeps it.** A Codeman
|
||||
installed with a password keeps it in its LaunchAgent (`install.sh`) or in
|
||||
`~/.codeman/.env` (what `codeman attach`, `codeman tui` and `codeman agent`
|
||||
read). The app reads the same places, so a password-protected Codeman on this
|
||||
Mac opens without typing the password. The username and password always come
|
||||
from the same place, since the server reads its username from its own
|
||||
environment. The login goes only to `127.0.0.1` on that Codeman's port, and
|
||||
only while `lsof` shows one of your own Codeman processes holding that port:
|
||||
with Codeman stopped, whatever else listens there never gets it.
|
||||
|
||||
**Never a second server on the same sessions.** Two Codeman servers on one
|
||||
data directory and tmux socket attach to each other's live sessions, and
|
||||
`codeman web -d` only notices a server it started itself or one at the same
|
||||
address. So the app looks for running Codeman servers of this user (`ps`)
|
||||
before it offers or runs a start, and opens the one it finds instead.
|
||||
|
||||
**Closing the main window quits the app.** Codeman's sessions keep running on
|
||||
the server, so nothing is lost, and reopening takes about a second.
|
||||
|
||||
**One reachability probe per user action, and one to check a refused login.**
|
||||
Codeman counts a request without valid credentials against its login rate
|
||||
limit (10 per IP, 15 minutes; a successful login resets the count), so the app
|
||||
never retries in a loop. Once the server refuses the login in a challenge, the
|
||||
app sends it only once more, in the single probe that checks the refusal. If
|
||||
that probe is refused or rate limited, the connect screen comes back; the
|
||||
app does not send the refused login again. Keeping no copy of the login in WebKit has a cost here: after a server
|
||||
restart, each request that arrives without a cookie gets a 401 before the
|
||||
login goes out, and those count until one succeeds. Many windows reconnecting
|
||||
at once can push a request past 10, and that request then fails with a 429
|
||||
instead of being answered. A refusal that lands while you are in another app
|
||||
does not bring Codeman forward: the connect screen loads where the window is,
|
||||
and the Dock icon bounces.
|
||||
|
||||
## Known limits
|
||||
|
||||
- **Self-signed HTTPS is refused.** A server started with `codeman web --https`
|
||||
uses a self-signed certificate, and WKWebView rejects it (wry has no trust
|
||||
override). Use `http://127.0.0.1:3000` for a server on this Mac. For another
|
||||
machine, serve Codeman through `tailscale serve`
|
||||
(`https://<machine>.<tailnet>.ts.net` has a real certificate) or a reverse
|
||||
proxy with a real certificate. Marking the certificate as trusted in
|
||||
Keychain Access can only help at `https://localhost` or `https://127.0.0.1`,
|
||||
the only names Codeman's certificate carries, and may not work even there.
|
||||
The connect screen says so when it happens.
|
||||
- **Keychain prompt after every rebuild of an ad-hoc build.** The Keychain
|
||||
knows the app that saved a password by its code signature, and an ad-hoc
|
||||
signature changes with every build and update. So each new build asks once
|
||||
for access to the saved passwords: choose *Always Allow*. All Keychain work
|
||||
runs on one background thread, so the prompt does not freeze the window
|
||||
while it waits for an answer. To keep one identity across builds, sign with
|
||||
`SIGN_IDENTITY` (a self-made code-signing certificate from Keychain Access
|
||||
is enough). Microphone and camera permissions are asked again for the same
|
||||
reason.
|
||||
- **Gatekeeper.** Without a Developer ID the app is signed ad hoc. A build you
|
||||
made yourself opens normally; a copy that arrived by download or AirDrop is
|
||||
quarantined. On macOS 15 and later, try to open it once, then click *Open
|
||||
Anyway* in System Settings > Privacy & Security (it asks for an administrator
|
||||
password). On macOS 14 and earlier, right-click it and choose *Open* once.
|
||||
Either way, `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the
|
||||
copy in Applications works too. Shipping it to other people needs a Developer
|
||||
ID signature and notarization:
|
||||
`SIGN_IDENTITY="Developer ID Application: ..." ./scripts/bundle-macos.sh --dmg`,
|
||||
then `xcrun notarytool submit` and `xcrun stapler staple`.
|
||||
- **Local Network permission (macOS 15 and later).** The first connection to an
|
||||
address on the local network (a LAN IP, a `.local` name) makes macOS ask
|
||||
whether Codeman may use the local network, and that first attempt can fail
|
||||
while the question is open: connect again after answering. If it was denied,
|
||||
turn Codeman on in System Settings > Privacy & Security > Local Network. A
|
||||
server started with *Start Codeman on this Mac* counts as part of the app
|
||||
here, so the same answer covers what it reaches on the local network (ssh to
|
||||
LAN hosts, Wake-on-LAN, web tabs pointing at LAN dashboards), also after the
|
||||
app quits.
|
||||
- **Subagent transcript windows do not open in the app.** Codeman writes that
|
||||
view into a blank `window.open('')` window, which the app refuses (see
|
||||
Decisions), and shows a toast instead. A Codeman without the null check in
|
||||
`viewSubagentTranscript` (`src/web/public/panels-ui.js`) reports *Failed to
|
||||
load transcript* there. Open the transcript from a browser tab.
|
||||
- **Dialogs are app-modal.** While a JavaScript dialog is open, every Codeman
|
||||
window waits for it. A page that calls `alert()` in a loop (a rendered HTML
|
||||
preview or a web tab can) leaves no way out but Force Quit (`⌥⌘⎋`).
|
||||
- **Codeman needs the WebKit of Safari 16.4.** The app itself runs on macOS
|
||||
11.3 or later (downloads go through WKDownload, which arrived in 11.3), but
|
||||
Codeman's terminal (xterm.js 6 uses class static blocks) needs the WebKit
|
||||
that came with Safari 16.4: macOS 13.3, or macOS 11 or 12 with Safari 16.4
|
||||
or later installed. On an older WebKit the connect screen says so and does
|
||||
not connect. Session pop-outs also need BroadcastChannel (Safari 15.4),
|
||||
which the same requirement covers.
|
||||
- **Links in rendered HTML and web tabs do not open.** A link to another site
|
||||
inside a rendered HTML preview or a web tab does nothing, in the preview
|
||||
overlay, and for a rendered HTML file in its pop-out window too. The app
|
||||
sends links out only from Codeman's own pages, and blocks navigation to
|
||||
other sites in every frame (see Decisions).
|
||||
- **No login dialog for a web tab's own HTTP Basic login.** The app answers
|
||||
only Codeman's own login, and WebKit shows no dialog for any other, so a
|
||||
dashboard in a web tab that asks for HTTP Basic shows its 401 page.
|
||||
- **Downloads from previews and web tabs are saved without asking.** Rendered
|
||||
HTML files and web tabs are allowed to start downloads, and the app saves
|
||||
every download to `~/Downloads` without a prompt. Asking first for those
|
||||
frames is not done yet.
|
||||
- **No desktop notifications yet.** WKWebView has no Web Notification API.
|
||||
Native notifications are the first item on the list below.
|
||||
- **No service worker.** Codeman works without it; it is only an offline cache.
|
||||
- **Installing from the app is macOS only so far**, and installing on another
|
||||
machine needs ssh access to it and `curl` or `wget` there. The app does not
|
||||
know when a remote install is done or which address it chose: the installer
|
||||
prints the address, and you connect to it under Another machine.
|
||||
- **A This Mac install always sets Codeman up as a LaunchAgent** that starts
|
||||
at login. To stop it: `launchctl unload ~/Library/LaunchAgents/com.codeman.web.plist`;
|
||||
to remove Codeman: `curl -fsSL https://getcodeman.com/install | bash -s -- uninstall`.
|
||||
- **Windows and Linux** need their menu hookup (`init_for_hwnd`, GTK), a
|
||||
credential store (Credential Manager, Secret Service) and installers. The
|
||||
rest of the code is platform-neutral.
|
||||
|
||||
## Ideas for next
|
||||
|
||||
- Native notifications and a Dock badge with the number of sessions waiting
|
||||
for you (tao has `set_badge_label`; Codeman already tracks which sessions need input).
|
||||
- A global hotkey that brings Codeman to the front from anywhere.
|
||||
- A menu bar icon showing session states.
|
||||
- Trusting a self-signed server certificate by its fingerprint.
|
||||
- Asking before a download that a rendered HTML file or a web tab starts.
|
||||
- A way out of a page that keeps opening dialogs (a "block further dialogs" option).
|
||||
- Windows and Linux builds, a CI job that builds the `.dmg`, notarization, auto-update.
|
||||
@@ -0,0 +1,897 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Codeman</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0a0a0a;
|
||||
--panel: #121316;
|
||||
--line: #23252b;
|
||||
--line-strong: #30333b;
|
||||
--text: #e8eaed;
|
||||
--muted: #8b9099;
|
||||
--accent: #60a5fa;
|
||||
--accent-2: #3b82f6;
|
||||
--bad: #f87171;
|
||||
--warn: #fbbf24;
|
||||
--ok: #4ade80;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
background: radial-gradient(1200px 600px at 50% -10%, #111a2b 0%, var(--bg) 60%) fixed, var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
-webkit-user-select: none; user-select: none;
|
||||
}
|
||||
main { width: min(440px, calc(100vw - 48px)); }
|
||||
header { text-align: center; margin-bottom: 26px; }
|
||||
header img { width: 76px; height: 76px; display: block; margin: 0 auto 12px; }
|
||||
h1 { font-size: 24px; font-weight: 650; letter-spacing: -0.01em; margin: 0; }
|
||||
header p { margin: 4px 0 0; color: var(--muted); }
|
||||
.card {
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
|
||||
padding: 20px; box-shadow: 0 20px 60px rgba(0,0,0,0.45);
|
||||
}
|
||||
label.field { display: block; font-size: 12px; color: var(--muted); margin: 0 0 6px; }
|
||||
input[type=text], input[type=password], input[type=url] {
|
||||
width: 100%; height: 38px; padding: 0 12px; border-radius: 9px;
|
||||
border: 1px solid var(--line-strong); background: #0c0d10; color: var(--text);
|
||||
font: inherit; outline: none; -webkit-user-select: text; user-select: text;
|
||||
}
|
||||
input:focus { border-color: var(--accent-2); box-shadow: 0 0 0 3px rgba(59,130,246,0.25); }
|
||||
.row { display: flex; gap: 10px; }
|
||||
.row > * { flex: 1; }
|
||||
.gap { height: 14px; }
|
||||
#recent { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
#recent:empty { display: none; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 2px; max-width: 100%;
|
||||
border: 1px solid var(--line-strong); border-radius: 999px; background: #0f1013;
|
||||
color: var(--text); font-size: 12px;
|
||||
}
|
||||
.chip button { all: unset; cursor: default; padding: 4px 4px 4px 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 300px; }
|
||||
.chip button.x { padding: 4px 9px 4px 4px; color: var(--muted); }
|
||||
.chip button.x:hover { color: var(--bad); }
|
||||
.chip:hover { border-color: var(--accent-2); }
|
||||
.chip button:disabled { opacity: 0.5; }
|
||||
#login[hidden] { display: none; }
|
||||
#login { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--line); }
|
||||
.check { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; margin-top: 10px; }
|
||||
.check input { accent-color: var(--accent-2); margin: 0; }
|
||||
.actions { display: flex; gap: 10px; margin-top: 18px; }
|
||||
button.primary, button.secondary {
|
||||
height: 38px; border-radius: 9px; font: inherit; font-weight: 600; padding: 0 16px; cursor: default;
|
||||
}
|
||||
button.primary { flex: 1; border: 0; color: white; background: linear-gradient(180deg, var(--accent) 0%, var(--accent-2) 100%); }
|
||||
button.primary:disabled { opacity: 0.55; }
|
||||
button.secondary { border: 1px solid var(--line-strong); background: #16181d; color: var(--text); }
|
||||
button.secondary:hover { border-color: var(--accent-2); }
|
||||
button.secondary:disabled { opacity: 0.55; }
|
||||
button[hidden] { display: none; }
|
||||
#status { min-height: 20px; margin-top: 14px; font-size: 13px; color: var(--muted); display: flex; gap: 8px; align-items: flex-start; }
|
||||
#status.bad { color: var(--bad); }
|
||||
#status.warn { color: var(--warn); }
|
||||
#status.ok { color: var(--ok); }
|
||||
#status .detail { display: block; color: var(--muted); font-size: 12px; margin-top: 4px; white-space: pre-wrap; -webkit-user-select: text; user-select: text; }
|
||||
#suggest { display: block; max-width: 100%; margin-top: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#suggest[hidden] { display: none; }
|
||||
#webkit { margin: 0 0 14px; padding: 10px 12px; border: 1px solid rgba(251,191,36,0.4); border-radius: 9px; background: rgba(251,191,36,0.08); color: var(--warn); font-size: 13px; -webkit-user-select: text; user-select: text; }
|
||||
#webkit[hidden] { display: none; }
|
||||
.spinner { width: 14px; height: 14px; flex: none; margin-top: 2px; border-radius: 50%; border: 2px solid rgba(96,165,250,0.25); border-top-color: var(--accent); animation: spin 0.8s linear infinite; }
|
||||
.spinner[hidden] { display: none; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 2.4s; } }
|
||||
.prefs { margin-top: 16px; }
|
||||
.tabs { display: flex; gap: 4px; padding: 4px; margin: 0 0 18px; background: #0c0d10; border: 1px solid var(--line); border-radius: 11px; }
|
||||
.tabs button { all: unset; flex: 1; text-align: center; height: 30px; line-height: 30px; border-radius: 8px; font-weight: 600; font-size: 13px; color: var(--muted); cursor: default; }
|
||||
.tabs button:hover { color: var(--text); }
|
||||
.tabs button[aria-selected="true"] { background: #1c1f26; color: var(--text); box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 1px 3px rgba(0,0,0,0.45); }
|
||||
.tabs button:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 1px; }
|
||||
[role=tabpanel][hidden] { display: none; }
|
||||
.local { display: flex; gap: 12px; align-items: flex-start; min-height: 42px; }
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; margin-top: 5px; flex: none; background: #4b5059; }
|
||||
.dot.ok { background: var(--ok); box-shadow: 0 0 0 4px rgba(74,222,128,0.14); }
|
||||
.dot.warn { background: var(--warn); }
|
||||
.dot.busy { background: var(--accent); animation: pulse 1.2s ease-in-out infinite; }
|
||||
@keyframes pulse { 50% { opacity: 0.35; } }
|
||||
@media (prefers-reduced-motion: reduce) { .dot.busy { animation: none; } }
|
||||
#local-title { font-weight: 600; }
|
||||
.sub { color: var(--muted); font-size: 12px; margin-top: 3px; -webkit-user-select: text; user-select: text; overflow-wrap: anywhere; }
|
||||
#local-network-row[hidden] { display: none; }
|
||||
#elsewhere { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--line); }
|
||||
#elsewhere summary { color: var(--muted); font-size: 13px; cursor: default; list-style: none; }
|
||||
#elsewhere summary::-webkit-details-marker { display: none; }
|
||||
#elsewhere summary::before { content: '\25B8'; display: inline-block; width: 14px; transition: transform 0.15s; }
|
||||
#elsewhere[open] summary::before { transform: rotate(90deg); }
|
||||
#elsewhere summary:hover { color: var(--text); }
|
||||
#elsewhere .field { margin-top: 12px; }
|
||||
.inline { display: flex; gap: 8px; align-items: center; }
|
||||
.inline > input { flex: 1; min-width: 0; }
|
||||
.inline > button { flex: none; }
|
||||
.oneliner { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
|
||||
.oneliner code { flex: 1; min-width: 0; font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; background: #0c0d10; border: 1px solid var(--line-strong); border-radius: 8px; padding: 9px 10px; color: #c9d1d9; -webkit-user-select: all; user-select: all; overflow-x: auto; white-space: nowrap; }
|
||||
.statusrow { display: flex; gap: 10px; align-items: flex-start; }
|
||||
.statusrow #status { flex: 1; }
|
||||
.statusrow #cancel { margin-top: 10px; }
|
||||
footer { margin-top: 18px; text-align: center; color: #5d626b; font-size: 12px; }
|
||||
footer a { color: #7c8491; text-decoration: none; cursor: default; }
|
||||
footer a:hover { color: var(--accent); }
|
||||
kbd { font: 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; border: 1px solid var(--line-strong); border-bottom-width: 2px; border-radius: 4px; padding: 2px 4px; color: #9aa1ab; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<img src="icon.png" alt="">
|
||||
<h1>Codeman</h1>
|
||||
<p>Run Codeman on this Mac, or connect to another machine</p>
|
||||
</header>
|
||||
|
||||
<div class="card">
|
||||
<div class="tabs" role="tablist" aria-label="Where Codeman runs">
|
||||
<button type="button" role="tab" id="tab-local" aria-controls="pane-local" aria-selected="true">This Mac</button>
|
||||
<button type="button" role="tab" id="tab-remote" aria-controls="form" aria-selected="false" tabindex="-1">Another machine</button>
|
||||
</div>
|
||||
|
||||
<p id="webkit" role="alert" hidden>Codeman needs a newer WebKit than this Mac has: macOS 13.3 or later, or Safari 16.4 or later on macOS 11 and 12.</p>
|
||||
|
||||
<section id="pane-local" role="tabpanel" aria-labelledby="tab-local">
|
||||
<div class="local">
|
||||
<span class="dot busy" id="local-dot"></span>
|
||||
<div>
|
||||
<div id="local-title">Looking for Codeman on this Mac…</div>
|
||||
<div class="sub" id="local-detail"></div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="check" id="local-network-row" hidden><input id="local-network" type="checkbox"> Also reach it from my phone and other computers (the installer asks how)</label>
|
||||
<div class="actions">
|
||||
<button class="primary" id="local-open" type="button" hidden>Open Codeman</button>
|
||||
<button class="primary" id="local-start" type="button" hidden>Start Codeman</button>
|
||||
<button class="primary" id="local-install" type="button" hidden>Install Codeman on this Mac</button>
|
||||
<button class="secondary" id="local-stop" type="button" hidden>Stop waiting</button>
|
||||
<button class="secondary" id="local-check" type="button" hidden>Check again</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form id="form" role="tabpanel" aria-labelledby="tab-remote" autocomplete="off" novalidate hidden>
|
||||
<label class="field" for="url">Server address</label>
|
||||
<input id="url" type="text" spellcheck="false" autocapitalize="off"
|
||||
placeholder="http://127.0.0.1:3000 or https://your-box.ts.net" list="recent-list">
|
||||
<datalist id="recent-list"></datalist>
|
||||
<div id="recent" aria-label="Recent servers"></div>
|
||||
|
||||
<div id="login" hidden>
|
||||
<div class="row">
|
||||
<div>
|
||||
<label class="field" for="username">Username</label>
|
||||
<input id="username" type="text" spellcheck="false" autocapitalize="off" placeholder="admin">
|
||||
</div>
|
||||
<div>
|
||||
<label class="field" for="password">Password</label>
|
||||
<input id="password" type="password" placeholder="">
|
||||
</div>
|
||||
</div>
|
||||
<label class="check"><input id="remember" type="checkbox" checked> Remember the password in the Keychain</label>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary" id="connect" type="submit">Connect</button>
|
||||
<button class="secondary" id="start" type="button" hidden>Start Codeman on this Mac</button>
|
||||
</div>
|
||||
|
||||
<details id="elsewhere">
|
||||
<summary>Codeman is not installed there yet?</summary>
|
||||
<label class="field" for="ssh">Install it over SSH</label>
|
||||
<div class="inline">
|
||||
<input id="ssh" type="text" spellcheck="false" autocapitalize="off" placeholder="you@machine, or a name from ~/.ssh/config">
|
||||
<button class="secondary" id="ssh-install" type="button">Install</button>
|
||||
</div>
|
||||
<p class="sub">Terminal opens, connects with ssh and runs Codeman’s installer there. Or run this in a terminal on that machine:</p>
|
||||
<div class="oneliner"><code id="oneliner">curl -fsSL https://getcodeman.com/install | bash</code><button class="secondary" id="copy" type="button">Copy</button></div>
|
||||
</details>
|
||||
</form>
|
||||
|
||||
<div class="statusrow">
|
||||
<div id="status" role="status" aria-live="polite"><span class="spinner" id="spinner" hidden></span><span id="status-text"></span></div>
|
||||
<button class="secondary" id="cancel" type="button" hidden>Cancel</button>
|
||||
</div>
|
||||
<button class="secondary" id="suggest" type="button" hidden></button>
|
||||
|
||||
<div class="prefs">
|
||||
<label class="check"><input id="auto" type="checkbox"> Connect automatically when the app opens</label>
|
||||
<label class="check"><input id="fullscreen" type="checkbox"> Always open in full screen <kbd>⌃⌘F</kbd></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<span id="version">Codeman Desktop</span> ·
|
||||
<a href="#" id="docs">Help</a>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Codeman's terminal (xterm 6) uses class static blocks, which WebKit parses
|
||||
// from Safari 16.4 on. An older WebKit rejects this whole script, so the flag
|
||||
// stays unset. This is its own script because the page's CSP has no
|
||||
// 'unsafe-eval', so new Function cannot run the test.
|
||||
void class { static { window.__codemanStaticBlocks = true; } };
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
// Codeman would not load in this web view: say so, never connect, and keep
|
||||
// Connect disabled. The page still answers the app (ready, preferences).
|
||||
var tooOld = window.__codemanStaticBlocks !== true;
|
||||
var els = {
|
||||
form: $('form'), url: $('url'), list: $('recent-list'), recent: $('recent'),
|
||||
login: $('login'), username: $('username'), password: $('password'), remember: $('remember'),
|
||||
connect: $('connect'), cancel: $('cancel'), start: $('start'), suggest: $('suggest'),
|
||||
status: $('status'), spinner: $('spinner'), statusText: $('status-text'),
|
||||
auto: $('auto'), fullscreen: $('fullscreen'), version: $('version'), webkit: $('webkit'),
|
||||
tabLocal: $('tab-local'), tabRemote: $('tab-remote'), paneLocal: $('pane-local'),
|
||||
localDot: $('local-dot'), localTitle: $('local-title'), localDetail: $('local-detail'),
|
||||
localNetworkRow: $('local-network-row'), localNetwork: $('local-network'),
|
||||
localOpen: $('local-open'), localStart: $('local-start'), localInstall: $('local-install'),
|
||||
localStop: $('local-stop'), localCheck: $('local-check'),
|
||||
elsewhere: $('elsewhere'), ssh: $('ssh'), sshInstall: $('ssh-install'), copy: $('copy')
|
||||
};
|
||||
var manual = /[?&]manual=1/.test(location.search);
|
||||
// startUrl: the address the user asked to start Codeman for ('' when this
|
||||
// page did not ask, e.g. it was reloaded while a start was running).
|
||||
// suggestion: the address the suggest button connects to.
|
||||
// tab: 'local' (This Mac) or 'remote' (Another machine). local: the last
|
||||
// This Mac status from the app. wait: set while an install runs in
|
||||
// Terminal and this page polls for the server it brings up.
|
||||
var state = {
|
||||
servers: [], busy: false, lastUrl: '', startUrl: '', suggestion: '',
|
||||
tab: 'local', local: null, localChecking: false, opening: false, wait: null, waitTimer: 0,
|
||||
stoppedWaiting: false
|
||||
};
|
||||
// While an install runs: how often to look, how long a finished install may
|
||||
// take to answer before Start is offered, and when to stop looking.
|
||||
var WAIT_POLL_MS = 3000;
|
||||
var WAIT_START_GRACE_MS = 90 * 1000;
|
||||
var WAIT_MAX_MS = 30 * 60 * 1000;
|
||||
|
||||
function post(msg) { try { window.ipc.postMessage(JSON.stringify(msg)); } catch (e) { /* not in the app */ } }
|
||||
|
||||
function setStatus(kind, text, detail) {
|
||||
els.status.className = kind || '';
|
||||
els.spinner.hidden = kind !== 'busy';
|
||||
els.statusText.textContent = text || '';
|
||||
if (detail) {
|
||||
var d = document.createElement('span');
|
||||
d.className = 'detail';
|
||||
d.textContent = detail;
|
||||
els.statusText.appendChild(d);
|
||||
}
|
||||
}
|
||||
|
||||
// One request at a time: every way to start one (Connect, a recent server,
|
||||
// the suggest button) is disabled while busy, never hidden, so the second
|
||||
// click of a double click cannot land on whatever moved into its place.
|
||||
// Cancel only where the native side can really cancel: a probe or a page
|
||||
// load, never a server start.
|
||||
function setBusy(busy, cancellable) {
|
||||
state.busy = busy;
|
||||
els.connect.disabled = busy || tooOld;
|
||||
els.suggest.disabled = busy || tooOld;
|
||||
els.cancel.hidden = !(busy && cancellable !== false);
|
||||
if (busy) els.start.hidden = true;
|
||||
disableChips();
|
||||
syncLocalButtons();
|
||||
}
|
||||
|
||||
// Forgetting a server stays possible when the WebKit is too old; connecting
|
||||
// to one does not.
|
||||
function disableChips() {
|
||||
Array.prototype.forEach.call(els.recent.querySelectorAll('button'), function (b) {
|
||||
b.disabled = state.busy || (tooOld && !b.classList.contains('x'));
|
||||
});
|
||||
}
|
||||
|
||||
// Nothing to suggest when this WebKit cannot open Codeman at any address.
|
||||
function suggest(url) {
|
||||
if (tooOld) return;
|
||||
state.suggestion = url;
|
||||
els.suggest.textContent = 'Use ' + url;
|
||||
els.suggest.title = 'Connect to ' + url;
|
||||
els.suggest.hidden = false;
|
||||
}
|
||||
|
||||
function hideSuggestion() {
|
||||
state.suggestion = '';
|
||||
els.suggest.hidden = true;
|
||||
}
|
||||
|
||||
function host(url) {
|
||||
try { var u = new URL(url); return u.host + (u.pathname === '/' ? '' : u.pathname.replace(/\/$/, '')); } catch (e) { return url; }
|
||||
}
|
||||
|
||||
// The address as the app normalizes it (policy::normalize_server_url):
|
||||
// http:// by default, https:// for a .ts.net name, no query or fragment, a
|
||||
// trailing slash. Only used to tell whether two addresses are one server.
|
||||
function normalize(raw) {
|
||||
var s = String(raw || '').trim();
|
||||
if (!s) return '';
|
||||
if (s.indexOf('://') < 0) {
|
||||
var name = s.split(/[\/:]/)[0].toLowerCase();
|
||||
s = (/\.ts\.net$/.test(name) ? 'https' : 'http') + '://' + s;
|
||||
}
|
||||
try {
|
||||
var u = new URL(s);
|
||||
u.search = '';
|
||||
u.hash = '';
|
||||
if (!/\/$/.test(u.pathname)) u.pathname += '/';
|
||||
return u.href;
|
||||
} catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function sameServer(a, b) {
|
||||
var na = normalize(a);
|
||||
return na !== '' && na === normalize(b);
|
||||
}
|
||||
|
||||
function renderRecent() {
|
||||
els.recent.textContent = '';
|
||||
els.list.textContent = '';
|
||||
state.servers.forEach(function (s) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = s.url;
|
||||
els.list.appendChild(opt);
|
||||
|
||||
var chip = document.createElement('span');
|
||||
chip.className = 'chip';
|
||||
var pick = document.createElement('button');
|
||||
pick.type = 'button';
|
||||
pick.textContent = host(s.url) + (s.username ? ' (' + s.username + ')' : '');
|
||||
pick.title = 'Connect to ' + s.url;
|
||||
pick.addEventListener('click', function () {
|
||||
if (state.busy) return;
|
||||
choose(s);
|
||||
connect();
|
||||
});
|
||||
var x = document.createElement('button');
|
||||
x.type = 'button';
|
||||
x.className = 'x';
|
||||
x.textContent = '×';
|
||||
x.title = 'Forget this server' + (s.hasSavedPassword ? ' and its saved password' : '');
|
||||
x.addEventListener('click', function () { if (!state.busy) post({ cmd: 'forget', url: s.url }); });
|
||||
chip.appendChild(pick);
|
||||
chip.appendChild(x);
|
||||
els.recent.appendChild(chip);
|
||||
});
|
||||
disableChips();
|
||||
}
|
||||
|
||||
function choose(s) {
|
||||
els.url.value = s.url;
|
||||
els.username.value = s.username || '';
|
||||
els.password.value = '';
|
||||
els.password.placeholder = s.hasSavedPassword ? 'saved in Keychain' : '';
|
||||
els.login.hidden = !s.username;
|
||||
}
|
||||
|
||||
// The saved server the address field names, if any, with its login.
|
||||
function matchSaved() {
|
||||
var match = state.servers.filter(function (s) { return s.url === els.url.value.trim(); })[0];
|
||||
if (match) choose(match);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (state.busy || tooOld) return;
|
||||
var url = els.url.value.trim();
|
||||
if (!url) { setStatus('bad', 'Enter the address of your Codeman server.'); els.url.focus(); return; }
|
||||
state.lastUrl = url;
|
||||
setBusy(true);
|
||||
setStatus('busy', 'Connecting to ' + url + '…');
|
||||
var msg = { cmd: 'connect', url: url, remember: els.remember.checked };
|
||||
if (!els.login.hidden) {
|
||||
if (els.username.value.trim()) msg.username = els.username.value.trim();
|
||||
if (els.password.value) msg.password = els.password.value;
|
||||
}
|
||||
post(msg);
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
els.login.hidden = false;
|
||||
if (!els.username.value) els.username.value = 'admin';
|
||||
els.password.focus();
|
||||
}
|
||||
|
||||
function hostName(url) {
|
||||
try { return new URL(url).hostname.toLowerCase(); } catch (e) { return ''; }
|
||||
}
|
||||
|
||||
var LOCAL_NETWORK = 'On macOS 15 and later the app may also need Local Network access: System Settings > Privacy & Security > Local Network, then turn on Codeman.';
|
||||
|
||||
var explain = {
|
||||
needsLogin: ['warn', 'This server asks for a login.', 'Codeman’s default username is admin; the password is CODEMAN_PASSWORD on the server.'],
|
||||
badCredentials: ['bad', 'Wrong username or password.', 'After 10 failed attempts Codeman locks logins from this address for 15 minutes.'],
|
||||
rateLimited: ['bad', 'Too many failed logins from this address.', 'If you know the password, enter it and connect: a single-user Codeman accepts the right password even now. Otherwise, or on a multi-user server, wait up to 15 minutes and try again.'],
|
||||
hostNotAllowed: ['bad', 'Codeman refused this host name.', 'Codeman only answers names it knows. Add this one to CODEMAN_ALLOWED_HOSTS on the server, or use its tailnet name or 127.0.0.1.'],
|
||||
refused: ['bad', 'Nothing is answering at this address.', ''],
|
||||
timeout: ['bad', 'The server did not answer within 5 seconds.', 'Check that the machine is awake and reachable (Tailscale connected, same network).'],
|
||||
unreachable: ['bad', 'This Mac cannot reach that address.', 'Check that the machine is on and on the same network (or that Tailscale is connected). ' + LOCAL_NETWORK],
|
||||
unresolvable: ['bad', 'That host name could not be found.', 'Check the spelling, or that Tailscale is connected for a .ts.net name.'],
|
||||
maybeHttps: ['bad', 'The server closed the connection without answering.', 'The port may be serving HTTPS: a Codeman started with --https only answers https://. Try the https:// address, or your tailnet name (https://….ts.net). Codeman’s own self-signed certificate names only localhost and 127.0.0.1, so the app cannot accept it at this address: serve Codeman through tailscale serve (a real ts.net certificate) or a reverse proxy with a real certificate.'],
|
||||
tlsUntrusted: ['bad', 'The server’s HTTPS certificate is not trusted.', 'This is usually Codeman’s self-signed certificate (codeman web --https). It names only localhost and 127.0.0.1, so the app cannot accept it at this address. Serve Codeman through tailscale serve (a real https://….ts.net certificate) or a reverse proxy with a real certificate. For a server on this Mac, run Codeman without --https and use http://127.0.0.1:PORT.'],
|
||||
passwordChangeRequired: ['warn', 'Codeman asks for a new password for this account.', 'Open it in a browser to set one, then connect with the new password.']
|
||||
};
|
||||
|
||||
// The same two at localhost or 127.0.0.1, the only names Codeman's
|
||||
// self-signed certificate carries, so the only ones where trusting it in
|
||||
// Keychain Access can help.
|
||||
var explainLoopback = {
|
||||
maybeHttps: 'The port may be serving HTTPS: a Codeman started with --https only answers https://. Try the https:// address. The app’s web view refuses Codeman’s self-signed certificate, though: marking it as trusted in Keychain Access may work, or run Codeman without --https.',
|
||||
tlsUntrusted: 'This is usually Codeman’s self-signed certificate (codeman web --https), which the app’s web view refuses. Run Codeman without --https and use http://127.0.0.1:PORT instead. Marking the certificate as trusted in Keychain Access may also work.'
|
||||
};
|
||||
|
||||
function loopbackName(url) {
|
||||
var h = hostName(url);
|
||||
return h === 'localhost' || h === '127.0.0.1';
|
||||
}
|
||||
|
||||
function onProbeResult(msg) {
|
||||
setBusy(false);
|
||||
hideSuggestion();
|
||||
var o = msg.outcome || {};
|
||||
var e = explain[o.kind];
|
||||
if (o.kind === 'needsLogin' || o.kind === 'badCredentials' || o.kind === 'rateLimited') {
|
||||
// The login fields live on the Another machine tab.
|
||||
selectTab('remote');
|
||||
showLogin();
|
||||
} else if (state.tab === 'local') {
|
||||
// Opening the Codeman on this Mac failed: look at it again.
|
||||
checkLocal('login');
|
||||
}
|
||||
if (o.kind === 'notCodeman') {
|
||||
setStatus('bad', 'Something answered, but it is not a Codeman server (HTTP ' + o.status + ').', 'Check the port and, behind a proxy, the path Codeman is mounted under.');
|
||||
} else if (o.kind === 'redirect') {
|
||||
var where = o.location ? 'It points to ' + o.location + '.' : '';
|
||||
if (o.suggested) {
|
||||
setStatus('warn', 'This address redirects to another one.', where + '\nCodeman seems to answer at ' + o.suggested + '.');
|
||||
suggest(o.suggested);
|
||||
} else {
|
||||
setStatus('bad', 'This address redirects somewhere else.', where + '\nThe app connects only to the address Codeman itself answers on. Check the address and, behind a proxy, the path Codeman is mounted under.');
|
||||
}
|
||||
} else if (o.kind === 'failed') {
|
||||
setStatus('bad', 'Could not connect.', o.detail || '');
|
||||
} else if (e) {
|
||||
var detail = e[2];
|
||||
if (o.kind === 'refused' && msg.canStart) detail = 'Codeman is not running on this Mac. Start it here, or check the port.';
|
||||
if (o.kind === 'rateLimited' && o.sentCredentials) detail = 'The saved or typed password may be wrong. ' + detail;
|
||||
if (o.kind === 'unresolvable' && /\.local\.?$/.test(hostName(msg.url))) {
|
||||
detail = 'Check the spelling, and that the machine is on and on the same network. ' + LOCAL_NETWORK;
|
||||
}
|
||||
if (explainLoopback[o.kind] && loopbackName(msg.url)) detail = explainLoopback[o.kind];
|
||||
if (o.kind === 'tlsUntrusted' && o.detail) detail += '\n(' + o.detail + ')';
|
||||
setStatus(e[0], e[1], detail);
|
||||
if (o.kind === 'maybeHttps' && /^http:/i.test(msg.url || '')) suggest(String(msg.url).replace(/^http:/i, 'https:'));
|
||||
} else {
|
||||
setStatus('bad', 'Could not connect.');
|
||||
}
|
||||
els.start.hidden = !(o.kind === 'refused' && msg.canStart);
|
||||
}
|
||||
|
||||
function onStartResult(msg) {
|
||||
setBusy(false);
|
||||
// Connect only to the server this page asked to start; after a reload
|
||||
// mid-start, only to the one the address field shows.
|
||||
var asked = state.startUrl || els.url.value;
|
||||
state.startUrl = '';
|
||||
if (!msg.ok) { setStatus('bad', 'Codeman did not start.', msg.output || ''); return; }
|
||||
setStatus('ok', 'Codeman started.', msg.output || '');
|
||||
if (!msg.url || !sameServer(msg.url, asked)) return;
|
||||
if (!sameServer(msg.url, els.url.value)) { els.url.value = msg.url; matchSaved(); }
|
||||
connect();
|
||||
}
|
||||
|
||||
function isLocalUrl(url) {
|
||||
var h = hostName(normalize(url));
|
||||
return h === 'localhost' || /^127\./.test(h) || h === '[::1]';
|
||||
}
|
||||
|
||||
function selectTab(tab) {
|
||||
if (tab !== 'local' && tab !== 'remote') return;
|
||||
var changed = state.tab !== tab;
|
||||
state.tab = tab;
|
||||
var local = tab === 'local';
|
||||
els.tabLocal.setAttribute('aria-selected', String(local));
|
||||
els.tabRemote.setAttribute('aria-selected', String(!local));
|
||||
els.tabLocal.tabIndex = local ? 0 : -1;
|
||||
els.tabRemote.tabIndex = local ? -1 : 0;
|
||||
els.paneLocal.hidden = !local;
|
||||
els.form.hidden = local;
|
||||
if (changed && !state.busy) {
|
||||
setStatus('', '');
|
||||
hideSuggestion();
|
||||
}
|
||||
if (local && !state.local && !state.localChecking) checkLocal('login');
|
||||
}
|
||||
|
||||
// quick: skip the login shell (used while an install runs, when only
|
||||
// "does it answer yet" matters).
|
||||
// How hard to look: 'quick' (files and processes, while an install runs),
|
||||
// 'login' (also the login shell; every automatic look) or 'deep' (also the
|
||||
// interactive profile, only on Check again).
|
||||
function checkLocal(how) {
|
||||
if (how !== 'quick') {
|
||||
state.localChecking = true;
|
||||
renderLocal();
|
||||
}
|
||||
var msg = { cmd: 'localStatus', quick: how === 'quick' };
|
||||
if (how === 'deep') msg.deep = true;
|
||||
post(msg);
|
||||
}
|
||||
|
||||
function syncLocalButtons() {
|
||||
els.localOpen.disabled = state.busy || tooOld;
|
||||
els.localStart.disabled = state.busy || tooOld;
|
||||
els.localInstall.disabled = state.busy || tooOld || state.opening;
|
||||
els.localCheck.disabled = state.busy || state.localChecking;
|
||||
els.sshInstall.disabled = state.busy || state.opening;
|
||||
}
|
||||
|
||||
function renderLocal() {
|
||||
var s = state.local;
|
||||
var show = { open: false, start: false, install: false, stop: false, check: false, network: false };
|
||||
var dot = '';
|
||||
var title = '';
|
||||
var detail = '';
|
||||
if (state.wait) {
|
||||
dot = 'busy';
|
||||
title = 'Installing Codeman in Terminal…';
|
||||
detail = 'Answer the installer’s questions there. This window opens Codeman when it is done.';
|
||||
show.stop = true;
|
||||
} else if (state.localChecking || !s) {
|
||||
dot = 'busy';
|
||||
title = 'Looking for Codeman on this Mac…';
|
||||
} else if (s.installRunning && s.state !== 'running' && s.state !== 'needsLogin') {
|
||||
dot = 'busy';
|
||||
title = 'Codeman is being installed in Terminal.';
|
||||
detail = 'Finish the installer there, then Check again.';
|
||||
show.check = true;
|
||||
} else if (s.state === 'runningElsewhere') {
|
||||
dot = 'warn';
|
||||
title = 'A Codeman server is already running on this Mac.';
|
||||
detail = 'It listens at ' + (s.elsewhere || []).join(', ') + ', which this app cannot open as ' + s.url +
|
||||
'. Connect to its address under Another machine. Starting another server here would take over its sessions.';
|
||||
show.check = true;
|
||||
} else if (s.state === 'running') {
|
||||
dot = 'ok';
|
||||
title = 'Codeman is running on this Mac.';
|
||||
detail = s.url + (s.version ? ' · version ' + s.version : '');
|
||||
show.open = true;
|
||||
} else if (s.state === 'needsLogin') {
|
||||
dot = 'ok';
|
||||
title = 'Codeman is running on this Mac.';
|
||||
detail = s.url + ' asks for a login: Open asks for the password.';
|
||||
show.open = true;
|
||||
} else if (s.state === 'stopped') {
|
||||
title = 'Codeman is installed on this Mac, but not running.';
|
||||
if (s.daemon) {
|
||||
detail = 'A system LaunchDaemon runs it, which an administrator starts: sudo launchctl kickstart -k system/com.codeman.web';
|
||||
} else if (/^https:/i.test(s.url)) {
|
||||
detail = 'It is set up with HTTPS (codeman web --https), whose self-signed certificate this app refuses. Run it without --https to open it here.';
|
||||
} else {
|
||||
detail = s.service
|
||||
? 'It is set up to start when you log in, but nothing answers at ' + s.url + '. Start asks launchd to run it now.'
|
||||
: 'Start runs it in the background at ' + s.url + '.';
|
||||
show.start = true;
|
||||
}
|
||||
show.check = true;
|
||||
} else if (s.state === 'notInstalled') {
|
||||
title = 'Codeman is not installed on this Mac.';
|
||||
detail = 'Install runs Codeman’s installer in Terminal: a few minutes, and it may ask for your Mac password. ' +
|
||||
'Codeman then runs in the background and starts when you log in, so your sessions keep running when this app is closed.';
|
||||
show.install = true;
|
||||
show.network = true;
|
||||
show.check = true;
|
||||
} else if (!s.installed) {
|
||||
// Something else holds Codeman's port; a fresh install takes the next free one.
|
||||
var other = s.outcome || {};
|
||||
title = 'Codeman is not installed on this Mac.';
|
||||
detail = 'Something else answers at ' + s.url + (other.kind === 'notCodeman' ? ' (HTTP ' + other.status + ')' : '') +
|
||||
', so Install sets Codeman up on the next free port. It runs Codeman’s installer in Terminal: a few minutes, and it may ask for your Mac password.';
|
||||
show.install = true;
|
||||
show.network = true;
|
||||
show.check = true;
|
||||
} else {
|
||||
var o = s.outcome || {};
|
||||
dot = 'warn';
|
||||
if (o.kind === 'notCodeman') {
|
||||
title = 'Something else answers at ' + s.url;
|
||||
detail = 'It is not Codeman (HTTP ' + o.status + '). Stop what runs on that port, or connect to Codeman’s real address under Another machine.';
|
||||
} else {
|
||||
var e = explain[o.kind];
|
||||
title = e ? e[1] : 'Could not reach Codeman on this Mac.';
|
||||
detail = (explainLoopback[o.kind]) || (e ? e[2] : (o.detail || ''));
|
||||
}
|
||||
show.check = true;
|
||||
}
|
||||
els.localDot.className = 'dot' + (dot ? ' ' + dot : '');
|
||||
els.localTitle.textContent = title;
|
||||
els.localDetail.textContent = detail;
|
||||
els.localOpen.hidden = !show.open;
|
||||
els.localStart.hidden = !show.start;
|
||||
els.localInstall.hidden = !show.install;
|
||||
els.localStop.hidden = !show.stop;
|
||||
els.localCheck.hidden = !show.check;
|
||||
els.localNetworkRow.hidden = !show.network;
|
||||
syncLocalButtons();
|
||||
}
|
||||
|
||||
function onLocalStatus(s) {
|
||||
state.localChecking = false;
|
||||
state.local = s || null;
|
||||
// An install from this app is still running (this page was shown again
|
||||
// while it ran): wait for it, unless the user stopped waiting.
|
||||
if (!state.wait && s && s.installRunning && !state.stoppedWaiting && s.state !== 'running' && s.state !== 'needsLogin') {
|
||||
state.wait = { since: Date.now(), finishedAt: 0, port: s.installPort || 3000 };
|
||||
renderLocal();
|
||||
scheduleWait();
|
||||
}
|
||||
if (state.wait) {
|
||||
onWaitStatus(state.local || {});
|
||||
return;
|
||||
}
|
||||
renderLocal();
|
||||
}
|
||||
|
||||
// Point the Another machine form at `url` with only the login saved for it:
|
||||
// never the username or a typed password of the server the form showed
|
||||
// before, which connect() would otherwise send along.
|
||||
function useAddress(url) {
|
||||
els.url.value = url || '';
|
||||
els.login.hidden = true;
|
||||
els.username.value = '';
|
||||
els.password.value = '';
|
||||
els.password.placeholder = '';
|
||||
matchSaved();
|
||||
}
|
||||
|
||||
function openLocal() {
|
||||
if (state.busy || tooOld || !state.local) return;
|
||||
useAddress(state.local.url);
|
||||
connect();
|
||||
}
|
||||
|
||||
function startLocal() {
|
||||
if (state.busy || tooOld || !state.local) return;
|
||||
useAddress(state.local.url);
|
||||
state.startUrl = state.local.url;
|
||||
post({ cmd: 'startServer', url: state.startUrl });
|
||||
}
|
||||
|
||||
function installLocal() {
|
||||
if (state.busy || tooOld || state.opening) return;
|
||||
state.opening = true;
|
||||
syncLocalButtons();
|
||||
setStatus('busy', 'Opening Terminal…');
|
||||
post({ cmd: 'installLocal', network: els.localNetwork.checked });
|
||||
}
|
||||
|
||||
function scheduleWait() {
|
||||
clearTimeout(state.waitTimer);
|
||||
state.waitTimer = setTimeout(function () { if (state.wait) checkLocal('quick'); }, WAIT_POLL_MS);
|
||||
}
|
||||
|
||||
function endWait() {
|
||||
clearTimeout(state.waitTimer);
|
||||
state.wait = null;
|
||||
}
|
||||
|
||||
// One answer while an install runs. It stops at the first answer from a
|
||||
// server: a password-protected one counts every unauthenticated look as a
|
||||
// failed login.
|
||||
function portOf(url) {
|
||||
try {
|
||||
var u = new URL(url);
|
||||
return Number(u.port || (u.protocol === 'https:' ? 443 : 80));
|
||||
} catch (e) { return 0; }
|
||||
}
|
||||
|
||||
function onWaitStatus(s) {
|
||||
var w = state.wait;
|
||||
var now = Date.now();
|
||||
if (s.installPort) w.port = s.installPort;
|
||||
// Until the installer writes its LaunchAgent, the app looks at port 3000,
|
||||
// where something else may answer when the install took another port.
|
||||
var ours = portOf(s.url) === w.port;
|
||||
if (ours && (s.state === 'running' || s.state === 'needsLogin')) {
|
||||
endWait();
|
||||
selectTab('local');
|
||||
renderLocal();
|
||||
openLocal();
|
||||
return;
|
||||
}
|
||||
if (ours && s.state === 'other') {
|
||||
// Something answers that is not a working Codeman: say what.
|
||||
endWait();
|
||||
renderLocal();
|
||||
return;
|
||||
}
|
||||
if (s.installComplete && (s.state === 'stopped' || !ours)) {
|
||||
if (!w.finishedAt) {
|
||||
w.finishedAt = now;
|
||||
} else if (now - w.finishedAt > WAIT_START_GRACE_MS) {
|
||||
endWait();
|
||||
renderLocal();
|
||||
setStatus('warn', 'The install finished, but Codeman is not answering.', 'Start runs it now. If the installer reported a problem, its log is ~/.codeman/install.log.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (now - w.since > WAIT_MAX_MS) {
|
||||
endWait();
|
||||
renderLocal();
|
||||
setStatus('warn', 'Stopped waiting for the install.', 'Check again once the installer in Terminal is done.');
|
||||
return;
|
||||
}
|
||||
scheduleWait();
|
||||
}
|
||||
|
||||
function installRemote() {
|
||||
if (state.busy || state.opening) return;
|
||||
var target = els.ssh.value.trim();
|
||||
if (!target) {
|
||||
setStatus('bad', 'Enter the machine to install Codeman on, as user@host.');
|
||||
els.ssh.focus();
|
||||
return;
|
||||
}
|
||||
state.opening = true;
|
||||
syncLocalButtons();
|
||||
setStatus('busy', 'Opening Terminal…');
|
||||
post({ cmd: 'installRemote', target: target });
|
||||
}
|
||||
|
||||
window.__codemanDesktop = {
|
||||
onNative: function (msg) {
|
||||
switch (msg.type) {
|
||||
case 'init':
|
||||
state.servers = msg.servers || [];
|
||||
state.startUrl = '';
|
||||
setBusy(false);
|
||||
hideSuggestion();
|
||||
setStatus('', '');
|
||||
els.start.hidden = true;
|
||||
els.auto.checked = !!msg.autoConnect;
|
||||
els.fullscreen.checked = !!msg.startFullscreen;
|
||||
els.version.textContent = 'Codeman Desktop ' + (msg.version || '');
|
||||
renderRecent();
|
||||
if (state.servers[0]) choose(state.servers[0]);
|
||||
// The tab of the server used last; This Mac on a first run.
|
||||
selectTab(state.servers[0] && !isLocalUrl(state.servers[0].url) ? 'remote' : 'local');
|
||||
if (msg.starting) {
|
||||
setBusy(true, false);
|
||||
setStatus('busy', 'Starting Codeman on this Mac…', msg.notice || '');
|
||||
} else if (msg.notice) {
|
||||
setStatus('warn', msg.notice);
|
||||
}
|
||||
if (msg.autoConnect && !manual && !tooOld && !msg.starting && !msg.notice && state.servers[0]) connect();
|
||||
else if (state.tab === 'remote') els.url.focus();
|
||||
break;
|
||||
case 'servers':
|
||||
state.servers = msg.servers || [];
|
||||
renderRecent();
|
||||
break;
|
||||
case 'prefs':
|
||||
if (typeof msg.startFullscreen === 'boolean') els.fullscreen.checked = msg.startFullscreen;
|
||||
if (typeof msg.autoConnect === 'boolean') els.auto.checked = msg.autoConnect;
|
||||
break;
|
||||
case 'probeResult':
|
||||
onProbeResult(msg);
|
||||
break;
|
||||
case 'opening':
|
||||
// Still busy: Cancel abandons the load and brings this page back.
|
||||
if (msg.passwordChange) {
|
||||
setStatus('ok', 'Opening Codeman…', 'Codeman will ask you for a new password for this account. Connect with the new password next time.');
|
||||
} else {
|
||||
setStatus('ok', msg.version ? 'Opening Codeman ' + msg.version + '…' : 'Opening Codeman…');
|
||||
}
|
||||
break;
|
||||
case 'error':
|
||||
setBusy(false);
|
||||
setStatus('bad', msg.message || 'Something went wrong.');
|
||||
if (msg.needsPassword) {
|
||||
selectTab('remote');
|
||||
showLogin();
|
||||
}
|
||||
break;
|
||||
case 'serverStarting':
|
||||
setBusy(true, false);
|
||||
setStatus('busy', 'Starting Codeman on this Mac…');
|
||||
break;
|
||||
case 'serverStartResult':
|
||||
onStartResult(msg);
|
||||
break;
|
||||
case 'localStatus':
|
||||
onLocalStatus(msg.status);
|
||||
break;
|
||||
case 'installStarted':
|
||||
state.opening = false;
|
||||
setStatus('', '');
|
||||
state.wait = { since: Date.now(), finishedAt: 0, port: Number(msg.port) || 3000 };
|
||||
selectTab('local');
|
||||
renderLocal();
|
||||
scheduleWait();
|
||||
break;
|
||||
case 'remoteInstallStarted':
|
||||
state.opening = false;
|
||||
syncLocalButtons();
|
||||
selectTab('remote');
|
||||
useAddress(msg.url);
|
||||
setStatus('ok', 'Terminal is installing Codeman on ' + msg.host + '.',
|
||||
'Answer the installer’s questions there. At the end it prints the address of Codeman on that machine: enter it above and connect (' + msg.url + ' is the usual one).');
|
||||
break;
|
||||
case 'installFailed':
|
||||
state.opening = false;
|
||||
syncLocalButtons();
|
||||
setStatus('bad', msg.message || 'Could not open Terminal.');
|
||||
break;
|
||||
case 'copied':
|
||||
setStatus('ok', 'Copied.', 'Paste it into a terminal on the other machine.');
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
els.form.addEventListener('submit', function (ev) { ev.preventDefault(); connect(); });
|
||||
els.cancel.addEventListener('click', function () { post({ cmd: 'cancel' }); setBusy(false); setStatus('', ''); });
|
||||
els.start.addEventListener('click', function () {
|
||||
if (state.busy) return;
|
||||
state.startUrl = state.lastUrl || els.url.value.trim();
|
||||
post({ cmd: 'startServer', url: state.startUrl });
|
||||
});
|
||||
els.suggest.addEventListener('click', function () {
|
||||
if (state.busy || !state.suggestion) return;
|
||||
els.url.value = state.suggestion;
|
||||
matchSaved();
|
||||
connect();
|
||||
});
|
||||
els.auto.addEventListener('change', function () { post({ cmd: 'setAutoConnect', value: els.auto.checked }); });
|
||||
els.fullscreen.addEventListener('change', function () { post({ cmd: 'setStartFullscreen', value: els.fullscreen.checked }); });
|
||||
els.url.addEventListener('input', function () {
|
||||
matchSaved();
|
||||
els.start.hidden = true;
|
||||
hideSuggestion();
|
||||
});
|
||||
els.tabLocal.addEventListener('click', function () { selectTab('local'); });
|
||||
els.tabRemote.addEventListener('click', function () { selectTab('remote'); });
|
||||
[els.tabLocal, els.tabRemote].forEach(function (tab) {
|
||||
tab.addEventListener('keydown', function (ev) {
|
||||
if (ev.key !== 'ArrowLeft' && ev.key !== 'ArrowRight') return;
|
||||
ev.preventDefault();
|
||||
var next = state.tab === 'local' ? 'remote' : 'local';
|
||||
selectTab(next);
|
||||
(next === 'local' ? els.tabLocal : els.tabRemote).focus();
|
||||
});
|
||||
});
|
||||
els.localOpen.addEventListener('click', openLocal);
|
||||
els.localStart.addEventListener('click', startLocal);
|
||||
els.localInstall.addEventListener('click', installLocal);
|
||||
els.localStop.addEventListener('click', function () { state.stoppedWaiting = true; endWait(); checkLocal('login'); });
|
||||
els.localCheck.addEventListener('click', function () { if (!state.busy && !state.localChecking) checkLocal('deep'); });
|
||||
els.sshInstall.addEventListener('click', installRemote);
|
||||
// Enter in the SSH field installs; it must not submit the form (Connect).
|
||||
els.ssh.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Enter') { ev.preventDefault(); installRemote(); }
|
||||
});
|
||||
els.copy.addEventListener('click', function () { post({ cmd: 'copyInstallCommand' }); });
|
||||
$('docs').addEventListener('click', function (ev) { ev.preventDefault(); post({ cmd: 'openExternal', url: 'https://github.com/Ark0N/Codeman/wiki' }); });
|
||||
|
||||
if (tooOld) {
|
||||
els.webkit.hidden = false;
|
||||
setBusy(false);
|
||||
}
|
||||
post({ cmd: 'ready' });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
@@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>Codeman</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Codeman</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.getcodeman.desktop</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>__VERSION__</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>__VERSION__</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>codeman-desktop</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Codeman</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<!-- 11.3 is where WKDownload arrived; wry saves files through it. Below
|
||||
that a Download link replaces the window or saves nothing.
|
||||
scripts/bundle-macos.sh builds for this version too. -->
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>11.3</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.developer-tools</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>MIT License, Codeman Contributors</string>
|
||||
<!-- Voice dictation and gesture control ask for these; without the
|
||||
strings macOS kills the app the first time a page requests them. -->
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Codeman uses the microphone for voice dictation when you turn it on.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Codeman uses the camera for hand-gesture control when you turn it on.</string>
|
||||
<!-- macOS 15 asks before an app reaches the local network (a LAN address,
|
||||
a .local name), and shows this text in that question. A process the
|
||||
app starts counts as the app (Apple TN3179), so a server started with
|
||||
"Start Codeman on this Mac" runs under the same answer. -->
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Codeman connects to Codeman servers on your local network. A Codeman server you start from this app uses the same permission for what it reaches on the network.</string>
|
||||
<!-- App Transport Security would block plain http:// to a host NAME
|
||||
(my-mac.local, a LAN box). IP literals are exempt, which is why
|
||||
http://127.0.0.1 works without this. -->
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Only used with a Developer ID (SIGN_IDENTITY=...), which turns on the
|
||||
hardened runtime; there, microphone and camera need these. -->
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1 @@
|
||||
max_width = 120
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build Codeman.app (and optionally a .dmg) from packages/desktop, on macOS.
|
||||
#
|
||||
# packages/desktop/scripts/bundle-macos.sh # this Mac's architecture
|
||||
# packages/desktop/scripts/bundle-macos.sh --universal --dmg
|
||||
# SIGN_IDENTITY="Developer ID Application: ..." packages/desktop/scripts/bundle-macos.sh --dmg
|
||||
#
|
||||
# Without SIGN_IDENTITY the app is signed ad hoc: it runs on the Mac that built
|
||||
# it, and a copy downloaded on another Mac is stopped by Gatekeeper. On macOS 15
|
||||
# and later, try to open it once, then click Open Anyway in System Settings >
|
||||
# Privacy & Security; on macOS 14 and earlier, right-click it and choose Open.
|
||||
# Or run `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the copy
|
||||
# in /Applications. A Developer ID signature + notarization avoids all of this.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
[ "$(uname -s)" = Darwin ] || { echo "bundle-macos.sh runs on macOS only" >&2; exit 1; }
|
||||
|
||||
UNIVERSAL=0
|
||||
DMG=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--universal) UNIVERSAL=1 ;;
|
||||
--dmg) DMG=1 ;;
|
||||
*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
VERSION=$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -1)
|
||||
[ -n "$VERSION" ] || { echo "could not read the version from Cargo.toml" >&2; exit 1; }
|
||||
|
||||
# The binary targets the same macOS as LSMinimumSystemVersion in Info.plist,
|
||||
# so the two cannot disagree.
|
||||
MACOSX_DEPLOYMENT_TARGET=$(/usr/libexec/PlistBuddy -c 'Print :LSMinimumSystemVersion' macos/Info.plist)
|
||||
[ -n "$MACOSX_DEPLOYMENT_TARGET" ] || { echo "could not read LSMinimumSystemVersion from macos/Info.plist" >&2; exit 1; }
|
||||
export MACOSX_DEPLOYMENT_TARGET
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
# Build with cargo and set BUILT to the executable cargo reports for
|
||||
# codeman-desktop. Asking cargo, rather than assuming ./target, keeps
|
||||
# CARGO_TARGET_DIR, build.target-dir and build.target from a cargo config
|
||||
# working, and a stale binary from an older build is never bundled.
|
||||
cargo_build() {
|
||||
local json
|
||||
json=$(cargo build --locked --release "$@" --message-format=json-render-diagnostics)
|
||||
BUILT=$(printf '%s\n' "$json" | awk '
|
||||
index($0, "\"reason\":\"compiler-artifact\"") &&
|
||||
index($0, "\"kind\":[\"bin\"]") &&
|
||||
index($0, "\"name\":\"codeman-desktop\"") &&
|
||||
match($0, /"executable":"[^"]*"/) { path = substr($0, RSTART + 14, RLENGTH - 15) }
|
||||
END { print path }')
|
||||
if [ -z "$BUILT" ] || [ ! -x "$BUILT" ]; then
|
||||
echo "cargo did not report a codeman-desktop executable${BUILT:+ (it named $BUILT)}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$UNIVERSAL" = 1 ]; then
|
||||
rustup target add aarch64-apple-darwin x86_64-apple-darwin >/dev/null
|
||||
cargo_build --target aarch64-apple-darwin
|
||||
ARM_BIN=$BUILT
|
||||
cargo_build --target x86_64-apple-darwin
|
||||
INTEL_BIN=$BUILT
|
||||
BIN="$WORK/codeman-desktop-universal"
|
||||
lipo -create -output "$BIN" "$ARM_BIN" "$INTEL_BIN"
|
||||
else
|
||||
cargo_build
|
||||
BIN=$BUILT
|
||||
fi
|
||||
|
||||
APP=dist/Codeman.app
|
||||
rm -rf "$APP"
|
||||
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
||||
cp "$BIN" "$APP/Contents/MacOS/codeman-desktop"
|
||||
sed "s/__VERSION__/$VERSION/g" macos/Info.plist > "$APP/Contents/Info.plist"
|
||||
plutil -lint "$APP/Contents/Info.plist" >/dev/null
|
||||
printf 'APPL????' > "$APP/Contents/PkgInfo"
|
||||
|
||||
# assets/icon.png (1024x1024, already on Apple's icon grid) -> Codeman.icns
|
||||
ICONSET="$WORK/Codeman.iconset"
|
||||
mkdir -p "$ICONSET"
|
||||
for size in 16 32 128 256 512; do
|
||||
sips -z "$size" "$size" assets/icon.png --out "$ICONSET/icon_${size}x${size}.png" >/dev/null
|
||||
double=$((size * 2))
|
||||
sips -z "$double" "$double" assets/icon.png --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null
|
||||
done
|
||||
iconutil -c icns "$ICONSET" -o "$APP/Contents/Resources/Codeman.icns"
|
||||
|
||||
if [ -n "${SIGN_IDENTITY:-}" ]; then
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements macos/entitlements.plist --sign "$SIGN_IDENTITY" "$APP"
|
||||
else
|
||||
codesign --force --sign - "$APP"
|
||||
fi
|
||||
codesign --verify --strict "$APP"
|
||||
|
||||
echo "Built $(pwd)/$APP ($(du -sh "$APP" | cut -f1), version $VERSION, macOS $MACOSX_DEPLOYMENT_TARGET or later)"
|
||||
|
||||
if [ "$DMG" = 1 ]; then
|
||||
STAGE="$WORK/dmg"
|
||||
mkdir -p "$STAGE"
|
||||
cp -R "$APP" "$STAGE/"
|
||||
ln -s /Applications "$STAGE/Applications"
|
||||
DMG_PATH="dist/Codeman-$VERSION.dmg"
|
||||
rm -f "$DMG_PATH"
|
||||
hdiutil create -quiet -volname "Codeman" -srcfolder "$STAGE" -ov -format UDZO "$DMG_PATH"
|
||||
echo "Built $(pwd)/$DMG_PATH ($(du -sh "$DMG_PATH" | cut -f1))"
|
||||
fi
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Files bundled into the binary: the connect page, its icon, and the script
|
||||
//! injected into every Codeman page (`window.CodemanHost`).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use wry::http::{header::CONTENT_TYPE, Request, Response, StatusCode};
|
||||
|
||||
const CONNECT_HTML: &[u8] = include_bytes!("../assets/connect.html");
|
||||
const ICON_PNG: &[u8] = include_bytes!("../assets/icon.png");
|
||||
|
||||
/// The connect page loads nothing from outside the app.
|
||||
const CSP: &str = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src codemanapp:";
|
||||
|
||||
/// Serve `codemanapp://localhost/<path>`.
|
||||
pub fn respond(request: Request<Vec<u8>>) -> Response<Cow<'static, [u8]>> {
|
||||
let (body, mime): (&'static [u8], &str) = match request.uri().path() {
|
||||
"/" | "/index.html" => (CONNECT_HTML, "text/html; charset=utf-8"),
|
||||
"/icon.png" => (ICON_PNG, "image/png"),
|
||||
_ => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Cow::Borrowed(&b"not found"[..]))
|
||||
.expect("static response");
|
||||
}
|
||||
};
|
||||
Response::builder()
|
||||
.header(CONTENT_TYPE, mime)
|
||||
.header("Content-Security-Policy", CSP)
|
||||
.header("Cache-Control", "no-store")
|
||||
.body(Cow::Borrowed(body))
|
||||
.expect("static response")
|
||||
}
|
||||
|
||||
/// Injected at document start into the MAIN frame of every page (wry's
|
||||
/// default), where it does nothing unless the page is http(s). It provides the
|
||||
/// native-wrapper bridge Codeman's frontend already looks for
|
||||
/// (`window.CodemanHost.openWindow/closeWindow/focusWindow`, see
|
||||
/// `openInHostWindow` in src/web/public/app.js), so pop-outs become real app
|
||||
/// windows. `openWindow` answers false, posting nothing, for a URL the native
|
||||
/// side would drop (not http(s), or longer than its 4096-byte cap), so
|
||||
/// Codeman shows its own error instead of marking a session detached with no
|
||||
/// window behind it. Besides that it:
|
||||
///
|
||||
/// - turns a click on a same-origin download link (`download=true`, or a path
|
||||
/// ending in `/download`) into a real download by giving the link a
|
||||
/// `download` attribute. Codeman's file tree and attachment cards link to
|
||||
/// `file-raw?download=true` without one, and the server sends a type WebKit
|
||||
/// can show (png, pdf, txt), so WebKit would otherwise replace the Codeman
|
||||
/// window with the file, with no way back. This runs in the capture phase,
|
||||
/// before the file tree's own `stopPropagation`.
|
||||
/// - does the same for `window.open` of such a `download=true` URL (the
|
||||
/// attachment history), returning null instead of a window.
|
||||
/// - sends clicks on links to other sites to the default browser, after
|
||||
/// Codeman's own handlers have had their turn (bubble phase, skipped when
|
||||
/// one of them called preventDefault). Middle clicks and modifier clicks
|
||||
/// count too: a bare WKWebView has no new-tab gesture of its own, so they
|
||||
/// would do nothing.
|
||||
/// - reports the first same-origin 401 a `fetch` gets (`sessionExpired`),
|
||||
/// once per page load: the session cookie is gone, usually because the
|
||||
/// server restarted, and nothing in the page asks for the login again.
|
||||
/// - defines `__codemanDesktopRefused(id)` (not enumerable) for
|
||||
/// [`refused_script`].
|
||||
pub const HOST_SHIM: &str = r#"(function () {
|
||||
'use strict';
|
||||
if (!/^https?:$/.test(location.protocol) || window.CodemanHost) return;
|
||||
var MAX_URL = 4096;
|
||||
var post = function (msg) {
|
||||
try { window.ipc.postMessage(JSON.stringify(msg)); return true; } catch (e) { return false; }
|
||||
};
|
||||
// An http(s) URL object, or null. fetch and window.open resolve against
|
||||
// the document base (Codeman sets <base href>), so those pass it.
|
||||
var httpUrl = function (raw, base) {
|
||||
try {
|
||||
var u = new URL(String(raw), base || location.href);
|
||||
return /^https?:$/.test(u.protocol) ? u : null;
|
||||
} catch (e) { return null; }
|
||||
};
|
||||
var sameOrigin = function (u) { return !!u && u.origin === location.origin; };
|
||||
var link = function (ev) {
|
||||
var a = ev.target && ev.target.closest ? ev.target.closest('a[href]') : null;
|
||||
return a && typeof a.href === 'string' && !a.hasAttribute('download') ? a : null;
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'CodemanHost', {
|
||||
value: Object.freeze({
|
||||
desktop: true,
|
||||
openWindow: function (url) {
|
||||
var u = httpUrl(url);
|
||||
return u && u.href.length <= MAX_URL ? post({ cmd: 'openWindow', url: u.href }) : false;
|
||||
},
|
||||
closeWindow: function () { post({ cmd: 'closeWindow' }); },
|
||||
focusWindow: function () { post({ cmd: 'focusWindow' }); }
|
||||
})
|
||||
});
|
||||
|
||||
// The native side could not open a window: say so. `id` is a string only
|
||||
// when CodemanHost.openWindow asked for that session's pop-out; that session
|
||||
// goes back to the dashboard (a fresh channel object, so this window's own
|
||||
// channel hears it too). Anything else, null included, only shows the toast.
|
||||
Object.defineProperty(window, '__codemanDesktopRefused', {
|
||||
value: function (id) {
|
||||
try {
|
||||
if (window.app && typeof window.app.showToast === 'function') {
|
||||
window.app.showToast('Could not open a new window', 'error');
|
||||
}
|
||||
} catch (e) {}
|
||||
try {
|
||||
if (typeof id === 'string' && id !== '' && typeof BroadcastChannel === 'function') {
|
||||
var ch = new BroadcastChannel('codeman-windows');
|
||||
ch.postMessage({ type: 'redocked', id: id });
|
||||
setTimeout(function () { try { ch.close(); } catch (e) {} }, 2000);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
});
|
||||
|
||||
var markDownload = function (ev) {
|
||||
if (ev.button > 1) return;
|
||||
var a = link(ev);
|
||||
var u = a && httpUrl(a.href);
|
||||
if (sameOrigin(u) && (u.searchParams.get('download') === 'true' || /\/download$/.test(u.pathname))) {
|
||||
a.setAttribute('download', '');
|
||||
}
|
||||
};
|
||||
window.addEventListener('click', markDownload, true);
|
||||
window.addEventListener('auxclick', markDownload, true);
|
||||
|
||||
var lastMiddle = null;
|
||||
var openOutside = function (ev) {
|
||||
if (ev.defaultPrevented || ev.button > 1) return;
|
||||
var a = link(ev);
|
||||
var u = a && httpUrl(a.href);
|
||||
if (!u || sameOrigin(u)) return;
|
||||
ev.preventDefault();
|
||||
// Older WebKit sends a middle click as 'click', newer as 'auxclick'; one
|
||||
// that sent both must still open the link once.
|
||||
if (ev.button === 1) {
|
||||
if (lastMiddle && lastMiddle.a === a && ev.timeStamp - lastMiddle.t < 500) return;
|
||||
lastMiddle = { a: a, t: ev.timeStamp };
|
||||
}
|
||||
post({ cmd: 'openExternal', url: u.href });
|
||||
};
|
||||
window.addEventListener('click', openOutside, false);
|
||||
window.addEventListener('auxclick', openOutside, false);
|
||||
|
||||
var nativeOpen = window.open;
|
||||
if (typeof nativeOpen === 'function') {
|
||||
window.open = function open(url) {
|
||||
try {
|
||||
var u = arguments.length && url !== undefined ? httpUrl(url, document.baseURI) : null;
|
||||
if (sameOrigin(u) && u.searchParams.get('download') === 'true') {
|
||||
var a = document.createElement('a');
|
||||
a.href = u.href;
|
||||
a.setAttribute('download', '');
|
||||
a.click();
|
||||
return null;
|
||||
}
|
||||
} catch (e) {}
|
||||
return nativeOpen.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
// Returns the promise fetch itself returned, so callers see exactly what
|
||||
// they saw before; the 401 check hangs off a side branch that swallows its
|
||||
// own errors and never reads the body.
|
||||
var expired = false;
|
||||
var nativeFetch = window.fetch;
|
||||
if (typeof nativeFetch === 'function') {
|
||||
var check = function (input, r) {
|
||||
if (expired || !r || r.status !== 401) return;
|
||||
var raw = r.url || (input && typeof input === 'object' && typeof input.url === 'string' ? input.url : input);
|
||||
if (!sameOrigin(httpUrl(raw, document.baseURI))) return;
|
||||
expired = true;
|
||||
post({ cmd: 'sessionExpired' });
|
||||
};
|
||||
window.fetch = function fetch(input) {
|
||||
var p = nativeFetch.apply(this, arguments);
|
||||
try {
|
||||
if (!expired && p && typeof p.then === 'function') {
|
||||
p.then(function (r) { try { check(input, r); } catch (e) {} }, function () {});
|
||||
}
|
||||
} catch (e) {}
|
||||
return p;
|
||||
};
|
||||
}
|
||||
})();"#;
|
||||
|
||||
/// A toast inside Codeman when a download finished (Codeman's own
|
||||
/// `showToast`; nothing happens on a page without it).
|
||||
pub fn download_toast_script(file_name: &str, ok: bool) -> String {
|
||||
let text = if ok {
|
||||
format!("Downloaded {file_name} to Downloads")
|
||||
} else {
|
||||
format!("Download of {file_name} failed")
|
||||
};
|
||||
let text = serde_json::to_string(&text).unwrap_or_else(|_| "\"Download finished\"".into());
|
||||
let kind = if ok { "\"success\"" } else { "\"error\"" };
|
||||
format!("try {{ window.app && window.app.showToast && window.app.showToast({text}, {kind}); }} catch (e) {{}}")
|
||||
}
|
||||
|
||||
/// Runs in the window that asked for a window the native side did not open
|
||||
/// (pop-out limit reached, policy refused it, window or web view failed to
|
||||
/// build): the page shows an error toast. Does nothing on a page without the
|
||||
/// shim.
|
||||
///
|
||||
/// `session_id` is the decoded id of a session pop-out
|
||||
/// (`policy::session_id_of`), and only for a `CodemanHost.openWindow` request,
|
||||
/// which comes from a trusted frame: that session then gets its tab back in
|
||||
/// the dashboard. Pass `None` for every other refusal, above all the ones a
|
||||
/// `window.open` or `target=_blank` from any frame caused, so a page cannot
|
||||
/// make the dashboard drop the detached state of a session that is still
|
||||
/// popped out.
|
||||
pub fn refused_script(session_id: Option<&str>) -> String {
|
||||
let id = serde_json::to_string(&session_id).unwrap_or_else(|_| "null".into());
|
||||
format!(
|
||||
"try {{ if (typeof window.__codemanDesktopRefused === 'function') window.__codemanDesktopRefused({id}); }} catch (e) {{}}"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn get(path: &str) -> Response<Cow<'static, [u8]>> {
|
||||
respond(
|
||||
Request::builder()
|
||||
.uri(format!("codemanapp://localhost{path}"))
|
||||
.body(Vec::new())
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_page_and_icon_only() {
|
||||
let page = get("/index.html");
|
||||
assert_eq!(page.status(), StatusCode::OK);
|
||||
assert!(page.headers()["content-type"]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.starts_with("text/html"));
|
||||
assert!(page.headers().contains_key("content-security-policy"));
|
||||
assert!(String::from_utf8_lossy(page.body()).contains("window.__codemanDesktop"));
|
||||
assert_eq!(get("/icon.png").status(), StatusCode::OK);
|
||||
assert_eq!(get("/../etc/passwd").status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(get("/other.js").status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_script_escapes_the_session_id() {
|
||||
let js = refused_script(Some("a'\"); alert(1); //\u{2028}\\"));
|
||||
assert!(js.starts_with("try { if (typeof window.__codemanDesktopRefused === 'function') "));
|
||||
assert!(js.contains(r#"window.__codemanDesktopRefused("a'\"); alert(1); //"#));
|
||||
assert!(js.contains("\u{2028}\\\\\"); } catch (e) {}"));
|
||||
assert!(js.ends_with(" } catch (e) {}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_script_without_a_session_passes_null() {
|
||||
assert_eq!(
|
||||
refused_script(None),
|
||||
"try { if (typeof window.__codemanDesktopRefused === 'function') window.__codemanDesktopRefused(null); } catch (e) {}"
|
||||
);
|
||||
assert!(refused_script(Some("")).contains("__codemanDesktopRefused(\"\")"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shim_keeps_its_contract() {
|
||||
for needle in [
|
||||
"Object.freeze",
|
||||
"openWindow",
|
||||
"closeWindow",
|
||||
"focusWindow",
|
||||
"__codemanDesktopRefused",
|
||||
"sessionExpired",
|
||||
"'auxclick'",
|
||||
"MAX_URL = 4096",
|
||||
"typeof id === 'string' && id !== ''",
|
||||
] {
|
||||
assert!(HOST_SHIM.contains(needle), "{needle}");
|
||||
}
|
||||
// The refused hook takes the session id the native side resolved; it
|
||||
// never parses or decodes a URL to find one.
|
||||
assert!(!HOST_SHIM.contains("decodeURIComponent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toast_escapes_file_names() {
|
||||
let js = download_toast_script("a\"); alert(1); (\".txt", true);
|
||||
assert!(js.contains(r#"Downloaded a\"); alert(1); (\".txt to Downloads"#));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,722 @@
|
||||
//! The app's own settings: recent servers, two preferences and the window
|
||||
//! frame. Stored as JSON next to other per-app data
|
||||
//! (`~/Library/Application Support/Codeman/desktop.json` on macOS). Passwords
|
||||
//! never go here; they live in the Keychain (see `credentials`).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const MAX_SERVERS: usize = 8;
|
||||
/// How many recovery copies of an unreadable config can sit side by side:
|
||||
/// `desktop.json.corrupt`, then `desktop.json.corrupt.1` to `.9`.
|
||||
const ASIDE_SLOTS: usize = 10;
|
||||
const MIN_WIDTH: f64 = 480.0;
|
||||
const MIN_HEIGHT: f64 = 360.0;
|
||||
const MAX_EDGE: f64 = 20_000.0;
|
||||
|
||||
/// Height of a standard title bar, in points.
|
||||
const TITLE_BAR: f64 = 28.0;
|
||||
/// How far the outer top of the window lies above `WindowFrame::y`. On macOS
|
||||
/// `y` is the content area's top (tao's `with_position` puts the content rect
|
||||
/// there and the title bar above it); elsewhere `y` is already the outer top.
|
||||
const TITLE_ABOVE_Y: f64 = if cfg!(target_os = "macos") { TITLE_BAR } else { 0.0 };
|
||||
/// How much of the title bar must be on a screen to grab it.
|
||||
const MIN_GRIP: f64 = 80.0;
|
||||
/// Room `fitted_to` leaves when it shrinks a window. Screens are full display
|
||||
/// bounds, so the menu bar and the Dock are inside them; these are the margins
|
||||
/// main.rs uses for the centred default frame.
|
||||
const FIT_MARGIN_X: f64 = 40.0;
|
||||
const FIT_MARGIN_Y: f64 = 80.0;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Config {
|
||||
/// Most recent first.
|
||||
pub servers: Vec<ServerEntry>,
|
||||
/// Connect to the most recent server at launch.
|
||||
pub auto_connect: bool,
|
||||
/// Always open in full screen (otherwise the last state is restored).
|
||||
pub start_fullscreen: bool,
|
||||
pub window: Option<WindowFrame>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Config {
|
||||
servers: Vec::new(),
|
||||
auto_connect: true,
|
||||
start_fullscreen: false,
|
||||
window: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerEntry {
|
||||
pub url: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub has_saved_password: bool,
|
||||
}
|
||||
|
||||
/// The main window's position and inner size, in logical (point)
|
||||
/// coordinates. `x`/`y` are where tao's `with_position` wants them: on macOS
|
||||
/// the top-left of the CONTENT area (the title bar, about 28 pt, sits above
|
||||
/// it), elsewhere the window's outer top-left. Saving the outer corner on
|
||||
/// macOS and restoring it as the content corner made the window creep up by
|
||||
/// a title bar on every launch.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WindowFrame {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
#[serde(default)]
|
||||
pub fullscreen: bool,
|
||||
}
|
||||
|
||||
/// A display's full bounds in logical coordinates, as tao reports them. This
|
||||
/// is the whole display, not the work area: the menu bar and the Dock are
|
||||
/// inside it.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Rect {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
impl WindowFrame {
|
||||
/// The frame clamped to sane sizes, or None when it holds junk.
|
||||
pub fn sanitized(self) -> Option<WindowFrame> {
|
||||
let vals = [self.x, self.y, self.width, self.height];
|
||||
if vals.iter().any(|v| !v.is_finite()) || self.x.abs() > MAX_EDGE || self.y.abs() > MAX_EDGE {
|
||||
return None;
|
||||
}
|
||||
Some(WindowFrame {
|
||||
width: self.width.clamp(MIN_WIDTH, MAX_EDGE),
|
||||
height: self.height.clamp(MIN_HEIGHT, MAX_EDGE),
|
||||
..self
|
||||
})
|
||||
}
|
||||
|
||||
/// The frame made to fit the screens it will open on, or None when its
|
||||
/// title bar is on none of them (the caller then falls back to a default
|
||||
/// frame). An edge that no screen covers is pulled onto the screen that
|
||||
/// holds the title bar, shrinking the window when it is too big for it:
|
||||
/// a frame saved on an external display that is gone would otherwise
|
||||
/// open with its right edge and resize corner far off screen. An edge
|
||||
/// that lands on some screen stays put, so a window spanning two
|
||||
/// displays keeps its span. `min` is the window's minimum inner size.
|
||||
pub fn fitted_to(&self, screens: &[Rect], min: (f64, f64)) -> Option<WindowFrame> {
|
||||
let home = *self.title_screen(screens)?;
|
||||
let top = self.outer_top();
|
||||
let outer_height = TITLE_BAR + self.height;
|
||||
let (x, width) = fit_axis(
|
||||
self.x,
|
||||
self.width,
|
||||
(
|
||||
covers_x(screens, self.x, top, top + outer_height),
|
||||
covers_x(screens, self.x + self.width, top, top + outer_height),
|
||||
),
|
||||
(home.x, home.x + home.width),
|
||||
FIT_MARGIN_X,
|
||||
min.0,
|
||||
);
|
||||
let (top, outer_height) = fit_axis(
|
||||
top,
|
||||
outer_height,
|
||||
(
|
||||
covers_y(screens, top, x, x + width),
|
||||
covers_y(screens, top + outer_height, x, x + width),
|
||||
),
|
||||
(home.y, home.y + home.height),
|
||||
FIT_MARGIN_Y - TITLE_BAR,
|
||||
min.1 + TITLE_BAR,
|
||||
);
|
||||
Some(WindowFrame {
|
||||
x,
|
||||
y: top + TITLE_ABOVE_Y,
|
||||
width,
|
||||
height: outer_height - TITLE_BAR,
|
||||
..*self
|
||||
})
|
||||
}
|
||||
|
||||
fn outer_top(&self) -> f64 {
|
||||
self.y - TITLE_ABOVE_Y
|
||||
}
|
||||
|
||||
/// The screen the title bar is on, or None when no screen has enough of
|
||||
/// it to grab (a display unplugged since the last run leaves the frame off
|
||||
/// screen). The middle of the title bar must be inside the screen and at
|
||||
/// least `MIN_GRIP` of the bar's width must overlap it; when several
|
||||
/// qualify, the one holding most of the bar wins.
|
||||
fn title_screen<'a>(&self, screens: &'a [Rect]) -> Option<&'a Rect> {
|
||||
let title_y = self.outer_top() + TITLE_BAR / 2.0;
|
||||
screens
|
||||
.iter()
|
||||
.filter(|m| title_y >= m.y && title_y <= m.y + m.height)
|
||||
.map(|m| {
|
||||
let grip = (self.x + self.width).min(m.x + m.width) - self.x.max(m.x);
|
||||
(m, grip)
|
||||
})
|
||||
.filter(|(_, grip)| *grip >= MIN_GRIP)
|
||||
.max_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(m, _)| m)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether some screen holds part of the vertical edge at `x` that runs from
|
||||
/// `top` to `bottom`.
|
||||
fn covers_x(screens: &[Rect], x: f64, top: f64, bottom: f64) -> bool {
|
||||
screens
|
||||
.iter()
|
||||
.any(|m| x >= m.x && x <= m.x + m.width && top < m.y + m.height && bottom > m.y)
|
||||
}
|
||||
|
||||
/// Whether some screen holds part of the horizontal edge at `y` that runs
|
||||
/// from `left` to `right`.
|
||||
fn covers_y(screens: &[Rect], y: f64, left: f64, right: f64) -> bool {
|
||||
screens
|
||||
.iter()
|
||||
.any(|m| y >= m.y && y <= m.y + m.height && left < m.x + m.width && right > m.x)
|
||||
}
|
||||
|
||||
/// One axis of `WindowFrame::fitted_to`. The window runs from `start` for
|
||||
/// `len`; `covered` says whether some screen holds each end; `home` is the
|
||||
/// title bar screen's range on this axis. An uncovered end is pulled onto
|
||||
/// `home`, and the window shrinks (leaving `margin`, never below `min`) when
|
||||
/// it does not fit; a covered end may stay where it is, on another display.
|
||||
fn fit_axis(start: f64, len: f64, covered: (bool, bool), home: (f64, f64), margin: f64, min: f64) -> (f64, f64) {
|
||||
if covered.0 && covered.1 {
|
||||
return (start, len);
|
||||
}
|
||||
let lo = if covered.0 { start.min(home.0) } else { home.0 };
|
||||
let hi = if covered.1 { (start + len).max(home.1) } else { home.1 };
|
||||
let len = len.min(hi - lo - margin).max(min);
|
||||
// `max` last: a window still too big for the screen keeps its start (the
|
||||
// left edge, or the title bar) on screen rather than its end.
|
||||
let start = start.min(hi - len).max(lo);
|
||||
(start, len)
|
||||
}
|
||||
|
||||
/// The first name an unreadable `desktop.json` can be moved to without
|
||||
/// replacing an earlier recovery copy: `desktop.json.corrupt`, then
|
||||
/// `desktop.json.corrupt.1` to `.9`, next to it.
|
||||
/// A name is free only when nothing at all is there: a file, a directory or
|
||||
/// a dangling symlink left at it is taken, and so is a name that cannot be
|
||||
/// checked. None when every name is taken.
|
||||
fn free_aside_name(path: &Path) -> Option<PathBuf> {
|
||||
(0..ASIDE_SLOTS)
|
||||
.map(|n| match n {
|
||||
0 => path.with_extension("json.corrupt"),
|
||||
n => path.with_extension(format!("json.corrupt.{n}")),
|
||||
})
|
||||
.find(|aside| matches!(fs::symlink_metadata(aside), Err(e) if e.kind() == io::ErrorKind::NotFound))
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Where the config lives, or None if the OS has no config directory.
|
||||
pub fn default_path() -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|d| d.join("Codeman").join("desktop.json"))
|
||||
}
|
||||
|
||||
/// Read the config, and say whether saving back to `path` is safe this
|
||||
/// run. A missing file is the default. A file that is there but cannot
|
||||
/// be read (not UTF-8, no permission, a directory, an I/O error) or does
|
||||
/// not parse is moved aside for recovery, and the defaults are used; it
|
||||
/// is never silently overwritten with them. It goes to the first free
|
||||
/// name of `desktop.json.corrupt`, `desktop.json.corrupt.1` to `.9`, so
|
||||
/// a second unreadable file never replaces the copy kept from the first.
|
||||
/// If every name is taken or the move fails, the answer is false: the
|
||||
/// original is still at `path`, and the save at exit must not replace it.
|
||||
pub fn load_from(path: &Path) -> (Config, bool) {
|
||||
let text = match fs::read_to_string(path) {
|
||||
Ok(text) => Some(text),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => return (Config::default(), true),
|
||||
Err(_) => None,
|
||||
};
|
||||
if let Some(mut config) = text.and_then(|t| serde_json::from_str::<Config>(&t).ok()) {
|
||||
config.sanitize();
|
||||
return (config, true);
|
||||
}
|
||||
let Some(aside) = free_aside_name(path) else {
|
||||
eprintln!(
|
||||
"codeman-desktop: {} could not be read, and {} to .{} are all taken; settings will not be saved this run",
|
||||
path.display(),
|
||||
path.with_extension("json.corrupt").display(),
|
||||
ASIDE_SLOTS - 1
|
||||
);
|
||||
return (Config::default(), false);
|
||||
};
|
||||
match fs::rename(path, &aside) {
|
||||
Ok(()) => {
|
||||
eprintln!(
|
||||
"codeman-desktop: {} could not be read; moved it to {} and started with defaults",
|
||||
path.display(),
|
||||
aside.display()
|
||||
);
|
||||
(Config::default(), true)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"codeman-desktop: {} could not be read or moved aside ({e}); settings will not be saved this run",
|
||||
path.display()
|
||||
);
|
||||
(Config::default(), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write via a temp file and rename, so a crash never leaves half a file.
|
||||
pub fn save_to(&self, path: &Path) -> io::Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let text = serde_json::to_string_pretty(self).map_err(io::Error::other)?;
|
||||
fs::write(&tmp, text)?;
|
||||
fs::rename(&tmp, path)
|
||||
}
|
||||
|
||||
fn sanitize(&mut self) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
self.servers.retain(|s| !s.url.is_empty() && seen.insert(s.url.clone()));
|
||||
self.servers.truncate(MAX_SERVERS);
|
||||
self.window = self.window.and_then(WindowFrame::sanitized);
|
||||
}
|
||||
|
||||
/// Move (or add) a server to the top of the recent list. Returns the
|
||||
/// Keychain accounts, as (url, username), of the entries with a username
|
||||
/// that fall off the end of the list (it keeps `MAX_SERVERS`), for the
|
||||
/// caller to delete. A different username on `url` itself is not
|
||||
/// reported: the caller deletes that account before it saves the new
|
||||
/// one, comparing the names exactly as the Keychain account string
|
||||
/// does. A delete made after the save could take the new password with
|
||||
/// it on a Keychain that treats two spellings of a name as one item.
|
||||
#[must_use = "the returned Keychain accounts must be deleted"]
|
||||
pub fn remember(&mut self, url: &str, username: Option<&str>, saved: bool) -> Vec<(String, String)> {
|
||||
self.servers.retain(|s| s.url != url);
|
||||
self.servers.insert(
|
||||
0,
|
||||
ServerEntry {
|
||||
url: url.to_string(),
|
||||
username: username.map(str::to_string),
|
||||
has_saved_password: saved,
|
||||
},
|
||||
);
|
||||
let evicted = self.servers.split_off(self.servers.len().min(MAX_SERVERS));
|
||||
evicted.into_iter().filter_map(|s| Some((s.url, s.username?))).collect()
|
||||
}
|
||||
|
||||
pub fn forget(&mut self, url: &str) -> Option<ServerEntry> {
|
||||
let idx = self.servers.iter().position(|s| s.url == url)?;
|
||||
Some(self.servers.remove(idx))
|
||||
}
|
||||
|
||||
pub fn find(&self, url: &str) -> Option<&ServerEntry> {
|
||||
self.servers.iter().find(|s| s.url == url)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tmp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("codeman-desktop-test-{}-{name}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
fn frame(x: f64, y: f64, width: f64, height: f64) -> WindowFrame {
|
||||
WindowFrame {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
fullscreen: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn rect(x: f64, y: f64, width: f64, height: f64) -> Rect {
|
||||
Rect { x, y, width, height }
|
||||
}
|
||||
|
||||
const MIN: (f64, f64) = (MIN_WIDTH, MIN_HEIGHT);
|
||||
|
||||
fn reachable(f: &WindowFrame, screens: &[Rect]) -> bool {
|
||||
f.title_screen(screens).is_some()
|
||||
}
|
||||
|
||||
/// The outer frame (title bar included) as left, top, right, bottom.
|
||||
fn outer(f: &WindowFrame) -> (f64, f64, f64, f64) {
|
||||
let top = f.y - TITLE_ABOVE_Y;
|
||||
(f.x, top, f.x + f.width, top + TITLE_BAR + f.height)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_is_default() {
|
||||
let dir = tmp_dir("missing");
|
||||
let (c, can_save) = Config::load_from(&dir.join("desktop.json"));
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(c.auto_connect);
|
||||
assert!(can_save);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_and_atomic_save() {
|
||||
let dir = tmp_dir("roundtrip");
|
||||
let path = dir.join("nested").join("desktop.json");
|
||||
let mut c = Config::default();
|
||||
assert!(c.remember("http://127.0.0.1:3000/", None, false).is_empty());
|
||||
assert!(c.remember("https://box.ts.net/", Some("admin"), true).is_empty());
|
||||
c.start_fullscreen = true;
|
||||
c.window = Some(WindowFrame {
|
||||
x: 10.0,
|
||||
y: 20.0,
|
||||
width: 1200.0,
|
||||
height: 800.0,
|
||||
fullscreen: true,
|
||||
});
|
||||
c.save_to(&path).unwrap();
|
||||
assert!(!path.with_extension("json.tmp").exists());
|
||||
let (back, can_save) = Config::load_from(&path);
|
||||
assert!(can_save);
|
||||
assert_eq!(back, c);
|
||||
assert_eq!(back.servers[0].url, "https://box.ts.net/");
|
||||
let text = fs::read_to_string(&path).unwrap();
|
||||
assert!(
|
||||
!text.to_lowercase().contains("password\":\""),
|
||||
"no password values on disk"
|
||||
);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_file_is_moved_aside() {
|
||||
let dir = tmp_dir("corrupt");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("desktop.json");
|
||||
fs::write(&path, "{ not json").unwrap();
|
||||
let (c, can_save) = Config::load_from(&path);
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(can_save);
|
||||
assert!(!path.exists());
|
||||
assert!(dir.join("desktop.json.corrupt").exists());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_utf8_file_is_moved_aside() {
|
||||
let dir = tmp_dir("non-utf8");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("desktop.json");
|
||||
// Valid JSON apart from a Latin-1 "ü", which is not UTF-8.
|
||||
let bytes = b"{\"servers\":[{\"url\":\"http://a/\",\"username\":\"J\xfcrg\"}]}";
|
||||
fs::write(&path, bytes).unwrap();
|
||||
let (c, can_save) = Config::load_from(&path);
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(can_save);
|
||||
assert!(!path.exists());
|
||||
assert_eq!(fs::read(dir.join("desktop.json.corrupt")).unwrap(), bytes);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_path_is_moved_aside() {
|
||||
let dir = tmp_dir("unreadable");
|
||||
let path = dir.join("desktop.json");
|
||||
// A directory where the file should be: it exists but cannot be read.
|
||||
fs::create_dir_all(path.join("inside")).unwrap();
|
||||
let (c, can_save) = Config::load_from(&path);
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(can_save);
|
||||
assert!(!path.exists());
|
||||
assert!(dir.join("desktop.json.corrupt").join("inside").is_dir());
|
||||
c.save_to(&path).unwrap();
|
||||
assert_eq!(Config::load_from(&path), (Config::default(), true));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_corruption_keeps_the_first_copy() {
|
||||
let dir = tmp_dir("corrupt-twice");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("desktop.json");
|
||||
fs::write(&path, "{ first").unwrap();
|
||||
assert_eq!(Config::load_from(&path), (Config::default(), true));
|
||||
fs::write(&path, "{ second").unwrap();
|
||||
assert_eq!(Config::load_from(&path), (Config::default(), true));
|
||||
assert!(!path.exists());
|
||||
assert_eq!(fs::read_to_string(dir.join("desktop.json.corrupt")).unwrap(), "{ first");
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.join("desktop.json.corrupt.1")).unwrap(),
|
||||
"{ second"
|
||||
);
|
||||
// A good file written afterwards loads normally and moves nothing.
|
||||
Config::default().save_to(&path).unwrap();
|
||||
assert_eq!(Config::load_from(&path), (Config::default(), true));
|
||||
assert!(!dir.join("desktop.json.corrupt.2").exists());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taken_aside_names_are_skipped() {
|
||||
let dir = tmp_dir("aside-taken");
|
||||
// A directory left at the first name and a file at the second: the
|
||||
// third is the first free one.
|
||||
fs::create_dir_all(dir.join("desktop.json.corrupt")).unwrap();
|
||||
fs::write(dir.join("desktop.json.corrupt.1"), "kept").unwrap();
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(dir.join("nowhere"), dir.join("desktop.json.corrupt.2")).unwrap();
|
||||
let next = if cfg!(unix) {
|
||||
"desktop.json.corrupt.3"
|
||||
} else {
|
||||
"desktop.json.corrupt.2"
|
||||
};
|
||||
let path = dir.join("desktop.json");
|
||||
fs::write(&path, "{ not json").unwrap();
|
||||
assert_eq!(Config::load_from(&path), (Config::default(), true));
|
||||
assert!(!path.exists());
|
||||
assert!(dir.join("desktop.json.corrupt").is_dir());
|
||||
assert_eq!(fs::read_to_string(dir.join("desktop.json.corrupt.1")).unwrap(), "kept");
|
||||
// The dangling symlink still points nowhere and still counts as taken.
|
||||
#[cfg(unix)]
|
||||
assert!(fs::symlink_metadata(dir.join("desktop.json.corrupt.2"))
|
||||
.unwrap()
|
||||
.is_symlink());
|
||||
assert_eq!(fs::read_to_string(dir.join(next)).unwrap(), "{ not json");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_free_aside_name_disables_saving() {
|
||||
let dir = tmp_dir("aside-full");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("desktop.json");
|
||||
let names: Vec<PathBuf> = std::iter::once(dir.join("desktop.json.corrupt"))
|
||||
.chain((1..ASIDE_SLOTS).map(|n| dir.join(format!("desktop.json.corrupt.{n}"))))
|
||||
.collect();
|
||||
assert_eq!(names.len(), 10);
|
||||
for (n, name) in names.iter().enumerate() {
|
||||
fs::write(name, format!("copy {n}")).unwrap();
|
||||
}
|
||||
fs::write(&path, "{ not json").unwrap();
|
||||
assert_eq!(Config::load_from(&path), (Config::default(), false));
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "{ not json");
|
||||
for (n, name) in names.iter().enumerate() {
|
||||
assert_eq!(fs::read_to_string(name).unwrap(), format!("copy {n}"));
|
||||
}
|
||||
assert!(!dir.join("desktop.json.corrupt.10").exists());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn failed_move_aside_disables_saving() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tmp_dir("stuck");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("desktop.json");
|
||||
fs::write(&path, "{ not json").unwrap();
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
|
||||
// Root ignores the read-only directory, so the rename cannot fail.
|
||||
if fs::write(dir.join("probe"), "").is_ok() {
|
||||
eprintln!("skipped: the directory is still writable (running as root?)");
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
return;
|
||||
}
|
||||
let (c, can_save) = Config::load_from(&path);
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(!can_save);
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "{ not json");
|
||||
assert!(!dir.join("desktop.json.corrupt").exists());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_list_is_deduped_and_capped() {
|
||||
let mut c = Config::default();
|
||||
for i in 0..12 {
|
||||
assert!(c.remember(&format!("http://h{i}/"), None, false).is_empty());
|
||||
}
|
||||
assert!(c.remember("http://h5/", Some("me"), false).is_empty());
|
||||
assert_eq!(c.servers.len(), MAX_SERVERS);
|
||||
assert_eq!(c.servers[0].url, "http://h5/");
|
||||
assert_eq!(c.servers.iter().filter(|s| s.url == "http://h5/").count(), 1);
|
||||
assert_eq!(c.forget("http://h5/").unwrap().username.as_deref(), Some("me"));
|
||||
assert!(c.find("http://h5/").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remember_leaves_a_username_change_to_the_caller() {
|
||||
let url = "https://box.ts.net/";
|
||||
let mut c = Config::default();
|
||||
assert!(c.remember(url, Some("Alice"), true).is_empty());
|
||||
// Every change of the username on the same server, a case-only one,
|
||||
// another user, or none at all, returns nothing: the caller deletes
|
||||
// the old account before it saves the new one.
|
||||
for (user, saved) in [
|
||||
(Some("alice"), true),
|
||||
(Some(" alice "), true),
|
||||
(Some("bob"), true),
|
||||
(None, false),
|
||||
(None, false),
|
||||
(Some("dave"), true),
|
||||
] {
|
||||
assert!(c.remember(url, user, saved).is_empty(), "{user:?}");
|
||||
let entry = c.find(url).unwrap();
|
||||
assert_eq!((entry.username.as_deref(), entry.has_saved_password), (user, saved));
|
||||
assert_eq!(c.servers.iter().filter(|s| s.url == url).count(), 1);
|
||||
}
|
||||
assert!(c.remember("http://other/", Some("carol"), true).is_empty());
|
||||
assert!(c.remember(url, Some("erin"), true).is_empty());
|
||||
assert_eq!(c.servers.len(), 2);
|
||||
assert_eq!(c.servers[0].url, url);
|
||||
assert_eq!(c.find("http://other/").unwrap().username.as_deref(), Some("carol"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remember_returns_evicted_accounts() {
|
||||
let mut c = Config::default();
|
||||
for i in 0..MAX_SERVERS {
|
||||
let user = (i % 2 == 0).then(|| format!("u{i}"));
|
||||
assert!(c.remember(&format!("http://h{i}/"), user.as_deref(), true).is_empty());
|
||||
}
|
||||
// Moving a listed server to the top evicts nothing.
|
||||
assert!(c.remember("http://h3/", None, false).is_empty());
|
||||
assert_eq!(c.servers.len(), MAX_SERVERS);
|
||||
// A new server pushes the oldest (h0, with a username) off the end.
|
||||
assert_eq!(
|
||||
c.remember("http://new/", None, false),
|
||||
vec![("http://h0/".to_string(), "u0".to_string())]
|
||||
);
|
||||
// The next oldest is h1, which has no username: nothing to delete.
|
||||
assert!(c.remember("http://newer/", Some("x"), true).is_empty());
|
||||
assert_eq!(c.servers.len(), MAX_SERVERS);
|
||||
assert!(c.find("http://h0/").is_none() && c.find("http://h1/").is_none());
|
||||
// A username change on a listed server returns nothing; the next new
|
||||
// server evicts the oldest entry, and only that account comes back.
|
||||
let mut c = Config::default();
|
||||
for i in 0..MAX_SERVERS {
|
||||
assert!(c.remember(&format!("http://h{i}/"), Some("u"), true).is_empty());
|
||||
}
|
||||
assert!(c.remember("http://h7/", Some("v"), true).is_empty());
|
||||
assert_eq!(
|
||||
c.remember("http://h8/", Some("w"), true),
|
||||
vec![("http://h0/".to_string(), "u".to_string())]
|
||||
);
|
||||
assert_eq!(c.find("http://h7/").unwrap().username.as_deref(), Some("v"));
|
||||
assert_eq!(c.servers.len(), MAX_SERVERS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_and_missing_fields_tolerated() {
|
||||
let c: Config = serde_json::from_str(r#"{"servers":[{"url":"http://a/"}],"future":1}"#).unwrap();
|
||||
assert!(c.auto_connect);
|
||||
assert_eq!(c.servers[0].username, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_sanitizing_and_reachability() {
|
||||
assert!(frame(f64::NAN, 0.0, 800.0, 600.0).sanitized().is_none());
|
||||
let small = frame(0.0, 0.0, 10.0, 10.0).sanitized().unwrap();
|
||||
assert_eq!((small.width, small.height), (MIN_WIDTH, MIN_HEIGHT));
|
||||
let screen = [rect(0.0, 0.0, 1512.0, 982.0)];
|
||||
let on = frame(100.0, 50.0, 1200.0, 800.0);
|
||||
assert!(reachable(&on, &screen));
|
||||
assert_eq!(on.fitted_to(&screen, MIN), Some(on));
|
||||
let off = frame(3000.0, 50.0, 1200.0, 800.0);
|
||||
assert!(!reachable(&off, &screen));
|
||||
assert_eq!(off.fitted_to(&screen, MIN), None);
|
||||
let above = frame(100.0, -900.0, 1200.0, 800.0);
|
||||
assert!(!reachable(&above, &screen));
|
||||
assert_eq!(above.fitted_to(&screen, MIN), None);
|
||||
// Only a sliver of the title bar on screen is not enough to grab.
|
||||
let sliver = frame(-1150.0, 50.0, 1200.0, 800.0);
|
||||
assert!(!reachable(&sliver, &screen));
|
||||
assert_eq!(sliver.fitted_to(&screen, MIN), None);
|
||||
// No screens at all (none reported): nothing is reachable.
|
||||
assert_eq!(on.fitted_to(&[], MIN), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_bar_is_above_y_on_macos() {
|
||||
let screen = [rect(0.0, 0.0, 1512.0, 982.0)];
|
||||
// On macOS the title bar of a content top at y = 10 is at -18..10,
|
||||
// mostly above the screen; elsewhere it is at 10..38.
|
||||
let high = frame(100.0, 10.0, 1200.0, 800.0);
|
||||
assert_eq!(reachable(&high, &screen), !cfg!(target_os = "macos"));
|
||||
// Half the bar above the top edge: still grabbable, and fitting
|
||||
// brings all of it onto the screen.
|
||||
let straddling = frame(100.0, TITLE_ABOVE_Y - 10.0, 1200.0, 800.0);
|
||||
assert!(reachable(&straddling, &screen));
|
||||
let fitted = straddling.fitted_to(&screen, MIN).unwrap();
|
||||
assert_eq!(outer(&fitted).1, 0.0);
|
||||
assert_eq!((fitted.x, fitted.width, fitted.height), (100.0, 1200.0, 800.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_from_an_unplugged_ultrawide_is_fitted() {
|
||||
// Saved on a 3440x1440 display at the origin, restored on a laptop.
|
||||
let laptop = [rect(0.0, 0.0, 1512.0, 982.0)];
|
||||
let wide = frame(0.0, 30.0, 3300.0, 1300.0);
|
||||
let fitted = wide.fitted_to(&laptop, MIN).unwrap();
|
||||
assert_eq!(
|
||||
(fitted.x, fitted.y, fitted.width, fitted.height),
|
||||
(0.0, 30.0, 1512.0 - FIT_MARGIN_X, 982.0 - FIT_MARGIN_Y)
|
||||
);
|
||||
let (left, top, right, bottom) = outer(&fitted);
|
||||
assert!(left >= 0.0 && top >= 0.0 && right <= 1512.0 && bottom <= 982.0);
|
||||
// Hanging off the right of the laptop by a bit: shifted, not shrunk.
|
||||
let fitted = frame(500.0, 100.0, 1200.0, 700.0).fitted_to(&laptop, MIN).unwrap();
|
||||
assert_eq!(
|
||||
(fitted.x, fitted.y, fitted.width, fitted.height),
|
||||
(312.0, 100.0, 1200.0, 700.0)
|
||||
);
|
||||
// Hanging off the left: shifted right.
|
||||
let fitted = frame(-1000.0, 100.0, 1200.0, 700.0).fitted_to(&laptop, MIN).unwrap();
|
||||
assert_eq!((fitted.x, fitted.width), (0.0, 1200.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_spanning_two_displays_is_left_alone() {
|
||||
let screens = [rect(0.0, 0.0, 1512.0, 982.0), rect(1512.0, -200.0, 2560.0, 1440.0)];
|
||||
let span = frame(1000.0, 100.0, 1500.0, 700.0);
|
||||
assert_eq!(span.fitted_to(&screens, MIN), Some(span));
|
||||
// Only the edge that lands on no display moves: the left edge stays
|
||||
// on the laptop, the right edge comes back onto the external one.
|
||||
let past = frame(1000.0, 100.0, 3500.0, 700.0);
|
||||
let fitted = past.fitted_to(&screens, MIN).unwrap();
|
||||
assert_eq!(fitted.x, 1000.0);
|
||||
assert_eq!(fitted.x + fitted.width, 1512.0 + 2560.0 - FIT_MARGIN_X);
|
||||
assert_eq!((fitted.y, fitted.height), (100.0, 700.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitted_frame_keeps_the_minimum_size() {
|
||||
let tiny = [rect(0.0, 0.0, 400.0, 300.0)];
|
||||
let fitted = frame(0.0, 40.0, 1200.0, 800.0).fitted_to(&tiny, MIN).unwrap();
|
||||
assert_eq!((fitted.width, fitted.height), MIN);
|
||||
// Too big for the screen even at the minimum: the left edge and the
|
||||
// title bar stay on it.
|
||||
let (left, top, _, _) = outer(&fitted);
|
||||
assert_eq!((left, top), (0.0, 0.0));
|
||||
assert!(reachable(&fitted, &tiny));
|
||||
// The fullscreen flag rides along.
|
||||
let full = WindowFrame {
|
||||
fullscreen: true,
|
||||
..frame(0.0, 40.0, 1200.0, 800.0)
|
||||
};
|
||||
assert!(full.fitted_to(&tiny, MIN).unwrap().fullscreen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
//! Saved passwords, in the macOS Keychain (service "Codeman Desktop", one item
|
||||
//! per username + server). Nothing secret is ever written to the config file.
|
||||
//! Windows and Linux get their stores when those builds land.
|
||||
//!
|
||||
//! Every Keychain call runs on ONE long-lived worker thread, never on the
|
||||
//! event loop: any of them can bring up the system's access prompt (an
|
||||
//! ad-hoc build's items name the previous build), and the window must keep
|
||||
//! running while it is up. One serial worker rather than a thread per call,
|
||||
//! because jobs must run in the order they were asked for: a delete from
|
||||
//! Forget that ran after a later save of the same account would remove the
|
||||
//! password that was just saved.
|
||||
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::thread::{self, JoinHandle};
|
||||
|
||||
pub const SERVICE: &str = "Codeman Desktop";
|
||||
|
||||
/// What a job reports when the worker thread is gone.
|
||||
const WORKER_GONE: &str = "The Keychain could not be reached.";
|
||||
|
||||
/// The Keychain account name for a username on a server.
|
||||
pub fn account(server_url: &str, username: &str) -> String {
|
||||
format!("{username} @ {server_url}")
|
||||
}
|
||||
|
||||
/// The password store the worker runs jobs against: the Keychain on macOS,
|
||||
/// a stub elsewhere, an in-memory map in the tests.
|
||||
trait Backend: Send {
|
||||
fn load(&mut self, account: &str) -> Option<String>;
|
||||
fn save(&mut self, account: &str, password: &str) -> Result<(), String>;
|
||||
fn delete(&mut self, account: &str);
|
||||
}
|
||||
|
||||
enum Job {
|
||||
Load {
|
||||
account: String,
|
||||
reply: Sender<Option<String>>,
|
||||
},
|
||||
Save {
|
||||
account: String,
|
||||
password: String,
|
||||
reply: Sender<Result<(), String>>,
|
||||
},
|
||||
Delete {
|
||||
account: String,
|
||||
},
|
||||
/// Ends the worker, so the tests can check what callers see after it.
|
||||
#[cfg(test)]
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// A handle on the Keychain worker. Start it once with [`Keychain::spawn`]
|
||||
/// and clone the handle: every clone feeds the same queue, which is what
|
||||
/// keeps jobs in order. The worker ends when the last handle is dropped.
|
||||
#[derive(Clone)]
|
||||
pub struct Keychain {
|
||||
jobs: Sender<Job>,
|
||||
}
|
||||
|
||||
impl Keychain {
|
||||
/// Starts the worker. If the thread cannot be started, the handle still
|
||||
/// works and every call fails soft (no password, save refused).
|
||||
pub fn spawn() -> Keychain {
|
||||
start(Box::new(imp::System)).0
|
||||
}
|
||||
|
||||
/// The saved password for an account. Blocks until every job queued
|
||||
/// before it has run and the Keychain has answered, which can mean
|
||||
/// waiting on the access prompt, so call it from a worker thread.
|
||||
/// `None` when nothing is saved, the item is unreadable or the worker
|
||||
/// is gone.
|
||||
pub fn load_blocking(&self, account: &str) -> Option<String> {
|
||||
let (reply, answer) = mpsc::channel();
|
||||
let job = Job::Load {
|
||||
account: account.to_owned(),
|
||||
reply,
|
||||
};
|
||||
self.jobs.send(job).ok()?;
|
||||
answer.recv().ok().flatten()
|
||||
}
|
||||
|
||||
/// Saves a password, replacing any saved before. Blocks like
|
||||
/// [`Keychain::load_blocking`]; the result is the Keychain's own.
|
||||
pub fn save_blocking(&self, account: &str, password: &str) -> Result<(), String> {
|
||||
let (reply, answer) = mpsc::channel();
|
||||
let job = Job::Save {
|
||||
account: account.to_owned(),
|
||||
password: password.to_owned(),
|
||||
reply,
|
||||
};
|
||||
if self.jobs.send(job).is_err() {
|
||||
return Err(WORKER_GONE.into());
|
||||
}
|
||||
answer.recv().unwrap_or_else(|_| Err(WORKER_GONE.into()))
|
||||
}
|
||||
|
||||
/// Queues the removal of an account's password and returns at once.
|
||||
/// Jobs queued after it see the account gone. Nothing happens when the
|
||||
/// worker is gone.
|
||||
pub fn delete(&self, account: &str) {
|
||||
let _ = self.jobs.send(Job::Delete {
|
||||
account: account.to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a worker on `backend`. The join handle is for the tests; `None`
|
||||
/// when the thread could not be started, in which case the receiver went
|
||||
/// down with the closure and every send fails.
|
||||
fn start(backend: Box<dyn Backend>) -> (Keychain, Option<JoinHandle<()>>) {
|
||||
let (jobs, queue) = mpsc::channel();
|
||||
let worker = thread::Builder::new()
|
||||
.name("keychain".into())
|
||||
.spawn(move || run(backend, queue))
|
||||
.ok();
|
||||
(Keychain { jobs }, worker)
|
||||
}
|
||||
|
||||
/// The worker loop: one job at a time, in the order they were sent, until
|
||||
/// every handle is gone. A reply nobody waits for any more is dropped.
|
||||
fn run(mut backend: Box<dyn Backend>, queue: Receiver<Job>) {
|
||||
for job in queue {
|
||||
match job {
|
||||
Job::Load { account, reply } => {
|
||||
let _ = reply.send(backend.load(&account));
|
||||
}
|
||||
Job::Save {
|
||||
account,
|
||||
password,
|
||||
reply,
|
||||
} => {
|
||||
let _ = reply.send(backend.save(&account, &password));
|
||||
}
|
||||
Job::Delete { account } => backend.delete(&account),
|
||||
#[cfg(test)]
|
||||
Job::Stop => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod imp {
|
||||
use super::{Backend, SERVICE};
|
||||
use objc2::rc::autoreleasepool;
|
||||
use security_framework::passwords::{delete_generic_password, get_generic_password, set_generic_password};
|
||||
|
||||
/// The login Keychain, through security-framework. The worker lives as
|
||||
/// long as the app and a thread's implicit autorelease pool drains only
|
||||
/// when the thread ends, so each call gets its own pool and whatever
|
||||
/// Security autoreleases is freed with the job.
|
||||
pub struct System;
|
||||
|
||||
impl Backend for System {
|
||||
fn load(&mut self, account: &str) -> Option<String> {
|
||||
autoreleasepool(|_| {
|
||||
get_generic_password(SERVICE, account)
|
||||
.ok()
|
||||
.and_then(|b| String::from_utf8(b).ok())
|
||||
})
|
||||
}
|
||||
|
||||
fn save(&mut self, account: &str, password: &str) -> Result<(), String> {
|
||||
autoreleasepool(|_| set_generic_password(SERVICE, account, password.as_bytes()).map_err(|e| e.to_string()))
|
||||
}
|
||||
|
||||
fn delete(&mut self, account: &str) {
|
||||
autoreleasepool(|_| {
|
||||
let _ = delete_generic_password(SERVICE, account);
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
mod imp {
|
||||
use super::Backend;
|
||||
|
||||
/// No password store yet on this platform.
|
||||
pub struct System;
|
||||
|
||||
impl Backend for System {
|
||||
fn load(&mut self, _account: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn save(&mut self, _account: &str, _password: &str) -> Result<(), String> {
|
||||
Err("Saving passwords is not supported on this platform yet.".into())
|
||||
}
|
||||
fn delete(&mut self, _account: &str) {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{start, Backend, Job, Keychain};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// An in-memory store that logs every job it runs. Its delete is slow on
|
||||
/// purpose: a queue that did not keep order would let the next job
|
||||
/// overtake it, and the tests would see that.
|
||||
#[derive(Clone, Default)]
|
||||
struct Memory {
|
||||
items: Arc<Mutex<HashMap<String, String>>>,
|
||||
log: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl Memory {
|
||||
fn note(&self, entry: String) {
|
||||
self.log.lock().unwrap().push(entry);
|
||||
}
|
||||
fn log(&self) -> Vec<String> {
|
||||
self.log.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Memory {
|
||||
fn load(&mut self, account: &str) -> Option<String> {
|
||||
self.note(format!("load {account}"));
|
||||
self.items.lock().unwrap().get(account).cloned()
|
||||
}
|
||||
fn save(&mut self, account: &str, password: &str) -> Result<(), String> {
|
||||
self.note(format!("save {account}"));
|
||||
self.items
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(account.to_owned(), password.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
fn delete(&mut self, account: &str) {
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
self.note(format!("delete {account}"));
|
||||
self.items.lock().unwrap().remove(account);
|
||||
}
|
||||
}
|
||||
|
||||
/// A store whose load fails the way a crashed worker would.
|
||||
struct Crashes;
|
||||
|
||||
impl Backend for Crashes {
|
||||
fn load(&mut self, _account: &str) -> Option<String> {
|
||||
panic!("keychain worker crashed");
|
||||
}
|
||||
fn save(&mut self, _account: &str, _password: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
fn delete(&mut self, _account: &str) {}
|
||||
}
|
||||
|
||||
fn keychain() -> (Keychain, Memory) {
|
||||
let memory = Memory::default();
|
||||
let (keychain, worker) = start(Box::new(memory.clone()));
|
||||
assert!(worker.is_some(), "the worker thread starts");
|
||||
(keychain, memory)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_names_are_per_user_and_server() {
|
||||
assert_eq!(
|
||||
super::account("http://127.0.0.1:3000/", "admin"),
|
||||
"admin @ http://127.0.0.1:3000/"
|
||||
);
|
||||
assert_ne!(
|
||||
super::account("http://a/", "admin"),
|
||||
super::account("http://b/", "admin")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_load_reads_what_a_save_wrote() {
|
||||
let (keychain, _) = keychain();
|
||||
assert_eq!(keychain.load_blocking("a"), None);
|
||||
assert_eq!(keychain.save_blocking("a", "secret"), Ok(()));
|
||||
assert_eq!(keychain.load_blocking("a").as_deref(), Some("secret"));
|
||||
assert_eq!(keychain.save_blocking("a", "changed"), Ok(()));
|
||||
assert_eq!(keychain.load_blocking("a").as_deref(), Some("changed"));
|
||||
assert_eq!(keychain.load_blocking("b"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delete_queued_before_a_save_never_removes_the_saved_password() {
|
||||
let (keychain, memory) = keychain();
|
||||
assert_eq!(keychain.save_blocking("a", "old"), Ok(()));
|
||||
keychain.delete("a");
|
||||
assert_eq!(keychain.save_blocking("a", "new"), Ok(()));
|
||||
assert_eq!(keychain.load_blocking("a").as_deref(), Some("new"));
|
||||
assert_eq!(memory.log(), ["save a", "delete a", "save a", "load a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delete_queued_after_a_save_removes_it_before_the_next_job() {
|
||||
let (keychain, memory) = keychain();
|
||||
assert_eq!(keychain.save_blocking("a", "secret"), Ok(()));
|
||||
keychain.delete("a");
|
||||
// No wait between the two: the load is queued behind the delete.
|
||||
assert_eq!(keychain.load_blocking("a"), None);
|
||||
assert_eq!(memory.log(), ["save a", "delete a", "load a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jobs_from_clones_on_other_threads_share_one_queue() {
|
||||
let (keychain, memory) = keychain();
|
||||
let other = keychain.clone();
|
||||
std::thread::spawn(move || {
|
||||
other.save_blocking("a", "secret").unwrap();
|
||||
other.delete("a");
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
assert_eq!(keychain.load_blocking("a"), None);
|
||||
assert_eq!(memory.log(), ["save a", "delete a", "load a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_call_fails_soft_once_the_worker_is_gone() {
|
||||
let memory = Memory::default();
|
||||
let (keychain, worker) = start(Box::new(memory.clone()));
|
||||
assert_eq!(keychain.save_blocking("a", "secret"), Ok(()));
|
||||
keychain.jobs.send(Job::Stop).unwrap();
|
||||
worker.unwrap().join().unwrap();
|
||||
|
||||
assert_eq!(keychain.load_blocking("a"), None);
|
||||
assert!(keychain.save_blocking("a", "secret").is_err());
|
||||
keychain.delete("a");
|
||||
keychain.clone().delete("a");
|
||||
assert_eq!(memory.log(), ["save a"]);
|
||||
}
|
||||
|
||||
#[cfg(panic = "unwind")]
|
||||
#[test]
|
||||
fn a_worker_that_dies_mid_job_answers_nothing_and_takes_no_more_jobs() {
|
||||
let (keychain, worker) = start(Box::new(Crashes));
|
||||
assert_eq!(keychain.load_blocking("a"), None);
|
||||
assert!(worker.unwrap().join().is_err(), "the worker panicked");
|
||||
assert!(keychain.save_blocking("a", "secret").is_err());
|
||||
keychain.delete("a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_handle_is_send_and_sync() {
|
||||
fn send_sync_clone<T: Send + Sync + Clone + 'static>() {}
|
||||
send_sync_clone::<Keychain>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
//! "Start Codeman on this Mac": `codeman web -d` through the user's login shell.
|
||||
//!
|
||||
//! A double-clicked app gets launchd's bare PATH (`/usr/bin:/bin:...`), which
|
||||
//! finds neither `codeman` nor `node`, and the server inherits whatever
|
||||
//! environment it is started with: every session it spawns looks for
|
||||
//! claude/codex/tmux on that PATH and reads provider keys from it. So the
|
||||
//! command runs with the login shell's environment, picking up Homebrew and the
|
||||
//! user's profile, and falls back to the interactive profile (`-i`) for setups
|
||||
//! that only extend PATH there (nvm in `.zshrc`). The URL's port and base path
|
||||
//! are passed as separate arguments (`"$@"`), never spliced into the script text.
|
||||
//!
|
||||
//! How depends on the shell, chosen by its basename as Codeman's own
|
||||
//! `src/utils/shell-resolver.ts` does:
|
||||
//! - A POSIX shell (zsh, bash, sh, ...) runs `SCRIPT` itself:
|
||||
//! `$SHELL -l [-i] -c SCRIPT codeman-desktop <args>`.
|
||||
//! - Any other shell (fish, nu, xonsh, elvish, pwsh, tcsh) cannot parse
|
||||
//! `SCRIPT`: fish has no `then`/`fi` and refuses `$@`. It is only asked to
|
||||
//! print its environment between two markers, and `SCRIPT` then runs under
|
||||
//! `/bin/sh` with exactly that environment.
|
||||
//!
|
||||
//! `codeman web -d` refuses to start a second server on the same data dir and
|
||||
//! returns once the new one answers `/api/status`, so this is safe to call
|
||||
//! only after the probe found nothing listening.
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
use url::Url;
|
||||
|
||||
use crate::policy;
|
||||
|
||||
/// For the whole start, every shell run and retry included.
|
||||
const TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// How long the pipes may stay open after the shell exited. Something the
|
||||
/// profile started in the background can inherit them and keep them open for
|
||||
/// as long as it runs; whatever arrived by then is used.
|
||||
const PIPE_GRACE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Found on PATH first, then the `install.sh` clone at `~/.codeman/app`.
|
||||
const SCRIPT: &str = r#"if command -v codeman >/dev/null 2>&1; then exec codeman "$@"; fi
|
||||
if [ -f "$HOME/.codeman/app/dist/index.js" ] && command -v node >/dev/null 2>&1; then
|
||||
exec node "$HOME/.codeman/app/dist/index.js" "$@"
|
||||
fi
|
||||
echo "Could not find the codeman command or ~/.codeman/app. Install Codeman first: https://github.com/Ark0N/Codeman#install" >&2
|
||||
exit 127"#;
|
||||
|
||||
/// Bracket the environment a non-POSIX login shell prints, so whatever its
|
||||
/// profile writes to stdout before or after cannot be read as a variable.
|
||||
const ENV_BEGIN: &str = "__CODEMAN_DESKTOP_ENV_BEGIN__";
|
||||
const ENV_END: &str = "__CODEMAN_DESKTOP_ENV_END__";
|
||||
|
||||
/// What a non-POSIX login shell runs to report its environment. The shell
|
||||
/// itself only parses a command path and one single-quoted word with no `;`,
|
||||
/// `$`, backslash or `!` in it, so fish, nu, xonsh, elvish, pwsh and tcsh have
|
||||
/// no syntax of their own to disagree on. The markers and `env` run under
|
||||
/// `/bin/sh`, which inherits everything the profile exported.
|
||||
fn capture_command() -> String {
|
||||
format!("/bin/sh -c 'echo {ENV_BEGIN}; /usr/bin/env; echo {ENV_END}'")
|
||||
}
|
||||
|
||||
fn login_shell() -> PathBuf {
|
||||
std::env::var_os("SHELL")
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.is_absolute() && p.exists())
|
||||
.unwrap_or_else(|| PathBuf::from("/bin/zsh"))
|
||||
}
|
||||
|
||||
/// How the login shell takes part in a start.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Flavor {
|
||||
/// Runs `SCRIPT` itself.
|
||||
Posix,
|
||||
/// Only reports its environment. One entry per attempt, in order: a later
|
||||
/// one runs when the earlier one printed no environment or its environment
|
||||
/// had no `codeman` (exit 127).
|
||||
Capture(&'static [&'static [&'static str]]),
|
||||
}
|
||||
|
||||
fn flavor(shell: &Path) -> Flavor {
|
||||
match shell.file_name().and_then(|n| n.to_str()).unwrap_or("") {
|
||||
"sh" | "bash" | "zsh" | "dash" | "ksh" | "ash" | "ksh93" | "mksh" | "pdksh" => Flavor::Posix,
|
||||
// tcsh honors -l only when it is the ONLY flag, so a login shell that
|
||||
// also runs a command is impossible; -i at least reads all of .tcshrc
|
||||
// (csh: .cshrc), including the parts guarded by `$?prompt`.
|
||||
"csh" | "tcsh" => Flavor::Capture(&[&["-ic"]]),
|
||||
_ => Flavor::Capture(&[&["-l", "-c"], &["-l", "-i", "-c"]]),
|
||||
}
|
||||
}
|
||||
|
||||
/// What one process printed, stdout and stderr kept apart.
|
||||
struct Output {
|
||||
code: Option<i32>,
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
}
|
||||
|
||||
/// What `codeman` (or `SCRIPT` failing to find it) said, for the connect page.
|
||||
struct Run {
|
||||
code: Option<i32>,
|
||||
output: String,
|
||||
}
|
||||
|
||||
impl From<Output> for Run {
|
||||
fn from(out: Output) -> Self {
|
||||
let mut output = out.stdout;
|
||||
if !out.stderr.trim().is_empty() {
|
||||
output.push_str(&out.stderr);
|
||||
}
|
||||
Run {
|
||||
code: out.code,
|
||||
output: last_lines(&output, 6),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy `pipe` into the channel as it arrives, tagged with `stream`, then send
|
||||
/// `None` once it closes.
|
||||
fn pump<R: Read + Send + 'static>(pipe: Option<R>, stream: usize, tx: mpsc::Sender<(usize, Option<Vec<u8>>)>) {
|
||||
std::thread::spawn(move || {
|
||||
if let Some(mut pipe) = pipe {
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
match pipe.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
if tx.send((stream, Some(buf[..n].to_vec()))).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = tx.send((stream, None));
|
||||
});
|
||||
}
|
||||
|
||||
/// Run `cmd` to completion with stdin closed, or kill it at `deadline`.
|
||||
fn exec(mut cmd: Command, deadline: Instant) -> Result<Output, String> {
|
||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Could not run {}: {e}", Path::new(cmd.get_program()).display()))?;
|
||||
|
||||
// Read both pipes on threads so a chatty profile cannot fill one and stall.
|
||||
let (tx, rx) = mpsc::channel();
|
||||
pump(child.stdout.take(), 0, tx.clone());
|
||||
pump(child.stderr.take(), 1, tx);
|
||||
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break status,
|
||||
Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(100)),
|
||||
Ok(None) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err("Starting Codeman took longer than a minute; it was stopped.".into());
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
let mut bufs = [Vec::new(), Vec::new()];
|
||||
let settle = (Instant::now() + PIPE_GRACE).min(deadline);
|
||||
let mut open = 2;
|
||||
while open > 0 {
|
||||
match rx.recv_timeout(settle.saturating_duration_since(Instant::now())) {
|
||||
Ok((stream, Some(bytes))) => bufs[stream].extend_from_slice(&bytes),
|
||||
Ok((_, None)) => open -= 1,
|
||||
Err(_) => {
|
||||
// Still held open past the grace: keep what already arrived.
|
||||
for (stream, bytes) in rx.try_iter() {
|
||||
if let Some(bytes) = bytes {
|
||||
bufs[stream].extend_from_slice(&bytes);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Lossy rather than strict: one stray non-UTF-8 byte in a profile's output
|
||||
// must not cost the whole environment.
|
||||
let [out, err] = bufs;
|
||||
Ok(Output {
|
||||
code: status.code(),
|
||||
stdout: String::from_utf8_lossy(&out).into_owned(),
|
||||
stderr: String::from_utf8_lossy(&err).into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The tail of what the command printed, for the connect page.
|
||||
fn last_lines(text: &str, n: usize) -> String {
|
||||
let lines: Vec<&str> = text
|
||||
.lines()
|
||||
.map(str::trim_end)
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.collect();
|
||||
lines[lines.len().saturating_sub(n)..].join("\n")
|
||||
}
|
||||
|
||||
/// The variables `env` printed between the markers, or None when the output
|
||||
/// does not hold both. A line that does not start with a portable variable
|
||||
/// name followed by `=` continues the previous value (a value with a newline
|
||||
/// in it); one that does starts the next variable.
|
||||
fn parse_env(output: &str) -> Option<Vec<(String, String)>> {
|
||||
let mut lines = output.split('\n');
|
||||
// ends_with: a profile line printed without its newline sits in front.
|
||||
lines.by_ref().find(|l| l.trim_end().ends_with(ENV_BEGIN))?;
|
||||
let mut vars: Vec<(String, String)> = Vec::new();
|
||||
for line in lines {
|
||||
if line.trim() == ENV_END {
|
||||
return Some(vars);
|
||||
}
|
||||
match env_entry(line) {
|
||||
Some((name, value)) => vars.push((name.to_string(), value.to_string())),
|
||||
None => {
|
||||
if let Some((_, value)) = vars.last_mut() {
|
||||
value.push('\n');
|
||||
value.push_str(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn env_entry(line: &str) -> Option<(&str, &str)> {
|
||||
let (name, value) = line.split_once('=')?;
|
||||
let mut chars = name.chars();
|
||||
let first = chars.next()?;
|
||||
let portable =
|
||||
(first.is_ascii_alphabetic() || first == '_') && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
|
||||
portable.then_some((name, value))
|
||||
}
|
||||
|
||||
/// Ask `shell` for its environment with `flags`. The outer error ends the
|
||||
/// start (the shell could not be run, or time is up); the inner one means this
|
||||
/// form printed no environment and the next may be tried.
|
||||
fn capture_env(
|
||||
shell: &Path,
|
||||
flags: &[&str],
|
||||
deadline: Instant,
|
||||
) -> Result<Result<Vec<(String, String)>, String>, String> {
|
||||
let mut cmd = Command::new(shell);
|
||||
cmd.args(flags).arg(capture_command());
|
||||
let out = exec(cmd, deadline)?;
|
||||
Ok(parse_env(&out.stdout).ok_or_else(|| {
|
||||
// stderr only: stdout can hold part of the environment, API keys included.
|
||||
let said = last_lines(&out.stderr, 6);
|
||||
let mut message = format!("Could not read the environment of your login shell {}", shell.display());
|
||||
if said.is_empty() {
|
||||
let code = out
|
||||
.code
|
||||
.map_or_else(|| "a signal".to_string(), |c| format!("exit code {c}"));
|
||||
message.push_str(&format!(" (it ended with {code})."));
|
||||
} else {
|
||||
message.push_str(":\n");
|
||||
message.push_str(&said);
|
||||
}
|
||||
message
|
||||
}))
|
||||
}
|
||||
|
||||
/// `script` (SCRIPT or FIND_SCRIPT) under `/bin/sh` with exactly the captured
|
||||
/// environment, so the server gets the profile's PATH, API keys and CODEMAN_*
|
||||
/// settings and nothing of launchd's bare one.
|
||||
fn run_with_env(script: &str, env: &[(String, String)], args: &[String], deadline: Instant) -> Result<Run, String> {
|
||||
let mut cmd = Command::new("/bin/sh");
|
||||
cmd.arg("-c").arg(script).arg("codeman-desktop").args(args);
|
||||
cmd.env_clear().envs(env.iter().map(|(k, v)| (k, v)));
|
||||
exec(cmd, deadline).map(Run::from)
|
||||
}
|
||||
|
||||
/// `script` run by the POSIX login shell itself.
|
||||
fn run_in_shell(
|
||||
script: &str,
|
||||
shell: &Path,
|
||||
interactive: bool,
|
||||
args: &[String],
|
||||
deadline: Instant,
|
||||
) -> Result<Run, String> {
|
||||
let mut cmd = Command::new(shell);
|
||||
cmd.arg("-l");
|
||||
if interactive {
|
||||
cmd.arg("-i");
|
||||
}
|
||||
cmd.arg("-c").arg(script).arg("codeman-desktop").args(args);
|
||||
exec(cmd, deadline).map(Run::from)
|
||||
}
|
||||
|
||||
/// Run `script` the way the login shell allows (see the module docs). Exit
|
||||
/// 127 means "no codeman here" and moves on to the next way of asking; the
|
||||
/// interactive profile is asked only when `interactive` allows it.
|
||||
fn launch(script: &str, shell: &Path, args: &[String], deadline: Instant, interactive: bool) -> Result<Run, String> {
|
||||
let attempts = match flavor(shell) {
|
||||
Flavor::Posix => {
|
||||
let result = run_in_shell(script, shell, false, args, deadline)?;
|
||||
if result.code != Some(127) || !interactive {
|
||||
return Ok(result);
|
||||
}
|
||||
// Not on the login PATH; try the interactive profile too.
|
||||
return run_in_shell(script, shell, true, args, deadline);
|
||||
}
|
||||
Flavor::Capture(attempts) => attempts,
|
||||
};
|
||||
let mut last_run = None;
|
||||
let mut capture_error = None;
|
||||
let interactive_flag = |flags: &&&[&str]| {
|
||||
flags
|
||||
.iter()
|
||||
.any(|f| f.starts_with('-') && !f.starts_with("--") && f.contains('i'))
|
||||
};
|
||||
for flags in attempts.iter().filter(|f| interactive || !interactive_flag(f)) {
|
||||
match capture_env(shell, flags, deadline)? {
|
||||
Ok(env) => {
|
||||
let result = run_with_env(script, &env, args, deadline)?;
|
||||
if result.code != Some(127) {
|
||||
return Ok(result);
|
||||
}
|
||||
last_run = Some(result);
|
||||
}
|
||||
Err(e) => capture_error = Some(e),
|
||||
}
|
||||
}
|
||||
if let Some(result) = last_run {
|
||||
return Ok(result);
|
||||
}
|
||||
// No form printed an environment: a shell that knows neither -l nor -i
|
||||
// may still run a bare -c.
|
||||
match capture_env(shell, &["-c"], deadline)? {
|
||||
Ok(env) => run_with_env(script, &env, args, deadline),
|
||||
Err(e) => Err(capture_error.unwrap_or(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Exit 0 when SCRIPT would find Codeman (the `codeman` command or the
|
||||
/// `install.sh` clone), 127 when it would not. Same lookup, nothing started.
|
||||
const FIND_SCRIPT: &str = r#"if command -v codeman >/dev/null 2>&1; then exit 0; fi
|
||||
if [ -f "$HOME/.codeman/app/dist/index.js" ] && command -v node >/dev/null 2>&1; then exit 0; fi
|
||||
exit 127"#;
|
||||
|
||||
/// How long the This Mac tab waits for the login shell to answer whether
|
||||
/// Codeman is installed.
|
||||
const FIND_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// `installed` with the shell given, so tests need not touch `$SHELL`.
|
||||
fn installed_with(shell: &Path, interactive: bool) -> bool {
|
||||
matches!(
|
||||
launch(FIND_SCRIPT, shell, &[], Instant::now() + FIND_TIMEOUT, interactive),
|
||||
Ok(Run { code: Some(0), .. })
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether "Start Codeman on this Mac" would find Codeman: the `codeman`
|
||||
/// command on the login shell's PATH, or `~/.codeman/app` with `node`. Runs
|
||||
/// the login shell, so never on the event loop. `interactive` also asks the
|
||||
/// interactive profile (nvm in `.zshrc`, say), whose side effects only a
|
||||
/// user's own click should set off.
|
||||
pub fn installed(interactive: bool) -> bool {
|
||||
installed_with(&login_shell(), interactive)
|
||||
}
|
||||
|
||||
/// `start` with the shell given, so tests need not touch `$SHELL`.
|
||||
fn start_with(shell: &Path, args: &[String]) -> Result<String, String> {
|
||||
let result = launch(SCRIPT, shell, args, Instant::now() + TIMEOUT, true)?;
|
||||
match result.code {
|
||||
Some(0) => Ok(result.output),
|
||||
_ if result.output.is_empty() => Err("codeman web -d failed without saying why.".into()),
|
||||
_ => Err(result.output),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a detached server for `url`. Ok carries what `codeman` printed.
|
||||
pub fn start(url: &Url) -> Result<String, String> {
|
||||
if !policy::can_start_locally(url) {
|
||||
return Err("Codeman can only be started here for http://127.0.0.1 or http://localhost on this Mac.".into());
|
||||
}
|
||||
start_with(&login_shell(), &policy::start_args(url))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// One spawning test at a time. A test writes a fake executable and then
|
||||
/// runs it; a process forked by a parallel test in between can still hold
|
||||
/// the write descriptor, and the exec then fails with ETXTBSY.
|
||||
fn serial() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_of_output() {
|
||||
assert_eq!(last_lines("a\n\nb\nc\n", 2), "b\nc");
|
||||
assert_eq!(last_lines("", 3), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_non_local_or_https() {
|
||||
assert!(start(&Url::parse("https://127.0.0.1:3000/").unwrap()).is_err());
|
||||
assert!(start(&Url::parse("http://192.168.1.4:3000/").unwrap()).is_err());
|
||||
}
|
||||
|
||||
/// The script passes its arguments through untouched and reports a missing
|
||||
/// `codeman` with exit 127 (run with an empty PATH and HOME so nothing is found).
|
||||
#[test]
|
||||
fn script_reports_missing_codeman() {
|
||||
let _serial = serial();
|
||||
let out = Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(SCRIPT)
|
||||
.arg("codeman-desktop")
|
||||
.args(["web", "-d", "--port", "3000"])
|
||||
.env("PATH", "/nonexistent")
|
||||
.env("HOME", "/nonexistent")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(127));
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("Could not find the codeman command"));
|
||||
}
|
||||
|
||||
/// Arguments are data: a hostile-looking value is passed as one argv entry
|
||||
/// to a fake `codeman`, never run by the shell.
|
||||
#[test]
|
||||
fn arguments_are_not_shell_text() {
|
||||
let _serial = serial();
|
||||
let dir = std::env::temp_dir().join(format!("codeman-desktop-fake-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let fake = dir.join("codeman");
|
||||
std::fs::write(&fake, "#!/bin/sh\nfor a in \"$@\"; do printf '[%s]' \"$a\"; done\n").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let out = Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(SCRIPT)
|
||||
.arg("codeman-desktop")
|
||||
.args(["web", "--base-url", "/x; touch /tmp/pwned"])
|
||||
.env("PATH", format!("{}:/usr/bin:/bin", dir.display()))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
"[web][--base-url][/x; touch /tmp/pwned]"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn pairs(vars: &[(&str, &str)]) -> Vec<(String, String)> {
|
||||
vars.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_capture_parser() {
|
||||
let out = format!(
|
||||
"Welcome to fish, the friendly interactive shell\nno newline here{ENV_BEGIN}\n\
|
||||
PATH=/opt/homebrew/bin:/usr/bin:/bin\n\
|
||||
MULTI=line one\nline two\n three = 3\n\
|
||||
EMPTY=\n\
|
||||
TRAIL=a \n\n\
|
||||
_=/usr/bin/env\n\
|
||||
{ENV_END} \nbye from the profile\nNOT_CAPTURED=1\n"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_env(&out),
|
||||
Some(pairs(&[
|
||||
("PATH", "/opt/homebrew/bin:/usr/bin:/bin"),
|
||||
("MULTI", "line one\nline two\n three = 3"),
|
||||
("EMPTY", ""),
|
||||
("TRAIL", "a \n"),
|
||||
("_", "/usr/bin/env"),
|
||||
]))
|
||||
);
|
||||
// A value may itself contain `=`; only the first one splits.
|
||||
assert_eq!(
|
||||
parse_env(&format!("{ENV_BEGIN}\nOPTS=a=b\n{ENV_END}\n")),
|
||||
Some(pairs(&[("OPTS", "a=b")]))
|
||||
);
|
||||
assert_eq!(parse_env(&format!("{ENV_BEGIN}\n{ENV_END}\n")), Some(vec![]));
|
||||
assert_eq!(parse_env(&format!("{ENV_BEGIN}\nPATH=/usr/bin\n")), None);
|
||||
assert_eq!(parse_env(&format!("PATH=/usr/bin\n{ENV_END}\n")), None);
|
||||
assert_eq!(parse_env("fish: Unknown command: then\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_flavor_by_basename() {
|
||||
for posix in [
|
||||
"/bin/zsh",
|
||||
"/bin/bash",
|
||||
"/bin/sh",
|
||||
"/bin/dash",
|
||||
"/bin/ksh",
|
||||
"/opt/homebrew/bin/bash",
|
||||
] {
|
||||
assert_eq!(flavor(Path::new(posix)), Flavor::Posix, "{posix}");
|
||||
}
|
||||
assert_eq!(flavor(Path::new("/bin/tcsh")), Flavor::Capture(&[&["-ic"]]));
|
||||
assert_eq!(flavor(Path::new("/bin/csh")), Flavor::Capture(&[&["-ic"]]));
|
||||
for other in [
|
||||
"/opt/homebrew/bin/fish",
|
||||
"/usr/local/bin/nu",
|
||||
"/opt/homebrew/bin/xonsh",
|
||||
"/usr/local/bin/pwsh",
|
||||
] {
|
||||
assert_eq!(
|
||||
flavor(Path::new(other)),
|
||||
Flavor::Capture(&[&["-l", "-c"], &["-l", "-i", "-c"]]),
|
||||
"{other}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The login shell's part avoids everything only POSIX shells read.
|
||||
#[test]
|
||||
fn capture_command_is_shell_neutral() {
|
||||
let _serial = serial();
|
||||
let cmd = capture_command();
|
||||
for bad in ["then", "fi", "$", "\\", "!", "\"", "&&"] {
|
||||
assert!(!cmd.contains(bad), "{bad} in {cmd}");
|
||||
}
|
||||
assert_eq!(cmd.matches('\'').count(), 2);
|
||||
assert!(cmd.starts_with("/bin/sh -c '"));
|
||||
// /bin/sh, as the capture runs it, prints a parsable environment.
|
||||
let out = Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(&cmd)
|
||||
.env("CODEMAN_DESKTOP_PARSER_PROBE", "two\nlines")
|
||||
.output()
|
||||
.unwrap();
|
||||
let env = parse_env(&String::from_utf8_lossy(&out.stdout)).expect("markers");
|
||||
assert!(env
|
||||
.iter()
|
||||
.any(|(k, v)| k == "CODEMAN_DESKTOP_PARSER_PROBE" && v == "two\nlines"));
|
||||
assert!(env.iter().any(|(k, _)| k == "PATH"));
|
||||
}
|
||||
|
||||
/// A scratch directory with a fake `codeman` in `bin/` that prints its
|
||||
/// arguments, the profile's marker variable and the names of everything in
|
||||
/// its environment.
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(name: &str) -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("codeman-desktop-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("bin")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("home")).unwrap();
|
||||
let scratch = Scratch(dir);
|
||||
scratch.exe(
|
||||
"bin/codeman",
|
||||
"#!/bin/sh\nprintf '[%s]' \"$@\"; echo\necho \"profile: $CODEMAN_DESKTOP_TEST\"\n\
|
||||
printf 'names:'; env | cut -d= -f1 | tr '\\n' ' '; echo\n",
|
||||
);
|
||||
scratch
|
||||
}
|
||||
|
||||
/// Write an executable at `rel`, with `DIR` in `body` replaced by the scratch path.
|
||||
fn exe(&self, rel: &str, body: &str) -> PathBuf {
|
||||
let path = self.0.join(rel);
|
||||
std::fs::write(&path, body.replace("DIR", &self.0.display().to_string())).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn argv_log(&self) -> String {
|
||||
std::fs::read_to_string(self.0.join("argv.log")).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
const HOSTILE_ARGS: [&str; 3] = ["web", "--base-url", "/x; touch /tmp/pwned"];
|
||||
|
||||
fn hostile_args() -> Vec<String> {
|
||||
HOSTILE_ARGS.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
/// A stand-in for fish: it refuses POSIX `then` and `$@` the way fish
|
||||
/// does, logs its flags, prints profile noise around whatever it runs, and
|
||||
/// its "profile" replaces the environment. On its interactive form only
|
||||
/// when `INTERACTIVE_ONLY` is yes, PATH reaches the fake `codeman`.
|
||||
const FAKE_FISH: &str = r#"#!/bin/sh
|
||||
args=""
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
*then*|*'$@'*) echo "fish: Unknown command: then" >&2; exit 127;;
|
||||
-*) args="$args$a ";;
|
||||
esac
|
||||
cmd="$a"
|
||||
done
|
||||
echo "$args" >> "DIR/argv.log"
|
||||
bin="DIR/bin"
|
||||
case "INTERACTIVE_ONLY:$args" in yes:*-i*|no:*) ;; *) bin=/nonexistent;; esac
|
||||
echo "Welcome to fish, the friendly interactive shell"
|
||||
/usr/bin/env -i PATH="$bin:/usr/bin:/bin" HOME="DIR/home" CODEMAN_DESKTOP_TEST="from the profile" /bin/sh -c "$cmd"
|
||||
echo "bye from the profile"
|
||||
"#;
|
||||
|
||||
/// SCRIPT never reaches the non-POSIX shell: it runs under /bin/sh with the
|
||||
/// environment the shell printed, and with nothing else.
|
||||
#[test]
|
||||
fn non_posix_shell_only_reports_its_environment() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("fish");
|
||||
let fish = s.exe("fish", &FAKE_FISH.replace("INTERACTIVE_ONLY", "no"));
|
||||
let out = start_with(&fish, &hostile_args()).expect("started");
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
assert_eq!(lines[0], "[web][--base-url][/x; touch /tmp/pwned]");
|
||||
assert_eq!(lines[1], "profile: from the profile");
|
||||
assert_eq!(s.argv_log(), "-l -c \n");
|
||||
|
||||
// env_clear: a variable this process has, but the profile did not
|
||||
// export, must not reach codeman.
|
||||
let names: Vec<&str> = lines[2].trim_start_matches("names:").split_whitespace().collect();
|
||||
assert!(names.contains(&"PATH") && names.contains(&"CODEMAN_DESKTOP_TEST"));
|
||||
let captured = ["PATH", "HOME", "CODEMAN_DESKTOP_TEST", "PWD", "OLDPWD", "SHLVL", "_"];
|
||||
let leaked: Vec<String> = std::env::vars_os()
|
||||
.filter_map(|(k, _)| k.into_string().ok())
|
||||
.filter(|k| !captured.contains(&k.as_str()) && !k.starts_with("__CF"))
|
||||
.filter(|k| names.contains(&k.as_str()))
|
||||
.collect();
|
||||
assert!(leaked.is_empty(), "leaked from the app: {leaked:?}");
|
||||
}
|
||||
|
||||
/// No codeman on the login PATH (exit 127): the environment is captured
|
||||
/// again from the interactive profile, and that run finds it.
|
||||
#[test]
|
||||
fn non_posix_shell_retries_with_the_interactive_profile() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("fish-retry");
|
||||
let fish = s.exe("fish", &FAKE_FISH.replace("INTERACTIVE_ONLY", "yes"));
|
||||
let out = start_with(&fish, &hostile_args()).expect("started");
|
||||
assert!(out.starts_with("[web][--base-url][/x; touch /tmp/pwned]\n"), "{out}");
|
||||
assert_eq!(s.argv_log(), "-l -c \n-l -i -c \n");
|
||||
}
|
||||
|
||||
/// tcsh takes -l only as its only flag, so it is asked with -ic alone.
|
||||
#[test]
|
||||
fn tcsh_gets_ic_and_never_l() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("tcsh");
|
||||
let tcsh = s.exe(
|
||||
"tcsh",
|
||||
r#"#!/bin/sh
|
||||
echo "$*" >> "DIR/argv.log"
|
||||
if [ "$#" != 2 ] || [ "$1" != -ic ]; then echo "tcsh: Unknown option: $1" >&2; exit 1; fi
|
||||
/usr/bin/env -i PATH="DIR/bin:/usr/bin:/bin" HOME="DIR/home" CODEMAN_DESKTOP_TEST=tcsh /bin/sh -c "$2"
|
||||
"#,
|
||||
);
|
||||
let out = start_with(&tcsh, &hostile_args()).expect("started");
|
||||
assert!(out.contains("profile: tcsh"), "{out}");
|
||||
assert_eq!(s.argv_log().lines().count(), 1);
|
||||
assert!(s.argv_log().starts_with("-ic /bin/sh -c '"));
|
||||
}
|
||||
|
||||
/// A shell that takes neither -l nor -i still gets a bare -c.
|
||||
#[test]
|
||||
fn shell_without_login_flags_falls_back_to_plain_c() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("nolflag");
|
||||
let shell = s.exe(
|
||||
"elvish",
|
||||
r#"#!/bin/sh
|
||||
echo "$1" >> "DIR/argv.log"
|
||||
case "$1" in -l|-i) echo "flag provided but not defined: $1" >&2; exit 2;; esac
|
||||
/usr/bin/env -i PATH="DIR/bin:/usr/bin:/bin" HOME="DIR/home" CODEMAN_DESKTOP_TEST=plain /bin/sh -c "$2"
|
||||
"#,
|
||||
);
|
||||
let out = start_with(&shell, &hostile_args()).expect("started");
|
||||
assert!(out.contains("profile: plain"), "{out}");
|
||||
assert_eq!(s.argv_log(), "-l\n-l\n-c\n");
|
||||
}
|
||||
|
||||
/// A shell that never prints its environment is reported by name with what
|
||||
/// it said on stderr, never with stdout (which can carry secrets).
|
||||
#[test]
|
||||
fn capture_failure_names_the_shell() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("broken");
|
||||
let shell = s.exe(
|
||||
"weirdsh",
|
||||
"#!/bin/sh\necho 'API_KEY=sk-secret'\necho 'weirdsh: cannot start' >&2\nexit 3\n",
|
||||
);
|
||||
let err = start_with(&shell, &hostile_args()).unwrap_err();
|
||||
assert!(err.contains("Could not read the environment"), "{err}");
|
||||
assert!(err.contains("weirdsh: cannot start"), "{err}");
|
||||
assert!(!err.contains("sk-secret"), "{err}");
|
||||
}
|
||||
|
||||
/// POSIX shells keep running SCRIPT themselves: `-l -c SCRIPT codeman-desktop
|
||||
/// args`, then `-l -i -c` when the login PATH had no codeman.
|
||||
#[test]
|
||||
fn posix_shell_runs_script_itself_and_retries_interactive() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("zsh");
|
||||
let zsh = s.exe(
|
||||
"zsh",
|
||||
r#"#!/bin/sh
|
||||
[ "$1" = -l ] || exit 90
|
||||
shift
|
||||
PATH=/usr/bin:/bin
|
||||
if [ "$1" = -i ]; then shift; PATH="DIR/bin:/usr/bin:/bin"; fi
|
||||
[ "$1" = -c ] || exit 91
|
||||
shift
|
||||
echo "$2" >> "DIR/argv.log"
|
||||
HOME="DIR/home"
|
||||
export PATH HOME
|
||||
exec /bin/sh -c "$@"
|
||||
"#,
|
||||
);
|
||||
let out = start_with(&zsh, &hostile_args()).expect("started");
|
||||
assert!(out.starts_with("[web][--base-url][/x; touch /tmp/pwned]\n"), "{out}");
|
||||
// $0 of the script, once per run: login, then interactive.
|
||||
assert_eq!(s.argv_log(), "codeman-desktop\ncodeman-desktop\n");
|
||||
}
|
||||
|
||||
/// The installed check asks the same shells the same way and runs nothing:
|
||||
/// found on the interactive PATH here, after the login PATH had none.
|
||||
#[test]
|
||||
fn installed_check_finds_codeman_without_running_it() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("find-zsh");
|
||||
let zsh = s.exe(
|
||||
"zsh",
|
||||
r#"#!/bin/sh
|
||||
[ "$1" = -l ] || exit 90
|
||||
shift
|
||||
PATH=/usr/bin:/bin
|
||||
if [ "$1" = -i ]; then shift; PATH="DIR/bin:/usr/bin:/bin"; fi
|
||||
[ "$1" = -c ] || exit 91
|
||||
shift
|
||||
echo "$2" >> "DIR/argv.log"
|
||||
HOME="DIR/home"
|
||||
export PATH HOME
|
||||
exec /bin/sh -c "$@"
|
||||
"#,
|
||||
);
|
||||
// A codeman that would leave a mark if it ran.
|
||||
s.exe("bin/codeman", "#!/bin/sh\ntouch DIR/ran\n");
|
||||
// Without the interactive profile: one login-shell look, not found.
|
||||
assert!(!installed_with(&zsh, false));
|
||||
assert_eq!(s.argv_log(), "codeman-desktop\n");
|
||||
assert!(installed_with(&zsh, true));
|
||||
assert_eq!(s.argv_log(), "codeman-desktop\ncodeman-desktop\ncodeman-desktop\n");
|
||||
assert!(!s.0.join("ran").exists(), "the installed check ran codeman");
|
||||
}
|
||||
|
||||
/// Nothing on any PATH and no ~/.codeman/app: not installed. Through a
|
||||
/// non-POSIX shell too, which only reports its environment.
|
||||
#[test]
|
||||
fn installed_check_reports_missing_codeman() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("find-fish");
|
||||
std::fs::remove_file(s.0.join("bin/codeman")).unwrap();
|
||||
let fish = s.exe("fish", &FAKE_FISH.replace("INTERACTIVE_ONLY", "no"));
|
||||
assert!(!installed_with(&fish, true));
|
||||
// The login-only look asks fish once, with -l and never -i.
|
||||
let before = s.argv_log();
|
||||
assert!(!installed_with(&fish, false));
|
||||
assert_eq!(&s.argv_log()[before.len()..], "-l -c \n");
|
||||
|
||||
// The install.sh clone counts once node is there.
|
||||
let app = s.0.join("home/.codeman/app/dist");
|
||||
std::fs::create_dir_all(&app).unwrap();
|
||||
std::fs::write(app.join("index.js"), "").unwrap();
|
||||
s.exe("bin/node", "#!/bin/sh\nexit 0\n");
|
||||
assert!(installed_with(&fish, true));
|
||||
assert!(installed_with(&fish, false));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,727 @@
|
||||
//! "Is a Codeman server answering at this address, and does it want a login?"
|
||||
//!
|
||||
//! One `GET <server>/api/status`, five seconds at most, run off the UI thread.
|
||||
//! The answer is classified into something the connect page can explain. It is
|
||||
//! asked once per user action, never in a loop: Codeman counts a request
|
||||
//! without valid credentials against the login rate limit (10 per IP).
|
||||
|
||||
use serde::Serialize;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const MAX_BODY: u64 = 8 * 1024 * 1024;
|
||||
|
||||
/// The path the probe asks for, relative to the server's base URL.
|
||||
const STATUS_PATH: &str = "api/status";
|
||||
|
||||
/// rustls' text for `Error::InvalidCertificate`, which is what a handshake
|
||||
/// with a certificate the OS does not trust fails with. ureq hands it over as
|
||||
/// an `io::Error` of kind `InvalidData`, so the text is all there is to match
|
||||
/// without a direct rustls dependency.
|
||||
const CERT_REJECTED_PREFIX: &str = "invalid peer certificate";
|
||||
|
||||
/// std's text for a failed `getaddrinfo`. Its kind is `Uncategorized`, which
|
||||
/// cannot be matched on stable Rust, so the prefix is the only handle. The
|
||||
/// prefix is the same on macOS and Linux; only the resolver's reason differs.
|
||||
const LOOKUP_FAILED_PREFIX: &str = "failed to lookup address information";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum ProbeOutcome {
|
||||
Ok {
|
||||
version: String,
|
||||
},
|
||||
/// 401 and we sent no credentials.
|
||||
NeedsLogin,
|
||||
/// 401 although we sent credentials.
|
||||
BadCredentials,
|
||||
/// 429: too many failed logins from this address. A single-user Codeman
|
||||
/// still accepts the right password, so the page needs to know whether
|
||||
/// this probe carried one. `retry_after_secs` is the server's
|
||||
/// `Retry-After`, when it gave one in seconds.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
RateLimited {
|
||||
sent_credentials: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
retry_after_secs: Option<u64>,
|
||||
},
|
||||
/// 403 `PASSWORD_CHANGE_REQUIRED`: the credentials are valid, but a
|
||||
/// multi-user Codeman wants this account's password changed first. Page
|
||||
/// loads are exempt, so the web UI can show its change-password dialog.
|
||||
PasswordChangeRequired,
|
||||
/// 403 from Codeman's Host guard (a name it does not know, e.g. a custom domain).
|
||||
HostNotAllowed,
|
||||
/// 3xx. `location` is the target resolved against the probed URL.
|
||||
/// `suggested` is a server address to use instead, set only when the
|
||||
/// redirect stays on the same host, only moves to https or another port,
|
||||
/// and still points at the status API. Never followed: a second request
|
||||
/// would carry the credentials to an address the user did not type.
|
||||
Redirect {
|
||||
location: String,
|
||||
suggested: Option<String>,
|
||||
},
|
||||
/// Something answered, but not Codeman's status API.
|
||||
NotCodeman {
|
||||
status: u16,
|
||||
},
|
||||
Refused,
|
||||
/// No route to the address: the machine is off or on another network,
|
||||
/// or macOS's Local Network privacy setting blocked the app.
|
||||
Unreachable,
|
||||
/// An http:// probe whose connection was closed without an answer. A
|
||||
/// Codeman started with `--https` does exactly that to plain HTTP.
|
||||
MaybeHttps,
|
||||
Timeout,
|
||||
TlsUntrusted {
|
||||
detail: String,
|
||||
},
|
||||
Unresolvable,
|
||||
Failed {
|
||||
detail: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Classify an HTTP answer to `<server>/api/status`. `request` is the URL that
|
||||
/// was asked, `location` and `retry_after` are those response headers.
|
||||
pub fn classify_response(
|
||||
request: &Url,
|
||||
status: u16,
|
||||
location: Option<&str>,
|
||||
retry_after: Option<&str>,
|
||||
sent_credentials: bool,
|
||||
body: &str,
|
||||
) -> ProbeOutcome {
|
||||
match status {
|
||||
200 => {
|
||||
let parsed: Option<serde_json::Value> = serde_json::from_str(body).ok();
|
||||
let version = parsed.as_ref().and_then(|v| {
|
||||
(v.get("success")? == &serde_json::Value::Bool(true))
|
||||
.then(|| v.get("data")?.get("version")?.as_str().map(str::to_string))
|
||||
.flatten()
|
||||
});
|
||||
match version {
|
||||
Some(version) => ProbeOutcome::Ok { version },
|
||||
None => ProbeOutcome::NotCodeman { status },
|
||||
}
|
||||
}
|
||||
300..=399 => {
|
||||
// A 3xx without a usable Location (304, a garbled header) is not a
|
||||
// redirect anyone can act on.
|
||||
match location.and_then(|l| request.join(l.trim()).ok()) {
|
||||
Some(target) => ProbeOutcome::Redirect {
|
||||
suggested: suggested_server(request, &target),
|
||||
location: target.into(),
|
||||
},
|
||||
None => ProbeOutcome::NotCodeman { status },
|
||||
}
|
||||
}
|
||||
401 if sent_credentials => ProbeOutcome::BadCredentials,
|
||||
401 => ProbeOutcome::NeedsLogin,
|
||||
403 if body.contains("host not allowed") => ProbeOutcome::HostNotAllowed,
|
||||
// Codeman only says this after the Basic credentials verified (the
|
||||
// probe sends no cookie), so without credentials it is not Codeman's
|
||||
// answer to us, and loading the page without them would not help.
|
||||
403 if sent_credentials && error_code(body).as_deref() == Some("PASSWORD_CHANGE_REQUIRED") => {
|
||||
ProbeOutcome::PasswordChangeRequired
|
||||
}
|
||||
429 => ProbeOutcome::RateLimited {
|
||||
sent_credentials,
|
||||
retry_after_secs: retry_after.and_then(|v| v.trim().parse::<u64>().ok()),
|
||||
},
|
||||
other => ProbeOutcome::NotCodeman { status: other },
|
||||
}
|
||||
}
|
||||
|
||||
/// `errorCode` of a Codeman `ApiResponse` error body.
|
||||
fn error_code(body: &str) -> Option<String> {
|
||||
let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||
Some(parsed.get("errorCode")?.as_str()?.to_string())
|
||||
}
|
||||
|
||||
/// The server base URL a redirect points to, when it is the same server
|
||||
/// moved to https or another port (a reverse proxy upgrading plain HTTP, the
|
||||
/// usual case). Anything else, such as another host (a login gateway the
|
||||
/// window could not pass anyway) or a downgrade to http, gets no suggestion.
|
||||
fn suggested_server(request: &Url, target: &Url) -> Option<String> {
|
||||
let upgrade = request.scheme() == "http" && target.scheme() == "https";
|
||||
let port_change =
|
||||
request.scheme() == target.scheme() && request.port_or_known_default() != target.port_or_known_default();
|
||||
if !(upgrade || port_change) || target.host() != request.host() {
|
||||
return None;
|
||||
}
|
||||
if !target.username().is_empty() || target.password().is_some() {
|
||||
return None;
|
||||
}
|
||||
// The redirect must keep pointing at the status API (a proxy may add a
|
||||
// base path in front of it); the base is what comes before it.
|
||||
if !target.path().ends_with(request.path()) || !request.path().ends_with(STATUS_PATH) {
|
||||
return None;
|
||||
}
|
||||
let base_path = &target.path()[..target.path().len() - STATUS_PATH.len()];
|
||||
let mut base = target.clone();
|
||||
base.set_path(base_path);
|
||||
base.set_query(None);
|
||||
base.set_fragment(None);
|
||||
Some(base.into())
|
||||
}
|
||||
|
||||
/// Transport failures, reduced to what the connect page explains differently.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TransportError {
|
||||
Refused,
|
||||
Unreachable,
|
||||
ClosedWithoutAnswer,
|
||||
Timeout,
|
||||
Tls(String),
|
||||
Dns,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
pub fn classify_transport(err: TransportError) -> ProbeOutcome {
|
||||
match err {
|
||||
TransportError::Refused => ProbeOutcome::Refused,
|
||||
TransportError::Unreachable => ProbeOutcome::Unreachable,
|
||||
TransportError::ClosedWithoutAnswer => ProbeOutcome::MaybeHttps,
|
||||
TransportError::Timeout => ProbeOutcome::Timeout,
|
||||
TransportError::Tls(detail) => ProbeOutcome::TlsUntrusted { detail },
|
||||
TransportError::Dns => ProbeOutcome::Unresolvable,
|
||||
TransportError::Other(detail) => ProbeOutcome::Failed { detail },
|
||||
}
|
||||
}
|
||||
|
||||
/// `plain_http`: the probe went to an http:// URL. Only there does a closed
|
||||
/// connection hint at an https-only server; over https the same close is a
|
||||
/// failed handshake with nothing more to say about it.
|
||||
fn transport_error(err: &ureq::Error, plain_http: bool) -> TransportError {
|
||||
use std::io::ErrorKind;
|
||||
match err {
|
||||
ureq::Error::Timeout(_) => TransportError::Timeout,
|
||||
ureq::Error::HostNotFound => TransportError::Dns,
|
||||
ureq::Error::ConnectionFailed => TransportError::Refused,
|
||||
// ureq passes both a rejected certificate (rustls wraps it as
|
||||
// InvalidData during the handshake) and a failed name lookup (std's
|
||||
// own error) through as plain io errors, so they are told apart here
|
||||
// before the kinds below swallow them.
|
||||
ureq::Error::Io(e) if e.kind() == ErrorKind::InvalidData && e.to_string().starts_with(CERT_REJECTED_PREFIX) => {
|
||||
TransportError::Tls(e.to_string())
|
||||
}
|
||||
ureq::Error::Io(e) if e.raw_os_error().is_none() && e.to_string().starts_with(LOOKUP_FAILED_PREFIX) => {
|
||||
TransportError::Dns
|
||||
}
|
||||
ureq::Error::Io(e) => match e.kind() {
|
||||
ErrorKind::ConnectionRefused | ErrorKind::ConnectionReset | ErrorKind::AddrNotAvailable => {
|
||||
TransportError::Refused
|
||||
}
|
||||
ErrorKind::HostUnreachable | ErrorKind::NetworkUnreachable => TransportError::Unreachable,
|
||||
ErrorKind::UnexpectedEof if plain_http => TransportError::ClosedWithoutAnswer,
|
||||
ErrorKind::TimedOut | ErrorKind::WouldBlock => TransportError::Timeout,
|
||||
_ => TransportError::Other(e.to_string()),
|
||||
},
|
||||
// ureq's own rustls errors come from setting up the TLS client (the
|
||||
// handshake's arrive as Io above); only a certificate rejection is
|
||||
// "untrusted", anything else is reported as it is.
|
||||
ureq::Error::Rustls(e) if e.to_string().starts_with(CERT_REJECTED_PREFIX) => TransportError::Tls(e.to_string()),
|
||||
other => TransportError::Other(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the server. Blocking: call it from a worker thread.
|
||||
pub fn probe(server: &Url, authorization: Option<&str>) -> ProbeOutcome {
|
||||
let status_url = match server.join(STATUS_PATH) {
|
||||
Ok(u) => u,
|
||||
Err(e) => return ProbeOutcome::Failed { detail: e.to_string() },
|
||||
};
|
||||
let tls = ureq::tls::TlsConfig::builder()
|
||||
.root_certs(ureq::tls::RootCerts::PlatformVerifier)
|
||||
.build();
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(TIMEOUT))
|
||||
.http_status_as_error(false)
|
||||
// Never follow: a 3xx comes back as an answer and is explained.
|
||||
.max_redirects(0)
|
||||
.tls_config(tls)
|
||||
.user_agent(concat!("CodemanDesktop/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.into();
|
||||
let mut req = agent.get(status_url.as_str()).header("Accept", "application/json");
|
||||
if let Some(auth) = authorization {
|
||||
req = req.header("Authorization", auth);
|
||||
}
|
||||
match req.call() {
|
||||
Ok(mut resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
let header = |name: &str| {
|
||||
resp.headers()
|
||||
.get(name)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string)
|
||||
};
|
||||
let location = header("location");
|
||||
let retry_after = header("retry-after");
|
||||
let body = resp
|
||||
.body_mut()
|
||||
.with_config()
|
||||
.limit(MAX_BODY)
|
||||
.read_to_string()
|
||||
.unwrap_or_default();
|
||||
classify_response(
|
||||
&status_url,
|
||||
status,
|
||||
location.as_deref(),
|
||||
retry_after.as_deref(),
|
||||
authorization.is_some(),
|
||||
&body,
|
||||
)
|
||||
}
|
||||
Err(e) => classify_transport(transport_error(&e, status_url.scheme() == "http")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io;
|
||||
|
||||
fn status_url() -> Url {
|
||||
Url::parse("http://studio.lan:3000/api/status").unwrap()
|
||||
}
|
||||
|
||||
/// `classify_response` for the cases that need no headers.
|
||||
fn classify(status: u16, sent_credentials: bool, body: &str) -> ProbeOutcome {
|
||||
classify_response(&status_url(), status, None, None, sent_credentials, body)
|
||||
}
|
||||
|
||||
fn redirect(request: &str, location: &str) -> ProbeOutcome {
|
||||
classify_response(&Url::parse(request).unwrap(), 301, Some(location), None, false, "")
|
||||
}
|
||||
|
||||
fn io_outcome(kind: io::ErrorKind, message: &str, plain_http: bool) -> ProbeOutcome {
|
||||
let err = ureq::Error::Io(io::Error::new(kind, message.to_string()));
|
||||
classify_transport(transport_error(&err, plain_http))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ok_needs_success_and_version() {
|
||||
assert_eq!(
|
||||
classify(
|
||||
200,
|
||||
false,
|
||||
r#"{"success":true,"data":{"version":"1.47.0","sessions":[]}}"#
|
||||
),
|
||||
ProbeOutcome::Ok {
|
||||
version: "1.47.0".into()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
classify(200, false, "<html>hello</html>"),
|
||||
ProbeOutcome::NotCodeman { status: 200 }
|
||||
);
|
||||
assert_eq!(
|
||||
classify(200, false, r#"{"success":false,"data":{"version":"1"}}"#),
|
||||
ProbeOutcome::NotCodeman { status: 200 }
|
||||
);
|
||||
assert_eq!(
|
||||
classify(200, false, r#"{"success":true}"#),
|
||||
ProbeOutcome::NotCodeman { status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_and_guards() {
|
||||
assert_eq!(classify(401, false, "Unauthorized"), ProbeOutcome::NeedsLogin);
|
||||
assert_eq!(classify(401, true, "Unauthorized"), ProbeOutcome::BadCredentials);
|
||||
assert_eq!(
|
||||
classify(403, false, "Forbidden: host not allowed"),
|
||||
ProbeOutcome::HostNotAllowed
|
||||
);
|
||||
// The Host guard runs before authentication, so it wins even with credentials.
|
||||
assert_eq!(
|
||||
classify(403, true, "Forbidden: host not allowed"),
|
||||
ProbeOutcome::HostNotAllowed
|
||||
);
|
||||
assert_eq!(classify(403, false, "nope"), ProbeOutcome::NotCodeman { status: 403 });
|
||||
assert_eq!(classify(502, false, ""), ProbeOutcome::NotCodeman { status: 502 });
|
||||
}
|
||||
|
||||
/// The exact body auth.ts sends: `createErrorResponse(PASSWORD_CHANGE_REQUIRED)`.
|
||||
#[test]
|
||||
fn password_change_required_only_after_sent_credentials() {
|
||||
let body = r#"{"success":false,"error":"You must change your password before continuing","errorCode":"PASSWORD_CHANGE_REQUIRED"}"#;
|
||||
assert_eq!(classify(403, true, body), ProbeOutcome::PasswordChangeRequired);
|
||||
assert_eq!(classify(403, false, body), ProbeOutcome::NotCodeman { status: 403 });
|
||||
// Another Codeman error code, or the text without the JSON field, is not it.
|
||||
assert_eq!(
|
||||
classify(403, true, r#"{"success":false,"errorCode":"FORBIDDEN"}"#),
|
||||
ProbeOutcome::NotCodeman { status: 403 }
|
||||
);
|
||||
assert_eq!(
|
||||
classify(403, true, "PASSWORD_CHANGE_REQUIRED"),
|
||||
ProbeOutcome::NotCodeman { status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limited_says_whether_credentials_went_along() {
|
||||
let body = "Too Many Requests, try again later";
|
||||
assert_eq!(
|
||||
classify(429, false, body),
|
||||
ProbeOutcome::RateLimited {
|
||||
sent_credentials: false,
|
||||
retry_after_secs: None
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
classify(429, true, body),
|
||||
ProbeOutcome::RateLimited {
|
||||
sent_credentials: true,
|
||||
retry_after_secs: None
|
||||
}
|
||||
);
|
||||
// auth.ts sends Retry-After in whole seconds; an HTTP date is not parsed.
|
||||
assert_eq!(
|
||||
classify_response(&status_url(), 429, None, Some(" 742 "), true, body),
|
||||
ProbeOutcome::RateLimited {
|
||||
sent_credentials: true,
|
||||
retry_after_secs: Some(742)
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
classify_response(
|
||||
&status_url(),
|
||||
429,
|
||||
None,
|
||||
Some("Wed, 21 Oct 2026 07:28:00 GMT"),
|
||||
false,
|
||||
body
|
||||
),
|
||||
ProbeOutcome::RateLimited {
|
||||
sent_credentials: false,
|
||||
retry_after_secs: None
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_to_https_on_the_same_host_is_suggested() {
|
||||
assert_eq!(
|
||||
redirect("http://studio.lan/api/status", "https://studio.lan/api/status"),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "https://studio.lan/api/status".into(),
|
||||
suggested: Some("https://studio.lan/".into())
|
||||
}
|
||||
);
|
||||
// A base path the proxy adds stays part of the suggestion; a query does not.
|
||||
assert_eq!(
|
||||
redirect(
|
||||
"http://studio.lan:3000/api/status",
|
||||
"https://studio.lan/codeman/api/status?from=http"
|
||||
),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "https://studio.lan/codeman/api/status?from=http".into(),
|
||||
suggested: Some("https://studio.lan/codeman/".into())
|
||||
}
|
||||
);
|
||||
// A relative Location resolves against the probed URL; another port is fine.
|
||||
assert_eq!(
|
||||
redirect(
|
||||
"http://studio.lan:3000/base/api/status",
|
||||
"//studio.lan:3001/base/api/status"
|
||||
),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "http://studio.lan:3001/base/api/status".into(),
|
||||
suggested: Some("http://studio.lan:3001/base/".into())
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_redirects_are_reported_without_a_suggestion() {
|
||||
// Another host (a login gateway, say).
|
||||
assert_eq!(
|
||||
redirect("http://studio.lan/api/status", "https://sso.example.com/login?next=x"),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "https://sso.example.com/login?next=x".into(),
|
||||
suggested: None
|
||||
}
|
||||
);
|
||||
// Same host, but no longer the status API.
|
||||
assert_eq!(
|
||||
redirect("http://studio.lan/api/status", "/login"),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "http://studio.lan/login".into(),
|
||||
suggested: None
|
||||
}
|
||||
);
|
||||
// A downgrade to plain http.
|
||||
assert_eq!(
|
||||
redirect("https://studio.lan/api/status", "http://studio.lan/api/status"),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "http://studio.lan/api/status".into(),
|
||||
suggested: None
|
||||
}
|
||||
);
|
||||
// Same scheme and port, only the path moved.
|
||||
assert_eq!(
|
||||
redirect("http://studio.lan/api/status", "/x/api/status"),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "http://studio.lan/x/api/status".into(),
|
||||
suggested: None
|
||||
}
|
||||
);
|
||||
// Credentials in the target are never suggested.
|
||||
assert_eq!(
|
||||
redirect("http://studio.lan/api/status", "https://u:p@studio.lan/api/status"),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "https://u:p@studio.lan/api/status".into(),
|
||||
suggested: None
|
||||
}
|
||||
);
|
||||
// No Location (304), or one that does not parse: nothing to act on.
|
||||
assert_eq!(
|
||||
classify_response(&status_url(), 304, None, None, false, ""),
|
||||
ProbeOutcome::NotCodeman { status: 304 }
|
||||
);
|
||||
assert_eq!(
|
||||
classify_response(&status_url(), 302, Some("http://[::1"), None, false, ""),
|
||||
ProbeOutcome::NotCodeman { status: 302 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_classes() {
|
||||
assert_eq!(classify_transport(TransportError::Refused), ProbeOutcome::Refused);
|
||||
assert_eq!(classify_transport(TransportError::Timeout), ProbeOutcome::Timeout);
|
||||
assert_eq!(classify_transport(TransportError::Dns), ProbeOutcome::Unresolvable);
|
||||
assert_eq!(
|
||||
classify_transport(TransportError::Tls("bad cert".into())),
|
||||
ProbeOutcome::TlsUntrusted {
|
||||
detail: "bad cert".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// The io errors exactly as ureq hands them over (`Error::Io`).
|
||||
#[test]
|
||||
fn io_errors_are_told_apart() {
|
||||
use io::ErrorKind;
|
||||
// rustls' certificate rejection from the handshake.
|
||||
let cert = "invalid peer certificate: UnknownIssuer";
|
||||
assert_eq!(
|
||||
io_outcome(ErrorKind::InvalidData, cert, false),
|
||||
ProbeOutcome::TlsUntrusted { detail: cert.into() }
|
||||
);
|
||||
// Any other InvalidData (a port that does not speak TLS, say) is not a certificate problem.
|
||||
assert_eq!(
|
||||
io_outcome(
|
||||
ErrorKind::InvalidData,
|
||||
"received corrupt message of type InvalidContentType",
|
||||
false
|
||||
),
|
||||
ProbeOutcome::Failed {
|
||||
detail: "received corrupt message of type InvalidContentType".into()
|
||||
}
|
||||
);
|
||||
// std's getaddrinfo failure (its real kind, Uncategorized, cannot be built on stable).
|
||||
assert_eq!(
|
||||
io_outcome(
|
||||
ErrorKind::Other,
|
||||
"failed to lookup address information: nodename nor servname provided, or not known",
|
||||
true
|
||||
),
|
||||
ProbeOutcome::Unresolvable
|
||||
);
|
||||
// The same text from the OS (an errno) is not a lookup failure.
|
||||
let os = ureq::Error::Io(io::Error::from_raw_os_error(1));
|
||||
assert!(!matches!(
|
||||
classify_transport(transport_error(&os, true)),
|
||||
ProbeOutcome::Unresolvable
|
||||
));
|
||||
// ureq's "Peer disconnected": over http a hint at https, over https just a failure.
|
||||
assert_eq!(
|
||||
io_outcome(ErrorKind::UnexpectedEof, "Peer disconnected", true),
|
||||
ProbeOutcome::MaybeHttps
|
||||
);
|
||||
assert_eq!(
|
||||
io_outcome(ErrorKind::UnexpectedEof, "Peer disconnected", false),
|
||||
ProbeOutcome::Failed {
|
||||
detail: "Peer disconnected".into()
|
||||
}
|
||||
);
|
||||
// No route: Local Network privacy, or a machine that is off.
|
||||
assert_eq!(
|
||||
io_outcome(ErrorKind::HostUnreachable, "No route to host", true),
|
||||
ProbeOutcome::Unreachable
|
||||
);
|
||||
assert_eq!(
|
||||
io_outcome(ErrorKind::NetworkUnreachable, "Network is unreachable", false),
|
||||
ProbeOutcome::Unreachable
|
||||
);
|
||||
assert_eq!(
|
||||
io_outcome(ErrorKind::ConnectionRefused, "Connection refused", true),
|
||||
ProbeOutcome::Refused
|
||||
);
|
||||
assert_eq!(
|
||||
classify_transport(transport_error(&ureq::Error::HostNotFound, true)),
|
||||
ProbeOutcome::Unresolvable
|
||||
);
|
||||
// A ureq TLS setup message is not an untrusted certificate.
|
||||
assert_eq!(
|
||||
classify_transport(transport_error(
|
||||
&ureq::Error::Tls("Rustls invalid dns name error"),
|
||||
false
|
||||
)),
|
||||
ProbeOutcome::Failed {
|
||||
detail: "Rustls invalid dns name error".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_serializes_for_the_page() {
|
||||
let json = |o: ProbeOutcome| serde_json::to_value(o).unwrap();
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::NotCodeman { status: 404 }),
|
||||
serde_json::json!({"kind":"notCodeman","status":404})
|
||||
);
|
||||
assert_eq!(json(ProbeOutcome::NeedsLogin), serde_json::json!({"kind":"needsLogin"}));
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::RateLimited {
|
||||
sent_credentials: true,
|
||||
retry_after_secs: None
|
||||
}),
|
||||
serde_json::json!({"kind":"rateLimited","sentCredentials":true})
|
||||
);
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::RateLimited {
|
||||
sent_credentials: false,
|
||||
retry_after_secs: Some(60)
|
||||
}),
|
||||
serde_json::json!({"kind":"rateLimited","sentCredentials":false,"retryAfterSecs":60})
|
||||
);
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::PasswordChangeRequired),
|
||||
serde_json::json!({"kind":"passwordChangeRequired"})
|
||||
);
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::Redirect {
|
||||
location: "https://studio.lan/api/status".into(),
|
||||
suggested: Some("https://studio.lan/".into())
|
||||
}),
|
||||
serde_json::json!({"kind":"redirect","location":"https://studio.lan/api/status","suggested":"https://studio.lan/"})
|
||||
);
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::Redirect {
|
||||
location: "https://sso.example.com/".into(),
|
||||
suggested: None
|
||||
}),
|
||||
serde_json::json!({"kind":"redirect","location":"https://sso.example.com/","suggested":null})
|
||||
);
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::Unreachable),
|
||||
serde_json::json!({"kind":"unreachable"})
|
||||
);
|
||||
assert_eq!(json(ProbeOutcome::MaybeHttps), serde_json::json!({"kind":"maybeHttps"}));
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::TlsUntrusted { detail: "x".into() }),
|
||||
serde_json::json!({"kind":"tlsUntrusted","detail":"x"})
|
||||
);
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::Unresolvable),
|
||||
serde_json::json!({"kind":"unresolvable"})
|
||||
);
|
||||
}
|
||||
|
||||
/// Against real sockets: a closed port is `Refused`, a listener that
|
||||
/// speaks HTTP but not Codeman is `NotCodeman`, a fake Codeman is `Ok`,
|
||||
/// a 401 is `NeedsLogin`, a redirect is reported without being followed,
|
||||
/// and a listener that hangs up without answering is `MaybeHttps`.
|
||||
#[test]
|
||||
fn probe_against_local_sockets() {
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
|
||||
let closed = {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
l.local_addr().unwrap().port()
|
||||
};
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{closed}/")).unwrap();
|
||||
assert_eq!(probe(&url, None), ProbeOutcome::Refused);
|
||||
|
||||
/// Accept one connection, read the request and send `response`
|
||||
/// (nothing, then close, for an empty one).
|
||||
fn serve_raw(response: String) -> u16 {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = l.local_addr().unwrap().port();
|
||||
std::thread::spawn(move || {
|
||||
if let Ok((mut s, _)) = l.accept() {
|
||||
// Read the whole request head (a GET has no body) so that
|
||||
// closing sends a FIN, not a reset for unread bytes.
|
||||
let mut head = Vec::new();
|
||||
let mut buf = [0u8; 4096];
|
||||
while !head.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
match s.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => head.extend_from_slice(&buf[..n]),
|
||||
}
|
||||
}
|
||||
let _ = s.write_all(response.as_bytes());
|
||||
}
|
||||
});
|
||||
port
|
||||
}
|
||||
fn serve_once(status_line: &str, extra_headers: &str, body: &str) -> u16 {
|
||||
serve_raw(format!(
|
||||
"HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n{extra_headers}Connection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
))
|
||||
}
|
||||
|
||||
let p = serve_once("200 OK", "", r#"{"success":true,"data":{"version":"9.9.9"}}"#);
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
|
||||
assert_eq!(
|
||||
probe(&url, None),
|
||||
ProbeOutcome::Ok {
|
||||
version: "9.9.9".into()
|
||||
}
|
||||
);
|
||||
|
||||
let p = serve_once("404 Not Found", "", "nope");
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
|
||||
assert_eq!(probe(&url, None), ProbeOutcome::NotCodeman { status: 404 });
|
||||
|
||||
let p = serve_once("401 Unauthorized", "", "Unauthorized");
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
|
||||
assert_eq!(probe(&url, None), ProbeOutcome::NeedsLogin);
|
||||
|
||||
let p = serve_once("429 Too Many Requests", "Retry-After: 30\r\n", "Too Many Requests");
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
|
||||
assert_eq!(
|
||||
probe(&url, Some("Basic YWRtaW46eA==")),
|
||||
ProbeOutcome::RateLimited {
|
||||
sent_credentials: true,
|
||||
retry_after_secs: Some(30)
|
||||
}
|
||||
);
|
||||
|
||||
// Nothing listens on the redirect target: had it been followed, the
|
||||
// answer would be Refused instead.
|
||||
let p = serve_once(
|
||||
"301 Moved Permanently",
|
||||
&format!("Location: https://127.0.0.1:{closed}/api/status\r\n"),
|
||||
"",
|
||||
);
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
|
||||
assert_eq!(
|
||||
probe(&url, None),
|
||||
ProbeOutcome::Redirect {
|
||||
location: format!("https://127.0.0.1:{closed}/api/status"),
|
||||
suggested: Some(format!("https://127.0.0.1:{closed}/"))
|
||||
}
|
||||
);
|
||||
|
||||
let p = serve_raw(String::new());
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
|
||||
assert_eq!(probe(&url, None), ProbeOutcome::MaybeHttps);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codeman",
|
||||
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.",
|
||||
"version": "1.47.0",
|
||||
"version": "1.48.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @fileoverview `codeman notify`: post to this tab's Discord thread (POST /api/sessions/:id/discord-notify).
|
||||
*
|
||||
* A CLI that is blocked on something only the user can do (a physical step, a blocker, a timed-out
|
||||
* wait) runs this. It reads CODEMAN_SESSION_ID and CODEMAN_API_URL, which Codeman sets in every
|
||||
* session, and the shared credentials, so the caller handles no auth. `runNotify` returns what to
|
||||
* print and the exit code instead of exiting, so a test can drive it.
|
||||
*
|
||||
* @module cli-notify
|
||||
*/
|
||||
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { readCodemanCredentials } from './codeman-credentials.js';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
export interface NotifyOptions {
|
||||
message: string;
|
||||
urgent?: boolean;
|
||||
file?: string;
|
||||
session?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface NotifyResult {
|
||||
code: number;
|
||||
out?: string;
|
||||
err?: string;
|
||||
}
|
||||
|
||||
export type JsonPost = (
|
||||
apiUrl: string,
|
||||
apiPath: string,
|
||||
payload: unknown,
|
||||
timeoutMs?: number
|
||||
) => Promise<{ status: number; error?: string; delivery?: { ok: boolean; error?: string } }>;
|
||||
|
||||
/** POST JSON to the Codeman API with the same credentials `codeman attach` uses. Status 0 means no answer. */
|
||||
export const postJson: JsonPost = (apiUrl, apiPath, payload, timeoutMs = REQUEST_TIMEOUT_MS) => {
|
||||
const { username, password } = readCodemanCredentials();
|
||||
const url = new URL(apiPath, apiUrl);
|
||||
const body = JSON.stringify(payload);
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
return new Promise((resolveOnce) => {
|
||||
// Settle once: a response that stalls after its headers ends in 'close' without 'end'.
|
||||
let settled = false;
|
||||
const resolve: typeof resolveOnce = (v) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolveOnce(v);
|
||||
};
|
||||
const headers: Record<string, string | number> = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
};
|
||||
if (password) headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
const req = transport.request(
|
||||
{
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
method: 'POST',
|
||||
path: `${url.pathname}${url.search}`,
|
||||
rejectUnauthorized: false,
|
||||
headers,
|
||||
timeout: timeoutMs,
|
||||
},
|
||||
(res) => {
|
||||
let text = '';
|
||||
res.on('data', (c) => (text += c));
|
||||
res.on('error', () => resolve({ status: 0 }));
|
||||
res.on('close', () => resolve({ status: 0 }));
|
||||
res.on('end', () => {
|
||||
let error: string | undefined;
|
||||
let delivery: { ok: boolean; error?: string } | undefined;
|
||||
try {
|
||||
const body = JSON.parse(text) as { error?: string; data?: { ok?: unknown; error?: string } };
|
||||
error = body.error;
|
||||
// A 200 only says Codeman took the request; `data.ok` says whether Discord took the post.
|
||||
if (typeof body.data?.ok === 'boolean') delivery = { ok: body.data.ok, error: body.data.error };
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
resolve({ status: res.statusCode ?? 0, error, delivery });
|
||||
});
|
||||
}
|
||||
);
|
||||
// A server that takes the connection and never answers must not hang the agent: status 0 is "no answer".
|
||||
req.on('timeout', () => req.destroy());
|
||||
req.on('error', () => resolve({ status: 0 }));
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
};
|
||||
|
||||
export async function runNotify(
|
||||
opts: NotifyOptions,
|
||||
env: NodeJS.ProcessEnv,
|
||||
cwd: string,
|
||||
post: JsonPost = postJson
|
||||
): Promise<NotifyResult> {
|
||||
const sessionId = opts.session || env.CODEMAN_SESSION_ID;
|
||||
const apiUrl = opts.url || env.CODEMAN_API_URL;
|
||||
if (!sessionId || !apiUrl) {
|
||||
return { code: 1, err: 'notify must run inside a Codeman session (CODEMAN_SESSION_ID and CODEMAN_API_URL)' };
|
||||
}
|
||||
const payload: { message: string; urgent?: boolean; file?: string } = { message: opts.message };
|
||||
if (opts.urgent) payload.urgent = true;
|
||||
if (opts.file) payload.file = isAbsolute(opts.file) ? opts.file : join(cwd, opts.file);
|
||||
const { status, error, delivery } = await post(
|
||||
apiUrl,
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/discord-notify`,
|
||||
payload
|
||||
);
|
||||
if (status >= 200 && status < 300) {
|
||||
if (delivery?.ok === false) {
|
||||
return { code: 1, err: `Could not post to Discord${delivery.error ? `: ${delivery.error}` : ''}` };
|
||||
}
|
||||
return { code: 0, out: 'Posted to Discord' };
|
||||
}
|
||||
// The bridge being off is not a failure: the caller just says it in the terminal.
|
||||
if (status === 409) return { code: 0, out: 'The Discord bridge is off. Say it in the terminal instead.' };
|
||||
if (status === 429)
|
||||
return { code: 1, err: 'Too many messages to Discord from this tab. Wait a minute, then send one.' };
|
||||
return { code: 1, err: `Could not post to Discord${error ? `: ${error}` : status ? ` (HTTP ${status})` : ''}` };
|
||||
}
|
||||
+17
@@ -16,6 +16,7 @@ import { isAbsolute, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import { readCodemanCredentials } from './codeman-credentials.js';
|
||||
import { runNotify } from './cli-notify.js';
|
||||
import { casePath } from './config/cases-dir.js';
|
||||
import { assertValidBasePath } from './config/base-path.js';
|
||||
import {
|
||||
@@ -92,6 +93,22 @@ async function postAttachment(apiUrl: string, sessionId: string, filePath: strin
|
||||
});
|
||||
}
|
||||
|
||||
program
|
||||
.command('notify <message>')
|
||||
.description(
|
||||
"Post a message to this tab's Discord thread (needs the user: a physical step, a blocker, a timed-out wait)"
|
||||
)
|
||||
.option('--urgent', 'Ping the user')
|
||||
.option('--file <path>', 'Send a file from the session folder with the message')
|
||||
.option('-s, --session <id>', 'Codeman session ID (defaults to CODEMAN_SESSION_ID)')
|
||||
.option('--url <url>', 'Codeman API URL (defaults to CODEMAN_API_URL)')
|
||||
.action(async (message: string, options) => {
|
||||
const { code, out, err } = await runNotify({ message, ...options }, process.env, process.cwd());
|
||||
if (out) console.log(code === 0 && out.startsWith('Posted') ? palette.ok(`✓ ${out}`) : out);
|
||||
if (err) console.error(palette.err(`✗ ${err}`));
|
||||
process.exit(code);
|
||||
});
|
||||
|
||||
program
|
||||
.command('attach <path>')
|
||||
.description('Show an attachment card for a local file')
|
||||
|
||||
@@ -53,6 +53,7 @@ export const CONFIG_BACKUP_FILES = [
|
||||
'clis.json',
|
||||
'custom-model-hosts.json',
|
||||
'webhook.json',
|
||||
'discord.json',
|
||||
'intents.json',
|
||||
'linked-cases.json',
|
||||
'remote-hosts.json',
|
||||
@@ -794,6 +795,7 @@ const REDACT_ALL_FILES: ReadonlySet<string> = new Set([
|
||||
'users.json',
|
||||
'push-keys.json',
|
||||
'webhook.json',
|
||||
'discord.json',
|
||||
'intents.json',
|
||||
]);
|
||||
/** Elsewhere a value is hidden when its own key looks like a credential. */
|
||||
|
||||
@@ -21,6 +21,7 @@ export type TokenPattern =
|
||||
| 'model'
|
||||
| 'model-claude'
|
||||
| 'model-plain'
|
||||
| 'model-plain-colon'
|
||||
| 'model-pi'
|
||||
| 'id'
|
||||
| 'id-dotted'
|
||||
@@ -47,6 +48,10 @@ const PATTERNS: Record<TokenPattern, RegExp> = {
|
||||
// copilot --model. Same characters as `model`, but a leading alphanumeric: the value follows a
|
||||
// flag, so `--yolo` must not pass as a model id and become its own flag (the allowAll clamp bypass).
|
||||
'model-plain': /^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/,
|
||||
// codex --model, and the value a custom-model `launchModel` is forced onto it. Same
|
||||
// shape as `model-plain` (leading alphanumeric, so a flag cannot ride the flag) plus
|
||||
// `:`, since Ollama-style ids like `qwen3:32b` must survive onto the command line.
|
||||
'model-plain-colon': /^[a-zA-Z0-9][a-zA-Z0-9._\-/:]*$/,
|
||||
// buildPiCommand — `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id`
|
||||
'model-pi': /^[a-zA-Z0-9._\-/:]+$/,
|
||||
// opencode --session, codex resume
|
||||
|
||||
@@ -80,6 +80,7 @@ const paramSpecSchema = z.union([
|
||||
'muxName',
|
||||
'effortLevel',
|
||||
'effortSettingsJson',
|
||||
'appendSystemPrompt',
|
||||
'codemanPrefixedSessionId',
|
||||
'launcherDefaultTarget',
|
||||
]),
|
||||
@@ -224,6 +225,7 @@ const envExportSchema = z
|
||||
'muxName',
|
||||
'effortLevel',
|
||||
'effortSettingsJson',
|
||||
'appendSystemPrompt',
|
||||
'codemanPrefixedSessionId',
|
||||
'launcherDefaultTarget',
|
||||
]),
|
||||
@@ -286,6 +288,14 @@ const launchModelTemplate = z
|
||||
.regex(/^[a-zA-Z0-9._\-/:{}]+$/)
|
||||
.optional();
|
||||
|
||||
/** One entry name under a CLI's real home, for `linkFromRealHome`: single path segment,
|
||||
* leading alphanumeric, no traversal or hidden-dot escape. */
|
||||
const homeSegmentName = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(40)
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/);
|
||||
|
||||
const capabilitiesSchema = z
|
||||
.object({
|
||||
external: z.boolean(),
|
||||
@@ -580,10 +590,61 @@ const capabilitiesSchema = z
|
||||
kind: z.literal('configDir'),
|
||||
dirEnvVar: envName,
|
||||
fileName: z.string().min(1).max(80),
|
||||
template: z.enum(['codex-toml', 'pi-models-json', 'omp-models-yml', 'grok-toml']),
|
||||
template: z.enum(['codex-toml', 'pi-models-json', 'omp-models-yml', 'grok-toml', 'deepseek-patch-json']),
|
||||
launchModel: launchModelTemplate,
|
||||
// State that must survive the isolated-directory redirect, symlinked by name from
|
||||
// the CLI's real home (deepseek: profiles/sessions/settings.yaml). Single path
|
||||
// segments only: the consumer joins them, so a traversal here would escape the
|
||||
// real home. See the field's comment in cli-registry/types.ts.
|
||||
linkFromRealHome: z.array(homeSegmentName).max(8).optional(),
|
||||
// Which of `linkFromRealHome` are directories (missing ones are created before
|
||||
// linking; missing files are skipped). Same segment pattern, see types.ts.
|
||||
linkFromRealHomeDirs: z.array(homeSegmentName).max(8).optional(),
|
||||
// The real home's fallback location, relative to the user's home, ONE segment:
|
||||
// a leading dot is legitimate (`.dsh` is what ships) but separators, `..` and
|
||||
// bare `.` are not; the consumer joins it under the user's home, a traversal
|
||||
// here escapes that home and `.` IS that home (linking THROUGH it unshielded).
|
||||
realHomeFallback: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(80)
|
||||
.regex(/^(?!\.\.?$)[^/\\]+$/)
|
||||
.optional(),
|
||||
})
|
||||
.strict(),
|
||||
.strict()
|
||||
.superRefine((recipe, ctx) => {
|
||||
// Both rules were comments before; the schema is the security boundary, so
|
||||
// enforce them at load time (see the header of this file).
|
||||
if (recipe.linkFromRealHome?.length && !recipe.realHomeFallback) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'realHomeFallback is required whenever linkFromRealHome is declared',
|
||||
path: ['realHomeFallback'],
|
||||
});
|
||||
}
|
||||
for (const name of recipe.linkFromRealHome ?? []) {
|
||||
if (name === recipe.fileName) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: `linkFromRealHome must never include fileName (${JSON.stringify(name)}): the generated file owns that name in the isolated directory, a link there would write THROUGH into the user's real file`,
|
||||
path: ['linkFromRealHome'],
|
||||
});
|
||||
}
|
||||
}
|
||||
// types.ts documents the dirs list as "must be a subset of linkFromRealHome";
|
||||
// a name absent from it names nothing (missing-file entries are skipped as
|
||||
// files), so the recipe would silently stop creating a directory it declares.
|
||||
const linkedNames = new Set(recipe.linkFromRealHome ?? []);
|
||||
for (const dir of recipe.linkFromRealHomeDirs ?? []) {
|
||||
if (!linkedNames.has(dir)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: `linkFromRealHomeDirs must be a subset of linkFromRealHome: ${JSON.stringify(dir)} is not linked, so the directory rule for it can never apply`,
|
||||
path: ['linkFromRealHomeDirs'],
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
z.object({ kind: z.literal('unsupported') }).strict(),
|
||||
]),
|
||||
})
|
||||
|
||||
@@ -152,6 +152,7 @@ const CLAUDE: CliEntry = {
|
||||
// from the EFFORT_LEVELS allowlist upstream, same as every other engine value).
|
||||
effortLevel: { type: 'engine', source: 'effortLevel' },
|
||||
effortJson: { type: 'engine', source: 'effortSettingsJson' },
|
||||
discordHint: { type: 'engine', source: 'appendSystemPrompt' },
|
||||
sessionId: { type: 'engine', source: 'sessionId' },
|
||||
sessionName: { type: 'engine', source: 'sessionName' },
|
||||
},
|
||||
@@ -179,6 +180,12 @@ const CLAUDE: CliEntry = {
|
||||
{ flag: '--effort', valueFrom: 'effortLevel', quote: 'single', when: { param: 'effortLevel', state: 'set' } },
|
||||
{ flag: '--settings', valueFrom: 'effortJson', quote: 'single', when: { param: 'effortJson', state: 'set' } },
|
||||
{ flag: '--name', valueFrom: 'sessionName', quote: 'double', when: { capabilityGate: 'nameFlag' } },
|
||||
{
|
||||
flag: '--append-system-prompt',
|
||||
valueFrom: 'discordHint',
|
||||
quote: 'single',
|
||||
when: { param: 'discordHint', state: 'set' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -203,6 +210,12 @@ const CLAUDE: CliEntry = {
|
||||
{ flag: '--effort', valueFrom: 'effortLevel', quote: 'single', when: { param: 'effortLevel', state: 'set' } },
|
||||
{ flag: '--settings', valueFrom: 'effortJson', quote: 'single', when: { param: 'effortJson', state: 'set' } },
|
||||
{ flag: '--name', valueFrom: 'sessionName', quote: 'double', when: { capabilityGate: 'nameFlag' } },
|
||||
{
|
||||
flag: '--append-system-prompt',
|
||||
valueFrom: 'discordHint',
|
||||
quote: 'single',
|
||||
when: { param: 'discordHint', state: 'set' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -628,7 +641,10 @@ const CODEX: CliEntry = {
|
||||
params: {
|
||||
bypassApprovals: { type: 'bool' },
|
||||
animations: { type: 'bool' },
|
||||
model: { type: 'token', pattern: 'model' },
|
||||
// `model-plain-colon`, not `model`: the custom-model feature forces the selected id
|
||||
// onto this param (`launchModel: '{modelId}'` below), and Ollama-style ids carry a
|
||||
// `:` (`qwen3:32b`). A leading alphanumeric still keeps `--yolo` from riding a flag.
|
||||
model: { type: 'token', pattern: 'model-plain-colon' },
|
||||
reasoningEffort: { type: 'enum', values: [...CODEX_REASONING_EFFORTS] },
|
||||
resumeId: { type: 'token', pattern: 'id' },
|
||||
},
|
||||
@@ -841,6 +857,7 @@ const CODEX: CliEntry = {
|
||||
dirEnvVar: 'CODEX_HOME',
|
||||
fileName: 'config.toml',
|
||||
template: 'codex-toml',
|
||||
launchModel: '{modelId}',
|
||||
},
|
||||
// CODEX_HOME already matches the CODEX_ allowedPrefix above, so it was ALREADY
|
||||
// reachable via plain envOverrides before this feature existed. It is arguably
|
||||
@@ -1545,30 +1562,30 @@ const DEEPSEEK: CliEntry = {
|
||||
// SERVER's own key into every dsh pane, so DEEPSEEK_BASE_URL is the exfiltration
|
||||
// vector, not the key itself — a non-granted owner supplying THEIR OWN key removes
|
||||
// privilege rather than granting it, and clamping it here was a real regression
|
||||
// (test/deepseek-mode.test.ts) fixed before this shipped.
|
||||
privilegedEnvKeys: ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL'],
|
||||
// Reuses the already-existing DEEPSEEK_BASE_URL/DEEPSEEK_API_KEY keys above. No
|
||||
// modelVars — dsh's model is a profile-composition entry (see `model: { source: 'none'
|
||||
// }` above), not an env var, so forcing a specific model name may not fully work;
|
||||
// verify against a real profile before shipping.
|
||||
//
|
||||
// ⚠️ appendV1Suffix is REQUIRED, not optional-nice-to-have: without it every request
|
||||
// 404s. Confirmed live and by reading dsh's own bundled source
|
||||
// (@deepseek-ai/dsh-llm-deepseek): it builds the request URL as
|
||||
// `${DEEPSEEK_BASE_URL}/chat/completions` with no "/v1" of its own (its real public
|
||||
// API, https://api.deepseek.com, expects the caller's base URL to already carry any
|
||||
// needed prefix), while llama-swap/llama.cpp only serves the OpenAI-conventional
|
||||
// "/v1/chat/completions" — a bare POST to ".../chat/completions" 404s live, and the
|
||||
// 404 reported here originally ("dsh: HTTP_404: DeepSeek API error (HTTP 404)")
|
||||
// matches dsh's own error-message template for exactly this failure. See the
|
||||
// customModelInjection doc comment in cli-registry/types.ts for the full reasoning,
|
||||
// including why claude/gemini must NOT get this.
|
||||
// (test/deepseek-mode.test.ts) fixed before this shipped. CODEMAN_CUSTOM_MODEL_API_KEY
|
||||
// joins it (like codex's entry): the dedicated custom-endpoint key rides the same
|
||||
// allowlisted-envOverrides path as the redirect itself.
|
||||
privilegedEnvKeys: ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL', 'CODEMAN_CUSTOM_MODEL_API_KEY'],
|
||||
// The installed profile supplies the transport; a per-session patch selects its exact model.
|
||||
customModelInjection: {
|
||||
kind: 'env',
|
||||
baseUrlVar: 'DEEPSEEK_BASE_URL',
|
||||
apiKeyVar: 'DEEPSEEK_API_KEY',
|
||||
modelVars: [],
|
||||
appendV1Suffix: true,
|
||||
kind: 'configDir',
|
||||
dirEnvVar: 'DSH_HOME',
|
||||
fileName: 'cordis.patch.yml',
|
||||
template: 'deepseek-patch-json',
|
||||
// dsh keeps state in its home that the redirect must NOT swallow: `sessions` is the
|
||||
// session-log root (deleting it would take the user's transcripts with it on clear/
|
||||
// close), `profiles` are the installed bundles the patch composes over, and
|
||||
// `settings.yaml` must keep applying (read through the link only until the session's
|
||||
// first settings write: dsh saves by rename, which replaces the link with a local file
|
||||
// that goes away with the session). The generated home-level `cordis.patch.yml`
|
||||
// deliberately replaces the user's own for the session rather than linking it.
|
||||
// `sessions` and `profiles` are directories: dsh creates them itself on a first
|
||||
// turn, and a fresh/relocated $DSH_HOME that never ran one would otherwise get a
|
||||
// dangling link whose every write fails ENOENT: the consumer creates missing
|
||||
// directory targets and skips missing file targets like `settings.yaml`.
|
||||
linkFromRealHome: ['profiles', 'sessions', 'settings.yaml'],
|
||||
linkFromRealHomeDirs: ['profiles', 'sessions'],
|
||||
realHomeFallback: '.dsh',
|
||||
},
|
||||
},
|
||||
overlays: {
|
||||
@@ -1921,8 +1938,8 @@ const COPILOT: CliEntry = {
|
||||
// custom provider and drops the GitHub sign-in requirement; COPILOT_PROVIDER_TYPE defaults to
|
||||
// "openai", which is what a Codeman custom endpoint is (llama.cpp, vLLM, Ollama).
|
||||
// COPILOT_MODEL names the model and COPILOT_PROVIDER_MODEL_ID / _WIRE_MODEL default to it.
|
||||
// The OpenAI-style route is `<base>/v1/chat/completions`, so the base gets the `/v1` suffix
|
||||
// the same way deepseek's does. All four already match the COPILOT_ prefix allowlist, so
|
||||
// The OpenAI-style route is `<base>/v1/chat/completions`, so the base gets the `/v1`
|
||||
// suffix via appendV1Suffix below. All four already match the COPILOT_ prefix allowlist, so
|
||||
// they are privileged env keys below: a non-granted multi-user owner must not be able to
|
||||
// redirect the session's endpoint or credentials through plain envOverrides.
|
||||
customModelInjection: {
|
||||
|
||||
@@ -29,6 +29,8 @@ export type EngineValue =
|
||||
| 'muxName'
|
||||
| 'effortLevel'
|
||||
| 'effortSettingsJson'
|
||||
/** The Discord bridge's startup hint, passed to Claude as `--append-system-prompt`. */
|
||||
| 'appendSystemPrompt'
|
||||
/** `sessionId` prefixed `codeman_<id>` — codex's unique per-pane rollout originator. */
|
||||
| 'codemanPrefixedSessionId'
|
||||
/**
|
||||
@@ -812,9 +814,11 @@ export interface CliCapabilities {
|
||||
* for pi and omp (`--model custom/<id>`, or the CLI stays on its own default
|
||||
* provider and reports "No API key found for the selected model") or for
|
||||
* grok (`--model codeman-custom`, the `[model.<name>]` block the config
|
||||
* declares). Absent = the config alone selects the model (claude's env vars,
|
||||
* opencode's blob, codex's top-level `model` key). Applied by the session's
|
||||
* respawn options through the entry's `legacyConfigField`, never by id.
|
||||
* declares), and codex carries its bare selected id (`{modelId}`, an id the
|
||||
* entry's `model` token pattern must be able to carry). Absent = the config
|
||||
* alone selects the model (claude's env vars, opencode's blob). Applied by
|
||||
* the session's respawn options through the entry's `legacyConfigField`,
|
||||
* never by id.
|
||||
*
|
||||
* `contextLengthVar` (env kind only): the env var a discovered per-model context-window
|
||||
* size is written to when known (claude's `CLAUDE_CODE_MAX_CONTEXT_TOKENS`) — without it,
|
||||
@@ -856,19 +860,13 @@ export interface CliCapabilities {
|
||||
* reuses that file.
|
||||
*
|
||||
* `appendV1Suffix` (env kind only): the raw `endpoint.baseUrl` gets `withV1Suffix()`
|
||||
* applied before being written to `baseUrlVar`, instead of being used verbatim.
|
||||
* DeepSeek needs this and claude/gemini must NOT get it — a per-CLI asymmetry confirmed
|
||||
* by reading each SDK's own request-building source, not assumed: DeepSeek Harness's
|
||||
* bundled `@deepseek-ai/dsh-llm-deepseek` concatenates `${connection.baseURL}/chat/
|
||||
* completions` with no `/v1` insertion of its own (its real public API base,
|
||||
* `https://api.deepseek.com`, expects the caller's base URL to already carry any
|
||||
* needed prefix), while llama-swap/llama.cpp only ever serves the OpenAI-conventional
|
||||
* `/v1/chat/completions` — confirmed live: a bare `POST <baseUrl>/chat/completions`
|
||||
* 404s, `POST <baseUrl>/v1/chat/completions` succeeds, and the harness's own error
|
||||
* message template (`DeepSeek API error (HTTP ${status})`) reproduces the exact
|
||||
* `HTTP_404` this feature originally shipped with unexplained. Claude Code's own SDK,
|
||||
* by contrast, was already confirmed working end-to-end against the RAW `baseUrl` with
|
||||
* no suffix — appending one there would be wrong, not just redundant.
|
||||
* applied before being written to `baseUrlVar`, instead of being used verbatim. Only
|
||||
* copilot declares it: its OpenAI-style route is `<base>/v1/chat/completions`, and a
|
||||
* gateway saves the bare root. claude/gemini must NOT get it: Claude Code's own SDK was
|
||||
* confirmed working end-to-end against the RAW `baseUrl`, so appending a suffix there
|
||||
* would be wrong, not just redundant. The per-session config-file recipes do not need
|
||||
* this flag at all: `renderConfigFile()` applies the suffix inside every template
|
||||
* (deepseek's patch included), so no configDir entry carries a raw base URL.
|
||||
*/
|
||||
customModelInjection:
|
||||
| {
|
||||
@@ -888,8 +886,40 @@ export interface CliCapabilities {
|
||||
kind: 'configDir';
|
||||
dirEnvVar: string;
|
||||
fileName: string;
|
||||
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml';
|
||||
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml' | 'deepseek-patch-json';
|
||||
launchModel?: string;
|
||||
/**
|
||||
* Names of entries under this CLI's REAL home that must be symlinked into the
|
||||
* isolated directory after the generated file is written: the same trick
|
||||
* `linkSharedProjectsDir()` plays for claude's `projects`. Declared for CLIs whose
|
||||
* home carries state that must survive the redirect (deepseek's `sessions` transcript
|
||||
* root and `profiles` bundles, plus `settings.yaml`, which would otherwise stop
|
||||
* applying and, for `sessions`, get deleted by `removeConfigDir` on clear/close).
|
||||
* A CLI that saves a linked file by rename replaces the link: dsh does, so the first
|
||||
* in-session settings write turns `settings.yaml` into a local file.
|
||||
* Never include `fileName` itself: the generated file owns that name in the isolated
|
||||
* directory, and a link there would write THROUGH into the user's real file.
|
||||
* Consumed generically by custom-model-injection-apply.ts.
|
||||
*/
|
||||
linkFromRealHome?: string[];
|
||||
/**
|
||||
* Which of `linkFromRealHome` are DIRECTORIES (`sessions`, `profiles`) rather than
|
||||
* files (`settings.yaml`). A missing directory target is created (mode 0700, what
|
||||
* dsh does itself on a first turn) before linking: linking a nonexistent directory
|
||||
* leaves a dangling symlink the CLI's own `mkdir` under it fails on with ENOENT on
|
||||
* EVERY turn. A missing FILE target is skipped instead of linked (dangling-file
|
||||
* links serve no purpose the CLI could not do itself). Must be a subset of
|
||||
* `linkFromRealHome`; entries absent from it are treated as files.
|
||||
* Consumed generically by custom-model-injection-apply.ts.
|
||||
*/
|
||||
linkFromRealHomeDirs?: string[];
|
||||
/**
|
||||
* Fallback location of the CLI's real home, relative to the user's home directory
|
||||
* (deepseek's `.dsh`), used only when neither the session's own `dirEnvVar` override
|
||||
* nor the server's environment sets it. Required whenever `linkFromRealHome` is
|
||||
* declared, so no consumer has to hardcode a per-CLI default of its own.
|
||||
*/
|
||||
realHomeFallback?: string;
|
||||
}
|
||||
| { kind: 'unsupported' };
|
||||
}
|
||||
|
||||
@@ -46,3 +46,10 @@ export const MAX_STALLED_PATH_PROBES = Math.min(
|
||||
PATH_PROBE_STALL_CEILING,
|
||||
envInt('CODEMAN_PATH_PROBE_MAX_STALLED', Math.max(1, PATH_PROBE_STALL_CEILING - 1), 1, 64)
|
||||
);
|
||||
|
||||
/**
|
||||
* How long a session waits before trying again to resolve its workspace's symlinks
|
||||
* after the folder did not answer, so a mount that recovers is picked up (the pane
|
||||
* directory reading stays in the resolved spelling meanwhile).
|
||||
*/
|
||||
export const WORKSPACE_REALPATH_RETRY_MS = 60_000;
|
||||
@@ -52,18 +52,22 @@ export interface CustomModelHost {
|
||||
defaultModelId?: string;
|
||||
/**
|
||||
* Discovered context-window size (tokens) per model id, keyed by the same strings as
|
||||
* `models`. Populated opportunistically during discovery (`custom-model-routes.ts`) from
|
||||
* llama.cpp/llama-swap's `GET /props?model=<id>` — the plain OpenAI-shaped `/v1/models`
|
||||
* response has no such field. Only ever probed for a model the server already reports as
|
||||
* loaded (llama-swap's `status.value === 'loaded'`); an unloaded one is deliberately never
|
||||
* probed, since llama-swap treats `/props?model=` as a routing hint that can trigger an
|
||||
* actual (slow, GPU-swapping) model load as a side effect of merely asking. A model this
|
||||
* has no entry for simply gets no context-length env override applied — never a guess.
|
||||
* `models`. Populated only by discovery (`custom-model-routes.ts`), from the strongest
|
||||
* source the server offers: the llama-swap launch command reported by `GET /running`
|
||||
* (the source of truth for a loaded model), then llama.cpp's `GET /props?model=<id>`
|
||||
* `n_ctx` (only ever probed for a model the server already reports as loaded, since
|
||||
* llama-swap treats `/props?model=` as a routing hint that can trigger an actual
|
||||
* (slow, GPU-swapping) model load as a side effect of merely asking), then the model's
|
||||
* own `GET /v1/models` entry (`context_length`, else `context_window`, which current
|
||||
* llama-swap, Bifrost and OpenRouter-style servers each state to varying degrees). A
|
||||
* model with no entry here falls back to the endpoint's `defaultContextLength`; with
|
||||
* neither, it simply gets no context-length env override applied, never a guess.
|
||||
*/
|
||||
modelContextLengths?: Record<string, number>;
|
||||
/**
|
||||
* Discovered number of parallel server slots per model id, keyed like `modelContextLengths`
|
||||
* and populated on the same loaded-models-only basis. Read from the launch command's
|
||||
* but populated only for models the server reports loaded (a listing never states a slot
|
||||
* count, unlike `contextLengths`, which every entry can supply). Read from the launch command's
|
||||
* `--parallel`/`-np` when it states one, otherwise from llama.cpp's own `/props`
|
||||
* `total_slots` (a llama-server started without `--parallel` picks the count itself, so
|
||||
* the command cannot say). llama-server splits its context across these slots (by default
|
||||
@@ -88,6 +92,29 @@ export interface CustomModelHost {
|
||||
* server-side check relies on.
|
||||
*/
|
||||
modelSizesGB?: Record<string, number>;
|
||||
/**
|
||||
* User-set fallback context length (tokens), applied via
|
||||
* `resolveCustomModelContextLength()` at every read site (the swap route, the
|
||||
* quick-start route, boot recovery) only when `modelContextLengths` has no entry for
|
||||
* the selected model (a gateway that states no per-model figure, a model never loaded
|
||||
* yet). It is the model's WHOLE window, never a per-slot figure: the Claude floor
|
||||
* check divides it by any stored slot count exactly like a discovered figure. Never
|
||||
* written by discovery, so a value set in the endpoint editor survives every
|
||||
* re-discovery sweep; unset keeps the long-standing behaviour of letting each CLI
|
||||
* assume its own default.
|
||||
*/
|
||||
defaultContextLength?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The context window to apply for `modelId` on `host`: the discovered per-model figure when
|
||||
* discovery has one, else the endpoint's user-set `defaultContextLength`, else undefined
|
||||
* (no override applied, never a guess). ONE helper for every read site: the swap route,
|
||||
* the quickstart route and boot recovery each re-write the CLI's config files, and a site
|
||||
* that forgot the fallback silently loses the endpoint default on the next respawn.
|
||||
*/
|
||||
export function resolveCustomModelContextLength(host: CustomModelHost, modelId: string): number | undefined {
|
||||
return host.modelContextLengths?.[modelId] ?? host.defaultContextLength;
|
||||
}
|
||||
|
||||
export function customModelHostsPath(configDir: string): string {
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
* cli-registry changes" requirement it was written against.
|
||||
*/
|
||||
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { homedir, platform } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { join, dirname, isAbsolute } from 'node:path';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import type { CliEntry } from './config/cli-registry/types.js';
|
||||
import {
|
||||
@@ -30,25 +30,99 @@ export function customModelConfigDir(sessionId: string): string {
|
||||
* Writes a `ConfigDirInjection`'s files under `baseDir` and returns the full
|
||||
* envOverrides object a caller should merge into the session/process env
|
||||
* (the dir-redirect var plus any `extraEnv` the config file references by
|
||||
* name). Never touches anything outside `baseDir` — the caller is
|
||||
* responsible for choosing an isolated directory (never the user's real
|
||||
* `~/.codex`, `~/.pi`, etc.).
|
||||
* name). The generated files stay under `baseDir`, but a recipe that declares
|
||||
* `linkFromRealHome` additionally links (and, for missing directory targets,
|
||||
* creates) entries in the CLI's REAL home: the caller is still responsible
|
||||
* for choosing an isolated `baseDir` (never the user's real `~/.codex`,
|
||||
* `~/.pi`, etc.), it is just no longer the only directory touched.
|
||||
*
|
||||
* pi and omp embed the API key literally in the file, so the tree is written
|
||||
* 0700/0600 like every other secret-bearing file under `~/.codeman`; the chmod
|
||||
* covers a re-apply onto a file that already exists (`mode` only applies at
|
||||
* creation).
|
||||
*/
|
||||
export function applyConfigDirInjection(baseDir: string, injection: ConfigDirInjection): Record<string, string> {
|
||||
export function applyConfigDirInjection(
|
||||
baseDir: string,
|
||||
injection: ConfigDirInjection,
|
||||
/**
|
||||
* The value this SESSION's own env overrides set for `injection.dirEnvVar`, when it
|
||||
* set one: a user who relocated the CLI's real home (an allowlisted key like
|
||||
* DSH_HOME) must have their state linked from THAT directory, not the server's.
|
||||
*/
|
||||
sessionHomeOverride?: string
|
||||
): Record<string, string> {
|
||||
for (const file of injection.files) {
|
||||
const filePath = join(baseDir, file.relPath);
|
||||
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(filePath, file.content, { encoding: 'utf8', mode: 0o600 });
|
||||
chmodSync(filePath, 0o600);
|
||||
}
|
||||
linkRealHomeEntries(baseDir, injection, sessionHomeOverride);
|
||||
return { [injection.dirEnvVar]: baseDir, ...injection.extraEnv };
|
||||
}
|
||||
|
||||
/**
|
||||
* Symlinks the registry recipe's `linkFromRealHome` entries out of the CLI's real home
|
||||
* into the otherwise-isolated `baseDir`, so redirecting `dirEnvVar` moves only the
|
||||
* generated config file and not the state that has to survive the session (deepseek:
|
||||
* the `sessions` transcript root `removeConfigDir` would otherwise delete, the `profiles`
|
||||
* bundles the patch composes over, and `settings.yaml`). Declared per entry in stock.ts:
|
||||
* this consumer is recipe-driven and names no CLI itself.
|
||||
*
|
||||
* Home resolution: the session's own override first (unless it IS `baseDir`, a re-apply
|
||||
* onto a session already holding a previous injection's value must not self-link), then
|
||||
* the server's environment, then the recipe's `realHomeFallback`. Only ABSOLUTE values
|
||||
* are considered: a set-but-relative `DSH_HOME`-style home resolves against the CLI's
|
||||
* cwd, and joining it here would mkdir/symlink under the SERVER's working directory and
|
||||
* leave relative links that dangle. No home resolvable =
|
||||
* no links, same best-effort posture as `linkSharedProjectsDir()`.
|
||||
*/
|
||||
function linkRealHomeEntries(baseDir: string, injection: ConfigDirInjection, sessionHomeOverride?: string): void {
|
||||
const names = injection.linkFromRealHome;
|
||||
if (!names || names.length === 0) return;
|
||||
const absoluteOnly = (value: string | undefined): string | undefined =>
|
||||
value && isAbsolute(value) ? value : undefined;
|
||||
const fromSession = sessionHomeOverride?.trim();
|
||||
const realHome =
|
||||
absoluteOnly(fromSession && fromSession !== baseDir ? fromSession : undefined) ||
|
||||
absoluteOnly(process.env[injection.dirEnvVar]?.trim()) ||
|
||||
(injection.realHomeFallback ? join(homedir(), injection.realHomeFallback) : undefined);
|
||||
if (!realHome) return;
|
||||
const dirs = new Set(injection.linkFromRealHomeDirs ?? []);
|
||||
for (const name of names) {
|
||||
linkRealHomeEntry(join(realHome, name), join(baseDir, name), dirs.has(name));
|
||||
}
|
||||
}
|
||||
|
||||
function linkRealHomeEntry(target: string, link: string, isDir: boolean): void {
|
||||
// lstat, never existsSync: existsSync is false for a DANGLING link, and recovery
|
||||
// re-applies on every restart, so the second pass would try to recreate the link and
|
||||
// throw EEXIST. lstat answers for real entries, good links and dangling links alike.
|
||||
try {
|
||||
if (lstatSync(link)) return; // already linked (idempotent re-apply) or a real dir wrote one
|
||||
} catch {
|
||||
// ENOENT: nothing is here yet, the only state in which a link gets created
|
||||
}
|
||||
if (!existsSync(target)) {
|
||||
if (!isDir) return; // missing FILE target: skip, never plant a dangling file link
|
||||
// Missing DIRECTORY target: a dangling directory link is not inert, since the CLI's own
|
||||
// recursive mkdir THROUGH it fails ENOENT on every turn (dsh writing under
|
||||
// `$DSH_HOME/sessions` on a fresh/relocated home). Create it first, 0700, exactly
|
||||
// what dsh itself does on a first turn.
|
||||
try {
|
||||
mkdirSync(target, { recursive: true, mode: 0o700 });
|
||||
} catch {
|
||||
return; // e.g. the target's parent refuses writes: no link rather than a dangling one
|
||||
}
|
||||
}
|
||||
try {
|
||||
symlinkSync(target, link, platform() === 'win32' ? 'junction' : 'dir');
|
||||
} catch {
|
||||
// best-effort only: a platform that refuses symlinks keeps the documented
|
||||
// side effect for this session rather than failing the whole apply over it
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Real, shared Claude config directory Codeman's own host process runs under — honors
|
||||
* `CLAUDE_CONFIG_DIR` the same way `claude-credentials.ts`'s `claudeCredentialsPath()`
|
||||
@@ -250,7 +324,13 @@ export function applyCustomModelInjection(
|
||||
* trust-dialog seed, and only when provided (boot recovery, which has no reason to
|
||||
* re-answer a dialog that already fired once, omits it rather than re-deriving it).
|
||||
*/
|
||||
workingDir?: string
|
||||
workingDir?: string,
|
||||
/**
|
||||
* Reads one env var from the SESSION's own env overrides (single-key on purpose: the
|
||||
* map carries credentials). Only the `configDir` kind uses it, to resolve the CLI's real
|
||||
* home for `linkFromRealHome` from the session's value before the server's.
|
||||
*/
|
||||
sessionEnvLookup?: (envVar: string) => string | undefined
|
||||
): AppliedCustomModel | undefined {
|
||||
const injection = buildCustomModelInjection(entry, endpoint, modelId, contextLength);
|
||||
if (injection.kind === 'unsupported') return undefined;
|
||||
@@ -283,6 +363,6 @@ export function applyCustomModelInjection(
|
||||
};
|
||||
}
|
||||
const configDir = customModelConfigDir(sessionId);
|
||||
const envOverrides = applyConfigDirInjection(configDir, injection);
|
||||
const envOverrides = applyConfigDirInjection(configDir, injection, sessionEnvLookup?.(injection.dirEnvVar));
|
||||
return { envOverrides, envKeys: Object.keys(envOverrides), configDir, launchModel: injection.launchModel };
|
||||
}
|
||||
+104
-11
@@ -87,6 +87,12 @@ export interface ConfigDirInjection {
|
||||
* `customModelInjection.launchModel` template, never hand-built per CLI.
|
||||
*/
|
||||
launchModel?: string;
|
||||
/** Carried from the registry recipe, see `cli-registry/types.ts` `linkFromRealHome`. */
|
||||
linkFromRealHome?: string[];
|
||||
/** Carried from the registry recipe, see `cli-registry/types.ts` `linkFromRealHomeDirs`. */
|
||||
linkFromRealHomeDirs?: string[];
|
||||
/** Carried from the registry recipe, see `cli-registry/types.ts` `realHomeFallback`. */
|
||||
realHomeFallback?: string;
|
||||
}
|
||||
|
||||
export interface UnsupportedInjection {
|
||||
@@ -136,14 +142,22 @@ export function buildCustomModelInjection(
|
||||
}
|
||||
|
||||
case 'configContentEnv': {
|
||||
const content = renderConfigContent(cap.template, endpoint, modelId, apiKey);
|
||||
const content = renderConfigContent(cap.template, endpoint, modelId, apiKey, contextLength);
|
||||
return withLaunchModel({ kind: 'env', envOverrides: { [cap.envVar]: content } }, cap.launchModel, modelId);
|
||||
}
|
||||
|
||||
case 'configDir': {
|
||||
const { content, extraEnv } = renderConfigFile(cap.template, endpoint, modelId, apiKey);
|
||||
const { content, extraEnv } = renderConfigFile(cap.template, endpoint, modelId, apiKey, contextLength);
|
||||
return withLaunchModel(
|
||||
{ kind: 'configDir', dirEnvVar: cap.dirEnvVar, files: [{ relPath: cap.fileName, content }], extraEnv },
|
||||
{
|
||||
kind: 'configDir',
|
||||
dirEnvVar: cap.dirEnvVar,
|
||||
files: [{ relPath: cap.fileName, content }],
|
||||
extraEnv,
|
||||
...(cap.linkFromRealHome ? { linkFromRealHome: cap.linkFromRealHome } : {}),
|
||||
...(cap.linkFromRealHomeDirs ? { linkFromRealHomeDirs: cap.linkFromRealHomeDirs } : {}),
|
||||
...(cap.realHomeFallback ? { realHomeFallback: cap.realHomeFallback } : {}),
|
||||
},
|
||||
cap.launchModel,
|
||||
modelId
|
||||
);
|
||||
@@ -164,41 +178,114 @@ function withLaunchModel<T extends EnvInjection | ConfigDirInjection>(
|
||||
return { ...result, launchModel: template.split('{modelId}').join(modelId) };
|
||||
}
|
||||
|
||||
/**
|
||||
* One quarter of the window is held back for the reply, capped at 8192 tokens (a small local
|
||||
* model's window must not lose a disproportionate slice to the reservation). Returns undefined
|
||||
* for an unknown or non-finite size: opencode then keeps its own default guess, which is the
|
||||
* pre-fix behaviour; never invent a limit here.
|
||||
*/
|
||||
function opencodeModelLimit(contextLength: number | undefined): { context: number; output: number } | undefined {
|
||||
if (contextLength === undefined || !Number.isFinite(contextLength)) return undefined;
|
||||
const total = Math.trunc(contextLength);
|
||||
if (total <= 0) return undefined;
|
||||
const output = Math.max(1, Math.min(8192, Math.floor(total / 4)));
|
||||
// `limit.context` is the FULL window as far as opencode is concerned: it reserves for
|
||||
// the reply itself, so the discovered total is passed through, never a shrunk figure.
|
||||
return { context: total, output };
|
||||
}
|
||||
|
||||
function renderConfigContent(
|
||||
template: 'opencode-json',
|
||||
endpoint: CustomModelEndpoint,
|
||||
modelId: string,
|
||||
apiKey: string
|
||||
apiKey: string,
|
||||
contextLength?: number
|
||||
): string {
|
||||
switch (template) {
|
||||
case 'opencode-json':
|
||||
case 'opencode-json': {
|
||||
// `limit` carries the discovered context window (issue #749): without it opencode assumes
|
||||
// a large default window for an unrecognized model id and eventually overflows a much
|
||||
// smaller local server. `context` is the FULL window and opencode reserves for the reply
|
||||
// inside it itself, so `opencodeModelLimit` passes the real total through and only
|
||||
// `output` gets the capped reservation.
|
||||
const limit = opencodeModelLimit(contextLength);
|
||||
return JSON.stringify({
|
||||
$schema: 'https://opencode.ai/config.json',
|
||||
provider: {
|
||||
custom: {
|
||||
options: { baseURL: withV1Suffix(endpoint.baseUrl), apiKey },
|
||||
models: { [modelId]: {} },
|
||||
models: { [modelId]: limit ? { limit } : {} },
|
||||
},
|
||||
},
|
||||
model: `custom/${modelId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const CODEX_API_KEY_ENV_VAR = 'CODEMAN_CUSTOM_MODEL_API_KEY';
|
||||
/** The DEDICATED env var carrying a custom endpoint's API key for config-file recipes that
|
||||
* reference keys by name (codex's `env_key`, deepseek's `apiKeyEnv`). Never a CLI-native
|
||||
* name: dsh's own `llm-deepseek`/`web-search-deepseek` rows read `DEEPSEEK_API_KEY` too,
|
||||
* so an in-session `/model` switch back to `deepseek-official` (or a profile with web
|
||||
* search on) would otherwise send the custom endpoint's key to api.deepseek.com. Both
|
||||
* entries list it in `privilegedEnvKeys` (stock.ts) like each other's redirect vars.
|
||||
*/
|
||||
const CUSTOM_MODEL_API_KEY_ENV_VAR = 'CODEMAN_CUSTOM_MODEL_API_KEY';
|
||||
|
||||
/** The `[model.<name>]` block name grok's config.toml uses for the injected model — also
|
||||
* what `-m <name>` in the standalone script's ONE_SHOT argv must reference to select it. */
|
||||
export const GROK_CUSTOM_MODEL_NAME = 'codeman-custom';
|
||||
|
||||
function renderConfigFile(
|
||||
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml',
|
||||
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml' | 'deepseek-patch-json',
|
||||
endpoint: CustomModelEndpoint,
|
||||
modelId: string,
|
||||
apiKey: string
|
||||
apiKey: string,
|
||||
contextLength?: number
|
||||
): { content: string; extraEnv?: Record<string, string> } {
|
||||
const baseUrl = withV1Suffix(endpoint.baseUrl);
|
||||
switch (template) {
|
||||
case 'deepseek-patch-json': {
|
||||
const route = { provider: 'codeman-custom', model: modelId };
|
||||
return {
|
||||
content: JSON.stringify(
|
||||
[
|
||||
{
|
||||
id: 'llm-pi-ai',
|
||||
name: '@deepseek-ai/dsh-llm-pi-ai',
|
||||
config: {
|
||||
providers: {
|
||||
'codeman-custom': {
|
||||
// By NAME only (never the literal key, see CUSTOM_MODEL_API_KEY_ENV_VAR).
|
||||
apiKeyEnv: CUSTOM_MODEL_API_KEY_ENV_VAR,
|
||||
api: 'openai-completions',
|
||||
baseURL: baseUrl,
|
||||
models: [
|
||||
{
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
...(contextLength !== undefined && Number.isFinite(contextLength) && contextLength > 0
|
||||
? {
|
||||
contextWindow: Math.trunc(contextLength),
|
||||
// A quarter of the window keeps the reply inside the prompt's share; 8192 caps it on a large window.
|
||||
maxTokens: Math.max(1, Math.min(8192, Math.floor(contextLength / 4))),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: 'dsh-tui', name: '@deepseek-harness-tui/dsh-tui', config: route },
|
||||
{ id: 'agent-default-model', name: '@deepseek-ai/dsh-agent-default-model', config: route },
|
||||
],
|
||||
null,
|
||||
2
|
||||
),
|
||||
extraEnv: { [CUSTOM_MODEL_API_KEY_ENV_VAR]: apiKey },
|
||||
};
|
||||
}
|
||||
case 'codex-toml': {
|
||||
// Verified against real codex (>= Feb 2026): `model` is a top-level STRING, never
|
||||
// a `[model].default` table — codex rejects that with "invalid type: map, expected
|
||||
@@ -217,15 +304,21 @@ function renderConfigFile(
|
||||
const content = [
|
||||
`model = ${quoted(modelId)}`,
|
||||
`model_provider = "custom"`,
|
||||
...(contextLength !== undefined && Number.isFinite(contextLength) && contextLength > 0
|
||||
? [
|
||||
`model_context_window = ${Math.trunc(contextLength)}`,
|
||||
`model_auto_compact_token_limit = ${Math.max(1, Math.floor(contextLength * 0.75))}`,
|
||||
]
|
||||
: []),
|
||||
'',
|
||||
'[model_providers.custom]',
|
||||
`name = "Custom Endpoint"`,
|
||||
`base_url = ${quoted(baseUrl)}`,
|
||||
`env_key = ${quoted(CODEX_API_KEY_ENV_VAR)}`,
|
||||
`env_key = ${quoted(CUSTOM_MODEL_API_KEY_ENV_VAR)}`,
|
||||
`wire_api = "responses"`,
|
||||
'',
|
||||
].join('\n');
|
||||
return { content, extraEnv: { [CODEX_API_KEY_ENV_VAR]: apiKey } };
|
||||
return { content, extraEnv: { [CUSTOM_MODEL_API_KEY_ENV_VAR]: apiKey } };
|
||||
}
|
||||
case 'pi-models-json':
|
||||
// Verified against pi's OWN bundled docs (models.md): `models` is an ARRAY of
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* @fileoverview Reads a local claude session's transcripts for its edit history: the main
|
||||
* transcript and its subagent transcripts, each from a byte offset.
|
||||
*
|
||||
* WHICH FILE (`resolveClaudeTranscript`). The transcript watcher's path, which the session's
|
||||
* hooks report, so a session is read from wherever its claude really writes. Before the first
|
||||
* hook after a server restart the watcher has none, and `findClaudeTranscript` looks for
|
||||
* `<id>.jsonl` under the session's own `CLAUDE_CONFIG_DIR` override, else the server's
|
||||
* (`claudeProjectsDir()`, as the relaunch pin does). A config dir set outside Codeman is not
|
||||
* guessed; it is found with the next hook. Subagents live in
|
||||
* `<main path without .jsonl>/subagents/agent-<id>.jsonl`.
|
||||
*
|
||||
* HOW MUCH. A requested file is read from its `offset` (clamped to its size), an unrequested
|
||||
* (new) one from `budget` bytes before its end, and a new subagent's `.meta.json` (≤ 8 KB)
|
||||
* comes along. `budgetBytes` is shared by all files, main first; once spent, files still
|
||||
* report their size with an empty chunk. A request with `len` is a block that must end at
|
||||
* `offset + len` (an "older" block ends at the floor): short of budget, its end is read.
|
||||
* With `onlyRequested` ("older" reads) unrequested files are left out.
|
||||
*
|
||||
* Tests: `test/edit-history-local-source.test.ts`.
|
||||
*
|
||||
* @module edit-history-local-source
|
||||
*/
|
||||
|
||||
import { open, readdir, realpath, stat } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, isAbsolute, join } from 'node:path';
|
||||
import { probePathKind, type PathProbeKind } from './utils/bounded-path-probe.js';
|
||||
import { claudeProjectsDir } from './utils/claude-transcript.js';
|
||||
|
||||
const ID_PATTERN = /^[A-Za-z0-9._-]+$/;
|
||||
const REL_PATH_PATTERN = /^(main|subagents\/agent-[A-Za-z0-9_-]+\.jsonl)$/;
|
||||
const AGENT_FILE_PATTERN = /^agent-([A-Za-z0-9_-]+)\.jsonl$/;
|
||||
const META_MAX_BYTES = 8192;
|
||||
|
||||
/** One file to read from where the last read stopped (`len` only for an "older" block). */
|
||||
export interface EditSourceRequest {
|
||||
/** `main` or `subagents/agent-<id>.jsonl`. */
|
||||
relPath: string;
|
||||
offset: number;
|
||||
len?: number;
|
||||
}
|
||||
|
||||
export interface EditSourceFile {
|
||||
relPath: string;
|
||||
/** The file's size when it was read. */
|
||||
size: number;
|
||||
startOffset: number;
|
||||
chunk: Buffer;
|
||||
}
|
||||
|
||||
export interface EditSources {
|
||||
/** The conversation id: the main transcript's file name without `.jsonl`. */
|
||||
transcriptId: string;
|
||||
files: EditSourceFile[];
|
||||
/** `agent-<id>.meta.json` text of each subagent read for the first time, by agent id. */
|
||||
metas: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface EditSourceReadOptions {
|
||||
/** Read only the requested files; the others are not listed. */
|
||||
onlyRequested?: boolean;
|
||||
}
|
||||
|
||||
/** What a path turned out to be (for Bash image candidates). */
|
||||
export interface PathProbe {
|
||||
/** The path with symlinks resolved. */
|
||||
realPath: string;
|
||||
kind: 'file' | 'directory' | 'other';
|
||||
/** Size in bytes (0 for anything that is not a regular file). */
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
async function readRange(path: string, offset: number, len: number): Promise<Buffer> {
|
||||
if (len <= 0) return Buffer.alloc(0);
|
||||
const fh = await open(path, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(len);
|
||||
const { bytesRead } = await fh.read(buf, 0, len, offset);
|
||||
return buf.subarray(0, bytesRead);
|
||||
} catch {
|
||||
return Buffer.alloc(0);
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function fileSize(path: string): Promise<number | null> {
|
||||
try {
|
||||
const s = await stat(path);
|
||||
return s.isFile() ? s.size : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The newest `<id>.jsonl` under any project dir, for the first id that has one; null when none does. */
|
||||
export async function findClaudeTranscript(
|
||||
ids: readonly string[],
|
||||
projectsDir: string = claudeProjectsDir()
|
||||
): Promise<string | null> {
|
||||
let dirs: string[];
|
||||
try {
|
||||
dirs = await readdir(projectsDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const id of new Set(ids)) {
|
||||
if (!ID_PATTERN.test(id)) continue;
|
||||
let best: { path: string; mtime: number } | null = null;
|
||||
for (const dir of dirs) {
|
||||
const path = join(projectsDir, dir, `${id}.jsonl`);
|
||||
try {
|
||||
const s = await stat(path);
|
||||
if (s.isFile() && (!best || s.mtimeMs > best.mtime)) best = { path, mtime: s.mtimeMs };
|
||||
} catch {
|
||||
// not in this project dir
|
||||
}
|
||||
}
|
||||
if (best) return best.path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's main transcript: the transcript watcher's (hook-reported) path when there is
|
||||
* one, else the newest `<id>.jsonl` under `configDir` (the session's `CLAUDE_CONFIG_DIR`
|
||||
* override; the server's config dir when unset). Null when neither finds one.
|
||||
*/
|
||||
export async function resolveClaudeTranscript(
|
||||
watcherPath: string | null | undefined,
|
||||
ids: readonly string[],
|
||||
configDir?: string
|
||||
): Promise<string | null> {
|
||||
return watcherPath || findClaudeTranscript(ids, claudeProjectsDir(configDir));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the main transcript at `mainPath` and its subagent transcripts. Null when `mainPath`
|
||||
* is not an absolute `<id>.jsonl` path or the file does not exist.
|
||||
*/
|
||||
export async function readClaudeEditSources(
|
||||
mainPath: string,
|
||||
requests: readonly EditSourceRequest[],
|
||||
budgetBytes: number,
|
||||
opts: EditSourceReadOptions = {}
|
||||
): Promise<EditSources | null> {
|
||||
if (!isAbsolute(mainPath) || !mainPath.endsWith('.jsonl')) return null;
|
||||
const transcriptId = basename(mainPath, '.jsonl');
|
||||
if (!ID_PATTERN.test(transcriptId)) return null;
|
||||
|
||||
const byPath = new Map<string, EditSourceRequest>();
|
||||
for (const r of requests) {
|
||||
if (!REL_PATH_PATTERN.test(r.relPath) || byPath.has(r.relPath)) continue;
|
||||
if (!Number.isSafeInteger(r.offset) || r.offset < 0) continue;
|
||||
if (r.len !== undefined && (!Number.isSafeInteger(r.len) || r.len < 0)) continue;
|
||||
byPath.set(r.relPath, r);
|
||||
}
|
||||
|
||||
const subDir = join(mainPath.slice(0, -'.jsonl'.length), 'subagents');
|
||||
const sources: Array<{ relPath: string; path: string; agentId?: string }> = [{ relPath: 'main', path: mainPath }];
|
||||
try {
|
||||
for (const name of (await readdir(subDir)).sort()) {
|
||||
const m = AGENT_FILE_PATTERN.exec(name);
|
||||
if (m) sources.push({ relPath: `subagents/${name}`, path: join(subDir, name), agentId: m[1] });
|
||||
}
|
||||
} catch {
|
||||
// no subagents yet
|
||||
}
|
||||
|
||||
let remaining = Math.max(0, Math.floor(budgetBytes));
|
||||
const files: EditSourceFile[] = [];
|
||||
const metas: Record<string, string> = {};
|
||||
for (const src of sources) {
|
||||
const size = await fileSize(src.path);
|
||||
if (size === null) {
|
||||
if (src.relPath === 'main') return null;
|
||||
continue;
|
||||
}
|
||||
const req = byPath.get(src.relPath);
|
||||
if (!req && opts.onlyRequested) continue;
|
||||
let start: number;
|
||||
let len: number;
|
||||
if (req?.len !== undefined) {
|
||||
const blockStart = Math.min(req.offset, size);
|
||||
const blockEnd = Math.min(req.offset + req.len, size);
|
||||
len = Math.min(blockEnd - blockStart, remaining);
|
||||
start = blockEnd - len;
|
||||
} else if (req) {
|
||||
start = Math.min(req.offset, size);
|
||||
len = Math.min(remaining, size - start);
|
||||
} else {
|
||||
len = Math.min(size, remaining);
|
||||
start = size - len;
|
||||
}
|
||||
const chunk = await readRange(src.path, start, len);
|
||||
remaining -= chunk.length;
|
||||
files.push({ relPath: src.relPath, size, startOffset: start, chunk });
|
||||
if (!req && src.agentId) {
|
||||
const metaPath = join(subDir, `agent-${src.agentId}.meta.json`);
|
||||
const metaSize = await fileSize(metaPath);
|
||||
if (metaSize !== null) {
|
||||
metas[src.agentId] = (await readRange(metaPath, 0, Math.min(metaSize, META_MAX_BYTES))).toString('utf8');
|
||||
}
|
||||
}
|
||||
}
|
||||
return { transcriptId, files, metas };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per path its resolved path, kind, size and mtime, or null when it is not a regular file
|
||||
* (missing, a directory, or on a filesystem that did not answer). `~/` means this user's home.
|
||||
*
|
||||
* The paths come from Bash output, so the agent chose them: each one first goes through the
|
||||
* bounded probe (`probePathKind`, no `pastCap`, this is a bulk scan), and only a path it saw
|
||||
* as a file is touched by `realpath` + `stat`. A path on a mount that stopped answering
|
||||
* then costs one stalled stat at most, never a sync that cannot finish (#516 rule).
|
||||
* Sequential, so one sync holds at most one threadpool worker.
|
||||
*/
|
||||
export async function probeLocalPaths(
|
||||
paths: readonly string[],
|
||||
home: string = homedir(),
|
||||
probeKind: (path: string) => Promise<PathProbeKind> = probePathKind
|
||||
): Promise<Array<PathProbe | null>> {
|
||||
const out: Array<PathProbe | null> = [];
|
||||
for (const p of paths) {
|
||||
const path = p.startsWith('~/') ? join(home, p.slice(2)) : p;
|
||||
if (!isAbsolute(path) || (await probeKind(path)) !== 'file') {
|
||||
out.push(null);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const real = await realpath(path);
|
||||
const s = await stat(real);
|
||||
out.push(s.isFile() ? { realPath: real, kind: 'file', size: s.size, mtimeMs: Math.floor(s.mtimeMs) } : null);
|
||||
} catch {
|
||||
out.push(null);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* @fileoverview Edit history parser: claude JSONL transcript lines → the files the agent
|
||||
* edited (with their diff) and the images its Bash calls may have produced.
|
||||
*
|
||||
* Every `Edit`/`Write` result line carries `toolUseResult.filePath` and a
|
||||
* `structuredPatch` (hunks with ` `/`+`/`-` prefixed lines), so a per-edit diff needs no
|
||||
* git. The whole-file fields next to it (`originalFile`, `content`) are never kept.
|
||||
*
|
||||
* Images rarely come from `Write`; they come from scripts run through Bash. A Bash call
|
||||
* yields a CANDIDATE: the image-looking paths in its command and output, with the call's
|
||||
* start and end time. Whether a candidate file was actually produced by the call (its
|
||||
* mtime falls inside that window) is decided later by whoever can stat it, locally or on
|
||||
* the remote host: this module does no IO.
|
||||
*
|
||||
* Chunked input: a transcript is read in byte ranges, so a Bash `tool_use` and its result
|
||||
* can land in different chunks. Unanswered calls are handed back as `pending` and passed
|
||||
* into the next call.
|
||||
*
|
||||
* Tests: `test/edit-history-parse.test.ts`.
|
||||
*
|
||||
* @module edit-history-parse
|
||||
*/
|
||||
|
||||
import { posix } from 'node:path';
|
||||
|
||||
/** Per-record budget for kept hunk lines; a larger patch is cut and marked `truncated`. */
|
||||
export const MAX_HUNK_BYTES = 64 * 1024;
|
||||
/** Image paths kept per Bash call. */
|
||||
export const MAX_IMAGE_CANDIDATES = 20;
|
||||
/** Unanswered Bash calls carried between chunks (an interrupted call never gets a result). */
|
||||
export const MAX_PENDING_BASH = 200;
|
||||
/**
|
||||
* Command text kept per unanswered Bash call. Pending calls are persisted with the store, so
|
||||
* this bounds it; an image path past this point of a command is still found in the output.
|
||||
*/
|
||||
export const MAX_PENDING_COMMAND_CHARS = 8192;
|
||||
/** Longest id, cwd or agent id of an unanswered Bash call; a call with a longer one is not kept. */
|
||||
export const MAX_PENDING_FIELD_CHARS = 4096;
|
||||
|
||||
export type EditKind = 'code' | 'image';
|
||||
/** `edit` = a result with a path and a patch from a tool other than Edit/Write. */
|
||||
export type EditTool = 'Edit' | 'Write' | 'edit';
|
||||
|
||||
export interface EditHunk {
|
||||
oldStart: number;
|
||||
oldLines: number;
|
||||
newStart: number;
|
||||
newLines: number;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
export interface EditRecord {
|
||||
/** The `tool_use_id` of the edit: unique per edit, used to deduplicate. */
|
||||
id: string;
|
||||
/** ms since epoch, from the result line's `timestamp` (0 when missing). */
|
||||
ts: number;
|
||||
path: string;
|
||||
kind: EditKind;
|
||||
tool: EditTool;
|
||||
/** A `Write` that created the file. */
|
||||
created: boolean;
|
||||
/** Counted over the whole patch, also when `hunks` was cut. */
|
||||
added: number;
|
||||
removed: number;
|
||||
hunks: EditHunk[];
|
||||
truncated: boolean;
|
||||
/** Set for lines of a subagent transcript. */
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export interface PendingBash {
|
||||
id: string;
|
||||
ts: number;
|
||||
command: string;
|
||||
cwd: string;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export interface ImageCandidate {
|
||||
toolUseId: string;
|
||||
start: number;
|
||||
end: number;
|
||||
paths: string[];
|
||||
agentId?: string;
|
||||
/**
|
||||
* Files claude handed to the user (`SendUserFile`): shown on purpose, so no mtime window
|
||||
* applies; the file must still exist, and a version already listed is not listed again.
|
||||
*/
|
||||
sent?: boolean;
|
||||
}
|
||||
|
||||
export interface EditParseResult {
|
||||
records: EditRecord[];
|
||||
candidates: ImageCandidate[];
|
||||
pending: PendingBash[];
|
||||
}
|
||||
|
||||
const IMAGE_EXT = '(?:png|jpe?g|gif|webp|avif|svg)';
|
||||
const IMAGE_PATH_RE = new RegExp(`\\.${IMAGE_EXT}$`, 'i');
|
||||
// After the extension: no further name character, and no `.ext` (a.png.bak).
|
||||
const EXT_END = '(?![\\w-])(?!\\.\\w)';
|
||||
// Characters that end an unquoted path token.
|
||||
const TOKEN_CHAR = '[^\\s\'"`<>|;&()=,:*?]';
|
||||
const PATH_TOKEN_RE = new RegExp(
|
||||
[
|
||||
// 1. file:// URL (Codex: `Saved to: file:///…`), which may contain spaces up to the extension.
|
||||
// The lazy scan stops at the next `file://`: otherwise it runs to the line end from
|
||||
// every `file://` of a line that repeats it (quadratic). `:` stays allowed, so a
|
||||
// timestamped name (`plot-12:30.png`) is still found.
|
||||
`file://(/(?:(?!file://)[^\\n])*?\\.${IMAGE_EXT})${EXT_END}`,
|
||||
// 2./3. Quoted paths, spaces allowed.
|
||||
`"([^"\\n]*\\.${IMAGE_EXT})"`,
|
||||
`'([^'\\n]*\\.${IMAGE_EXT})'`,
|
||||
// 4. Unquoted token. A match starts only at a token boundary (line start or a character
|
||||
// that ends a token), never inside a URL (`:` is not a boundary), a glob or a longer
|
||||
// name. Anchoring at boundaries keeps this linear: a lookbehind that only excluded
|
||||
// some characters let a match start at every other character of a long token
|
||||
// (`####…`, a base64 blob) and rescan the token from each, quadratic in the line
|
||||
// length. The name before the extension must not be empty.
|
||||
`(?<=^|[\\s'"\`<>|;&()=,*?])((?:${TOKEN_CHAR}*/)?[^\\s'"\`<>|;&()=,:*?/]+\\.${IMAGE_EXT})${EXT_END}`,
|
||||
].join('|'),
|
||||
'gi'
|
||||
);
|
||||
|
||||
/** `image` for the image extensions the panel can thumbnail, else `code`. */
|
||||
export function classifyEditPath(path: string): EditKind {
|
||||
return IMAGE_PATH_RE.test(path) ? 'image' : 'code';
|
||||
}
|
||||
|
||||
/**
|
||||
* Image-looking paths in a Bash call's command and output, in order of appearance,
|
||||
* deduplicated and capped at {@link MAX_IMAGE_CANDIDATES}. Absolute paths are normalised,
|
||||
* `~/` paths stay literal (the host that stats them expands `~`), relative ones are
|
||||
* resolved against `cwd` and dropped when `cwd` is not absolute. URLs and globs are
|
||||
* skipped.
|
||||
*/
|
||||
export function extractImagePathCandidates(command: string, stdout: string, stderr: string, cwd: string): string[] {
|
||||
const text = [command, stdout, stderr].join('\n');
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const m of text.matchAll(PATH_TOKEN_RE)) {
|
||||
const raw = m[1] ?? m[2] ?? m[3] ?? m[4];
|
||||
if (!raw || raw.includes('://') || /[*?]/.test(raw)) continue;
|
||||
let resolved: string;
|
||||
if (raw.startsWith('/')) resolved = posix.normalize(raw);
|
||||
else if (raw.startsWith('~/')) resolved = raw;
|
||||
else if (cwd.startsWith('/')) resolved = posix.resolve(cwd, raw);
|
||||
else continue;
|
||||
if (seen.has(resolved)) continue;
|
||||
seen.add(resolved);
|
||||
out.push(resolved);
|
||||
if (out.length >= MAX_IMAGE_CANDIDATES) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* The hunks of `patch` with their string lines, cut once the lines reach {@link MAX_HUNK_BYTES}
|
||||
* (`truncated`). Also applied to hunks loaded from a store file.
|
||||
*/
|
||||
export function capHunks(patch: readonly unknown[]): { hunks: EditHunk[]; truncated: boolean } {
|
||||
const hunks: EditHunk[] = [];
|
||||
let budget = MAX_HUNK_BYTES;
|
||||
let truncated = false;
|
||||
for (const h of patch) {
|
||||
if (!isRecord(h) || !Array.isArray(h.lines)) continue;
|
||||
if (truncated) break;
|
||||
const kept: string[] = [];
|
||||
for (const l of h.lines) {
|
||||
if (typeof l !== 'string') continue;
|
||||
const cost = Buffer.byteLength(l) + 3;
|
||||
if (cost > budget) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
budget -= cost;
|
||||
kept.push(l);
|
||||
}
|
||||
hunks.push({
|
||||
oldStart: Number(h.oldStart) || 0,
|
||||
oldLines: Number(h.oldLines) || 0,
|
||||
newStart: Number(h.newStart) || 0,
|
||||
newLines: Number(h.newLines) || 0,
|
||||
lines: kept,
|
||||
});
|
||||
}
|
||||
return { hunks, truncated };
|
||||
}
|
||||
|
||||
function toHunks(patch: unknown[]): { hunks: EditHunk[]; added: number; removed: number; truncated: boolean } {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const h of patch) {
|
||||
if (!isRecord(h) || !Array.isArray(h.lines)) continue;
|
||||
for (const l of h.lines) {
|
||||
if (typeof l !== 'string') continue;
|
||||
if (l.startsWith('+')) added++;
|
||||
else if (l.startsWith('-')) removed++;
|
||||
}
|
||||
}
|
||||
return { ...capHunks(patch), added, removed };
|
||||
}
|
||||
|
||||
/** A new file's content as one all-added hunk, in `structuredPatch` form. */
|
||||
function createdFileHunk(content: string): Record<string, unknown> {
|
||||
const lines = content.split('\n');
|
||||
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
||||
return { oldStart: 0, oldLines: 0, newStart: 1, newLines: lines.length, lines: lines.map((l) => `+${l}`) };
|
||||
}
|
||||
|
||||
function toEditRecord(id: string, ts: number, result: unknown, agentId: string | undefined): EditRecord | null {
|
||||
if (!isRecord(result)) return null;
|
||||
const { filePath, structuredPatch } = result;
|
||||
if (typeof filePath !== 'string' || !filePath || !Array.isArray(structuredPatch)) return null;
|
||||
const tool: EditTool = typeof result.type === 'string' ? 'Write' : 'oldString' in result ? 'Edit' : 'edit';
|
||||
const kind = classifyEditPath(filePath);
|
||||
// A Write that created a file reports an empty patch; its diff is the whole content.
|
||||
const patch =
|
||||
result.type === 'create' && structuredPatch.length === 0 && kind !== 'image' && typeof result.content === 'string'
|
||||
? [createdFileHunk(result.content)]
|
||||
: structuredPatch;
|
||||
const { hunks, added, removed, truncated } = toHunks(patch);
|
||||
const record: EditRecord = {
|
||||
id,
|
||||
ts,
|
||||
path: filePath,
|
||||
kind,
|
||||
tool,
|
||||
created: result.type === 'create',
|
||||
added,
|
||||
removed,
|
||||
hunks,
|
||||
truncated,
|
||||
};
|
||||
if (agentId) record.agentId = agentId;
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Image paths of a `SendUserFile` result (`{ caption, attachments: [{ path }] }`): the
|
||||
* files claude delivered to the user, which is how a session shows generated images.
|
||||
*/
|
||||
function sentImagePaths(result: unknown): string[] {
|
||||
if (!isRecord(result) || !Array.isArray(result.attachments)) return [];
|
||||
const out: string[] = [];
|
||||
for (const a of result.attachments) {
|
||||
if (!isRecord(a) || typeof a.path !== 'string' || !a.path.startsWith('/')) continue;
|
||||
if (IMAGE_PATH_RE.test(a.path) && !out.includes(a.path)) out.push(a.path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The text of a `tool_result` block's content (a string or a list of text blocks). */
|
||||
function blockText(content: unknown): string {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.map((b) => (isRecord(b) && typeof b.text === 'string' ? b.text : ''))
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse transcript lines into edit records and Bash image candidates.
|
||||
*
|
||||
* @param text complete JSONL lines; a first line cut mid-record (a byte range starting
|
||||
* inside it) and anything else that does not parse is skipped.
|
||||
* @param pending unanswered Bash calls from the previous chunk.
|
||||
*/
|
||||
export function parseClaudeEditLines(text: string, pending: readonly PendingBash[] = []): EditParseResult {
|
||||
const open = new Map<string, PendingBash>(pending.map((p) => [p.id, p]));
|
||||
const records: EditRecord[] = [];
|
||||
const candidates: ImageCandidate[] = [];
|
||||
|
||||
for (const rawLine of text.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
let rec: unknown;
|
||||
try {
|
||||
rec = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(rec)) continue;
|
||||
const message = rec.message;
|
||||
const content = isRecord(message) ? message.content : undefined;
|
||||
if (!Array.isArray(content)) continue;
|
||||
const parsedTs = typeof rec.timestamp === 'string' ? Date.parse(rec.timestamp) : NaN;
|
||||
const ts = Number.isFinite(parsedTs) ? parsedTs : 0;
|
||||
const agentId = typeof rec.agentId === 'string' && rec.agentId ? rec.agentId : undefined;
|
||||
const cwd = typeof rec.cwd === 'string' ? rec.cwd : '';
|
||||
|
||||
if (rec.type === 'assistant') {
|
||||
for (const block of content) {
|
||||
if (!isRecord(block) || block.type !== 'tool_use' || block.name !== 'Bash') continue;
|
||||
if (typeof block.id !== 'string' || !isRecord(block.input)) continue;
|
||||
const command = block.input.command;
|
||||
if (typeof command !== 'string') continue;
|
||||
if ([block.id, cwd, agentId ?? ''].some((f) => f.length > MAX_PENDING_FIELD_CHARS)) continue;
|
||||
const entry: PendingBash = { id: block.id, ts, command: command.slice(0, MAX_PENDING_COMMAND_CHARS), cwd };
|
||||
if (agentId) entry.agentId = agentId;
|
||||
open.delete(block.id);
|
||||
open.set(block.id, entry);
|
||||
while (open.size > MAX_PENDING_BASH) open.delete(open.keys().next().value as string);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rec.type !== 'user') continue;
|
||||
const results = content.filter(
|
||||
(b): b is Record<string, unknown> => isRecord(b) && b.type === 'tool_result' && typeof b.tool_use_id === 'string'
|
||||
);
|
||||
// `toolUseResult` describes the line's result; it is unambiguous only for one block.
|
||||
const single = results.length === 1;
|
||||
for (const block of results) {
|
||||
const id = block.tool_use_id as string;
|
||||
if (single) {
|
||||
const edit = toEditRecord(id, ts, rec.toolUseResult, agentId);
|
||||
if (edit) records.push(edit);
|
||||
}
|
||||
const sent = single ? sentImagePaths(rec.toolUseResult) : [];
|
||||
if (sent.length > 0) {
|
||||
const candidate: ImageCandidate = { toolUseId: id, start: ts, end: ts, paths: sent, sent: true };
|
||||
if (agentId) candidate.agentId = agentId;
|
||||
candidates.push(candidate);
|
||||
}
|
||||
const call = open.get(id);
|
||||
if (!call) continue;
|
||||
open.delete(id);
|
||||
const r = single ? rec.toolUseResult : undefined;
|
||||
let stdout: string;
|
||||
let stderr = '';
|
||||
if (isRecord(r) && (typeof r.stdout === 'string' || typeof r.stderr === 'string')) {
|
||||
stdout = typeof r.stdout === 'string' ? r.stdout : '';
|
||||
stderr = typeof r.stderr === 'string' ? r.stderr : '';
|
||||
} else {
|
||||
stdout = blockText(block.content);
|
||||
}
|
||||
const paths = extractImagePathCandidates(call.command, stdout, stderr, call.cwd);
|
||||
if (paths.length === 0) continue;
|
||||
const candidate: ImageCandidate = { toolUseId: id, start: call.ts, end: ts, paths };
|
||||
if (call.agentId) candidate.agentId = call.agentId;
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return { records, candidates, pending: [...open.values()] };
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
/**
|
||||
* @fileoverview Per-session edit history: the parsed edits and Bash images of one claude
|
||||
* session, plus where in each transcript file reading stopped.
|
||||
*
|
||||
* Sources are the main transcript (`main`) and each subagent transcript
|
||||
* (`subagents/agent-<id>.jsonl`). Each has a cursor:
|
||||
* - `offset`: the next byte to read forward; always a line boundary.
|
||||
* - `floor`: the lowest byte already parsed; > 0 while older lines are unread
|
||||
* ("ältere laden" reads `[floor − budget, floor)`, see `olderRange`).
|
||||
* The reader (`edit-history-local-source.ts`) reads forward from each `offset` of the
|
||||
* current conversation, a file without one from `budget` bytes before its end, and hands the
|
||||
* bytes to `apply`. Cuts happen only at `\n`, which never occurs inside a UTF-8 sequence, so
|
||||
* multibyte characters split by a chunk boundary are never decoded half.
|
||||
*
|
||||
* A first read of a file starts `budget` bytes before its end (transcripts reach 100 MB+),
|
||||
* drops the cut first line and leaves `floor` there. A line longer than the budget is
|
||||
* skipped instead of blocking the cursor, reading forward (`midLine`) and backward
|
||||
* (`floorMidLine`); the line after it (forward) or before it (backward) is read whole.
|
||||
* That needs every chunk to be a full budget unless it reaches the file's end (forward) or
|
||||
* start (older): a chunk with no line end then means one line longer than the budget. The
|
||||
* caller does not hand over a chunk that a budget shared by several files cut short.
|
||||
*
|
||||
* A new conversation (`/clear`, resume) arrives as a new `transcriptId`: cursors and
|
||||
* pending Bash calls start over, entries stay. Entries are capped at {@link MAX_ENTRIES}
|
||||
* and {@link MAX_STORE_BYTES} of diff text; the oldest by time are dropped. Besides them
|
||||
* the file holds one cursor per transcript file of the current conversation, at most
|
||||
* {@link MAX_PENDING_BASH} unanswered Bash calls and at most {@link MAX_AGENTS} subagent labels.
|
||||
*
|
||||
* ⚠️ The file holds secrets: a diff carries whatever the agent wrote, keys and `.env`
|
||||
* contents included. So, like `intents.json`, it is written 0600 via tmp+rename, bounded,
|
||||
* deleted with the session (`remove()`; `sweep()` at startup removes files no session owns),
|
||||
* never fed to `/api/search`, and refused by the attachment, folder and download routes through
|
||||
* `isSensitivePath` (`web/sensitive-path.ts`). `load()` takes the file as untrusted: past
|
||||
* {@link MAX_FILE_BYTES} it is not parsed, and every part is rebuilt under the same bounds.
|
||||
*
|
||||
* Persisted as JSON under `dataPath('edit-history', '<sessionId>.json')`, written debounced.
|
||||
*
|
||||
* Tests: `test/edit-history-store.test.ts`.
|
||||
*
|
||||
* @module edit-history-store
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import {
|
||||
capHunks,
|
||||
MAX_HUNK_BYTES,
|
||||
MAX_PENDING_BASH,
|
||||
MAX_PENDING_COMMAND_CHARS,
|
||||
MAX_PENDING_FIELD_CHARS,
|
||||
parseClaudeEditLines,
|
||||
type EditRecord,
|
||||
type EditTool,
|
||||
type ImageCandidate,
|
||||
type PendingBash,
|
||||
} from './edit-history-parse.js';
|
||||
|
||||
export const MAX_ENTRIES = 500;
|
||||
/** Diff text (and paths) kept per session; see {@link entryBytes}. */
|
||||
export const MAX_STORE_BYTES = 4 * 1024 * 1024;
|
||||
/** Longest string kept from a subagent's `meta.json` field. */
|
||||
export const MAX_AGENT_META_CHARS = 1024;
|
||||
/**
|
||||
* Subagent labels kept; past it the oldest one no entry uses is dropped. One more than
|
||||
* {@link MAX_ENTRIES}, so besides the newest label there always is one.
|
||||
*/
|
||||
export const MAX_AGENTS = MAX_ENTRIES + 1;
|
||||
/** Bytes read per file and pass (first read, forward step, "older" step). */
|
||||
export const READ_BUDGET_BYTES = 8 * 1024 * 1024;
|
||||
/**
|
||||
* A store file larger than this is not parsed (starts empty). One written within the caps
|
||||
* above stays far below it; JSON escaping at most doubles ordinary text.
|
||||
*/
|
||||
export const MAX_FILE_BYTES = 32 * 1024 * 1024;
|
||||
export const MAIN_SOURCE = 'main';
|
||||
const SAVE_DEBOUNCE_MS = 1000;
|
||||
const FILE_VERSION = 1;
|
||||
const NL = 0x0a;
|
||||
/** A transcript id as the reader takes it (`<id>.jsonl`): no path separators. */
|
||||
const TRANSCRIPT_ID = /^[A-Za-z0-9._-]+$/;
|
||||
/**
|
||||
* Names this process's tmp files (`<id>.json.<tag>.tmp`); any other tmp file is left over from
|
||||
* an earlier run (`sweep`), even one whose pid this process has again (a container's pid 1).
|
||||
*/
|
||||
export const TMP_TAG = `${process.pid}-${randomBytes(4).toString('hex')}`;
|
||||
|
||||
/** `bash`: an image a Bash call wrote; `sent`: an image claude delivered to the user. */
|
||||
export type EditHistoryTool = EditTool | 'bash' | 'sent';
|
||||
|
||||
export interface EditHistoryEntry extends Omit<EditRecord, 'tool'> {
|
||||
tool: EditHistoryTool;
|
||||
}
|
||||
|
||||
export interface SourceCursor {
|
||||
offset: number;
|
||||
floor: number;
|
||||
/** The next forward read starts inside a line longer than the budget; skip to its end. */
|
||||
midLine?: boolean;
|
||||
/** `floor` lies inside a line longer than the budget; the next older read drops its tail. */
|
||||
floorMidLine?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentMeta {
|
||||
description: string;
|
||||
agentType: string;
|
||||
worktreePath?: string;
|
||||
worktreeBranch?: string;
|
||||
}
|
||||
|
||||
export interface ByteRange {
|
||||
offset: number;
|
||||
len: number;
|
||||
}
|
||||
|
||||
export interface ApplyInput {
|
||||
transcriptId: string;
|
||||
/** `main` or `subagents/agent-<id>.jsonl`. */
|
||||
source: string;
|
||||
startOffset: number;
|
||||
chunk: Buffer;
|
||||
/** Size of the file when the chunk was read. */
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface ApplyResult {
|
||||
added: number;
|
||||
candidates: ImageCandidate[];
|
||||
}
|
||||
|
||||
export interface ConfirmedImage {
|
||||
toolUseId: string;
|
||||
path: string;
|
||||
ts: number;
|
||||
agentId?: string;
|
||||
tool?: 'bash' | 'sent';
|
||||
}
|
||||
|
||||
export interface EditHistoryStoreOptions {
|
||||
/** Directory holding `<sessionId>.json`; default `dataPath('edit-history')`. */
|
||||
dir?: string;
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
interface StoreFile {
|
||||
version: number;
|
||||
sessionId: string;
|
||||
transcriptId: string | null;
|
||||
cursors: Record<string, SourceCursor>;
|
||||
pendingBash: PendingBash[];
|
||||
agents: Record<string, AgentMeta>;
|
||||
entries: EditHistoryEntry[];
|
||||
}
|
||||
|
||||
function isObj(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/** What an entry costs against {@link MAX_STORE_BYTES}: its strings and diff lines, plus a fixed overhead. */
|
||||
export function entryBytes(e: EditHistoryEntry): number {
|
||||
let n = 128 + Buffer.byteLength(e.id) + Buffer.byteLength(e.path) + Buffer.byteLength(e.agentId ?? '');
|
||||
for (const h of e.hunks) for (const l of h.lines) n += Buffer.byteLength(l) + 3;
|
||||
return n;
|
||||
}
|
||||
|
||||
function metaString(v: unknown): string | undefined {
|
||||
return typeof v === 'string' && v ? v.slice(0, MAX_AGENT_META_CHARS) : undefined;
|
||||
}
|
||||
|
||||
/** Parse an `agent-<id>.meta.json` text; null when it is not an object. */
|
||||
export function parseAgentMeta(text: string): AgentMeta | null {
|
||||
try {
|
||||
return agentMetaFrom(JSON.parse(text));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isOffset(v: unknown): v is number {
|
||||
return Number.isSafeInteger(v) && (v as number) >= 0;
|
||||
}
|
||||
|
||||
// The loaders below take the persisted file as untrusted: a hand-edited or damaged store
|
||||
// loses the broken parts instead of failing every request or stalling a cursor.
|
||||
|
||||
function agentMetaFrom(raw: unknown): AgentMeta | null {
|
||||
if (!isObj(raw)) return null;
|
||||
const meta: AgentMeta = {
|
||||
description: metaString(raw.description) ?? '',
|
||||
agentType: metaString(raw.agentType) ?? '',
|
||||
};
|
||||
const worktreePath = metaString(raw.worktreePath);
|
||||
const worktreeBranch = metaString(raw.worktreeBranch);
|
||||
if (worktreePath) meta.worktreePath = worktreePath;
|
||||
if (worktreeBranch) meta.worktreeBranch = worktreeBranch;
|
||||
return meta;
|
||||
}
|
||||
|
||||
function cursorFrom(raw: unknown): SourceCursor | null {
|
||||
if (!isObj(raw) || !isOffset(raw.offset) || !isOffset(raw.floor) || raw.floor > raw.offset) return null;
|
||||
const cursor: SourceCursor = { offset: raw.offset, floor: raw.floor };
|
||||
if (raw.midLine === true) cursor.midLine = true;
|
||||
if (raw.floorMidLine === true) cursor.floorMidLine = true;
|
||||
return cursor;
|
||||
}
|
||||
|
||||
const loadedField = (v: unknown): v is string => typeof v === 'string' && v.length <= MAX_PENDING_FIELD_CHARS;
|
||||
|
||||
function pendingBashFrom(raw: unknown): PendingBash | null {
|
||||
if (!isObj(raw)) return null;
|
||||
const { id, ts, command, cwd, agentId } = raw;
|
||||
if (!loadedField(id) || !Number.isFinite(ts) || typeof command !== 'string' || !loadedField(cwd)) return null;
|
||||
if (agentId !== undefined && !loadedField(agentId)) return null;
|
||||
const pending: PendingBash = { id, ts: ts as number, command: command.slice(0, MAX_PENDING_COMMAND_CHARS), cwd };
|
||||
if (agentId) pending.agentId = agentId;
|
||||
return pending;
|
||||
}
|
||||
|
||||
const ENTRY_KINDS = new Set(['code', 'image']);
|
||||
const ENTRY_TOOLS = new Set(['Edit', 'Write', 'edit', 'bash', 'sent']);
|
||||
const countFrom = (v: unknown): number => (Number.isSafeInteger(v) && (v as number) >= 0 ? (v as number) : 0);
|
||||
|
||||
/**
|
||||
* A loaded entry rebuilt from its known fields: unknown fields and non-string diff lines are
|
||||
* dropped and the per-edit {@link MAX_HUNK_BYTES} is applied again; null when it is unusable.
|
||||
*/
|
||||
function entryFrom(raw: unknown): EditHistoryEntry | null {
|
||||
if (!isObj(raw) || typeof raw.id !== 'string' || !raw.id || typeof raw.path !== 'string' || !raw.path) return null;
|
||||
if (!Number.isFinite(raw.ts) || !ENTRY_KINDS.has(raw.kind as string) || !ENTRY_TOOLS.has(raw.tool as string)) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(raw.hunks)) return null;
|
||||
const { hunks, truncated } = capHunks(raw.hunks);
|
||||
const entry: EditHistoryEntry = {
|
||||
id: raw.id,
|
||||
ts: raw.ts as number,
|
||||
path: raw.path,
|
||||
kind: raw.kind as EditHistoryEntry['kind'],
|
||||
tool: raw.tool as EditHistoryTool,
|
||||
created: raw.created === true,
|
||||
added: countFrom(raw.added),
|
||||
removed: countFrom(raw.removed),
|
||||
hunks,
|
||||
truncated: truncated || raw.truncated === true,
|
||||
};
|
||||
if (typeof raw.agentId === 'string' && raw.agentId) entry.agentId = raw.agentId;
|
||||
return entry;
|
||||
}
|
||||
|
||||
export class EditHistoryStore {
|
||||
readonly sessionId: string;
|
||||
private readonly file: string;
|
||||
private readonly debounceMs: number;
|
||||
private transcriptId: string | null = null;
|
||||
private cursors = new Map<string, SourceCursor>();
|
||||
private pendingBash: PendingBash[] = [];
|
||||
private agents = new Map<string, AgentMeta>();
|
||||
private entries = new Map<string, EditHistoryEntry>();
|
||||
/** Sum of {@link entryBytes} over `entries`. */
|
||||
private bytes = 0;
|
||||
private saveTimer: NodeJS.Timeout | null = null;
|
||||
/** The last queued write; `remove()` waits for it. */
|
||||
private writing: Promise<void> | null = null;
|
||||
private removed = false;
|
||||
|
||||
constructor(sessionId: string, opts: EditHistoryStoreOptions = {}) {
|
||||
this.sessionId = sessionId;
|
||||
this.file = join(opts.dir ?? dataPath('edit-history'), `${sessionId}.json`);
|
||||
this.debounceMs = opts.debounceMs ?? SAVE_DEBOUNCE_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* A store with the persisted state of `sessionId`; empty when the file is missing,
|
||||
* unreadable or larger than {@link MAX_FILE_BYTES}.
|
||||
*/
|
||||
static async load(sessionId: string, opts: EditHistoryStoreOptions = {}): Promise<EditHistoryStore> {
|
||||
const store = new EditHistoryStore(sessionId, opts);
|
||||
let data: unknown;
|
||||
try {
|
||||
if ((await stat(store.file)).size > MAX_FILE_BYTES) return store;
|
||||
data = JSON.parse(await readFile(store.file, 'utf-8'));
|
||||
} catch {
|
||||
return store;
|
||||
}
|
||||
if (!isObj(data)) return store;
|
||||
if (data.version !== FILE_VERSION) return store;
|
||||
const f = data as unknown as StoreFile;
|
||||
// The sync builds the previous transcript's path from it (`/clear`), so only a plain id counts.
|
||||
store.transcriptId =
|
||||
typeof f.transcriptId === 'string' && TRANSCRIPT_ID.test(f.transcriptId) ? f.transcriptId : null;
|
||||
// Cursors are offsets into that conversation's files; without it they mean nothing.
|
||||
const cursors = store.transcriptId !== null && isObj(f.cursors) ? f.cursors : {};
|
||||
for (const [k, raw] of Object.entries(cursors)) {
|
||||
const cursor = cursorFrom(raw);
|
||||
if (cursor) store.cursors.set(k, cursor);
|
||||
}
|
||||
if (Array.isArray(f.pendingBash)) {
|
||||
store.pendingBash = f.pendingBash
|
||||
.map(pendingBashFrom)
|
||||
.filter((p): p is PendingBash => p !== null)
|
||||
.slice(-MAX_PENDING_BASH);
|
||||
}
|
||||
if (Array.isArray(f.entries)) {
|
||||
store.addRecords(f.entries.map(entryFrom).filter((e): e is EditHistoryEntry => e !== null));
|
||||
}
|
||||
for (const [k, raw] of Object.entries(isObj(f.agents) ? f.agents : {})) {
|
||||
const meta = k.length <= MAX_AGENT_META_CHARS ? agentMetaFrom(raw) : null;
|
||||
if (meta) store.putAgent(k, meta);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the stored history of every session `keep` rejects, and tmp files other processes
|
||||
* left behind (startup: a crash between removing a session's record and deleting its file,
|
||||
* or mid-write; an older build).
|
||||
* Returns how many sessions' files were deleted.
|
||||
*/
|
||||
static async sweep(
|
||||
keep: (sessionId: string) => boolean,
|
||||
opts: Pick<EditHistoryStoreOptions, 'dir'> = {}
|
||||
): Promise<number> {
|
||||
const dir = opts.dir ?? dataPath('edit-history');
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(dir);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
let removed = 0;
|
||||
for (const name of names) {
|
||||
const tmp = /\.json\.[\w-]+\.tmp$/.test(name);
|
||||
const stale = tmp
|
||||
? !name.endsWith(`.json.${TMP_TAG}.tmp`)
|
||||
: name.endsWith('.json') && !keep(name.slice(0, -'.json'.length));
|
||||
if (!stale) continue;
|
||||
await unlink(join(dir, name)).catch(() => undefined);
|
||||
if (!tmp) removed++;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** Delete the stored history of `sessionId` without loading it. */
|
||||
static async delete(sessionId: string, opts: Pick<EditHistoryStoreOptions, 'dir'> = {}): Promise<void> {
|
||||
await new EditHistoryStore(sessionId, opts).remove();
|
||||
}
|
||||
|
||||
getTranscriptId(): string | null {
|
||||
return this.transcriptId;
|
||||
}
|
||||
|
||||
getCursor(source: string): SourceCursor | undefined {
|
||||
const c = this.cursors.get(source);
|
||||
return c ? { ...c } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* True while any source has unread older lines that could still be shown. At a cap (no
|
||||
* room left for another entry) an older edit would be dropped the moment it is read, so
|
||||
* there is nothing more to load.
|
||||
*/
|
||||
get backfillTruncated(): boolean {
|
||||
if (this.entries.size >= MAX_ENTRIES || this.bytes > MAX_STORE_BYTES - MAX_HUNK_BYTES) return false;
|
||||
for (const c of this.cursors.values()) if (c.floor > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Entries newest first. */
|
||||
list(): EditHistoryEntry[] {
|
||||
return [...this.entries.values()].sort((a, b) => b.ts - a.ts);
|
||||
}
|
||||
|
||||
get(id: string): EditHistoryEntry | undefined {
|
||||
return this.entries.get(id);
|
||||
}
|
||||
|
||||
getAgents(): Record<string, AgentMeta> {
|
||||
return Object.fromEntries(this.agents);
|
||||
}
|
||||
|
||||
hasAgentMeta(agentId: string): boolean {
|
||||
return this.agents.has(agentId);
|
||||
}
|
||||
|
||||
/** Record a subagent's `meta.json`; ignored when it does not parse. */
|
||||
setAgentMeta(agentId: string, metaText: string): void {
|
||||
const meta = this.removed ? null : parseAgentMeta(metaText);
|
||||
if (!meta) return;
|
||||
this.putAgent(agentId, meta);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
/** The next older range of `source` ("ältere laden"), or null when it is read to its start. */
|
||||
olderRange(source: string, budget = READ_BUDGET_BYTES): ByteRange | null {
|
||||
const cursor = this.cursors.get(source);
|
||||
if (!cursor || cursor.floor <= 0) return null;
|
||||
const offset = Math.max(0, cursor.floor - budget);
|
||||
return { offset, len: cursor.floor - offset };
|
||||
}
|
||||
|
||||
/** Every source with a cursor in the current conversation. */
|
||||
knownSources(): string[] {
|
||||
return [...this.cursors.keys()];
|
||||
}
|
||||
|
||||
/** Sources with older lines left. */
|
||||
sourcesWithOlder(): string[] {
|
||||
return [...this.cursors.entries()].filter(([, c]) => c.floor > 0).map(([s]) => s);
|
||||
}
|
||||
|
||||
/**
|
||||
* A source whose file is gone (a deleted subagent transcript): its cursor goes, so it no
|
||||
* longer offers older lines. Its entries stay.
|
||||
*/
|
||||
dropSource(source: string): void {
|
||||
if (this.removed || !this.cursors.delete(source)) return;
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a forward chunk: read from the source's cursor, or, without one, its last `budget`
|
||||
* bytes; a full budget unless it reaches the file's end. A file that shrank below its cursor
|
||||
* (replaced) loses the cursor and is read from its end next time. Returns new entries and
|
||||
* Bash image candidates.
|
||||
*/
|
||||
apply(input: ApplyInput): ApplyResult {
|
||||
if (this.removed) return { added: 0, candidates: [] };
|
||||
const { transcriptId, source, startOffset, chunk, size } = input;
|
||||
if (transcriptId !== this.transcriptId) {
|
||||
this.transcriptId = transcriptId;
|
||||
this.cursors.clear();
|
||||
this.pendingBash = [];
|
||||
}
|
||||
let cursor = this.cursors.get(source);
|
||||
if (cursor && size < cursor.offset) {
|
||||
this.cursors.delete(source);
|
||||
cursor = undefined;
|
||||
}
|
||||
if (cursor && startOffset !== cursor.offset) return { added: 0, candidates: [] };
|
||||
|
||||
const atEof = startOffset + chunk.length >= size;
|
||||
// Bytes before `from` belong to a line that started before this chunk.
|
||||
let from = 0;
|
||||
if (cursor ? cursor.midLine : startOffset > 0) {
|
||||
const nl = chunk.indexOf(NL);
|
||||
if (nl === -1) {
|
||||
// Still inside that line: move past it unless it is the line being written. A first
|
||||
// read began inside it, so the floor does too.
|
||||
if (!atEof) this.setCursor(source, cursor, startOffset + chunk.length, startOffset, true, true);
|
||||
return { added: 0, candidates: [] };
|
||||
}
|
||||
from = nl + 1;
|
||||
}
|
||||
const lastNl = chunk.lastIndexOf(NL);
|
||||
if (lastNl < from) {
|
||||
if (from > 0) {
|
||||
// The skipped (or cut first) line ended here; the next one is read from its start.
|
||||
this.setCursor(source, cursor, startOffset + from, startOffset + from, false);
|
||||
} else if (!atEof && chunk.length > 0) {
|
||||
// One line longer than the budget: skip it instead of re-reading it forever.
|
||||
this.setCursor(source, cursor, startOffset + chunk.length, startOffset, true);
|
||||
} else if (!cursor) {
|
||||
this.setCursor(source, undefined, startOffset, startOffset, false);
|
||||
}
|
||||
return { added: 0, candidates: [] };
|
||||
}
|
||||
const end = lastNl + 1;
|
||||
this.setCursor(source, cursor, startOffset + end, startOffset + from, false);
|
||||
const parsed = parseClaudeEditLines(chunk.subarray(from, end).toString('utf8'), this.pendingBash);
|
||||
this.pendingBash = parsed.pending;
|
||||
const added = this.addRecords(parsed.records);
|
||||
this.scheduleSave();
|
||||
return { added, candidates: parsed.candidates };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a chunk read at `olderRange`: a full budget unless it reaches the file's start. It
|
||||
* ends at the floor, which is a line start, or with `floorMidLine` lies inside a line longer
|
||||
* than the budget (its part in this chunk is dropped).
|
||||
*/
|
||||
applyOlder(input: ApplyInput): ApplyResult {
|
||||
const { transcriptId, source, startOffset, chunk } = input;
|
||||
const cursor = this.removed ? undefined : this.cursors.get(source);
|
||||
if (transcriptId !== this.transcriptId || !cursor || startOffset + chunk.length !== cursor.floor) {
|
||||
return { added: 0, candidates: [] };
|
||||
}
|
||||
const none = { added: 0, candidates: [] };
|
||||
let end = chunk.length;
|
||||
if (cursor.floorMidLine) {
|
||||
const lastNl = chunk.lastIndexOf(NL);
|
||||
if (lastNl === -1 && startOffset > 0) {
|
||||
// Still inside the long line.
|
||||
cursor.floor = startOffset;
|
||||
this.scheduleSave();
|
||||
return none;
|
||||
}
|
||||
// The long line starts at `end`: drop its part.
|
||||
end = lastNl + 1;
|
||||
delete cursor.floorMidLine;
|
||||
}
|
||||
let from = 0;
|
||||
if (startOffset > 0) {
|
||||
// The newline at `end - 1` closes a line that may have started before this chunk.
|
||||
const nl = chunk.subarray(0, end).indexOf(NL);
|
||||
if (nl === -1 || nl === end - 1) {
|
||||
if (end < chunk.length) {
|
||||
// It ends where the long line starts: the next block, ending there, holds it whole.
|
||||
cursor.floor = startOffset + end;
|
||||
} else {
|
||||
// No line starts in this chunk: it lies inside one line longer than the budget. Skip it.
|
||||
cursor.floor = startOffset;
|
||||
cursor.floorMidLine = true;
|
||||
}
|
||||
this.scheduleSave();
|
||||
return none;
|
||||
}
|
||||
from = nl + 1;
|
||||
}
|
||||
cursor.floor = startOffset + from;
|
||||
// Calls whose results lie in already-read newer lines cannot be paired here.
|
||||
const parsed = parseClaudeEditLines(chunk.subarray(from, end).toString('utf8'));
|
||||
const added = this.addRecords(parsed.records);
|
||||
this.scheduleSave();
|
||||
return { added, candidates: parsed.candidates };
|
||||
}
|
||||
|
||||
/** Add Bash images whose mtime was confirmed inside their call's window. */
|
||||
addImageEntries(images: readonly ConfirmedImage[]): number {
|
||||
if (this.removed) return 0;
|
||||
const records: EditHistoryEntry[] = images.map((img) => {
|
||||
const entry: EditHistoryEntry = {
|
||||
id: `${img.toolUseId}:${img.path}`,
|
||||
ts: img.ts,
|
||||
path: img.path,
|
||||
kind: 'image',
|
||||
tool: img.tool ?? 'bash',
|
||||
created: false,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
hunks: [],
|
||||
truncated: false,
|
||||
};
|
||||
if (img.agentId) entry.agentId = img.agentId;
|
||||
return entry;
|
||||
});
|
||||
const added = this.addRecords(records);
|
||||
if (added) this.scheduleSave();
|
||||
return added;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write pending changes, if any, and wait for a write under way (the store is dropped from
|
||||
* memory and loaded again later). Unlike `flush()`, a store without changes writes nothing.
|
||||
*/
|
||||
async settle(): Promise<void> {
|
||||
if (this.saveTimer) return this.flush();
|
||||
await this.writing;
|
||||
}
|
||||
|
||||
/** Write now (cancels a pending debounced write). Writes run one after another. */
|
||||
async flush(): Promise<void> {
|
||||
if (this.saveTimer) {
|
||||
clearTimeout(this.saveTimer);
|
||||
this.saveTimer = null;
|
||||
}
|
||||
const run = (this.writing ?? Promise.resolve()).then(() => this.write());
|
||||
this.writing = run.catch(() => undefined);
|
||||
await run;
|
||||
}
|
||||
|
||||
/** True once {@link remove} ran: the store ignores every later write. */
|
||||
isRemoved(): boolean {
|
||||
return this.removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the persisted file (session deleted) and forget the content. The store takes no
|
||||
* changes afterwards, so a read still in flight neither writes nor refills it.
|
||||
*/
|
||||
async remove(): Promise<void> {
|
||||
this.removed = true;
|
||||
if (this.saveTimer) {
|
||||
clearTimeout(this.saveTimer);
|
||||
this.saveTimer = null;
|
||||
}
|
||||
this.entries.clear();
|
||||
this.bytes = 0;
|
||||
this.cursors.clear();
|
||||
this.pendingBash = [];
|
||||
this.agents.clear();
|
||||
// A write already under way ends first (or sees `removed`), so it cannot rename its
|
||||
// tmp file over the deleted store afterwards.
|
||||
await this.writing;
|
||||
await unlink(this.file).catch(() => undefined);
|
||||
}
|
||||
|
||||
private async write(): Promise<void> {
|
||||
if (this.removed) return;
|
||||
const data: StoreFile = {
|
||||
version: FILE_VERSION,
|
||||
sessionId: this.sessionId,
|
||||
transcriptId: this.transcriptId,
|
||||
cursors: Object.fromEntries(this.cursors),
|
||||
pendingBash: this.pendingBash,
|
||||
agents: Object.fromEntries(this.agents),
|
||||
entries: this.list(),
|
||||
};
|
||||
const dir = join(this.file, '..');
|
||||
await mkdir(dir, { recursive: true, mode: 0o700 });
|
||||
// `mode` applies only to a file being created: the fresh tmp file is 0600, and the rename
|
||||
// makes it the store, so an existing store never keeps wider bits.
|
||||
const tmp = `${this.file}.${TMP_TAG}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify(data), { mode: 0o600 });
|
||||
if (this.removed) {
|
||||
await unlink(tmp).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
await rename(tmp, this.file);
|
||||
}
|
||||
|
||||
private putAgent(agentId: string, meta: AgentMeta): void {
|
||||
this.agents.delete(agentId);
|
||||
this.agents.set(agentId, meta);
|
||||
if (this.agents.size <= MAX_AGENTS) return;
|
||||
const used = new Set<string>();
|
||||
for (const e of this.entries.values()) if (e.agentId) used.add(e.agentId);
|
||||
for (const id of this.agents.keys()) {
|
||||
if (this.agents.size <= MAX_AGENTS) break;
|
||||
// The newest label has no entries yet: they come with its transcript.
|
||||
if (id !== agentId && !used.has(id)) this.agents.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
private setCursor(
|
||||
source: string,
|
||||
cursor: SourceCursor | undefined,
|
||||
offset: number,
|
||||
firstFloor: number,
|
||||
midLine: boolean,
|
||||
firstFloorMidLine = false
|
||||
): void {
|
||||
const next: SourceCursor = { offset, floor: cursor ? cursor.floor : firstFloor };
|
||||
if (midLine) next.midLine = true;
|
||||
if (cursor ? cursor.floorMidLine : firstFloorMidLine) next.floorMidLine = true;
|
||||
this.cursors.set(source, next);
|
||||
}
|
||||
|
||||
private addRecords(records: readonly EditHistoryEntry[]): number {
|
||||
const fresh: string[] = [];
|
||||
for (const r of records) {
|
||||
if (this.entries.has(r.id)) continue;
|
||||
this.entries.set(r.id, r);
|
||||
this.bytes += entryBytes(r);
|
||||
fresh.push(r.id);
|
||||
}
|
||||
if (this.entries.size > MAX_ENTRIES || this.bytes > MAX_STORE_BYTES) {
|
||||
for (const e of [...this.entries.values()].sort((a, b) => a.ts - b.ts)) {
|
||||
if (this.entries.size <= MAX_ENTRIES && this.bytes <= MAX_STORE_BYTES) break;
|
||||
this.entries.delete(e.id);
|
||||
this.bytes -= entryBytes(e);
|
||||
}
|
||||
}
|
||||
// Only what is still there counts: an "older" block past the cap adds nothing.
|
||||
return fresh.filter((id) => this.entries.has(id)).length;
|
||||
}
|
||||
|
||||
private scheduleSave(): void {
|
||||
if (this.removed || this.saveTimer) return;
|
||||
this.saveTimer = setTimeout(() => {
|
||||
this.saveTimer = null;
|
||||
void this.flush().catch((err) => {
|
||||
console.warn(`[EditHistory] Save failed for session ${this.sessionId}:`, err);
|
||||
});
|
||||
}, this.debounceMs);
|
||||
this.saveTimer.unref?.();
|
||||
}
|
||||
}
|
||||
+90
-15
@@ -50,9 +50,10 @@
|
||||
import { spawn, execFileSync } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { rename, rm } from 'node:fs/promises';
|
||||
import { lstat, readdir, rename, rm } from 'node:fs/promises';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import { probePathKind } from './utils/bounded-path-probe.js';
|
||||
|
||||
// ─── Tunables ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -188,8 +189,13 @@ export interface GitFailure {
|
||||
export interface CloneOptions {
|
||||
/** Pre-validated operand from `parseGitRepositoryUrl`. */
|
||||
repository: string;
|
||||
/** Absolute destination directory. Must NOT exist; created by git. */
|
||||
/** Absolute destination directory. Must NOT exist (created by git), unless `intoEmptyDir` is set. */
|
||||
destination: string;
|
||||
/**
|
||||
* Clone straight into `destination` when it already exists as an EMPTY directory (the folder a user
|
||||
* picked), instead of refusing it. A folder with anything in it, or a symlink, is still refused.
|
||||
*/
|
||||
intoEmptyDir?: boolean;
|
||||
/** Optional branch or tag (`--branch <ref> --single-branch`). */
|
||||
ref?: string;
|
||||
/** `--depth 1`: history-less but much faster on large repos. */
|
||||
@@ -590,6 +596,11 @@ export function classifyGitFailure(stderr: string, timedOut: boolean, spawnError
|
||||
stderr: clean,
|
||||
};
|
||||
}
|
||||
// First: git's refusal quotes the destination path, so a folder named openssl, rustls or "not found" would
|
||||
// otherwise match the host and repository patterns below, and a wrong code here decides a deletion.
|
||||
if (/already exists and is not an empty directory|destination path .* already exists/.test(lower)) {
|
||||
return { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: clean };
|
||||
}
|
||||
if (timedOut) {
|
||||
return {
|
||||
code: 'TIMEOUT',
|
||||
@@ -625,12 +636,13 @@ export function classifyGitFailure(stderr: string, timedOut: boolean, spawnError
|
||||
stderr: clean,
|
||||
};
|
||||
}
|
||||
if (/could not resolve host|connection refused|connection timed out|network is unreachable|ssl|tls/.test(lower)) {
|
||||
if (
|
||||
/could not resolve host|connection refused|connection timed out|network is unreachable|\bssl(?:\b|_)|\btls\b|certificate|handshake/.test(
|
||||
lower
|
||||
)
|
||||
) {
|
||||
return { code: 'HOST_UNREACHABLE', message: 'Could not reach that host from this machine.', stderr: clean };
|
||||
}
|
||||
if (/already exists and is not an empty directory|destination path .* already exists/.test(lower)) {
|
||||
return { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: clean };
|
||||
}
|
||||
return { code: 'FAILED', message: clean ? `git failed: ${firstLine(clean)}` : 'git failed.', stderr: clean };
|
||||
}
|
||||
|
||||
@@ -701,6 +713,8 @@ interface GitRun {
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
timedOut: boolean;
|
||||
/** False when git never ran (queue full, slot wait timed out, spawn failed): nothing was written. */
|
||||
started: boolean;
|
||||
spawnError?: string;
|
||||
}
|
||||
|
||||
@@ -720,10 +734,17 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number)
|
||||
const queuedAt = Date.now();
|
||||
const slot = await acquireGitSlot(timeoutMs);
|
||||
if (slot === 'queue-full') {
|
||||
return { stdout: '', stderr: '', code: null, timedOut: false, spawnError: 'EBUSY: git operation queue is full' };
|
||||
return {
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
code: null,
|
||||
timedOut: false,
|
||||
spawnError: 'EBUSY: git operation queue is full',
|
||||
started: false,
|
||||
};
|
||||
}
|
||||
if (slot === 'timed-out') {
|
||||
return { stdout: '', stderr: '', code: null, timedOut: true };
|
||||
return { stdout: '', stderr: '', code: null, timedOut: true, started: false };
|
||||
}
|
||||
const remainingMs = Math.max(1, timeoutMs - (Date.now() - queuedAt));
|
||||
try {
|
||||
@@ -736,7 +757,7 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number)
|
||||
detached: true,
|
||||
});
|
||||
} catch (err) {
|
||||
resolve({ stdout: '', stderr: '', code: null, timedOut: false, spawnError: String(err) });
|
||||
resolve({ stdout: '', stderr: '', code: null, timedOut: false, started: false, spawnError: String(err) });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -783,8 +804,10 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number)
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
child.on('error', (err) => finish({ stdout, stderr, code: null, timedOut, spawnError: String(err) }));
|
||||
child.on('close', (code) => finish({ stdout, stderr, code, timedOut }));
|
||||
child.on('error', (err) =>
|
||||
finish({ stdout, stderr, code: null, timedOut, started: false, spawnError: String(err) })
|
||||
);
|
||||
child.on('close', (code) => finish({ stdout, stderr, code, timedOut, started: true }));
|
||||
});
|
||||
} finally {
|
||||
releaseGitSlot();
|
||||
@@ -847,6 +870,35 @@ export async function probeGitRemote(
|
||||
};
|
||||
}
|
||||
|
||||
/** Destinations with a direct (`intoEmptyDir`) clone in flight. */
|
||||
const directClones = new Set<string>();
|
||||
|
||||
function destinationExists(): CloneResult {
|
||||
return {
|
||||
ok: false,
|
||||
failure: { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: '' },
|
||||
};
|
||||
}
|
||||
|
||||
/** True for a real (not symlinked) directory with nothing in it. Any read error counts as not empty. */
|
||||
async function isEmptyDirectory(path: string): Promise<boolean> {
|
||||
try {
|
||||
const st = await lstat(path);
|
||||
return st.isDirectory() && (await readdir(path)).length === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove everything inside `path`, keeping `path` itself. Best effort. */
|
||||
async function emptyDirectory(path: string): Promise<void> {
|
||||
try {
|
||||
for (const entry of await readdir(path)) await rm(join(path, entry), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* nothing to clean, or it vanished */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone `repository` into `destination`.
|
||||
*
|
||||
@@ -872,10 +924,33 @@ export async function cloneRepository(opts: CloneOptions): Promise<CloneResult>
|
||||
};
|
||||
}
|
||||
if (existsSync(opts.destination)) {
|
||||
return {
|
||||
ok: false,
|
||||
failure: { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: '' },
|
||||
};
|
||||
// One direct clone per folder at a time: once two gits have written into the same folder their
|
||||
// files cannot be told apart, so the second caller is refused before it spawns anything. Taken
|
||||
// before the emptiness check (which can precede a long wait for a git slot).
|
||||
if (!opts.intoEmptyDir || directClones.has(opts.destination)) return destinationExists();
|
||||
directClones.add(opts.destination);
|
||||
try {
|
||||
if (!(await isEmptyDirectory(opts.destination))) return destinationExists();
|
||||
// The folder is the user's own, so there is no temporary sibling to rename into place: git fills it.
|
||||
const direct = await runGit(buildCloneArgs(opts), opts.timeoutMs ?? GIT_CLONE_TIMEOUT_MS, MAX_STDERR_BYTES);
|
||||
if (direct.code === 0 && !direct.spawnError) return { ok: true, stderr: sanitizeGitOutput(direct.stderr) };
|
||||
const failure = classifyGitFailure(direct.stderr, direct.timedOut, direct.spawnError);
|
||||
// The folder was empty when this attempt took it, so what is in it is this attempt's partial tree, but
|
||||
// only if git actually ran: a refused folder, a full queue or a slot wait that timed out wrote nothing,
|
||||
// and anything in the folder by then (files the user copied in, another case created there) is not ours.
|
||||
// A mount that stopped answering is left alone too, so a libuv worker is not held on it.
|
||||
// git's own refusal is tested on its text, not on the classifier's code, so the deletion never depends on
|
||||
// which pattern matched first.
|
||||
const refused = /already exists and is not an empty directory/i.test(direct.stderr);
|
||||
if (direct.started && !refused && failure.code !== 'DESTINATION_EXISTS') {
|
||||
if ((await probePathKind(opts.destination, { pastCap: true })) === 'directory') {
|
||||
await emptyDirectory(opts.destination);
|
||||
}
|
||||
}
|
||||
return { ok: false, failure };
|
||||
} finally {
|
||||
directClones.delete(opts.destination);
|
||||
}
|
||||
}
|
||||
|
||||
// Sibling of the destination (same filesystem), so the rename is atomic.
|
||||
|
||||
@@ -14,7 +14,7 @@ import { basename, extname, relative } from 'node:path';
|
||||
import { statSync } from 'node:fs';
|
||||
import type { AttachmentDetectedEvent, AttachmentDetectedType, ImageDetectedEvent } from './types.js';
|
||||
import { KeyedDebouncer } from './utils/index.js';
|
||||
import { UPLOAD_DIR_NAMES } from './web/paste-image-gc.js';
|
||||
import { DISCORD_RECEIVED_DIR, UPLOAD_DIR_NAMES } from './web/paste-image-gc.js';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
@@ -159,14 +159,14 @@ export class ImageWatcher extends EventEmitter {
|
||||
// Ignore common heavy directories for performance
|
||||
ignored: (path: string) => {
|
||||
// Skip node_modules, .git, and other heavy directories, and Codeman's
|
||||
// own upload folders: a pdf the user handed to the agent is not a file
|
||||
// the agent produced.
|
||||
// own upload folders: a pdf the user handed to the agent, by upload or
|
||||
// from Discord, is not a file the agent produced.
|
||||
if (
|
||||
path.includes('/node_modules/') ||
|
||||
path.includes('/.git/') ||
|
||||
path.includes('/dist/') ||
|
||||
path.includes('/.next/') ||
|
||||
UPLOAD_DIR_NAMES.some((name) => path.includes(`/${name}/`))
|
||||
[...UPLOAD_DIR_NAMES, DISCORD_RECEIVED_DIR].some((name) => path.includes(`/${name}/`))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
+11
-1
@@ -271,7 +271,7 @@ export interface PaneCaptureOptions {
|
||||
* - `sessionKilled` (data: { sessionId: string }) - Session terminated
|
||||
* - `sessionDied` (data: { sessionId: string }) - Session died unexpectedly
|
||||
* - `statsUpdated` (sessions: MuxSessionWithStats[]) - Stats refreshed
|
||||
* - `paneExitsUpdated` () - A pane read finished; ask `getPaneExit()` per session
|
||||
* - `paneExitsUpdated` () - A pane read finished; ask `getPaneExit()` (and `getPaneCurrentPath()`) per session
|
||||
*/
|
||||
export interface TerminalMultiplexer extends EventEmitter {
|
||||
/** Which backend this instance uses */
|
||||
@@ -396,6 +396,16 @@ export interface TerminalMultiplexer extends EventEmitter {
|
||||
*/
|
||||
getPaneExit?(muxName: string): PaneExit | undefined;
|
||||
|
||||
/**
|
||||
* The directory the session's pane was in at the last pane read
|
||||
* (`#{pane_current_path}`), or `undefined` when unknown. Read from the same
|
||||
* batched watcher as {@link getPaneExit}, so it costs no tmux call. A relative
|
||||
* path printed in the terminal resolves against it; it is never a
|
||||
* confinement root. A multiplexer without it leaves relative paths resolving
|
||||
* against `workingDir`.
|
||||
*/
|
||||
getPaneCurrentPath?(muxName: string): string | undefined;
|
||||
|
||||
/**
|
||||
* How many authoritative pane reads have agreed on the exit `getPaneExit()`
|
||||
* reports, or 0 when it reports none. The exited-agent sweep closes a session
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* @module session-cli-registry-bridge
|
||||
*/
|
||||
|
||||
import { getDiscordStartupHint } from './discord-bridge.js';
|
||||
import type { CliEntry } from './config/cli-registry/types.js';
|
||||
import { renderLaunch, type EngineValues, type ParamValues } from './config/cli-registry/argv.js';
|
||||
import { matchesPattern } from './config/cli-registry/patterns.js';
|
||||
@@ -248,6 +249,10 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri
|
||||
engineValues.effortSettingsJson = JSON.stringify(settingsObj);
|
||||
}
|
||||
|
||||
// The Discord bridge's startup hint: undefined (so the flag is omitted) unless the bridge and its
|
||||
// `startupHint` setting are on. Claude-only in practice, like the settings object above.
|
||||
engineValues.appendSystemPrompt = getDiscordStartupHint();
|
||||
|
||||
// Preserves buildSpawnCommand's original fallback exactly: an EXPLICIT `undefined` probes
|
||||
// the local claude CLI (getClaudeCliVersion, null under vitest); an explicit `null` means
|
||||
// "known to be unresolvable" and must not probe. The probe only ever runs from
|
||||
|
||||
+146
-4
@@ -30,6 +30,7 @@
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { basename } from 'node:path';
|
||||
import { realpath } from 'node:fs/promises';
|
||||
import { execSync, execFileSync } from 'node:child_process';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as pty from 'node-pty';
|
||||
@@ -71,6 +72,8 @@ import {
|
||||
import type { GitFetchStatus } from './git-fetch.js';
|
||||
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
|
||||
import { claudeTranscriptExists } from './utils/claude-transcript.js';
|
||||
import { probePathKind } from './utils/bounded-path-probe.js';
|
||||
import { PATH_PROBE_TIMEOUT_MS, WORKSPACE_REALPATH_RETRY_MS } from './config/path-probe.js';
|
||||
import { matchesPattern } from './config/cli-registry/patterns.js';
|
||||
import { probeDockerCliVersion } from './docker-hosts.js';
|
||||
import { probeRemoteCliVersion } from './remote-hosts.js';
|
||||
@@ -398,6 +401,21 @@ const DOCKER_CLI_VERSION_PROBE_DELAY_MS = 3000;
|
||||
/** Delay before the over-ssh Claude CLI version probe (keeps session start off the ssh round-trip). */
|
||||
const REMOTE_CLI_VERSION_PROBE_DELAY_MS = 3000;
|
||||
|
||||
/** Whether absolute `path` is `root` or lies under it (a lexical compare). */
|
||||
function isSameOrUnder(path: string, root: string): boolean {
|
||||
return path === root || path.startsWith(root === '/' ? '/' : `${root}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite `path` from under `from` to the same place under `to`; a path not at or
|
||||
* under `from` comes back unchanged. Exported for unit testing.
|
||||
*/
|
||||
export function rebasePath(path: string, from: string, to: string): string {
|
||||
if (path === from) return to;
|
||||
if (path.startsWith(`${from}/`)) return to + path.slice(from.length);
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask tmux for the current window geometry of `muxName` so a re-attaching PTY
|
||||
* client can spawn at the same size and avoid the resize-flicker / scrollback
|
||||
@@ -743,6 +761,21 @@ export class Session extends EventEmitter {
|
||||
* rendered as "alive".
|
||||
*/
|
||||
private _paneExit: PaneExit | null = null;
|
||||
/**
|
||||
* The pane's live directory, from the pane-exit watcher's read. Runtime-only:
|
||||
* serialized by toState() but never restored by the constructor.
|
||||
*/
|
||||
private _paneCwd: string | undefined;
|
||||
/**
|
||||
* `workingDir` with symlinks resolved, so a pane reading (tmux reads the
|
||||
* process cwd, always the resolved path) can be put back into `workingDir`'s
|
||||
* spelling. `undefined` until the first resolution settles; `null` when there
|
||||
* is nothing to translate (no symlink in the path) or it could not be resolved.
|
||||
*/
|
||||
private _workspaceRealPath: string | null | undefined;
|
||||
private _workspaceRealPathPending = false;
|
||||
/** When a resolution that met an unreachable folder may be tried again (0 = never). */
|
||||
private _workspaceRealPathRetryAt = 0;
|
||||
/**
|
||||
* How many starts, attaches or relaunches are running for this session's
|
||||
* pane. While one is, a dead-pane reading may describe a pane that is being
|
||||
@@ -1330,6 +1363,17 @@ export class Session extends EventEmitter {
|
||||
return value && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* One env var from this session's overrides, for internal path resolution only
|
||||
* (custom-model injection linking an isolated config dir back to a path that must
|
||||
* survive lives in). Single-key lookup by design, like `deepSeekHomeOverride`: the
|
||||
* map can hold provider credentials and stays off the public `SessionState`.
|
||||
*/
|
||||
envOverrideValue(key: string): string | undefined {
|
||||
const value = this._envOverrides?.[key];
|
||||
return value && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
/** Owning username in multi-user mode, else undefined. */
|
||||
get owner(): string | undefined {
|
||||
return this._owner;
|
||||
@@ -1485,6 +1529,99 @@ export class Session extends EventEmitter {
|
||||
if (this._muxSession) this._mux?.clearPaneExit?.(this._muxSession.muxName);
|
||||
}
|
||||
|
||||
/** The pane's live working directory, when the pane read has reported one. */
|
||||
get paneCwd(): string | undefined {
|
||||
return this._paneCwd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the pane's live directory. Returns whether it CHANGED, so the caller
|
||||
* broadcasts only on a real transition.
|
||||
*
|
||||
* Scoped by {@link paneExitApplies}, the same rule as the exit reading: a
|
||||
* remote session's local pane runs the ssh client and a docker case's runs a
|
||||
* `docker exec`, so their pane directory is a host directory the agent was
|
||||
* never in, and a direct-PTY or rediscovered session has no pane Codeman can
|
||||
* vouch for. A relative value is refused rather than stored.
|
||||
*
|
||||
* tmux reads the pane's directory from the process cwd, which is always the
|
||||
* resolved path, while `workingDir` keeps the spelling the case was created
|
||||
* with. A workspace reached through a symlink would otherwise read as outside
|
||||
* itself, and the browser's preview routing (a lexical compare against
|
||||
* `workingDir`) would send every relative link to the attachment route. So a
|
||||
* reading at or under the workspace's resolved path is written back in
|
||||
* `workingDir`'s spelling. Until that resolution has settled, only a reading
|
||||
* lexically inside `workingDir` is accepted.
|
||||
*/
|
||||
setPaneCwd(next: string | undefined): boolean {
|
||||
let resolved = this.paneExitApplies && next?.startsWith('/') ? next : undefined;
|
||||
if (resolved !== undefined) {
|
||||
if (
|
||||
this._workspaceRealPath === undefined ||
|
||||
(this._workspaceRealPathRetryAt !== 0 && Date.now() >= this._workspaceRealPathRetryAt)
|
||||
) {
|
||||
this._resolveWorkspaceRealPath();
|
||||
}
|
||||
if (this._workspaceRealPath === undefined) {
|
||||
// The workspace's resolved path is not known yet. A reading outside
|
||||
// `workingDir` could be the workspace itself seen through its symlink, so
|
||||
// it waits for the next tick rather than mis-route links for one.
|
||||
if (!isSameOrUnder(resolved, this._workspaceSpelling())) return false;
|
||||
} else if (this._workspaceRealPath !== null) {
|
||||
resolved = rebasePath(resolved, this._workspaceRealPath, this._workspaceSpelling());
|
||||
}
|
||||
}
|
||||
if (resolved === this._paneCwd) return false;
|
||||
this._paneCwd = resolved;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `workingDir` without a trailing separator (the root stays `/`). */
|
||||
private _workspaceSpelling(): string {
|
||||
return this.workingDir.length > 1 ? this.workingDir.replace(/\/+$/, '') : this.workingDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `workingDir`'s symlinks in the background for {@link setPaneCwd}.
|
||||
*
|
||||
* Bounded: the folder can sit on a network mount that stopped answering, so it
|
||||
* is probed with `probePathKind()` first and the `realpath()` is raced against
|
||||
* the same timeout. An `unknown` probe, a failed or slow `realpath()` leave no
|
||||
* translation and allow one more try after `WORKSPACE_REALPATH_RETRY_MS`; a
|
||||
* folder that is absent or not a directory is settled for good.
|
||||
*/
|
||||
private _resolveWorkspaceRealPath(): void {
|
||||
if (this._workspaceRealPathPending) return;
|
||||
this._workspaceRealPathPending = true;
|
||||
const spelling = this._workspaceSpelling();
|
||||
void (async () => {
|
||||
let real: string | null = null;
|
||||
let retry = false;
|
||||
try {
|
||||
const kind = await probePathKind(spelling);
|
||||
if (kind === 'unknown') {
|
||||
retry = true;
|
||||
} else if (kind === 'directory') {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const answer = await Promise.race([
|
||||
realpath(spelling).catch(() => undefined),
|
||||
new Promise<undefined>((resolveTimeout) => {
|
||||
timer = setTimeout(() => resolveTimeout(undefined), PATH_PROBE_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
}),
|
||||
]).finally(() => clearTimeout(timer));
|
||||
if (answer === undefined) retry = true;
|
||||
else if (answer !== spelling && answer !== '/') real = answer;
|
||||
}
|
||||
} catch {
|
||||
retry = true;
|
||||
}
|
||||
this._workspaceRealPath = real;
|
||||
this._workspaceRealPathRetryAt = retry ? Date.now() + WORKSPACE_REALPATH_RETRY_MS : 0;
|
||||
this._workspaceRealPathPending = false;
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what the mux layer observed of this pane's agent, and say whether
|
||||
* that changed the answer. The caller persists and broadcasts on a true.
|
||||
@@ -2162,6 +2299,7 @@ export class Session extends EventEmitter {
|
||||
pid: this.pid,
|
||||
status: this._status,
|
||||
workingDir: this.workingDir,
|
||||
paneCwd: this._paneCwd,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
@@ -2692,7 +2830,7 @@ export class Session extends EventEmitter {
|
||||
if (entry?.launch.chain !== 'fallback') return options;
|
||||
|
||||
const resumeIdPattern = entry.launch.params?.resumeId;
|
||||
const configDir = this._claudeConfigDir();
|
||||
const configDir = this.claudeConfigDir();
|
||||
const chainTail = this._claudeSessionChain[this._claudeSessionChain.length - 1];
|
||||
const candidates = [chainTail, options.resumeSessionId, this.id].filter((v): v is string => !!v);
|
||||
for (const candidate of candidates) {
|
||||
@@ -2714,8 +2852,11 @@ export class Session extends EventEmitter {
|
||||
return options;
|
||||
}
|
||||
|
||||
/** The session's Claude config dir when it has been relocated (#255), else undefined. */
|
||||
private _claudeConfigDir(): string | undefined {
|
||||
/**
|
||||
* The session's Claude config dir when it has been relocated (#255), else undefined.
|
||||
* Public for the edit history reader, which looks for the transcript there.
|
||||
*/
|
||||
claudeConfigDir(): string | undefined {
|
||||
// Trimmed like `claudeProjectsDir()` trims the process-wide override: the
|
||||
// envOverrides schema validates keys only, and a whitespace-only value would
|
||||
// otherwise resolve to a relative path and read "no transcript" for everything.
|
||||
@@ -2724,7 +2865,8 @@ export class Session extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Force the custom-model selection's `launchModel` (pi/omp `custom/<id>`, grok's
|
||||
* `[model.<name>]` block name) onto the CLI's `model` launch param. Where that param
|
||||
* `[model.<name>]` block name, codex's bare selected id) onto the CLI's `model`
|
||||
* launch param. Where that param
|
||||
* lives is registry DATA — the entry's `legacyConfigField` (`piConfig`, `grokConfig`,
|
||||
* ...) or the top-level `model` for an entry that declares none — so this stays a
|
||||
* generic reader rather than a branch per CLI. Applied on the OPTIONS only: the stored
|
||||
|
||||
@@ -422,6 +422,46 @@ export function derivePaneExits(rows: PaneRow[], now: number): Map<string, PaneE
|
||||
return exits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide, from every pane tmux listed, which directory each tmux session's pane
|
||||
* is in now (`#{pane_current_path}`). Exported for unit testing.
|
||||
*
|
||||
* This is what a relative path printed in the terminal is relative TO: a
|
||||
* session's `workingDir` is fixed at create time and never follows a `cd`.
|
||||
*
|
||||
* - Only sessions with EXACTLY ONE pane answer, for the reason
|
||||
* {@link derivePaneExits} gives: a hand-split session has no single "the"
|
||||
* pane to speak for it.
|
||||
* - Only absolute paths count. A blank field is a tmux that could not read the
|
||||
* process cwd: usually a pane whose program exited under `remain-on-exit`
|
||||
* (tmux 3.4 then reports it blank on every read), otherwise a read that
|
||||
* failed that tick. Either way the directory is unknown, not gone, so a
|
||||
* single-pane session with a blank field keeps its entry from `previous`.
|
||||
* A session split into several panes, or one the read no longer lists, gets
|
||||
* no entry: those are authoritative absences, not unanswered questions.
|
||||
*
|
||||
* The launch directory is NOT filtered here (unlike the rediscovery path): a
|
||||
* remote or docker pane that never leaves it is refused by `Session.setPaneCwd()`,
|
||||
* and a local pane reporting it really is there.
|
||||
*/
|
||||
export function derivePaneCurrentPaths(
|
||||
rows: PaneRow[],
|
||||
previous: ReadonlyMap<string, string> = new Map()
|
||||
): Map<string, string> {
|
||||
const panesPerSession = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
panesPerSession.set(row.sessionName, (panesPerSession.get(row.sessionName) ?? 0) + 1);
|
||||
}
|
||||
const paths = new Map<string, string>();
|
||||
for (const row of rows) {
|
||||
if (panesPerSession.get(row.sessionName) !== 1) continue;
|
||||
const path = row.currentPath ?? previous.get(row.sessionName);
|
||||
if (!path || !path.startsWith('/')) continue;
|
||||
paths.set(row.sessionName, path);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Could any of these tmux sessions ever produce a pane-exit answer? Exported
|
||||
* for unit testing.
|
||||
@@ -2005,6 +2045,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* raw pane reading.
|
||||
*/
|
||||
private paneExits: Map<string, TrackedPaneExit> = new Map();
|
||||
/**
|
||||
* muxName -> `#{pane_current_path}` from the last authoritative pane read
|
||||
* ({@link derivePaneCurrentPaths}). Replaced wholesale by each non-empty read,
|
||||
* so it is bounded by the panes on the socket. Like {@link paneExits} it holds
|
||||
* the raw reading; `Session.setPaneCwd()` decides which sessions may use it.
|
||||
*/
|
||||
private paneCurrentPaths: Map<string, string> = new Map();
|
||||
/** The pane-exit watcher's own interval. Runs whether or not stats are on. */
|
||||
private paneExitInterval: NodeJS.Timeout | null = null;
|
||||
/**
|
||||
@@ -3507,6 +3554,19 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory the session's pane was in at the last authoritative pane
|
||||
* read, or `undefined` when unknown. Costs nothing: it reads the map the
|
||||
* pane-exit watcher's batched `list-panes -a` fills, so no tmux call is made
|
||||
* per click. A failed or empty read keeps the previous answers, and so does
|
||||
* a blank `#{pane_current_path}` on a single-pane session. After a successful
|
||||
* read, `undefined` means that read listed no single directory for the
|
||||
* session: it was split into several panes or is no longer on the socket.
|
||||
*/
|
||||
getPaneCurrentPath(muxName: string): string | undefined {
|
||||
return this.paneCurrentPaths.get(muxName);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the last pane read saw of this tmux session's agent. `undefined` is
|
||||
* the UNKNOWN answer and must never be rendered as "alive": it covers a pane
|
||||
@@ -3568,6 +3628,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
this.paneExitReadInFlight = false;
|
||||
}
|
||||
if (rows.length === 0) return;
|
||||
// The directory reading rides the same read. It is applied before the
|
||||
// generation check below: a respawn invalidates an exit observation, but the
|
||||
// pane's directory at read time is still the best answer there is. A blank
|
||||
// field on a single-pane session carries the previous answer forward.
|
||||
this.paneCurrentPaths = derivePaneCurrentPaths(rows, this.paneCurrentPaths);
|
||||
// A pane was respawned or killed while this read was out, so what it saw is
|
||||
// already history. Dropping it is what stops a freshly respawned pane from
|
||||
// being republished as exited.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @fileoverview Discord bridge type definitions.
|
||||
*
|
||||
* The option lists and the config, result and status shapes of the two-way Discord bridge, kept apart
|
||||
* from the IO module (`src/discord-bridge.ts`, which pulls in `ws`) so the request schemas and the
|
||||
* route can import them alone, as the webhook's equivalents live in `push.ts`.
|
||||
*
|
||||
* Key exports:
|
||||
* - DISCORD_THREAD_CREATION, DISCORD_ON_DELETE, DISCORD_PING_MODES (+ their types): the option lists
|
||||
* - DiscordConfig: `~/.codeman/discord.json` (mode 0600; the bot token is a secret)
|
||||
* - DiscordResult, DiscordGatewayState, DiscordStatus: what `GET /api/discord` reports
|
||||
*
|
||||
* No dependencies on other domain modules but `push.ts` (the shared scope list).
|
||||
*/
|
||||
|
||||
import type { WebhookScope } from './push.js';
|
||||
|
||||
export const DISCORD_THREAD_CREATION = ['lazy', 'onSessionStart'] as const;
|
||||
export type DiscordThreadCreation = (typeof DISCORD_THREAD_CREATION)[number];
|
||||
export const DISCORD_ON_DELETE = ['keep', 'archive', 'delete'] as const;
|
||||
export type DiscordOnDelete = (typeof DISCORD_ON_DELETE)[number];
|
||||
export const DISCORD_PING_MODES = ['off', 'critical', 'all'] as const;
|
||||
export type DiscordPingMode = (typeof DISCORD_PING_MODES)[number];
|
||||
|
||||
export interface DiscordConfig {
|
||||
enabled: boolean;
|
||||
token: string;
|
||||
guildId: string;
|
||||
parentChannelId: string;
|
||||
allowedUserIds: string[];
|
||||
threadCreation: DiscordThreadCreation;
|
||||
onSessionDelete: DiscordOnDelete;
|
||||
/** What to do with threads the bridge made that no tab maps to any more: checked after boot, then daily. */
|
||||
staleThreads: DiscordOnDelete;
|
||||
pingMode: DiscordPingMode;
|
||||
/** `attention` skips info-level events (a finished response); `all` sends them too. */
|
||||
scope: WebhookScope;
|
||||
/** Tell each session how to reach the user (see DISCORD_STARTUP_HINT). On by default while the bridge is on. */
|
||||
startupHint: boolean;
|
||||
}
|
||||
|
||||
export interface DiscordResult {
|
||||
ok: boolean;
|
||||
/** True when the same event for the same tab was posted within the last few seconds, so nothing was sent. */
|
||||
deduped?: boolean;
|
||||
status?: number;
|
||||
error?: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
export type DiscordGatewayState = 'off' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
/** `GET /api/discord`: the config without its token, plus what the bridge is doing. */
|
||||
export type DiscordStatus = Omit<DiscordConfig, 'token'> & {
|
||||
hasToken: boolean;
|
||||
tokenMasked: string;
|
||||
gateway: DiscordGatewayState;
|
||||
gatewayError: string | null;
|
||||
lastResult: DiscordResult | null;
|
||||
};
|
||||
@@ -25,6 +25,7 @@
|
||||
* | tools | ActiveBashTool, ImageDetectedEvent | In-memory, broadcast via SSE |
|
||||
* | teams | TeamConfig, TeamMember, TeamTask, InboxMessage, PaneInfo | `~/.claude/teams/`, `~/.claude/tasks/` → `GET /api/teams` |
|
||||
* | push | PushSubscriptionRecord, VapidKeys, WebhookConfig, WebhookStatus, WebhookResult | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json`, `~/.codeman/webhook.json` |
|
||||
* | discord | DiscordConfig, DiscordResult, DiscordStatus, DiscordGatewayState | `~/.codeman/discord.json` → `GET`/`PUT /api/discord` |
|
||||
* | plan | PlanItem, PlanTaskStatus, TddPhase | In-memory → `GET /api/sessions/:id/plan/tasks` |
|
||||
* | orchestrator | OrchestratorState, OrchestratorPlan, OrchestratorConfig, OrchestratorPersistState | `~/.codeman/state.json` → `GET /api/orchestrator/status` |
|
||||
* | mcp-sync | McpSyncResult, McpSyncTargetResult | Other CLIs' own config files → `GET`/`POST /api/mcp-sync` |
|
||||
@@ -65,6 +66,7 @@ export * from './run-summary.js';
|
||||
export * from './tools.js';
|
||||
export * from './teams.js';
|
||||
export * from './push.js';
|
||||
export * from './discord.js';
|
||||
export * from './plan.js';
|
||||
export * from './orchestrator.js';
|
||||
export * from './update.js';
|
||||
|
||||
@@ -834,6 +834,19 @@ export interface SessionState {
|
||||
status: SessionStatus;
|
||||
/** Working directory path */
|
||||
workingDir: string;
|
||||
/**
|
||||
* The directory the session's local tmux pane is in NOW (`#{pane_current_path}`),
|
||||
* when known. `workingDir` is fixed at create time and never follows a `cd`;
|
||||
* this is what a relative path printed in the terminal is relative to.
|
||||
*
|
||||
* Runtime-only: refreshed from the pane-exit watcher's read and never restored
|
||||
* from disk, so a restart starts blank. Absent for remote and docker sessions
|
||||
* (their local pane runs the ssh / docker-exec client) and for direct-PTY or
|
||||
* rediscovered sessions. NOT a confinement root: the file routes stay confined
|
||||
* to `workingDir`, and a path resolved outside it goes through the attachment
|
||||
* routes like any other out-of-workspace path.
|
||||
*/
|
||||
paneCwd?: string;
|
||||
/** Remote execution metadata, present when this session runs over SSH through local tmux */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */
|
||||
|
||||
+1
-1
@@ -84,7 +84,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
⚠️ **`sendEnterKey()` MUST go through `terminal._core.coreService.triggerDataEvent('\r', true)`** — not `sendInput()`, and never a raw POST to `/api/sessions/:id/input`. `localEchoEnabled` defaults to `MobileDetection.isTouchDevice()`, so on every phone the characters you type are buffered in the `LocalEchoOverlay` and have **never reached the PTY**; the `onData` Enter branch in terminal-ui.js is what flushes `pendingText` first and only then sends `\r` (after an 80ms delay so text lands first). Sending a bare `\r` submits an empty line and strands the typed text on screen, so the button looks dead. Replaying the keypress reuses the overlay flush, the flushed-offset cleanup and the ordering instead of reimplementing them. `KeyboardAccessory.sendKey()` is for escape sequences (arrows/Esc) and is the WRONG template to copy for input.
|
||||
|
||||
⚠️ **Skin overrides outrank plain class rules.** `styles.css` nests its skin block inside `html:not([data-skin="og"]) { … }`, so a bare `.btn-toolbar` rule in there resolves to specificity **(0,2,1)** and beats a `.btn-toolbar.btn-x` rule **(0,2,0)** in `mobile.css` regardless of load order. Toolbar-button colors set from mobile.css therefore need `!important` — that is why mobile.css leans on it so heavily. Symptom: only your `!important` properties land and everything else silently renders in generic toolbar grey.
|
||||
⚠️ **Skin overrides outrank plain class rules.** `styles.css` nests its skin block inside `html:not([data-skin="og"]) { … }`, so a bare `.btn-toolbar` rule in there resolves to specificity **(0,2,1)** and beats a `.btn-toolbar.btn-x` rule **(0,2,0)** in `mobile.css` regardless of load order. Toolbar-button colors set from mobile.css therefore need `!important`: that is why mobile.css leans on it so heavily. Symptom: only your `!important` properties land and everything else silently renders in generic toolbar grey. Press feedback on the phone bars sidesteps this: the `:active` rules for `.btn-toolbar`, the mobile icon buttons and `.accessory-btn` (mobile.css) use `transform` and `filter` only, which nothing on these buttons sets at a higher specificity, so one rule reads the same on every skin; a background `:active` lost to the skin block on all but og, and an inset `box-shadow` lost to the skin hover rules, which is why neither is used. The light skins darken instead of brightening, and `prefers-reduced-motion` drops the scale.
|
||||
|
||||
**Connection-loss UI** (`computeConnectionLossUi()` in constants.js, writer `_updateConnectionLossUi()` in app.js): the service worker serves the cached app shell, so an unreachable server (phone off the tailnet, VPN down, server stopped) used to render a normal-looking empty dashboard whose only tell was the 8px header dot, which reads as "no sessions", not "no connection". Two surfaces now: a full-screen **overlay** while no server state has loaded this page load (nothing behind it is worth preserving), and a non-blocking **banner** once it has (the terminal scrollback stays readable). ⚠️ A **2.5s grace** is load-bearing: a COM deploy restarts the server and SSE is back in ~200ms, and a banner on every deploy trains the user to ignore it. `navigator.onLine === false` skips the grace, since that is never a blip. Retry re-arms SSE **and** the terminal WS (`planWsReconnect` can 'give-up', and the SSE backoff caps at 30s).
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ export interface NewCasePathContext {
|
||||
* listed as a local case, so it must not be registered as a linked one too.
|
||||
*/
|
||||
casesDirs?: readonly string[];
|
||||
/** What to do instead when the path is the cases folder or directly in it. Defaults to Create New's wording. */
|
||||
casesDirAdvice?: string;
|
||||
}
|
||||
|
||||
export type NewCasePathResult =
|
||||
@@ -106,10 +108,10 @@ export function blockedReason(
|
||||
// refusing everything below it blocked most of the tree.
|
||||
for (const dir of ctx.casesDirs ?? []) {
|
||||
if (absPath === dir) {
|
||||
return `That is the cases folder itself (${dir}); create a case inside it with plain Create New (no custom folder)`;
|
||||
return `That is the cases folder itself (${dir}); ${ctx.casesDirAdvice ?? 'create a case inside it with plain Create New (no custom folder)'}`;
|
||||
}
|
||||
if (dirname(absPath) === dir) {
|
||||
return `That folder is directly in the cases folder (${dir}), where it is listed as a local case; create it there with plain Create New (no custom folder)`;
|
||||
return `That folder is directly in the cases folder (${dir}), where it is listed as a local case; ${ctx.casesDirAdvice ?? 'create it there with plain Create New (no custom folder)'}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -132,7 +134,7 @@ async function resolvedPolicy(ctx: NewCasePathContext): Promise<[NewCasePathCont
|
||||
Promise.all((ctx.casesDirs ?? []).map(realpathOr)),
|
||||
Promise.all(BLOCKED_SYSTEM_ROOTS.map(realpathOr)),
|
||||
]);
|
||||
return [{ home, dataDir, casesDirs }, systemRoots];
|
||||
return [{ home, dataDir, casesDirs, casesDirAdvice: ctx.casesDirAdvice }, systemRoots];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* @fileoverview Reads a claude session's edit history on request: the new bytes of its
|
||||
* transcripts (main + subagents) go into its `EditHistoryStore`, and the Bash image
|
||||
* candidates that came with them are confirmed against the files on disk.
|
||||
*
|
||||
* WHEN. Only when the panel asks (`GET …/edits?sync=1` on open and while it follows the
|
||||
* session, `POST …/edits/older`): no timer, no hook, no background read for sessions nobody
|
||||
* looks at. One sync per session runs at a time; requests during a run are folded into one
|
||||
* more forward read and/or one more older read afterwards.
|
||||
*
|
||||
* HOW MUCH. A forward read shares one budget over all files, main first. A file whose chunk
|
||||
* the shared budget cut short is not applied (the store would take a chunk with no line end
|
||||
* for a line longer than the budget) but read in the next pass; up to
|
||||
* {@link MAX_FORWARD_PASSES} passes per sync, the rest with the next one.
|
||||
*
|
||||
* NEW CONVERSATION. When the transcript path names another conversation (`/clear`, resume),
|
||||
* the previous transcript (same directory) is first read to its end, so edits it got after
|
||||
* the last read are not lost with its cursors; then the new one is read from its end.
|
||||
* "Load older" reaches back within the current conversation only.
|
||||
*
|
||||
* MEMORY. At most {@link MAX_LOADED_STORES} stores stay loaded; past that the least recently
|
||||
* used one without a run is written (if it changed) and dropped, and loaded again on demand.
|
||||
*
|
||||
* GONE. `remove()` (the session's record is deleted) marks the session gone, deletes the file
|
||||
* and then drops the session's slot: a run in flight stops after its current read and its
|
||||
* store no longer writes; a request during the removal reads nothing (`unavailable`, reason
|
||||
* `removed`). A session that comes back with the same id (reboot restore) starts afresh.
|
||||
* `sweep()` deletes, at startup, the stored history of sessions that no longer exist. A
|
||||
* subagent transcript that vanished loses its cursor (`dropSource`), not its entries.
|
||||
*
|
||||
* OLDER. "Load older" reads a full budget block of up to {@link MAX_OLDER_SOURCES} sources
|
||||
* with older lines per click; with more sources they take turns.
|
||||
*
|
||||
* WHICH SESSIONS. Local claude sessions. Remote, Docker and non-claude sessions answer
|
||||
* `unsupported` and are never read.
|
||||
*
|
||||
* IMAGES. A Bash candidate counts only when the file's mtime lies in the call's window
|
||||
* (start − 2 s … end + 2 s), so `ls *.png` lists nothing.
|
||||
*
|
||||
* Tests: `test/edit-history-sync.test.ts`.
|
||||
*
|
||||
* @module web/edit-history-sync
|
||||
*/
|
||||
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import type { SessionRemote } from '../types/session.js';
|
||||
import type { EditSourceReadOptions, EditSourceRequest, EditSources, PathProbe } from '../edit-history-local-source.js';
|
||||
import type { ImageCandidate } from '../edit-history-parse.js';
|
||||
import { EditHistoryStore, READ_BUDGET_BYTES, type ConfirmedImage } from '../edit-history-store.js';
|
||||
import { getCli } from '../config/cli-registry/registry.js';
|
||||
|
||||
/** Slack around a Bash call's window for the image mtime check. */
|
||||
export const IMAGE_MTIME_SLACK_MS = 2000;
|
||||
/** Forward passes per sync when files grew by more than one budget. */
|
||||
export const MAX_FORWARD_PASSES = 3;
|
||||
/** Sources read back per "Load older" click, a full budget each. */
|
||||
export const MAX_OLDER_SOURCES = 3;
|
||||
/** Stores kept in memory; the least recently used one past this is dropped. */
|
||||
export const MAX_LOADED_STORES = 3;
|
||||
/** `reason` when the session has no transcript yet (no prompt yet, or not found). */
|
||||
export const NO_TRANSCRIPT = 'no-transcript';
|
||||
/** `reason` once the session's history was deleted. */
|
||||
export const REMOVED = 'removed';
|
||||
|
||||
export type EditHistoryState = 'ok' | 'unavailable' | 'unsupported';
|
||||
|
||||
export interface EditHistoryStatus {
|
||||
state: EditHistoryState;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** The slice of a session this needs. */
|
||||
export interface EditHistorySession {
|
||||
id: string;
|
||||
mode: string;
|
||||
remote: SessionRemote | undefined;
|
||||
docker?: unknown;
|
||||
claudeSessionId: string | null;
|
||||
/** The session's own `CLAUDE_CONFIG_DIR` (Codeman env override), if it has one. */
|
||||
claudeConfigDir?: string;
|
||||
}
|
||||
|
||||
export interface EditHistorySyncDeps {
|
||||
/** The session's main transcript path, or null when it has none yet. */
|
||||
transcriptPath(session: EditHistorySession): Promise<string | null>;
|
||||
readSources(
|
||||
mainPath: string,
|
||||
requests: readonly EditSourceRequest[],
|
||||
budget: number,
|
||||
opts?: EditSourceReadOptions
|
||||
): Promise<EditSources | null>;
|
||||
probe(paths: readonly string[]): Promise<Array<PathProbe | null>>;
|
||||
loadStore(sessionId: string): Promise<EditHistoryStore>;
|
||||
/** Delete a session's stored history without loading it. */
|
||||
deleteStore(sessionId: string): Promise<void>;
|
||||
/** Delete the stored history of every session `keep` rejects; returns how many. */
|
||||
sweepStores(keep: (sessionId: string) => boolean): Promise<number>;
|
||||
budget?: number;
|
||||
}
|
||||
|
||||
export interface SyncOptions {
|
||||
older?: boolean;
|
||||
}
|
||||
|
||||
/** Why a session has no edit history, or undefined when it can have one. */
|
||||
export function unsupportedReason(session: EditHistorySession): string | undefined {
|
||||
if (session.remote) return 'remote';
|
||||
if (session.docker) return 'docker';
|
||||
if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl') return 'mode';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** One turn of the event loop, between synchronous parses of up to one budget each. */
|
||||
function breathe(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bash candidates whose probed file is a regular file with an mtime inside the call's window
|
||||
* (files claude sent to the user skip the window: they exist and were shown on purpose).
|
||||
* A candidate whose file version is already recorded is skipped: `known(path)` is the newest
|
||||
* entry time for that path, and an entry at or after `mtime − slack` saw this version: the
|
||||
* `ls *.png` right after the render, or a Write followed by a listing. Candidates are taken
|
||||
* in call order so a batch dedupes itself the same way.
|
||||
*/
|
||||
export function confirmImages(
|
||||
candidates: readonly ImageCandidate[],
|
||||
probe: (path: string) => PathProbe | null | undefined,
|
||||
known: (path: string) => number | undefined = () => undefined
|
||||
): ConfirmedImage[] {
|
||||
const out: ConfirmedImage[] = [];
|
||||
const seen = new Map<string, number>();
|
||||
for (const c of [...candidates].sort((a, b) => a.end - b.end)) {
|
||||
for (const path of c.paths) {
|
||||
const p = probe(path);
|
||||
if (!p || p.kind !== 'file' || !p.mtimeMs) continue;
|
||||
const inWindow = p.mtimeMs >= c.start - IMAGE_MTIME_SLACK_MS && p.mtimeMs <= c.end + IMAGE_MTIME_SLACK_MS;
|
||||
if (!c.sent && !inWindow) continue;
|
||||
const entryPath = path.startsWith('~/') ? p.realPath : path;
|
||||
const last = Math.max(seen.get(entryPath) ?? -Infinity, known(entryPath) ?? -Infinity);
|
||||
if (last >= p.mtimeMs - IMAGE_MTIME_SLACK_MS) continue;
|
||||
seen.set(entryPath, c.end);
|
||||
const img: ConfirmedImage = {
|
||||
toolUseId: c.toolUseId,
|
||||
path: entryPath,
|
||||
ts: c.end,
|
||||
tool: c.sent ? 'sent' : 'bash',
|
||||
};
|
||||
if (c.agentId) img.agentId = c.agentId;
|
||||
out.push(img);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface SyncWant {
|
||||
forward: boolean;
|
||||
older: boolean;
|
||||
}
|
||||
|
||||
interface SessionSlot {
|
||||
store?: Promise<EditHistoryStore>;
|
||||
status: EditHistoryStatus;
|
||||
running?: Promise<void>;
|
||||
again?: SyncWant;
|
||||
/** The history was deleted: nothing reads for this session any more. */
|
||||
gone?: boolean;
|
||||
/** The last source the previous "Load older" read; the next one starts after it. */
|
||||
olderAfter?: string;
|
||||
}
|
||||
|
||||
export class EditHistorySync {
|
||||
/** By session, least recently used first. */
|
||||
private readonly slots = new Map<string, SessionSlot>();
|
||||
/** Writes of dropped stores; loading that session again waits for its write. */
|
||||
private readonly unloading = new Map<string, Promise<void>>();
|
||||
private readonly budget: number;
|
||||
|
||||
constructor(private readonly deps: EditHistorySyncDeps) {
|
||||
this.budget = deps.budget ?? READ_BUDGET_BYTES;
|
||||
}
|
||||
|
||||
/** The session's store, loaded from disk on first use (one load for concurrent callers). */
|
||||
getStore(sessionId: string): Promise<EditHistoryStore> {
|
||||
return this.storeOf(sessionId, this.slot(sessionId));
|
||||
}
|
||||
|
||||
getStatus(sessionId: string): EditHistoryStatus {
|
||||
return { ...this.slot(sessionId).status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read what is new (or, with `older`, the next older block). Resolves when this
|
||||
* request's run is done; never rejects. A call during a run queues one more run.
|
||||
*/
|
||||
async sync(session: EditHistorySession, opts: SyncOptions = {}): Promise<EditHistoryStatus> {
|
||||
const why = unsupportedReason(session);
|
||||
const slot = this.slot(session.id);
|
||||
if (slot.gone) return { ...slot.status };
|
||||
if (why) {
|
||||
slot.status = { state: 'unsupported', reason: why };
|
||||
return { ...slot.status };
|
||||
}
|
||||
const want: SyncWant = { forward: !opts.older, older: !!opts.older };
|
||||
if (slot.running) {
|
||||
slot.again = {
|
||||
forward: (slot.again?.forward ?? false) || want.forward,
|
||||
older: (slot.again?.older ?? false) || want.older,
|
||||
};
|
||||
await slot.running;
|
||||
// The queued run starts when the current one ends; wait for it too.
|
||||
if (slot.running) await slot.running;
|
||||
return { ...slot.status };
|
||||
}
|
||||
slot.running = this.runLoop(session, slot, want);
|
||||
try {
|
||||
await slot.running;
|
||||
} finally {
|
||||
slot.running = undefined;
|
||||
this.unloadIdle(slot);
|
||||
}
|
||||
return { ...slot.status };
|
||||
}
|
||||
|
||||
/** Write every loaded store's pending changes (server shutdown). */
|
||||
async flushAll(): Promise<void> {
|
||||
await Promise.all([
|
||||
...[...this.slots.values()].map(async (slot) => (await slot.store?.catch(() => undefined))?.settle()),
|
||||
...this.unloading.values(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session's stored history (its record is gone). While it runs the session is
|
||||
* marked, so a run in flight or a request meanwhile cannot load and write a new store for
|
||||
* it; then its slot goes, and a session that comes back with this id starts afresh.
|
||||
*/
|
||||
async remove(sessionId: string): Promise<void> {
|
||||
const slot = this.slot(sessionId);
|
||||
slot.gone = true;
|
||||
slot.again = undefined;
|
||||
slot.status = { state: 'unavailable', reason: REMOVED };
|
||||
try {
|
||||
const store = await slot.store?.catch(() => undefined);
|
||||
if (store) {
|
||||
await store.remove();
|
||||
} else {
|
||||
await this.unloading.get(sessionId);
|
||||
await this.deps.deleteStore(sessionId);
|
||||
}
|
||||
await slot.running;
|
||||
} finally {
|
||||
if (this.slots.get(sessionId) === slot) this.slots.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the stored history of sessions that no longer exist (startup: left behind by a
|
||||
* crash during deletion, or by an older build). A session in use here is kept.
|
||||
*/
|
||||
sweep(exists: (sessionId: string) => boolean): Promise<number> {
|
||||
return this.deps.sweepStores((id) => this.slots.has(id) || exists(id));
|
||||
}
|
||||
|
||||
/** The session's slot, now the most recently used. */
|
||||
private slot(sessionId: string): SessionSlot {
|
||||
const slot = this.slots.get(sessionId) ?? { status: { state: 'ok' } };
|
||||
this.slots.delete(sessionId);
|
||||
this.slots.set(sessionId, slot);
|
||||
return slot;
|
||||
}
|
||||
|
||||
private storeOf(sessionId: string, slot: SessionSlot): Promise<EditHistoryStore> {
|
||||
if (!slot.store) {
|
||||
const loading = (async () => {
|
||||
await this.unloading.get(sessionId);
|
||||
return this.deps.loadStore(sessionId);
|
||||
})();
|
||||
slot.store = loading;
|
||||
loading.catch(() => {
|
||||
if (slot.store === loading) slot.store = undefined;
|
||||
});
|
||||
this.unloadIdle(slot);
|
||||
}
|
||||
return slot.store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Past {@link MAX_LOADED_STORES}, drop the least recently used stores no run uses: each is
|
||||
* written if it changed, and a later request loads it again (after that write).
|
||||
*/
|
||||
private unloadIdle(keep?: SessionSlot): void {
|
||||
let loaded = 0;
|
||||
for (const slot of this.slots.values()) if (slot.store) loaded++;
|
||||
for (const [sessionId, slot] of this.slots) {
|
||||
if (loaded <= MAX_LOADED_STORES) break;
|
||||
if (!slot.store || slot.running || slot.gone || slot === keep) continue;
|
||||
this.slots.delete(sessionId);
|
||||
loaded--;
|
||||
const writing: Promise<void> = slot.store
|
||||
.then((store) => store.settle())
|
||||
.catch((err) => {
|
||||
console.warn(`[EditHistory] Saving the history of ${sessionId} failed:`, err);
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.unloading.get(sessionId) === writing) this.unloading.delete(sessionId);
|
||||
});
|
||||
this.unloading.set(sessionId, writing);
|
||||
}
|
||||
}
|
||||
|
||||
private async runLoop(session: EditHistorySession, slot: SessionSlot, want: SyncWant): Promise<void> {
|
||||
let next: SyncWant | undefined = want;
|
||||
while (next && !slot.gone) {
|
||||
if (next.forward) await this.runOnce(session, slot, false);
|
||||
if (next.older && !slot.gone) await this.runOnce(session, slot, true);
|
||||
next = slot.again;
|
||||
slot.again = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async runOnce(session: EditHistorySession, slot: SessionSlot, older: boolean): Promise<void> {
|
||||
let status: EditHistoryStatus;
|
||||
try {
|
||||
// This slot's store: a run still in flight after `remove()` cannot reach a later one.
|
||||
const store = await this.storeOf(session.id, slot);
|
||||
const path = slot.gone ? null : await this.deps.transcriptPath(session);
|
||||
let found: boolean;
|
||||
if (!path) {
|
||||
found = false;
|
||||
} else if (older) {
|
||||
found = await this.readOlder(path, store, slot);
|
||||
} else {
|
||||
found = true;
|
||||
for (let pass = 0; pass < MAX_FORWARD_PASSES && found && !slot.gone; pass++) {
|
||||
const r = await this.readForward(path, store);
|
||||
found = r.found;
|
||||
if (!r.more) break;
|
||||
}
|
||||
}
|
||||
status = found ? { state: 'ok' } : { state: 'unavailable', reason: NO_TRANSCRIPT };
|
||||
} catch (err) {
|
||||
status = { state: 'unavailable', reason: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
if (!slot.gone) slot.status = status;
|
||||
}
|
||||
|
||||
private forwardRequests(store: EditHistoryStore): EditSourceRequest[] {
|
||||
const out: EditSourceRequest[] = [];
|
||||
for (const relPath of store.knownSources()) {
|
||||
const c = store.getCursor(relPath);
|
||||
if (c) out.push({ relPath, offset: c.offset });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* One forward read of all sources. `more` = some file has unread bytes after it, or was cut
|
||||
* short by the shared budget and left for the next pass. A transcript other than the stored
|
||||
* one (`/clear`, resume) is read like a first read, once the stored one is read to its end.
|
||||
*/
|
||||
private async readForward(path: string, store: EditHistoryStore): Promise<{ found: boolean; more: boolean }> {
|
||||
const own = this.storedTranscript(path, store);
|
||||
if (own !== path && store.knownSources().length > 0) {
|
||||
const previous = await this.readFrom(own, store, this.forwardRequests(store));
|
||||
if (previous.more) return { found: true, more: true };
|
||||
}
|
||||
return this.readFrom(path, store, own === path ? this.forwardRequests(store) : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* The path of the conversation the store holds cursors for: `path` itself, or the previous
|
||||
* conversation's transcript next to it (`/clear` and resume keep the project directory).
|
||||
*/
|
||||
private storedTranscript(path: string, store: EditHistoryStore): string {
|
||||
const known = store.getTranscriptId();
|
||||
return known === null || basename(path) === `${known}.jsonl` ? path : join(dirname(path), `${known}.jsonl`);
|
||||
}
|
||||
|
||||
private async readFrom(
|
||||
path: string,
|
||||
store: EditHistoryStore,
|
||||
requests: EditSourceRequest[]
|
||||
): Promise<{ found: boolean; more: boolean }> {
|
||||
const res = await this.deps.readSources(path, requests, this.budget);
|
||||
if (!res) return { found: false, more: false };
|
||||
this.dropMissing(store, requests, res);
|
||||
for (const [agentId, text] of Object.entries(res.metas)) {
|
||||
if (!store.hasAgentMeta(agentId)) store.setAgentMeta(agentId, text);
|
||||
}
|
||||
const requested = new Set(requests.map((r) => r.relPath));
|
||||
let more = false;
|
||||
const candidates: ImageCandidate[] = [];
|
||||
let parsed = false;
|
||||
for (const f of res.files) {
|
||||
// Each file is up to one budget (8 MiB) of synchronous parsing: yield between them.
|
||||
if (parsed) await breathe();
|
||||
if (store.isRemoved()) break;
|
||||
// A requested file reads on from its cursor, a new one its tail.
|
||||
const available = requested.has(f.relPath) ? f.size - f.startOffset : f.size;
|
||||
if (f.chunk.length < Math.min(this.budget, available)) {
|
||||
more = true;
|
||||
continue;
|
||||
}
|
||||
parsed = true;
|
||||
const r = store.apply({
|
||||
transcriptId: res.transcriptId,
|
||||
source: f.relPath,
|
||||
startOffset: f.startOffset,
|
||||
chunk: f.chunk,
|
||||
size: f.size,
|
||||
});
|
||||
candidates.push(...r.candidates);
|
||||
if (f.startOffset + f.chunk.length < f.size) more = true;
|
||||
}
|
||||
await this.addImages(store, candidates);
|
||||
return { found: true, more };
|
||||
}
|
||||
|
||||
private async readOlder(path: string, store: EditHistoryStore, slot: SessionSlot): Promise<boolean> {
|
||||
if (!store.backfillTruncated) return true;
|
||||
// Older lines belong to the stored conversation, even before a forward read moved on.
|
||||
path = this.storedTranscript(path, store);
|
||||
const sources = store.sourcesWithOlder().sort();
|
||||
if (sources.length === 0) return true;
|
||||
// Take turns: start after the last source the previous click read.
|
||||
const after = slot.olderAfter;
|
||||
const first =
|
||||
after === undefined
|
||||
? 0
|
||||
: Math.max(
|
||||
0,
|
||||
sources.findIndex((s) => s > after)
|
||||
);
|
||||
const picked = [...sources.slice(first), ...sources.slice(0, first)].slice(0, MAX_OLDER_SOURCES);
|
||||
const requests: EditSourceRequest[] = [];
|
||||
for (const relPath of picked) {
|
||||
const range = store.olderRange(relPath, this.budget);
|
||||
if (range) requests.push({ relPath, offset: range.offset, len: range.len });
|
||||
}
|
||||
if (requests.length === 0) return true;
|
||||
slot.olderAfter = picked[picked.length - 1];
|
||||
const res = await this.deps.readSources(path, requests, this.budget * requests.length, { onlyRequested: true });
|
||||
if (!res) return false;
|
||||
if (res.transcriptId !== store.getTranscriptId()) return true;
|
||||
this.dropMissing(store, requests, res);
|
||||
const wanted = new Set(requests.map((r) => r.relPath));
|
||||
const candidates: ImageCandidate[] = [];
|
||||
let parsed = false;
|
||||
for (const f of res.files) {
|
||||
if (!wanted.has(f.relPath)) continue;
|
||||
// Each file is up to one budget (8 MiB) of synchronous parsing: yield between them.
|
||||
if (parsed) await breathe();
|
||||
parsed = true;
|
||||
if (slot.gone) return true;
|
||||
const r = store.applyOlder({
|
||||
transcriptId: res.transcriptId,
|
||||
source: f.relPath,
|
||||
startOffset: f.startOffset,
|
||||
chunk: f.chunk,
|
||||
size: f.size,
|
||||
});
|
||||
candidates.push(...r.candidates);
|
||||
}
|
||||
await this.addImages(store, candidates);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Requested files the reader no longer lists are gone: their cursors go too. */
|
||||
private dropMissing(store: EditHistoryStore, requests: readonly EditSourceRequest[], res: EditSources): void {
|
||||
const listed = new Set(res.files.map((f) => f.relPath));
|
||||
for (const r of requests) if (!listed.has(r.relPath)) store.dropSource(r.relPath);
|
||||
}
|
||||
|
||||
private async addImages(store: EditHistoryStore, candidates: readonly ImageCandidate[]): Promise<void> {
|
||||
const paths = [...new Set(candidates.flatMap((c) => c.paths))];
|
||||
// A removed store keeps nothing: do not probe agent-printed paths for it.
|
||||
if (paths.length === 0 || store.isRemoved()) return;
|
||||
let probes: Array<PathProbe | null>;
|
||||
try {
|
||||
probes = await this.deps.probe(paths);
|
||||
} catch {
|
||||
// The edits of this read are in; only its images are lost. The read itself succeeded.
|
||||
return;
|
||||
}
|
||||
const byPath = new Map(paths.map((p, i) => [p, probes[i]]));
|
||||
const newest = new Map<string, number>();
|
||||
for (const e of store.list()) newest.set(e.path, Math.max(newest.get(e.path) ?? -Infinity, e.ts));
|
||||
store.addImageEntries(
|
||||
confirmImages(
|
||||
candidates,
|
||||
(p) => byPath.get(p),
|
||||
(p) => newest.get(p)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,12 @@ export const UPLOADS_DIR = '.codeman-uploads';
|
||||
export const LEGACY_UPLOADS_DIR = '.claude-images';
|
||||
/** Every upload dir name, current first. Retiring the legacy one here retires it for every reader. */
|
||||
export const UPLOAD_DIR_NAMES = [UPLOADS_DIR, LEGACY_UPLOADS_DIR];
|
||||
/**
|
||||
* Where files a person sent from Discord land (discord-bridge.ts). Named here with the others so the
|
||||
* image watcher can skip it without importing the bridge. Not an upload dir: neither the sweep nor a
|
||||
* session delete touches it.
|
||||
*/
|
||||
export const DISCORD_RECEIVED_DIR = '.codeman-discord';
|
||||
|
||||
/**
|
||||
* Every directory a session's uploads sit in, current first. Both consumers
|
||||
|
||||
@@ -10,6 +10,11 @@ import type { SessionPlacementHint } from '../../tab-layout-service.js';
|
||||
|
||||
export interface SessionPort {
|
||||
readonly sessions: ReadonlyMap<string, Session>;
|
||||
/**
|
||||
* The last-response reader (the body of `GET /api/sessions/:id/last-response`), filled in when the
|
||||
* session routes register, so other server code reads the same per-CLI transcript the viewer does.
|
||||
*/
|
||||
lastResponseFor?: (session: Session, context?: string) => Promise<{ text: string; timestamp?: string }>;
|
||||
/**
|
||||
* Register a created session with the owner's tab layout. `placement.tabGroupId`
|
||||
* puts it at the end of that group in the same layout write (ignored when the
|
||||
@@ -19,6 +24,11 @@ export interface SessionPort {
|
||||
*/
|
||||
addSession(session: Session, placement?: SessionPlacementHint): Promise<TabLayout>;
|
||||
cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise<void>;
|
||||
/**
|
||||
* Delete a session's stored edit history once its record is gone. `cleanupSession` does it
|
||||
* itself; this is for removing a record without a live session (DELETE route).
|
||||
*/
|
||||
forgetEditHistory(sessionId: string): void;
|
||||
setupSessionListeners(session: Session): Promise<void>;
|
||||
persistSessionState(session: Session): void;
|
||||
persistSessionStateNow(session: Session): void;
|
||||
|
||||
+35
-5
@@ -286,6 +286,10 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.IMAGE_DETECTED, '_onImageDetected'],
|
||||
[SSE_EVENTS.ATTACHMENT_DETECTED, '_onAttachmentDetected'],
|
||||
|
||||
// Edit history (edit-history-ui.js): an open panel follows its session's tool ends
|
||||
// (and its turn ends, from _onSessionIdle: one handler per event in this map)
|
||||
[SSE_EVENTS.TRANSCRIPT_TOOL_END, '_onEditHistoryTranscriptEvent'],
|
||||
|
||||
// Tunnel
|
||||
[SSE_EVENTS.TUNNEL_STARTED, '_onTunnelStarted'],
|
||||
[SSE_EVENTS.TUNNEL_STOPPED, '_onTunnelStopped'],
|
||||
@@ -3587,6 +3591,9 @@ class CodemanApp {
|
||||
this.sendPendingCtrlL(data.id);
|
||||
if (data.id === this.activeSessionId) this._updateLocalEchoState();
|
||||
}
|
||||
// An open Edit history panel re-reads at the end of its session's turn (edit-history-ui.js),
|
||||
// not after Codeman's own keep-alive turn, which edits nothing.
|
||||
if (!data.keepAlive) this.onEditHistorySessionIdle?.(data.id);
|
||||
// Start stuck detection timer (only if no respawn running). Not at the end of Codeman's
|
||||
// own keep-alive turn (`keepAlive`): the session has been waiting since the last real
|
||||
// turn, whose timer is still running or has already fired.
|
||||
@@ -7035,6 +7042,8 @@ class CodemanApp {
|
||||
container.classList.remove('tabs-corner');
|
||||
container.style.removeProperty('--tab-corner-width');
|
||||
container.style.removeProperty('--tab-corner-height');
|
||||
container.style.removeProperty('--tab-corner-first-max');
|
||||
for (const tab of container.querySelectorAll(':scope > .tab-corner-fit')) tab.classList.remove('tab-corner-fit');
|
||||
};
|
||||
// A strip past its max-height scrolls, and the float would scroll its tabs
|
||||
// under the toolbar, which stays put: keep the plain wrap. The plain strip
|
||||
@@ -7071,14 +7080,33 @@ class CodemanApp {
|
||||
const contentTop = strip.top + parseFloat(style.paddingTop);
|
||||
const contentLeft = strip.left + container.clientLeft + parseFloat(style.paddingLeft);
|
||||
const contentRight = strip.left + container.clientLeft + container.clientWidth - parseFloat(style.paddingRight);
|
||||
// A first tab that cannot fit beside the toolbar would drop the whole first
|
||||
// line below it and leave an empty band: keep the plain strip instead.
|
||||
const firstTab = container.querySelector(':scope > .session-tab');
|
||||
if (firstTab && box.left - 8 - contentLeft < firstTab.offsetWidth + 2) {
|
||||
// The first tab must fit beside the toolbar, or the whole first line drops
|
||||
// below it and leaves an empty band. A selected tab is wider (gear and
|
||||
// close show), so falling back to the plain strip here made the layout
|
||||
// follow the selection; the first tab's name is trimmed to the room
|
||||
// instead. Only a toolbar that leaves no usable room keeps the plain strip.
|
||||
// The room is what the float (rounded up below) leaves, less the tab's 2px
|
||||
// right margin and a pixel for subpixel layout; a tab at the exact width
|
||||
// still wrapped under the float.
|
||||
const floatWidth = Math.max(0, Math.ceil(contentRight - box.left + 8));
|
||||
const room = Math.floor(contentRight - floatWidth - contentLeft) - 3;
|
||||
if (room < TAB_CORNER_MIN_FIRST_TAB) {
|
||||
off();
|
||||
return;
|
||||
}
|
||||
const width = `${Math.max(0, Math.ceil(contentRight - box.left + 8))}px`;
|
||||
const firstTab = container.querySelector(':scope > .session-tab');
|
||||
// Capped every time, not only once it measures too wide: selectSession()
|
||||
// widens a newly selected tab at once and this pass is debounced, so a cap
|
||||
// set only here afterwards would leave the first row below the toolbar until
|
||||
// it runs. The cap changes nothing on a tab narrower than the room.
|
||||
for (const tab of container.querySelectorAll(':scope > .tab-corner-fit')) {
|
||||
if (tab !== firstTab) tab.classList.remove('tab-corner-fit');
|
||||
}
|
||||
firstTab?.classList.add('tab-corner-fit');
|
||||
if (container.style.getPropertyValue('--tab-corner-first-max') !== `${room}px`) {
|
||||
container.style.setProperty('--tab-corner-first-max', `${room}px`);
|
||||
}
|
||||
const width = `${floatWidth}px`;
|
||||
const height = `${Math.max(0, Math.ceil(box.bottom - contentTop))}px`;
|
||||
if (container.style.getPropertyValue('--tab-corner-width') !== width) {
|
||||
container.style.setProperty('--tab-corner-width', width);
|
||||
@@ -9987,6 +10015,8 @@ class CodemanApp {
|
||||
// Remote-host reachability banner: only meaningful for a remote session, so this
|
||||
// also clears it when the newly active tab is local.
|
||||
this.refreshHostWakeBanner?.(sessionId);
|
||||
// An open Edit history panel follows the active session (edit-history-ui.js; no poll).
|
||||
this.onEditHistoryActiveSession?.(sessionId);
|
||||
|
||||
// Restore flushed offset AND text IMMEDIATELY so backspace/typing work during
|
||||
// the async buffer load. Without this, the offset is 0 during the
|
||||
|
||||
+114
-25
@@ -281,6 +281,11 @@ function resolveTabRailKeyboardWidth(input = {}) {
|
||||
// the gap is what tells the user there is more strip to swipe to.
|
||||
const TAB_SCROLL_REVEAL_PX = 16;
|
||||
|
||||
// Narrowest room beside the toolbar the L-shaped header strip trims its first
|
||||
// tab into (`_syncTabStripCorner()`, app.js). Less than this keeps the plain
|
||||
// wrap: a tab squeezed further shows its number and buttons but no name.
|
||||
const TAB_CORNER_MIN_FIRST_TAB = 120;
|
||||
|
||||
// Phone/tablet tab-strip scroll policy (issue #257). Those breakpoints scroll
|
||||
// the strip horizontally (desktop wraps to a second row instead and never
|
||||
// scrolls), so the active tab can sit entirely outside the visible slice with
|
||||
@@ -2106,7 +2111,7 @@ function computeRewriteScrollLine(input) {
|
||||
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
|
||||
*/
|
||||
const FILE_PATH_LINK_PATTERN =
|
||||
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
|
||||
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|conf|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
|
||||
|
||||
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
|
||||
function absoluteFilePathPattern() {
|
||||
@@ -2159,14 +2164,14 @@ function previewsInFileViewer(filePath) {
|
||||
* {@link findFilePathLinks}.
|
||||
*/
|
||||
const RELATIVE_FILE_PATH_LINK_PATTERN =
|
||||
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
|
||||
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|conf|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
|
||||
|
||||
/** The `(?:log|txt|…)` extension group, read off the rooted pattern so it cannot drift. */
|
||||
const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LINK_PATTERN.source)[1];
|
||||
|
||||
/**
|
||||
* Every linkable file path in `text`, sorted by position. Spaced candidates
|
||||
* reserve their whole span first, even when unlinkable; rooted, `~/`, relative
|
||||
* reserve their whole span first, including paths bounded by parentheses, even when unlinkable; rooted, `~/`, relative
|
||||
* and folder detectors then skip reserved spans, earlier matches and URLs.
|
||||
*
|
||||
* The one entry point for both consumers (terminal link provider, response
|
||||
@@ -2195,19 +2200,26 @@ function findFilePathLinks(text) {
|
||||
// A path with spaces is taken whole, or the span it covers links nothing
|
||||
// (spacedFilePathOnLine). Either way no detector below may cut a fragment out of it.
|
||||
const reserved = [];
|
||||
const isReserved = (index, length) =>
|
||||
reserved.some((r) => index < r.index + r.path.length && r.index < index + length);
|
||||
const reserve = (spaced, index) => {
|
||||
if (!spaced) return;
|
||||
const start = index + spaced.index;
|
||||
if (inUrl(start, spaced.path.length) || isReserved(start, spaced.path.length)) return;
|
||||
reserved.push({ index: start, path: spaced.path });
|
||||
if (spaced.link) found.push({ path: spaced.path, index: start });
|
||||
};
|
||||
let lineStart = 0;
|
||||
for (const line of value.split('\n')) {
|
||||
const spaced = spacedFilePathOnLine(line);
|
||||
if (spaced && !inUrl(lineStart + spaced.index, spaced.path.length)) {
|
||||
const index = lineStart + spaced.index;
|
||||
reserved.push({ index, path: spaced.path });
|
||||
if (spaced.link) found.push({ path: spaced.path, index });
|
||||
reserve(spacedFilePathOnLine(line), lineStart);
|
||||
// Parentheses bound an inline path just as a whole line bounds a standalone path.
|
||||
const parenthesised = /\(([^()\r\n]{1,1000})\)/g;
|
||||
while ((match = parenthesised.exec(line)) !== null) {
|
||||
if (opensBashCall(line, match.index)) continue;
|
||||
reserve(spacedFilePathOnLine(match[1], { parenthesised: true }), lineStart + match.index + 1);
|
||||
}
|
||||
lineStart += line.length + 1;
|
||||
}
|
||||
const isReserved = (index, length) =>
|
||||
reserved.some((r) => index < r.index + r.path.length && r.index < index + length);
|
||||
|
||||
const absolute = absoluteFilePathPattern();
|
||||
while ((match = absolute.exec(value)) !== null) {
|
||||
if (!inUrl(match.index, match[1].length) && !isReserved(match.index, match[1].length)) {
|
||||
@@ -2235,14 +2247,29 @@ function findFilePathLinks(text) {
|
||||
const SPACED_FILE_PATH_LABEL_PATTERN =
|
||||
/^(?:path|files?|saved (?:to|as)|wrote|written to|output|created|location|attachment):[ \t]*$/i;
|
||||
|
||||
/**
|
||||
* True when the `(` at `open` starts a `Bash(` tool row, which holds a command rather than
|
||||
* a path: `Bash(/tmp/a.out input.txt)` is a program and its argument, so the parentheses do
|
||||
* not bound one spaced path and the ordinary detectors keep the row. Only `Bash(`: the path
|
||||
* tools (`Read(`, `Update(`, `Write(`) do hold a path, spaces included.
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {number} open Index of the `(`.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function opensBashCall(text, open) {
|
||||
return /(?:^|\W)Bash\($/.test(text.slice(Math.max(0, open - 5), open + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* A file path WITH SPACES on one line, or null.
|
||||
*
|
||||
* Every path pattern ends a path at whitespace, so `/mnt/c/OneDrive - Contoso/a.docx`
|
||||
* linked `/mnt/c/OneDrive` plus a relative `Contoso/a.docx`, two links to the
|
||||
* wrong targets. A space is only safe inside a path where the line itself bounds
|
||||
* the path, so a spaced path is recognised in exactly these shapes:
|
||||
* the path or a parenthesised group bounds it, so a spaced path is recognised in these shapes:
|
||||
*
|
||||
* Updated (/mnt/c/My Docs/report.docx) inside parentheses in prose
|
||||
* /mnt/c/My Docs/report.docx the whole line is the path
|
||||
* - /mnt/c/My Docs/report.docx after a list marker (`-`, `*`, `•`, `1.`, `2)`)
|
||||
* Saved to: 03 Attachments/Build SIPOC Design/plan.pptx after a path label (SPACED_FILE_PATH_LABEL_PATTERN)
|
||||
@@ -2256,7 +2283,7 @@ const SPACED_FILE_PATH_LABEL_PATTERN =
|
||||
* it ends on a known extension, a rooted one has a directory before its first
|
||||
* space, no earlier word holds an extension (`a.md,`, `a.ts#L12`) or ends in `/`,
|
||||
* and none holds a `:` and a digit (`a.ts:42`, a line suffix ends the path). Later words cannot start a new
|
||||
* `/`, `~/`, `./` or `../` path, no earlier word ends in `:`, is an arrow or has
|
||||
* `/`, `~/`, `./` or `../` path, no earlier word ends in `:` (or `,` inside parentheses), is an arrow or has
|
||||
* no letter or digit (a prompt glyph such as `❯` or `$`; a bare `-` is kept), and
|
||||
* a run of spaces (a column gap) is refused, so two paths on a line are never
|
||||
* merged. A relative candidate whose only `/` word is its last is prose after the
|
||||
@@ -2278,9 +2305,10 @@ const SPACED_FILE_PATH_LABEL_PATTERN =
|
||||
* Linear: one bounded prefix match, then string scans.
|
||||
*
|
||||
* @param {string} line One line, no newline.
|
||||
* @param {{parenthesised?: boolean}} [options] Refuse comma-separated lists inside parentheses only.
|
||||
* @returns {{path: string, index: number, link: boolean} | null}
|
||||
*/
|
||||
function spacedFilePathOnLine(line) {
|
||||
function spacedFilePathOnLine(line, options = {}) {
|
||||
const text = String(line || '');
|
||||
const prefix = /^[ \t]*((?:\d{1,3}[.)]|[-*•])[ \t]+)?((?:[A-Za-z][\w-]{0,30}(?: [\w-]{1,30}){0,3}):[ \t]+)?/.exec(text);
|
||||
const index = prefix[0].length;
|
||||
@@ -2304,6 +2332,7 @@ function spacedFilePathOnLine(line) {
|
||||
}
|
||||
for (const word of words.slice(0, -1)) {
|
||||
if (word.endsWith('/') || word.endsWith(':') || /^(?:—|–|→|=>|->|<-|=)$/.test(word)) return null;
|
||||
if (options.parenthesised && word.endsWith(',')) return null;
|
||||
// A shell prompt that prints its working directory in full (`/home/me/proj$ cat
|
||||
// notes.md`; WSL's `/mnt/c/...` is outside `$HOME`) is a command line too, and so is
|
||||
// one whose directory has a space in it (`/mnt/c/My Docs$ ls notes.md`): the `$`
|
||||
@@ -2397,6 +2426,31 @@ function resolveLinkedFilePath(filePath, workingDir) {
|
||||
return `/${parts.join('/')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory a RELATIVE path printed for a session is relative TO.
|
||||
*
|
||||
* `workingDir` is recorded once at create time and never follows a `cd`, so it
|
||||
* stops describing where a shell (or an agent that changed directory) really
|
||||
* is. `session.paneCwd` is the pane's live `#{pane_current_path}`, published by the
|
||||
* server from its pane read. It only changes which BASE a relative path joins;
|
||||
* the result is then routed like any other path, so a target outside the
|
||||
* workspace still goes through the attachment routes.
|
||||
*
|
||||
* Remote and Docker sessions are excluded (the server never sends them a paneCwd,
|
||||
* and this checks again): their local pane runs the ssh / docker-exec CLIENT,
|
||||
* so its directory says nothing about the agent. An unknown paneCwd falls back to
|
||||
* `workingDir`.
|
||||
*
|
||||
* @param {{workingDir?: string, paneCwd?: string, remote?: unknown, docker?: unknown}} [session]
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
function relativeResolutionBase(session) {
|
||||
if (!session) return undefined;
|
||||
const paneCwd = session.paneCwd;
|
||||
if (!session.remote && !session.docker && typeof paneCwd === 'string' && paneCwd.startsWith('/')) return paneCwd;
|
||||
return session.workingDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extensions the file preview renders as a PAGE, from the sandboxed
|
||||
* `/html-view/<cap>/` route (src/web/routes/html-view-routes.ts, whose
|
||||
@@ -2557,9 +2611,10 @@ const pathFirstSegment = (path) => path.replace(/^(?:\.?\/)+/, '').split('/')[0]
|
||||
* top directory the broken token did (the next entry of a listing).
|
||||
*/
|
||||
function pathContinuation(prevText, nextText) {
|
||||
const tail = /[^\s"'<>|;&]+$/.exec(String(prevText || '').replace(/\s+$/, ''))?.[0] || '';
|
||||
if (!tail.includes('/') || !/[\w\-.~+@%/]$/.test(tail) || PATH_TOKEN_ENDS_WITH_EXTENSION.test(tail)) return null;
|
||||
const previous = String(prevText || '').trimEnd();
|
||||
const next = String(nextText || '').replace(/^\s+/, '');
|
||||
const tail = /[^\s"'<>|;&]+$/.exec(previous)?.[0] || '';
|
||||
if (!tail.includes('/') || !/[\w\-.~+@%/]$/.test(tail) || PATH_TOKEN_ENDS_WITH_EXTENSION.test(tail)) return null;
|
||||
const head = /^[^\s"'<>|;&]+/.exec(next)?.[0] || '';
|
||||
if (!head || PATH_TOKEN_STARTS_NEW_PATH.test(head) || PATH_TOKEN_STARTS_EXPLICIT_RELATIVE.test(head)) return null;
|
||||
// A listing (`git ls-files`, `find`, `ls -R`) puts one path per row, so `src/components`
|
||||
@@ -2586,7 +2641,9 @@ function pathContinuation(prevText, nextText) {
|
||||
* A following logical line is glued on (its indent dropped, cell mapping kept
|
||||
* exact) when {@link pathContinuation} says it carries the path on and the row it
|
||||
* broke off is at least as long as the row it continues on. Bounded by the same
|
||||
* `maxRows` as the logical line.
|
||||
* `maxRows` as the logical line. Hard breaks concatenate without inserting spaces.
|
||||
* An ambiguous parenthesised filename also carries `pathAlternatives` for a
|
||||
* guarded lookup on activation; its text and cell mapping remain unchanged.
|
||||
*
|
||||
* @returns Same shape as {@link terminalLogicalLine}; null when the row does not exist.
|
||||
*/
|
||||
@@ -2607,6 +2664,7 @@ function terminalPathLine(buffer, row, cols, maxRows) {
|
||||
/** `first` and the lines a path carries on into, stopping at the last confirmed join. */
|
||||
const extend = (first) => {
|
||||
const parts = [first];
|
||||
const alternatives = [];
|
||||
let text = first.text;
|
||||
let rows = rowsOf(first);
|
||||
let confirmed = 1;
|
||||
@@ -2616,25 +2674,41 @@ function terminalPathLine(buffer, row, cols, maxRows) {
|
||||
const kind = continues(parts[parts.length - 1], next, text);
|
||||
if (!kind) break;
|
||||
parts.push(next);
|
||||
if (kind === 'join') {
|
||||
const opening = text.lastIndexOf('(');
|
||||
const closing = next.text.indexOf(')');
|
||||
if (opening >= 0 && closing > 0 && !opensBashCall(text, opening)) {
|
||||
const before = text.slice(opening + 1);
|
||||
const after = next.text.slice(0, closing).trimStart();
|
||||
const spaced = spacedFilePathOnLine(`${before} ${after}`, { parenthesised: true });
|
||||
if (!before.includes(')') && !/[/-]$/.test(before) && !after.includes('/') && spaced?.link) {
|
||||
alternatives.push({ path: before.slice(spaced.index) + after, spacedPath: spaced.path });
|
||||
}
|
||||
}
|
||||
}
|
||||
text += next.text.replace(/^\s+/, '');
|
||||
rows += rowsOf(next);
|
||||
if (kind === 'join') confirmed = parts.length;
|
||||
}
|
||||
return parts.slice(0, confirmed);
|
||||
return { parts: parts.slice(0, confirmed), alternatives };
|
||||
};
|
||||
|
||||
// The hovered row may be the second or third line of a broken path: walk back
|
||||
// to where it could start, then let the forward pass decide what really joins.
|
||||
// A middle fragment may have no slash, so only a forward pass from its
|
||||
// rooted start can confirm a path spanning three or more rows.
|
||||
let extended = extend(base);
|
||||
let first = base;
|
||||
let rows = rowsOf(base);
|
||||
while (first.startRow > 0) {
|
||||
const prev = lineAt(first.startRow - 1);
|
||||
if (!prev || rows + rowsOf(prev) > bound || !continues(prev, first, prev.text)) break;
|
||||
if (!prev || rows + rowsOf(prev) > bound) break;
|
||||
first = prev;
|
||||
rows += rowsOf(prev);
|
||||
const tail = /[^\s"'<>|;&]+$/.exec(first.text.trimEnd())?.[0] || '';
|
||||
if (!tail.includes('/')) continue;
|
||||
const candidate = extend(first);
|
||||
if (candidate.parts[candidate.parts.length - 1].endRow >= base.startRow) extended = candidate;
|
||||
}
|
||||
let parts = extend(first);
|
||||
if (parts[parts.length - 1].endRow < base.startRow) parts = extend(base);
|
||||
const { parts, alternatives } = extended;
|
||||
if (parts.length === 1) return parts[0];
|
||||
|
||||
const segments = [];
|
||||
@@ -2659,7 +2733,14 @@ function terminalPathLine(buffer, row, cols, maxRows) {
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
return { startRow: parts[0].startRow, endRow: parts[parts.length - 1].endRow, text, offsetToCell, cellToOffset };
|
||||
return {
|
||||
startRow: parts[0].startRow,
|
||||
endRow: parts[parts.length - 1].endRow,
|
||||
text,
|
||||
offsetToCell,
|
||||
cellToOffset,
|
||||
...(alternatives.length ? { pathAlternatives: alternatives } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2915,7 +2996,14 @@ function terminalLinkLine(buffer, row, cols, maxRows) {
|
||||
for (const s of segments) if (s.row === targetRow && (!seg || s.colStart <= targetCol)) seg = s;
|
||||
return seg ? seg.textStart + Math.max(0, targetCol - seg.colStart) : -1;
|
||||
};
|
||||
return { startRow, endRow: lastRow, text, offsetToCell, cellToOffset };
|
||||
return {
|
||||
startRow,
|
||||
endRow: lastRow,
|
||||
text,
|
||||
offsetToCell,
|
||||
cellToOffset,
|
||||
...(base.pathAlternatives ? { pathAlternatives: base.pathAlternatives } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4025,6 +4113,7 @@ if (typeof window !== 'undefined') {
|
||||
absoluteFilePathPattern,
|
||||
findFilePathLinks,
|
||||
resolveLinkedFilePath,
|
||||
relativeResolutionBase,
|
||||
previewsInFileViewer,
|
||||
FILE_PREVIEW_EXTENSIONS,
|
||||
rendersAsPage,
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
/**
|
||||
* @fileoverview Edit history: a header button (next to Attachments) and the floating panel it opens
|
||||
* right below, listing every file edit the ACTIVE session's agent (and its subagents) made, newest
|
||||
* first.
|
||||
*
|
||||
* OPTIONAL and per-device: `showEditHistory` (App Settings → Header & Panels → Header), default
|
||||
* OFF. While it is off the button never shows and nothing is requested. The panel DRIVES the
|
||||
* server's reading, which never runs in the background: it reads
|
||||
* `GET /api/sessions/:id/edits?sync=1` when it opens and when the active session changes, and,
|
||||
* only while it is open, re-reads (debounced) on the active session's `transcript:tool_end` for a
|
||||
* tool that can change files (the existing transcript watcher's event) and on its `session:idle`
|
||||
* (the end of a turn). No poll, and nothing at all while the panel is closed.
|
||||
*
|
||||
* - A code entry opens its diff inline (`GET /api/sessions/:id/edits/:entryId`), drawn with the Git
|
||||
* panel's diff classes (`git-diff`, `git-diff-line--hunk|add|del`).
|
||||
* - An image entry opens the shared file preview (panels-ui.js `openFilePreview`), as does a click
|
||||
* on any path; inside the workspace it also shows a thumbnail. Both go through the existing file
|
||||
* routes only: the thumbnail is the confined `file-raw`, and the preview of a file outside the
|
||||
* workspace registers it on click with `notify: false` (`_registerExternalPreview`, which
|
||||
* enforces the attachment guard and its extension allowlist). A path from the transcript is
|
||||
* never served by a route of its own.
|
||||
* - `backfillTruncated` offers "Load older" (`POST /api/sessions/:id/edits/older`).
|
||||
*
|
||||
* Paths, agent descriptions and diffs are untrusted text: they are only ever written with
|
||||
* `textContent`, never `innerHTML`.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (this.activeSessionId, this.sessions, this.loadAppSettingsFromStorage, this.getDefaultSettings, this.$)
|
||||
* @dependency api-client.js (this._api, this._apiJson; resolved at call time)
|
||||
* @dependency panels-ui.js (openFilePreview, _isExternalPreviewPath)
|
||||
* @dependency i18n.js (window.codemanT)
|
||||
* @loadorder 12.58 of 16, after git-status-ui.js, before entrance-animations.js
|
||||
*/
|
||||
|
||||
/** SSE bursts (one event per tool call) collapse into one read. */
|
||||
const EDIT_HISTORY_SSE_DEBOUNCE_MS = 600;
|
||||
/** Tools whose end can mean changed files (Agent/Task: a subagent's edits; SendUserFile: an image). */
|
||||
const EDIT_HISTORY_FOLLOW_TOOLS = new Set([
|
||||
'Edit',
|
||||
'Write',
|
||||
'MultiEdit',
|
||||
'NotebookEdit',
|
||||
'Bash',
|
||||
'Agent',
|
||||
'Task',
|
||||
'SendUserFile',
|
||||
]);
|
||||
/** Per-device view toggles (a viewer convenience, so plain localStorage). */
|
||||
const EDIT_HISTORY_GROUP_KEY = 'codeman-edit-history-group';
|
||||
const EDIT_HISTORY_MAIN_ONLY_KEY = 'codeman-edit-history-main-only';
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/** Per-device setting, default OFF. */
|
||||
isEditHistoryEnabled() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
return (settings.showEditHistory ?? defaults.showEditHistory ?? false) === true;
|
||||
},
|
||||
|
||||
/** Translate an English source string (i18n.js), with `{var}` interpolation. */
|
||||
_eht(source, vars) {
|
||||
if (typeof window.codemanT === 'function') return window.codemanT(source, vars);
|
||||
return vars ? source.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m)) : source;
|
||||
},
|
||||
|
||||
/** A row badge ("new"/"edit") through its scoped i18n key; English shows the bare word. */
|
||||
_ehBadge(word) {
|
||||
const key = `Edit history badge: ${word}`;
|
||||
const out = this._eht(key);
|
||||
return out === key ? word : out;
|
||||
},
|
||||
|
||||
/** Called from applyHeaderVisibilitySettings() (boot and every settings save). */
|
||||
applyEditHistoryVisibility() {
|
||||
const on = this.isEditHistoryEnabled();
|
||||
// Marker class, not [hidden]: the base rule is display:inline-flex !important (src/web/CLAUDE.md).
|
||||
this.$('editHistoryBtn')?.classList.toggle('btn-edit-history--hidden', !on);
|
||||
if (!on) {
|
||||
this.closeEditHistoryPanel();
|
||||
this._editHistory = null;
|
||||
this._editHistoryEpoch = (this._editHistoryEpoch || 0) + 1; // an in-flight read must not repaint
|
||||
clearTimeout(this._editHistoryDebounce);
|
||||
this._editHistoryDebounce = null;
|
||||
}
|
||||
},
|
||||
|
||||
// ── Per-device view toggles ─────────────────────────────────────────────
|
||||
|
||||
_editHistoryPref(key) {
|
||||
try {
|
||||
return localStorage.getItem(key) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
_setEditHistoryPref(key, on) {
|
||||
try {
|
||||
localStorage.setItem(key, on ? '1' : '0');
|
||||
} catch {
|
||||
/* private window / blocked storage: the toggle still works for this page */
|
||||
}
|
||||
},
|
||||
|
||||
isEditHistoryGrouped() {
|
||||
return this._editHistoryGroupOn ?? (this._editHistoryGroupOn = this._editHistoryPref(EDIT_HISTORY_GROUP_KEY));
|
||||
},
|
||||
|
||||
isEditHistoryMainOnly() {
|
||||
return this._editHistoryMainOnly ?? (this._editHistoryMainOnly = this._editHistoryPref(EDIT_HISTORY_MAIN_ONLY_KEY));
|
||||
},
|
||||
|
||||
setEditHistoryGrouped(on) {
|
||||
this._editHistoryGroupOn = !!on;
|
||||
this._setEditHistoryPref(EDIT_HISTORY_GROUP_KEY, !!on);
|
||||
this._renderEditHistoryPanel();
|
||||
},
|
||||
|
||||
setEditHistoryMainOnly(on) {
|
||||
this._editHistoryMainOnly = !!on;
|
||||
this._setEditHistoryPref(EDIT_HISTORY_MAIN_ONLY_KEY, !!on);
|
||||
this._renderEditHistoryPanel();
|
||||
},
|
||||
|
||||
// ── Data ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The data for the session on screen, or null. */
|
||||
_currentEditHistory() {
|
||||
const h = this._editHistory;
|
||||
return h && h.sessionId === this.activeSessionId ? h : null;
|
||||
},
|
||||
|
||||
_editHistoryBase(sid) {
|
||||
return `/api/sessions/${encodeURIComponent(sid)}/edits`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Read the active session's history and repaint. `older` asks the server for entries before the
|
||||
* backfill window (POST), otherwise the normal read. Answers for another session are dropped.
|
||||
*/
|
||||
async refreshEditHistory({ older = false } = {}) {
|
||||
if (!this.isEditHistoryEnabled()) return;
|
||||
const sid = this.activeSessionId || null;
|
||||
if (!sid) {
|
||||
this._editHistory = null;
|
||||
this._renderEditHistoryPanel();
|
||||
return;
|
||||
}
|
||||
const epoch = (this._editHistoryEpoch = (this._editHistoryEpoch || 0) + 1);
|
||||
const prev = this._currentEditHistory();
|
||||
if (!prev) {
|
||||
// Another session's list must not stay on screen while this one loads.
|
||||
this._editHistory = { sessionId: sid, state: 'loading', data: null, diffs: new Map(), open: new Set() };
|
||||
this._renderEditHistoryPanel();
|
||||
} else if (older) {
|
||||
prev.loadingOlder = true;
|
||||
this._renderEditHistoryPanel();
|
||||
}
|
||||
const base = this._editHistoryBase(sid);
|
||||
const data = older
|
||||
? await this._apiJson(`${base}/older`, { method: 'POST', body: {} })
|
||||
: await this._apiJson(`${base}?sync=1`);
|
||||
if (epoch !== this._editHistoryEpoch || sid !== this.activeSessionId || !this.isEditHistoryEnabled()) return;
|
||||
const cur = this._currentEditHistory() || {
|
||||
sessionId: sid,
|
||||
diffs: new Map(),
|
||||
open: new Set(),
|
||||
};
|
||||
cur.loadingOlder = false;
|
||||
if (data && typeof data === 'object') {
|
||||
cur.state = 'ok';
|
||||
cur.data = data;
|
||||
} else if (!cur.data) {
|
||||
cur.state = 'error';
|
||||
}
|
||||
this._editHistory = cur;
|
||||
this._renderEditHistoryPanel();
|
||||
},
|
||||
|
||||
loadOlderEditHistory() {
|
||||
return this.refreshEditHistory({ older: true });
|
||||
},
|
||||
|
||||
/**
|
||||
* SSE `transcript:tool_end` ({ sessionId, toolName }), and the end of a turn through
|
||||
* `onEditHistorySessionIdle`: while the panel is open on that session, re-read after a tool that
|
||||
* can change files, and when the turn ends (a parallel tool call reports only one tool end; a
|
||||
* background subagent writes after the tool end that started it). Closed: nothing.
|
||||
*/
|
||||
_onEditHistoryTranscriptEvent(data) {
|
||||
if (!data || data.sessionId !== this.activeSessionId || !this._isEditHistoryPanelOpen()) return;
|
||||
if (data.toolName !== undefined && !EDIT_HISTORY_FOLLOW_TOOLS.has(data.toolName)) return;
|
||||
if (!this.isEditHistoryEnabled()) return;
|
||||
clearTimeout(this._editHistoryDebounce);
|
||||
this._editHistoryDebounce = setTimeout(() => {
|
||||
this._editHistoryDebounce = null;
|
||||
if (this._isEditHistoryPanelOpen()) this.refreshEditHistory();
|
||||
}, EDIT_HISTORY_SSE_DEBOUNCE_MS);
|
||||
},
|
||||
|
||||
/**
|
||||
* Called by app.js's `session:idle` handler ({ id }): the end of a turn. Claude's interactive
|
||||
* transcript has no `result` line, so the transcript watcher's `transcript:complete` never fires
|
||||
* for these sessions.
|
||||
*/
|
||||
onEditHistorySessionIdle(sessionId) {
|
||||
this._onEditHistoryTranscriptEvent({ sessionId });
|
||||
},
|
||||
|
||||
/** Called by selectSession(): the open panel follows the active session. */
|
||||
onEditHistoryActiveSession(sessionId) {
|
||||
if (!this.isEditHistoryEnabled() || !this._isEditHistoryPanelOpen()) return;
|
||||
if (this._editHistory?.sessionId === sessionId) return;
|
||||
this.refreshEditHistory();
|
||||
},
|
||||
|
||||
// ── Panel ───────────────────────────────────────────────────────────────
|
||||
|
||||
_isEditHistoryPanelOpen() {
|
||||
return !!this.$('editHistoryPanel')?.classList.contains('visible');
|
||||
},
|
||||
|
||||
toggleEditHistoryPanel() {
|
||||
if (this._isEditHistoryPanelOpen()) {
|
||||
this.closeEditHistoryPanel();
|
||||
return;
|
||||
}
|
||||
const panel = this.$('editHistoryPanel');
|
||||
if (!panel) return;
|
||||
panel.classList.add('visible');
|
||||
this.$('editHistoryBtn')?.setAttribute('aria-expanded', 'true');
|
||||
const group = this.$('editHistoryGroupToggle');
|
||||
if (group) group.checked = this.isEditHistoryGrouped();
|
||||
const main = this.$('editHistoryMainOnlyToggle');
|
||||
if (main) main.checked = this.isEditHistoryMainOnly();
|
||||
this._ensureEditHistoryPanelDrag();
|
||||
this._renderEditHistoryPanel();
|
||||
this.refreshEditHistory();
|
||||
},
|
||||
|
||||
closeEditHistoryPanel() {
|
||||
const panel = this.$('editHistoryPanel');
|
||||
if (panel) {
|
||||
panel.classList.remove('visible');
|
||||
// Reset a dragged position so it reopens at the default spot (a resized size is kept).
|
||||
panel.style.left = panel.style.top = panel.style.right = panel.style.bottom = '';
|
||||
}
|
||||
this.$('editHistoryBtn')?.setAttribute('aria-expanded', 'false');
|
||||
clearTimeout(this._editHistoryDebounce);
|
||||
this._editHistoryDebounce = null;
|
||||
},
|
||||
|
||||
/** Drag by the header (pointer events: mouse, pen and touch). One set of listeners per page. */
|
||||
_ensureEditHistoryPanelDrag() {
|
||||
const panel = this.$('editHistoryPanel');
|
||||
const handle = panel?.querySelector('.edit-history-header');
|
||||
if (!panel || !handle || handle._dragReady) return;
|
||||
handle._dragReady = true;
|
||||
let drag = null;
|
||||
handle.addEventListener('pointerdown', (e) => {
|
||||
if (e.target.closest('button')) return;
|
||||
const rect = panel.getBoundingClientRect();
|
||||
drag = { dx: e.clientX - rect.left, dy: e.clientY - rect.top };
|
||||
// Switch from right/bottom anchoring to explicit left/top so the drag has one coordinate system.
|
||||
panel.style.left = `${rect.left}px`;
|
||||
panel.style.top = `${rect.top}px`;
|
||||
panel.style.right = 'auto';
|
||||
panel.style.bottom = 'auto';
|
||||
// Pin the size too: the phone layout stretches between left and right, which a drag gives up.
|
||||
panel.style.width = `${rect.width}px`;
|
||||
panel.style.height = `${rect.height}px`;
|
||||
handle.setPointerCapture?.(e.pointerId);
|
||||
e.preventDefault();
|
||||
});
|
||||
handle.addEventListener('pointermove', (e) => {
|
||||
if (!drag) return;
|
||||
const maxX = window.innerWidth - panel.offsetWidth - 4;
|
||||
const maxY = window.innerHeight - panel.offsetHeight - 4;
|
||||
panel.style.left = `${Math.max(4, Math.min(e.clientX - drag.dx, maxX))}px`;
|
||||
panel.style.top = `${Math.max(4, Math.min(e.clientY - drag.dy, maxY))}px`;
|
||||
});
|
||||
const end = (e) => {
|
||||
drag = null;
|
||||
handle.releasePointerCapture?.(e.pointerId);
|
||||
};
|
||||
handle.addEventListener('pointerup', end);
|
||||
handle.addEventListener('pointercancel', end);
|
||||
},
|
||||
|
||||
_ehEl(tag, className, text) {
|
||||
const el = document.createElement(tag);
|
||||
if (className) el.className = className;
|
||||
if (text !== undefined) el.textContent = text;
|
||||
return el;
|
||||
},
|
||||
|
||||
/** "14:03:22", with the date in front when it is not today. */
|
||||
_editHistoryTime(ts) {
|
||||
const d = new Date(ts);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
return d.toDateString() === new Date().toDateString() ? time : `${d.toLocaleDateString()} ${time}`;
|
||||
},
|
||||
|
||||
/** Open `path` in the shared file preview overlay (panels-ui.js). */
|
||||
_openEditHistoryPreview(path) {
|
||||
const sid = this.activeSessionId;
|
||||
if (sid && path) this.openFilePreview?.(path, sid);
|
||||
},
|
||||
|
||||
_editHistoryUrl(url) {
|
||||
return typeof CodemanBase !== 'undefined' && CodemanBase.url ? CodemanBase.url(url) : url;
|
||||
},
|
||||
|
||||
/**
|
||||
* Thumbnail URL for an image entry inside the workspace: the confined `file-raw` route the file
|
||||
* preview uses. An image outside the workspace gets none: showing it needs an attachment id
|
||||
* (`POST …/attachments`, `notify: false`), and minting one per rendered row would push the
|
||||
* session's real attachments out of its capped registry. Clicking it opens the file preview,
|
||||
* which registers it like any other external file. SVGs get none either (`file-raw` serves
|
||||
* them as a download).
|
||||
*/
|
||||
_editHistoryThumbSrc(path, sid) {
|
||||
if (!path || /\.svg$/i.test(path) || this._isExternalPreviewPath?.(path, sid) !== false) return null;
|
||||
return this._editHistoryUrl(`/api/sessions/${encodeURIComponent(sid)}/file-raw?path=${encodeURIComponent(path)}`);
|
||||
},
|
||||
|
||||
_renderEditHistoryPanel() {
|
||||
const body = this.$('editHistoryBody');
|
||||
const count = this.$('editHistoryCount');
|
||||
if (!body || !this._isEditHistoryPanelOpen()) return;
|
||||
const el = (tag, cls, text) => this._ehEl(tag, cls, text);
|
||||
const t = (s, v) => this._eht(s, v);
|
||||
const focusKey = body.contains(document.activeElement)
|
||||
? document.activeElement.closest?.('[data-edit-key]')?.dataset.editKey
|
||||
: null;
|
||||
const scroll = body.scrollTop;
|
||||
body.replaceChildren();
|
||||
if (count) count.textContent = '';
|
||||
|
||||
if (!this.activeSessionId) {
|
||||
body.append(el('div', 'edit-history-empty', t('Open a session to see its edits.')));
|
||||
return;
|
||||
}
|
||||
const h = this._currentEditHistory();
|
||||
if (!h || h.state === 'loading') {
|
||||
body.append(el('div', 'edit-history-empty', t('Reading the edit history…')));
|
||||
return;
|
||||
}
|
||||
if (h.state === 'error' || !h.data) {
|
||||
body.append(el('div', 'edit-history-empty', t('Could not read the edit history.')));
|
||||
return;
|
||||
}
|
||||
const data = h.data;
|
||||
if (data.state === 'unsupported') {
|
||||
body.append(el('div', 'edit-history-empty', t('Edit history is only available for local Claude sessions.')));
|
||||
return;
|
||||
}
|
||||
if (data.state === 'unavailable') {
|
||||
body.append(
|
||||
el(
|
||||
'div',
|
||||
'edit-history-note edit-history-note--warn',
|
||||
data.reason === 'no-transcript'
|
||||
? t(
|
||||
'No transcript found for this session yet. It is picked up with the next prompt; until then a CLAUDE_CONFIG_DIR set outside Codeman cannot be found.'
|
||||
)
|
||||
: t('Showing saved entries only: {reason}', { reason: data.reason || '' })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const all = Array.isArray(data.entries) ? data.entries : [];
|
||||
const entries = this.isEditHistoryMainOnly() ? all.filter((e) => !e.agentId) : all;
|
||||
if (count) count.textContent = all.length ? String(entries.length) : '';
|
||||
if (!all.length) {
|
||||
body.append(el('div', 'edit-history-empty', t('No edits yet.')));
|
||||
} else if (!entries.length) {
|
||||
body.append(el('div', 'edit-history-empty', t('No edits by the main agent.')));
|
||||
} else if (this.isEditHistoryGrouped()) {
|
||||
const groups = new Map();
|
||||
for (const e of entries) {
|
||||
const key = e.path || e.relPath;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(e);
|
||||
}
|
||||
for (const items of groups.values()) body.append(this._editHistoryGroup(items, data, h));
|
||||
} else {
|
||||
const list = el('div', 'edit-history-list');
|
||||
for (const e of entries) list.append(...this._editHistoryEntry(e, data, h, true));
|
||||
body.append(list);
|
||||
}
|
||||
|
||||
if (data.backfillTruncated) {
|
||||
const more = el(
|
||||
'button',
|
||||
'btn-toolbar btn-sm edit-history-older',
|
||||
h.loadingOlder ? t('Loading older edits…') : t('Load older')
|
||||
);
|
||||
more.type = 'button';
|
||||
more.disabled = !!h.loadingOlder;
|
||||
more.addEventListener('click', () => this.loadOlderEditHistory());
|
||||
body.append(more);
|
||||
}
|
||||
|
||||
body.scrollTop = scroll;
|
||||
if (focusKey) {
|
||||
const again = [...body.querySelectorAll('[data-edit-key]')].find((n) => n.dataset.editKey === focusKey);
|
||||
again?.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
|
||||
/** One file with all its edits (newest first), for the "group by file" view. */
|
||||
_editHistoryGroup(items, data, h) {
|
||||
const el = (tag, cls, text) => this._ehEl(tag, cls, text);
|
||||
const first = items[0];
|
||||
const group = el('section', 'edit-history-group');
|
||||
const head = el('div', 'edit-history-group-head');
|
||||
head.append(this._editHistoryPathButton(first));
|
||||
const code = items.filter((e) => e.kind !== 'image');
|
||||
if (code.length) {
|
||||
const added = code.reduce((s, e) => s + (Number(e.added) || 0), 0);
|
||||
const removed = code.reduce((s, e) => s + (Number(e.removed) || 0), 0);
|
||||
head.append(this._editHistoryStats(added, removed));
|
||||
}
|
||||
head.append(el('span', 'edit-history-group-count', String(items.length)));
|
||||
group.append(head);
|
||||
for (const e of items) group.append(...this._editHistoryEntry(e, data, h, false));
|
||||
return group;
|
||||
},
|
||||
|
||||
/**
|
||||
* The path as folder + file name: the folder is cut from its start when the row is narrow, so a
|
||||
* long absolute path (an image in claude's scratchpad) still shows which file it is.
|
||||
*/
|
||||
_editHistoryPathButton(e) {
|
||||
const shown = e.relPath || e.path || '';
|
||||
const btn = this._ehEl('button', 'edit-history-path');
|
||||
const cut = shown.lastIndexOf('/') + 1;
|
||||
if (cut > 0) {
|
||||
const dir = this._ehEl('span', 'edit-history-path-dir');
|
||||
const bdi = this._ehEl('bdi', null, shown.slice(0, cut));
|
||||
bdi.dir = 'ltr';
|
||||
dir.append(bdi);
|
||||
btn.append(dir);
|
||||
}
|
||||
btn.append(this._ehEl('span', 'edit-history-path-name', shown.slice(cut)));
|
||||
btn.type = 'button';
|
||||
btn.title = e.path || e.relPath || '';
|
||||
btn.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
this._openEditHistoryPreview(e.path);
|
||||
});
|
||||
return btn;
|
||||
},
|
||||
|
||||
_editHistoryStats(added, removed) {
|
||||
const stats = this._ehEl('span', 'edit-history-stats');
|
||||
stats.append(this._ehEl('span', 'edit-history-add', `+${added}`));
|
||||
stats.append(this._ehEl('span', 'edit-history-del', `−${removed}`));
|
||||
return stats;
|
||||
},
|
||||
|
||||
/** A row, followed by its diff when the row is open. `withPath` false inside a file group. */
|
||||
_editHistoryEntry(e, data, h, withPath) {
|
||||
const el = (tag, cls, text) => this._ehEl(tag, cls, text);
|
||||
const t = (s, v) => this._eht(s, v);
|
||||
const isImage = e.kind === 'image';
|
||||
const row = el('div', `edit-history-entry edit-history-entry--${isImage ? 'image' : 'code'}`);
|
||||
row.dataset.editKey = e.id;
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute('role', 'button');
|
||||
row.append(el('span', 'edit-history-time', this._editHistoryTime(e.ts)));
|
||||
|
||||
let badge;
|
||||
if (isImage) {
|
||||
badge = el('span', 'edit-history-badge edit-history-badge--image');
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('width', '12');
|
||||
svg.setAttribute('height', '12');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
for (const [tag, attrs] of [
|
||||
['rect', { x: '3', y: '4', width: '18', height: '16', rx: '2' }],
|
||||
['circle', { cx: '9', cy: '10', r: '2' }],
|
||||
['path', { d: 'M21 17l-5-5-9 8' }],
|
||||
]) {
|
||||
const n = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
||||
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
|
||||
svg.append(n);
|
||||
}
|
||||
badge.append(svg);
|
||||
badge.title = t('Image');
|
||||
} else if (e.created) {
|
||||
badge = el('span', 'edit-history-badge edit-history-badge--new', this._ehBadge('new'));
|
||||
badge.title = t('File created');
|
||||
} else {
|
||||
badge = el('span', 'edit-history-badge edit-history-badge--edit', this._ehBadge('edit'));
|
||||
badge.title = t('File edited ({tool})', { tool: e.tool || '' });
|
||||
}
|
||||
row.append(badge);
|
||||
|
||||
if (withPath) row.append(this._editHistoryPathButton(e));
|
||||
row.append(el('span', 'edit-history-spacer'));
|
||||
if (!isImage) row.append(this._editHistoryStats(Number(e.added) || 0, Number(e.removed) || 0));
|
||||
|
||||
if (e.agentId) {
|
||||
const agent = data.agents?.[e.agentId];
|
||||
const chip = el('span', 'edit-history-agent', agent?.description || e.agentId);
|
||||
const bits = [agent?.agentType, agent?.worktreeBranch].filter(Boolean);
|
||||
chip.title = bits.join(' · ') || t('Subagent');
|
||||
row.append(chip);
|
||||
}
|
||||
|
||||
const out = [row];
|
||||
if (isImage) {
|
||||
row.title = t('Open in the file preview');
|
||||
const open = () => this._openEditHistoryPreview(e.path);
|
||||
row.addEventListener('click', open);
|
||||
row.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault();
|
||||
open();
|
||||
}
|
||||
});
|
||||
const src = this._editHistoryThumbSrc(e.path, h.sessionId);
|
||||
if (src) {
|
||||
const img = el('img', 'edit-history-thumb');
|
||||
img.loading = 'lazy';
|
||||
img.alt = e.relPath || '';
|
||||
img.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
open();
|
||||
});
|
||||
img.addEventListener('error', () => img.remove());
|
||||
img.src = src;
|
||||
out.push(img);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const isOpen = h.open.has(e.id);
|
||||
row.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
row.title = isOpen ? t('Hide the diff') : t('Show what changed');
|
||||
const toggle = () => this.toggleEditHistoryDiff(e.id);
|
||||
row.addEventListener('click', toggle);
|
||||
row.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
});
|
||||
if (isOpen) {
|
||||
row.classList.add('edit-history-entry--open');
|
||||
out.push(this._editHistoryDiffView(h.diffs.get(e.id)));
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
/** Open or close one entry's diff; the first open loads it, later ones reuse it. */
|
||||
async toggleEditHistoryDiff(entryId) {
|
||||
const h = this._currentEditHistory();
|
||||
if (!h) return;
|
||||
if (h.open.has(entryId)) {
|
||||
h.open.delete(entryId);
|
||||
this._renderEditHistoryPanel();
|
||||
return;
|
||||
}
|
||||
h.open.add(entryId);
|
||||
const cached = h.diffs.get(entryId);
|
||||
if (cached && cached.state !== 'error') {
|
||||
this._renderEditHistoryPanel();
|
||||
return;
|
||||
}
|
||||
const view = { state: 'loading' };
|
||||
h.diffs.set(entryId, view);
|
||||
this._renderEditHistoryPanel();
|
||||
const res = await this._api(`${this._editHistoryBase(h.sessionId)}/${encodeURIComponent(entryId)}`);
|
||||
let body = null;
|
||||
try {
|
||||
body = res ? await res.json() : null;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
if (h.diffs.get(entryId) !== view) return;
|
||||
if (res && res.ok && body?.success && typeof body.data?.diff === 'string') {
|
||||
view.state = 'ok';
|
||||
view.diff = body.data.diff;
|
||||
view.truncated = !!body.data.truncated;
|
||||
} else {
|
||||
view.state = 'error';
|
||||
view.error = body?.error || this._eht('Could not read the diff.');
|
||||
}
|
||||
if (this._editHistory === h) this._renderEditHistoryPanel();
|
||||
},
|
||||
|
||||
/** The diff block under an open row, drawn with the Git panel's classes. */
|
||||
_editHistoryDiffView(view) {
|
||||
const el = (tag, cls, text) => this._ehEl(tag, cls, text);
|
||||
const t = (s, v) => this._eht(s, v);
|
||||
const wrap = el('div', 'edit-history-diff');
|
||||
if (!view || view.state === 'loading') {
|
||||
wrap.append(el('div', 'edit-history-empty', t('Reading the diff…')));
|
||||
return wrap;
|
||||
}
|
||||
if (view.state === 'error') {
|
||||
wrap.append(el('div', 'edit-history-empty', view.error));
|
||||
return wrap;
|
||||
}
|
||||
if (!view.diff.trim()) {
|
||||
wrap.append(el('div', 'edit-history-empty', t('No textual changes.')));
|
||||
} else {
|
||||
const pre = el('pre', 'git-diff');
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const line of view.diff.replace(/\n$/, '').split('\n')) {
|
||||
let cls = 'git-diff-line';
|
||||
// The API sends hunks only (no `---`/`+++` headers), so a `---` line is a removed `--` line.
|
||||
if (line.startsWith('@@')) cls += ' git-diff-line--hunk';
|
||||
else if (line.startsWith('+')) cls += ' git-diff-line--add';
|
||||
else if (line.startsWith('-')) cls += ' git-diff-line--del';
|
||||
frag.append(el('span', cls, line + '\n'));
|
||||
}
|
||||
pre.append(frag);
|
||||
wrap.append(pre);
|
||||
}
|
||||
if (view.truncated)
|
||||
wrap.append(el('div', 'edit-history-more', t('Diff cut short: it is larger than the viewer shows.')));
|
||||
return wrap;
|
||||
},
|
||||
});
|
||||
@@ -553,6 +553,39 @@
|
||||
'Fetch git status': '拉取 Git 状态',
|
||||
'Refresh git status': '刷新 Git 状态',
|
||||
'Close git status': '关闭 Git 状态',
|
||||
// Edit history (edit-history-ui.js): settings chip, header button and panel.
|
||||
// The two row badges use scoped keys: a bare "new"/"edit" key would also
|
||||
// translate unrelated words elsewhere (the lookup is case-insensitive).
|
||||
'Edit history': '编辑历史',
|
||||
'Open edit history': '打开编辑历史',
|
||||
'Refresh edit history': '刷新编辑历史',
|
||||
'Close edit history': '关闭编辑历史',
|
||||
'Group by file': '按文件分组',
|
||||
'Main agent only': '仅主智能体',
|
||||
'Open a session to see its edits.': '打开一个会话以查看其编辑。',
|
||||
'Reading the edit history…': '正在读取编辑历史…',
|
||||
'Could not read the edit history.': '无法读取编辑历史。',
|
||||
'Edit history is only available for local Claude sessions.': '编辑历史仅适用于本地 Claude 会话。',
|
||||
'Showing saved entries only: {reason}': '仅显示已保存的条目:{reason}',
|
||||
'No transcript found for this session yet. It is picked up with the next prompt; until then a CLAUDE_CONFIG_DIR set outside Codeman cannot be found.':
|
||||
'尚未找到此会话的记录。它会在下一次提示时被识别;在此之前,在 Codeman 之外设置的 CLAUDE_CONFIG_DIR 无法被找到。',
|
||||
'No edits yet.': '暂无编辑。',
|
||||
'No edits by the main agent.': '主智能体没有编辑。',
|
||||
'Load older': '加载更早的编辑',
|
||||
'Loading older edits…': '正在加载更早的编辑…',
|
||||
'Edit history badge: new': '新建',
|
||||
'Edit history badge: edit': '编辑',
|
||||
'File created': '已创建文件',
|
||||
'File edited ({tool})': '已编辑文件({tool})',
|
||||
Image: '图片',
|
||||
Subagent: '子智能体',
|
||||
'Open in the file preview': '在文件预览中打开',
|
||||
'Show what changed': '显示更改内容',
|
||||
'Hide the diff': '隐藏差异',
|
||||
'Reading the diff…': '正在读取差异…',
|
||||
'Could not read the diff.': '无法读取差异。',
|
||||
'No textual changes.': '没有文本更改。',
|
||||
'Diff cut short: it is larger than the viewer shows.': '差异已截断:内容超出查看器可显示的范围。',
|
||||
Panels: '面板',
|
||||
Monitor: '监视器',
|
||||
'Project Insights': '项目洞察',
|
||||
@@ -615,6 +648,9 @@
|
||||
'API key': 'API 密钥',
|
||||
'Optional. Left blank on edit keeps the existing key.': '可选。编辑时留空将保留现有密钥。',
|
||||
'Auth header': '认证请求头',
|
||||
'Default context length (tokens)': '默认上下文长度(token 数)',
|
||||
"Used for models whose size cannot be discovered. The model's whole window; the Claude floor check divides it by any stored slot count. Leave empty to let each CLI assume its own default.":
|
||||
'用于无法自动发现上下文大小的模型。这是模型的完整窗口;Claude 下限检查会将其除以已存储的槽位数。留空则让各个 CLI 使用其自身的默认值。',
|
||||
'Never send both — some servers hang indefinitely.': '切勿同时发送两者 — 部分服务器会因此无限期挂起。',
|
||||
'Authorization: Bearer (default)': 'Authorization: Bearer(默认)',
|
||||
'api-key header (Azure)': 'api-key 请求头(Azure)',
|
||||
@@ -624,9 +660,15 @@
|
||||
'Custom Endpoints': '自定义端点',
|
||||
'Choose a model': '选择模型',
|
||||
'Currently loaded': '当前已加载',
|
||||
'In use by a session': '会话正在使用',
|
||||
'Last used': '上次使用',
|
||||
'Nothing is marked: this endpoint did not report which model it has loaded, and none of your running sessions uses one. Pick the model to launch.':
|
||||
'未标记任何模型:此端点没有报告它已加载了哪个模型,你运行中的会话也没有在使用它。请选择要启动的模型。',
|
||||
'That endpoint no longer exists': '该端点已不存在',
|
||||
'No models discovered for this endpoint yet': '此端点尚未发现任何模型',
|
||||
// Model picker search box (index.html placeholder/aria-label and the no-matches line)
|
||||
'Search models': '搜索模型',
|
||||
'No models match': '没有匹配的模型',
|
||||
'Subagent Options': '子智能体选项',
|
||||
'Enable Tracking': '启用跟踪',
|
||||
'Active Tab Only': '仅活动标签页',
|
||||
@@ -917,6 +959,8 @@
|
||||
'Saved on this device. Server-wide settings can only be changed by an admin.':
|
||||
'已保存到本设备。服务器全局设置只能由管理员更改。',
|
||||
'Server-wide settings can only be changed by an admin': '服务器全局设置只能由管理员更改',
|
||||
// The reason a settings write gives when no answer came back (settings-ui.js _settingsWriteError).
|
||||
'the server could not be reached': '无法连接到服务器',
|
||||
'Tunnel active': '隧道已启用',
|
||||
'Tunnel starting — QR code will appear when ready...': '隧道正在启动,准备好后将显示二维码…',
|
||||
'Push notifications enabled': '推送通知已启用',
|
||||
@@ -939,6 +983,14 @@
|
||||
'Could not open a new window for this session': '无法在新窗口中打开此会话',
|
||||
'Could not open a new window for this preview': '无法在新窗口中打开此预览',
|
||||
'Could not open a new window for this dashboard': '无法在新窗口中打开此网页标签',
|
||||
// The Codeman desktop app's shim when the app would not open the window a
|
||||
// page asked for (packages/desktop/src/assets.rs).
|
||||
'Could not open a new window': '无法打开新窗口',
|
||||
// A subagent transcript or a detached preview whose window.open came back
|
||||
// null (panels-ui.js viewSubagentTranscript / detachFilePreview).
|
||||
'Could not open the transcript window: pop-up windows are blocked here': '无法打开对话记录窗口:此处已阻止弹出窗口',
|
||||
'Pop-up blocked: allow pop-ups for this site to detach previews':
|
||||
'弹出窗口已被阻止:请允许此网站的弹出窗口以分离预览',
|
||||
// Dictation whose session closed before the text was sent (voice-input.js).
|
||||
'That session has closed; dictation not sent': '该会话已关闭,语音输入未发送',
|
||||
// Terminal touch-selection bar (long-press to select). The bar is a sibling of
|
||||
@@ -1379,6 +1431,7 @@
|
||||
[/^Lifetime: (\d+) sessions created$/, (_m, count) => `累计已创建 ${count} 个会话`],
|
||||
[/^Tunnel active: (.+)$/, (_m, url) => `隧道已启用:${url}`],
|
||||
[/^Tunnel error: (.+)$/, (_m, error) => `隧道错误:${error}`],
|
||||
[/^Tunnel not stopped: ([\s\S]+)$/, (_m, why) => `隧道未停止:${why}`],
|
||||
[/^Update to v(.+)$/, (_m, version) => `更新到 v${version}`],
|
||||
[/^You're up to date \(v(.+)\)\.$/, (_m, version) => `已是最新版本(v${version})。`],
|
||||
[/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`],
|
||||
@@ -1412,6 +1465,10 @@
|
||||
[/^Worktree (\S+) was kept: ([\s\S]+)$/, (_m, wt, why) => `工作树 ${wt} 已保留:${why}`],
|
||||
// A pick, drop or paste that mixed admitted files with refused ones (image-input.js).
|
||||
[/^(\d+) files? skipped: unsupported type$/, (_m, n) => `已跳过 ${n} 个不支持类型的文件`],
|
||||
// The Codeman desktop app's download toasts (packages/desktop/src/assets.rs).
|
||||
// The file name is user text: it passes through untranslated.
|
||||
[/^Downloaded (.+) to Downloads$/, (_m, name) => `已将 ${name} 下载到“下载”文件夹`],
|
||||
[/^Download of (.+) failed$/, (_m, name) => `下载 ${name} 失败`],
|
||||
// Group names are user text: they pass through untranslated.
|
||||
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
|
||||
// Session names are user text too.
|
||||
@@ -1447,6 +1504,17 @@
|
||||
(_m, n) => `窗口太小,容纳不下 ${n} 个窗格:只显示聚焦的窗格`,
|
||||
],
|
||||
[/^The agent exited \((-?\d+)\)$/, (_m, code) => `智能体已退出(${code})`],
|
||||
// A settings write the server did not store (settings-ui.js). The server's reason passes
|
||||
// through untranslated; the one the page supplies itself has its own entry.
|
||||
[
|
||||
/^Saved on this device, not on the server: ([\s\S]+)$/,
|
||||
(_m, why) => `已保存到本设备,但未保存到服务器:${ZH_CN[why] || why}`,
|
||||
],
|
||||
[
|
||||
/^(Run mode|Respawn presets) saved on this device, not on the server: ([\s\S]+)$/,
|
||||
(_m, what, why) =>
|
||||
`${what === 'Run mode' ? '运行模式' : '重生预设'}已保存到本设备,但未保存到服务器:${ZH_CN[why] || why}`,
|
||||
],
|
||||
// A dialog read off a hook-less CLI's screen (src/web/screen-blocked.ts), as the
|
||||
// notification drawer shows it. The CLI's label passes through.
|
||||
[/^(.+) is waiting for your answer in the terminal$/, (_m, cli) => `${cli} 正在终端中等待你的回答`],
|
||||
|
||||
+147
-1
@@ -216,6 +216,13 @@
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
|
||||
</button>
|
||||
<!-- Edit history of the active session (edit-history-ui.js), next to Attachments: both show
|
||||
what the session produced, and the panel opens right below. Optional and per-device
|
||||
(App Settings → Header & Panels → Header, default OFF): hidden by its marker class,
|
||||
never on phones (mobile.css). -->
|
||||
<button type="button" class="btn-icon-header btn-edit-history btn-edit-history--hidden" id="editHistoryBtn" aria-expanded="false" aria-controls="editHistoryPanel" title="Edit history" aria-label="Open edit history" onclick="app.toggleEditHistoryPanel()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg>
|
||||
</button>
|
||||
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path class="icon-folder-closed" d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><g class="icon-folder-open"><path d="M3 17V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1.5"/><path d="M3 17l2.3-5.4A2 2 0 0 1 7.2 10.5H20a1.5 1.5 0 0 1 1.4 2l-1.9 5.2A2 2 0 0 1 17.6 19H5a2 2 0 0 1-2-2z"/></g></svg></button>
|
||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
|
||||
@@ -633,6 +640,22 @@
|
||||
<div class="git-status-footer" id="gitStatusFooter" data-i18n-skip></div>
|
||||
</div>
|
||||
|
||||
<!-- Edit history panel (edit-history-ui.js): every file edit the active session's agents made. -->
|
||||
<div class="edit-history-panel" id="editHistoryPanel" role="dialog" aria-label="Edit history">
|
||||
<div class="edit-history-header">
|
||||
<span class="edit-history-title">Edit history <span class="edit-history-count" id="editHistoryCount" data-i18n-skip></span></span>
|
||||
<div class="edit-history-actions">
|
||||
<button class="btn-icon-sm" onclick="app.refreshEditHistory()" title="Refresh" aria-label="Refresh edit history">↻</button>
|
||||
<button class="btn-icon-sm" onclick="app.closeEditHistoryPanel()" title="Close" aria-label="Close edit history">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-history-controls">
|
||||
<label class="edit-history-toggle"><input type="checkbox" id="editHistoryGroupToggle" onchange="app.setEditHistoryGrouped(this.checked)"><span>Group by file</span></label>
|
||||
<label class="edit-history-toggle"><input type="checkbox" id="editHistoryMainOnlyToggle" onchange="app.setEditHistoryMainOnly(this.checked)"><span>Main agent only</span></label>
|
||||
</div>
|
||||
<div class="edit-history-body" id="editHistoryBody" data-i18n-skip></div>
|
||||
</div>
|
||||
|
||||
<!-- File Preview Overlay -->
|
||||
<div class="file-preview-overlay" id="filePreviewOverlay">
|
||||
<div class="file-preview-window">
|
||||
@@ -1033,6 +1056,20 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="form-hint" id="customModelPickHint"></p>
|
||||
<!-- Case-insensitive filter over the rendered rows (app.filterCustomModelPick):
|
||||
a display layer only, the rows themselves are never re-rendered. Reset on every
|
||||
open and focused only on non-touch devices, same in-memory rule as the rail's
|
||||
search box. -->
|
||||
<input type="search" id="customModelPickSearch" class="session-sidebar-filter-input custom-model-pick-search"
|
||||
placeholder="Search models" aria-label="Search models"
|
||||
autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="search"
|
||||
oninput="app.filterCustomModelPick(this.value)">
|
||||
<p class="form-hint custom-model-pick-no-matches" id="customModelPickNoMatches" role="status" hidden>No models match</p>
|
||||
<!-- Shown only when no row carries a loaded/in-use/last-used mark AND the
|
||||
running-status probe answered `isLlamaSwap: false` (see
|
||||
_openCustomModelPickModal): it says out loud WHY nothing is marked, in the user's language, rather than leaving an
|
||||
alphabetical wall that looks like the question was ignored. -->
|
||||
<p class="form-hint custom-model-pick-unknown" id="customModelPickUnknown" role="status" hidden>Nothing is marked: this endpoint did not report which model it has loaded, and none of your running sessions uses one. Pick the model to launch.</p>
|
||||
<div id="customModelPickList" class="run-mode-custom-models"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2191,6 +2228,7 @@
|
||||
<label class="set-chip" data-preview="header" data-preview-order="5"><input type="checkbox" id="appSettingsShowAwayDigestButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg><span>Away Digest</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="6"><input type="checkbox" id="appSettingsShowSessionButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg><span>Session Manager</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="9"><input type="checkbox" id="appSettingsShowAttachmentsButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>Attachments</span></label>
|
||||
<label class="set-chip" data-search="edit history edits changed files diffs" data-preview="header" data-preview-order="9.5"><input type="checkbox" id="appSettingsShowEditHistory"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg><span>Edit history</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="10"><input type="checkbox" id="appSettingsShowFileViewerButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span>File Viewer</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="11"><input type="checkbox" id="appSettingsShowMultiMonitorButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg><span>Multi-monitor</span></label>
|
||||
<label class="set-chip" data-search="split pane side by side two sessions" data-preview="header" data-preview-order="11.5"><input type="checkbox" id="appSettingsShowSplitButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg><span>Split</span></label>
|
||||
@@ -2791,6 +2829,10 @@
|
||||
<div class="set-row-text"><span class="set-row-label">Base URL</span></div>
|
||||
<input type="text" id="customModelHostBaseUrl" class="set-input" placeholder="http://192.168.1.50:8080">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Default context length (tokens)</span><span class="set-row-desc">Used for models whose size cannot be discovered. The model's whole window; the Claude floor check divides it by any stored slot count. Leave empty to let each CLI assume its own default.</span></div>
|
||||
<input type="number" id="customModelHostDefaultContextLength" class="set-input" min="1" max="100000000" step="1" placeholder="131072">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">API key</span><span class="set-row-desc">Optional. Left blank on edit keeps the existing key.</span></div>
|
||||
<input type="password" id="customModelHostApiKey" class="set-input" autocomplete="new-password">
|
||||
@@ -3234,6 +3276,97 @@
|
||||
<div id="webhookResult" class="set-note" style="display:none" data-i18n-skip></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="set-group" id="discordGroup" style="display:none">
|
||||
<div class="set-group-head"><h4>Discord (two-way, one thread per tab)</h4><span class="set-scope">server</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="discord bot thread reply phone notification blocked">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Talk to your sessions from Discord</span>
|
||||
<span class="set-row-desc">Posts alerts into a thread per tab and types your reply in that thread into the tab. Only the user IDs listed below can reply. Setup (bot, least-privilege permissions) is in docs/discord-bridge.md. This group has its own Save button.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="discordEnabled"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Bot token</span>
|
||||
<span class="set-row-desc" id="discordTokenHint">Nothing saved yet.</span>
|
||||
</div>
|
||||
<input type="password" id="discordToken" class="set-input" autocomplete="off" spellcheck="false" placeholder="Bot token from the Developer Portal">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Guild (server) ID</span></div>
|
||||
<input type="text" id="discordGuildId" class="set-input" autocomplete="off" spellcheck="false" inputmode="numeric">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Parent channel ID</span><span class="set-row-desc">Threads are created inside this channel.</span></div>
|
||||
<input type="text" id="discordParentChannelId" class="set-input" autocomplete="off" spellcheck="false" inputmode="numeric">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Allowed user IDs</span><span class="set-row-desc">Comma separated. Only these Discord users can reply into a session.</span></div>
|
||||
<input type="text" id="discordAllowedUserIds" class="set-input" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Create threads</span></div>
|
||||
<select id="discordThreadCreation" class="set-select">
|
||||
<option value="lazy">On the first alert</option>
|
||||
<option value="onSessionStart">For every new tab</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">When a tab is deleted</span><span class="set-row-desc">Deleting a thread needs the Manage Threads permission on the channel.</span></div>
|
||||
<select id="discordOnSessionDelete" class="set-select">
|
||||
<option value="keep">Keep the thread</option>
|
||||
<option value="archive">Archive the thread</option>
|
||||
<option value="delete">Delete the thread</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Stale threads</span><span class="set-row-desc">Threads this bot made that no tab uses any more, checked after startup and then daily. This includes threads that "When a tab is deleted" kept or archived. Threads under an hour old are skipped. Use one channel per Codeman.</span></div>
|
||||
<select id="discordStaleThreads" class="set-select">
|
||||
<option value="keep">Leave them</option>
|
||||
<option value="archive">Archive them</option>
|
||||
<option value="delete">Delete them</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">@ ping</span></div>
|
||||
<select id="discordPingMode" class="set-select">
|
||||
<option value="off">Never</option>
|
||||
<option value="critical">Critical events only</option>
|
||||
<option value="all">Every post</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Tell Claude how to reach you</span>
|
||||
<span class="set-row-desc">Adds a short note about the codeman notify command to Claude's system prompt at launch, for when it is blocked on something only you can do. Nothing appears in the conversation. Local tabs only: remote and Docker tabs have no codeman command.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="discordStartupHint"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Which events</span>
|
||||
<span class="set-row-desc">"Needs attention" skips the routine "response complete" message.</span>
|
||||
</div>
|
||||
<select id="discordScope" class="set-select">
|
||||
<option value="attention">Needs attention</option>
|
||||
<option value="all">Everything</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Save and test</span>
|
||||
<span class="set-row-desc">Send test saves first, then posts to the parent channel.</span>
|
||||
</div>
|
||||
<span>
|
||||
<button class="btn-toolbar btn-sm btn-primary" id="discordSaveBtn" onclick="app.saveDiscord()">Save</button>
|
||||
<button class="btn-toolbar btn-sm" id="discordTestBtn" onclick="app.testDiscord()">Send test</button>
|
||||
<button class="btn-toolbar btn-sm" id="discordClearBtn" onclick="app.clearDiscord()" style="display:none">Remove token</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="discordResult" class="set-note" style="display:none" data-i18n-skip></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ Voice ════════════════════════════════════════════════════ -->
|
||||
@@ -3654,7 +3787,19 @@
|
||||
<div class="form-row">
|
||||
<label>Case Name</label>
|
||||
<input type="text" id="cloneCaseName" placeholder="repo" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false" oninput="app.onCloneNameEdited()">
|
||||
<span class="form-hint">Filled in from the URL. Cloned into ~/codeman-cases/<name>, so deleting the case deletes this working tree.</span>
|
||||
<span class="form-hint" id="cloneCaseNameHint">Filled in from the URL. Cloned into ~/codeman-cases/<name>, so deleting the case deletes this working tree.</span>
|
||||
</div>
|
||||
<div class="form-row" id="cloneCustomPathToggleRow">
|
||||
<label class="checkbox-row"><input type="checkbox" id="cloneCustomPathToggle" onchange="app.toggleCloneCustomPath()"> 📁 Clone into a custom folder</label>
|
||||
<span class="form-hint">By default the clone goes under ~/codeman-cases. Choose a folder and the repository is cloned into that folder itself (an empty folder, or one that does not exist yet); it is listed like any other case, and deleting the case only unlinks it (your files stay).</span>
|
||||
</div>
|
||||
<div class="form-row" id="cloneCustomPathRow" style="display:none">
|
||||
<label>Folder</label>
|
||||
<div class="path-input-group">
|
||||
<input type="text" id="cloneCasePath" placeholder="~/projects" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" oninput="app.updateCloneCasePathPreview()">
|
||||
<button type="button" class="btn path-input-browse" onclick="app.openCloneCasePathPicker()">Browse…</button>
|
||||
</div>
|
||||
<span class="form-hint" id="cloneCasePathPreview">Pick an empty folder, or one that does not exist yet, to clone into.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Branch or Tag (optional)</label>
|
||||
@@ -4520,6 +4665,7 @@
|
||||
<script defer src="mobile-overview.js"></script>
|
||||
<script defer src="home-sessions.js"></script>
|
||||
<script defer src="git-status-ui.js"></script>
|
||||
<script defer src="edit-history-ui.js"></script>
|
||||
<script defer src="entrance-animations.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
|
||||
@@ -80,7 +80,7 @@ const PathPicker = {
|
||||
/**
|
||||
* Open the lazy filesystem browser.
|
||||
* @param {{sessionId?: string, initialPath?: string, directoriesOnly?: boolean,
|
||||
* title?: string, onSelect: (path: string) => void}} options
|
||||
* allowNewFolder?: boolean, title?: string, onSelect: (path: string) => void}} options
|
||||
*/
|
||||
open(options) {
|
||||
this.close(false);
|
||||
@@ -114,9 +114,15 @@ const PathPicker = {
|
||||
<input type="text" class="path-picker-current" aria-label="Current folder path" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go">
|
||||
<button type="submit" class="path-picker-go" title="Go to this path" aria-label="Go to this path">Go</button>
|
||||
</form>
|
||||
<button type="button" class="path-picker-newfolder" title="New folder" aria-label="New folder" hidden>+📁</button>
|
||||
<button type="button" class="path-picker-hidden" title="Show hidden files and folders" aria-label="Show hidden files and folders" aria-pressed="false">.*</button>
|
||||
<button type="button" class="path-picker-refresh" title="Refresh" aria-label="Refresh">↻</button>
|
||||
</div>
|
||||
<form class="path-picker-newfolder-form" hidden>
|
||||
<input type="text" class="path-picker-newfolder-name" aria-label="New folder name" placeholder="New folder name" maxlength="255" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="done">
|
||||
<button type="submit" class="path-picker-newfolder-create">Create</button>
|
||||
<button type="button" class="path-picker-newfolder-cancel">Cancel</button>
|
||||
</form>
|
||||
<div class="path-picker-toolbar">
|
||||
<div class="path-picker-status" aria-live="polite">Loading...</div>
|
||||
<label class="path-picker-sort-label">Sort
|
||||
@@ -147,6 +153,7 @@ const PathPicker = {
|
||||
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load(this._currentPath));
|
||||
overlay.querySelector('.path-picker-hidden').addEventListener('click', () => this.toggleHidden());
|
||||
this._syncHiddenButton();
|
||||
this._wireNewFolder(overlay, !!options.allowNewFolder);
|
||||
// Typing a path is the fast way there. The listing is loaded ONLY on Enter/Go,
|
||||
// never on each keystroke: a half-typed path is a 404 the server has to
|
||||
// answer for nothing, and jumping mid-edit would yank the field around.
|
||||
@@ -183,6 +190,70 @@ const PathPicker = {
|
||||
this.load(options.initialPath || '');
|
||||
},
|
||||
|
||||
/**
|
||||
* "New folder": opt-in per caller (`allowNewFolder`), so a picker that browses somewhere the host
|
||||
* filesystem route cannot create in (the container-workdir picker) never shows it. The folder is made
|
||||
* inside the folder being viewed and is selected once it exists.
|
||||
*/
|
||||
_wireNewFolder(overlay, enabled) {
|
||||
const button = overlay.querySelector('.path-picker-newfolder');
|
||||
const form = overlay.querySelector('.path-picker-newfolder-form');
|
||||
const input = overlay.querySelector('.path-picker-newfolder-name');
|
||||
if (!enabled) return;
|
||||
button.hidden = false;
|
||||
const close = () => {
|
||||
form.hidden = true;
|
||||
input.value = '';
|
||||
};
|
||||
button.addEventListener('click', () => {
|
||||
form.hidden = !form.hidden;
|
||||
if (!form.hidden) input.focus();
|
||||
});
|
||||
overlay.querySelector('.path-picker-newfolder-cancel').addEventListener('click', close);
|
||||
input.addEventListener('keydown', (event) => {
|
||||
// Escape closes the form first; the document handler would close the whole picker.
|
||||
if (event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
close();
|
||||
});
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const name = input.value.trim();
|
||||
if (!name || !this._currentPath) return;
|
||||
const status = overlay.querySelector('.path-picker-status');
|
||||
const create = overlay.querySelector('.path-picker-newfolder-create');
|
||||
const parent = this._currentPath;
|
||||
// One request at a time: a double tap would send a second one whose 409 shows after the first succeeded.
|
||||
create.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/api/filesystem/mkdir', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
path: parent,
|
||||
name,
|
||||
sessionId: this._options?.sessionId || undefined,
|
||||
showHidden: this._showHidden ? 'true' : undefined,
|
||||
}),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!result?.success) throw new Error(result?.error || 'Could not create the folder');
|
||||
if (!this.overlay) return;
|
||||
close();
|
||||
await this.load(parent);
|
||||
// The listing may have moved on while the request ran; only select it if it is still on screen.
|
||||
if (this._currentPath === parent) this.select(result.data.path);
|
||||
} catch (error) {
|
||||
if (!this.overlay) return;
|
||||
status.textContent = error.message || 'Could not create the folder';
|
||||
status.classList.add('error');
|
||||
} finally {
|
||||
create.disabled = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_loadShowHidden() {
|
||||
try {
|
||||
return localStorage.getItem(PATH_PICKER_SHOW_HIDDEN_KEY) === '1';
|
||||
|
||||
@@ -586,6 +586,8 @@ html.mobile-init .file-browser-panel {
|
||||
.btn-icon-header.btn-away-digest,
|
||||
.btn-icon-header.btn-session-manager,
|
||||
.btn-icon-header.btn-file-viewer,
|
||||
/* Edit history: a desktop panel; phones never show the button (opt-in, default OFF anyway). */
|
||||
.btn-icon-header.btn-edit-history,
|
||||
/* Approvals bell: phones answer from the overview's NEEDS YOU rows instead
|
||||
(inline approve/deny in mobile-overview.js); the bell would only crowd the
|
||||
header it was designed to stay out of. */
|
||||
@@ -3473,6 +3475,47 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
.subagent-dropdown-trigger:focus + .subagent-dropdown-menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Press feedback for the touch bars. The pressed look is transform + filter,
|
||||
never a background or a shadow: the skin block (html:not([data-skin="og"]),
|
||||
specificity 0,2,1) and the per-run-mode colors own the background and its
|
||||
hover rules own box-shadow, so a background or shadow :active lost to them
|
||||
on every non-og skin. Nothing on these buttons sets transform or filter
|
||||
(the base .btn-toolbar:active nudge sits at lower specificity), so one rule
|
||||
reads the same on all of them. 80ms so a quick tap shows the press at all,
|
||||
on the press's own properties rather than `all`. */
|
||||
.toolbar .btn-toolbar,
|
||||
.btn-voice-mobile,
|
||||
.btn-attach-mobile,
|
||||
.btn-settings-mobile,
|
||||
.accessory-btn {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: transform 80ms ease, filter 80ms ease, background-color 80ms ease, border-color 80ms ease;
|
||||
}
|
||||
|
||||
.toolbar .btn-toolbar:active,
|
||||
.btn-voice-mobile:active,
|
||||
.btn-attach-mobile-wrap:active .btn-attach-mobile,
|
||||
.btn-settings-mobile:active,
|
||||
.accessory-btn:active {
|
||||
transform: scale(0.94);
|
||||
filter: brightness(1.25);
|
||||
}
|
||||
|
||||
/* Light skins darken on press instead of brightening (white on white reads as nothing). */
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.toolbar .btn-toolbar:active, .btn-voice-mobile:active, .btn-attach-mobile-wrap:active .btn-attach-mobile, .btn-settings-mobile:active, .accessory-btn:active) {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) and (prefers-reduced-motion: reduce) {
|
||||
.toolbar .btn-toolbar:active,
|
||||
.btn-voice-mobile:active,
|
||||
.btn-attach-mobile-wrap:active .btn-attach-mobile,
|
||||
.btn-settings-mobile:active,
|
||||
.accessory-btn:active {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
|
||||
@@ -1963,7 +1963,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Show in a modal or new window
|
||||
const content = data.data.formatted.join('\n');
|
||||
const win = window.open('', '_blank', 'width=800,height=600');
|
||||
// This open runs after the fetch above, so a pop-up blocker that wants a
|
||||
// click can refuse it, and the Codeman desktop app always refuses an
|
||||
// empty window. Both return null; say so instead of throwing into the
|
||||
// catch below, whose 'Failed to load transcript' would be wrong.
|
||||
let win = null;
|
||||
try { win = window.open('', '_blank', 'width=800,height=600'); } catch {}
|
||||
if (!win) {
|
||||
this.showToast('Could not open the transcript window: pop-up windows are blocked here', 'error');
|
||||
return;
|
||||
}
|
||||
win.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
@@ -4195,7 +4204,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* Register an out-of-workspace path as a live external attachment and return
|
||||
* Register a file path as a live external attachment and return
|
||||
* its id, so the preview can render it through the by-id attachment routes.
|
||||
*
|
||||
* `notify: false` keeps this quiet: the caller is already opening the file in
|
||||
@@ -4204,7 +4213,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
* full attachment guard (blocked secret trees, extension allowlist, symlinks
|
||||
* resolved), so a refusal here is a policy answer worth showing verbatim.
|
||||
*
|
||||
* @returns {Promise<{attachmentId?: string, size?: number, error?: string}>}
|
||||
* @returns {Promise<{attachmentId?: string, size?: number, error?: string, status?: number}>}
|
||||
*/
|
||||
async _registerExternalPreview(filePath, sessionId) {
|
||||
try {
|
||||
@@ -4220,14 +4229,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
const reason = result?.error || `Cannot open this file (HTTP ${res.status})`;
|
||||
// The registry's type answer is a policy term, not an explanation, and the
|
||||
// user just clicked a file they can see on disk. Say what IS previewable
|
||||
// from outside the workspace instead.
|
||||
// through this lookup instead, using the path's actual workspace scope.
|
||||
if (/unsupported/i.test(reason)) {
|
||||
const ext = (filePath.split('.').pop() || '').toLowerCase();
|
||||
const target = this._isExternalPreviewPath(filePath, sessionId)
|
||||
? `.${ext} from outside the session workspace`
|
||||
: `"${filePath}" through the attachment lookup`;
|
||||
return {
|
||||
error: `Cannot preview .${ext} from outside the session workspace (images, video, audio, PDF, Office documents and text files only).`,
|
||||
status: res.status,
|
||||
error: `Cannot preview ${target} (images, video, audio, PDF, Office documents and text files only).`,
|
||||
};
|
||||
}
|
||||
return { error: reason };
|
||||
return { error: reason, status: res.status };
|
||||
} catch (err) {
|
||||
return { error: err.message || 'Cannot open this file' };
|
||||
}
|
||||
|
||||
@@ -601,13 +601,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
// presets are a key of the server-wide settings.json there, and its PUT is admin-only
|
||||
// (403), so they are kept as their own on this device and win over the server's on the
|
||||
// next load (_rememberOwnSetting, settings-ui.js). A PUT refused that way (sent before the
|
||||
// role was known) marks the page read-only for the writes after it.
|
||||
// role was known) marks the page read-only for the writes after it; any other failure is toasted.
|
||||
if (this._settingsReadOnly?.()) {
|
||||
this._rememberOwnSetting?.('respawnPresets', custom);
|
||||
return;
|
||||
}
|
||||
this._apiPut('/api/settings', { respawnPresets: custom })
|
||||
.then((res) => this._noteSettingsWriteRefusal?.(res))
|
||||
.then((res) => this._noteSettingsWriteResult?.(res, 'Respawn presets'))
|
||||
.catch(() => {});
|
||||
},
|
||||
|
||||
|
||||
@@ -1,21 +1 @@
|
||||
{
|
||||
"notices": [
|
||||
{
|
||||
"id": "opencode-console-breach-2026-10",
|
||||
"severity": "warning",
|
||||
"cli": "opencode",
|
||||
"published": "2026-10-17",
|
||||
"expires": "2026-12-31",
|
||||
"title": "OpenCode Console breach: check your API keys",
|
||||
"message": "OpenCode reported unauthorized access to a legacy system on 17 October. Provider API keys (OpenAI, Anthropic and others) saved in some OpenCode Console workspaces may have been exposed. If OpenCode emailed you about it, revoke and replace those keys and check their recent usage. If you got no email, OpenCode says you were not affected.",
|
||||
"link": { "label": "Read OpenCode's notice", "url": "https://x.com/opencode/status/2111519637049319818" },
|
||||
"i18n": {
|
||||
"zh-CN": {
|
||||
"title": "OpenCode Console 遭入侵:请检查你的 API 密钥",
|
||||
"message": "OpenCode 于 10 月 17 日报告,其一个旧系统遭到未授权访问,部分用户保存在 OpenCode Console 工作区中的提供商 API 密钥(OpenAI、Anthropic 等)可能已泄露。如果你收到了 OpenCode 的相关邮件,请撤销并更换这些密钥,同时检查近期用量。OpenCode 表示,没有收到邮件的用户不受影响。",
|
||||
"label": "查看 OpenCode 公告"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
{ "notices": [] }
|
||||
+214
-26
@@ -735,12 +735,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
// where settings.json is server-wide and its PUT admin-only: the mode is kept as their own
|
||||
// on this device and wins over the server's on the next load (_rememberOwnSetting,
|
||||
// settings-ui.js). A PUT refused that way (sent before the role was known) marks the page
|
||||
// read-only for the writes after it (_noteSettingsWriteRefusal).
|
||||
// read-only for the writes after it (_noteSettingsWriteRefusal); any other failure is toasted.
|
||||
if (this._settingsReadOnly?.()) {
|
||||
this._rememberOwnSetting?.('runMode', mode);
|
||||
} else {
|
||||
this._apiPut('/api/settings', { runMode: mode })
|
||||
.then((res) => this._noteSettingsWriteRefusal?.(res))
|
||||
.then((res) => this._noteSettingsWriteResult?.(res, 'Run mode'))
|
||||
.catch(() => {});
|
||||
}
|
||||
// Close menu
|
||||
@@ -1002,12 +1002,23 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* Best-effort lookup of the model llama-swap currently has loaded and ready on this
|
||||
* endpoint, so the picker can offer it first instead of making the user remember what
|
||||
* they picked last time it mattered. Mirrors `_watchLlamaSwapLoading`'s own
|
||||
* `state === 'ready'` check. Returns null for a plain (non-llama-swap) server, an
|
||||
* unreachable endpoint, or a loaded model this host no longer lists as discovered —
|
||||
* never throws, since a failed probe should just skip promotion, not break the picker.
|
||||
* Best-effort lookup of what the endpoint itself says it has loaded and ready, so the
|
||||
* picker can offer it first instead of making the user remember what they picked last
|
||||
* time it mattered. Only llama-swap answers this question (the route reads its
|
||||
* `/running`); every other OpenAI-compatible server (a bare llama.cpp, Ollama, vLLM,
|
||||
* or a gateway such as Bifrost fronting a whole fleet) replies `isLlamaSwap: false`,
|
||||
* which is why `_getCustomModelInUseBySession` exists. `state === 'ready'` mirrors
|
||||
* `_watchLlamaSwapLoading`'s own check.
|
||||
*
|
||||
* Resolves the raw route payload rather than a model id so the caller can tell the
|
||||
* three answers apart: llama-swap naming a model; `isLlamaSwap: false`, which is what
|
||||
* the route answers for EVERY failure it catches (a server without a `/running`
|
||||
* route, a refused or unresolvable endpoint, a 401 from a wrong key): safe to
|
||||
* explain in the UI as long as the wording also fits an endpoint that is down; and
|
||||
* `null`, only when Codeman's own request to the route failed or the client-side
|
||||
* bound below won the race, which stays silent, since the endpoint may merely have
|
||||
* been asleep. Never throws: a failed probe should
|
||||
* just skip promotion, not break the picker.
|
||||
*
|
||||
* ⚠️ Client-side bounded to ~800ms via Promise.race, on top of (never instead of) the
|
||||
* route's own 5s server-side timeout (`RUNNING_TIMEOUT_MS`, custom-model-routes.ts) —
|
||||
@@ -1024,17 +1035,58 @@ Object.assign(CodemanApp.prototype, {
|
||||
* `vi.useFakeTimers()` patches, so a param is the only way to test the timeout without
|
||||
* actually waiting on it. Real callers never pass it.
|
||||
*/
|
||||
async _getCustomModelCurrentlyLoaded(host, timeoutMs = 800) {
|
||||
async _getCustomModelRunningStatus(host, timeoutMs = 800) {
|
||||
const probe = this._apiJson(`/api/model-endpoints/${encodeURIComponent(host.id)}/running-status`).catch(
|
||||
() => null
|
||||
);
|
||||
const timeout = new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs));
|
||||
const status = await Promise.race([probe, timeout]);
|
||||
return Promise.race([probe, timeout]);
|
||||
},
|
||||
|
||||
/**
|
||||
* The model llama-swap reports `ready` on this endpoint AND that this host still lists
|
||||
* as discovered (an undropped-but-undiscovered model has no row to promote), else null.
|
||||
*/
|
||||
_pickReadyRunningModel(status, host) {
|
||||
if (!status?.isLlamaSwap) return null;
|
||||
const ready = (status.running || []).find((r) => r.state === 'ready' && (host.models || []).includes(r.model));
|
||||
return ready?.model || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* The model a LIVE session of this instance is pointed at on `host`, or null. It is the
|
||||
* fallback for every endpoint that cannot answer "what is loaded?" about itself.
|
||||
* A session already running on the endpoint is first-hand evidence of a model in use
|
||||
* there: Codeman itself injected that endpoint and model into it, so it earns the same
|
||||
* promotion llama-swap's own answer gets, under its own honest tag.
|
||||
*
|
||||
* ⚠️ Reads ONLY `customModel` (what Codeman injected), never `displayModel`: that field
|
||||
* is display text scraped off a CLI's footer or statusline for a session header (pane
|
||||
* derived, `source: 'screen'`), and promoting a row because a terminal printed a
|
||||
* similar string is worse than showing nothing.
|
||||
* ⚠️ Requires a live pane: `status: 'stopped'`, a null `pid` (the PTY exited; the
|
||||
* same live-pane test keep-warm applies, `src/prompt-cache-keepwarm.ts`) or a recorded
|
||||
* `paneExit` means nothing is on the endpoint any more, and a dead session's stale pin
|
||||
* is exactly what an "in use" claim must not resurrect. `session:updated` keeps it
|
||||
* current, so a session cleared back to the CLI's own backend or moved to another
|
||||
* endpoint stops counting.
|
||||
* ⚠️ A since-deleted endpoint simply matches no host, so no extra liveness check is
|
||||
* needed here.
|
||||
*/
|
||||
_getCustomModelInUseBySession(host) {
|
||||
const models = host?.models || [];
|
||||
if (models.length === 0) return null;
|
||||
let newest = null;
|
||||
for (const [, session] of this.sessions || []) {
|
||||
if (!session || session.status === 'stopped' || session.pid === null || session.paneExit) continue;
|
||||
const pinned = session.customModel;
|
||||
if (!pinned || pinned.endpointId !== host.id) continue;
|
||||
if (!models.includes(pinned.modelId)) continue; // undiscovered since: no row to promote
|
||||
if (!newest || (newest.createdAt || 0) <= (session.createdAt || 0)) newest = session;
|
||||
}
|
||||
return newest?.customModel?.modelId || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders the "which model" picker for a (harness, endpoint) pair with more than one
|
||||
* discovered model. Async since it now awaits the currently-loaded-model probe below,
|
||||
@@ -1055,15 +1107,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
const generation = (this._customModelPickGeneration = (this._customModelPickGeneration || 0) + 1);
|
||||
const isCurrent = () => this._customModelPickGeneration === generation;
|
||||
|
||||
// Whichever model llama-swap actually has loaded right now beats a merely
|
||||
// remembered choice — it's what a launch would attach to with zero wait, while
|
||||
// "last used" might have been swapped out by another session since. Neither
|
||||
// reorders past the top: exactly one model is promoted, everything else keeps
|
||||
// its discovery order.
|
||||
const currentlyLoaded = await this._getCustomModelCurrentlyLoaded(host);
|
||||
// Three kinds of knowledge, best first: what llama-swap says it has loaded right
|
||||
// now (zero wait to attach to), what a live session of this instance is pointed at
|
||||
// on that endpoint (the endpoint itself cannot say, but Codeman put it there), and
|
||||
// only then this device's memory of the last launch. One wins, and it never reorders
|
||||
// anything else: every other row keeps its discovery order.
|
||||
const runningStatus = await this._getCustomModelRunningStatus(host);
|
||||
if (!isCurrent()) return; // a newer pick opened (and possibly already rendered) while this probe was in flight
|
||||
const lastUsed = currentlyLoaded ? null : this._getCustomModelLastUsed(mode, host.id);
|
||||
const promoted = currentlyLoaded || lastUsed;
|
||||
const currentlyLoaded = this._pickReadyRunningModel(runningStatus, host);
|
||||
const inUse = currentlyLoaded ? null : this._getCustomModelInUseBySession(host);
|
||||
const lastUsed = currentlyLoaded || inUse ? null : this._getCustomModelLastUsed(mode, host.id);
|
||||
const promoted = currentlyLoaded || inUse || lastUsed;
|
||||
const models = [...(host.models || [])];
|
||||
if (promoted && models.includes(promoted)) {
|
||||
models.splice(models.indexOf(promoted), 1);
|
||||
@@ -1078,23 +1132,91 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('customModelPickTitle').textContent = 'Choose a model';
|
||||
document.getElementById('customModelPickHint').textContent =
|
||||
`${cliLabel} → ${host.label} — ${(host.models || []).length} models discovered.`;
|
||||
// A plain static line (its markup lives in index.html), shown when NOTHING is known
|
||||
// about any row: 40 alphabetical rows with no mark on any of them otherwise read as
|
||||
// the app ignoring the question. `isLlamaSwap: false` is what the route answers for
|
||||
// every failure it catches: a server that cannot report AND a down or refused one
|
||||
// alike (`getLlamaSwapStatus`, custom-model-routes.ts), so the line's wording
|
||||
// promises only that nothing was reported, which stays true either way. A probe
|
||||
// that never got an answer at all (`runningStatus === null`: Codeman's own request
|
||||
// failed or the 800ms bound won) keeps the line hidden, the box may merely be
|
||||
// asleep.
|
||||
// A remembered id that has since vanished from host.models is no promotion: count
|
||||
// only a promoted id that is actually a row, or a stale "last used" would both skip
|
||||
// tagging any row and hide this line, leaving 40 unmarked rows with no explanation.
|
||||
const nothingKnown = !(promoted && models.includes(promoted)) && runningStatus?.isLlamaSwap === false;
|
||||
const unknownLine = document.getElementById('customModelPickUnknown');
|
||||
if (unknownLine) unknownLine.hidden = !nothingKnown;
|
||||
list.innerHTML = models
|
||||
.map((m) => {
|
||||
// Two independent tags, never one exclusive slot: the promotion tag says what
|
||||
// llama-swap (or this device's history) knows about the model, the Default pill
|
||||
// says what the saved endpoint says about it, and on a single-purpose GPU box the
|
||||
// promoted model IS the default more often than not. One slot holding whichever
|
||||
// applied first silently dropped the Default marking for exactly that row.
|
||||
const promotion = m === currentlyLoaded ? 'Currently loaded' : m === lastUsed ? 'Last used' : null;
|
||||
// Two independent tags, never one exclusive slot: the promotion tag says what the
|
||||
// endpoint, a live session, or this device's history knows about the model, the
|
||||
// Default pill says what the saved endpoint says about it, and on a
|
||||
// single-purpose GPU box the promoted model IS the default more often than not.
|
||||
// One slot holding whichever applied first silently dropped the Default marking
|
||||
// for exactly that row.
|
||||
const promotion =
|
||||
m === currentlyLoaded
|
||||
? 'Currently loaded'
|
||||
: m === inUse
|
||||
? 'In use by a session'
|
||||
: m === lastUsed
|
||||
? 'Last used'
|
||||
: null;
|
||||
const tags = [promotion, m === host.defaultModelId ? 'Default' : null].filter(Boolean);
|
||||
const arg = escapeHtml(JSON.stringify(m));
|
||||
// data-model-id is the filter's haystack (escapeHtml covers `"` as well as
|
||||
// `<>&`, so a hostile model id cannot break out of the attribute); the
|
||||
// filter reads it back through dataset, which decodes it.
|
||||
return `
|
||||
<button class="run-mode-option" onclick="app.chooseCustomModelAndRun(${arg})">
|
||||
<button class="run-mode-option" data-model-id="${escapeHtml(m)}" onclick="app.chooseCustomModelAndRun(${arg})">
|
||||
<span class="run-mode-dot ${escapeHtml(mode)}"></span>${escapeHtml(m)}${tags.map((t) => ` <span class="set-scope">${escapeHtml(t)}</span>`).join('')}
|
||||
</button>`;
|
||||
})
|
||||
.join('');
|
||||
modal.classList.add('active');
|
||||
// A fresh open always starts unfiltered with the caret in the search box: the
|
||||
// list was just rebuilt, so clearing the input is the whole reset; focus is the
|
||||
// user's next keystroke away. Inside the generation guard above, so a
|
||||
// superseded probe can neither focus nor clear a modal that now shows another
|
||||
// endpoint's models.
|
||||
const search = document.getElementById('customModelPickSearch');
|
||||
if (search) {
|
||||
search.value = '';
|
||||
// readmymind-ui.js's rule: on touch devices never focus an input unprompted, or
|
||||
// the OS keyboard pops over the very list the picker just opened to show. Desktop
|
||||
// keeps the caret in the box, one keystroke from filtering.
|
||||
if (!(typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice())) {
|
||||
search.focus();
|
||||
}
|
||||
}
|
||||
this.filterCustomModelPick('');
|
||||
},
|
||||
|
||||
/**
|
||||
* View filter for the open "Choose a model" picker: case-insensitive substring
|
||||
* over each row's model id, driven from the search input's own `oninput`.
|
||||
* Deliberately toggles `hidden` on the already-rendered buttons rather than
|
||||
* re-rendering from a second copy of the model list: the loaded/last-used
|
||||
* promotion, the Default pill, the escaped inline click argument and the async
|
||||
* generation guard all then stay exactly where they are, and clearing the
|
||||
* query restores the list byte for byte. ⚠️ `.run-mode-option` carries its own
|
||||
* `display: flex` (styles.css, restated in mobile.css), which outranks the UA
|
||||
* `[hidden]` rule, so styles.css re-asserts `display: none !important` for
|
||||
* these rows: without it the filter would silently do nothing.
|
||||
*/
|
||||
filterCustomModelPick(value) {
|
||||
const list = document.getElementById('customModelPickList');
|
||||
if (!list) return;
|
||||
const query = String(value || '').trim().toLowerCase();
|
||||
let visible = 0;
|
||||
for (const btn of list.querySelectorAll('button[data-model-id]')) {
|
||||
const match = !query || btn.dataset.modelId.toLowerCase().includes(query);
|
||||
btn.hidden = !match;
|
||||
if (match) visible += 1;
|
||||
}
|
||||
const noMatches = document.getElementById('customModelPickNoMatches');
|
||||
if (noMatches) noMatches.hidden = visible > 0;
|
||||
},
|
||||
|
||||
closeCustomModelPickModal() {
|
||||
@@ -4097,6 +4219,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
set('cloneRepoUrl', '');
|
||||
set('cloneCaseName', '');
|
||||
set('cloneRepoRef', '');
|
||||
set('cloneCasePath', '');
|
||||
// Custom folder starts off each time, and is not offered to a non-admin in multi-user mode: the
|
||||
// server refuses it (it writes outside the cases directory and into the shared registry).
|
||||
const customToggle = document.getElementById('cloneCustomPathToggle');
|
||||
if (customToggle) customToggle.checked = false;
|
||||
const customRow = document.getElementById('cloneCustomPathToggleRow');
|
||||
const me = window.__codemanUser || {};
|
||||
if (customRow) customRow.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
|
||||
this.toggleCloneCustomPath();
|
||||
const shallow = document.getElementById('cloneShallow');
|
||||
if (shallow) shallow.checked = false;
|
||||
const start = document.getElementById('cloneStartSession');
|
||||
@@ -4124,6 +4255,50 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/** Custom-folder row for Clone Repo: shows or hides the parent-folder field and rewords the name hint. */
|
||||
toggleCloneCustomPath() {
|
||||
const custom = document.getElementById('cloneCustomPathToggle');
|
||||
const row = document.getElementById('cloneCustomPathRow');
|
||||
if (!custom || !row) return;
|
||||
row.style.display = custom.checked ? '' : 'none';
|
||||
const hint = document.getElementById('cloneCaseNameHint');
|
||||
if (hint) {
|
||||
hint.textContent = custom.checked
|
||||
? 'Filled in from the URL. Names the case only: the repository is cloned into the folder chosen below.'
|
||||
: 'Filled in from the URL. Cloned into ~/codeman-cases/<name>, so deleting the case deletes this working tree.';
|
||||
}
|
||||
this.updateCloneCasePathPreview();
|
||||
},
|
||||
|
||||
/** The folder the repository is cloned into: exactly what the field holds (no case name is added). */
|
||||
_cloneTargetPath() {
|
||||
const raw = (document.getElementById('cloneCasePath')?.value || '').trim();
|
||||
// Trailing slashes off, but `/` stays the root rather than becoming an empty path.
|
||||
return raw.length > 1 ? raw.replace(/\/+$/, '') || '/' : raw;
|
||||
},
|
||||
|
||||
updateCloneCasePathPreview() {
|
||||
const hint = document.getElementById('cloneCasePathPreview');
|
||||
if (!hint) return;
|
||||
const target = this._cloneTargetPath();
|
||||
hint.textContent = target ? `Will clone into: ${target}` : 'Pick an empty folder, or one that does not exist yet, to clone into.';
|
||||
},
|
||||
|
||||
openCloneCasePathPicker() {
|
||||
const input = document.getElementById('cloneCasePath');
|
||||
PathPicker.open({
|
||||
title: 'Choose the folder to clone into',
|
||||
initialPath: input.value.trim(),
|
||||
directoriesOnly: true,
|
||||
onSelect: (path) => {
|
||||
input.value = path;
|
||||
this.updateCloneCasePathPreview();
|
||||
input.focus();
|
||||
input.setSelectionRange(path.length, path.length);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
_setCloneStatus(message, kind) {
|
||||
const el = document.getElementById('cloneRepoStatus');
|
||||
if (!el) return;
|
||||
@@ -4272,6 +4447,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
const customFolder = !!document.getElementById('cloneCustomPathToggle')?.checked;
|
||||
if (customFolder && !(document.getElementById('cloneCasePath')?.value || '').trim()) {
|
||||
this.showToast('Choose the folder to clone into', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this._setCloneStatus(`Cloning ${url}… this can take a while for a large repository.`, '');
|
||||
try {
|
||||
const res = await fetch('/api/cases/clone', {
|
||||
@@ -4279,7 +4460,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
// Zod `.optional()` rejects an explicit null, and JSON.stringify keeps one
|
||||
// on the wire — omit the empty fields instead of sending null.
|
||||
body: JSON.stringify({ name, repository: url, ...(ref ? { ref } : {}), ...(shallow ? { shallow: true } : {}) }),
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
repository: url,
|
||||
...(ref ? { ref } : {}),
|
||||
...(shallow ? { shallow: true } : {}),
|
||||
...(customFolder ? { path: this._cloneTargetPath() } : {}),
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
@@ -4315,6 +4502,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
title: 'Select Existing Project Folder',
|
||||
initialPath: pathInput.value.trim(),
|
||||
directoriesOnly: true,
|
||||
allowNewFolder: true,
|
||||
onSelect: (path) => {
|
||||
pathInput.value = path;
|
||||
const nameInput = document.getElementById('linkCaseName');
|
||||
|
||||
+214
-16
@@ -570,6 +570,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.loadConfigBackups();
|
||||
this._applyDoctorAdminGate();
|
||||
this.loadWebhook();
|
||||
this.loadDiscord();
|
||||
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
|
||||
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
|
||||
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
|
||||
@@ -609,6 +610,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// below and are not stripped from a save: a stripped key would never reach the scheduler.
|
||||
document.getElementById('appSettingsGitFetchEnabled').checked = settings.gitFetchEnabled === true;
|
||||
document.getElementById('appSettingsGitFetchIntervalSeconds').value = settings.gitFetchIntervalSeconds ?? 300;
|
||||
document.getElementById('appSettingsShowEditHistory').checked = settings.showEditHistory ?? defaults.showEditHistory ?? false;
|
||||
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
|
||||
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
|
||||
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
||||
@@ -1733,6 +1735,115 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Discord bridge (Settings → Notifications). Server-side config behind /api/discord, not a
|
||||
* settings-payload field: the bot token is a secret, so it never round-trips through settings.json
|
||||
* or this page. The token box is write-only. Unlike the webhook group it has its own Save button
|
||||
* only (the modal's main Save does not touch it), so a half-typed token is never sent by accident.
|
||||
*/
|
||||
_discordSay(text, bad = false) {
|
||||
const out = document.getElementById('discordResult');
|
||||
if (!out) return;
|
||||
out.textContent = text;
|
||||
out.style.display = text ? 'block' : 'none';
|
||||
out.style.color = bad ? 'var(--danger, #e5534b)' : '';
|
||||
},
|
||||
|
||||
async loadDiscord() {
|
||||
const group = document.getElementById('discordGroup');
|
||||
if (!group) return;
|
||||
const res = await this._api('/api/discord');
|
||||
let body = null;
|
||||
try { body = res && res.ok ? await res.json() : null; } catch { /* leave hidden */ }
|
||||
if (!body || body.success === false) { group.style.display = 'none'; return; } // not an admin, or an older server
|
||||
const d = body.data;
|
||||
group.style.display = '';
|
||||
document.getElementById('discordEnabled').checked = d.enabled === true;
|
||||
const token = document.getElementById('discordToken');
|
||||
token.value = '';
|
||||
token.placeholder = d.hasToken ? 'Saved. Paste a new token to replace it' : 'Bot token from the Developer Portal';
|
||||
document.getElementById('discordTokenHint').textContent = d.hasToken ? `Saved: ${d.tokenMasked}` : 'Nothing saved yet.';
|
||||
document.getElementById('discordGuildId').value = d.guildId || '';
|
||||
document.getElementById('discordParentChannelId').value = d.parentChannelId || '';
|
||||
document.getElementById('discordAllowedUserIds').value = (d.allowedUserIds || []).join(', ');
|
||||
document.getElementById('discordThreadCreation').value = d.threadCreation;
|
||||
document.getElementById('discordOnSessionDelete').value = d.onSessionDelete;
|
||||
document.getElementById('discordStaleThreads').value = d.staleThreads || 'keep';
|
||||
document.getElementById('discordPingMode').value = d.pingMode;
|
||||
document.getElementById('discordScope').value = d.scope;
|
||||
document.getElementById('discordStartupHint').checked = d.startupHint !== false;
|
||||
const clearBtn = document.getElementById('discordClearBtn');
|
||||
if (clearBtn) clearBtn.style.display = d.hasToken ? '' : 'none';
|
||||
const gw = { off: 'Not connected (disabled).', connecting: 'Connecting to Discord…', connected: 'Connected: replies are being read.', error: `Not connected: ${d.gatewayError || 'error'}` };
|
||||
let line = gw[d.gateway] || '';
|
||||
if (d.lastResult) {
|
||||
const when = new Date(d.lastResult.at).toLocaleString();
|
||||
line += d.lastResult.ok ? ` Last post succeeded (${when}).` : ` Last post failed (${when}): ${d.lastResult.error}`;
|
||||
}
|
||||
this._discordSay(line.trim(), d.gateway === 'error' || (d.lastResult && !d.lastResult.ok));
|
||||
},
|
||||
|
||||
/** PUT the group's state. Resolves to '' on success, else the error (also shown in the group). */
|
||||
async saveDiscord() {
|
||||
const ids = document.getElementById('discordAllowedUserIds').value.split(/[\s,]+/).filter(Boolean);
|
||||
const payload = {
|
||||
enabled: document.getElementById('discordEnabled').checked,
|
||||
guildId: document.getElementById('discordGuildId').value.trim(),
|
||||
parentChannelId: document.getElementById('discordParentChannelId').value.trim(),
|
||||
allowedUserIds: ids,
|
||||
threadCreation: document.getElementById('discordThreadCreation').value,
|
||||
onSessionDelete: document.getElementById('discordOnSessionDelete').value,
|
||||
staleThreads: document.getElementById('discordStaleThreads').value,
|
||||
pingMode: document.getElementById('discordPingMode').value,
|
||||
scope: document.getElementById('discordScope').value,
|
||||
startupHint: document.getElementById('discordStartupHint').checked,
|
||||
};
|
||||
const token = document.getElementById('discordToken').value.trim();
|
||||
if (token) payload.token = token; // blank = keep the saved one (Remove token is the way to clear it)
|
||||
const res = await this._api('/api/discord', { method: 'PUT', body: payload });
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
const error = body?.error || 'Could not save the Discord settings.';
|
||||
this._discordSay(error, true);
|
||||
return error;
|
||||
}
|
||||
await this.loadDiscord();
|
||||
return '';
|
||||
},
|
||||
|
||||
async clearDiscord() {
|
||||
if (!confirm('Remove the saved bot token from the server? The Discord bridge stops until you save a new one.')) return;
|
||||
const res = await this._api('/api/discord', { method: 'PUT', body: { token: '', enabled: false } });
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
this._discordSay(body?.error || 'Could not remove the token.', true);
|
||||
return;
|
||||
}
|
||||
await this.loadDiscord();
|
||||
},
|
||||
|
||||
async testDiscord() {
|
||||
const btn = document.getElementById('discordTestBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
if (await this.saveDiscord()) return; // saves first, so it never tests the old values; the error is shown
|
||||
this._discordSay('Sending…');
|
||||
const res = await this._apiPost('/api/discord/test', {});
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
this._discordSay(body?.error || 'Could not send the test.', true);
|
||||
return;
|
||||
}
|
||||
const r = body.data;
|
||||
this._discordSay(r.ok ? 'Test sent. Check the channel.' : `Delivery failed: ${r.error}`, !r.ok);
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Settings → System → Diagnostics: run `codeman doctor` on the server (GET /api/doctor) and list
|
||||
* each tool. Built with DOM nodes and textContent: paths and versions come from the host.
|
||||
@@ -2300,6 +2411,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
// A stop the server did not store (a 409 for a settings.json that does not parse) left the
|
||||
// tunnel running: say so, and leave the button showing it on.
|
||||
const stopError = newEnabled ? '' : await this._settingsWriteError(res);
|
||||
if (stopError) {
|
||||
this.showToast(`Tunnel not stopped: ${stopError}`, 'error', { duration: 0 });
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (newEnabled) {
|
||||
this._showTunnelConnecting();
|
||||
// Poll tunnel status as fallback in case SSE event is missed
|
||||
@@ -2661,9 +2780,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Shows the server's (actionable) message as an error toast. Since #706 this is the
|
||||
* fallback for when `_confirmTunnelEnable` could not read the tunnel status.
|
||||
* @param {Response|null} res - the fetch Response from the settings PUT
|
||||
* @param {object} [sent] - what that PUT sent, resent with the acknowledgement: the refusal
|
||||
* rejected all of it, so an App Settings save retried with the tunnel keys alone lost the rest
|
||||
* @returns {Promise<boolean>} true if the tunnel-enable was refused (caller should abort)
|
||||
*/
|
||||
async _handleTunnelEnableRefusal(res) {
|
||||
async _handleTunnelEnableRefusal(res, sent = { tunnelEnabled: true }) {
|
||||
if (!res || res.ok) return false;
|
||||
let message = 'Tunnel refused: set CODEMAN_PASSWORD before exposing Codeman publicly.';
|
||||
let errorCode = '';
|
||||
@@ -2695,7 +2816,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const retry = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tunnelEnabled: true, acknowledgeUnauthTunnel: true }),
|
||||
body: JSON.stringify({ ...sent, acknowledgeUnauthTunnel: true }),
|
||||
});
|
||||
if (retry.ok) {
|
||||
this.showToast('Public tunnel enabling — no password set ⚠️', 'warning');
|
||||
@@ -2745,6 +2866,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.closeTunnelPanel();
|
||||
return;
|
||||
}
|
||||
// A stop the server did not store left the tunnel running (see toggleTunnelFromWelcome).
|
||||
const stopError = enable ? '' : await this._settingsWriteError(res);
|
||||
if (stopError) {
|
||||
this.showToast(`Tunnel not stopped: ${stopError}`, 'error', { duration: 0 });
|
||||
this.closeTunnelPanel();
|
||||
return;
|
||||
}
|
||||
if (enable) {
|
||||
this._updateTunnelIndicator(false);
|
||||
const indicator = document.getElementById('tunnelIndicator');
|
||||
@@ -2963,7 +3091,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Gesture overlay is injected at page render (server-side), so a change to it
|
||||
// only takes effect on reload — remember the prior value to decide below.
|
||||
const _prev = this.loadAppSettingsFromStorage();
|
||||
const _prevGestureEnabled = (_prev.gestureControlEnabled ?? false) === true;
|
||||
// After a save the server did not store, this device's blob is ahead of the server, so the
|
||||
// two server-side changes judged against it (the gesture reload, the plan-usage collection
|
||||
// flip) are judged against the blob that save started from until a save lands.
|
||||
const _prevServer = this._unsavedSettingsBaseline ?? _prev;
|
||||
const _prevGestureEnabled = (_prevServer.gestureControlEnabled ?? false) === true;
|
||||
// WebGL toggle: default ON (desktop), so only an explicit stored false counts
|
||||
// as "previously off" — used below to detect a real OFF→ON flip.
|
||||
const _prevWebglEnabled = (_prev.webglRendererEnabled ?? true) === true;
|
||||
@@ -3027,6 +3159,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
3600,
|
||||
Math.max(30, parseInt(document.getElementById('appSettingsGitFetchIntervalSeconds').value, 10) || 300)
|
||||
),
|
||||
showEditHistory: document.getElementById('appSettingsShowEditHistory').checked,
|
||||
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
||||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||||
@@ -3282,7 +3415,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// switch collection off for every desktop, whose chip then went stale with
|
||||
// no error anywhere. An explicit toggle on any device still writes it, in
|
||||
// either direction.
|
||||
const _chipFlip = this.planUsageCollectionFlip(_prev, settings.showPlanUsageLimits);
|
||||
const _chipFlip = this.planUsageCollectionFlip(_prevServer, settings.showPlanUsageLimits);
|
||||
const {
|
||||
localEchoEnabled: _leo,
|
||||
// Per-device (it is about this screen's keyboard) and absent from
|
||||
@@ -3334,6 +3467,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
gitStatusTree: _gst,
|
||||
gitStatusMaxRepos: _gsm,
|
||||
gitStatusTimeoutSeconds: _gst2,
|
||||
// Per-device header button (edit-history-ui.js), likewise absent from the .strict() schema.
|
||||
showEditHistory: _seh,
|
||||
showTabDetachButton: _tdb,
|
||||
showTabNewButton: _tnb,
|
||||
// Detach Tiles: what a tile drag does is a property of this device's
|
||||
@@ -3355,6 +3490,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
...serverSettings
|
||||
} = settings;
|
||||
let webhookError = '';
|
||||
let saveError = '';
|
||||
let serverSaved = false;
|
||||
try {
|
||||
// #706: a save that turns the tunnel on asks first when the server has no login
|
||||
@@ -3363,18 +3499,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
? await this._confirmTunnelEnable()
|
||||
: { proceed: true, acknowledge: false };
|
||||
if (!tunnelGate.proceed) {
|
||||
this._unsavedSettingsBaseline = _prevServer;
|
||||
this._abandonTunnelEnable(settings, keepOpen);
|
||||
return;
|
||||
}
|
||||
// A non-admin's save is not sent at all (see settingsReadOnly above): the server
|
||||
// would refuse it with a 403, the plan-usage collection flip included.
|
||||
const res = settingsReadOnly ? null : await this._apiPut('/api/settings', {
|
||||
const body = {
|
||||
...serverSettings,
|
||||
...(_chipFlip !== undefined ? { showPlanUsageLimits: _chipFlip } : {}),
|
||||
...(tunnelGate.acknowledge ? { acknowledgeUnauthTunnel: true } : {}),
|
||||
notificationPreferences: notifPrefsToSave,
|
||||
voiceSettings,
|
||||
});
|
||||
};
|
||||
const res = settingsReadOnly ? null : await this._apiPut('/api/settings', body);
|
||||
|
||||
// Multi-user mode, a non-admin whose role was not known when this save started
|
||||
// (/api/me had not answered, or failed and admin-ui.js fell back to a single-user
|
||||
@@ -3389,15 +3527,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// COD-55: the server refuses an unauthenticated public tunnel with a 403 — which
|
||||
// rejects the WHOLE settings PUT. Surface the message and revert the tunnel toggle
|
||||
// so it doesn't look enabled.
|
||||
if (!refusedAsNonAdmin && settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) {
|
||||
// so it doesn't look enabled. Only a 403: any other failure with the switch on is
|
||||
// not about the tunnel, and turning the switch off for it would be wrong too.
|
||||
const tunnelRefused = !refusedAsNonAdmin && settings.tunnelEnabled && res?.status === 403;
|
||||
if (tunnelRefused && (await this._handleTunnelEnableRefusal(res, body))) {
|
||||
this._unsavedSettingsBaseline = _prevServer;
|
||||
this._abandonTunnelEnable(settings, keepOpen);
|
||||
return;
|
||||
}
|
||||
|
||||
// `_apiPut` answers null or a non-ok response instead of throwing, so this is the only
|
||||
// evidence the server kept the flags the Apply refresh below reads.
|
||||
serverSaved = !!res?.ok;
|
||||
// evidence the server kept the flags the Apply refresh below reads (a tunnel 403 still
|
||||
// here was retried with the whole save and accepted).
|
||||
serverSaved = !!res?.ok || tunnelRefused;
|
||||
// Any other failure (a 409 for a settings.json that does not parse, a 400, a 500, a dropped
|
||||
// connection) stored nothing on the server, so the save must not say it did. A known
|
||||
// non-admin sent nothing (res is null).
|
||||
saveError =
|
||||
settingsReadOnly || refusedAsNonAdmin || tunnelRefused ? '' : await this._settingsWriteError(res);
|
||||
this._unsavedSettingsBaseline = saveError ? _prevServer : null;
|
||||
|
||||
// Save model configuration separately. For a non-admin it is not sent either, and the
|
||||
// answer says whether this save would have changed it.
|
||||
@@ -3408,7 +3556,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// it. Only when the group was edited: an untouched group must not re-PUT. A refusal (bad URL,
|
||||
// enabled with no URL) keeps the modal open below, with the pasted URL still in the box.
|
||||
webhookError = this._webhookPending() ? await this.saveWebhook() : '';
|
||||
if (webhookError) {
|
||||
if (saveError) {
|
||||
this.showToast(`Saved on this device, not on the server: ${saveError}`, 'warning', { duration: 0 });
|
||||
} else if (webhookError) {
|
||||
this.showToast(`Settings saved, but not the webhook: ${webhookError}`, 'warning');
|
||||
} else if (syncedChangeRefused || modelConfigRefused || refusedAsNonAdmin) {
|
||||
// ONE toast for the whole save, and only when the user really changed a server-wide
|
||||
@@ -3421,7 +3571,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Show tunnel-specific feedback if toggled on (never for a non-admin, whose save
|
||||
// starts nothing: the box only shows what the server already has)
|
||||
if (settings.tunnelEnabled && !settingsReadOnly && !refusedAsNonAdmin) {
|
||||
if (settings.tunnelEnabled && !settingsReadOnly && !refusedAsNonAdmin && !saveError) {
|
||||
this.showToast('Tunnel starting — QR code will appear when ready...', 'info');
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -3433,9 +3583,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// old flags, and a webhook-only failure still saved the rest, so this runs ahead of that branch.
|
||||
if (keepOpen && serverSaved) this._refreshSettingsAfterApply(settings);
|
||||
|
||||
// A failed settings PUT keeps the modal open too, with the form as the user left it.
|
||||
if (webhookError) {
|
||||
document.getElementById('webhookGroup')?.scrollIntoView({ block: 'center' });
|
||||
} else if (!keepOpen) {
|
||||
} else if (!keepOpen && !saveError) {
|
||||
this.closeAppSettings();
|
||||
}
|
||||
|
||||
@@ -3446,10 +3597,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// The gesture overlay is injected at page render (server reads
|
||||
// gestureControlEnabled from settings.json), so a change only takes effect on
|
||||
// reload. Reload when it actually changed — the server PUT above already
|
||||
// persisted the new value.
|
||||
// persisted the new value (not after a failed PUT: the reload would render the old one).
|
||||
// `?? false`: a non-admin's save puts a synced key the server never stored back to
|
||||
// absent (_keepServerSyncedSettings), and absent means off, as in _prevGestureEnabled.
|
||||
if ((settings.gestureControlEnabled ?? false) !== _prevGestureEnabled) {
|
||||
if (!saveError && (settings.gestureControlEnabled ?? false) !== _prevGestureEnabled) {
|
||||
this.showToast(
|
||||
settings.gestureControlEnabled ? 'Enabling gesture control — reloading…' : 'Disabling gesture control — reloading…',
|
||||
'info'
|
||||
@@ -3498,6 +3649,35 @@ Object.assign(CodemanApp.prototype, {
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Why a settings write did not land, or '' when it did: the server's `error` (a 409 when
|
||||
* settings.json does not parse, a 400, ...), else the HTTP status, else, for the null that
|
||||
* `_apiPut` answers a dropped connection with, that the server could not be reached. Reads a
|
||||
* clone, like _noteSettingsWriteRefusal.
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async _settingsWriteError(res) {
|
||||
if (!res) return 'the server could not be reached';
|
||||
if (res.ok) return '';
|
||||
let body = null;
|
||||
try {
|
||||
body = await res.clone().json();
|
||||
} catch {
|
||||
/* not JSON: the status is all there is */
|
||||
}
|
||||
return body?.error || `HTTP ${res.status}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* The end of a background settings write (run mode, respawn presets): the admin-only refusal is
|
||||
* _noteSettingsWriteRefusal's, and any other failure says that `what` stayed on this device only.
|
||||
*/
|
||||
async _noteSettingsWriteResult(res, what) {
|
||||
if (await this._noteSettingsWriteRefusal(res)) return;
|
||||
const error = await this._settingsWriteError(res);
|
||||
if (error) this.showToast(`${what} saved on this device, not on the server: ${error}`, 'warning', { duration: 0 });
|
||||
},
|
||||
|
||||
/**
|
||||
* Where a non-admin's OWN run mode and respawn presets live (multi-user mode). Both are keys of
|
||||
* the server-wide settings.json, whose PUT is admin-only, so this user's picks are kept on this
|
||||
@@ -3809,6 +3989,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('customModelHostApiKey').value = ''; // the server never returns the real value (apiKeySet is a bool)
|
||||
document.getElementById('customModelHostApiKey').placeholder = host?.apiKeySet ? '•••••••• (unchanged if left blank)' : '';
|
||||
document.getElementById('customModelHostAuthStyle').value = host?.authStyle || 'bearer';
|
||||
// User-set fallback, round-tripped verbatim (discovery never touches it, so the
|
||||
// cached copy is authoritative); blank means "no default", cleared on save.
|
||||
document.getElementById('customModelHostDefaultContextLength').value = host?.defaultContextLength ?? '';
|
||||
this._populateCustomModelDefaultSelect(host);
|
||||
document.getElementById('customModelHostEditor').style.display = '';
|
||||
},
|
||||
@@ -3834,6 +4017,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
const baseUrl = document.getElementById('customModelHostBaseUrl').value.trim();
|
||||
const apiKeyInput = document.getElementById('customModelHostApiKey').value;
|
||||
const authStyle = document.getElementById('customModelHostAuthStyle').value;
|
||||
// Blank means "no default" (key omitted, so PUT clears it); a value must satisfy
|
||||
// the same bound the server schema enforces, caught here for a friendlier message.
|
||||
const defaultContextLengthInput = document.getElementById('customModelHostDefaultContextLength').value.trim();
|
||||
let defaultContextLength;
|
||||
if (defaultContextLengthInput !== '') {
|
||||
const parsed = Number(defaultContextLengthInput);
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100000000) {
|
||||
this.showToast('Default context length must be a whole number of tokens between 1 and 100,000,000', 'warning');
|
||||
return;
|
||||
}
|
||||
defaultContextLength = parsed;
|
||||
}
|
||||
const defaultModelId = document.getElementById('customModelHostDefaultModel').value || undefined;
|
||||
if (!id || !label || !baseUrl) {
|
||||
this.showToast('Id, label and base URL are all required', 'warning');
|
||||
@@ -3854,6 +4049,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
baseUrl,
|
||||
authStyle,
|
||||
defaultModelId,
|
||||
defaultContextLength,
|
||||
apiKey: apiKeyInput || undefined,
|
||||
models: existing?.models,
|
||||
lastDiscoveredAt: existing?.lastDiscoveredAt,
|
||||
@@ -4634,6 +4830,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Bottom-bar Git indicator (git-status-ui.js): opt-in, per-device. Starts or stops its poll to
|
||||
// match the setting, so a live toggle needs no reload.
|
||||
this.applyGitStatusVisibility?.();
|
||||
// Header Edit history button (edit-history-ui.js): opt-in, per-device, marker-class hidden.
|
||||
this.applyEditHistoryVisibility?.();
|
||||
|
||||
// Notification bell is retired (notifications live in Settings → Notifications
|
||||
// + the drawer); keep it hidden regardless of the notification-enabled state.
|
||||
@@ -4997,7 +5195,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'language',
|
||||
'terminalWheelLocalScrollback',
|
||||
'autoCopySelection', 'copyStripMargin',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds', 'showEditHistory',
|
||||
'showTabDetachButton',
|
||||
'showTabNewButton',
|
||||
'mobileOverviewEnabled',
|
||||
|
||||
@@ -651,6 +651,13 @@ body {
|
||||
margin: 0 2px var(--lineage-row-gap, 4px) 0;
|
||||
}
|
||||
|
||||
/* The first tab is capped to the room beside the toolbar: one wider than that (a
|
||||
selected one shows its gear and close) has its name trimmed to fit, so the
|
||||
layout never depends on which tab is selected. */
|
||||
.session-tabs-host > .session-tabs.tabs-corner > .session-tab.tab-corner-fit {
|
||||
max-width: var(--tab-corner-first-max, none);
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.lineage-tree.tabs-corner {
|
||||
padding-bottom: calc(var(--lineage-bottom-room) - var(--lineage-row-gap));
|
||||
}
|
||||
@@ -7807,6 +7814,52 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* The picker's search box reuses .session-sidebar-filter-input's theme-token
|
||||
styling (same reasoning as the .set-scope pill above): only its own spacing
|
||||
and the no-matches line are added here. 0.35rem is the standard gap the
|
||||
modal's neighbouring form fields already use (`.form-hint`'s own margin-top):
|
||||
the global `* { margin: 0 }` reset left the box sitting flush under the CLI
|
||||
hint line above it, and the old 0.5rem read as too big a gap above the list
|
||||
(compounded by the always-visible no-matches line the rule below fixes). */
|
||||
#customModelPickModal .custom-model-pick-search {
|
||||
margin-top: 0.35rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
#customModelPickModal .custom-model-pick-no-matches {
|
||||
text-align: center;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
/* "Nothing is marked: this endpoint did not report which model it has loaded": the same
|
||||
`.form-hint` `display: block` beats `[hidden]` trap the two rules below fix for the
|
||||
no-matches line and the filtered rows, so the attribute needs the same re-assertion. */
|
||||
#customModelPickModal .custom-model-pick-unknown {
|
||||
padding-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
#customModelPickModal .custom-model-pick-unknown[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Same class of bug as the row rule below: the no-matches line carries
|
||||
`.form-hint`, whose `display: block` outranks the UA `[hidden]` rule, so
|
||||
`filterCustomModelPick()` toggling `hidden` changed nothing on screen and
|
||||
"No models match" showed while every model was listed with an empty search
|
||||
box. The `[hidden]` re-assertion makes the attribute hide the line again. */
|
||||
#customModelPickModal .custom-model-pick-no-matches[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* A filtered-out row's `hidden` must actually hide it: `.run-mode-option` sets
|
||||
`display: flex` (and mobile.css restates the class in a media query), which
|
||||
outranks the UA `[hidden] { display: none }` rule, so the filter would
|
||||
silently do nothing without this re-assertion. Scoped to this modal's rows;
|
||||
the Run menu's own .run-mode-option entries never take `hidden`. */
|
||||
#customModelPickModal .run-mode-option[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Custom Model Endpoint Profiles: llama-swap model-swap confirmation — replaces a native
|
||||
confirm() popup (docs/custom-model-endpoints-plan.md) so it looks and feels like the
|
||||
rest of the app instead of a browser chrome dialog. Shares the context-window-too-small
|
||||
@@ -15764,6 +15817,7 @@ body.touch-device.cjk-input-visible .main {
|
||||
|
||||
.path-picker-up,
|
||||
.path-picker-refresh,
|
||||
.path-picker-newfolder,
|
||||
.path-picker-hidden {
|
||||
flex: 0 0 38px;
|
||||
height: 38px;
|
||||
@@ -15788,6 +15842,45 @@ body.touch-device.cjk-input-visible .main {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.path-picker-newfolder {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.path-picker-newfolder[hidden],
|
||||
.path-picker-newfolder-form[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.path-picker-newfolder-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 0 12px 10px;
|
||||
}
|
||||
|
||||
.path-picker-newfolder-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 38px;
|
||||
padding: 0 10px;
|
||||
color: var(--text);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.path-picker-newfolder-create,
|
||||
.path-picker-newfolder-cancel {
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
color: var(--text);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.path-picker-up:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
@@ -22586,6 +22679,297 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
/* ── Edit history: header button + panel (edit-history-ui.js) ───────────────── */
|
||||
|
||||
/* "Edit history" header button: opt-in (App Settings → Header & Panels), hidden by default.
|
||||
Same marker pattern as the Attachments button: a base inline-flex !important so an inline
|
||||
style can't override it, and a more-specific marker rule to hide. Phones hide it regardless
|
||||
(mobile.css). */
|
||||
.btn-edit-history {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
.btn-edit-history.btn-edit-history--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.btn-edit-history[aria-expanded='true'] {
|
||||
background: color-mix(in srgb, currentColor 16%, transparent);
|
||||
}
|
||||
|
||||
/* Same frame as the Git and Files windows: draggable by the header, resizable at the corner. */
|
||||
.edit-history-panel {
|
||||
position: fixed;
|
||||
top: calc(var(--header-height) + 10px);
|
||||
/* Left of the Git window's default spot; on a narrow viewport slide right so the left edge stays on screen. */
|
||||
right: clamp(8px, calc(100vw - 428px), 410px);
|
||||
width: 420px;
|
||||
max-width: calc(100vw - 16px);
|
||||
height: calc(100vh - var(--header-height) - var(--toolbar-height) - 40px);
|
||||
height: calc(100dvh - var(--header-height) - var(--toolbar-height) - 40px);
|
||||
max-height: 640px;
|
||||
min-width: 260px;
|
||||
min-height: 220px;
|
||||
background: var(--floating-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
z-index: 100;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
}
|
||||
|
||||
.edit-history-panel.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.edit-history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--bg-input);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.edit-history-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.edit-history-count {
|
||||
margin-left: 0.4rem;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.edit-history-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.edit-history-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem 0.9rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.edit-history-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-history-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.4rem 0.5rem;
|
||||
font-size: 0.74rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.edit-history-empty,
|
||||
.edit-history-note,
|
||||
.edit-history-more {
|
||||
color: var(--text-muted);
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.edit-history-note--warn {
|
||||
color: #e0a030;
|
||||
}
|
||||
|
||||
.edit-history-entry {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
padding: 0.15rem 0.25rem;
|
||||
border-radius: 4px;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-history-entry:hover,
|
||||
.edit-history-entry:focus-visible,
|
||||
.edit-history-entry--open {
|
||||
background: var(--bg-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.edit-history-time {
|
||||
flex: 0 0 auto;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.64rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.edit-history-badge {
|
||||
flex: 0 0 auto;
|
||||
min-width: 2rem;
|
||||
text-align: center;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: #e0a030;
|
||||
}
|
||||
|
||||
.edit-history-badge--new {
|
||||
color: rgb(34, 197, 94);
|
||||
}
|
||||
|
||||
.edit-history-badge--image {
|
||||
color: var(--text-muted);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.edit-history-badge--image svg {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Only the name itself opens the preview: the rest of the row (the spacer) toggles the diff. */
|
||||
/* The folder part gives way first, so the file name stays readable (…/icons/bremse_alt1.png). */
|
||||
.edit-history-path {
|
||||
display: inline-flex;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-history-path-dir {
|
||||
/* Shrinks long before the name does; the name only gives way once the folder is at min-width. */
|
||||
flex: 0 10000 auto;
|
||||
min-width: 1.2em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
/* Ellipsis at the START of the folder (rtl); the inner <bdi dir="ltr"> keeps its text in order. */
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.edit-history-path-name {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.edit-history-path:hover,
|
||||
.edit-history-path:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.edit-history-spacer {
|
||||
flex: 1;
|
||||
align-self: stretch;
|
||||
min-width: 0.5rem;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
.edit-history-stats {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
gap: 0.3rem;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
|
||||
.edit-history-add {
|
||||
color: rgb(34, 197, 94);
|
||||
}
|
||||
|
||||
.edit-history-del {
|
||||
color: #e5534b;
|
||||
}
|
||||
|
||||
.edit-history-agent {
|
||||
flex: 0 1 auto;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0 0.4rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.62rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.edit-history-thumb {
|
||||
display: block;
|
||||
max-width: 160px;
|
||||
max-height: 90px;
|
||||
margin: 0.15rem 0 0.35rem 3.2rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
object-fit: contain;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.edit-history-diff {
|
||||
margin: 0.2rem 0 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
.edit-history-group {
|
||||
margin-bottom: 0.45rem;
|
||||
padding-bottom: 0.2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.edit-history-group-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
padding: 0.2rem 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.edit-history-group-head .edit-history-path {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.edit-history-group-count {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.64rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.edit-history-older {
|
||||
margin: 0.5rem auto 0.25rem;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Tab grouping by state (tabArrangement: 'state', opt-in;
|
||||
Discussion #426 option C)
|
||||
|
||||
@@ -2146,13 +2146,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/**
|
||||
* A clicked path as the preview needs it: relative paths resolved against the
|
||||
* session's working directory, absolute and `~/` paths unchanged
|
||||
* directory the session's pane is in now, else its working directory
|
||||
* (`relativeResolutionBase`), absolute and `~/` paths unchanged
|
||||
* (`resolveLinkedFilePath`, constants.js). Shared by the terminal link
|
||||
* provider and the response viewer, so one path opens the same file in both.
|
||||
*/
|
||||
_resolveLinkedFilePath(filePath, sessionId) {
|
||||
if (typeof resolveLinkedFilePath !== 'function') return filePath;
|
||||
return resolveLinkedFilePath(filePath, this.sessions?.get?.(sessionId)?.workingDir);
|
||||
const session = this.sessions?.get?.(sessionId);
|
||||
const base = typeof relativeResolutionBase === 'function' ? relativeResolutionBase(session) : session?.workingDir;
|
||||
return resolveLinkedFilePath(filePath, base);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -2318,7 +2321,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Pattern 3: Bash() tool output
|
||||
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
|
||||
|
||||
const activateFilePath = (text, folder = false) => {
|
||||
const activateFilePath = (text, folder = false, verifiedSessionId = null) => {
|
||||
// Tailing a PNG in the log viewer shows binary noise; the file preview
|
||||
// already renders images, PDFs, documents and media inline — and it
|
||||
// now reaches files outside the workspace too, which is where an
|
||||
@@ -2336,8 +2339,26 @@ Object.assign(CodemanApp.prototype, {
|
||||
// A relative path is resolved against the session's working
|
||||
// directory first, and a `~/` path always previews (only the
|
||||
// server knows that host's home, and the log viewer cannot ask).
|
||||
const sessionId = getSessionId();
|
||||
const sessionId = verifiedSessionId || getSessionId();
|
||||
if (!sessionId) return;
|
||||
const target = self._resolveLinkedFilePath(text, sessionId);
|
||||
const alternative = !folder && logical.pathAlternatives?.find((candidate) => candidate.path === text);
|
||||
if (alternative && !verifiedSessionId) {
|
||||
return (async () => {
|
||||
const original = await self._registerExternalPreview(target, sessionId);
|
||||
// Attachment policy must not gate the workspace's normal opener.
|
||||
if (original.status !== 404) return activateFilePath(target, false, sessionId);
|
||||
const path = self._resolveLinkedFilePath(alternative.spacedPath, sessionId);
|
||||
const registered = await self._registerExternalPreview(path, sessionId);
|
||||
if (registered.status === 404) return activateFilePath(target, false, sessionId);
|
||||
if (!registered.attachmentId) {
|
||||
self.showToast(registered.error || 'Cannot open this file', 'error');
|
||||
return;
|
||||
}
|
||||
// The normal opener rechecks its guards; attachment registration deduplicates the path.
|
||||
return activateFilePath(path, false, sessionId);
|
||||
})();
|
||||
}
|
||||
// A folder path opens the folder grid (which falls back to the
|
||||
// preview if the server finds a file there after all).
|
||||
if (folder) {
|
||||
@@ -2372,7 +2393,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
underline: true,
|
||||
},
|
||||
activate(_event, text) {
|
||||
activateFilePath(text, folder);
|
||||
return activateFilePath(text, folder);
|
||||
},
|
||||
hover() {
|
||||
setHovered(true);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"version": "1.47.0",
|
||||
"version": "1.48.0",
|
||||
"highlights": [
|
||||
"The wrapped tab strip uses the room under the toolbar (#721)",
|
||||
"Bodyless requests work through Cloudflare Tunnel (#725)",
|
||||
"A desktop dead key is typed once, and Ctrl+V stays a paste after a replay (#726, #716)",
|
||||
"Remote sessions no longer touch the local disk at their path (#722, #727, #728)",
|
||||
"Rendered Markdown fills a resized File Viewer (#730)",
|
||||
"A middle click on a file path opens the file (#731)",
|
||||
"No more duplicate-key warning on build (#729)"
|
||||
"Talk to your agents from Discord (#741)",
|
||||
"Edit history panel (#693)",
|
||||
"Custom endpoints go further (#752, #755, #757)",
|
||||
"Clone into a custom folder, and make a folder from the picker (#744, #745)",
|
||||
"Relative links follow your `cd` (#740)",
|
||||
"File links keep the whole name (#735)",
|
||||
"A failed settings save says so (#736)",
|
||||
"Phone keys show a press (#737)"
|
||||
]
|
||||
}
|
||||
@@ -51,6 +51,8 @@ import {
|
||||
probeGitRemote,
|
||||
} from '../../git-clone.js';
|
||||
import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js';
|
||||
import { isPathWithin } from '../../git-worktrees.js';
|
||||
import { defaultDockerWorkspaces } from './git-status-routes.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { prepareNewCasePath } from '../case-path.js';
|
||||
import {
|
||||
@@ -150,6 +152,10 @@ const APP_VERSION = (() => {
|
||||
const LOCAL_CLONE_ADMIN_ONLY =
|
||||
'Cloning from a local path is admin-only in multi-user mode. Use a repository URL instead.';
|
||||
|
||||
/** Refusal text for a clone at or inside a Docker case workspace (git would run its hooks on the host). */
|
||||
const DOCKER_CLONE_REFUSAL =
|
||||
'Cloning is not available into a folder inside a Docker case workspace: a container can write there, and git would run the new repository’s hooks and filters on this machine. Clone outside the workspace and link it, or clone from inside the container.';
|
||||
|
||||
/**
|
||||
* Whether a clone or preflight must run with git's credential helpers cleared:
|
||||
* a non-admin in multi-user mode. Every user's git runs as the one server
|
||||
@@ -661,7 +667,8 @@ export function registerCaseRoutes(
|
||||
* request died mid-clone still sees the case appear over SSE when git finishes.
|
||||
*
|
||||
* Deliberately NOT admin-gated in multi-user mode: unlike `/api/cases/link`,
|
||||
* this writes only inside the caller's own `resolveCasesDir`. Two things would
|
||||
* this writes only inside the caller's own `resolveCasesDir`, EXCEPT with a `path` (a custom folder,
|
||||
* registered as a linked case in the shared registry), which is admin-only like Link Existing. Two things would
|
||||
* otherwise read through that boundary: a `local`-transport source (refused for
|
||||
* non-admins) and the server account's git credential helpers, which every user
|
||||
* shares (cleared for non-admins, see `cloneWithoutCredentialHelpers`).
|
||||
@@ -686,7 +693,7 @@ export function registerCaseRoutes(
|
||||
warnings: string[];
|
||||
}>
|
||||
> => {
|
||||
const { name, repository, ref, shallow, description } = parseBody(CloneCaseSchema, req.body);
|
||||
const { name, repository, ref, shallow, description, path: customPath } = parseBody(CloneCaseSchema, req.body);
|
||||
const user = getAuthUser(req);
|
||||
|
||||
const parsed = parseGitRepositoryUrl(repository);
|
||||
@@ -705,8 +712,59 @@ export function registerCaseRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
// A custom folder writes outside the cases directory and registers the path in the shared,
|
||||
// ownerless linked-cases registry, so it carries the same bar as POST /api/cases/link.
|
||||
if (customPath !== undefined) {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
}
|
||||
|
||||
const casesDir = resolveCasesDir(user);
|
||||
const casePath = validatePathWithinBase(name, casesDir);
|
||||
let casePath: string | null;
|
||||
let intoEmptyDir = false;
|
||||
if (customPath !== undefined) {
|
||||
const prepared = await prepareNewCasePath(customPath, {
|
||||
home: homedir(),
|
||||
dataDir: getDataDir(),
|
||||
casesDirs: [...new Set([casesDir, resolveCasesDir()])],
|
||||
casesDirAdvice: 'clone there without a custom folder',
|
||||
});
|
||||
if (!prepared.ok) {
|
||||
reply.code(
|
||||
prepared.code === 'UNREACHABLE'
|
||||
? 422
|
||||
: prepared.code === 'NOT_FOUND'
|
||||
? 404
|
||||
: prepared.code === 'EXISTS'
|
||||
? 409
|
||||
: 400
|
||||
);
|
||||
return createErrorResponse(
|
||||
prepared.code === 'UNREACHABLE'
|
||||
? ApiErrorCode.OPERATION_FAILED
|
||||
: prepared.code === 'NOT_FOUND'
|
||||
? ApiErrorCode.NOT_FOUND
|
||||
: prepared.code === 'EXISTS'
|
||||
? ApiErrorCode.ALREADY_EXISTS
|
||||
: ApiErrorCode.INVALID_INPUT,
|
||||
prepared.code === 'EXISTS'
|
||||
? `${customPath} already has files in it. Pick an empty folder, or a folder that does not exist yet.`
|
||||
: prepared.reason
|
||||
);
|
||||
}
|
||||
intoEmptyDir = prepared.existedEmpty;
|
||||
casePath = prepared.path;
|
||||
// The same folder listed as a local case in the cases dir would make deleting it remove files.
|
||||
if (existsSync(join(casesDir, name))) {
|
||||
reply.code(409);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.ALREADY_EXISTS,
|
||||
'A case with this name already exists in codeman-cases.'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
casePath = validatePathWithinBase(name, casesDir);
|
||||
}
|
||||
if (!casePath) return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
|
||||
// Reject a duplicate name across EVERY case kind before invoking git, so a
|
||||
@@ -715,8 +773,14 @@ export function registerCaseRoutes(
|
||||
const linkedCases = await readLinkedCases();
|
||||
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
const linkedAs = Object.entries(linkedCases).find(([, p]) => p === casePath)?.[0];
|
||||
if (linkedAs && customPath !== undefined) {
|
||||
reply.code(409);
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `That folder is already the case "${linkedAs}"`);
|
||||
}
|
||||
if (
|
||||
existsSync(casePath) ||
|
||||
// A custom folder was judged by prepareNewCasePath (an existing empty one is fine).
|
||||
(customPath === undefined && existsSync(casePath)) ||
|
||||
linkedCases[name] ||
|
||||
dockerCases.some((item) => item.name === name) ||
|
||||
remoteCases.some((item) => item.name === name)
|
||||
@@ -724,16 +788,47 @@ export function registerCaseRoutes(
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||
}
|
||||
|
||||
// git creates the leaf, not necessarily the case space above it.
|
||||
// git creates the leaf, not necessarily the case space above it (a custom folder's parent
|
||||
// already exists: prepareNewCasePath refused a missing one).
|
||||
if (customPath === undefined) {
|
||||
try {
|
||||
mkdirSync(casesDir, { recursive: true });
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
// git runs the new repository's hooks and filters on THIS machine once the clone finishes, and a container
|
||||
// with the workspace bind-mounted can write both while it fetches. Like git status, fetch and worktrees,
|
||||
// never clone at or inside a Docker case workspace. Fails closed when the Docker case list cannot be read.
|
||||
try {
|
||||
mkdirSync(casesDir, { recursive: true });
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
// Probed first (#516): a case's workspace can sit on a mount that stopped answering, and an unbounded
|
||||
// realpath there would hold this request and a threadpool worker. Such a root is judged as spelled.
|
||||
const roots = await Promise.all(
|
||||
(await defaultDockerWorkspaces()).map(async (root) =>
|
||||
(await probePathKind(root, { pastCap: true })) === 'unknown'
|
||||
? resolve(root)
|
||||
: fs.realpath(root).catch(() => resolve(root))
|
||||
)
|
||||
);
|
||||
// A custom folder is already symlink-resolved; the default one is judged through its real cases dir.
|
||||
const dest =
|
||||
customPath !== undefined ? casePath : join(await fs.realpath(casesDir).catch(() => casesDir), name);
|
||||
if (roots.some((root) => isPathWithin(dest, root))) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, DOCKER_CLONE_REFUSAL);
|
||||
}
|
||||
} catch {
|
||||
reply.code(503);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Could not read the Docker case list, so Codeman cannot tell whether that folder is a Docker case workspace. Try again.'
|
||||
);
|
||||
}
|
||||
|
||||
const clone = await cloneRepository({
|
||||
repository: parsed.repository,
|
||||
destination: casePath,
|
||||
...(intoEmptyDir ? { intoEmptyDir: true } : {}),
|
||||
withoutCredentialHelpers: cloneWithoutCredentialHelpers(req),
|
||||
...(ref ? { ref } : {}),
|
||||
...(shallow ? { shallow: true } : {}),
|
||||
@@ -794,6 +889,26 @@ export function registerCaseRoutes(
|
||||
warnings.push(`Case scaffolding was incomplete: ${getErrorMessage(err)}`);
|
||||
}
|
||||
|
||||
if (customPath !== undefined) {
|
||||
// Re-read right before writing so a case linked while git ran is not dropped. Like
|
||||
// POST /api/cases/link the write is not serialized, so this only narrows the window.
|
||||
try {
|
||||
const fresh = await readLinkedCases();
|
||||
if (fresh[name])
|
||||
throw Object.assign(new Error(`Case "${name}" was linked while the clone ran`), { conflict: true });
|
||||
fresh[name] = casePath;
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(fresh, null, 2));
|
||||
} catch (err) {
|
||||
// The clone itself succeeded: keep the folder (the user waited for it) and say where it is.
|
||||
const conflict = (err as { conflict?: boolean }).conflict;
|
||||
reply.code(conflict ? 409 : 500);
|
||||
return createErrorResponse(
|
||||
conflict ? ApiErrorCode.ALREADY_EXISTS : ApiErrorCode.OPERATION_FAILED,
|
||||
`Cloned into ${casePath} but could not register the case (${getErrorMessage(err)}). Use Link Existing to add it.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -150,7 +150,12 @@ export async function runConfigBackup(
|
||||
});
|
||||
}
|
||||
|
||||
export function registerConfigBackupRoutes(app: FastifyInstance): void {
|
||||
export interface ConfigBackupRouteDeps {
|
||||
/** Re-read discord.json into the running bridge (it caches its config and would keep the pre-restore token). */
|
||||
reloadDiscord?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function registerConfigBackupRoutes(app: FastifyInstance, deps: ConfigBackupRouteDeps = {}): void {
|
||||
const deny = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => {
|
||||
if (isMultiUserMode() && !isAdmin(req)) {
|
||||
reply.code(403);
|
||||
@@ -236,6 +241,8 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
|
||||
if (restored.includes('intents.json')) intentStore.reload();
|
||||
// restoreConfigBackup writes clis.json directly (the one writer besides mutateRegistryFile).
|
||||
if (restored.includes('clis.json')) reloadCliRegistry();
|
||||
// The Discord bridge caches discord.json and rewrites nothing from it, so a restore must reach the running bridge.
|
||||
if (restored.includes('discord.json')) void deps.reloadDiscord?.().catch(() => undefined);
|
||||
// A restore of users.json replaces accounts, password hashes, roles and bypass grants.
|
||||
if (isMultiUserMode()) {
|
||||
void appendAdminAudit({
|
||||
|
||||
@@ -21,7 +21,7 @@ import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
import { isBlockedWebviewUrl } from '../webview-egress-policy.js';
|
||||
import { egressBlockedReason, webviewFetch } from '../webview-egress.js';
|
||||
import { CustomModelHostSchema } from '../schemas.js';
|
||||
import { CustomModelHostSchema, MAX_DISCOVERED_CONTEXT_TOKENS } from '../schemas.js';
|
||||
import { readCustomModelHosts, writeCustomModelHosts, type CustomModelHost } from '../../custom-model-hosts.js';
|
||||
import type { CliEntry } from '../../config/cli-registry/types.js';
|
||||
|
||||
@@ -158,9 +158,14 @@ function authHeaders(host: Pick<CustomModelHost, 'apiKey' | 'authStyle'>): Recor
|
||||
|
||||
export interface DiscoveryResult {
|
||||
models: string[];
|
||||
/** See `CustomModelHost.modelContextLengths` — only ever populated for models already loaded. */
|
||||
/**
|
||||
* See `CustomModelHost.modelContextLengths`: populated from a model's own `/v1/models`
|
||||
* entry (`context_length`, else `context_window`) whenever the server states one, and
|
||||
* from the launch command or `/props` for loaded models, which overwrite (and so win
|
||||
* over) any listed figure for the same model.
|
||||
*/
|
||||
contextLengths: Record<string, number>;
|
||||
/** See `CustomModelHost.modelSlotCounts`; populated on the same loaded-models-only basis as `contextLengths`. */
|
||||
/** See `CustomModelHost.modelSlotCounts`; populated only for models the server reports loaded (launch command or `/props`). No listing states a slot count, unlike `contextLengths`. */
|
||||
slotCounts: Record<string, number>;
|
||||
/** See `CustomModelHost.modelSizesGB` — populated for every model whose own listing states one. */
|
||||
sizesGB: Record<string, number>;
|
||||
@@ -182,6 +187,20 @@ function parseSizeGB(description: unknown): number | undefined {
|
||||
return Number.isFinite(size) && size > 0 ? size : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort: reads a context length (tokens) stated by a model's OWN entry in
|
||||
* `GET /v1/models`. OpenRouter-style servers call the field `context_length`; current
|
||||
* llama-swap sends both `context_length` and `context_window` per model, and current
|
||||
* Bifrost (a gateway in front of llama-swap) passes `context_window` through. Older
|
||||
* servers send neither, and a server that states nothing gets no entry, never a guess.
|
||||
* Non-numeric, zero, negative, fractional or over-the-ceiling values are ignored, the
|
||||
* same rule the stored field's schema enforces: a wrong figure here is worse than none.
|
||||
*/
|
||||
function parseListedContextLength(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) return undefined;
|
||||
return value <= MAX_DISCOVERED_CONTEXT_TOKENS ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort: fetches `GET /props?model=<id>` (llama.cpp-native, llama-swap-proxied) for
|
||||
* ONE already-loaded model and pulls its `n_ctx` and `total_slots` out. Never called for a model that
|
||||
@@ -312,25 +331,42 @@ async function discoverModels(
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = (await res.json()) as {
|
||||
data?: Array<{ id?: unknown; status?: { value?: unknown }; description?: unknown }>;
|
||||
data?: Array<{
|
||||
id?: unknown;
|
||||
status?: { value?: unknown };
|
||||
description?: unknown;
|
||||
context_length?: unknown;
|
||||
context_window?: unknown;
|
||||
}>;
|
||||
};
|
||||
const entries = body.data ?? [];
|
||||
const models = entries.map((m) => m.id).filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
|
||||
const sizesGB: Record<string, number> = {};
|
||||
// The lowest-precedence context source, and the only one a gateway without /running or
|
||||
// /props of its own (e.g. Bifrost) can offer: the figure a model's own listing states
|
||||
// (`context_length`, else `context_window`). The loaded-model pass below OVERWRITES
|
||||
// these for whatever it reads from the launch command or /props, so precedence per
|
||||
// model stays: `/running` cmd, then `/props`, then this listed figure.
|
||||
const contextLengths: Record<string, number> = {};
|
||||
for (const entry of entries) {
|
||||
if (typeof entry.id !== 'string' || !entry.id) continue;
|
||||
const size = parseSizeGB(entry.description);
|
||||
if (size !== undefined) sizesGB[entry.id] = size;
|
||||
const listed = parseListedContextLength(entry.context_length) ?? parseListedContextLength(entry.context_window);
|
||||
if (listed !== undefined) contextLengths[entry.id] = listed;
|
||||
}
|
||||
|
||||
// llama-swap-specific, feature-detected: a server that never mentions `status` on ANY
|
||||
// entry gets no context-length enrichment at all, rather than treating "no status field"
|
||||
// as "assume unloaded" — either reading is a guess, and skipping is the safe one, since
|
||||
// fetchContextLength must only ever run against a model this server itself calls loaded.
|
||||
// entry gets no launch-command/props context enrichment at all, rather than treating
|
||||
// "no status field" as "assume unloaded"; either reading is a guess, and skipping is
|
||||
// the safe one, since fetchContextLength must only ever run against a model this server
|
||||
// itself calls loaded. Listed figures above are unaffected: reading them triggers nothing.
|
||||
const hasStatusField = entries.some((m) => m && typeof m === 'object' && 'status' in m);
|
||||
const contextLengths: Record<string, number> = {};
|
||||
const slotCounts: Record<string, number> = {};
|
||||
// Ids whose figure THIS round's loaded-model probe answered: the listed figure remains
|
||||
// the source only for the ids NOT in this set (see the per-slot correction below).
|
||||
const ctxFromProbe = new Set<string>();
|
||||
if (hasStatusField) {
|
||||
const loadedIds = entries
|
||||
.filter((m) => m.status && typeof m.status === 'object' && (m.status as { value?: unknown }).value === 'loaded')
|
||||
@@ -364,11 +400,30 @@ async function discoverModels(
|
||||
// merges onto what was learned before).
|
||||
if (cmdSlots !== undefined && ctxFromProps) slots = 1;
|
||||
}
|
||||
if (ctx !== undefined) contextLengths[id] = ctx;
|
||||
// Assigning only when a probe answered keeps the listed figure (if any) when
|
||||
// neither the launch command nor /props stated one, and replaces it with the
|
||||
// stronger source whenever they did: cmd > /props > listed, never the reverse.
|
||||
if (ctx !== undefined) {
|
||||
contextLengths[id] = ctx;
|
||||
ctxFromProbe.add(id);
|
||||
}
|
||||
if (slots !== undefined) slotCounts[id] = slots;
|
||||
}
|
||||
}
|
||||
}
|
||||
// llama-swap documents the LISTED `context_length` as the PER-SLOT window, so whenever
|
||||
// the listed figure is the one taken this round (the model was not loaded, or the
|
||||
// probe answered nothing), its slot count is 1 by definition: the floor check must NOT
|
||||
// divide a per-slot figure a second time. Without recording 1 here, the count a loaded
|
||||
// round stored survives the merge (applyDiscoveredModels keeps what a later round does
|
||||
// not overwrite): 262144 @ 4 slots stored, model unloaded, listed 65536 → 65536 with 4
|
||||
// slots → a false 16384-per-slot warning. Even a count this round's launch command
|
||||
// parsed must yield: pairing a per-slot listed figure with the cmd's --parallel is the
|
||||
// same double division. Same rule the /props branch above applies. Ids whose figure the
|
||||
// probe DID answer keep their probe-derived count untouched.
|
||||
for (const id of Object.keys(contextLengths)) {
|
||||
if (!ctxFromProbe.has(id)) slotCounts[id] = 1;
|
||||
}
|
||||
return { models, contextLengths, slotCounts, sizesGB };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* @fileoverview Discord bridge settings and the deliberate-post route (src/discord-bridge.ts).
|
||||
*
|
||||
* GET /api/discord the config WITHOUT its token, plus gateway state and last result
|
||||
* PUT /api/discord change any field; an empty `token` clears it
|
||||
* POST /api/discord/test post one test message to the parent channel
|
||||
* POST /api/sessions/:id/discord-notify a CLI asks for the user (physical step, blocker, timed-out watch)
|
||||
*
|
||||
* The bot token is a bearer secret, so it is stored in its own 0600 file and never returned. In
|
||||
* multi-user mode the three settings routes are admin only: the server receives every session's
|
||||
* events. `discord-notify` follows the session's own ownership rules instead.
|
||||
*/
|
||||
|
||||
import { readFile, realpath, stat } from 'node:fs/promises';
|
||||
import { basename, isAbsolute, relative, sep } from 'node:path';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
||||
import { canAccessOwned, findSessionOrFail, getAuthUser, isAdmin, parseBody } from '../route-helpers.js';
|
||||
import type { SessionPort } from '../ports/session-port.js';
|
||||
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { describeUnknownPath, probePathKind } from '../../utils/bounded-path-probe.js';
|
||||
import { DiscordNotifySchema, DiscordUpdateSchema } from '../schemas.js';
|
||||
import {
|
||||
discordConfigProblem,
|
||||
MAX_FILE_BYTES,
|
||||
maskToken,
|
||||
readDiscordConfig,
|
||||
writeDiscordConfig,
|
||||
type DiscordBridge,
|
||||
} from '../../discord-bridge.js';
|
||||
import type { DiscordConfig, DiscordResult, DiscordStatus } from '../../types/discord.js';
|
||||
|
||||
export interface DiscordRouteDeps {
|
||||
bridge: DiscordBridge;
|
||||
configDir: string;
|
||||
ctx: SessionPort;
|
||||
hostTitle: () => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Docker case exports CODEMAN_SESSION_ID cut to 8 characters, so accept a unique prefix of at
|
||||
* least that length. Anything ambiguous or shorter is left as given and 404s like any unknown id.
|
||||
*/
|
||||
function resolveSessionId(ctx: SessionPort, req: FastifyRequest, id: string): string {
|
||||
if (ctx.sessions.has(id) || id.length < 8) return id;
|
||||
// Only sessions the caller may see count, so another user's session is never revealed by its prefix.
|
||||
const user = getAuthUser(req);
|
||||
const hits = [...ctx.sessions.values()].filter((s) => s.id.startsWith(id) && canAccessOwned(user, s.owner));
|
||||
return hits.length === 1 ? hits[0].id : id;
|
||||
}
|
||||
|
||||
// A loop or a prompt-injected agent must not ping the phones until Discord answers 429, nor hold 8 MB
|
||||
// uploads in memory without bound. Per session, refilled continuously (the same shape as paste-image's).
|
||||
const NOTIFY_PER_MINUTE = 6;
|
||||
const notifyBuckets = new Map<string, { tokens: number; at: number }>();
|
||||
|
||||
function consumeNotifyToken(sessionId: string, now: number = Date.now()): boolean {
|
||||
if (notifyBuckets.size > 1000) {
|
||||
for (const [k, b] of notifyBuckets) if (now - b.at > 3_600_000) notifyBuckets.delete(k);
|
||||
}
|
||||
const b = notifyBuckets.get(sessionId) ?? { tokens: NOTIFY_PER_MINUTE, at: now };
|
||||
b.tokens = Math.min(NOTIFY_PER_MINUTE, b.tokens + ((now - b.at) * NOTIFY_PER_MINUTE) / 60_000);
|
||||
b.at = now;
|
||||
notifyBuckets.set(sessionId, b);
|
||||
if (b.tokens < 1) return false;
|
||||
b.tokens -= 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Give back a token spent on a request that was refused before anything was posted. */
|
||||
function refundNotifyToken(sessionId: string): void {
|
||||
const b = notifyBuckets.get(sessionId);
|
||||
if (b) b.tokens = Math.min(NOTIFY_PER_MINUTE, b.tokens + 1);
|
||||
}
|
||||
|
||||
/** Test hook: forget every bucket between runs. */
|
||||
export function _resetNotifyBuckets(): void {
|
||||
notifyBuckets.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* A file a session may send: a regular file, at most MAX_FILE_BYTES, whose REAL path (symlinks
|
||||
* followed) lies inside the session's working folder and is not on the attachment blocklist. The
|
||||
* blocklist matters as much as the folder: with a home directory as the session folder, confinement
|
||||
* alone would let a CLI be talked into posting ~/.codeman/discord.json, an ssh key or a `.env`.
|
||||
* Returns the bytes, or the reason.
|
||||
*/
|
||||
async function fileProblem(
|
||||
workingDir: string,
|
||||
remote: boolean,
|
||||
file: string
|
||||
): Promise<{ name: string; data: Uint8Array } | string> {
|
||||
if (remote) return 'Files can only be sent from a local session';
|
||||
if (!isAbsolute(file)) return 'The file path must be absolute';
|
||||
// Bounded first: realpath on a dead mount would hang the request.
|
||||
if ((await probePathKind(workingDir, { pastCap: true })) !== 'directory') {
|
||||
return describeUnknownPath('The session folder', workingDir, { pastCap: true });
|
||||
}
|
||||
// The agent chose this path: probe it bounded too, before realpath can hold a worker on a dead mount.
|
||||
const kind = await probePathKind(file, { pastCap: true });
|
||||
if (kind === 'unknown') return describeUnknownPath('The file', file, { pastCap: true });
|
||||
if (kind !== 'file') return 'Not a regular file';
|
||||
try {
|
||||
const [root, real] = await Promise.all([realpath(workingDir), realpath(file)]);
|
||||
const rel = relative(root, real);
|
||||
if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
||||
return 'The file must be inside the session folder';
|
||||
if (isBlockedAttachmentPath(real, (await loadAttachmentGuardConfig()).blockedTrees)) {
|
||||
return 'That file cannot be sent: it can hold credentials';
|
||||
}
|
||||
const info = await stat(real);
|
||||
if (!info.isFile()) return 'Not a regular file';
|
||||
if (info.size > MAX_FILE_BYTES) return `The file is over ${MAX_FILE_BYTES / 1024 / 1024} MB`;
|
||||
return { name: basename(real), data: new Uint8Array(await readFile(real)) };
|
||||
} catch {
|
||||
return 'The file could not be read';
|
||||
}
|
||||
}
|
||||
|
||||
export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDeps): void {
|
||||
const denied = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => {
|
||||
if (isMultiUserMode() && !isAdmin(req)) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const status = async (): Promise<DiscordStatus> => {
|
||||
const { token, ...rest } = await readDiscordConfig(deps.configDir);
|
||||
return {
|
||||
...rest,
|
||||
hasToken: token !== '',
|
||||
tokenMasked: maskToken(token),
|
||||
gateway: deps.bridge.gatewayState,
|
||||
gatewayError: deps.bridge.gatewayError,
|
||||
lastResult: deps.bridge.lastResult,
|
||||
};
|
||||
};
|
||||
|
||||
app.get('/api/discord', async (req, reply): Promise<ApiResponse<DiscordStatus>> => {
|
||||
const no = denied(req, reply);
|
||||
if (no) return no;
|
||||
return { success: true, data: await status() };
|
||||
});
|
||||
|
||||
app.put('/api/discord', async (req, reply): Promise<ApiResponse<DiscordStatus>> => {
|
||||
const no = denied(req, reply);
|
||||
if (no) return no;
|
||||
const patch = parseBody(DiscordUpdateSchema, req.body, 'Invalid Discord settings');
|
||||
const current = await readDiscordConfig(deps.configDir);
|
||||
const next: DiscordConfig = {
|
||||
...current,
|
||||
...patch,
|
||||
token: patch.token !== undefined ? patch.token.trim() : current.token,
|
||||
};
|
||||
const problem = discordConfigProblem(next);
|
||||
if (problem) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem);
|
||||
}
|
||||
try {
|
||||
await writeDiscordConfig(deps.configDir, next);
|
||||
await deps.bridge.reload();
|
||||
} catch (err) {
|
||||
reply.code(500);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
return { success: true, data: await status() };
|
||||
});
|
||||
|
||||
app.post('/api/discord/test', async (req, reply): Promise<ApiResponse<DiscordResult>> => {
|
||||
const no = denied(req, reply);
|
||||
if (no) return no;
|
||||
const cfg = await readDiscordConfig(deps.configDir);
|
||||
if (!cfg.token || !cfg.parentChannelId) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Save the bot token and parent channel ID first');
|
||||
}
|
||||
// 200 even when delivery failed: the request to Codeman worked, `data.ok` says whether Discord did.
|
||||
return { success: true, data: await deps.bridge.sendTest(cfg, deps.hostTitle()) };
|
||||
});
|
||||
|
||||
app.post('/api/sessions/:id/discord-notify', async (req, reply): Promise<ApiResponse<DiscordResult | null>> => {
|
||||
const { id: rawId } = req.params as { id: string };
|
||||
const id = resolveSessionId(deps.ctx, req, rawId);
|
||||
const { message, urgent, file } = parseBody(DiscordNotifySchema, req.body, 'Invalid Discord message');
|
||||
const session = findSessionOrFail(deps.ctx, id, req);
|
||||
if (!consumeNotifyToken(session.id)) {
|
||||
reply.code(429);
|
||||
reply.header('Retry-After', '60');
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.RATE_LIMITED,
|
||||
`Too many messages (${NOTIFY_PER_MINUTE} per minute per session)`
|
||||
);
|
||||
}
|
||||
let upload: { name: string; data: Uint8Array } | undefined;
|
||||
if (file !== undefined) {
|
||||
const problem = await fileProblem(session.workingDir, !!session.remote, file);
|
||||
if (typeof problem === 'string') {
|
||||
refundNotifyToken(session.id);
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem);
|
||||
}
|
||||
upload = problem;
|
||||
}
|
||||
const result = await deps.bridge.notify(
|
||||
{
|
||||
event: 'session:discord-notify',
|
||||
title: urgent ? 'Needs you now' : 'Message from the CLI',
|
||||
body: message,
|
||||
urgency: urgent ? 'critical' : 'warning',
|
||||
sessionId: session.id,
|
||||
sessionName: session.name,
|
||||
host: deps.hostTitle(),
|
||||
file: upload,
|
||||
},
|
||||
// deliberate: an agent's own message is never deduped, so a second, different post is not lost.
|
||||
{ urgent: urgent === true, deliberate: true }
|
||||
);
|
||||
if (result === null) {
|
||||
reply.code(409);
|
||||
return createErrorResponse(ApiErrorCode.CONFLICT, 'The Discord bridge is not enabled');
|
||||
}
|
||||
return { success: true, data: result };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @fileoverview The edit history panel's API: which files a claude session edited (main agent
|
||||
* and subagents), each edit's diff, and Bash-generated images (src/web/edit-history-sync.ts).
|
||||
*
|
||||
* GET /api/sessions/:id/edits[?sync=1] list (no diffs); `sync=1` reads what is new first
|
||||
* GET /api/sessions/:id/edits/:entryId one edit's diff as unified text
|
||||
* POST /api/sessions/:id/edits/older read the next older block, then list
|
||||
*
|
||||
* Read-only, and reads only on these requests (the panel drives it; nothing syncs in the
|
||||
* background). Remote, Docker and non-claude sessions answer `state: 'unsupported'`
|
||||
* without being read. Ownership goes through `findSessionOrFail` with `req`, like every
|
||||
* session-scoped route. No route here serves a file: an image entry is shown through the
|
||||
* existing file routes (workspace `file-raw`, or the attachment routes outside it).
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js';
|
||||
import { findSessionOrFail } from '../route-helpers.js';
|
||||
import type { SessionPort } from '../ports/index.js';
|
||||
import type { EditHistoryEntry, EditHistoryStore } from '../../edit-history-store.js';
|
||||
import type { EditHunk } from '../../edit-history-parse.js';
|
||||
import {
|
||||
unsupportedReason,
|
||||
type EditHistorySession,
|
||||
type EditHistoryState,
|
||||
type EditHistoryStatus,
|
||||
type EditHistorySync,
|
||||
} from '../edit-history-sync.js';
|
||||
|
||||
export interface EditHistoryListEntry extends Omit<EditHistoryEntry, 'hunks'> {
|
||||
relPath: string;
|
||||
}
|
||||
|
||||
export interface EditHistoryList {
|
||||
state: EditHistoryState;
|
||||
reason?: string;
|
||||
backfillTruncated: boolean;
|
||||
agents: Record<string, { description: string; agentType: string; worktreeBranch?: string }>;
|
||||
entries: EditHistoryListEntry[];
|
||||
}
|
||||
|
||||
export interface EditHistoryDiff {
|
||||
diff: string;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/** `path` relative to `base` when it lies below it, else unchanged. */
|
||||
export function relativeTo(path: string, base: string | undefined): string {
|
||||
if (!base) return path;
|
||||
const root = base.endsWith('/') ? base : `${base}/`;
|
||||
return path.startsWith(root) ? path.slice(root.length) : path;
|
||||
}
|
||||
|
||||
/** Hunks as unified diff text, the form the Git panel's renderer reads. */
|
||||
export function hunksToUnified(hunks: readonly EditHunk[]): string {
|
||||
const out: string[] = [];
|
||||
for (const h of hunks) {
|
||||
out.push(`@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`);
|
||||
out.push(...h.lines);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function listOf(
|
||||
store: EditHistoryStore,
|
||||
workingDir: string,
|
||||
status: { state: EditHistoryState; reason?: string }
|
||||
): EditHistoryList {
|
||||
const agents = store.getAgents();
|
||||
const entries = store.list().map(({ hunks: _hunks, ...rest }) => ({
|
||||
...rest,
|
||||
relPath: relativeTo(rest.path, (rest.agentId && agents[rest.agentId]?.worktreePath) || workingDir),
|
||||
}));
|
||||
const publicAgents: EditHistoryList['agents'] = {};
|
||||
for (const [id, a] of Object.entries(agents)) {
|
||||
publicAgents[id] = { description: a.description, agentType: a.agentType };
|
||||
if (a.worktreeBranch) publicAgents[id].worktreeBranch = a.worktreeBranch;
|
||||
}
|
||||
const list: EditHistoryList = {
|
||||
state: status.state,
|
||||
backfillTruncated: store.backfillTruncated,
|
||||
agents: publicAgents,
|
||||
entries,
|
||||
};
|
||||
if (status.reason) list.reason = status.reason;
|
||||
return list;
|
||||
}
|
||||
|
||||
/** The answer for a session that is never read (remote, Docker, non-claude): no store involved. */
|
||||
function unsupportedList(reason: string): EditHistoryList {
|
||||
return { state: 'unsupported', reason, backfillTruncated: false, agents: {}, entries: [] };
|
||||
}
|
||||
|
||||
export function registerEditHistoryRoutes(app: FastifyInstance, ctx: SessionPort, sync: EditHistorySync): void {
|
||||
const sessionOf = (id: string, req: Parameters<typeof findSessionOrFail>[2]) => {
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const view: EditHistorySession = {
|
||||
id: session.id,
|
||||
mode: session.mode,
|
||||
remote: session.remote,
|
||||
docker: session.docker,
|
||||
claudeSessionId: session.claudeSessionId,
|
||||
claudeConfigDir: session.claudeConfigDir(),
|
||||
};
|
||||
return { session, view };
|
||||
};
|
||||
|
||||
app.get('/api/sessions/:id/edits', async (req): Promise<ApiResponse<EditHistoryList>> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { sync: doSync } = req.query as { sync?: string };
|
||||
const { session, view } = sessionOf(id, req);
|
||||
const why = unsupportedReason(view);
|
||||
// Never read: answer an empty list without loading (or creating) a store for it.
|
||||
if (why) return { success: true, data: unsupportedList(why) };
|
||||
// The sync starts in the same tick as the session check: a deletion right after it marks
|
||||
// the running sync gone (EditHistorySync.remove), so nothing writes a deleted session.
|
||||
const status: EditHistoryStatus = doSync === '1' ? await sync.sync(view) : sync.getStatus(session.id);
|
||||
return { success: true, data: listOf(await sync.getStore(session.id), session.workingDir, status) };
|
||||
});
|
||||
|
||||
app.post('/api/sessions/:id/edits/older', async (req): Promise<ApiResponse<EditHistoryList>> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { session, view } = sessionOf(id, req);
|
||||
const why = unsupportedReason(view);
|
||||
if (why) return { success: true, data: unsupportedList(why) };
|
||||
const status = await sync.sync(view, { older: true });
|
||||
return { success: true, data: listOf(await sync.getStore(session.id), session.workingDir, status) };
|
||||
});
|
||||
|
||||
app.get('/api/sessions/:id/edits/:entryId', async (req, reply): Promise<ApiResponse<EditHistoryDiff>> => {
|
||||
const { id, entryId } = req.params as { id: string; entryId: string };
|
||||
const { session, view } = sessionOf(id, req);
|
||||
const entry = unsupportedReason(view) ? undefined : (await sync.getStore(session.id)).get(entryId);
|
||||
if (!entry) {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'No such edit in this session');
|
||||
}
|
||||
return { success: true, data: { diff: hunksToUnified(entry.hunks), truncated: entry.truncated } };
|
||||
});
|
||||
}
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
findSessionOrFail,
|
||||
getAuthUser,
|
||||
parseBody,
|
||||
resolveCasesDir,
|
||||
validateSessionFilePath,
|
||||
validateSessionFilePathLexical,
|
||||
} from '../route-helpers.js';
|
||||
@@ -74,7 +75,12 @@ import { parseByteRange } from '../http-range.js';
|
||||
import { isSensitivePath } from '../sensitive-path.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
|
||||
import { FilesystemBrowseQuerySchema, FilesystemPreviewQuerySchema, FileWriteSchema } from '../schemas.js';
|
||||
import {
|
||||
FilesystemBrowseQuerySchema,
|
||||
FilesystemMkdirSchema,
|
||||
FilesystemPreviewQuerySchema,
|
||||
FileWriteSchema,
|
||||
} from '../schemas.js';
|
||||
import {
|
||||
MAX_EDITABLE_BYTES,
|
||||
applyEol,
|
||||
@@ -1448,6 +1454,83 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
};
|
||||
});
|
||||
|
||||
// "New folder" in the path picker: creates ONE folder directly inside a folder the picker may browse.
|
||||
app.post('/api/filesystem/mkdir', async (req, reply): Promise<ApiResponse<{ path: string }>> => {
|
||||
const { path: parentPath, name, sessionId, showHidden } = parseBody(FilesystemMkdirSchema, req.body);
|
||||
// Probed first (#516): the picker's resolver runs realpathSync on this path, which would freeze the
|
||||
// whole server on a mount that stopped answering.
|
||||
if ((await probePathKind(parentPath, { pastCap: true })) === 'unknown') {
|
||||
reply.code(422);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
describeUnknownPath('The folder', parentPath, { pastCap: true })
|
||||
);
|
||||
}
|
||||
const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath(
|
||||
ctx,
|
||||
req,
|
||||
parentPath,
|
||||
sessionId,
|
||||
wantsHiddenPickerEntries(showHidden)
|
||||
);
|
||||
const target = join(resolvedPath, name);
|
||||
if (isBlockedPickerPath(resolvedPath, blockedTrees, true) || isBlockedPickerPath(target, blockedTrees, true)) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Folders cannot be created here');
|
||||
}
|
||||
// The cases directories (shared, and the caller's own in multi-user mode) are what GET /api/cases lists as
|
||||
// local cases: a folder made there is a case with no scaffold, which Link then refuses by name and which
|
||||
// deleting would take the linked folder with. A case there is made through Add Case > New Case.
|
||||
const casesDirs = await Promise.all(
|
||||
[...new Set([CASES_DIR, resolveCasesDir(getAuthUser(req))])].map((dir) =>
|
||||
fs.realpath(dir).catch(() => resolve(dir))
|
||||
)
|
||||
);
|
||||
if (casesDirs.includes(resolvedPath)) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'This is a cases folder: every folder in it is a case. Use Add Case > New Case to make one there.'
|
||||
);
|
||||
}
|
||||
// Like browse, a file is not a place to create in.
|
||||
try {
|
||||
if (!(await fs.stat(resolvedPath)).isDirectory()) throw new Error('not a directory');
|
||||
} catch {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'The folder to create in must be a directory');
|
||||
}
|
||||
try {
|
||||
// Not recursive: the parent was just resolved inside an allowed root, and an existing folder must be a 409.
|
||||
await fs.mkdir(target);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'EEXIST') {
|
||||
reply.code(409);
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `"${name}" already exists in this folder`);
|
||||
}
|
||||
if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'This folder is not writable');
|
||||
}
|
||||
if (code === 'ENOENT') {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${candidatePath}`);
|
||||
}
|
||||
if (code === 'ENAMETOOLONG') {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Folder name is too long');
|
||||
}
|
||||
// Never the errno text: it names the resolved, symlink-followed path, not the one the picker shows.
|
||||
reply.code(500);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
`Could not create the folder${code ? ` (${code})` : ''}`
|
||||
);
|
||||
}
|
||||
return { success: true, data: { path: join(candidatePath, name) } };
|
||||
});
|
||||
|
||||
// Inline preview for files selected through the root-confined filesystem picker.
|
||||
app.get('/api/filesystem/preview', { compress: false }, async (req, reply): Promise<void> => {
|
||||
const { path: requestedPath, sessionId, showHidden } = parseBody(FilesystemPreviewQuerySchema, req.query);
|
||||
|
||||
@@ -16,6 +16,7 @@ export { registerInboxRoutes } from './inbox-routes.js';
|
||||
export { registerRebootRestoreRoutes } from './reboot-restore-routes.js';
|
||||
export { registerReadMyMindRoutes } from './readmymind-routes.js';
|
||||
export { registerGitStatusRoutes } from './git-status-routes.js';
|
||||
export { registerEditHistoryRoutes } from './edit-history-routes.js';
|
||||
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
|
||||
export { registerCaseRoutes } from './case-routes.js';
|
||||
export { registerWorktreeRoutes } from './worktree-routes.js';
|
||||
@@ -35,6 +36,7 @@ export { registerTabLayoutRoutes } from './tab-layout-routes.js';
|
||||
export { registerMcpSyncRoutes } from './mcp-sync-routes.js';
|
||||
export { registerConfigBackupRoutes, runConfigBackup } from './config-backup-routes.js';
|
||||
export { registerWebhookRoutes } from './webhook-routes.js';
|
||||
export { registerDiscordRoutes } from './discord-routes.js';
|
||||
export { registerDoctorRoutes } from './doctor-routes.js';
|
||||
export {
|
||||
registerCustomModelRoutes,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { claudeProjectDirName } from '../../case-rename.js';
|
||||
import { FastifyInstance, type FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { join, dirname, extname, basename, resolve as resolvePath } from 'node:path';
|
||||
import { join, dirname, extname, basename, isAbsolute, resolve as resolvePath } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { execFile } from 'node:child_process';
|
||||
@@ -70,8 +70,9 @@ import {
|
||||
SessionWaitOutputQuerySchema,
|
||||
CustomModelSelectionSchema,
|
||||
} from '../schemas.js';
|
||||
import { readCustomModelHosts } from '../../custom-model-hosts.js';
|
||||
import { readCustomModelHosts, resolveCustomModelContextLength } from '../../custom-model-hosts.js';
|
||||
import { applyCustomModelInjection, removeConfigDir } from '../../custom-model-injection-apply.js';
|
||||
import { buildCustomModelInjection } from '../../custom-model-injection.js';
|
||||
import {
|
||||
getLlamaSwapStatus,
|
||||
triggerLlamaSwapLoad,
|
||||
@@ -126,7 +127,7 @@ import { agentInbox } from '../agent-inbox.js';
|
||||
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
|
||||
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
|
||||
import type { AgentSkillDir, NewlineSequence } from '../../config/cli-registry/types.js';
|
||||
import type { AgentSkillDir, CliEntry, NewlineSequence } from '../../config/cli-registry/types.js';
|
||||
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
|
||||
import { applyLaunchDefaults } from '../launch-defaults.js';
|
||||
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
|
||||
@@ -230,6 +231,39 @@ function landedInGroup(layout: TabLayout | undefined, sessionId: string): boolea
|
||||
return !!layout?.groups.some((group) => group.refs.some((ref) => ref.kind === 'session' && ref.id === sessionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard a session-supplied configDir home BEFORE `applyCustomModelInjection` runs.
|
||||
*
|
||||
* The apply resolves the CLI's real home from this session's env override and then runs
|
||||
* synchronous `existsSync`/`mkdirSync` on it (deepseek `DSH_HOME`, and any future
|
||||
* `linkFromRealHome` recipe). A path on an unreachable network mount makes a synchronous
|
||||
* stat wait forever, and this runs INSIDE a request handler, so it would freeze the whole
|
||||
* web server (CLAUDE.md, the #516 rule on user-chosen paths). A relative home is left to
|
||||
* the apply, which already refuses to join non-absolute values.
|
||||
*
|
||||
* Probe the session's own home with `probePathKind(..., { pastCap: true })` first: on
|
||||
* `'unknown'` (a dead mount that never answered) hand the apply a lookup that hides that
|
||||
* one env var, so linking falls through to the server's own environment and the unresponsive
|
||||
* path is never touched. Any other entry's lookup passes through untouched.
|
||||
*/
|
||||
export async function guardSessionConfigDirHome(
|
||||
entry: Pick<CliEntry, 'capabilities'>,
|
||||
baseLookup: (envVar: string) => string | undefined
|
||||
): Promise<(envVar: string) => string | undefined> {
|
||||
const recipe = entry.capabilities.customModelInjection;
|
||||
if (recipe?.kind !== 'configDir' || !recipe.linkFromRealHome?.length) return baseLookup;
|
||||
const dirEnvVar = recipe.dirEnvVar;
|
||||
const supplied = baseLookup(dirEnvVar)?.trim();
|
||||
if (!supplied || !isAbsolute(supplied)) return baseLookup;
|
||||
if ((await probePathKind(supplied, { pastCap: true })) === 'unknown') {
|
||||
console.warn(
|
||||
`[custom-model] ${dirEnvVar} (${supplied}) did not answer; linking from the server's own home instead`
|
||||
);
|
||||
return (envVar: string) => (envVar === dirEnvVar ? undefined : baseLookup(envVar));
|
||||
}
|
||||
return baseLookup;
|
||||
}
|
||||
|
||||
/**
|
||||
* The case a POST /api/sessions `workingDir` belongs to, for its default session
|
||||
* group: a direct child of the caller's case space, or (admin / single-user, as
|
||||
@@ -1541,9 +1575,36 @@ export function registerSessionRoutes(
|
||||
if (!endpoint) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found');
|
||||
}
|
||||
const contextLength = endpoint.modelContextLengths?.[body.modelId];
|
||||
// Discovery's per-model figure wins; the endpoint's user-set default covers models
|
||||
// whose size discovery could not determine (a gateway that states none, say). With
|
||||
// neither, the existing "no override, CLI assumes its own default" behaviour stands.
|
||||
const contextLength = resolveCustomModelContextLength(endpoint, body.modelId);
|
||||
const slotCount = endpoint.modelSlotCounts?.[body.modelId];
|
||||
|
||||
// A CLI whose config alone cannot select the model also gets its `model` launch param
|
||||
// forced (pi/omp `custom/<id>`, grok's block name, and codex now carries its bare
|
||||
// selected id too). The argv engine DROPS a token that
|
||||
// fails its pattern rather than quoting it, which would silently launch the CLI on its
|
||||
// own default provider again, so refuse an id the pattern cannot carry up front.
|
||||
// The check runs on the PURE build result, before applyCustomModelInjection below:
|
||||
// the forced launch id only comes out of that build, and writing first would mean a
|
||||
// refused id already replaced the config dir a live codex session uses as CODEX_HOME.
|
||||
// It also runs before the two confirmations, so nobody agrees to evict a model only
|
||||
// to be told the id was never usable.
|
||||
const modelSpec = entry.launch.params.model;
|
||||
const injection = buildCustomModelInjection(entry, endpoint, body.modelId, contextLength);
|
||||
const forcedLaunchModel = injection.kind === 'unsupported' ? undefined : injection.launchModel;
|
||||
if (
|
||||
forcedLaunchModel !== undefined &&
|
||||
modelSpec?.type === 'token' &&
|
||||
!matchesPattern(modelSpec.pattern, forcedLaunchModel)
|
||||
) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`Model id ${JSON.stringify(body.modelId)} cannot be passed to ${session.mode} on its command line`
|
||||
);
|
||||
}
|
||||
|
||||
// Some CLIs (today: only claude) carry enough of their own fixed system-prompt/tool-
|
||||
// schema overhead that a small enough real context guarantees a first-message failure
|
||||
// no matter what CLAUDE_CODE_MAX_CONTEXT_TOKENS says — confirmed live at ~36.4K tokens
|
||||
@@ -1607,33 +1668,18 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// A CLI whose config alone cannot select the model also gets its `model` launch param
|
||||
// forced (pi/omp `custom/<id>`, grok's block name). The argv engine DROPS a token that
|
||||
// fails its pattern rather than quoting it, which would silently launch the CLI on its
|
||||
// own default provider again, so refuse an id the pattern cannot carry up front.
|
||||
const modelSpec = entry.launch.params.model;
|
||||
const applied = applyCustomModelInjection(
|
||||
entry,
|
||||
endpoint,
|
||||
body.modelId,
|
||||
session.id,
|
||||
contextLength,
|
||||
session.workingDir
|
||||
session.workingDir,
|
||||
await guardSessionConfigDirHome(entry, (envVar) => session.envOverrideValue(envVar))
|
||||
);
|
||||
if (!applied) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${session.mode} has no known custom-model mechanism`);
|
||||
}
|
||||
if (
|
||||
applied.launchModel !== undefined &&
|
||||
modelSpec?.type === 'token' &&
|
||||
!matchesPattern(modelSpec.pattern, applied.launchModel)
|
||||
) {
|
||||
removeConfigDir(applied.configDir);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`Model id ${JSON.stringify(body.modelId)} cannot be passed to ${session.mode} on its command line`
|
||||
);
|
||||
}
|
||||
|
||||
const { previousConfigDir } = session.setCustomModel(
|
||||
{
|
||||
@@ -1683,7 +1729,8 @@ export function registerSessionRoutes(
|
||||
// Called for its existence/ownership 404 side effect only — demoteOrRemoveSession
|
||||
// below re-looks-up the record by id, so the returned SessionState is unused here.
|
||||
findPersistedSessionOrFail(ctx.store, id, req);
|
||||
ctx.store.demoteOrRemoveSession(id);
|
||||
// The edit history goes with the record (same rule as `cleanupSession`).
|
||||
if (ctx.store.demoteOrRemoveSession(id) === 'removed') ctx.forgetEditHistory(id);
|
||||
// A detached session kept its agent inbox (server.ts _doCleanupSession drops it
|
||||
// only on killMux); retiring the row is the delete, so the mail goes now.
|
||||
agentInbox.drop(id);
|
||||
@@ -2874,16 +2921,14 @@ export function registerSessionRoutes(
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
|
||||
app.get('/api/sessions/:id/last-response', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// The body of GET /api/sessions/:id/last-response, so the Discord bridge reads the same transcript
|
||||
// the response viewer does (per-CLI reader, pane fallback) without a second copy of this logic.
|
||||
const lastResponseFor = async (session: Session, context?: string) => {
|
||||
// Codex sessions don't write to ~/.claude/projects — their transcripts
|
||||
// live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the
|
||||
// response-viewer works for Codex panes too.
|
||||
if (getCli(session.mode)?.capabilities.transcript === 'codex-rollout') {
|
||||
const codexQuery = req.query as { context?: string };
|
||||
return await readCodexLastResponse(session, codexQuery.context === 'full');
|
||||
return await readCodexLastResponse(session, context === 'full');
|
||||
}
|
||||
|
||||
// DeepSeek Harness writes a real structured transcript under
|
||||
@@ -2902,8 +2947,7 @@ export function registerSessionRoutes(
|
||||
// would starve on an answer that exists. Those configurations keep the
|
||||
// pane segmenter below: coarse, but the real conversation.
|
||||
if (getCli(session.mode)?.capabilities.transcript === 'deepseek-zstd' && !session.docker && !session.remote) {
|
||||
const deepSeekQuery = req.query as { context?: string };
|
||||
const full = deepSeekQuery.context === 'full';
|
||||
const full = context === 'full';
|
||||
const transcript = await readDeepSeekLastResponse(session, { blocks: full });
|
||||
if (transcript) {
|
||||
return {
|
||||
@@ -2921,7 +2965,6 @@ export function registerSessionRoutes(
|
||||
// the pane IS the transcript for these CLIs. Codex is already handled above,
|
||||
// where a real rollout file is the better source.
|
||||
if (isExternalCliTranscriptMode(session.mode)) {
|
||||
const externalQuery = req.query as { context?: string };
|
||||
// A repainting full-screen TUI (copilot) streams absolute cursor moves, not lines, so the
|
||||
// buffer cannot be segmented; read the pane's visible screen instead, and fall back to the
|
||||
// buffer when there is no tmux pane to ask (direct PTY, tests).
|
||||
@@ -2934,7 +2977,7 @@ export function registerSessionRoutes(
|
||||
text: getLastTranscriptResponse(blocks),
|
||||
timestamp: '',
|
||||
hasContext: blocks.length > 0,
|
||||
messages: externalQuery.context === 'full' ? blocks : undefined,
|
||||
messages: context === 'full' ? blocks : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2960,11 +3003,10 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
const query = req.query as { context?: string };
|
||||
// `turn` is the brief view's context: the last ANSWERED turn's assistant
|
||||
// messages, so a multi-row answer is not reduced to its final row. `text`
|
||||
// stays the last assistant row in every mode (agent pollers hash it).
|
||||
const wantsMessages = query.context === 'full' || query.context === 'turn';
|
||||
const wantsMessages = context === 'full' || context === 'turn';
|
||||
const claudeSessionId = session.claudeSessionId || session.id;
|
||||
const transcript = await findClaudeTranscript(projectsDir, claudeSessionId, session.id);
|
||||
if (!transcript) {
|
||||
@@ -2985,7 +3027,7 @@ export function registerSessionRoutes(
|
||||
try {
|
||||
const content = await fs.readFile(transcript.path, 'utf8');
|
||||
const parsed = parseClaudeResponseTranscript(content, wantsMessages);
|
||||
if (query.context === 'turn') {
|
||||
if (context === 'turn') {
|
||||
return {
|
||||
text: parsed.text,
|
||||
timestamp: parsed.timestamp,
|
||||
@@ -2996,6 +3038,13 @@ export function registerSessionRoutes(
|
||||
} catch {
|
||||
return wantsMessages ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
|
||||
}
|
||||
};
|
||||
ctx.lastResponseFor = lastResponseFor;
|
||||
|
||||
app.get('/api/sessions/:id/last-response', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
return lastResponseFor(session, (req.query as { context?: string }).context);
|
||||
});
|
||||
|
||||
function isCodexInjectedContext(text: string): boolean {
|
||||
@@ -4447,9 +4496,28 @@ export function registerSessionRoutes(
|
||||
const cmHosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR);
|
||||
const cmEndpoint = cmHosts.find((h) => h.id === customModel.endpointId);
|
||||
if (!cmEndpoint) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found');
|
||||
const cmContextLength = cmEndpoint.modelContextLengths?.[customModel.modelId];
|
||||
// Per-model discovery figure first, user-set endpoint default second, nothing third
|
||||
// (see the dedicated route's own comment above).
|
||||
const cmContextLength = resolveCustomModelContextLength(cmEndpoint, customModel.modelId);
|
||||
const cmSlotCount = cmEndpoint.modelSlotCounts?.[customModel.modelId];
|
||||
|
||||
// See the dedicated route: refused on the pure build, before either confirmation, so
|
||||
// nobody agrees to evict a model for an id that could never launch, and before anything
|
||||
// is written.
|
||||
const cmModelSpec = cmEntry.launch.params.model;
|
||||
const cmInjection = buildCustomModelInjection(cmEntry, cmEndpoint, customModel.modelId, cmContextLength);
|
||||
const cmForcedLaunchModel = cmInjection.kind === 'unsupported' ? undefined : cmInjection.launchModel;
|
||||
if (
|
||||
cmForcedLaunchModel !== undefined &&
|
||||
cmModelSpec?.type === 'token' &&
|
||||
!matchesPattern(cmModelSpec.pattern, cmForcedLaunchModel)
|
||||
) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`Model id ${JSON.stringify(customModel.modelId)} cannot be passed to ${mode} on its command line`
|
||||
);
|
||||
}
|
||||
|
||||
// See the dedicated route's own comment for the full reasoning: some CLIs' own fixed
|
||||
// overhead can exceed a small enough real context on the very first message,
|
||||
// regardless of contextLengthVar. Warn before creating a session that's certain to
|
||||
@@ -4514,24 +4582,12 @@ export function registerSessionRoutes(
|
||||
customModel.modelId,
|
||||
qsCustomModelSessionId,
|
||||
cmContextLength,
|
||||
workspacePath
|
||||
workspacePath,
|
||||
await guardSessionConfigDirHome(cmEntry, (envVar) => qsGatedEnvOverrides?.[envVar])
|
||||
);
|
||||
if (!cmApplied) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
|
||||
}
|
||||
const cmModelSpec = cmEntry.launch.params.model;
|
||||
if (
|
||||
cmApplied.launchModel !== undefined &&
|
||||
cmModelSpec?.type === 'token' &&
|
||||
!matchesPattern(cmModelSpec.pattern, cmApplied.launchModel)
|
||||
) {
|
||||
removeConfigDir(cmApplied.configDir);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`Model id ${JSON.stringify(customModel.modelId)} cannot be passed to ${mode} on its command line`
|
||||
);
|
||||
}
|
||||
|
||||
qsCustomModelEnvOverrides = { ...qsGatedEnvOverrides, ...cmApplied.envOverrides };
|
||||
qsCustomModelAppliedEnvOverrides = cmApplied.envOverrides;
|
||||
qsCustomModelLaunchModel = cmApplied.launchModel;
|
||||
|
||||
@@ -856,6 +856,7 @@ export function registerSystemRoutes(
|
||||
// Keeping a pane that died while detached errs toward the 12 h TTL and the boot
|
||||
// prune, the same trade the boot keep set makes.
|
||||
if (!ctx.mux.getSession(s.id)) agentInbox.drop(s.id);
|
||||
ctx.forgetEditHistory(s.id);
|
||||
}
|
||||
return { cleanedSessions: result.count };
|
||||
});
|
||||
|
||||
+81
-1
@@ -20,6 +20,7 @@ import {
|
||||
import { MAX_EDITABLE_BYTES } from '../config/file-editing.js';
|
||||
import { CODEX_REASONING_EFFORTS } from '../types/session.js';
|
||||
import { WEBHOOK_KINDS, WEBHOOK_SCOPES } from '../types/push.js';
|
||||
import { DISCORD_ON_DELETE, DISCORD_PING_MODES, DISCORD_THREAD_CREATION } from '../types/discord.js';
|
||||
import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js';
|
||||
import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js';
|
||||
import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js';
|
||||
@@ -82,6 +83,35 @@ const filesystemPickerPathSchema = z
|
||||
*/
|
||||
const showHiddenQuerySchema = z.enum(['true', 'false']).optional();
|
||||
|
||||
/**
|
||||
* Body for the path picker's "New folder": one folder name created directly inside `path`. The name is held
|
||||
* to the character set `safePathSchema` accepts (letters, digits, `_ - . ~` and space, no `..`), so every
|
||||
* name it accepts is one Link Existing and a session working directory accept (the parent's own path is
|
||||
* judged there separately), and never a control character. It is also one path segment, not dot-prefixed (the picker hides those by default) and not tilde-prefixed (a folder named `~` is an `rm -rf ~` accident), and at
|
||||
* most 255 BYTES, the usual filesystem limit.
|
||||
*/
|
||||
export const FilesystemMkdirSchema = z.object({
|
||||
path: filesystemPickerPathSchema,
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Folder name is required')
|
||||
.refine((n) => Buffer.byteLength(n) <= 255, { message: 'Folder name is too long' })
|
||||
.refine((n) => /^[\p{L}\p{N}_\-. ~]+$/u.test(n), {
|
||||
message: 'Folder name can only use letters, numbers, spaces and _ - . ~',
|
||||
})
|
||||
.refine((n) => !n.includes('..') && !n.startsWith('.') && !n.startsWith('~'), {
|
||||
message: 'Folder name cannot contain "..", or start with a dot or a tilde',
|
||||
}),
|
||||
sessionId: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
|
||||
.optional(),
|
||||
/** Same opt-in flag as browse: lets the picker create inside a hidden folder it is showing. */
|
||||
showHidden: showHiddenQuerySchema,
|
||||
});
|
||||
|
||||
/** Query validation for the lazy, allowlisted filesystem path picker. */
|
||||
export const FilesystemBrowseQuerySchema = z.object({
|
||||
path: filesystemPickerPathSchema.optional(),
|
||||
@@ -785,6 +815,12 @@ export const CloneCaseSchema = z.object({
|
||||
/** `--depth 1`. */
|
||||
shallow: z.boolean().optional(),
|
||||
description: z.string().max(1000).optional(),
|
||||
/**
|
||||
* Clone into this exact folder (the folder itself, no case-name subfolder is added) instead of under the cases directory, and
|
||||
* register it as a linked case. Absolute or starting with `~`; only length-bounded here, since
|
||||
* `prepareNewCasePath()` (web/case-path.ts) judges it and produces the user-facing reason.
|
||||
*/
|
||||
path: z.string().min(1).max(1000).optional(),
|
||||
});
|
||||
|
||||
/** Schema for POST /api/cases/clone-preflight — ask the remote what it has, clone nothing. */
|
||||
@@ -2214,6 +2250,37 @@ export const WebhookUpdateSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* PUT /api/discord. `.strict()` and every field optional, so a change of one option never needs the
|
||||
* token re-sent; an empty `token` clears it. Option lists are the bridge's own, so the schema cannot
|
||||
* accept a value the store would coerce away. Ids are checked again (as snowflakes) by the route.
|
||||
*/
|
||||
export const DiscordUpdateSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
token: z.string().max(200).optional(),
|
||||
guildId: z.string().max(25).optional(),
|
||||
parentChannelId: z.string().max(25).optional(),
|
||||
allowedUserIds: z.array(z.string().max(25)).max(20).optional(),
|
||||
threadCreation: z.enum(DISCORD_THREAD_CREATION).optional(),
|
||||
onSessionDelete: z.enum(DISCORD_ON_DELETE).optional(),
|
||||
staleThreads: z.enum(DISCORD_ON_DELETE).optional(),
|
||||
pingMode: z.enum(DISCORD_PING_MODES).optional(),
|
||||
scope: z.enum(WEBHOOK_SCOPES).optional(),
|
||||
startupHint: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** POST /api/sessions/:id/discord-notify */
|
||||
export const DiscordNotifySchema = z
|
||||
.object({
|
||||
message: z.string().min(1).max(1500),
|
||||
urgent: z.boolean().optional(),
|
||||
/** Absolute path of a file inside the session's working folder, uploaded with the post. */
|
||||
file: z.string().min(1).max(1024).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// ========== Ralph Loop ==========
|
||||
|
||||
/** POST /api/ralph-loop/start */
|
||||
@@ -2352,6 +2419,14 @@ export const WebviewUpdateSchema = WebviewBaseSchema.partial();
|
||||
/** POST /api/webviews/probe: reachability + framing check for the editor's Test button. */
|
||||
export const WebviewProbeSchema = z.object({ url: webviewUrlSchema });
|
||||
|
||||
/**
|
||||
* Ceiling on a stated context length (tokens), shared between the bounds below and
|
||||
* discovery (`custom-model-routes.ts`): anything above it reads as nonsense and is
|
||||
* ignored, because a bogus figure here overflows the smaller server, which is the exact
|
||||
* failure the discovery field exists to prevent.
|
||||
*/
|
||||
export const MAX_DISCOVERED_CONTEXT_TOKENS = 100_000_000;
|
||||
|
||||
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) — a
|
||||
// user-configured custom OpenAI-compatible endpoint, local (llama.cpp) or cloud
|
||||
// (Azure AI Foundry, etc.). Lives below `webviewUrlSchema` because `baseUrl` IS that
|
||||
@@ -2375,11 +2450,16 @@ export const CustomModelHostSchema = z.object({
|
||||
defaultModelId: z.string().max(200).optional(),
|
||||
// Server-populated by discovery (custom-model-routes.ts); accepted here only so a client
|
||||
// round-tripping the GET response back through PUT (edit-save) doesn't drop it.
|
||||
modelContextLengths: z.record(z.string().max(200), z.number().int().positive().max(100_000_000)).optional(),
|
||||
modelContextLengths: z
|
||||
.record(z.string().max(200), z.number().int().positive().max(MAX_DISCOVERED_CONTEXT_TOKENS))
|
||||
.optional(),
|
||||
// Same reasoning as modelContextLengths above.
|
||||
modelSlotCounts: z.record(z.string().max(200), z.number().int().positive().max(4096)).optional(),
|
||||
// Same reasoning as modelContextLengths above.
|
||||
modelSizesGB: z.record(z.string().max(200), z.number().positive().max(100_000)).optional(),
|
||||
// User-set fallback for models discovery has no context length for (session-routes.ts);
|
||||
// discovery never writes it. Same bound as the modelContextLengths values above.
|
||||
defaultContextLength: z.number().int().positive().max(MAX_DISCOVERED_CONTEXT_TOKENS).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -102,6 +102,21 @@ const SENSITIVE_PATTERNS: RegExp[] = [
|
||||
/\/\.codeman[^/]*\/intents\.json$/,
|
||||
// Pending agent-inbox messages (opt-in snapshot): same text as prompts, same 0600.
|
||||
/\/\.codeman[^/]*\/agent-inbox\.json$/,
|
||||
// The edit history panel's stores (edit-history-store.ts) hold every diff an
|
||||
// agent wrote, keys and .env contents included; written 0600 and kept out of
|
||||
// /api/search for the same reason as intents.json.
|
||||
/\/\.codeman[^/]*\/edit-history\//,
|
||||
// Credentials kept outside settings.json: the Discord bot token, the webhook URL
|
||||
// (a bearer secret), the custom endpoints' API keys, and the generated per-session
|
||||
// configs, some of which carry that key inline.
|
||||
/\/\.codeman[^/]*\/discord\.json$/,
|
||||
/\/\.codeman[^/]*\/webhook\.json$/,
|
||||
/\/\.codeman[^/]*\/custom-model-hosts\.json$/,
|
||||
/\/\.codeman[^/]*\/custom-model-configs\//,
|
||||
// Config backups are dated COPIES of the files above (users.json and settings.json
|
||||
// included), which the name-anchored patterns do not match. A custom
|
||||
// `configBackupDir` is outside what a pattern here can name.
|
||||
/\/\.codeman[^/]*\/backups\//,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user