mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-23 10:29:33 +02:00
Compare commits
113
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
002de32a08 | ||
|
|
a85da87378 | ||
|
|
463a083a23 | ||
|
|
3b6818449e | ||
|
|
e8fc08beb3 | ||
|
|
9045b18553 | ||
|
|
dda16d3e2a | ||
|
|
3198a6972c | ||
|
|
33d697afe3 | ||
|
|
0c1578581f | ||
|
|
34cf798b10 | ||
|
|
9b8cb82c3b | ||
|
|
a3efc9b963 | ||
|
|
f80f107445 | ||
|
|
3652670a7d | ||
|
|
3ca1d4832e | ||
|
|
5a677bf03c | ||
|
|
9d30a7ff36 | ||
|
|
04faa0d3f1 | ||
|
|
6d2763d50b | ||
|
|
6310868696 | ||
|
|
b4275e690e | ||
|
|
cef64dc052 | ||
|
|
b67018b318 | ||
|
|
6eaaa960ff | ||
|
|
620e7728ec | ||
|
|
6cf5ab05d6 | ||
|
|
fde23e3a07 | ||
|
|
ebb50ef53a | ||
|
|
a198b815d2 | ||
|
|
6fb9c4ed8a | ||
|
|
8a216df12f | ||
|
|
73ff2339d8 | ||
|
|
df521267ae | ||
|
|
922b077cc9 | ||
|
|
0952a2ee47 | ||
|
|
a02a5879ca | ||
|
|
76c17e5c4e | ||
|
|
d38291b143 | ||
|
|
0fcf52d38a | ||
|
|
6c97d63499 | ||
|
|
0ca64f8256 | ||
|
|
2a56b2a997 | ||
|
|
a56038ebef | ||
|
|
f334b59dd3 | ||
|
|
e408fa21bc | ||
|
|
6d1a542018 | ||
|
|
7db4d90bb6 | ||
|
|
0fa79b37ed | ||
|
|
ac8409920b | ||
|
|
71bf7288e0 | ||
|
|
30a60f7bb5 | ||
|
|
28a7c9bdf5 | ||
|
|
74c980413a | ||
|
|
63cada938d | ||
|
|
d56b90b7c6 | ||
|
|
242bbfcd31 | ||
|
|
8c8ff383c2 | ||
|
|
b9b6ce57b7 | ||
|
|
9a00c2feaa | ||
|
|
2fe9105bab | ||
|
|
302e2660be | ||
|
|
c3fa6d8923 | ||
|
|
f17aa047d0 | ||
|
|
e58c1a2f19 | ||
|
|
2b9aa055cd | ||
|
|
157752b4f0 | ||
|
|
00d89ff828 | ||
|
|
74dcf9a59d | ||
|
|
cf9511cb57 | ||
|
|
3b747774f1 | ||
|
|
ced88b3310 | ||
|
|
b073035bdc | ||
|
|
225fdbf505 | ||
|
|
cf999c1296 | ||
|
|
3392a69290 | ||
|
|
f06635512a | ||
|
|
fe10cad83f | ||
|
|
e3de354461 | ||
|
|
3bd7e81233 | ||
|
|
04eb1ed158 | ||
|
|
8a4ff775fb | ||
|
|
9c0fc30927 | ||
|
|
241886d44d | ||
|
|
c982fa1ce5 | ||
|
|
c5f1e99036 | ||
|
|
71fcefbaf2 | ||
|
|
a0b9570457 | ||
|
|
8b27e4beb6 | ||
|
|
247740fc77 | ||
|
|
a2e49eb26d | ||
|
|
06182c6fce | ||
|
|
3756f8b5c2 | ||
|
|
f2230d5d3d | ||
|
|
39077281d1 | ||
|
|
131ee1a31e | ||
|
|
e3c6f9f2c7 | ||
|
|
6e7dee75eb | ||
|
|
83d80578e4 | ||
|
|
5db6c633d3 | ||
|
|
71a51df2e2 | ||
|
|
6f785e116f | ||
|
|
971192ce02 | ||
|
|
dbb41c70cf | ||
|
|
2cd0d82544 | ||
|
|
1b6c2664b6 | ||
|
|
08e57ed708 | ||
|
|
8cb3441dd9 | ||
|
|
710ed73585 | ||
|
|
1802bafc74 | ||
|
|
686ce5ac02 | ||
|
|
ef2001b1c1 | ||
|
|
39d7c01b7b |
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 only inside a Codeman-managed session.",
|
||||
"version": "1.43.0",
|
||||
"version": "1.44.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
+3
-2
@@ -8,8 +8,9 @@ DECISIONS.md
|
||||
# Written by install.sh into end-user clones when setup finishes
|
||||
.install-complete
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
# Dependencies. No trailing slash: `node_modules/` matches only a directory, so a
|
||||
# worktree's `node_modules` symlink to a shared install got committed (#658, #662).
|
||||
node_modules
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.44.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ### Thanks
|
||||
- @opticon454 for five PRs: config backups you can compare and restore (#661, ten review rounds), a File Viewer you can resize, expand and walk Back and Forward through (#659), GitHub Copilot's premium-request quota in the plan-usage chip (#662), a warning when llama.cpp's parallel slots leave too little context per request (#658, from #641), and a bare `npx vitest` that can no longer write into your real `~/.codeman` (#660, from #640).
|
||||
- @JDProfresh for four PRs: the opt-in prompt-cache keep-alive (#666, step 2 of #554), Markdown links to file paths that open in the File Viewer (#665), a phone keyboard that stays up while you press accessory keys (#673), and a session swipe that no longer taps or scrolls the session you are leaving (#674), with the iPhone testing behind both phone fixes.
|
||||
- @christianhaberl for four PRs: `CODEMAN_MAX_SESSIONS` (#668), tmux prefix keys that can no longer move a browser tab onto another session (#669), multi-line prompts that keep their line breaks (#670), and rediscovered tmux sessions that keep their own folder (#667). Also for the earlier #224, #225, #226 and the session sidebar (#307), whose commits carried an agent identity that GitHub credits to another account.
|
||||
- @johoja12, a first-time contributor, for scroll batches that stay with the tab that made them, smoother trackpad scrolling and wheel scrolling in fullscreen Codex (#645), measured against the real CLI through five review rounds.
|
||||
- @ludovicfourrage for touch scrolling in apps that track the mouse themselves (#664), and the careful write-up in #663.
|
||||
- @aakhter for a default tab group per case (#676).
|
||||
|
||||
**Config backups (#661).** Codeman now keeps timestamped copies of the files that hold your setup: settings, CLI overrides, custom model endpoints, the webhook, linked cases and, in multi-user mode, the accounts. It takes one at startup and whenever one of them changes, into `~/.codeman/backups/config` by default. App Settings → System lets you pick the folder and the retention (keep N backups, drop ones older than N days), back up now, compare two backups or a backup with what is running (credentials are never shown, only whether they changed), and restore one. A restore first saves what it is about to replace, so it can be undone, and retention only ever deletes folders Codeman made and never the newest backup.
|
||||
|
||||
**Keep an idle Claude session's prompt cache warm (#666, opt-in).** Claude Code's prompt cache expires after an hour of quiet, and the next prompt then pays to re-read the whole conversation. Switch on **Keep the cache warm while idle** in a session's Respawn tab (or **Keep prompt caches warm** in App Settings → System → Automation for every new session) and Codeman sends a one-line keep-alive turn about five minutes before the cache would expire. It only does that when nothing else is going on: an empty prompt, no open dialog, nobody typing, no respawn, Ralph loop or waiting agent, no usage-limit pause. It stops on its own a while after your last prompt (**Keep warm for at most**, default 2 hours). A session started with it on also launches with Claude Code's own idle compaction off (Claude Code 2.1.290 or newer). The keep-alive raises no alert, push, webhook or Away-digest row, and the readout's Status line says what it is doing.
|
||||
|
||||
**A default tab group per case (#676).** Case settings has a **Default session group**: every new session for that case lands straight in that tab group, in the same write that creates it, so it never flashes in Ungrouped first. An explicit group on the create request and a spawned child following its parent still win. It is stored per user and survives a group rename.
|
||||
|
||||
**A File Viewer you can resize, with Back and Forward (#659).** Drag the corner grip to resize the window, or press Expand to fill the screen (remembered on this device). Following a link from one file to another builds a history that the ← and → buttons and Alt+← / Alt+→ walk through. Phones keep the full-screen viewer.
|
||||
|
||||
**Copilot's premium-request quota in the plan-usage chip (#662).** With the GitHub Copilot CLI installed and signed in, the header chip gains a Copilot row for the month's premium requests, with the counts and the reset date in the tooltip. Codeman reads it every 10 minutes from GitHub with the Copilot CLI's own sign-in token. It is on by default, like the Codex row, and turning Plan Usage Limits off in App Settings stops the request.
|
||||
|
||||
**More (or fewer) sessions with `CODEMAN_MAX_SESSIONS` (#668).** The cap of 50 concurrent sessions can be set at startup, from 1 to 462. A value that is not a positive integer falls back to 50, and the per-user cap in multi-user mode follows it unless `CODEMAN_MAX_SESSIONS_PER_USER` is set.
|
||||
|
||||
**Multi-line prompts keep their line breaks (#670).** A prompt sent through the API with `useMux` used to arrive as one welded line (`line oneline two`). Its lines now go into the pane as one bracketed paste, followed by the Enter that submits them together. The senders that run unattended (cron, respawn's update and kickstart prompts, the auto-compact focus, `codeman agent send`, the TUI and the skill's `sendwait`) stay single-line and join lines with a space, because the submit check cannot yet recognise a multi-line paste in the composer.
|
||||
|
||||
**A warning for llama.cpp slots that split the context too thin (#658).** A custom llama.cpp model that runs several parallel slots splits its context between them, and four 40K-token Claude requests against a 4-slot, 131K pool all failed with "Context size has been exceeded". Discovery now records each model's slot count, and a Claude launch warns when one slot gets less than 40K tokens, naming the fix (`--parallel 1`, or a context large enough for every slot).
|
||||
|
||||
**Fixes.**
|
||||
- **tmux prefix keys stay out of browser tabs (#669).** Ctrl+B then `)`, `(`, `s` or `w` in a tab switched its tmux client to another session while the tab kept the first one's name. Codeman's tmux socket now keeps only Ctrl+B Ctrl+B (send-prefix) in its prefix table, set at session start and at boot. This also means `Ctrl+B d` no longer detaches a terminal attached to that socket: `codeman tui` and `scripts/tmux-manager.sh` detach with F1.
|
||||
- **Scrolling stays with its tab, and fullscreen Codex scrolls (#645).** A queued wheel batch can no longer land in a tab you switched to, trackpads keep their fractional travel instead of dropping it, the first wheel report of a tab visit goes out at once, and Codex gets wheel reports while it has mouse tracking on (measured on Codex 0.163.0, where an approval prompt's selection is unaffected).
|
||||
- **Touch scrolling in apps that track the mouse (#664).** A finger drag and its fling now scroll a program in the pane that asked for SGR mouse reports (a TUI in a shell tab, for example) instead of scrolling a local buffer that cannot show it. Other mouse encodings are left alone.
|
||||
- **A session swipe only switches sessions (#674).** A sideways swipe on the terminal no longer also opens a link, taps a CLI menu or toggles the keyboard on the session you are leaving. A deliberate swipe gets 500 ms instead of 300, a selection or a second finger pauses it, and a scroll that starts on a sideways thumb arc still scrolls.
|
||||
- **The phone keyboard stays up (#673).** Arrow, Tab, Esc and the other accessory keys that act on the terminal no longer blur it for a moment, so the toolbar stops bouncing on every tap.
|
||||
- **Markdown links to files open in the File Viewer (#665).** A link like Codex's `[label](/abs/path.json)` in the response viewer now opens the file instead of a same-origin 404 tab, and its label keeps the prose font.
|
||||
- **Rediscovered sessions keep their folder (#667).** A tmux session Codeman finds again on its socket (after `mux-sessions.json` was lost) now gets the directory tmux reports for its pane instead of the server's own, so a respawn goes back to the right project.
|
||||
- **Web tabs send POST bodies to dashboards that read Content-Length.** Codeman's web-tab proxy now relays the browser's Content-Length, so a dashboard built on Python's `http.server` or a small Go or C server no longer sees an empty body on every POST made through a web tab.
|
||||
|
||||
**Fixes applied while landing.** A restore that fails partway now says which files it restored, which it could not and where the pre-restore copy is, re-reads what was written and refills the Settings form; backup passes list the manifests once, and a pre-restore copy equal to the newest backup is not made twice (#661). A failed keep-alive write is retried before the expiry instead of never, an unreadable `settings.json` is logged once instead of every minute for every session, the keep-alive's own hooks stay out of the run summary, and saving App Settings re-plans keep-alives only when their settings changed (#666). The Copilot quota read checks the setting and the sign-in token before it ever probes for the CLI, so a machine without Copilot runs no login shell every 10 minutes, and drops a header the Copilot CLI itself does not send (#662). The prefix-table reset still binds send-prefix on an empty table and no longer reruns on every session for a prefix tmux lists escaped (#669). Claude worktree sessions follow the case's default group like every other CLI's (#676). The remote-history PATH change that rode along in #645 was left for its own PR. Smaller touches: the Expand button keeps its label while pressed and Alt+← / Alt+→ are listed in the shortcut overlay and help (#659); the scroll log names xterm's mouse encoding (#664); the response-viewer link test covers `?query` and folder links (#665).
|
||||
|
||||
**Docs and tests.** A bare `npx vitest run` from the repo now uses the isolated CI config instead of running without the setup file, the helpers that delete test folders refuse to run outside a sandboxed HOME, and the temp-HOME teardown is bounded so a stuck writer cannot time the hook out (#660). New sections and lines for config backups, the keep-alive and the Copilot quota in the architecture notes and `CLAUDE.md`, a test that a deleted pane directory is never adopted (#667), the codeman skill preamble re-stamped 1.44.0, and browser tests for the touch-scroll and swipe fixes.
|
||||
|
||||
## 1.43.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
>
|
||||
> **This file is in `.prettierignore` on purpose.** Prettier's markdown printer escapes underscores inside the glob-heavy paths used throughout (`agent-*.jsonl` became `agent-\_.jsonl`, collapsing backtick spans and corrupting a whole paragraph). Do not remove the ignore entry, and do not run `prettier --write` on it.
|
||||
>
|
||||
> **Repo root is kept short on purpose** (the README sits below the file listing on GitHub). Config lives in `config/` (`eslint.config.js`, `knip.json`, the vitest configs), Prettier's config is the `"prettier"` key in `package.json`, and `SECURITY.md` is under `.github/`. Root-only files are the ones tools genuinely require there: `CLAUDE.md` + `AGENTS.md` (loaded from the root by Claude Code / Codex), `CHANGELOG.md` (changesets writes it next to `package.json`), `tsconfig.json`, `.editorconfig`, `.nvmrc`/`.npmrc`, `.prettierignore` (resolved relative to cwd), `LICENSE` (GitHub detection), `.dockerignore` (the build context is the repo root, so Docker resolves it there and nowhere else) and `install.sh` (its raw URL is the published install one-liner). `.claude-plugin/marketplace.json` is root-only for the same reason: `/plugin marketplace add Ark0N/Codeman` reads it from the repo root and nowhere else, which makes the repo its own plugin marketplace. The one plugin it lists is `plugins/codeman/` (manifest + README + a MIRROR of `skills/codeman/`), and ⚠️ the plugin is a small separate directory on purpose: `claude plugin install` copies the plugin root into its cache, and a plugin root that carries a `package.json` gets an **npm install** at install time (measured with the repo root as plugin root: 832 MB, 511 packages and this repo's postinstall on every installer's machine), while a symlink to `skills/codeman` would dangle in the copy. `skills/codeman/` stays the single source; `scripts/sync-plugin.mjs` mirrors it and writes `package.json`'s version into both manifests inside `version-packages`, and `test/plugin-manifest.test.ts` pins the byte-identity, the versions, the absence of a `package.json` in the plugin root and that no other component (`commands/`, `agents/`, `hooks/`, `.mcp.json`, `settings.json`) rides along. `npm run check:plugin` runs that drift check plus both strict validations with EXPLICIT paths (`plugins/codeman`, `.claude-plugin/marketplace.json`): a bare `.` argument copied out of prose reads as a full stop and gets dropped, which surfaces as `missing required argument 'path'`. Needs the `claude` CLI, so it is a local check, not a CI step. Don't relocate those.
|
||||
> **Repo root is kept short on purpose** (the README sits below the file listing on GitHub). Config lives in `config/` (`eslint.config.js`, `knip.json`, the vitest configs), Prettier's config is the `"prettier"` key in `package.json`, and `SECURITY.md` is under `.github/`. Root-only files are the ones tools genuinely require there: `CLAUDE.md` + `AGENTS.md` (loaded from the root by Claude Code / Codex), `CHANGELOG.md` (changesets writes it next to `package.json`), `tsconfig.json`, `.editorconfig`, `.nvmrc`/`.npmrc`, `.prettierignore` (resolved relative to cwd), `vitest.config.ts` (a one-line re-export of `config/vitest.ci.config.ts`: vitest finds its config by walking up from cwd, so without it a bare `npx vitest` ran with no setup file and wrote into the real `~/.codeman`, #640), `LICENSE` (GitHub detection), `.dockerignore` (the build context is the repo root, so Docker resolves it there and nowhere else) and `install.sh` (its raw URL is the published install one-liner). `.claude-plugin/marketplace.json` is root-only for the same reason: `/plugin marketplace add Ark0N/Codeman` reads it from the repo root and nowhere else, which makes the repo its own plugin marketplace. The one plugin it lists is `plugins/codeman/` (manifest + README + a MIRROR of `skills/codeman/`), and ⚠️ the plugin is a small separate directory on purpose: `claude plugin install` copies the plugin root into its cache, and a plugin root that carries a `package.json` gets an **npm install** at install time (measured with the repo root as plugin root: 832 MB, 511 packages and this repo's postinstall on every installer's machine), while a symlink to `skills/codeman` would dangle in the copy. `skills/codeman/` stays the single source; `scripts/sync-plugin.mjs` mirrors it and writes `package.json`'s version into both manifests inside `version-packages`, and `test/plugin-manifest.test.ts` pins the byte-identity, the versions, the absence of a `package.json` in the plugin root and that no other component (`commands/`, `agents/`, `hooks/`, `.mcp.json`, `settings.json`) rides along. `npm run check:plugin` runs that drift check plus both strict validations with EXPLICIT paths (`plugins/codeman`, `.claude-plugin/marketplace.json`): a bare `.` argument copied out of prose reads as a full stop and gets dropped, which surfaces as `missing required argument 'path'`. Needs the `claude` CLI, so it is a local check, not a CI step. Don't relocate those.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
@@ -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.43.0 (must match `package.json`)
|
||||
**Version**: 1.44.0 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -88,7 +88,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink. ⚠️ **Input must END with `\r` or Enter is never sent**: `sendInput()` only issues `send-keys Enter` when the payload contains a carriage return, a `\r`-less `POST /api/sessions/:id/input` still succeeds (send-and-wait even reports `delivered:true`) while the text sits unsubmitted on the composer, and any `wait` burns its whole timeout on a turn that never started. Embedded newlines are stripped, not rejected, so `"echo A\necho B\r"` runs the joined `echo Aecho B`. ⚠️ **Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints** while still taking the typed text (measured 2026-09-19: an Enter at 28 s stranded the prompt, one at 51 s submitted it), so text+`\r` sent at readiness sits unsent with `0 tokens` and a `wait` burns its timeout. So the SERVER verifies every programmatic write that carried a `\r`: `SubmitVerifier` (`session-submit-verifier.ts`, armed from `writeViaMux`) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's own `promptGlyph`) verifiably still holds the head of what was sent; an empty composer, other text, or no composer line at all (a shell, a direct-PTY session) ends it, and a newer write replaces the schedule. The skill's `sendwait` keeps its own copy of the loop (`_composer_text` in `skills/codeman/preamble.sh`) for servers that predate this. The `shift+tab` footer only means the composer painted, never that Enter is accepted. ⚠️ **A prompt must never be written into the pane as ONE burst**: Claude Code 2.1.283 takes a `<text>\r` burst of ~100+ chars as a paste, its `\r` lands as a NEWLINE and the prompt strands (a later raw `\r` does not recover it, a tmux `send-keys Enter` does). So `POST .../input` routes a plain prompt (`isPlainPromptInput()`, route-helpers.ts: printable text + exactly one trailing `\r`) through `writeViaMux` even without `useMux`, AWAITED so the browser's serialized POST fallback keeps frame order; raw frames and an explicit `useMux:false` keep the direct write
|
||||
- **A prompt is one paste plus Enter** — `writeViaMux()` sends the text as one bracketed paste and Enter separately. ⚠️ **Input must END with `\r` or Enter is never sent**: `sendInput()` only issues `send-keys Enter` when the payload contains a carriage return, a `\r`-less `POST /api/sessions/:id/input` still succeeds (send-and-wait even reports `delivered:true`) while the text sits unsubmitted on the composer, and any `wait` burns its whole timeout on a turn that never started. Embedded newlines are kept (`\r\n` becomes `\n`): all lines go in as that ONE paste and the Enter after it submits them together, so `"echo A\necho B\r"` runs both commands in a shell (bash holds the pasted lines until the Enter; a shell without bracketed paste runs each line as it arrives). Multi-line text WITHOUT `\r` is pasted the same way; on a pane that has not enabled bracketed paste (POSIX sh, macOS `/bin/bash` 3.2) tmux delivers every line break as a CR, so every line but the last runs, where the old line-stripping typed the joined text and ran nothing. Leading and trailing line breaks are dropped. ⚠️ `SubmitVerifier` cannot reliably recognise a multi-line paste in the composer: it matches one Claude shows inline whose first line has 24+ non-space characters, but misses a short first line and the `[Pasted text #N +k lines]` placeholder, so it may not re-press a stranded Enter for one. ⚠️ **Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints** while still taking the typed text (measured 2026-09-19: an Enter at 28 s stranded the prompt, one at 51 s submitted it), so text+`\r` sent at readiness sits unsent with `0 tokens` and a `wait` burns its timeout. So the SERVER verifies every programmatic write that carried a `\r`: `SubmitVerifier` (`session-submit-verifier.ts`, armed from `writeViaMux`) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's own `promptGlyph`) verifiably still holds the head of what was sent; an empty composer, other text, or no composer line at all (a shell, a direct-PTY session) ends it, and a newer write replaces the schedule. The skill's `sendwait` keeps its own copy of the loop (`_composer_text` in `skills/codeman/preamble.sh`) for servers that predate this. The `shift+tab` footer only means the composer painted, never that Enter is accepted. ⚠️ **A prompt must never be written into the pane as ONE burst**: Claude Code 2.1.283 takes a `<text>\r` burst of ~100+ chars as a paste, its `\r` lands as a NEWLINE and the prompt strands (a later raw `\r` does not recover it, a tmux `send-keys Enter` does). So `POST .../input` routes a plain prompt (`isPlainPromptInput()`, route-helpers.ts: printable text + exactly one trailing `\r`) through `writeViaMux` even without `useMux`, AWAITED so the browser's serialized POST fallback keeps frame order; raw frames and an explicit `useMux:false` keep the direct write
|
||||
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
|
||||
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`)
|
||||
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
|
||||
@@ -102,6 +102,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
- **`xterm-zerolag-input` is single-source** — BOTH echo addons live ONLY in `packages/xterm-zerolag-input/src/`, bundled into TWO **gitignored** vendor files: `vendor/xterm-zerolag-input.js` (buffer overlay, entry `zerolag-input-addon.ts`) and `vendor/xterm-predictive-echo.js` (codex write-through, entry `predictive-echo-addon.ts`) — dev by `scripts/postinstall.js`, prod by `scripts/build.mjs`. `app.js`/terminal-ui.js only **consume** them via `new LocalEchoOverlay(terminal)` / `new PredictiveEchoOverlay(terminal)`; there is no inline copy. So: change the package source, then rerun the bundle step (`npm install` for dev, `npm run build` for prod). **Never hand-edit `app.js` for overlay behavior, and never commit the gitignored vendor bundles.** Always test on mobile after touching it. → [architecture-invariants#xterm-zerolag-input-is-single-source](docs/architecture-invariants.md#xterm-zerolag-input-is-single-source), `docs/local-echo-overlay-plan.md`
|
||||
- **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md`
|
||||
- **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`, and **any new `tmux -L` caller through `resolveTmuxSocketName()`** (both in `config/instance.ts`): the TUI shells out to tmux from a second process, and a hardcoded `codeman` there would point a beta instance at prod's panes. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger)
|
||||
- **The prefix table on Codeman's socket holds only `<prefix> send-prefix`** — browser tabs are tmux attach clients, and tmux's stock bindings let Ctrl+B `)`/`s` move a tab onto another session. `createSession` resets the table, and `reconcileSessions()` at boot for sessions that survived an upgrade (`src/tmux-prefix-table.ts`); `root` and `C-<letter> detach-client` (the TUI attach's way out) are left alone, so while a `codeman tui` attach is open its `root` keys reach browser tabs too, Alt+1..9 `switch-client` included (open follow-up). ⚠️ So `Ctrl+B d` does not exist there: anything attaching a user's terminal claims its own detach key (F1 in `root`, as `codeman tui` and `scripts/tmux-manager.sh` do). → [architecture-invariants#the-prefix-table-on-codemans-socket-holds-only-send-prefix](docs/architecture-invariants.md#the-prefix-table-on-codemans-socket-holds-only-send-prefix)
|
||||
- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-<arch>/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ The flip side of that: since Linux has no prebuild, `npm install` **needs a C/C++ toolchain there** (`make`, `g++`, `python3`), so `install.sh` checks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp with `not found: make`. Do not drop that step. ⚠️ Look in **`prebuilds/<platform>-<arch>/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable)
|
||||
- **User-chosen paths are probed BOUNDED, never with `existsSync`/`statSync`** (#516): a linked case or a `workingDir` can sit on a network mount that stopped answering. A synchronous check there freezes the whole server, and an unbounded async `stat`/`lstat`/`readFile` holds one of libuv's threadpool workers (4 by default, shared with every `fs`, `dns.lookup` and `crypto` call) until the mount returns. On a request or spawn path use `probePath()`/`probePathKind()` (`utils/bounded-path-probe.ts`, read its `@fileoverview`). ⚠️ `unknown` is NEVER `absent`: nothing is created, scaffolded or 404'd on it. Bulk scans keep the stall cap; a request for ONE path the user named may pass `{ pastCap: true }`, which still stops at the ceiling that keeps one worker free; and a helper that would otherwise touch the path skips whatever is still `unknown`. User-facing errors for it go through `describeUnknownPath()`, so a refused probe is not reported as a broken folder.
|
||||
- **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — under DSF=2 xterm's WebGL renderer draws glyphs at ~2× nominal size while still *reporting* nominal cell dims, so only the pixels reveal it and only the terminal font looks wrong. And overwriting a fixed output path leaves OS image viewers showing the old render, which reads as "the fix didn't work"; `scripts/capture-real-overview.mjs` mints a timestamped filename per run. Seed the per-device `localStorage` keys (`codeman:skin`, `codeman-font-size`, `codeman-app-settings`) so the capture matches a real device. → [architecture-invariants#headless-screenshot-capture](docs/architecture-invariants.md#headless-screenshot-capture)
|
||||
@@ -124,7 +125,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**Input**: `session.writeViaMux()` for programmatic/curl input via one tmux bracketed paste (`paste-buffer -p`) + `send-keys Enter`, single-line only. A single character is typed with `send-keys -l` instead, because selection dialogs ignore a pasted digit. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience)
|
||||
**Input**: `session.writeViaMux()` for programmatic/curl input via one tmux bracketed paste (`paste-buffer -p`) + `send-keys Enter`; a multi-line prompt keeps its line breaks inside that paste. A single character is typed with `send-keys -l` instead, because selection dialogs ignore a pasted digit. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience)
|
||||
|
||||
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
|
||||
|
||||
@@ -156,7 +157,11 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit)
|
||||
|
||||
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
|
||||
**Config backups** (`src/config-backup.ts`, `routes/config-backup-routes.ts`; `configBackupEnabled` absent = ON, SYNCED; `docs/wiki/Config-Backups.md`): timestamped `cfg-*` snapshots of a FIXED allowlist of config files (`CONFIG_BACKUP_FILES`; `state.json` deliberately not) at startup and whenever their content changes, into `<data dir>/backups/config` or `configBackupDir`. ⚠️ A field a tracked file rewrites on its own goes in `VOLATILE_KEYS`, or every tick makes a snapshot and the history shrinks to about 95 minutes. ⚠️ Retention deletes only this instance's folders that carry a valid manifest, never the newest, and ages a snapshot from its SUCCESSOR; a retention key ABSENT from `settings.json` means what the newest manifest recorded (`withRecordedRetention`), never the defaults. ⚠️ The instance is identified by the random id in `config-backup-id`, never the hostname (a Docker recreate changes it). ⚠️ A custom folder is probed BOUNDED (#516) before ANY read, the recorded-retention fallback included, and a pass reads the folder once (`runConfigBackupPass`). ⚠️ Restore verifies every SHA-256 and refuses symlinks and group/world bits before it writes anything, takes the undo copy first, and reports a partial failure as 500 with `restored`/`failed`/`safetySnapshot` in `data`; every path that wrote files runs `afterRestore()` (intents reload, CLI registry reload, admin audit), and Settings re-reads the form after a `settings.json` restore. ⚠️ Compare never returns a credential (`REDACT_ALL_FILES`, `SECRET_KEY_RE`). Admin only in multi-user mode. → [architecture-invariants#config-backups](docs/architecture-invariants.md#config-backups)
|
||||
|
||||
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). Copilot comes from a read-only `GET https://api.github.com/copilot_internal/user` (`refreshCopilotPlanUsage()` in server.ts, at startup and every 10 minutes; `parseCopilotQuotaResponse()` keeps only the metered `premium_interactions` quota, as a `monthly` window with `used`/`limit`) sent with Copilot's OWN sign-in token (`resolveCopilotToken()`: `COPILOT_GITHUB_TOKEN`/`GH_TOKEN`/`GITHUB_TOKEN`, else the plain-text token in `<COPILOT_HOME or ~/.copilot>/config.json`; a keychain sign-in shows no row). It is **on by default**, like the Codex poll: it rides `showPlanUsageLimits` (absent = ON), and Plan Usage Limits off stops the request. ⚠️ The gates live in `readCopilotPlanUsageIfEnabled()` (copilot-plan-usage.ts) in this order: the setting, then a token, then `isCopilotAvailable()`, whose resolver can run a login-shell probe that a host with no sign-in must never pay each tick. ⚠️ `redirect: 'error'`, the token is never logged, stored or broadcast, any failure is silently no row, and the row is admin-only on the wire like Codex's. The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
|
||||
|
||||
**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in on the Respawn tab, app-wide default `promptCacheKeepWarm` + cap `promptCacheKeepWarmMaxHours` in App Settings → System → Automation, SYNCED, default OFF / 2 h; step 2 of Discussion #554): one keep-alive turn (`KEEP_WARM_NUDGE`, under 100 chars) five minutes before an idle claude session's cache would expire, so the next prompt reads the conversation from cache instead of re-writing it at 2x. Codeman owns the idle policy for these sessions: a launch with keep-warm on also carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON (`session-cli-registry-bridge.ts`), decided once per create/respawn by `decideIdleCompactionOptOut()` (tmux-manager.ts: keep-warm on via `keepWarmOn()`, exporter `injected`, claude's `idleCompactionSetting` gate at 2.1.290, fail-closed like `--name`) and recorded as `MuxSession.idleCompactionOff`; a keep-warm-off launch stays byte-identical (`test/cli-registry-spawn-golden.test.ts`). Switched on after launch it is best-effort and the readout says idle compaction may still run until the next launch. ⚠️ The deadline is `Session.promptCache.expiresAt` from the statusline, never idle detection or the plan tier; a `5m` TTL is a hard stop, and so is a launch whose `statusLineExporter` is not `injected`. ⚠️ Every guard lives in the pure `decideKeepWarm()` (effective opt-in, where an agent-spawned session (`parentSessionId`) does not inherit the app default; exporter injected; warm; no `compactedAt` this gap; tokens at stake; live pane (pid AND no `paneExit`); no limit pause (auto-resume's, or a limit message on screen); no respawn/Ralph/task/waiter; inside the cap since `lastRealPromptAt`; idle; no dialog; EMPTY composer via `composerIsEmpty()`, continuation lines included, read from a STYLED capture so Claude Code's faint prompt suggestion is not taken for a draft; no recent keystroke); add a guard there, never in the service. ⚠️ The nudge IS a submit and moves `lastSubmitAt` (the response viewer's conversation correlation needs it); the window `beginKeepAliveTurn()` opens keeps it off `lastRealPromptAt` (the cap's own persisted clock) and opens its `stop`/`idle_prompt` hooks pre-acknowledged (`preAcknowledged` on `notePrompt`), so nothing alerts, and leaves an idle item already pending exactly as found (only a human spends an idle alert; `session:working`/`session:idle` carry `keepAlive: true` so the page's stuck timer ignores the turn); the keep-alive's own `idle_prompt` ends that window (`endKeepAliveTurn()`; `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling, since a slow turn's idle prompt lands a minute after it ends), so does a nudge whose write failed (retried a minute later, never past the last-chance line), and so does any other submit: a `fromUser` write, a programmatic write of anything but the nudge, or a `prompt_submitted` hook whose prompt is not the nudge. Read My Mind drops its exact text (`isCapturablePrompt`). → [architecture-invariants#prompt-cache-keep-warm](docs/architecture-invariants.md#prompt-cache-keep-warm)
|
||||
|
||||
**Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`.
|
||||
|
||||
@@ -170,7 +175,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md`
|
||||
|
||||
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts`, which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
|
||||
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
|
||||
|
||||
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP, Copilot)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All nine **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. ⚠️ **Copilot**: `allowAll` (`--yolo`) is a `privilegedParam` and `COPILOT_HOME`/`COPILOT_ALLOW_ALL`/`COPILOT_PROVIDER_*`/`COPILOT_MODEL` are privileged env keys, and so is every other `COPILOT_*` variable the CLI reads that switches approvals, runs code with no approval row or sends its sign-in token elsewhere (the 1.0.98 list is in `stock.ts`; the prefix admits whatever a later release adds, so re-read the bundle whenever the measured version moves); the resume id must start with a letter or digit (`path-segment`), since `--resume`'s value is optional and `--resume --yolo` would otherwise make `--yolo` its own flag; `--name` is dropped next to `--resume`/`--continue` (Copilot refuses the pair); its mouse DECSETs are stripped (`strip-mux-and-mouse`) so a drag selects and the wheel is forwarded by `_shouldForwardWheelToApp` while `cliMouseTracking` is on; `composerReadyMark` is the footer hint `/ commands · ? help`, NOT `❯` (the folder-trust dialog draws `❯ 1. Yes`); `last-response` reads the pane's visible screen (`transcriptNeedsScreenCapture`) through a per-CLI `PANE_DIALECTS` row, because its stream is cursor moves, not lines (a prompt row scrolled off by a tall answer reads as the answer's tail unless a banner/trust dialog is on screen, and typed composer text is not a prompt); Docker seeds no Copilot credential (`config.json` holds a plain-text token without a keyring). → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp)
|
||||
|
||||
@@ -178,7 +183,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key). ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
|
||||
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. 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)
|
||||
⚠️ **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`
|
||||
|
||||
@@ -188,7 +193,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core `src/services/unified-session-service.ts`), backing the Cmd+K Session Manager, pinning and cross-device tab order (`PUT /api/session-order`, `src/session-order.ts`). ⚠️ Transcript history is THREE stores (`~/.claude/projects`, `~/.omp/agent/sessions`, `~/.codex/sessions`), folded via the `claudeSessionId → Codeman id` alias map (not Claude-only despite the name). ⚠️ `resumeId` is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
|
||||
|
||||
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend reads AND edits it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. A ref may carry `focus: true` ("Add to Focus" in the row/web-tab menu, the named `setFocus` operation): the rail then draws a SHORTCUT to it in a pinned, collapsible Focus section (`_renderTabFocus()`), its own element in `#tabRail` ABOVE `#sessionTabs`, so it shows on the flat rail too and is never a row, tree item, Alt+N slot, lineage anchor or drag target. ⚠️ Every copy of a ref (`copyRef` in tab-layout-browser.js, `parseRef`/`mapRef` in tab-layout.ts) must carry `focus` like `placement`: the browser PUTs whole layouts, so a dropped field is erased on the next save by whoever saves. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. ⚠️ Every browser write is a named operation through ONE serialized `PUT /api/tab-layout` at a time (`createEditCoordinator`): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend (the `pagehide` keepalive in `_persistPendingTabLayoutEdits` is the one deliberate exception). ⚠️ Placing a NEW session in a group rides its create request (`tabGroupId` on `POST /api/sessions` / `/api/quick-start`) into `TabLayoutService.sessionCreated()`, which registers the session inside the owner lock and places it in the same write; never place a created session with a follow-up PUT. A spawned child lands after its parent; whether it keeps FOLLOWING it until moved by hand is the synced `spawnedTabsFollowParent` setting (default OFF: pinned `manual` in the creating write), read fresh in `sessionCreated()`. ⚠️ A legacy order PUT marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`), and the browser never echoes create/delete through `PUT /api/session-order`: the server already re-projected and broadcast it. Web-tab OPENNESS stays per device. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
|
||||
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend reads AND edits it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. A ref may carry `focus: true` ("Add to Focus" in the row/web-tab menu, the named `setFocus` operation): the rail then draws a SHORTCUT to it in a pinned, collapsible Focus section (`_renderTabFocus()`), its own element in `#tabRail` ABOVE `#sessionTabs`, so it shows on the flat rail too and is never a row, tree item, Alt+N slot, lineage anchor or drag target. ⚠️ Every copy of a ref (`copyRef` in tab-layout-browser.js, `parseRef`/`mapRef` in tab-layout.ts) must carry `focus` like `placement`: the browser PUTs whole layouts, so a dropped field is erased on the next save by whoever saves. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. ⚠️ Every browser write is a named operation through ONE serialized `PUT /api/tab-layout` at a time (`createEditCoordinator`): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend (the `pagehide` keepalive in `_persistPendingTabLayoutEdits` is the one deliberate exception). ⚠️ Placing a NEW session in a group rides its create request (`tabGroupId` on `POST /api/sessions` / `/api/quick-start`) into `TabLayoutService.sessionCreated()`, which registers the session inside the owner lock and places it in the same write; never place a created session with a follow-up PUT. A spawned child lands after its parent; whether it keeps FOLLOWING it until moved by hand is the synced `spawnedTabsFollowParent` setting (default OFF: pinned `manual` in the creating write), read fresh in `sessionCreated()`. A case's **default session group** (Case settings; `caseDefaultGroups` in state.json, per owner and case) is the fallback placement in that same write, below `tabGroupId` and the parent, from the create request's `caseName` (quick-start, Ralph start, or inferred lexically from a `POST /api/sessions` `workingDir`); it is read and written ONLY through `TabLayoutService`. ⚠️ A legacy order PUT marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`), and the browser never echoes create/delete through `PUT /api/session-order`: the server already re-projected and broadcast it. Web-tab OPENNESS stays per device. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
|
||||
|
||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation)
|
||||
|
||||
@@ -206,7 +211,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection, drop recovery, clear and a tile's load use `SHELL_LOAD_QUERY` (constants.js), a bounded window of tmux's RENDERED history (`?full=1&tail=1MiB&alt=recording&lines=10000`), never the raw byte recording, whose full-screen repaints replayed at another pane height push copies of their top row into scrollback; the route reports a client `lines=` bound below the history ceiling that left lines in tmux as `truncationReason: 'tail'`, and `alt=recording` falls back to the visible frame over the tailed byte recording when the pane is in the alternate screen (a nested tmux in a remote or Docker case keeps no tmux history; vim/less take it too while open). A Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||
|
||||
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode and copilot get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 and copilot ONLY, and only while they have mouse tracking on** (copilot has no version gate; with a stale flag it falls back to PageUp/PageDown through its `transcriptPageKeys` capability) (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
|
||||
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode and copilot get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward only with observed `cliMouseTracking`: **Claude ≥ 2.1.187, tracked Codex, or tracked Copilot**. Codex 0.163.0 fullscreen was measured with `alternate_on=1`, `mouse_any_flag=1`: one SGR report moves one transcript row; see the fresh measurement in architecture-invariants. Inline/untracked Codex retains local history or the capability-gated paging fallback. Copilot has no version gate; a stale/false tracking flag falls back to PageUp/PageDown. Touch alone has one more branch (`_shouldForwardTouchToApp`): in any mode, a drag also forwards as SGR wheel reports while xterm ITSELF tracks the mouse with SGR encoding (an app in the pane asked for it; a tracking mode other than `x10` and `coreMouseService.activeEncoding === 'SGR'`, so DEFAULT, X10 and SGR-pixel apps fail closed), because xterm encodes a wheel there but the touch listeners consume the drag. ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
|
||||
**Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
|
||||
|
||||
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
|
||||
@@ -302,7 +307,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), `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), `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), `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), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
|
||||
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
|
||||
|
||||
@@ -331,11 +336,11 @@ npm run test:all # literally everything; fails ~87 tests on a clean master
|
||||
|
||||
⚠️ **A file filter must match the runner.** `npm test -- test/mobile/keyboard.test.ts` matches nothing and exits GREEN having run zero tests, because the gate's config excludes that path — an excluded file needs its own runner (`npm run test:mobile -- <file>`, `npm run test:browser -- <file>`, `npm run test:perf -- <file>`). Vitest treats "no files matched a filter" as success, so read the file count, not just the colour.
|
||||
|
||||
Raw `npx vitest` skips the config (and with it `setup.ts`); always use `npm test --` or pass `--config`.
|
||||
A raw `npx vitest` used to find no config, so `setup.ts` never ran and the suite wrote fixtures into the REAL `~/.codeman` (a `clis.json` with a test entry, `users.json`, `webhook.json`, `.env`, settings values: it looks exactly like "my settings were reset"). The root `vitest.config.ts` now re-exports `config/vitest.ci.config.ts`, so a bare run is isolated too, and `test/test-env-isolation.test.ts` pins that. Still prefer `npm test --`, and pass `--config config/vitest.config.ts` (or the browser/mobile/perf configs) deliberately for the suites the gate leaves out.
|
||||
|
||||
**Config**: Vitest with `globals: true`, `fileParallelism: false`. Timeout 30s, teardown 60s. `config/vitest.config.ts` is the everything-config behind `test:all`; `config/vitest.ci.config.ts` is the gate and derives its excludes from `config/test-suites.ts`, which is also what `vitest.browser.config.ts` and `vitest.perf.config.ts` derive their includes from — so the exclusions and the runners cannot drift apart. Keep shared options in sync across them.
|
||||
|
||||
**Tmux safety**: under vitest (`VITEST`), `TmuxManager` no-ops ALL shell commands (`IS_TEST_MODE` in `src/tmux-manager.ts`), docker IO is no-op'd likewise, and `Session` spawns an echo PTY (`TEST_PTY_SCRIPT`) instead of attaching tmux. `test/setup.ts` gives each file a temp `HOME`/`USERPROFILE` and strips `CODEMAN_PASSWORD`/`CODEMAN_USERNAME`, `CODEMAN_GESTURE` and `CODEMAN_INSTANCE`/`CODEMAN_DATA_DIR`/`CODEMAN_TMUX_SOCKET` (pinned by `test/test-env-isolation.test.ts`). ⚠️ `CODEMAN_DATA_DIR` overrides the temp HOME, so never drop its strip; strip `CODEMAN_INSTANCE` in the setup file, never in a hook (captured at first import). ⚠️ Delete case trees only via `safeRmHomeTree()`. ⚠️ Raw `npx vitest` without `--config` skips `setup.ts` and its isolation. → [architecture-invariants#test-isolation-tmux-docker-and-home](docs/architecture-invariants.md#test-isolation-tmux-docker-and-home)
|
||||
**Tmux safety**: under vitest (`VITEST`), `TmuxManager` no-ops ALL shell commands (`IS_TEST_MODE` in `src/tmux-manager.ts`), docker IO is no-op'd likewise, and `Session` spawns an echo PTY (`TEST_PTY_SCRIPT`) instead of attaching tmux. `test/setup.ts` gives each file a temp `HOME`/`USERPROFILE` and strips `CODEMAN_PASSWORD`/`CODEMAN_USERNAME`, `CODEMAN_GESTURE` and `CODEMAN_INSTANCE`/`CODEMAN_DATA_DIR`/`CODEMAN_TMUX_SOCKET` (pinned by `test/test-env-isolation.test.ts`). ⚠️ `CODEMAN_DATA_DIR` overrides the temp HOME, so never drop its strip; strip `CODEMAN_INSTANCE` in the setup file, never in a hook (captured at first import). ⚠️ Delete case trees only via `safeRmHomeTree()`. ⚠️ A bare `npx vitest` gets `setup.ts` only through the root `vitest.config.ts` (a re-export of the gate config): keep that file, and never point `--config` at a config without `setupFiles`. → [architecture-invariants#test-isolation-tmux-docker-and-home](docs/architecture-invariants.md#test-isolation-tmux-docker-and-home)
|
||||
|
||||
**Ports**: every in-process test server binds port 0 (the one exception, `test/codex-predictive-echo.test.ts`, starts a separate lab server process on 3222). A test that constructs `WebServer` reads `server.boundPort` (mobile tests: `createTestServer()`, then `server.boundPort`); a raw `http`/`net`/Fastify/`ws` server listens on `0` and reads `address().port`. `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port and a raw `listen` on a number or `…PORT` constant. A test that needs a port nothing listens on binds 0, reads it and closes (`closedPort()` in `test/daemon-control.test.ts`), never "the server's port + 1". Never 3000 (the live instance).
|
||||
|
||||
|
||||
@@ -479,7 +479,8 @@ 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
|
||||
- **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; per device, on for desktops and off for phones
|
||||
- **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
|
||||
- **Session list, your way** — the header strip, a left sidebar with a filter box, or a vertical rail whose detailed rows carry created and state stamps and sort by activity; the phone home screen and the desktop home rail use the same order. On the rail, **Search sessions** narrows the list to the tabs that match, across every group, and a tab with an alert stays visible even when its name does not. Named **tab groups** collapse, reorder and take drags; a group's menu has **New session**, and a worker an agent spawns lands right after its parent (**Spawned tabs follow their parent**, off by default, keeps it moving with the parent). **Add to Focus** pins a shortcut to any tab in a section at the top of the rail, and **CLI Logos on Tabs** can be switched off per device
|
||||
- **Terminal looks** — seven skins, four of them light, per-device font family and weight (the bundled JetBrains Mono covers weights 100 to 800), and opt-in animations for tabs, agent windows, the terminal pane, connection lines and [tile-grid tiles](#tile-grid), all in App Settings → Animations (with a lab to try them)
|
||||
@@ -851,7 +852,7 @@ When a CLI runs in a Codeman-managed session, these environment variables are se
|
||||
|
||||
### Rules of the road (read before you POST)
|
||||
|
||||
1. **Single-line input, ending in `\r`.** Programmatic input is sent as literal text, and Enter fires **only when the input contains a carriage return**: `{"input":"run tests\r"}`. Without the `\r` the text sits on the session's prompt unsubmitted (and a combined `wait` runs its full timeout on a turn that never started). Embedded newlines are stripped rather than rejected, so `"echo A\necho B\r"` runs the joined command `echo Aecho B`: send one line per call.
|
||||
1. **Input must end in `\r`.** Programmatic input is sent as literal text, and Enter fires **only when the input contains a carriage return**: `{"input":"run tests\r"}`. Without the `\r` the text sits on the session's prompt unsubmitted (and a combined `wait` runs its full timeout on a turn that never started). With `useMux`, embedded newlines are kept and the lines go in as one paste, so `"echo A\necho B\r"` runs both commands in a shell; the check that re-presses a stranded Enter cannot reliably recognise a multi-line paste, so put long multi-line content in a file the agent is told to read.
|
||||
2. **Make input idempotent.** Include a stable `clientId` and a monotonic per-session `seq` on `POST …/input`. The server de-duplicates, so a retry after a dropped connection can't double-deliver a prompt.
|
||||
3. **Auth.** If `CODEMAN_PASSWORD` is set, send HTTP Basic auth (user `admin` or `CODEMAN_USERNAME`) or a `codeman_session` cookie. The default loopback install is passwordless. A missing `Origin` header is allowed, so plain `curl` works; cross-site browser origins are rejected (CSRF guard). ⚠️ A `401` replies with the bare string `Unauthorized`, **not** the JSON envelope, so piping it into `jq` throws a parse error instead of showing the failure: check the status before parsing.
|
||||
4. **Response envelope.** Most endpoints return `{ "success": true, "data": … }` (errors: `{ "success": false, "error", "errorCode" }`). A few legacy GETs return bare bodies — **handle both** (`body.data ?? body`).
|
||||
|
||||
+1
-1
@@ -853,7 +853,7 @@ deleted 9f2d8e5f, aff9c691, be9f1f15 标签消失
|
||||
|
||||
### 行路规则(POST 之前先读)
|
||||
|
||||
1. **只发单行输入,而且必须以 `\r` 结尾。** 编程输入按字面文本发送,**只有当输入里含回车符时才会触发 Enter**:`{"input":"run tests\r"}`。少了 `\r`,文本就停在会话的输入框里不被提交(同一次调用里的 `wait` 还会在一个压根没开始的回合上耗满整个超时)。内嵌的换行会被剥掉而不是报错,因此 `"echo A\necho B\r"` 执行的是拼起来的 `echo Aecho B`:一次调用只发一行。
|
||||
1. **输入必须以 `\r` 结尾。** 编程输入按字面文本发送,**只有当输入里含回车符时才会触发 Enter**:`{"input":"run tests\r"}`。少了 `\r`,文本就停在会话的输入框里不被提交(同一次调用里的 `wait` 还会在一个压根没开始的回合上耗满整个超时)。使用 `useMux` 时内嵌的换行会保留,各行作为一次粘贴送入,因此 `"echo A\necho B\r"` 在 shell 里会执行两条命令;但重新按下滞留 Enter 的检查无法可靠识别多行粘贴,所以较长的多行内容最好写进文件,让 agent 自己去读。
|
||||
2. **让输入幂等。** 在 `POST …/input` 上带上稳定的 `clientId` 和按会话单调递增的 `seq`。服务端会去重,因此连接中断后的重试不会重复投递提示。
|
||||
3. **认证。** 若设置了 `CODEMAN_PASSWORD`,发送 HTTP Basic 认证(用户 `admin` 或 `CODEMAN_USERNAME`)或 `codeman_session` cookie。默认的环回安装无密码。缺失的 `Origin` 头被允许,因此普通 `curl` 可用;跨站的浏览器 origin 会被拒绝(CSRF 防护)。⚠️ `401` 回的是裸字符串 `Unauthorized`,**不是** JSON 信封,直接喂给 `jq` 只会抛解析错误而看不到真正的失败原因:先看状态码,再解析。
|
||||
4. **响应信封。** 多数端点返回 `{ "success": true, "data": … }`(错误:`{ "success": false, "error", "errorCode" }`)。少数遗留 GET 返回裸响应体 —— **两种都要处理**(`body.data ?? body`)。
|
||||
|
||||
@@ -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.43.0",
|
||||
"version": "1.44.0",
|
||||
"websiteUrl": "https://getcodeman.com",
|
||||
"repository": {
|
||||
"url": "https://github.com/Ark0N/Codeman",
|
||||
@@ -13,7 +13,7 @@
|
||||
{
|
||||
"registryType": "npm",
|
||||
"identifier": "aicodeman",
|
||||
"version": "1.43.0",
|
||||
"version": "1.44.0",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
},
|
||||
|
||||
@@ -37,6 +37,7 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
|
||||
'test/split-pane-terminal.browser.test.ts',
|
||||
'test/terminal-tile-scroll.browser.test.ts',
|
||||
'test/codex-wheel-routing.browser.test.ts',
|
||||
'test/terminal-touch-paging.browser.test.ts',
|
||||
'test/shift-enter-keypress.browser.test.ts',
|
||||
'test/key-tester.browser.test.ts',
|
||||
@@ -51,6 +52,7 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/run-mode-menu-scroll.browser.test.ts',
|
||||
'test/markdown-anchor-links.browser.test.ts',
|
||||
'test/fullscreen-tab-switch-capture.browser.test.ts',
|
||||
'test/terminal-touch-swipe-axis.browser.test.ts',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+57
-1
@@ -434,6 +434,12 @@ about a hundred characters or more is taken as a paste by Claude Code, its `\r`
|
||||
becomes a newline, and the prompt stays unsent (measured on 2.1.283). Any other
|
||||
input (escape sequences, a bracketed-paste frame, a line feed, a bare `\r`) keeps
|
||||
the raw write, and an explicit `"useMux": false` forces it.
|
||||
With `"useMux": true`, a line feed is kept: the text goes in as one bracketed paste
|
||||
(`\r\n` read as `\n`, leading and trailing line breaks dropped), then Enter if the
|
||||
input carried a `\r`; a pane that has not enabled bracketed paste receives each line
|
||||
break as a carriage return and runs the lines as they arrive. The check that
|
||||
re-presses a stranded Enter cannot reliably recognise a multi-line paste, so a long
|
||||
multi-line prompt to a just-started agent is still safer in a file it is told to read.
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$API/api/v1/sessions/$SID/input" \
|
||||
@@ -778,6 +784,28 @@ deleted group, another user's group) is ignored and the session lands where it
|
||||
otherwise would, never a `400`. A malformed value (`null`, an empty string, a
|
||||
non-string) is a `400 INVALID_INPUT`. Requests without the field are unchanged.
|
||||
|
||||
### A case's default session group
|
||||
|
||||
- `GET /api/v1/cases/:name/default-group` → `{ caseName, groupId, groups }`: `groupId` is a
|
||||
group id from the caller's own tab layout or `null` (Ungrouped), `groups` the caller's
|
||||
current named groups (`{ id, name }`, in layout order). A stored id whose group was
|
||||
deleted reads back as `null` (the read changes nothing; the next session created for the
|
||||
case forgets it).
|
||||
- `PUT /api/v1/cases/:name/default-group` with `{ "groupId": string | null }` saves it. An
|
||||
id not in the caller's layout is a `400 INVALID_INPUT` that leaves the old value in place.
|
||||
|
||||
Both answer `404 NOT_FOUND` for a case the caller cannot see (the same rules as
|
||||
`GET /api/v1/cases`). The value is stored per owner and case, so in multi-user mode it is
|
||||
never shared and needs no admin role. A new session of the case is placed in that group in
|
||||
its creating write when nothing stronger applies; the order is: an explicit `tabGroupId`,
|
||||
then the session it was spawned from (`parentSessionId`), then the case default, else
|
||||
Ungrouped. `POST /api/v1/quick-start` uses its `caseName` (local, linked, remote and Docker
|
||||
cases); `POST /api/v1/sessions` uses the case whose folder `workingDir` is (a direct child of
|
||||
the caller's case space, a linked case's exact path, or a Claude worktree of either,
|
||||
`<case>/.claude/worktrees/<name>`); without a `workingDir` there is no case. When a case default places the
|
||||
session, the create response carries `tabLayout` as it does for `tabGroupId`. Deleting the
|
||||
case forgets the value; existing sessions never move.
|
||||
|
||||
## Approvals Inbox
|
||||
|
||||
Cross-session queue of prompts waiting on a human (permission dialogs,
|
||||
@@ -990,7 +1018,10 @@ confirmed? } | { clear: true }` applies (or clears) the session's
|
||||
just because a swap is needed at all — only when it would disrupt
|
||||
someone else.
|
||||
- `{requiresContextWarning: true, modelId, contextLength,
|
||||
minSafeContextTokens}` — Claude Code's own fixed per-turn overhead
|
||||
minSafeContextTokens, slotCount?}` — `slotCount` is present whenever the model runs more than
|
||||
one parallel llama.cpp slot, and the warning also fires when the context
|
||||
divided across those slots is below the floor even though the whole context
|
||||
is not. Claude Code's own fixed per-turn overhead
|
||||
(system prompt + tool schemas) can exceed a small model's entire
|
||||
discovered context on its own, before any conversation history exists
|
||||
to compact, guaranteeing the very first message fails regardless of
|
||||
@@ -1152,6 +1183,31 @@ Result (`data`):
|
||||
|
||||
The result carries server **names** only, never `env` values, `headers` or file content. Each changed file keeps its previous content as `<file>.codeman-bak` (overwritten by each sync); a file that receives servers carrying `env` or `headers` is left mode `0600`.
|
||||
|
||||
## Prompt-cache keep-warm
|
||||
|
||||
Claude Code reports the main conversation's prompt-cache state on its statusline (v2.1.251+); the exporter keeps it per session as `promptCache` on `GET /api/sessions` (`{ warm, ttl, expiresAt, recacheTokensIfCold, misses, lastMissCauses }`, epoch ms). The keep-warm sends one keep-alive turn shortly before an idle session's cache would expire; `keepWarmEnabled` (absent = the app-wide `promptCacheKeepWarm` setting applies, which a session spawned by another session does not inherit), the live `keepWarm` status and `lastRealPromptAt` (epoch ms of the last submit that was not a keep-alive, the cap's clock) ride the same session state. A keep-alive is a submit, so it moves `lastSubmitAt` like any prompt. A claude launch with keep-warm on also carries `"idleCompaction": false` in its `--settings` JSON (Claude Code 2.1.290 or newer).
|
||||
|
||||
| Method | Path | Body | Notes |
|
||||
| ------ | ---------------------------------- | ------------------------------------ | ----- |
|
||||
| `POST` | `/api/v1/sessions/:id/keep-warm` | `{ "enabled": true \| false \| null }` | Sets the session's choice; `null` clears it so the app-wide default applies. `200` with `{ keepWarm: { enabled, status } }`. `400 INVALID_INPUT` for a CLI that reports no cache telemetry on its statusline (anything but Claude today). |
|
||||
|
||||
`status` is `{ enabled, idleCompactionOff, state, nextCheckAt?, reason?, nudges, lastNudgeAt?, budgetEndsAt? }` (`idleCompactionOff`: whether the session's current launch carries that opt-out; `budgetEndsAt` only while `enabled`) with `state` one of `off`, `armed` (waiting for the due time), `waiting` (due, deferred by a guard named in `reason`), `nudged` (sent, waiting for the next cache report) or `stopped` (`reason` says why). Two SSE events carry changes: `session:promptCache` (`{ sessionId, ...promptCache }`) and `session:keepWarm` (`{ sessionId, ...status }`), both change-only. The keep-alive turn's `hook:stop` and `hook:idle_prompt` broadcasts carry `acknowledgedReason: "cache keep-alive"`, no push is sent for them and the run summary does not record them; an idle approval already pending stays as it was (only a person spends its alert). Its `session:working` and `session:idle` broadcasts carry `keepAlive: true`.
|
||||
|
||||
## Config backups
|
||||
|
||||
Timestamped snapshots of the files that hold what a user has customized (`src/config-backup.ts`). On by default; a snapshot is taken at startup and whenever the tracked files' content changes (checked every 5 minutes), so there is one entry per real change. All four routes are admin only in multi-user mode (`403`), because the files include credentials. Responses carry file names and sizes; only the diff returns values, and never a credential's.
|
||||
|
||||
Tracked files (a fixed list, relative to the data dir): `settings.json`, `clis.json`, `custom-model-hosts.json`, `webhook.json`, `intents.json`, `linked-cases.json`, `remote-hosts.json`, `remote-cases.json`, `docker-hosts.json`, `docker-cases.json`, `webviews.json`, `push-keys.json`, `users.json`, `.env`. Session state (`state.json`, which also holds cron jobs and respawn presets), logs, the hook secret and caches are not.
|
||||
|
||||
Settings (synced, in `settings.json`): `configBackupEnabled` (absent = on), `configBackupDir` (absolute path, `~` expanded; empty = `<data dir>/backups/config`), `configBackupKeepCount` (2–500, default 20), `configBackupKeepDays` (0–3650, default 30; `0` = no age limit). A key absent from `settings.json` means the retention the newest backup in the folder recorded, and the default only when none did. Retention only ever deletes snapshot folders Codeman made there, and never the newest one. Each snapshot is a `0700` folder named `cfg-YYYYMMDD-HHMMSS` (server local time; `-2`, `-3`… on a same-second collision) holding the tracked files under their own names plus a `manifest.json` (`id`, `createdAt`, `reason` of `startup`/`auto`/`manual`/`pre-restore`, `appVersion`, the change-check `hash`, an `origin` id, the `keepCount` and `keepDays` in force, and a SHA-256 and size per file), all `0600`. A snapshot is never dated earlier than the instance's newest one, so a clock that read ahead once does not make every later pass write a copy. All four routes answer `503 OPERATION_FAILED` when a custom folder does not respond, and diff and restore answer `409` for a symlinked or group/world-accessible backup as well as a checksum mismatch. Only folders with that name and a valid manifest are listed, restored or pruned. Full guide: `docs/wiki/Config-Backups.md`.
|
||||
|
||||
| Method | Path | Body | Notes |
|
||||
| ------ | ------------------------------------ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GET` | `/api/config-backups` | none | `settings` in effect, `dir`, `defaultDir`, `tracked[]` and `backups[]` (`id`, `createdAt`, `reason`, `appVersion`, `files[]`, `bytes`), newest first. |
|
||||
| `POST` | `/api/config-backups` | none | Snapshot now, even if nothing changed (`reason: manual`), then apply retention. `status` is `created` or `empty` (nothing tracked exists yet); `removed[]` lists the ids retention deleted. `503` when a custom folder does not answer. |
|
||||
| `GET` | `/api/config-backups/diff?from=&to=` | none | What differs between two states: `from`/`to` are a backup id or `current` (the running config; the default for `to`). `files[]` (`name`, `status` `added`/`removed`/`changed`/`unchanged`, `changes[]` of `path`, `kind`, `before`/`after`, `truncated`, `detail`) and `changed`. Credential values are never returned (`redacted: true`). `400` without `from`; `404` unknown id; `409` if a file fails its checksum. |
|
||||
| `POST` | `/api/config-backups/:id/restore` | none | Put a snapshot's files back, atomically, after a `pre-restore` snapshot of the current ones (none when nothing changed since the newest backup). `404` unknown id; `409 CONFLICT` if a file fails its checksum (nothing changed). Returns `restored[]`, `untouched[]` (tracked files present now that the backup did not hold; left as they are), `safetySnapshot` (the `pre-restore` id, or the newest backup's when nothing had changed since it) and `restartRequired[]` (files the running server only reads at start: `.env`, `push-keys.json`, `settings.json`). A file that cannot be written (a full disk, a directory in its place) does not stop the others: the answer is then `500 OPERATION_FAILED` with the same fields in `data` plus `failed[]` (`name`, `error`); the files listed in `restored[]` are the backup's and are reloaded like a full restore. |
|
||||
|
||||
## Webhook notifications
|
||||
|
||||
Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Settings → Notifications). Off by default. The webhook URL is a bearer secret (anyone holding a Slack/Discord URL can post as it), so it lives in `~/.codeman/webhook.json` (0600), is **never returned**, and is kept out of `settings.json`. All three routes answer `403` for a non-admin in multi-user mode.
|
||||
|
||||
@@ -16,6 +16,10 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
||||
|
||||
**Instance isolation / multi-instance attach danger** — data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts` (`getDataDir()`/`dataPath()`/`DEFAULT_TMUX_SOCKET`). ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions** (`tmux -L codeman attach-session …`), resizing/mutating them — `$HOME` isolation is NOT enough (tmux is system-global). To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes BOTH dir+socket: `~/.codeman-<name>` + `-L codeman-<name>`), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually. **`CODEMAN_INSTANCE` defaults to empty = the production layout (`~/.codeman`, `-L codeman`, port 3000)**, so this branch is safe to ship to master without disturbing existing installs. To run THIS beta alongside prod, launch with `scripts/run-beta.sh` (`CODEMAN_INSTANCE=beta` + `CODEMAN_PORT=5000`) — it never collides with prod's data dir/socket/port. Any new `~/.codeman/...` path MUST go through `dataPath()`, never `join(homedir(), '.codeman', …)`, and any new `tmux -L` caller through `resolveTmuxSocketName()` (same module): it applies the `CODEMAN_TMUX_SOCKET` override only when the name is safe and falls back to `DEFAULT_TMUX_SOCKET` otherwise. `TmuxManager` was the only such caller until `codeman tui` shelled out to tmux from a SECOND process for its degraded-mode listing and its attach handoff; a hardcoded `codeman` there would have pointed a beta instance straight at prod's panes.
|
||||
|
||||
### The prefix table on Codeman's socket holds only send-prefix
|
||||
|
||||
**Browser tabs are tmux clients, so tmux's prefix table must not reach `switch-client`** (`src/tmux-prefix-table.ts`, called from `createSession` before `respawn-pane`, and from `reconcileSessions()` at boot whenever the socket lists a pane, so sessions that survived an upgrade are covered). Every keystroke typed into a browser terminal reaches the `tmux attach-session` client behind it, and app.js deliberately lets Ctrl+B through to the terminal (readline, Claude Code's "ctrl+b ctrl+b"). With tmux's stock prefix table, Ctrl+B then `(` `)` `L` `s` `w` moved that client onto ANOTHER session, which the tab then showed and typed into under the original's label; `d` detached it, `$` renamed the session, `:` opened tmux's prompt. So the table is reduced to `<prefix> send-prefix` (the socket's configured prefix, read with `show-options -gv prefix`), once per tmux server in practice: the reset only runs while `list-keys -T prefix` shows anything else. ⚠️ Keep `send-prefix`: Claude Code inside tmux backgrounds a command with "ctrl+b ctrl+b". ⚠️ Never touch the `root` table, and tolerate (and re-bind after a reset) `C-<letter> detach-client`: `codeman tui`'s attach claims F1 and Alt+1..9 in `root` and the held-Ctrl alias `C-d` in `prefix` (`beginAttachHandoff`), and a session created mid-attach must not take that way out away. ⚠️ Key tables are server-wide, so what the TUI binds for its attach applies to browser tabs as well: F1 and `C-d` detach a tab's client, and **`M-1`..`M-9` (`switch-client -t <session>`) move it onto another session**, the very thing this reset exists to stop. The browser keeps a typed Alt+digit out of the terminal (`terminal-ui.js` swallows it for its own tab switching), but Esc followed by a digit within tmux's `escape-time` (500 ms on tmux 3.4) arrives as the same `M-<digit>`. This lasts for the length of the attach, and indefinitely after a TUI that is killed before `restore()` runs. Not fixed here; scoping those bindings to the TUI's own client is the open follow-up. ⚠️ The consequence for every terminal attach: `Ctrl+B d` does not exist on this socket. Anything that attaches a user's terminal claims its own way out for the attach, as `beginAttachHandoff` and `scripts/tmux-manager.sh` (F1 in `root`) do, and never advertises tmux's stock chord. Tests: `test/tmux-prefix-table.test.ts` (real tmux on a private socket, driven through a pty attach client), the wiring in `test/tmux-manager.test.ts`, the script in `test/tmux-manager-script-attach.test.ts`.
|
||||
|
||||
### Reverse-proxy base path
|
||||
|
||||
**Reverse-proxy base path** (`--base-url` / `CODEMAN_BASE_URL`, default `/`; `src/config/base-path.ts` is the pure single-source, normalized to `''` for root or `/foo`): lets Codeman be mounted under a sub-path behind a proxy that **forwards the prefix unchanged** (does NOT strip it). Deliberately few choke points, mirrored ingress/egress:
|
||||
@@ -113,13 +117,13 @@ Further detail: ⚠️ An ADOPTED container may back SEVERAL cases at different
|
||||
|
||||
Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden.test.ts`, remote/docker pane commands in `test/location-overlay-commands.test.ts`. ⚠️ That second golden no longer covers **remote claude or remote omp**: both now have their own arm in `buildRemoteLaunchCommand` (a `--session-id || --resume` pair, and `--continue`, so a respawn continues the same conversation) and never reach `defaultRemoteCommandForMode`, which is what that test asserts. Their real pins are `toContain` substrings in `test/tmux-manager.test.ts` and `test/remote-shared-sessions.test.ts`; changing either arm will NOT fail the golden.
|
||||
|
||||
⚠️ Anything reading the registry resolves it AT CALL TIME (`sessionModeSchema()`, `allowedEnvPrefixes()`, `dependencyRegistry()`, the resolvers' `searchDirs` thunks) — a module-level const freezes at first import, so a CLI enabled while the server ran moved the run menu but not that surface. `~/.codeman/clis.json` overrides any entry. Importing the registry still has no filesystem side effects: the only writer is `registry-writer.ts`, imported only by `cli-registry-routes.ts` (the opt-in CLI management routes, `cliManagementEnabled`, default OFF). ⚠️ Every write goes through `mutateRegistryFile()`: mutations run one at a time on a single promise chain, each with a unique temp file (three parallel toggles once lost two and 500'd on a shared temp name), and a file the READER would ignore (group/world permission bits) or quarantine (unparseable) is REFUSED with a 409 rather than rewritten, so one Settings click cannot replace a hand-edit or turn a refused file into trusted 0600 config. Only a missing file starts fresh. After a write the registry is reloaded, so changes apply without a restart. User guide: `docs/cli-registry.md`.
|
||||
⚠️ Anything reading the registry resolves it AT CALL TIME (`sessionModeSchema()`, `allowedEnvPrefixes()`, `dependencyRegistry()`, the resolvers' `searchDirs` thunks) — a module-level const freezes at first import, so a CLI enabled while the server ran moved the run menu but not that surface. `~/.codeman/clis.json` overrides any entry. Importing the registry still has no filesystem side effects: the only writer is `registry-writer.ts`, imported only by `cli-registry-routes.ts` (the opt-in CLI management routes, `cliManagementEnabled`, default OFF). ⚠️ Every write goes through `mutateRegistryFile()` (the one exception is a config-backup restore of `clis.json`, which writes it atomically and calls `reloadCliRegistry()`): mutations run one at a time on a single promise chain, each with a unique temp file (three parallel toggles once lost two and 500'd on a shared temp name), and a file the READER would ignore (group/world permission bits) or quarantine (unparseable) is REFUSED with a 409 rather than rewritten, so one Settings click cannot replace a hand-edit or turn a refused file into trusted 0600 config. Only a missing file starts fresh. After a write the registry is reloaded, so changes apply without a restart. User guide: `docs/cli-registry.md`.
|
||||
|
||||
## Session data and lifecycle
|
||||
|
||||
### Input delivery and WS resilience
|
||||
|
||||
**Input**: `session.writeViaMux()` for programmatic/curl input — the text as one tmux bracketed paste (`paste-buffer -p`, so Codex cannot fold the Enter into a paste burst) + `send-keys Enter`. A single character is typed with `send-keys -l` instead (`submitsAsKeys()`): Claude Code's and Codex's selection dialogs ignore a pasted `3`, and the Enter then confirms the highlighted option. Single-line only (fire-and-once). Interactive **browser** input goes through a durable **exactly-once** layer: each frame carries a stable `clientId` + monotonic per-session `seq`, persisted to localStorage until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt. A duplicate is ACKed as `{t:'ia',seq,dup:true,last}` so a client whose persisted counter rolled back below the server's watermark can lift itself out instead of typing into a silently dead terminal (`docs/reliable-input-delivery.md`). **WS resilience** (#149): the upgrade URL carries `cid = clientId + ':' + perTabNonce`, and `ws-connection-registry.ts` supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare `clientId` for seq dedup); reconnects back off exponentially (attempts preserved across `_connectWs`), and the header connection chip renders from a real `_wsState` lifecycle (`connecting`/`connected`/`fallback`/`reconnecting`/`disconnected`).
|
||||
**Input**: `session.writeViaMux()` for programmatic/curl input — the text as one tmux bracketed paste (`paste-buffer -p`, so Codex cannot fold the Enter into a paste burst) + `send-keys Enter`. A single character is typed with `send-keys -l` instead (`submitsAsKeys()`): Claude Code's and Codex's selection dialogs ignore a pasted `3`, and the Enter then confirms the highlighted option. A multi-line prompt keeps its line breaks inside that one paste, with or without the Enter (fire-and-once). Interactive **browser** input goes through a durable **exactly-once** layer: each frame carries a stable `clientId` + monotonic per-session `seq`, persisted to localStorage until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt. A duplicate is ACKed as `{t:'ia',seq,dup:true,last}` so a client whose persisted counter rolled back below the server's watermark can lift itself out instead of typing into a silently dead terminal (`docs/reliable-input-delivery.md`). **WS resilience** (#149): the upgrade URL carries `cid = clientId + ':' + perTabNonce`, and `ws-connection-registry.ts` supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare `clientId` for seq dedup); reconnects back off exponentially (attempts preserved across `_connectWs`), and the header connection chip renders from a real `_wsState` lifecycle (`connecting`/`connected`/`fallback`/`reconnecting`/`disconnected`).
|
||||
|
||||
### Per-session env overrides: exact-key allowlist and CLAUDE_CONFIG_DIR
|
||||
|
||||
@@ -155,10 +159,14 @@ Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgent
|
||||
|
||||
### Plan-usage chip (statusLine telemetry)
|
||||
|
||||
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON** since 1.9.3, handhelds OFF) renders compact Claude and Codex provider rows. Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). ⚠️ **Injected as an EPHEMERAL `claude --settings` CLI flag at spawn (2026-09-07), never written to disk** — `resolveStatusLineCliCommand()`/`ensureStatusLineExporterScript()` in `hooks-config.ts` (`generateStatusLineCommand()`/`applyStatusLineConfig()` remain, but only as the legacy disk-write self-heal path: a workspace an older Codeman build touched gets its stale `.claude/settings.local.json` entry stripped the first time a session starts there again). The exporter WRAPS a user's own real statusline (`findEffectiveUserStatusLineCommand()`, walking Claude Code's own settings precedence) rather than replacing it, and POSTs the `rate_limits` blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (foreground POST in the no-wrap branch so its own stdout becomes the footer, printing NOTHING on failure, `curl -sfk` plus `|| true`, since a bare brand word on the statusline is what discussion #405 opened with; backgrounded — `>/dev/null 2>&1 </dev/null &`, closing stdin too — only in the wrap branch, where the user's own command owns the footer; `curl --max-time 5` bounds a hung, not just refused, Codeman). Main Codex subscription usage comes from the signed-in host CLI's read-only app-server `account/rateLimits/read` request at startup and every 5 minutes; `usage-telemetry.ts` selects only the main `codex` bucket (never model-specific buckets such as Spark), maps whatever 5-hour/7-day windows it supplies, and omits the provider row when unavailable. Credentials stay inside the CLI and no auth material is sent to the browser. `plan-usage-latest.ts` merges both process-wide sources and replays them in the SSE init snapshot (`getLightState`) so `#planUsageChip` renders immediately on page load/reconnect. `planUsageChipEnabled()` remains the single resolver behind the checkbox and chip visibility (DISPLAY only) — the SAME `showPlanUsageLimits` setting also doubles as the server-side telemetry COLLECTION switch, read FRESH from `settings.json` by `readPlanUsageTelemetryEnabled()` at every claude session create/respawn (`TmuxManager.createSession`/`respawnPane`), never cached, with no per-session field and no per-request wire field — applies uniformly across every claude-creation path (interactive Run, cron, Ralph Loop API, quick-start) and survives a Codeman restart by construction (nothing per-session to lose). ⚠️ An ABSENT key reads as ON, the same way an absent `workspaceHooksEnabled` does: the desktop chip already shows as on for an install that never touched the setting, and the exporter posts only to this Codeman over loopback. Resolving the default in the reader is what keeps `GET /api/settings` a plain read. It briefly reconciled the key on first read (persisting `true` when absent), but `readJsonConfig()` answers `{}` for ANY read failure, not only ENOENT, and every page load hits that route, so one unlucky read replaced the whole settings file with a one-key file; pinned by `test/routes/system-routes-settings-get-plan-usage-default.test.ts`. ⚠️ The client sends `showPlanUsageLimits` in a settings save ONLY when that save FLIPS the chip relative to what the device had (`planUsageCollectionFlip()` in settings-ui.js): the chip defaults OFF on handhelds, so sending it on every save let a phone saving its font size persist `false` and switch collection off for every desktop, whose chip then went stale with no error anywhere. An explicit toggle on any device still writes the switch. Injection covers LOCAL tmux-spawned claude sessions only: the non-tmux direct-PTY fallback (`Session.startInteractive` when tmux is unavailable) and the remote/docker pane builders do not carry the flag. Registry-gated on `getCli(mode)?.capabilities.statusLineTelemetry` rather than a hardcoded mode string. **Distinct from auto-resume** (which reacts to the Claude limit _message_; this proactively shows live percentages). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`, `test/codex-plan-usage.test.ts`, `test/plan-usage-chip.test.ts`, `test/plan-usage-latest.test.ts`, `test/hooks-config.test.ts` (statusline exporter script + `readPlanUsageTelemetryEnabled`), `test/statusline-cli-flag.test.ts`.
|
||||
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON** since 1.9.3, handhelds OFF) renders compact Claude, Codex and Copilot provider rows. Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). ⚠️ **Injected as an EPHEMERAL `claude --settings` CLI flag at spawn (2026-09-07), never written to disk** — `resolveStatusLineCliCommand()`/`ensureStatusLineExporterScript()` in `hooks-config.ts` (`generateStatusLineCommand()`/`applyStatusLineConfig()` remain, but only as the legacy disk-write self-heal path: a workspace an older Codeman build touched gets its stale `.claude/settings.local.json` entry stripped the first time a session starts there again). The exporter WRAPS a user's own real statusline (`findEffectiveUserStatusLineCommand()`, walking Claude Code's own settings precedence) rather than replacing it, and POSTs the `rate_limits` blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (foreground POST in the no-wrap branch so its own stdout becomes the footer, printing NOTHING on failure, `curl -sfk` plus `|| true`, since a bare brand word on the statusline is what discussion #405 opened with; backgrounded — `>/dev/null 2>&1 </dev/null &`, closing stdin too — only in the wrap branch, where the user's own command owns the footer; `curl --max-time 5` bounds a hung, not just refused, Codeman). Main Codex subscription usage comes from the signed-in host CLI's read-only app-server `account/rateLimits/read` request at startup and every 5 minutes; `usage-telemetry.ts` selects only the main `codex` bucket (never model-specific buckets such as Spark), maps whatever 5-hour/7-day windows it supplies, and omits the provider row when unavailable. Credentials stay inside the CLI and no auth material is sent to the browser. GitHub Copilot's monthly premium-request quota comes from `GET https://api.github.com/copilot_internal/user` (the snapshot the editors show; not part of GitHub's documented REST API, so read-only, best-effort and silent: any failure, an unlimited quota or a zero entitlement means no row) at startup and every 10 minutes (`refreshCopilotPlanUsage()`, `COPILOT_USAGE_POLL_INTERVAL_MS`). `parseCopilotQuotaResponse()` (usage-telemetry.ts) keeps only `premium_interactions` as a `monthly` window with `used` and `limit` (`chat` and `completions` are unlimited on every plan seen), and `telemetrySignature()` carries the limit, so a plan change mid-month re-renders even when `used` and the reset did not move. It is sent with Copilot's OWN sign-in token, resolved by `resolveCopilotToken()` the way the CLI does (`COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN`, else the plain-text `authTokens` entry for a github.com sign-in in `<COPILOT_HOME or ~/.copilot>/config.json`); a sign-in kept in the OS keychain is not read, so it shows no row. ⚠️ The read is **on by default**, like the Codex poll: it rides the same `showPlanUsageLimits` collection switch (an absent key reads as ON, so it runs even for a phone-only user whose chip is hidden), and turning Plan Usage Limits off stops the request. ⚠️ `readCopilotPlanUsageIfEnabled()` (copilot-plan-usage.ts) holds the three gates in order: the setting, then a token, then `isCopilotAvailable()`, whose resolver ends in a login-shell probe that a host without a Copilot sign-in must never run on each tick; a null clears the row. ⚠️ The token is never logged, stored or broadcast, the request refuses redirects (`redirect: 'error'`, so a redirect cannot carry the token elsewhere), and the row is admin-only on the wire like Codex's (`deriveSseHint`, and the init snapshot's `planUsage`). `plan-usage-latest.ts` merges the three process-wide sources (a Claude statusline sample keeps the `codex` and `copilot` keys) and replays them in the SSE init snapshot (`getLightState`) so `#planUsageChip` renders immediately on page load/reconnect. `planUsageChipEnabled()` remains the single resolver behind the checkbox and chip visibility (DISPLAY only) — the SAME `showPlanUsageLimits` setting also doubles as the server-side telemetry COLLECTION switch, read FRESH from `settings.json` by `readPlanUsageTelemetryEnabled()` at every claude session create/respawn (`TmuxManager.createSession`/`respawnPane`), never cached, with no per-session field and no per-request wire field — applies uniformly across every claude-creation path (interactive Run, cron, Ralph Loop API, quick-staLine truncated
|
||||
|
||||
Further detail: the handheld OFF default comes from the mobile block in `getDefaultSettings()`. `planUsageChipEnabled()` backs the two call sites that must never disagree (the App Settings checkbox and the chip's visibility). The absent-key-reads-as-ON rule mirrors `readWorkspaceHooksEnabled()`. The same statusline payload carries a `prompt_cache` object since Claude Code v2.1.251 (`warm`, `ttl`, `expires_at` in epoch seconds, `recache_tokens_if_cold`, `misses`, `expected_rebuilds`, `hit_ratio`, `last_miss_at`, `last_miss_cause`); `StatusTelemetrySchema` lists every documented scalar field null-tolerant and keeps `causes` as `unknown` (the object gains fields across CLI versions and several are documented as possibly null, so a stricter shape would 400 the whole post and blank the footer — the recurring `.optional()`/null trap), while `parsePromptCache()` (usage-telemetry.ts) normalizes only the subset Codeman stores, so a hostile localhost payload cannot bloat state. The footer shows it as `cache:until HH:MM` (server-local clock, never a countdown: the footer only re-renders on statusline triggers) while warm and `cache:cold` otherwise. Claude re-runs the statusline when `expires_at` passes, so the footer flips to cold on its own. ⚠️ A user's OWN statusline wins the footer (the exporter then posts in the background and prints theirs), so the state ALSO lives on `Session.promptCache` (`setPromptCache()`, in `toState()` for the Session Options → Respawn readout `#promptCacheStatus`, worded by the pure `CodemanPromptCache.format` in constants.js, which also shows the hit rate, the last miss's `causes`, and — after a compaction (`/compact` or Claude Code's own idle compaction) or a tool-result clearing, the first warm report whose `recache_tokens_if_cold` is null after one that carried it — a `Context compacted at HH:MM` note that is NEVER an alert) and the route broadcasts `session:promptCache` (the STORED object, so the compaction stamp rides along) only when the report CHANGES (the statusline fires per assistant message). ⚠️ `setPromptCache()` returns a NEW object when it stamps `compactedAt`, never mutating the parser's output, which is what keeps broadcasting the stored object meaningful; the stamp needs a KNOWN prior `recache_tokens_if_cold` (a first report already inside the null window, such as a mid-session restart's, gets none), is carried while the window holds and dropped at the next request, and never reads `expected_rebuilds`: Claude Code's tracker clears the window and counts the expected rebuild in that same next request, so no single report carries both (2.1.294-2.1.296), and `adoptClaudeSessionId()` drops the whole report on a conversation switch so a post-`/clear` pane never shows the old prefix's warm-until. The last report is restored on a Codeman restart (the constructor's `promptCache`, set directly so it is not re-stamped, like `displayModel`) so the readout is not blank until the pane's next render; the readout is time-aware, so a value whose `expiresAt` has passed reads as cold rather than as a stale warm (and `parsePromptCache` validates the converted-to-ms expiry against the Date range, so a huge `expires_at` cannot render `NaN:NaN`). ⚠️ A launch without the exporter never reports, so `TmuxManager._resolveStatusLine()` records why on the mux record at every create and respawn (`MuxSession.statusLine`, published as `SessionState.statusLineExporter`: `injected`, `collection-off`, `workspace-statusline`, `remote`) and the readout names the cause instead of "not reported yet" forever. It is a spawn-time fact: flipping Plan Usage on later changes nothing until the next launch. ⚠️ This object, not the plan tier, is the source of truth for the TTL: Claude Code requests the hour only for a subscription within plan usage, and drops to five minutes on an API key, a cloud provider or extra-usage credits.
|
||||
|
||||
### Prompt-cache keep-warm
|
||||
|
||||
**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in `keepWarmEnabled` on the Respawn tab, app-wide default `promptCacheKeepWarm` and cap `promptCacheKeepWarmMaxHours` under App Settings → System → Automation, both SYNCED, default OFF and 2 h; step 2 of Discussion #554): shortly before an idle claude session's prompt cache would expire, Codeman sends ONE keep-alive turn (`KEEP_WARM_NUDGE`, under 100 characters so Claude Code never reads the burst as a paste) so the next real prompt still reads the conversation from cache. Measured on two sessions of ~110k tokens: the nudge read 109,831 tokens from cache and wrote 70, replied `ok` in three seconds with no tool use, and a real turn seven minutes past the original expiry stayed a hit while the un-nudged control re-wrote 89,882 tokens with `last_miss_cause: ttl_expired_1h`; the payoff on real usage is concentrated in gaps of one to four hours, which is why there is a cap after the last real prompt rather than an indefinite loop (2 h by default: the maintainer's simulation over 30 days of gaps came out slightly cheaper than 3 h). ⚠️ **Codeman owns the idle policy for a keep-warm session.** Claude Code 2.1.289+ can compact an idle 1h-cache session by itself at 90% of the TTL (a server-flagged experiment, `fireAtFraction` tunable down to 0.5), which on the default timing always lands a minute before the nudge and leaves only a summary to keep warm. So a launch with keep-warm on carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON with ultracode, the advisor and the statusLine exporter (`buildSpawnCommandFromRegistry`; never a second `--settings` flag, Claude Code takes one), so a keep-warm-off launch is byte-identical to one from before keep-warm (`test/cli-registry-spawn-golden.test.ts`). The `--settings` flag outranks user, project and local settings, which is why the key is conditional: everywhere else it would take Claude Code's own behaviour away with no way back. `decideIdleCompactionOptOut()` (tmux-manager.ts, pure) decides it once per create and respawn: keep-warm on (`keepWarmOn()`: the session's choice, else the app default, read fresh by `readKeepWarmSettings()`), the exporter `injected` (without `prompt_cache` reports keep-warm cannot run, and the opt-out alone would only cost the user the idle compaction), and claude's `idleCompactionSetting` gate (`capabilities.gates`, 2.1.290, the first release whose settings schema has the key; 2.1.289 already idle-compacts with no opt-out), fail-closed through `launchGatePasses()` exactly like `--name`: an unknown version means no key, and a log line says so. The result is recorded on the mux record (`MuxSession.idleCompactionOff`, like `statusLine`, persisted in mux-sessions.json) and published in the keep-warm status as `idleCompactionOff`; a session switched on after launch is kept warm best-effort (a relaunch to install the opt-out would cost exactly the re-write being avoided) and its readout says Claude Code's idle compaction may still run until its next launch, which any relaunch (dead-pane recovery, a custom-model `restartCli()`) fixes for free since every relaunch re-decides. ⚠️ The deadline is the statusline's `prompt_cache.expires_at` (`Session.promptCache`), never idle detection or the plan tier, and a `5m` lifetime is a hard stop (a nudge every four minutes costs more than the miss it prevents; API-key users get the hour from `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in `envOverrides`). ⚠️ `decideKeepWarm()` is the ONE rulebook and every guard lives in it, tested as a table: the effective opt-in (`keepWarmOn()`; a session an agent spawned, `parentSessionId`, does not inherit the app default, since an idle worker that reported done looks exactly like one that will be prompted again and most never are; its own switch still works), the launch's `statusLineExporter` is `injected` (which also stops every CLI without cache telemetry; the toggle line says why in the readout's own `PROMPT_CACHE_NOT_COLLECTED` words), a warm cache with an expiry, no `compactedAt` this gap (a compaction already replaced the context, and the stamp is dropped at the next request, which re-opens the next gap), not a `5m` lifetime, at least `KEEP_WARM_MIN_TOKENS` at stake, a live pane (a pid AND no recorded `paneExit`: an agent that exited in a `remain-on-exit` pane keeps its pid), no usage-limit pause (auto-resume's, or, read off the same pane capture as the composer, a usage-limit message whose reset is still ahead, since auto-resume tracks pauses only while it is on), no automation (respawn running, a Ralph loop active, a task assigned, or an agent long-polling `wait`), inside the cap measured from `lastRealPromptAt` (else `createdAt`), due at `expires_at` minus `KEEP_WARM_LEAD_MS` but not within `KEEP_WARM_LAST_CHANCE_MS`, idle, no open permission or question dialog, an EMPTY composer (`composerIsEmpty()` in session-submit-verifier.ts reads the last glyph line AND its Line truncated
|
||||
|
||||
### Cron jobs
|
||||
|
||||
**Cron (cron-style `CronJob`s)**: saved, named jobs with a recurring schedule (`once`/`interval`/`daily`/`weekly`), enable/disable, Run Now, next-run calc, and per-job run history (`CronJobRun`). ⚠️ **Distinct from the legacy `ScheduledRun`** (`/api/scheduled`, a run-now duration-bounded autonomous loop) — the two never interact; the legacy concept keeps the `Scheduled*` names, the recurring-job feature is `Cron*`. `CronService` (`src/cron/cron-service.ts`) owns CRUD + the 30s background due-tick (`tickDueJobs`, registered via `cleanup.setInterval` in `server.ts`; `init()` recomputes nextRunAt on boot) and **reuses the existing session layer** (create → `addSession` → `setupSessionListeners` → `startInteractive`/`startShell` → prompt via `writeViaMux`/`write`) rather than rebuilding tmux logic. Next-run math is pure/unit-tested in `cron-time.ts` (SERVER-LOCAL timezone for daily/weekly). Dup-launch guard = `lastDueKey` (jobId:fireTime); schedule is advanced BEFORE launch so a slow launch can't re-trigger. `once` jobs self-disable after firing (`completedOnce`). Persisted via `AppState.cronJobs`/`cronJobRuns` (StateStore accessors). Routes `/api/cron/jobs*` + `/api/cron/runs` (`cron-routes.ts`, `CronPort`); schema `CronJobSchema` (cross-field `superRefine`; the `.partial()` update schema does NOT re-run it); SSE `cron:*`. Frontend `cron-ui.js` (#cronModal). Claude/shell/opencode/codex/gemini/antigravity/pi agent types. Tests: `test/cron-time.test.ts`, `test/cron-service.test.ts`. Design: `docs/cron-discovery.md`.
|
||||
@@ -225,11 +233,17 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`
|
||||
|
||||
⚠️ **What a mouse-DECSET strip (full or mouse) removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach.
|
||||
|
||||
**Only claude ≥ 2.1.187 and copilot, each with mouse tracking on, forward the wheel; everything else scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. Claude repeats this exactly in its default INLINE renderer (measured on 2.1.280: `alternate_on=0`, `mouse_any_flag=0`, `history_size` grows), and swipes on iOS Safari were dead there while codex scrolled; only fullscreen claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in `~/.claude/settings.json`: alt screen plus modes 1003/1006) pages its transcript on wheel reports. So claude forwards only while the server-observed `cliMouseTracking` flag is true; a stale-false flag after a server restart falls through to the PageUp/PageDown fallback, never a dead wheel. **Copilot is the second forwarder** (1.0.95 measured in tmux: alt screen, `mouse_any_flag=1`): its mouse DECSETs are stripped so a drag selects, which also stops xterm encoding the wheel for it, so `_shouldForwardWheelToApp` sends the same SGR reports by hand while `cliMouseTracking` is true; six `\x1b[<64;…M` reports moved its transcript 18 lines, a phone swipe takes the same gate, and there is NO version gate. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS are gated by `_shouldReportMouseToCli()` (the server-observed `cliMouseTracking` flag, recorded by `_recordStrippedMouseMode` in session.ts as it strips, so only ever true for a stripped mode): codex never enables mouse tracking, so since #325 no tap report is sent there at all — click-to-position was already a measured no-op in codex, and a pane that has fallen back to a shell no longer receives `[<0;88;20M` junk.
|
||||
**Wheel forwarding requires observed mouse tracking: Claude ≥ 2.1.187, tracked Codex or tracked Copilot** (`terminal-ui.js:_shouldForwardWheelToApp`). The original #227 measurement remains valid for **Codex 0.147.0 inline**: `alternate_on=0`, `mouse_any_flag=0`, growing tmux history, and SGR wheel reports do nothing. Claude 2.1.280 inline similarly reports `alternate_on=0`, `mouse_any_flag=0`; forwarding there swallows gestures, so it keeps local scrollback. Fullscreen Claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"`) enables mouse tracking and accepts reports. **Fresh Codex 0.163.0 fullscreen measurement (2026-10-18, isolated local tmux pane)**: `alternate_on=1`, `mouse_any_flag=1`; one `\x1b[<64;10;10M` report moves the transcript one row upward, and `\x1b[<65;10;10M` restores the original screen. This is capability evidence, not a minimum-version promise: untracked Codex (including a 0.162.0 measurement (2026-10) with `mouse_any_flag=0`) keeps local history or the hollow-pane paging fallback. Codex does not publish the Claude-only CLI version field, so its gate requires the observed flag rather than a Claude version. The registry records this as `wheelForward.mode: 'always'` (observed mouse tracking is still required); Claude remains `version-gated`. A stale/false flag after startup cannot authorize forwarding. ⚠️ A strip mode alone is not evidence that a CLI consumes SGR reports. Verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR taps remain independently gated by `_shouldReportMouseToCli()`; the older #325 no-op measurement describes untracked inline Codex and must not be generalized to newer tracked fullscreen renderers. Shift/local-history opt-outs and xterm's native mouse/alternate-screen handling remain intact.
|
||||
|
||||
**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`.
|
||||
**Copilot also receives forwarded reports** (1.0.95 measured in tmux: alt screen, `mouse_any_flag=1`): its mouse DECSETs are stripped so a drag selects, which also stops xterm encoding the wheel for it, so `_shouldForwardWheelToApp` sends the same SGR reports by hand while `cliMouseTracking` is true; six `\x1b[<64;…M` reports moved its transcript 18 lines, a phone swipe takes the same gate, and there is NO version gate.
|
||||
|
||||
**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()`: the `transcriptPageKeys` capability, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretched to one second (`PAGE_KEY_DEBT_MS`) while a pre-paid page is still owed in the SAME direction, so slow small wheel ticks (40 px every 250 ms, a Firefox 1-line notch) pay the debt back instead of each pre-paying a page; after a second of quiet the debt lapses and the next small tick pages at once again. The debt also ends the moment travel repays it (reaches or crosses zero): the rest is plain travel and the 150 ms gap applies again. (3) A direction change inside a gesture (within the gap, or within one contact) drops a pre-paid debt (it belongs to the other direction) and accumulates WITHOUT pre-paying, so a +/-3 px trackpad wobble pages once for its opening event and then nothing; a reversal after the gap is a new gesture and pages at once. A tab switch also starts a new gesture. ⚠️ These gesture rules (first-event page, contact and debt lifetimes, reversal handling, notch accumulation, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector; the primary pane logs that case as `page-keys-dialog-open` so a wheel dead under a stale alert leaves a `[scroll]` line. The dialog check is the one rule outside the helper, because it is per pane: the primary pane reads `tabAlerts` for `activeSessionId`, a tile for its own `sessionId` (usually not the active one). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex is on it although it is never forwarded the wheel: newer codex draws on tmux's alternate screen, which the full strip hides from xterm, so its pane is hollow too, Line truncated
|
||||
Codex 0.163.0 approval check in that isolated read-only/on-request session: the "Would you like to run the following command?" prompt remained on `1. Yes, proceed` after SGR wheel-up and wheel-down over both the transcript and approval option rows 36/37; the requested marker file was not created. The background transcript can scroll while the approval prompt remains fixed. Codeman does alert on this prompt: `src/web/screen-blocked.ts` reads Codex dialogs off the pane (`capabilities.workDetect.blockedLine`) and broadcasts `hook:permission_prompt`, which `_onHookPermissionPrompt` (settings-ui.js) records and `updateTabAlertFromHooks` (app.js) turns into the `'action'` tab alert, and both page-key fallbacks check it. The SGR wheel path deliberately does not consult that alert, because this measurement showed wheel reports leave the approval selection unchanged. Native Codex owns its approval selection; do not mistake a hook-only unit fixture for approval-dialog evidence.
|
||||
|
||||
Native wheel travel retains upstream pixel/line/page units (100 px = 4 lines; Firefox 3-line events = 3 lines); no one-tick-per-event policy is introduced here. A fresh native gesture at or above 0.1 row (2.5px) rounds its opening travel to at least one tick (subject to the per-event cap), so an isolated 4px mouse click responds, 40px sends two ticks and 120px sends five. The signed remainder is carried forward; travel discarded by the per-event cap does not become a backlog. The signed remainder is debt repaid by subsequent same-direction events: ten 2.5px events still total one tick. Debt/fractions expire after a 150ms gap; a reversal inside that gap discards the old debt without another pre-payment. Sub-threshold jitter does not pre-pay. The first native report of each tab visit bypasses the 40 ms batching timer; later reports coalesce and only unsent bytes can be cancelled on reversal. Every queued batch belongs to its originating session, so switching tabs cannot send its bytes into the newly selected pane. Zero-delta events leave pending movement untouched. Tiles use the same helpers and their own queue/session.
|
||||
|
||||
**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (Claude ≥ 2.1.187 or tracked Codex, with observed `cliMouseTracking`; see the native measurement above), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches after the first native report, 5-tick per-event cap; no aggregate byte/tick limit). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). Touch asks `_shouldForwardTouchToApp()`, which is that gate plus one branch: in any session mode, a drag and its fling momentum also forward as SGR wheel reports while xterm itself tracks the mouse (`modes.mouseTrackingMode` other than `none`/`x10`) with SGR encoding (`_core.coreMouseService.activeEncoding === 'SGR'`, an xterm private pinned by `test/xterm-private-api.test.ts`; DEFAULT, X10 and SGR-pixel apps fail closed). xterm encodes a native wheel for such an app itself, but the touch listeners consume the drag, and a local scroll shows nothing useful: a direct-PTY app is on xterm's alternate screen, and in a tmux-backed session xterm stays in the normal buffer holding stale tmux repaints. `_wheelScrollLinesFloat()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`.
|
||||
|
||||
**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()`: the `transcriptPageKeys` capability, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretched to one second (`PAGE_KEY_DEBT_MS`) while a pre-paid page is still owed in the SAME direction, so slow small wheel ticks (40 px every 250 ms, a Firefox 1-line notch) pay the debt back instead of each pre-paying a page; after a second of quiet the debt lapses and the next small tick pages at once again. The debt also ends the moment travel repays it (reaches or crosses zero): the rest is plain travel and the 150 ms gap applies again. (3) A direction change inside a gesture (within the gap, or within one contact) drops a pre-paid debt (it belongs to the other direction) and accumulates WITHOUT pre-paying, so a +/-3 px trackpad wobble pages once for its opening event and then nothing; a reversal after the gap is a new gesture and pages at once. A tab switch also starts a new gesture. ⚠️ These gesture rules (first-event page, contact and debt lifetimes, reversal handling, notch accumulation, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector; the primary pane logs that case as `page-keys-dialog-open` so a wheel dead under a stale alert leaves a `[scroll]` line. The dialog check is the one rule outside the helper, because it is per pane: the primary pane reads `tabAlerts` for `activeSessionId`, a tile for its own `sessionId` (usually not the active one). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex retains this fallback when mouse tracking has not been observed: alternate-screen drawing is stripped from xterm and can leave a hollow pane, while PageUp still paLine truncated
|
||||
|
||||
**Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing.
|
||||
|
||||
@@ -331,6 +345,7 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t
|
||||
- **Edits are named operations, saved serially.** `createEditCoordinator` applies `createGroup` / `renameGroup` / `deleteGroup` / `reorderGroup` / `moveRef` to the rail at once, then sends ONE `PUT /api/tab-layout {baseVersion, layout}` at a time; edits made meanwhile wait and go out on the version that write returns. A 409 carries the server's layout: the in-flight operations are replayed onto it (an operation that no longer applies is dropped and reported) and re-sent, at most `maxAttempts` times; a 400 re-reads once (a second 400 is reported as a failed save, not as a race); anything else reports and re-reads. A `moveRef` moves the session together with the sessions that still follow it and marks a hand-moved child `placement: 'manual'`, mirroring the server's `moveRef`, and `normalizeLayout` keeps `placement` because whole layouts are written back. `_onTabLayoutChanged` / `_applyTabLayout` defer a read while a write is in flight and rebase unsaved edits onto a read otherwise. ⚠️ A FAILED read (`_applyTabLayout(null)`) while edits are pending keeps the held layout and the editor and re-reads after the write settles: disposing there would orphan the in-flight write, whose 409 then never gets its rebase. Any path that does drop unsaved work says so in a toast. On `pagehide`, unconfirmed operations go out in a `keepalive` PUT AND into sessionStorage as `{ owner, baseVersion, savedAt, operations }`; after reload they replay onto the fresh layout (a no-op when the keepalive landed), but only for the same owner, within `TAB_LAYOUT_PENDING_MAX_AGE_MS` (60 s), and never onto a layout older than the copy's base. A "Move to <group>" with no anchor carries no `index`, so a replay still puts the row last.
|
||||
- **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to <group>, Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline (Enter commits and refocuses the header; a commit by BLUR leaves focus where it went, since refocusing from inside the blur handler overrides the user's click). The glyph stays visible under `@media (hover: none)`: a touch tablet has no hover and no long-press `contextmenu`. Group names in "Move to" labels are quoted, so a group named "New group" or "ungrouped" cannot read (or translate, case-insensitively) like the fixed entries. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus leaving it, a resize, a second open and any full re-render. It borrows the `.tab-rail-action-menu` class for its look only: `closeTabRailActionMenu()` excludes `.tab-layout-group-action-menu`, so closing the row menu (every `session:deleted` does) cannot strand the group menu's listeners. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`.
|
||||
- **New, child and web tabs land in groups.** A group menu's "New session" (`createSessionInTabGroup`) runs ONE launch with `run({ count: 1, tabGroupId })`; every launch path puts `tabGroupId` on its create request (`POST /api/sessions` or `/api/quick-start`), and `TabLayoutService.sessionCreated(owner, id, { tabGroupId }, register)` places the session at the end of that group in the SAME versioned write that first records it. The session enters the live map inside the owner lock (`register`, rolled back if the write fails), so no queued layout edit can commit it first. The group is looked up in the creating owner's own layout, so an unknown, deleted or foreign id is ignored rather than failing the spawn, and an explicit group is a hand placement (a child placed this way is `manual`). The create response carries the committed `tabLayout`, adopted before the tab is drawn. A session spawned by another one (`parentSessionId`) and placed by nobody lands after its parent: the server's normalization puts it after the parent's rows in the parent's container, and `project({ parents })` draws a not-yet-stored child there at once. Whether it then FOLLOWS its parent is the synced `spawnedTabsFollowParent` setting (default OFF), which `sessionCreated()` reads fresh through the `childrenFollowParent` dep: off, the child is marked `manual` in that same creating write; on, it follows until moved by hand. Deciding at creation is enough, so flipping the setting leaves existing tabs alone. A hand move makes a following child `manual`; with the setting on, the row menu notes where a child sits (following, placed by hand, parent closed, parent loop; plain text, never a focusable menu item) and offers "Follow parent again" only to a `manual` child, which clears `placement` and is re-derived by the server on save. With the setting off the row menu says nothing about placement. A legacy `PUT /api/session-order` (strip or phone drag, a same-section Ctrl+Shift+{/}, a device without a layout, an admin's cross-owner order) marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`, `childrenSplitFromParent`); a PUT of the current order, or one that moves a family together, leaves every stored placement alone. The browser never echoes a create or delete through that PUT, with or without groups: the service already re-projects the order and broadcasts it (`session:orderChanged`), and `syncSessionOrder` adopts it. A child whose parent is gone stands on its own and is never re-adopted. Saved web tabs are layout refs like sessions, but OPENNESS stays per device: a grouped web tab renders (and takes an Alt+N slot) only while it is open on this device, and reopens where the layout keeps it.
|
||||
- **A case's default session group** (`caseDefaultGroups` in state.json, keyed by the layout owner key then case name; a group id or `null` for Ungrouped) is the fallback placement for a new session of that case. It is read and written ONLY through `TabLayoutService` (`caseDefaultGroup`, `setCaseDefaultGroup`, `forgetCaseDefaultGroup`, and `sessionCreated`'s `caseName` hint), under the owner lock, never by a route or the store directly. Precedence inside `sessionCreated`'s single write: an explicit `tabGroupId` found in the owner's layout, then a parent that places the child (`hasPlacingParent`, the same conditions as `pinCreatedChild`), then the case default, else normal placement. The id is looked up in the creating owner's own layout, so a deleted or foreign id never fails a spawn; a stale id is forgotten only AFTER the creating write lands (a failed write rolls back and changes nothing). `POST /api/quick-start` and `POST /api/ralph-loop/start` pass their `caseName`; `POST /api/sessions` infers it LEXICALLY from `workingDir` (`inferLocalCaseName`: a direct child of the caller's case space, or an exact linked-case path for an admin or single-user, or a Claude worktree of either, `<case>/.claude/worktrees/<name>`, which is how the Git worktrees modal launches Claude while every other CLI goes through quick-start), only for a named `workingDir` (never the server's own cwd), never for a remote attach and never touching the filesystem. Cron jobs, the one-shot run route and reboot restore keep normal placement. The Case settings selector (`onCaseDefaultGroupChanged`, session-ui.js) reads it on popover open and is redrawn from an adopted layout whose groups changed (`_refreshCaseDefaultGroupOptions` in `_applyTabLayout`; an unchanged group list is skipped, since every create, delete and reorder broadcasts a layout and a rebuild would reset an open dropdown).
|
||||
- **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, a group with no open rows is a leaf (no `aria-expanded`, no owned group), and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's.
|
||||
- **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk. `aria-posinset`/`aria-setsize` follow painted order too, so the incremental render path re-runs `_applyTabTreePositions` after it re-sorts rows in place. ⚠️ `_handleTabTreeKeydown` acts only when the key lands on the treeitem ITSELF: a key on a focused in-row control (close, overflow, the rename input) is that control's, or Enter on the overflow button re-selects the row instead of reopening its menu. ⚠️ The roving `tabindex=-1` also hides every item but the stop from the keyboard-dismiss selector's `[tabindex]` arm, which is why `MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR` lists `[role="treeitem"]` (see Dismissing the on-screen keyboard).
|
||||
|
||||
@@ -386,7 +401,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli
|
||||
|
||||
**One pattern, two consumers.** `FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` live in `constants.js`; the xterm link provider (`registerFilePathLinkProvider`, terminal-ui.js) and the response viewer's `_linkifyFilePaths()` (app.js) both reach it through `findFilePathLinks()` (below), which builds a fresh instance per call. ⚠️ Fresh per call, never one shared object: `lastIndex` is per-object state on a `/g` regex. The pattern is anchored on a known absolute root and terminated by a known extension, so a fraction (`3/4`) or a date can't match and trailing punctuation stays out. Roots include `Users` and `mnt`, without which nothing was clickable on macOS or WSL. The linear-time guard and the "terminal-ui builds from the factory" structural check are in `test/link-provider-regex.test.ts`.
|
||||
|
||||
**The chat linkifier walks text nodes.** `_linkifyFilePaths()` builds anchors with `createElement`/`textContent` on the rendered subtree, never by rebuilding sanitized markup as a string — the source is model output. Subtrees already inside an `<a>` are skipped (marked autolinks URLs; a nested anchor would swallow the click), and the anchor's text is the path verbatim so "copy code" still yields what the agent printed. `test/response-viewer-file-links.test.ts` pins both properties.
|
||||
**The chat linkifier walks text nodes.** `_linkifyFilePaths()` builds anchors with `createElement`/`textContent` on the rendered subtree, never by rebuilding sanitized markup as a string — the source is model output. Subtrees already inside an `<a>` are skipped (marked autolinks URLs; a nested anchor would swallow the click), and the anchor's text is the path verbatim so "copy code" still yields what the agent printed. Before the walk it also rewrites existing anchors whose href is a scheme-less, non-`//` path (Codex cites files as `[label](/abs/path.json)`, which otherwise opens as a same-origin URL) into the same `a.rv-path` shape, plus `rv-path-link` so the label keeps the prose font, on purpose without `FILE_PATH_LINK_PATTERN`'s root and extension filter: the href is a link target the agent chose. `test/response-viewer-file-links.test.ts` pins both properties and the anchor rewrite.
|
||||
|
||||
**Out-of-workspace paths go through the attachment routes, not the file routes.** `file-content`/`file-raw` resolve against `workingDir` and 404 anything that escapes it, which is correct and unchanged — but the paths agents most often print (a `/tmp` capture, Claude's own scratchpad, another checkout) are exactly that, so clicking one used to report "File not found" for a file sitting on disk. `openFilePreview()` now detects the case (`_isExternalPreviewPath`, a string compare for ROUTING only; the real decision stays server-side) and registers the path via `POST /api/sessions/:id/attachments` first, rendering by id. ⚠️ That registration passes `notify: false`, which suppresses ONLY the `attachment:detected` broadcast — the guard, the registry entry and the by-id routes are identical either way. Without it every click also popped an attachment card announcing the file already filling the screen. ⚠️ The click is an explicit user action on the **explicit, Origin-guarded** registration route, which is why it may cross the workspace boundary at all; the passive magic-link scanner stays force-confined. A type outside `SUPPORTED_ATTACHMENT_EXTENSIONS` (`.svg`, `.bmp`) is refused with a message naming what IS previewable, rather than the registry's own policy term.
|
||||
|
||||
@@ -541,9 +556,9 @@ Further detail on the Docker Compose supervisor (`docker-compose`), see also `do
|
||||
1. **Root-absolute URLs built at runtime.** `<base href>` only governs URLs the HTML parser resolves; `fetch('/api/data')` bypasses it and lands on Codeman's root. That is how most dashboards talk to their own backend. The `Referer`-keyed 404 fallback is only a rescue (it fires after every real route missed, and only when the browser sends a usable `Referer`), so the fix is `runtimeUrlShim()`: a small script injected right after `<base>` that rebases root-absolute and same-origin-absolute URLs into the prefix. It removes the whole class inside the iframe instead of trading security for it. ⚠️ It must be injected even when the page ships its OWN `<base>` (an early return there silently breaks exactly the pages that need it most). ⚠️ **The DOM sinks are as load-bearing as `fetch`.** Patching only `fetch`/`XHR`/`WebSocket`/`EventSource` leaves `container.innerHTML = '<img src="/api/hero?slug=x">'` and `img.src = '/api/slide'` untouched, and neither of the other layers can reach those either (`<base>` never applies to root-absolute URLs, and `rewriteHtml()` only ever sees the INITIAL document, never markup built later by page script). The symptom is precise and easy to misdiagnose as an upstream fault: the dashboard's **data** loads while every **image** stays broken. So the shim also wraps `innerHTML`/`outerHTML`/`insertAdjacentHTML`, `setAttribute`/`setAttributeNS`, and the `src`/`srcset`/`href`/`poster`/`data`/`action` property setters, with a `MutationObserver` as a last net for sinks not patched above. Every rewrite routes through the same idempotent `rw()`, which matters because unlike the server-side rewrite this one sees markup that may ALREADY be proxied (a page re-injecting its own `outerHTML` would otherwise double-prefix). The DOM half is pinned in jsdom by `test/webview-proxy.test.ts`; `curl` cannot see any of it.
|
||||
2. **CORS on same-host requests.** An opaque-origin document treats EVERY request as cross-origin, including to the very host it was served from, so its `fetch`/XHR are CORS-checked and its preflights carry `Origin: null`. Static subresources (script/css/img) are NOT CORS-checked, which is why the page renders while its API calls die with an opaque `net::ERR_FAILED`. `buildProxyCorsHeaders()` echoes the origin (omitting `allow-credentials` for `null`, which browsers reject in combination), upstream `access-control-*` headers are dropped (they describe the dashboard's origin, not the frame's), and the proxy answers preflights itself rather than relaying them. ⚠️ `registerSecurityHeaders` answers EVERY `OPTIONS` with a bare 204 before routing, and its CORS block only emits headers for localhost origins, so that short-circuit **must** exempt a valid webview capability or every preflight fails. `curl` cannot reproduce any of this because curl does not enforce CORS.
|
||||
|
||||
**Rewrites, each load-bearing** (pure + unit-tested in `src/web/webview-proxy.ts`): drop `x-frame-options` and the CSP `frame-ancestors` directive (the point of the proxy); drop `content-encoding`/`content-length` because undici's `fetch` already decoded the body (forwarding them makes the browser gunzip plaintext); rewrite `Location` for same-origin redirects only, handing CROSS-origin redirects back unchanged so this never becomes an open relay; rebase `Set-Cookie` `Path` onto the prefix and drop `Domain`; inject `<base href>` and rebase root-absolute `src`/`href`/`action`. `resolveUpstreamUrl()` returns null on anything escaping the upstream origin.
|
||||
**Rewrites, each load-bearing** (pure + unit-tested in `src/web/webview-proxy.ts`): drop `x-frame-options` and the CSP `frame-ancestors` directive (the point of the proxy); drop the response's `content-encoding`/`content-length` because undici's `fetch` already decoded the body (forwarding them makes the browser gunzip plaintext); relay the REQUEST's `content-length` whenever a body goes upstream (`relayBodyLength`, no `transfer-encoding` present), see Fastify specifics; rewrite `Location` for same-origin redirects only, handing CROSS-origin redirects back unchanged so this never becomes an open relay; rebase `Set-Cookie` `Path` onto the prefix and drop `Domain`; inject `<base href>` and rebase root-absolute `src`/`href`/`action`. `resolveUpstreamUrl()` returns null on anything escaping the upstream origin.
|
||||
|
||||
**Fastify specifics.** The proxy lives in an **encapsulated plugin scope** with `removeAllContentTypeParsers()` + a `'*'` pass-through parser, so raw bodies relay byte-for-byte while the root instance keeps its JSON parsing (and keeps `text/plain` RAW, which was a real CSRF hole once). One `app.route({ method:'GET', handler, wsHandler })` serves both HTTP and the WebSocket upgrade; **HEAD must NOT be declared** on the sibling route because `exposeHeadRoutes` already derives it and the duplicate is a startup error. ⚠️ **Every exit path in the proxy handler RETURNS `reply.send(...)`**: the handler is `async`, and a bare `return` after `reply.send(stream)` resolves the handler promise to `undefined` before the stream is consumed, so Fastify answers with an EMPTY body. HTML survives that (synchronous string payload) while every streamed asset comes back zero-length, which is a genuinely confusing failure. The root-absolute fallback hangs off `setNotFoundHandler`, so real Codeman routes always win.
|
||||
**Fastify specifics.** The proxy lives in an **encapsulated plugin scope** with `removeAllContentTypeParsers()` + a `'*'` pass-through parser, so raw bodies relay byte-for-byte while the root instance keeps its JSON parsing (and keeps `text/plain` RAW, which was a real CSRF hole once). ⚠️ **Bytes, and the FRAMING too**: undici sends a stream body with no known length as `Transfer-Encoding: chunked`, and a server that reads only `Content-Length` (Python's `http.server`, many small Go/C ones) then sees an EMPTY body. That broke every POST to such a dashboard until 2026-10-21 (a WoL page's Shut down lost its `{"confirm":true}` and was refused on every click, while curl straight at it worked), so `buildUpstreamRequestHeaders` puts the browser's `content-length` back, pinned end to end by `test/webview-proxy-body-framing.test.ts` against a length-only upstream. One `app.route({ method:'GET', handler, wsHandler })` serves both HTTP and the WebSocket upgrade; **HEAD must NOT be declared** on the sibling route because `exposeHeadRoutes` already derives it and the duplicate is a startup error. ⚠️ **Every exit path in the proxy handler RETURNS `reply.send(...)`**: the handler is `async`, and a bare `return` after `reply.send(stream)` resolves the handler promise to `undefined` before the stream is consumed, so Fastify answers with an EMPTY body. HTML survives that (synchronous string payload) while every streamed asset comes back zero-length, which is a genuinely confusing failure. The root-absolute fallback hangs off `setNotFoundHandler`, so real Codeman routes always win.
|
||||
|
||||
**Frontend** (`src/web/public/webview-tabs.js`, load order 12.5): web tabs render into `#sessionTabs` carrying `data-webview-id` instead of `data-id`, so every session-tab path (drag-and-drop, alerts, badges) skips them. ⚠️ `_renderSessionTabsImmediate()`'s `canIncremental` check must include the web-tab comparison: the session-only comparison is vacuously "unchanged" whenever session count is stable, most visibly at ZERO sessions (`0 === 0`), where opening a dashboard would never draw its tab. ⚠️ `isActive` for a session tab is `id === activeSessionId && !activeWebviewId`: `activeSessionId` stays set while a web tab is showing (the terminal keeps streaming underneath), so without that clause the debounced re-render lights two tabs at once. Frames stay MOUNTED while hidden (LRU-evicted past `maxLiveFrames`) so tab switching never reloads a dashboard.
|
||||
|
||||
@@ -633,9 +648,9 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse
|
||||
|
||||
⚠️ **That launch-time check cannot catch a swap caused by a DIFFERENT session's LATER, ordinary use** — confirmed live: a second Codex session picking a different model launched with no warning at all (nothing conflicted at that exact instant), yet it silently evicted the first session's model regardless, since llama-swap has no push notification of its own. `detectCustomModelSwapDisplacements()` (`custom-model-routes.ts`) is a separate periodic sweep (`server.ts`, `CUSTOM_MODEL_SWAP_CHECK_INTERVAL_MS` = 20s) that compares each live custom-model session's own `modelId` against what `/running` actually reports loaded, broadcasting a `custom-model:swapped-out` SSE event — shown as a global toast, never tied to the displaced session's own tab, since the whole point is telling the user before they type into it — the first time a mismatch appears, via a caller-owned de-dupe `Set` cleared once that session's own model is loaded and ready again so a later, genuinely new displacement notifies again rather than staying silently un-notified forever after the first one.
|
||||
|
||||
⚠️ **Context length is read from the REAL launch command, never `/props`** — `/props?model=`'s `default_generation_settings.n_ctx` was confirmed live to report a `--fit-ctx`-launched backend's theoretical/trained maximum rather than the real runtime-configured size (a measured 154112-vs-16384 discrepancy, caught only because the unfixed value still overflowed), so discovery parses the actual configured size straight out of `/running`'s own `cmd` field instead (`parseCtxFromCmd`: `--fit-ctx <N>` first, then plain llama.cpp `-c`/`--ctx-size`), falling back to `/props` only when `cmd` states no recognizable flag at all.
|
||||
⚠️ **Context length is read from the REAL launch command, never `/props`** — `/props?model=`'s `default_generation_settings.n_ctx` was confirmed live to report a `--fit-ctx`-launched backend's theoretical/trained maximum rather than the real runtime-configured size (a measured 154112-vs-16384 discrepancy, caught only because the unfixed value still overflowed), so discovery parses the actual configured size straight out of `/running`'s own `cmd` field instead (`parseCtxFromCmd`: `--fit-ctx <N>` first, then plain llama.cpp `-c`/`--ctx-size`), falling back to `/props` only when `cmd` states no recognizable flag at all. The same rule covers the slot check: `/props` supplies `total_slots` only when `cmd` has no `--parallel`, and it NEVER widens the pool the slots divide (an earlier cut let a larger `/props` `n_ctx` become the pool and skip the warning; the 154112-vs-16384 measurement is exactly why that is wrong). `/props` refreshes llama-swap's last-use time for the model, so `discoverModels` probes it once per launch command (`propsByCmd`, in memory, keyed by host and model id and compared with `/running`'s `cmd`); probing on every 5-minute sweep would keep a model with a `ttl` loaded forever. With an explicit `--parallel N` llama.cpp reports `n_ctx` per slot, so a `/props` context beside it is stored with a slot count of 1: it is not divided a second time, and the 1 overwrites a count an earlier discovery round stored (`applyDiscoveredModels` merges onto what was learned before, so recording no count would keep the stale one).
|
||||
|
||||
⚠️ **Claude alone gets a context-window FLOOR check, on top of the ceiling `contextLengthVar` already fixes** — `exceedsSafeContextFloor()` (gated on the registry declaring `contextLengthVar`, so a no-op for every other CLI by construction) compares a model's discovered context against `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000): confirmed live, twice, that Claude Code's own system prompt and tool schemas cost roughly 36.4K tokens on the very first message, before any conversation history exists to compact, so a smaller real context fails outright regardless of what `CLAUDE_CODE_MAX_CONTEXT_TOKENS` says (that var only controls when HISTORY gets compacted, and there is none yet on message one). Below the floor, the apply returns `{requiresContextWarning, modelId, contextLength, minSafeContextTokens}` instead of launching, shown as an in-app dialog naming the actual fix: give the model an explicit larger `-c`/`--ctx-size` in llama-swap's config instead of relying on `--fit-ctx` auto-fit, which optimizes for the biggest MODEL that fits rather than the biggest CONTEXT.
|
||||
⚠️ **Claude alone gets a context-window FLOOR check, on top of the ceiling `contextLengthVar` already fixes** — `exceedsSafeContextFloor()` (gated on the registry declaring `contextLengthVar`, so a no-op for every other CLI by construction) compares a model's discovered context against `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000): confirmed live, twice, that Claude Code's own system prompt and tool schemas cost roughly 36.4K tokens on the very first message, before any conversation history exists to compact, so a smaller real context fails outright regardless of what `CLAUDE_CODE_MAX_CONTEXT_TOKENS` says (that var only controls when HISTORY gets compacted, and there is none yet on message one). Below the floor, or when `contextLength / slotCount` (`perSlotContext()`, from `modelSlotCounts`; an unknown count is one slot) is below it, the apply returns `{requiresContextWarning, modelId, contextLength, minSafeContextTokens}` (plus `slotCount` when it is above 1) instead of launching, shown as an in-app dialog naming the actual fix: give the model an explicit larger `-c`/`--ctx-size` in llama-swap's config instead of relying on `--fit-ctx` auto-fit, which optimizes for the biggest MODEL that fits rather than the biggest CONTEXT.
|
||||
|
||||
⚠️ **A fresh, isolated `CLAUDE_CONFIG_DIR` looks like a brand-new Claude Code profile and replays its ENTIRE first-run sequence on every launch** — the theme picker, the security-notes screen, the per-project "trust this folder?" dialog, and (running with a bypass-permissions flag) a one-time warning about it, confirmed live, none of which a real, already-onboarded profile shows again. `skipFirstRunPrompts` (claude's entry only, requires `apiKeyTrustFile` since it reuses the same file) pre-seeds that same "already been through this" state: `hasCompletedOnboarding` and this session's own `projects[workingDir].hasTrustDialogAccepted` merge into the same `.claude.json` the API-key trust file already writes to, and `skipDangerousModePermissionPrompt` merges into `settings.json` (a different file, same corrupt-tolerant merge).
|
||||
|
||||
@@ -707,6 +722,36 @@ 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.
|
||||
|
||||
### 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).
|
||||
|
||||
⚠️ **The tracked list is a fixed allowlist** (`CONFIG_BACKUP_FILES`), never "everything in the data dir", and restore writes only those names whatever a manifest lists. `state.json` is deliberately NOT tracked: it is rewritten on every session event, so it would make a snapshot per event and bury the real changes. It also holds cron jobs and respawn presets, which is the documented gap.
|
||||
|
||||
⚠️ **One snapshot per real change.** The change check compares a digest of the file set with the newest manifest's `hash`, looking through `VOLATILE_KEYS`, fields a tracked file rewrites on its own: `custom-model-hosts.json` `lastDiscoveredAt` (the discovery poll), `users.json` `lastLoginAt` (every Basic login), `settings.json` `lastUsedCase` (a session started in another case), `intents.json` `recentPrompts`/`updatedAt` (every captured prompt), `docker-cases.json` `lastClaudeSessionId` (a `/clear` in a Docker case) and `webviews.json` `lastOpenedAt` (opening a web tab). Each was found the same way: one tick-driven rewrite made a snapshot per tick, and the 20 kept copies covered about 95 minutes. A new self-rewritten field in a tracked file needs an entry there and a row in the `it.each` table of `test/config-backup.test.ts`. The snapshot still stores the bytes and restore verifies the raw digest; only the comparison looks through these keys (so Compare calls `intents.json` unchanged when only prompts differ, yet a restore drops the prompts captured since).
|
||||
|
||||
⚠️ **Retention deletes only what this module made, and never the newest snapshot.** A folder counts only with the name pattern AND a parseable manifest of this origin, so a custom folder that also holds other things is safe to point at. A snapshot is aged from its SUCCESSOR's `createdAt` (its state was live until the next one was made): aging it by its own date deleted the last good copy on the first change after a quiet month, which is exactly the bad write the feature exists for. The keep count floor is 2 (`CONFIG_BACKUP_MIN_KEEP_COUNT`), since 1 deletes the state before every change. A new snapshot is never dated earlier than the newest one (`createdAt + 1`): one written while the clock read ahead (dual boot, a VM resumed with a stale clock) stayed newest after the correction, differed from every later pass, and made each one write a copy until real time caught up. The flip side, a clock once set to a future year, is a Troubleshooting entry (delete the future-dated folder).
|
||||
|
||||
⚠️ **The origin is a random id in `<data dir>/config-backup-id`, never the hostname.** Instances that share a folder (two machines on one synced folder, prod and a beta) must keep to their own snapshots, so the change check, list, prune, Compare and restore are all scoped to it; without that, each instance snapshotted on every tick and Restore offered the other machine's `users.json` and `.env`. The hostname was tried first and was wrong: a Docker Compose container gets a new one on every recreate, which unlisted every earlier snapshot, and since prune is origin-scoped they were never deleted either. The id is not the hook secret and is never tracked or restored. Listing never creates it (`readConfigBackupOrigin()`; only a pass or a restore does). A fresh or reinstalled data dir is a new owner; copying an `origin` from an old manifest into the file makes those snapshots visible again.
|
||||
|
||||
⚠️ **Retention survives a bad `settings.json`.** Each manifest records the retention in force. `runConfigBackup()` reads `settings.json` itself, because `readJsonConfig` answers `{}` for any failure, including a read that lands inside the non-atomic write of `PUT /api/settings`: a file that does not parse still gets its snapshot but prunes nothing, and a retention key ABSENT from the file (a reset) means what the newest manifest that recorded one holds (`withRecordedRetention()`, per key, in the folder the file names, so re-entering a custom folder after a reset finds that folder's own history). `GET /api/config-backups` reports the same values the pass uses, the form fills from them, and Save sends a retention key only when it was saved before or edited there (`_configBackupRetentionPayload()`), so merely displaying the recorded retention never writes it back. Restoring `settings.json` keeps the four `configBackup*` keys at their live values (an old `configBackupDir` would point the list at another folder and hide the undo copy just made), except a retention key the live file lacks, which keeps the backup's. While `settings.json` does not parse a pass reads it as `{}`, so its snapshot goes to the default folder; nothing is widened.
|
||||
|
||||
⚠️ **A custom folder is probed BOUNDED before anything reads it** (#516: it may be a network mount that stopped answering, and the GET runs every time App Settings opens). `probeBackupDir()` runs `probePathKind(dir, { pastCap: true })` before every pass and in every route, and `readEffectiveBackupSettings()` probes BEFORE the recorded-retention fallback lists the folder (that fallback once did a `readdirSync` first whenever only `configBackupDir` was set). `unknown` answers 503 `OPERATION_FAILED` through `describeUnknownPath()` and skips the pass; a missing custom folder is created only when its parent is present. The default folder needs no probe. `test/routes/config-backup-unreachable.test.ts` pins all four routes with a recording `node:fs` mock.
|
||||
|
||||
⚠️ **One walk of the folder per pass.** `runConfigBackupPass()` reads the folder once and hands the names to the temp sweep and the manifests to the retention fallback, the change check and the prune (with the snapshot it just made prepended: it is always the newest, by the date floor above). Each of the three used to list the folder itself, so every manifest was read three times per pass, every five minutes, on what may be a network folder; restore lists once too. `test/routes/config-backup-one-walk.test.ts` counts the reads.
|
||||
|
||||
⚠️ **Restore verifies everything before it writes anything.** Every file's SHA-256 against the manifest, and the id must match the name pattern (no traversal); a mismatch is a 409 with nothing changed. It also refuses a snapshot folder or file that is a symlink or has a group/world bit: this module only ever writes 0700/0600 real files, so such a thing was planted or loosened later, and rewriting it as 0600 would turn it into trusted config (`clis.json` selects the binaries Codeman spawns; `isUnsafePermissions` refuses the same bits there). The mode test is skipped where the filesystem keeps no modes (vfat/exFAT, CIFS, WSL `/mnt/d` without `metadata` report 0755/0777 for everything, and Restore would refuse every backup there): `folderKeepsModes()` decides by creating a 0600 probe file with a random name and `wx`, because the earlier fixed `.mode-probe-<pid>` name let a planted symlink be truncated and chmodded by one Compare click. Compare applies the same checks to the snapshots it reads.
|
||||
|
||||
⚠️ **The undo copy comes first, and a partial restore is reported, never hidden.** A `pre-restore` snapshot of the live files precedes the writes; it is not forced, so when nothing changed since the newest backup that backup is the undo copy and is the one reported (a forced copy spent a retention slot on a duplicate). It records the retention in force, so it cannot hide that retention from the next pass. Each file is replaced atomically (temp + rename, 0600). A file that cannot be written (a full disk, a directory in its place) does not stop the others: `ConfigBackupPartialRestoreError` carries `restored`, `failed`, `untouched` and `safetySnapshot`, and the route answers 500 with the error envelope plus those fields (and `restartRequired`) in `data`, the shape the tab-layout 409 uses. The bare 500 it replaced named neither the files already put back nor the undo copy.
|
||||
|
||||
⚠️ **What a restore wrote must reach the running server**, on the full and the partial path alike (`afterRestore()` in the route): `intentStore.reload()` (the store caches `intents.json` and rewrote it whole on the next captured prompt, reverting the restore), `reloadCliRegistry()` (a restore of `clis.json` is the one write that bypasses `mutateRegistryFile()`, see CLI registry), and in multi-user mode a `config_backup_restore` admin-audit entry (`users.json` holds accounts, password hashes, roles and bypass grants). `users.json` itself is re-read within a second by the user store. `.env`, `push-keys.json` and `settings.json` (whose watcher toggles only `PUT /api/settings` runs) apply after a restart: `RESTART_REQUIRED`, returned as `restartRequired`. Settings re-reads `/api/settings` into the form after any restore that wrote `settings.json`, partial ones included, because the form still holds the old values and the next Save would PUT them over the restored file.
|
||||
|
||||
⚠️ **Compare never returns a credential.** `REDACT_ALL_FILES` (`.env`, `users.json`, `push-keys.json`, `webhook.json` whose URL is a bearer secret, `intents.json` whose prompts carry secrets) report changed key names only; anywhere else a key matching `SECRET_KEY_RE` (token, secret, password, credential, auth, `*key`) is reported as changed without its value, and URL values lose userinfo, query and fragment. Listing returns file names and sizes only.
|
||||
|
||||
⚠️ **Admin only in multi-user mode**: all four routes answer 403 for a non-admin and the Settings group is hidden for them (`_applyDoctorAdminGate()`), because the files include credentials.
|
||||
|
||||
Tests: `test/config-backup.test.ts`, `test/config-backup-origin.test.ts`, `test/config-backup-restore-ui.test.ts` (vm harness over the real `settings-ui.js`), `test/routes/config-backup-routes.test.ts`, `test/routes/config-backup-unreachable.test.ts`, `test/routes/config-backup-one-walk.test.ts`.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Command palette and shortcut registry
|
||||
@@ -928,7 +973,9 @@ Further detail: the design follows Apple's [Designing for iPhone Duo](https://de
|
||||
|
||||
⚠️ **Long-press selects** by driving xterm's public `select()` (renderer-independent — under WebGL the glyphs are pixels and native selection cannot exist), drag or a further tap extends, and Copy goes through `copyTerminalSelection()` for its execCommand fallback on plain-HTTP installs. Three guards are load-bearing and each came from a real phone: the compat mouse pair after `touchend` (xterm focuses on mousedown and SelectionService resets the model there, so the keyboard sprang up and the selection vanished on lift), the platform's own ~500ms long-press (Android Chrome focuses the nearest editable element — the helper textarea — through no event a handler can preventDefault, so a bounded focus guard blurs it and `contextmenu` is suppressed for the gesture window), and `copyTerminalSelection()`'s closing `terminal.focus()` (right on desktop, wrong on a phone).
|
||||
|
||||
Tests: `test/terminal-touch-tap.test.ts`.
|
||||
⚠️ **A sideways drag belongs to the session swipe**, never to a tap or a scroll. The touch handler locks the dominant axis on the first travel past the tap slop (`panAxis`: `y` scrolls the buffer, `x` pans a terminal wider than its box, `swipe` when it cannot pan) and publishes it per gesture as `app._terminalGestureAxis`; a `swipe` drag is consumed without scrolling and never ends as a tap, which used to activate a link, a CLI click or a keyboard toggle on the session being left. ⚠️ Only up to the swipe's own drift limit (`SwipeHandler.maxVerticalDrift`): past it the swipe has given up, so the drag is handed back to the scroll (`y`). Otherwise a scroll whose first few pixels lean sideways (a thumb arc) locked as `swipe` and did nothing at all. `SwipeHandler` (mobile-handlers.js) reads that axis and stands aside for `x` and `y`, for a touch selection, after a second finger, and on surfaces with controls of their own (`IGNORE_SELECTOR`: the tile grid, the selection bar, the CJK input, the history banner); the phone overview and the welcome screen are deliberately not listed, since swiping from home into a session is a feature. A swipe is 80 px of travel within 500 ms (300 ms rejected an unhurried but deliberate swipe outright) with at most 50 px of vertical drift at any point: `touchmove` cancels it mid-gesture rather than only at the lift. Two more rules came from walking the sequences: a touch that starts outside the terminal (a swipe over the home screen) resets the published axis, so a terminal scroll a moment earlier cannot veto it, and a lift the terminal consumed (`defaultPrevented`: its long-press selection gesture, which on blank space leaves no selection behind to check) is never a swipe; the second-finger cancel stays set until the next touchstart, so the first finger lifting cannot clear it.
|
||||
|
||||
Tests: `test/terminal-touch-tap.test.ts`, `test/session-swipe-gesture.test.ts` (the real `SwipeHandler` and the terminal's axis lock), `test/terminal-touch-swipe-axis.browser.test.ts` (the real touch listeners: the drift handback and a clean swipe, browser suite), `test/mobile/tabs.test.ts` (CDP swipes, mobile suite).
|
||||
|
||||
### Entrance animations
|
||||
|
||||
@@ -1050,7 +1097,7 @@ Mapping is `ctrlByteFor()` (`code & 0x1f` over @A-Z[\]^_ and a-z, plus Ctrl+Spac
|
||||
|
||||
⚠️ **Delivery is a hand-built bracketed-paste frame** (`\x1b[200~` + the text with newlines mapped to `\r` + `\x1b[201~`, byte-identical to what `terminal.paste()` would emit) through `_sendInputAsync` WITHOUT `{ useMux: true }`, plus a SEPARATE Enter 120 ms later WITH it (codex drops keys that share a PTY read with a bracketed paste). Not `terminal.paste()`, for two reasons: xterm's `bracketedPasteMode` mirror is false for every session after a tab switch or reload (`terminal.reset()` in the replay re-clones the DEC modes, the tmux capture carries no `?2004h`, and tmux never forwards the pane's DECSET to a client after attach), so a paste through xterm would go out unbracketed and the CLI would submit at the first `\r`; and xterm's onData is where the local-echo paste branch flushes pending overlay text AHEAD of the block, text the composer has already taken and erased from the PTY, so the frame goes straight to the wire with the composer as the prompt's only owner. The unconditional frame is safe for a CLI that never enabled DECSET 2004 because tmux does the gating (measured against a live pane: markers stripped for a `cat -v` pane, forwarded intact to a process that had emitted `?2004h`).
|
||||
|
||||
⚠️ The frame must never take the mux fallback: `TmuxManager.sendInput()` strips every `\r` and `\n`, which welds the lines together and submits them.
|
||||
⚠️ The frame must never take the mux fallback: `TmuxManager.sendInput()` strips every `\r`, the frame's line separator, so the lines weld into one, pasted inside a second pair of markers, and get submitted.
|
||||
|
||||
⚠️ The size guard sits on the `MAX_INPUT_LENGTH` boundary (64 KiB of UTF-16 code units): `ws-routes.ts` drops a longer frame WITHOUT an ACK, which would wedge the durable queue, so `_composerMaxLength` is derived from that limit minus both markers and an oversized prompt stays a draft with a toast.
|
||||
|
||||
@@ -1068,6 +1115,8 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
|
||||
|
||||
⚠️ **A scroll ends in `touchend` too** — dismissing there closes the keyboard and drops the composer mid-read, so travel is tracked from `touchstart` and multi-touch is never a tap. Both classifiers MUST share one threshold: `initTerminal`'s `TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`, since a gesture the terminal calls a scroll and the dismiss handler calls a tap is exactly that bug.
|
||||
|
||||
⚠️ **An accessory key that keeps the keyboard up must not take focus on the press.** The keys that act on the terminal and leave the keyboard open (`ACCESSORY_REFOCUS_ACTIONS` in keyboard-accessory.js: scroll, arrows, Tab, Shift+Tab, Ctrl, Esc, clear input and the rest) cancel the `mousedown` default, the same pattern as the Attach input, so the hidden textarea keeps focus through the press; the `terminal.focus()` in the click handler stays as the fallback. Letting the tap blur the terminal and refocusing it afterwards made every such key start a keyboard dismissal, a blur/focus pair per tap that shifted the toolbar each time. A first tap on clear/compact only arms the confirm and is treated the same; the confirming tap, and keys that open a dialog or dismiss the keyboard on purpose, keep the default. Pinned against the real module by `test/accessory-keeps-terminal-focus.test.ts`.
|
||||
|
||||
⚠️ **The gate excludes `test/mobile/**`, so CI cannot see the only test covering (1)** — run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. (Not `npm test --`: the gate's config excludes that path, so a file filter pointing into it matches nothing and exits green having run zero tests.) That blind spot is why merging the two PRs, which conflicted semantically but not textually, produced a red suite with two green CI checks.
|
||||
|
||||
### SSE staleness watchdog
|
||||
@@ -1124,7 +1173,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
|
||||
|
||||
### Buffers, uploads, and terminal history
|
||||
|
||||
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. A camera-roll VIDEO (`?kind=video` on the same route, set by the client from the file's MIME type) is never buffered: it streams to disk under `MAX_PASTE_VIDEO_BYTES` 1GB (env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), sniffed from the stream's first bytes (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv) and unlinked on any failure. The client keys the flag on the MIME type, falling back to the name's extension because some Android file managers hand over a File with none; that classifier (`_promptAttachKind()`, image-input.js) is the ONE filter every pick and paste surface reads: Attach, drop, the Compose and paste-dialog pickers and the clipboard handlers. They reach it through `_admitPromptAttachments()` (same file), which also toasts what a pick skipped: the count of a mixed pick's refused files, `Unsupported file type` when none pass, and nothing for a text paste. A new surface admits through that helper, never by filtering on `_promptAttachKind()` directly, or it silently drops files with no word to the user. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced so an uploaded video also previews: mp4/webm/mov/m4v/ogv; 3gp/mkv are out), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. A DOCUMENT (the office formats pdf/docx/pptx/xlsx/zip plus the text family txt/md/csv/tsv/json/log/yaml/yml/xml, the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`) is admitted by its name's extension and buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (a magic-number signature, or UTF-8 without a NUL byte for the text family) and saved under a sanitized `[A-Za-z0-9._-]` stem of its name; sh/py/js/html are a deliberate SCOPE exclusion by extension (the server declines to store what `file-raw` would serve or an agent could run), not a content filter, since the agent can already write any of them. `_promptAttachKind()` returns `image`, `video` or `document`, and the server's dispatch mirrors it: an image MIME wins whatever the name says, a document extension takes the document branch, and a foreign extension with no image MIME is refused `Unsupported file type` only after the image sniff fails, so nothing the image path accepted by its bytes is lost. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
|
||||
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50 (env `CODEMAN_MAX_SESSIONS`, 1 to 462), max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. A camera-roll VIDEO (`?kind=video` on the same route, set by the client from the file's MIME type) is never buffered: it streams to disk under `MAX_PASTE_VIDEO_BYTES` 1GB (env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), sniffed from the stream's first bytes (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv) and unlinked on any failure. The client keys the flag on the MIME type, falling back to the name's extension because some Android file managers hand over a File with none; that classifier (`_promptAttachKind()`, image-input.js) is the ONE filter every pick and paste surface reads: Attach, drop, the Compose and paste-dialog pickers and the clipboard handlers. They reach it through `_admitPromptAttachments()` (same file), which also toasts what a pick skipped: the count of a mixed pick's refused files, `Unsupported file type` when none pass, and nothing for a text paste. A new surface admits through that helper, never by filtering on `_promptAttachKind()` directly, or it silently drops files with no word to the user. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced so an uploaded video also previews: mp4/webm/mov/m4v/ogv; 3gp/mkv are out), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. A DOCUMENT (the office formats pdf/docx/pptx/xlsx/zip plus the text family txt/md/csv/tsv/json/log/yaml/yml/xml, the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`) is admitted by its name's extension and buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (a magic-number signature, or UTF-8 without a NUL byte for the text family) and saved under a sanitized `[A-Za-z0-9._-]` stem of its name; sh/py/js/html are a deliberate SCOPE exclusion by extension (the server declines to store what `file-raw` would serve or an agent could run), not a content filter, since the agent can already write any of them. `_promptAttachKind()` returns `image`, `video` or `document`, and the server's dispatch mirrors it: an image MIME wins whatever the name says, a document extension takes the document branch, and a foreign extension with no image MIME is refused `Unsupported file type` only after the image sniff fails, so nothing the image path accepted by its bytes is lost. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
|
||||
|
||||
**Prompt uploads live in `<workspace>/.codeman-uploads/`** (`POST /api/sessions/:id/paste-image`; the names live ONLY in `src/web/paste-image-gc.ts`): IN the workspace because that is the only path that resolves identically for a local agent and a container (only the workspace is bind-mounted, at the same absolute path; `~/.codeman` is not); hidden so it stays out of `git status` and the agent's view of the repository; FLAT because a nested `<workspace>/.codeman/` IS the data dir (`join(homedir(), '.codeman')`, `src/config/instance.ts`) when the workspace is the home directory, which nothing refuses; and self-ignoring through a `.gitignore` of `*` written once with `wx`, so a file already there is the user's and stays. The pre-move `.claude-images/` receives nothing but stays readable for one release: `UPLOAD_DIR_NAMES` lists both, the hourly sweep and the delete cleanup in `cleanupSession()` go through `uploadDirs()`, and the image watcher's ignore filter reads the names. ⚠️ `uploadDirs()` is async: the working directory is a user-chosen path, so it is probed BOUNDED first (`probePathKind()`, the #516 rule; `unknown` is skipped and never touched, the hourly sweep keeps the stall cap and `cleanupSession()` passes `pastCap`, since it acts on one path at the user's request), and the lstat and realpath after it are `fs.promises` calls, never sync, because the sweep runs 30 s after boot and hourly over every live session and a linked case on a dead mount would otherwise freeze the whole server. It returns only REAL directories (lstat at the leaf, so a planted `.codeman-uploads -> /other-case/.codeman-uploads` is never listed: `readdir` follows a link to a directory, and the sweep would otherwise age out the other case's uploads through it), none that is or contains `getDataDir()` (contrived, `CODEMAN_INSTANCE=uploads` makes `~/.codeman-uploads` the data dir of a home workspace and `CODEMAN_DATA_DIR` can point inside one, but it is one check next to a recursive delete; one strictly INSIDE the data dir is listed, since it only ever holds uploads and a workspace like the `~/.codeman/app` that `install.sh` clones would otherwise never have its uploads collected), and nothing for a remote (SSH) session, whose `workingDir` is the remote path and would otherwise name a same-named LOCAL directory for the sweep and the delete. The check is made when the directories are listed: a same-user process that swaps a listed directory for a link during the sweep's awaits is accepted (Node has no `openat()`, and that actor already writes anywhere this process can). The route refuses a remote (SSH) session with 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. ⚠️ And because a live session's workspace can go onto a network mount that later stops answering, the route probes `session.workingDir` BOUNDED (`probePathKind(..., { pastCap: true })`) before the first disk touch, refusing an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED) exactly as `POST /api/sessions` does, so a mount already unreachable cannot freeze a libuv worker on every upload (`ensureUploadDir()`'s own lstat/mkdir then start on a reachable path; a mount that dies between the probe and that write is the same residual race `POST /api/sessions` accepts, since a pre-check cannot bound the write that follows it). Tests: `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, the paste-image block of `test/routes/session-routes.test.ts`, `test/routes/session-create-unreachable-path.test.ts`.
|
||||
|
||||
@@ -1170,9 +1219,9 @@ The repair is a **chmod, not a rebuild**: the prebuilt binary is fine. `scripts/
|
||||
|
||||
⚠️ `CODEMAN_DATA_DIR` is the one that matters: `getDataDir()` reads it as an ABSOLUTE override before it ever looks at `homedir()`, so one inherited from the shell (a second instance, a beta run, a shell left over from `codeman web -d`) bypasses the temp HOME entirely, and a bare suite run once overwrote the real `remote-hosts.json` with a route test's fixture. `os.homedir()` itself DOES follow `$HOME`, so the temp HOME is what redirects everything else; `CODEMAN_INSTANCE` must be stripped in the setup file and never in a hook, because `config/instance.ts` captures it into a module-level const on first import.
|
||||
|
||||
Tests that delete case trees go through `safeRmHomeTree()` (`test/mocks`), which refuses any path outside the temp HOME, so a wrong anchor leaves a temp dir behind instead of deleting `~/codeman-cases`.
|
||||
Tests that delete case trees go through `safeRmHomeTree()` (`test/mocks`), which refuses any path outside the temp HOME, so a wrong anchor leaves a temp dir behind instead of deleting `~/codeman-cases`. ⚠️ It also refuses everything unless HOME IS the throwaway dir `test/setup.ts` made (`isSandboxedHome()`: its basename starts with `TEST_HOME_PREFIX`, `codeman-vitest-`, the one thing a real home cannot have). "Under HOME" alone is vacuous when setup never ran: on 2026-10-10 a bare `vitest` run had the developer's real home as HOME, every real file was "under the test HOME", and the guard written to prevent exactly that deleted the real linked-cases registry. `isUnderTestHome()` (for single-file deletes such as `linked-cases.json`) carries the same requirement, and the shared route harness `createRouteTestHarness()` calls `assertSandboxedHome()`, so a route test built on it refuses to start against a real `~/.codeman` (a route test that builds its own app from `createMockRouteContext()` is not covered by that).
|
||||
|
||||
⚠️ Raw `npx vitest` without `--config` skips `setup.ts` and with it the temp-HOME isolation.
|
||||
⚠️ A bare `npx vitest` (no `--config`) resolves the root `vitest.config.ts`, which re-exports `config/vitest.ci.config.ts`, so it runs `setup.ts` and gets the temp HOME like `npm test`. Vitest looks for its config by walking up from cwd, so this holds from `test/` as well, while `packages/xterm-zerolag-input` keeps its own config. Before that file existed a bare run found no config, ran without the setup file, and wrote fixtures into the real `~/.codeman` (#640: a `clis.json` with a test entry, `users.json`, `webhook.json`, `.env`, settings values). `test/test-env-isolation.test.ts` pins it; a `--config` pointing at a config without `setupFiles` would still skip the isolation.
|
||||
|
||||
### install.sh: the public installer
|
||||
|
||||
|
||||
@@ -350,6 +350,6 @@ The rules the module keeps and the tests pin: it only ADDS (a name already defin
|
||||
|
||||
## GitHub Copilot CLI
|
||||
|
||||
`copilot` (npm `@github/copilot`) is a stock entry. A bare launch is `copilot`, in its own Manual Approval mode. The launch parameters follow the grok/omp shape: `model` (`--model`), `resumeId` (`--resume <id>`, an id, id prefix or session name: a single word that starts with a letter or digit, so a flag-shaped value such as `--yolo` is dropped rather than read as its own flag) and `continueSession` (`--continue`, skipped when a resume id is set), plus `allowAll` (`--yolo`: allow every tool, path and URL). The legacy config object is `copilotConfig` (`{ model, allowAll, resumeSessionId, continueSession }`). The Run button sends `allowAll: true` like the other agent CLIs' bypass switches, and `allowAll` is declared in `privilegedParams`, so a non-granted multi-user owner has it forced back to `false` (an absent config is never materialized: a bare spawn is already safe). Session history resume (Past Sessions) sends only `copilotConfig.continueSession`, which continues the most recent conversation Copilot has for that directory; `resumeSessionId` is for API callers. The tab names the model it reads from the footer (`modelDetect`). Its `altScreen` is `strip-mux-and-mouse`, like opencode: Copilot turns mouse tracking on itself, and left in, a plain drag is reported to the TUI instead of selecting (Auto Copy and copy-on-select did nothing); stripped, a drag is a local selection, clicks go through the hand-encoded tap, and the wheel is forwarded as SGR reports while `cliMouseTracking` is on. It declares `mcpConfig` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`) and no `credStore`: its token sits in `config.json` in plain text on a host without a keyring, so a Docker case is not seeded and you sign in inside the container. `env.allowedKeys` is empty on purpose: a headless host sets `COPILOT_GITHUB_TOKEN`, which the `COPILOT_` prefix admits, and `GH_TOKEN`/`GITHUB_TOKEN` stay out of the one global allowlist.
|
||||
`copilot` (npm `@github/copilot`) is a stock entry. A bare launch is `copilot`, in its own Manual Approval mode. The launch parameters follow the grok/omp shape: `model` (`--model`), `resumeId` (`--resume <id>`, an id, id prefix or session name: a single word that starts with a letter or digit, so a flag-shaped value such as `--yolo` is dropped rather than read as its own flag) and `continueSession` (`--continue`, skipped when a resume id is set), plus `allowAll` (`--yolo`: allow every tool, path and URL). The legacy config object is `copilotConfig` (`{ model, allowAll, resumeSessionId, continueSession }`). The Run button sends `allowAll: true` like the other agent CLIs' bypass switches, and `allowAll` is declared in `privilegedParams`, so a non-granted multi-user owner has it forced back to `false` (an absent config is never materialized: a bare spawn is already safe). Session history resume (Past Sessions) sends only `copilotConfig.continueSession`, which continues the most recent conversation Copilot has for that directory; `resumeSessionId` is for API callers. The tab names the model it reads from the footer (`modelDetect`). Its `altScreen` is `strip-mux-and-mouse`, like opencode: Copilot turns mouse tracking on itself, and left in, a plain drag is reported to the TUI instead of selecting (Auto Copy and copy-on-select did nothing); stripped, a drag is a local selection, clicks go through the hand-encoded tap, and the wheel is forwarded as SGR reports while `cliMouseTracking` is on. It declares `mcpConfig` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`) and no `credStore`: its token sits in `config.json` in plain text on a host without a keyring, so a Docker case is not seeded and you sign in inside the container. `env.allowedKeys` is empty on purpose: a headless host sets `COPILOT_GITHUB_TOKEN`, which the `COPILOT_` prefix admits, and `GH_TOKEN`/`GITHUB_TOKEN` stay out of the one global allowlist. **Plan usage:** the header chip gains a `Copilot` row with one `mo` window, the calendar-month premium-request quota (`quota_snapshots.premium_interactions` from `GET api.github.com/copilot_internal/user`, an undocumented endpoint the editors use; the tooltip also shows the request counts and reset). It is polled every 10 minutes by the host, read-only, and only when the CLI is installed, a token resolves (`COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN`, then `authTokens` in `<COPILOT_HOME or ~/.copilot>/config.json`) and plan-usage display is on. Any failure leaves the chip without a Copilot row; an unlimited quota has nothing to meter and is omitted.
|
||||
|
||||
Measured on 1.0.94 inside tmux: an alternate-screen TUI with mouse tracking on, whose composer is the `❯` between two rules. While a turn runs the footer reads `◉ Working esc edit prompt` (the dot alternates `◉`/`◎`), which is the `workDetect` working line. Keystrokes sent with `tmux send-keys` reach the composer, Shift+Enter inserts a newline, and a shell tool call completes inside tmux; github/copilot-cli#4180 (a driven PTY ignores input) and #4223 (a shell command never completes under tmux) report both failing on 1.0.70 to 1.0.74 and neither reproduces on 1.0.94. `COPILOT_HOME` and `COPILOT_ALLOW_ALL` are privileged env keys, so a non-granted multi-user owner cannot set them through `envOverrides`, and so are the other `COPILOT_*` variables (read from the 1.0.98 bundle, far more than `copilot help environment` lists) that switch approvals, run code with no approval row or send the sign-in token elsewhere. The `COPILOT_` prefix admits whatever a later release adds, so the list needs a look whenever the measured version moves.
|
||||
@@ -57,6 +57,7 @@ Codeman never logs Copilot in or stores its credentials.
|
||||
| Model detection | The right-hand field of the footer's last row, at rest and mid-turn. |
|
||||
| Status probe | `GET /api/copilot/status`, plus an entry in the CLI-installed probes and `codeman doctor`. |
|
||||
| MCP | The entry declares `mcpConfig` (`~/.copilot/mcp-config.json`, dialect `copilot-json`, relocated by `COPILOT_HOME`), so [MCP server sync](./cli-registry.md) treats it as a target and a source. `copilot mcp disable` is honoured (read from `settings.json`, never written). |
|
||||
| Plan usage | The header chip's **Copilot** row: the monthly premium-request quota from `GET https://api.github.com/copilot_internal/user`, read at startup and every 10 minutes with Copilot's own sign-in token (`COPILOT_GITHUB_TOKEN`, `GH_TOKEN` or `GITHUB_TOKEN`, else the plain-text token in `<COPILOT_HOME or ~/.copilot>/config.json`; a sign-in kept in the OS keychain shows no row). On by default, like the Codex usage poll: it rides Plan Usage Limits (`showPlanUsageLimits`), and turning that off stops the request. Read-only, redirects refused, the token never logged or stored; any failure leaves the row out. |
|
||||
| Docker | The agent image installs `@github/copilot`. No credential directory is seeded (see below). |
|
||||
|
||||
### Your own endpoint (BYOK)
|
||||
|
||||
+8
-7
@@ -207,9 +207,10 @@ trailing carriage return:
|
||||
| `typed` | `session.writeViaMux()` — the text as a tmux bracketed paste + Enter (a single character is typed, so a dialog answer selects) | Default; like a human pasting the prompt and pressing Enter. |
|
||||
| `paste` | `session.write()` — writes directly to the PTY/mux | Bulk paste-style delivery. |
|
||||
|
||||
> ⚠️ **Single-line only — enforced.** Like all programmatic input in Codeman,
|
||||
> multi-line delivery would be silently corrupted (Ink-based TUIs treat a
|
||||
> newline as submit; typed mode fuses lines). So newlines are **rejected**: the
|
||||
> ⚠️ **Single-line only — enforced.** A job fires unattended, and the submit
|
||||
> check that re-presses an ignored Enter cannot reliably recognise a multi-line
|
||||
> paste in the composer, so a stranded prompt would go unnoticed (in paste mode
|
||||
> the newlines reach the CLI as keys). So newlines are **rejected**: the
|
||||
> schema and the form refuse a multi-line `promptText`, and at fire time a
|
||||
> prompt file whose content is multi-line (after stripping trailing newlines)
|
||||
> fails the run with a clear `errorMessage`. Put multi-line instructions in a
|
||||
@@ -261,8 +262,8 @@ Sequence in `CronService.launch()`:
|
||||
4. **Auto-close previous session** (recurring schedules, unless
|
||||
`autoClosePreviousSession: false`): any still-open session created by this
|
||||
job's previous runs is closed via the normal session-cleanup path.
|
||||
5. The global session cap is checked (`MAX_CONCURRENT_SESSIONS = 50`). At cap →
|
||||
**`failed`**.
|
||||
5. The global session cap is checked (`MAX_CONCURRENT_SESSIONS`, 50 unless
|
||||
`CODEMAN_MAX_SESSIONS` sets it). At cap → **`failed`**.
|
||||
6. A `Session` is created **with `useMux: true`** (so it runs inside tmux),
|
||||
registered, listeners attached, and started via `startInteractive()`
|
||||
(`startShell()` for `shell` mode). Model/claudeMode come from global config.
|
||||
@@ -284,7 +285,7 @@ automatically after **Run Now**.
|
||||
> ⚠️ **Session-cap math if you disable auto-close.** With
|
||||
> `autoClosePreviousSession: false`, nothing ever closes the sessions a
|
||||
> recurring job creates — an interval job every 30 min creates 48 tabs/day and
|
||||
> hits the global 50-session cap in ~25 hours (sooner with existing tabs), after
|
||||
> hits the global session cap (50 by default, `CODEMAN_MAX_SESSIONS`) in ~25 hours (sooner with existing tabs), after
|
||||
> which **every** fire of **every** job fails with "Maximum concurrent sessions
|
||||
> reached" until you delete tabs by hand. Leave auto-close on for unattended
|
||||
> recurring jobs, or clean up sessions yourself.
|
||||
@@ -376,7 +377,7 @@ boot. Sessions the jobs create persist through the normal session-recovery path.
|
||||
| Readiness settle | 2000ms | `CRON_READY_SETTLE_MS` |
|
||||
| Run-history cap (global) | 500 | `MAX_CRON_RUN_HISTORY` (`config/map-limits.ts`) |
|
||||
| Saved-jobs cap | 100 | `MAX_CRON_JOBS` (`config/map-limits.ts`) |
|
||||
| Concurrent-session cap | 50 | `MAX_CONCURRENT_SESSIONS` |
|
||||
| Concurrent-session cap | 50 | `MAX_CONCURRENT_SESSIONS` (env `CODEMAN_MAX_SESSIONS`) |
|
||||
| Prompt-file size cap | 1 MiB | `MAX_PROMPT_FILE_BYTES` (`cron-service.ts`) |
|
||||
| `name` length | 1–200 | `CronJobSchema` |
|
||||
| `promptText` length | ≤ 100000 | `CronJobSchema` |
|
||||
|
||||
@@ -97,6 +97,13 @@ llama-swap's own launch command instead (`GET /running`'s `cmd` field —
|
||||
hand-written command might use), and only falls back to the `/props` probe
|
||||
when `cmd` states no recognizable flag at all.
|
||||
|
||||
**Parallel slots are read the same way.** `--parallel N` / `-np N` from `cmd` gives
|
||||
the slot count (`modelSlotCounts`); only when `cmd` has none does discovery read
|
||||
`total_slots` from `/props`, because a server started without the flag picks the
|
||||
number itself. `/props` never widens the context the slot check divides, and it is
|
||||
probed once per launch command rather than on every sweep, because llama-swap
|
||||
treats the call as use of the model and a `ttl` would otherwise never expire.
|
||||
|
||||
**File size is discovered too, when the server states one.** llama-swap
|
||||
writes a GB figure into an auto-discovered model's own `description`
|
||||
(`"Auto-discovered 16.35 GB - parameters auto-fitted by llama.cpp"`), parsed
|
||||
@@ -438,11 +445,11 @@ construction, never a hardcoded mode check). If the model's discovered
|
||||
context (`modelContextLengths`, from discovery above) is below
|
||||
`CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000, comfortably above the measured
|
||||
~36.4K overhead), the response is `{requiresContextWarning: true, modelId,
|
||||
contextLength, minSafeContextTokens}` instead of applying — nothing is
|
||||
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).
|
||||
Retrying with `confirmedContext: true` launches anyway (the legacy `confirmed: true` still means both questions).
|
||||
The same warning fires when the context divided across the model's parallel slots (`contextLength / slotCount`) is below the floor, because Claude Code sends several 40K-token requests at once and the slots share one pool. Retrying with `confirmedContext: true` launches anyway (the legacy `confirmed: true` still means both questions).
|
||||
|
||||
The Run-menu picker shows this as an in-app modal
|
||||
(`#customModelContextWarningModal`, matching the llama-swap conflict
|
||||
|
||||
+13
-11
@@ -183,7 +183,7 @@ curl -u admin:$PASS -X POST http://127.0.0.1:3000/api/v1/sessions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"workingDir":"/home/me/project","mode":"claude"}'
|
||||
|
||||
# Send a prompt (single-line only, and it must end with \r: Enter is sent only
|
||||
# Send a prompt (it must end with \r: Enter is sent only
|
||||
# when the input contains a carriage return; without it the text sits on the
|
||||
# session's prompt unsubmitted)
|
||||
curl -u admin:$PASS -X POST http://127.0.0.1:3000/api/v1/sessions/$ID/input \
|
||||
@@ -294,8 +294,8 @@ if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
|
||||
fi
|
||||
|
||||
# 3. Send the prompt AND register the wait in one call, so the answer cannot be
|
||||
# the previous turn's idle state. Single line only, ending in \r (otherwise
|
||||
# Enter is never sent and this wait times out on a turn that never started).
|
||||
# the previous turn's idle state. It must end in \r (otherwise Enter is never
|
||||
# sent and this wait times out on a turn that never started).
|
||||
W=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"input":"Run the test suite and summarize the failures\r","useMux":true,
|
||||
@@ -414,14 +414,16 @@ Every one of these has cost somebody real time.
|
||||
shipped bugs more than once.
|
||||
- **`text/plain` bodies stay raw.** Auto-parsing them as JSON enabled
|
||||
simple-request CSRF, so it is deliberate. Send `application/json`.
|
||||
- **Prompts are single-line and must end with `\r`.** The server splits your text
|
||||
and Enter into two separate tmux writes (Ink needs them apart), but it sends the
|
||||
Enter **only when the input contains a carriage return**. Without it your text
|
||||
sits on the prompt unsubmitted, which is the single most common "the wait
|
||||
endpoints don't work" report: the wait runs its full timeout on a turn that never
|
||||
started. Newlines inside the string are stripped rather than rejected, so
|
||||
`"echo A\necho B\r"` runs the single joined command `echo Aecho B`: send one line
|
||||
per call.
|
||||
- **Prompts must end with `\r`.** The server splits your text and Enter into two
|
||||
separate tmux writes (Ink needs them apart), but it sends the Enter **only when
|
||||
the input contains a carriage return**. Without it your text sits on the prompt
|
||||
unsubmitted, which is the single most common "the wait endpoints don't work"
|
||||
report: the wait runs its full timeout on a turn that never started. With
|
||||
`useMux`, newlines inside the string are kept: the text goes in as one paste and
|
||||
the Enter after it submits all of it, so `"echo A\necho B\r"` runs both commands
|
||||
in a shell. The check that re-presses a stranded Enter cannot reliably recognise
|
||||
a multi-line paste, so a long multi-line prompt to a just-started agent is still
|
||||
safer in a file it is told to read.
|
||||
- **`wait-output`'s `from=now` is not "printed after you asked".** tmux repaints
|
||||
the visible screen on attach, on resize, and on any TUI redraw, and a repaint
|
||||
arrives as ordinary output, so text already on screen can satisfy a fresh wait.
|
||||
|
||||
@@ -78,7 +78,7 @@ input for the session, and came back from localStorage on each reload.
|
||||
- `_sendInputAsync()` splits a paste over the frame limit into in-limit frames
|
||||
(`CodemanInputLimit.split`, constants.js, never cutting a surrogate pair). They
|
||||
go out in seq order, so the PTY sees one contiguous stream. A paste over
|
||||
`PASTE_MAX_CHARS` (1 MiB), or an oversized `useMux` write (line-oriented, never
|
||||
`PASTE_MAX_CHARS` (1 MiB), or an oversized `useMux` write (one paste plus its own Enter, never
|
||||
split), is refused with a toast and never queued.
|
||||
- The WebSocket answers an oversized sequenced frame with
|
||||
`{t:'ia', seq, err:'too_large', max}`; the client drops it with a toast. A
|
||||
|
||||
+8
-5
@@ -136,9 +136,9 @@ instead of failing silently; `Enter` and `1-9` keep working.
|
||||
|
||||
### `p` sends exactly one line
|
||||
|
||||
The composer is a single line by design, ending in a carriage return: that is the
|
||||
input contract every Codeman path follows, because multi-line text breaks the
|
||||
agent's own composer. Pasted newlines become spaces rather than being rejected, so
|
||||
The composer is a single line by design, ending in a carriage return, because the
|
||||
server's submit check cannot reliably recognise a multi-line paste: a prompt it cannot
|
||||
see on the composer line is not re-submitted when the agent ignored the first Enter. Pasted newlines become spaces rather than being rejected, so
|
||||
a paste cannot silently run a different command than the one you read.
|
||||
|
||||
## Answering approvals
|
||||
@@ -178,8 +178,11 @@ bar first named the wrong letter (tmux binds lowercase `d` to `detach-client` an
|
||||
capital `D` to `choose-client`), and once corrected it still failed for anyone who
|
||||
kept Ctrl held, because that sends `Ctrl+D`, which tmux leaves unbound. So the TUI
|
||||
claims `F1` in tmux's prefix-less key table for the length of the attach and gives
|
||||
it back afterwards. The chord still works; it is simply not what you are told to
|
||||
press.
|
||||
it back afterwards. The held-Ctrl chord (`Ctrl+B Ctrl+D`) works too, as the TUI
|
||||
claims it for the attach as well. Plain `Ctrl+B d` does not: on Codeman's socket
|
||||
the prefix table holds nothing but `send-prefix`, because the browser's terminals
|
||||
are tmux clients on the same server and must not be able to `switch-client` or
|
||||
`detach-client` themselves (`src/tmux-prefix-table.ts`).
|
||||
|
||||
You do not have to remember any of it. For as long as the attach lasts the pane
|
||||
wears a bar across the top:
|
||||
|
||||
+13
-4
@@ -72,7 +72,7 @@ output. The other CLIs expose no equivalent.
|
||||
| Docker cases, remote SSH cases | Yes | Yes |
|
||||
| Precise idle detection | Yes | Codex, Pi, OpenCode, OMP, Copilot, Gemini and Antigravity: same screen check, via their own prompt and working line. DeepSeek: reports its end of turn itself. Grok: generic cues only, unreliable |
|
||||
| Auto-resume when a usage limit resets | Yes | No |
|
||||
| Plan usage chip | Yes | No |
|
||||
| Plan usage chip | Yes | Codex (limits) and Copilot (monthly premium requests); the others no |
|
||||
| Approvals Inbox | Yes | DeepSeek and Codex as alerts (answer in the terminal); others no |
|
||||
| Read My Mind | Yes | No |
|
||||
| Ralph loop and its task tracker | Yes | No |
|
||||
@@ -146,9 +146,9 @@ Two behaviours that are deliberate and worth knowing:
|
||||
a `/` opens a live-filtering picker, arrows edit server-side state. Buffering keystrokes
|
||||
until Enter starved it, so Codex paints each keystroke at the predicted cell while the
|
||||
bytes on the wire stay byte-identical to what you typed.
|
||||
- **The wheel is not forwarded** into its transcript. Codex ignores the mouse reports
|
||||
Codeman would send, so forwarding produced a dead wheel. Scrolling in a Codex session is
|
||||
local scrollback.
|
||||
- **Wheel forwarding follows mouse tracking.** Codex with mouse tracking on gets the wheel
|
||||
forwarded like fullscreen Claude; otherwise it pages its own transcript when local history
|
||||
is empty, or scrolls locally.
|
||||
- **Work detection is Codex's own.** Codex declares its `›` composer glyph and its
|
||||
`esc to interrupt` working line, so it gets the same screen-checked idle detection Claude
|
||||
does; before 1.26.1 every Codex session reported idle for its whole life. Codex
|
||||
@@ -280,6 +280,15 @@ GitHub's `copilot`, installed with `npm install -g @github/copilot` (CLI managem
|
||||
that for you). Codeman recognises it only when `--version` answers `GitHub Copilot CLI`, and
|
||||
`GET /api/copilot/status` reports the path and version that resolved.
|
||||
|
||||
**Quota in the plan usage chip.** While Copilot CLI is installed and signed in, the chip gains
|
||||
a **Copilot** row with the calendar-month premium-request quota (requests used and the reset in
|
||||
the tooltip). This is **on by default**, like the Codex usage poll: every 10 minutes the server
|
||||
asks `api.github.com` for the quota with Copilot's own sign-in token (read-only, redirects
|
||||
refused, the token never logged or stored). The token is read from `COPILOT_GITHUB_TOKEN`,
|
||||
`GH_TOKEN` or `GITHUB_TOKEN`, or from the plain-text `~/.copilot/config.json`, so a sign-in kept
|
||||
in the OS keychain (a macOS or desktop Linux keyring) shows no row. Any failure just leaves the row out. Turn off
|
||||
**Plan Usage Limits** in App Settings to stop the request.
|
||||
|
||||
**Signing in.** Codeman does not log it in: run `copilot` once and use `/login`, or sign in
|
||||
inside a Codeman tab. Copilot keeps the token in the OS keychain when there is one; a headless
|
||||
server has none, so it offers to save it as **plain text in `~/.copilot/config.json`**, which is
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
# Config Backups
|
||||
|
||||
Codeman keeps timestamped copies of the files that hold what you have customized, so a bad
|
||||
write, a hand edit or a botched restart can be undone. It is on by default, needs no setup,
|
||||
and everything about it (where the copies go, how many are kept, how long) is a setting.
|
||||
|
||||
## What is backed up
|
||||
|
||||
A fixed list of files from the data directory (`~/.codeman/`, or the directory of the
|
||||
[instance](Core-Concepts#instances) you run):
|
||||
|
||||
| File | What it holds |
|
||||
| ---- | ------------- |
|
||||
| `settings.json` | Your preferences that sync across devices, including model and CLI settings. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `webviews.json` | Saved dashboard URLs. |
|
||||
| `push-keys.json` | Web-push keys (regenerating them drops every device's subscription). |
|
||||
| `users.json` | Multi-user accounts. **Holds password hashes.** |
|
||||
| `.env` | Credentials the TUI client reads. **Holds a password.** |
|
||||
|
||||
Only files that exist are included. This is an allowlist: nothing else in the data
|
||||
directory is ever copied.
|
||||
|
||||
**Not backed up:** `state.json` (sessions, and also cron jobs and respawn presets: it is rewritten
|
||||
with every session event, so it would bury the real changes), logs, `mux-sessions.json`, the hook
|
||||
secret, Docker seeds and caches. Back `state.json` up with your normal file backups if you rely on
|
||||
cron jobs.
|
||||
|
||||
## When a backup is made
|
||||
|
||||
- **At startup**, so the state the server found is always on record.
|
||||
- **Whenever the content changes.** The files are read and hashed every five minutes; a new
|
||||
backup is written only if the set differs from the latest backup. An idle server writes
|
||||
nothing, and the history is one entry per real change rather than one per timer tick.
|
||||
A field a file rewrites on its own is not a change, and the comparison looks through it (the
|
||||
snapshot still stores the file exactly as written, and a restore puts those fields back as they
|
||||
were): `custom-model-hosts.json` `lastDiscoveredAt` (the model-discovery poll, every five
|
||||
minutes), `users.json` `lastLoginAt`, `settings.json` `lastUsedCase`, `intents.json`
|
||||
`recentPrompts` and `updatedAt` (every captured prompt) and `docker-cases.json`
|
||||
`lastClaudeSessionId` (a Docker case's conversation id after a `/clear`) and `webviews.json`
|
||||
`lastOpenedAt` (opening a web tab). Without that, every
|
||||
tick looked like an edit and 20 copies covered only about 100 minutes. So Compare shows
|
||||
`intents.json` as unchanged when only prompts differ, yet a restore drops the prompts captured
|
||||
since.
|
||||
- **On demand**, with **Back up now** (always writes one, even if nothing changed).
|
||||
- **Before a restore**, as a `pre-restore` safety copy of the files about to be replaced (unless
|
||||
nothing changed since the newest backup).
|
||||
|
||||
## Where they go and what they are called
|
||||
|
||||
The default folder is `<data dir>/backups/config` (`~/.codeman/backups/config`). You can point
|
||||
it anywhere with an absolute path (a leading `~` is expanded). A path that is not absolute, or
|
||||
that is the data directory itself, falls back to the default.
|
||||
|
||||
Each backup is one folder named for the moment it was made, in the server's local time:
|
||||
|
||||
```
|
||||
~/.codeman/backups/config/
|
||||
cfg-20261011-120305/
|
||||
manifest.json id, createdAt (epoch ms), reason, appVersion, hash, origin,
|
||||
keepCount and keepDays in force, SHA-256 + size per file
|
||||
settings.json the original file names, unchanged
|
||||
clis.json
|
||||
custom-model-hosts.json
|
||||
...
|
||||
cfg-20261011-123012/
|
||||
cfg-20261011-123012-2/ a second backup in the same second gets -2, -3, ...
|
||||
```
|
||||
|
||||
| Part | Meaning |
|
||||
| ---- | ------- |
|
||||
| `cfg-YYYYMMDD-HHMMSS` | Date and time (server local time) the backup was made. A collision in the same second gets `-2`, `-3`, … |
|
||||
| `manifest.json` | The record of the backup: `reason`, `appVersion`, the change-check `hash`, the `origin` id, the retention in force (`keepCount`, `keepDays`), and a SHA-256 and size for every file. |
|
||||
| `reason` | `startup`, `auto` (a change was detected), `manual` (Back up now) or `pre-restore`. It is in the manifest and the Settings list, not in the folder name. |
|
||||
| `.tmp-…` | A backup being written. It is renamed into place when complete and is never listed. |
|
||||
|
||||
A folder counts as a backup only if its name matches that pattern **and** it has a valid
|
||||
`manifest.json`. That is what makes it safe to point the folder at a directory that also holds
|
||||
other things.
|
||||
|
||||
## Retention
|
||||
|
||||
Two limits, applied after every backup:
|
||||
|
||||
| Setting | Range | Default | Meaning |
|
||||
| ------- | ----- | ------- | ------- |
|
||||
| **Keep this many backups** (`configBackupKeepCount`) | 2 – 500 | 20 | Keep the newest N; older ones are removed. (1 is not offered: every change would delete the state before it.) |
|
||||
| **Delete backups older than (days)** (`configBackupKeepDays`) | 0 – 3650 | 30 | Also remove a backup once it has been replaced for longer than this. `0` means no age limit. |
|
||||
|
||||
Both apply together. Four guarantees:
|
||||
|
||||
- **The newest backup is never removed**, whatever the numbers say, so there is always one to
|
||||
go back to.
|
||||
- **Only folders Codeman made are ever removed**, never anything else that happens to live in a
|
||||
custom folder.
|
||||
- **Age counts from when a backup was replaced**, not from when it was made. Backups are made on
|
||||
change, so a config that stays the same for months has one backup whose own date keeps
|
||||
ageing; it is the copy you need the day something goes wrong, so it stays until a newer one
|
||||
has existed for longer than the limit.
|
||||
- Because backups are made on change, 20 backups reach back across 20 *changes*, however long
|
||||
that took, not 20 timer ticks.
|
||||
|
||||
## Settings → System → Config backups
|
||||
|
||||
| Control | Setting | Default |
|
||||
| ------- | ------- | ------- |
|
||||
| Back up my configuration | `configBackupEnabled` | On (absent means on) |
|
||||
| Backup folder | `configBackupDir` | empty = `<data dir>/backups/config` |
|
||||
| Keep this many backups | `configBackupKeepCount` | 20 |
|
||||
| Delete backups older than (days) | `configBackupKeepDays` | 30 |
|
||||
| **Back up now** | | |
|
||||
| The list, with **Restore**, **Compare with current** and **Changes in this backup** per entry | | |
|
||||
| **Compare** … with … **Show changes** | | |
|
||||
|
||||
The settings are saved server-side and read fresh, so a change applies on the next pass. The
|
||||
list and **Back up now** use the **saved** folder and retention: apply your edits first.
|
||||
|
||||
## Restoring
|
||||
|
||||
From the list, press **Restore** on a backup and confirm. Codeman then:
|
||||
|
||||
1. **Verifies** every file in the backup against the SHA-256 in its manifest. If any file is
|
||||
missing or does not match, nothing is changed and you get an error. It also refuses a backup
|
||||
folder or file that is a symlink or that group or others can read or write (Codeman never
|
||||
makes one like that, and writing it back as `0600` would turn it into trusted config). The
|
||||
permission test is skipped when the backup folder's filesystem does not keep file modes
|
||||
(FAT/exFAT drives, SMB shares, WSL's `/mnt/d` without the `metadata` option report the same
|
||||
mode for every file, so it would refuse every backup there). The symlink test is not. Compare
|
||||
applies the same checks to the backups it reads.
|
||||
2. **Takes a `pre-restore` backup** of the files about to be replaced, so a restore can itself
|
||||
be undone. When nothing changed since the newest backup (the same comparison an automatic
|
||||
backup makes), no copy is made and the response names that backup instead (`safetySnapshot`).
|
||||
3. **Replaces each file atomically** (written beside it, then renamed) with mode `0600`.
|
||||
|
||||
Only the tracked names are ever written, whatever a manifest lists.
|
||||
|
||||
If a file cannot be written (a full disk, a folder where the file should be), the restore still
|
||||
writes the others and then reports an error that names the files it restored, the ones it could
|
||||
not write, and the backup that holds the files as they were before. The restored files are in
|
||||
effect as after a full restore (Settings re-reads `settings.json` into the form), so fix the cause
|
||||
and restore again, or restore that backup to go back.
|
||||
|
||||
Restoring `settings.json` keeps the four backup settings (on or off, folder, how many, how many
|
||||
days) at their current values, so a restore from before a folder change does not point the list
|
||||
at the old folder and hide the `pre-restore` copy it just made. The exception is the two
|
||||
retention values: where the live `settings.json` lacks one (a reset file), the restore keeps the
|
||||
backup's own, key by key, so the next pass does not fall back to 20 copies and 30 days. The
|
||||
`pre-restore` copy records the retention in force too.
|
||||
|
||||
Settings are re-read on the next request, so reload the page. `.env` and `push-keys.json` are
|
||||
read once per process: **restart Codeman** for those to take effect (the response lists which).
|
||||
`clis.json` is reloaded by the restore itself and `users.json` is re-read within a second. Restoring `settings.json` also does not re-run the switches
|
||||
that saving in Settings applies at once (file watchers and the like), so those apply after a
|
||||
restart, and `settings.json` is listed with the restart-required files. A restore does not touch
|
||||
running sessions.
|
||||
|
||||
When the backup folder is shared (a synced folder used by two machines, or prod and a beta),
|
||||
each snapshot records a random id that the instance made once and keeps in its data directory
|
||||
(`config-backup-id`; not the hostname, which a Docker container changes on every recreate). Every
|
||||
instance only snapshots, lists, prunes, compares and restores its own. Deleting that file makes
|
||||
the instance a new owner, and its earlier snapshots stop being listed (they stay on disk). The
|
||||
same happens after a reinstall of the data directory. To see them again, copy the `origin` value
|
||||
from any of their `manifest.json` files into `<data dir>/config-backup-id`.
|
||||
|
||||
You can also restore by hand: the files in a backup folder are the originals, so copying one back
|
||||
over the file in the data directory is equivalent (stop Codeman first if you are replacing
|
||||
`clis.json` or `users.json`).
|
||||
|
||||
## Comparing backups
|
||||
|
||||
Before you restore, or to find out what changed and when, use **Compare** in the list:
|
||||
|
||||
- **Compare with current** on a backup shows what differs between that backup and the config
|
||||
running now. A restore puts back the files the backup holds; files created since are left as
|
||||
they are, and the restore response names them (`untouched`).
|
||||
- **Changes in this backup** shows what differs between the backup before it and this one.
|
||||
- The **Compare … with … Show changes** pickers compare any two backups, or a backup with the
|
||||
current config.
|
||||
|
||||
Each file that differs is listed with its changed keys (`theme.mode`, `tags[1]`, a variable name
|
||||
in `.env`) as before → after, plus files that appeared or vanished between the two states. Files
|
||||
that are the same are listed on one line. A file that is not JSON shows only that it differs, and
|
||||
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 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
|
||||
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.
|
||||
Choose a private location, or turn the feature off if you cannot.
|
||||
- **Retention survives a bad `settings.json`.** If it does not parse, a pass still takes its
|
||||
snapshot but deletes nothing. If a retention setting is missing (a reset file), the pass
|
||||
prunes with the retention the newest backup that recorded one holds, not the 20 copies and 30
|
||||
days defaults, and Settings shows those values. Saving sends a retention value only if it was
|
||||
saved before or you edited it there, so saving other settings (or re-entering a custom folder
|
||||
after a reset) does not write the displayed 20 and 30 over it; the first pass in the re-entered
|
||||
folder finds the retention its own backups recorded. While `settings.json` does not
|
||||
parse, a pass reads it as empty, so its snapshot goes to the default folder even if a custom
|
||||
folder is set or backups are switched off; nothing is widened (same modes, inside the data
|
||||
directory).
|
||||
- **Anyone who can write to a custom backup folder can plant a backup that Restore applies.**
|
||||
Restore refuses symlinks and files others can write, but a file of your own user's making
|
||||
passes, so keep the folder private.
|
||||
- In [multi-user mode](Multi-User-Mode) all of it is admin only: the list, Back up now,
|
||||
Restore and Compare, and each restore is written to the admin audit log
|
||||
(`config_backup_restore`).
|
||||
- Listing returns file names and sizes, never contents. A comparison returns changed values but
|
||||
never a credential's (see [Comparing backups](#comparing-backups)).
|
||||
|
||||
## HTTP API
|
||||
|
||||
```bash
|
||||
API=http://localhost:3000
|
||||
curl -s "$API/api/config-backups" | jq # settings, folder, list
|
||||
curl -s -X POST "$API/api/config-backups" | jq # back up now
|
||||
curl -s -X POST "$API/api/config-backups/cfg-20261011-120305/restore" | jq
|
||||
curl -s "$API/api/config-backups/diff?from=cfg-20261011-120305" | jq # that backup vs the running config
|
||||
curl -s "$API/api/config-backups/diff?from=cfg-20261011-120305&to=cfg-20261012-090000" | jq
|
||||
```
|
||||
|
||||
See [HTTP API](HTTP-API) and the endpoint reference in `docs/api-reference.md`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **The list is empty.** None of the tracked files existed yet, or backups are turned off. Press
|
||||
**Back up now**.
|
||||
- **"Backups are not available here".** You are not an admin in multi-user mode.
|
||||
- **No new backup after I changed a setting.** Changes are picked up within five minutes; **Back up
|
||||
now** takes one immediately.
|
||||
- **A restore said a file does not match its checksum.** The backup was altered or damaged on
|
||||
disk. Nothing was changed; restore an earlier one.
|
||||
- **The backups folder is growing.** Lower **Keep this many backups**. Each backup is a few
|
||||
kilobytes, but `custom-model-hosts.json` and `intents.json` can be larger.
|
||||
- **I want my own off-machine copy.** Point the folder at a location your backup tool already
|
||||
covers, mind the Security note above, and keep **Keep this many backups** high enough that
|
||||
your tool sees each one.
|
||||
|
||||
## Read next
|
||||
|
||||
- [Settings Reference](Settings-Reference) - where the controls live.
|
||||
- [Core Concepts](Core-Concepts) - what is on disk.
|
||||
- [Security](Security) - what is protected and how.
|
||||
- [Troubleshooting](Troubleshooting) - "my settings were reset".
|
||||
@@ -53,7 +53,9 @@ something other than the checked-out HEAD, or when the tree has uncommitted chan
|
||||
checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; a
|
||||
`pre-push` hook of your own is never overwritten.
|
||||
|
||||
`npm test` runs the same suite CI runs, so a green run locally means a green run there. It
|
||||
`npm test` runs the same suite CI runs, so a green run locally means a green run there. A bare
|
||||
`npx vitest run` resolves the same isolated config (a root `vitest.config.ts`), so it never
|
||||
touches your real `~/.codeman`; it did before that file existed. `npm test` also
|
||||
leaves out three suites that cannot pass on an arbitrary machine, each with its own command:
|
||||
`npm run test:browser` (Playwright, Chromium and a live server), `npm run test:mobile` (the
|
||||
same plus environment-specific screenshot baselines) and `npm run test:perf` (wall-clock
|
||||
|
||||
@@ -13,6 +13,10 @@ Codeman-side configuration:
|
||||
|
||||
- Which CLI the Run button should default to.
|
||||
- Per-case toggles (Agent Teams, 1M Opus context).
|
||||
- A **Default session group** (Case settings…): new sessions of the case land in that tab group of the
|
||||
vertical rail unless you start them from a group's own *New session* or they follow the session that
|
||||
spawned them. *Ungrouped* turns it off. It follows you across devices, never moves existing tabs, and a
|
||||
deleted group simply falls back to Ungrouped.
|
||||
- Where it runs, if it is not the local filesystem: see [Location overlays](#location-overlays).
|
||||
|
||||
Three ways to get one, all under **New or link a case…** at the bottom of the case picker
|
||||
@@ -97,7 +101,10 @@ would. That indirection buys:
|
||||
- **Real scrollback.** History is held by tmux, so reconnecting replays what happened while
|
||||
you were gone instead of starting from blank.
|
||||
- **Attach from anywhere else.** The same session is reachable from a terminal over SSH
|
||||
with `codeman tui`, or plain `tmux -L codeman attach`.
|
||||
with `codeman tui` (F1 detaches), `./scripts/tmux-manager.sh` (likewise F1), or plain
|
||||
`tmux -L codeman attach`. A plain attach has no detach key: Codeman keeps tmux's prefix
|
||||
table on its socket down to `send-prefix`, so `Ctrl+B d` does nothing there; leave it
|
||||
with `tmux -L codeman detach-client` from another shell, or bind a key yourself.
|
||||
- **Secrets off the command line.** Environment overrides are injected with socket-scoped
|
||||
`tmux setenv` rather than being visible in the spawn command.
|
||||
|
||||
@@ -135,6 +142,7 @@ Everything Codeman knows lives under `~/.codeman/`:
|
||||
| `users.json` | Multi-user accounts, mode 0600. |
|
||||
| `push-*.json` | Web push keys and subscriptions. |
|
||||
| `certs/` | Self-signed TLS for `--https`. |
|
||||
| `backups/config/` | Timestamped copies of the files above that you customized ([Config Backups](Config-Backups)). |
|
||||
|
||||
None of it needs root, none of it leaves the machine, and deleting `~/.codeman/` resets
|
||||
Codeman to a fresh install without touching your code.
|
||||
|
||||
@@ -51,10 +51,11 @@ or a server restart shifts later runs slightly. That drift is accepted rather th
|
||||
|
||||
## Prompts are single line
|
||||
|
||||
This is the rule people trip over. Programmatic input into an agent session is single line
|
||||
everywhere in Codeman, because the terminal UIs these CLIs use treat a newline as submit. A
|
||||
multi-line prompt would be silently mangled, so it is **rejected** instead: the form refuses
|
||||
it, and a prompt file whose contents are multi-line fails the run with a clear message.
|
||||
This is the rule people trip over. A cron job fires with nobody watching, and the check that
|
||||
re-presses an Enter the agent ignored cannot reliably recognise a multi-line paste in the
|
||||
composer, so a multi-line prompt could sit there unsent. In `paste` mode the newlines would
|
||||
reach the agent as keys. So a multi-line prompt is **rejected**: the form refuses it, and a
|
||||
prompt file whose contents are multi-line fails the run with a clear message.
|
||||
|
||||
For anything longer than a sentence, put the instructions in a file and make the prompt tell
|
||||
the agent to read it:
|
||||
|
||||
@@ -150,6 +150,34 @@ relying on auto-fit (`--fit-ctx`), which sizes the context around fitting the bi
|
||||
rather than the biggest context — for example adding `-c 65536` to that model's llama-swap
|
||||
entry. "Launch anyway" is still there if you want to try regardless.
|
||||
|
||||
**A context that is big enough overall can still be too small once it is split across
|
||||
slots.** `llama-server` runs several parallel slots by default (four on the build this was measured on) and,
|
||||
unless you say otherwise, they all share one pool of context. Claude Code sends more than one
|
||||
request at a time: the main session plus every subagent it runs in parallel, and each of those
|
||||
requests carries the roughly 40K tokens of system prompt and tools on its own. Four requests
|
||||
of 40K tokens do not fit in a shared 131K pool, so the server refuses them with HTTP 500
|
||||
`Context size has been exceeded`, even though any single request would have been fine.
|
||||
Discovery therefore also records how many slots each loaded model has. It reads an explicit
|
||||
`--parallel` or `-np` from the launch command, and otherwise asks the server for
|
||||
`total_slots`, because a server started without the flag chooses the number itself. When the
|
||||
context divided by the slot count drops below what Claude Code needs, picking that model shows
|
||||
the same in-app dialog, this time explaining the shared slots. The division uses the context
|
||||
Codeman read from the launch command (`--fit-ctx` or `-c`), never a larger figure from the
|
||||
server's `/props`, which has been measured reporting far more than the server really runs
|
||||
with. So a default llama-swap model with auto slots shows the dialog, which names the fix and
|
||||
lets you launch anyway. The fix is a single slot:
|
||||
|
||||
```yaml
|
||||
cmd: >-
|
||||
llama-server -m /models/your-model.gguf --parallel 1 ...
|
||||
```
|
||||
|
||||
With one slot the active conversation gets the whole context, requests from parallel
|
||||
subagents queue behind it instead of failing, and Claude Code's repeated prompt prefix stays
|
||||
in the cache instead of being processed again in a different slot each time. The alternative
|
||||
is a context large enough that every slot can hold 40K tokens. A slot count Codeman could not
|
||||
read is treated as one slot, never guessed.
|
||||
|
||||
## Which harnesses actually work
|
||||
|
||||
| Harness | Status |
|
||||
|
||||
@@ -125,11 +125,14 @@ These are set in every managed session. Read them rather than hardcoding anythin
|
||||
|
||||
Read these before writing any code. Each one has cost somebody an afternoon.
|
||||
|
||||
1. **Input is single line and must end with `\r`.** Enter fires only when the payload
|
||||
contains a carriage return. Without it the text sits unsubmitted on the prompt, the
|
||||
request still succeeds, and a combined wait burns its full timeout on a turn that never
|
||||
started. Embedded newlines are stripped rather than rejected, so `"echo A\necho B\r"` runs
|
||||
the joined `echo Aecho B`. One line per call.
|
||||
1. **Input must end with `\r`.** Enter fires only when the payload contains a carriage
|
||||
return. Without it the text sits unsubmitted on the prompt, the request still succeeds,
|
||||
and a combined wait burns its full timeout on a turn that never started. With
|
||||
`useMux`, embedded newlines are kept: the text goes in as one paste and the Enter after
|
||||
it submits all of it, so `"echo A\necho B\r"` runs both commands in a shell. The check
|
||||
that re-presses a stranded Enter cannot reliably recognise a multi-line paste, so a
|
||||
long multi-line prompt to a just-started agent is still safer in a file it is told to
|
||||
read.
|
||||
2. **Make input idempotent.** Send a stable `clientId` and a monotonic per-session `seq`. The
|
||||
server deduplicates, so a retry after a dropped connection cannot double-deliver.
|
||||
3. **Auth.** With `CODEMAN_PASSWORD` set, use HTTP Basic or the session cookie. A missing
|
||||
|
||||
+6
-2
@@ -29,8 +29,12 @@ are Claude-only; [Agent CLIs](Agent-CLIs) has the table.
|
||||
|
||||
### Does Codeman send my code or prompts anywhere?
|
||||
|
||||
No. There is no telemetry, no analytics, and no phone-home. The only network traffic
|
||||
Codeman itself makes is between your browser and your server.
|
||||
No. There is no telemetry, no analytics, and no phone-home. Beyond the traffic between
|
||||
your browser and your server, the server makes a few requests of its own for features you use
|
||||
(the Codex usage poll, web push, webhooks). One is on by default: while GitHub Copilot CLI is
|
||||
installed and signed in, every 10 minutes the server asks `api.github.com` for your own
|
||||
premium-request quota with Copilot's own token. Turn off Plan Usage Limits in App Settings to
|
||||
stop it (see [Agent CLIs](Agent-CLIs)).
|
||||
|
||||
Your agent CLI is a separate matter: Claude Code talks to Anthropic, Codex talks to OpenAI,
|
||||
and so on. That traffic is the CLI's, on your own account, exactly as it would be in a
|
||||
|
||||
@@ -72,7 +72,7 @@ Roughly 235 handlers across 26 route modules. By domain:
|
||||
| ------------------- | -------- | --------------------------------------------------- |
|
||||
| System | 56 | Status, settings, digest, updates, tunnel. |
|
||||
| Sessions | 34 | Create, input, terminal, wait, last response, kill. |
|
||||
| Cases | 37 | Create, link, clone, worktrees, remote and docker cases. |
|
||||
| Cases | 39 | Create, link, clone, worktrees, remote and docker cases. |
|
||||
| Files | 17 | Preview, edit, raw, attachments, path picker. |
|
||||
| Orchestrator | 10 | Plans and phases. |
|
||||
| Ralph | 9 | Loop control and configuration. |
|
||||
@@ -173,6 +173,10 @@ curl -s "$API/api/subagents" | jq # background agents
|
||||
curl -s "$API/api/search?q=deploy" | jq # cross-session search
|
||||
curl -s "$API/api/mcp-sync" | jq # preview MCP server sync (opt-in: 403 until mcpSyncEnabled is on)
|
||||
curl -s -X POST "$API/api/mcp-sync" | jq # apply it: add missing servers to each CLI config, never edit/remove
|
||||
curl -s "$API/api/config-backups" | jq # config snapshots: folder, retention, list (admin only in multi-user mode)
|
||||
curl -s -X POST "$API/api/config-backups" | jq # back up the customized config files now
|
||||
curl -s -X POST "$API/api/config-backups/cfg-20261011-120000/restore" | jq # put a snapshot back (after a pre-restore copy)
|
||||
curl -s "$API/api/config-backups/diff?from=cfg-20261011-120000" | jq # what differs from the running config (`to=` another snapshot id to compare two)
|
||||
|
||||
# with ID set to a session id:
|
||||
curl -s "$API/api/sessions/$ID/last-response" | jq -r '.data.text' # last answer, from the transcript (claude, codex, deepseek)
|
||||
@@ -185,7 +189,7 @@ curl -s -X POST "$API/api/sessions/$ID/custom-model" -H 'Content-Type: applicati
|
||||
|
||||
| Limit | Default |
|
||||
| --------------------- | ------------------------------------------ |
|
||||
| Max sessions | 50 |
|
||||
| Max sessions | 50 (`CODEMAN_MAX_SESSIONS`) |
|
||||
| Max agent windows | 500 |
|
||||
| Max SSE clients | 100 |
|
||||
| Terminal buffer | 32 MB per session |
|
||||
|
||||
@@ -113,6 +113,7 @@ codeman web # then open http://localhost:3000
|
||||
| Page | What it answers |
|
||||
| --------------------------------------------- | -------------------------------------------------- |
|
||||
| [Running As A Service](Running-As-A-Service) | How do I keep it up across reboots, and update it? |
|
||||
| [Config Backups](Config-Backups) | How do I get my settings back after they were changed or lost? |
|
||||
| [Troubleshooting](Troubleshooting) | Why is it doing that? |
|
||||
| [FAQ](FAQ) | The questions that keep coming up. |
|
||||
| [Contributing](Contributing) | How do I send a fix? |
|
||||
|
||||
@@ -125,9 +125,10 @@ Sending prompts over the API has one rule that catches everyone: **the payload m
|
||||
`\r`** or Enter is never sent. The request still succeeds, the text sits unsubmitted in the
|
||||
composer, and any wait burns its whole timeout on a turn that never started.
|
||||
|
||||
Input is also **single line**. Embedded newlines are stripped rather than rejected, so
|
||||
`"echo A\necho B\r"` runs the joined `echo Aecho B`. Put multi-line content in a file and
|
||||
tell the agent to read it.
|
||||
With `useMux`, embedded newlines are kept: multi-line input goes in as one paste and the
|
||||
Enter after it submits all of it, so `"echo A\necho B\r"` runs both commands in a shell. The
|
||||
check that re-presses a stranded Enter cannot reliably recognise a multi-line paste, so long
|
||||
multi-line content is still safer in a file the agent is told to read.
|
||||
|
||||
See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent).
|
||||
|
||||
|
||||
@@ -12,7 +12,10 @@ Getting Codeman onto a machine, verifying it works, updating it, and removing it
|
||||
| **An agent CLI** | At least one of [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [OMP](https://github.com/can1357/oh-my-pi). Plain shell sessions need none. See [Agent CLIs](Agent-CLIs). |
|
||||
|
||||
Codeman itself sends no telemetry and phones no home. The only network traffic is your
|
||||
browser to your server, and whatever the agent CLI you chose does on its own.
|
||||
browser to your server, and whatever the agent CLI you chose does on its own, plus the
|
||||
server's own few requests (Codex usage, web push, webhooks) and, on by default while Copilot
|
||||
CLI is installed and signed in, a read of your own GitHub Copilot quota every 10 minutes
|
||||
(Plan Usage Limits in App Settings turns it off; see [Agent CLIs](Agent-CLIs)).
|
||||
|
||||
## Route A: the installer (recommended)
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ limit-paused. Without that, the next cycle would fire `/clear` and wipe the conv
|
||||
are waiting to resume. This is the single most useful setting for overnight runs on a
|
||||
subscription plan.
|
||||
|
||||
## The prompt cache readout
|
||||
## The prompt cache: readout and keep-warm
|
||||
|
||||
**Claude only.** On the Respawn tab, above the loop controls.
|
||||
|
||||
@@ -109,10 +109,19 @@ Claude Code bills a turn against the cached conversation while that cache is war
|
||||
|
||||
Claude Code 2.1.251 and newer reports the cache state on the statusline after every response, and Codeman's statusline exporter keeps it per session. The row shows **Warm until HH:MM** with the lifetime and the tokens that would be re-cached if it went cold, or **Cold** with the size of the re-cache the next turn pays. When Claude reports them it also shows the session's cache hit rate and the cause of the last cache miss (for example `ttl_expired_1h` or `tools_changed`), so a surprise miss has a reason next to it. If the conversation is compacted while it stays warm (Claude Code now does this on its own on large idle sessions, a manual `/compact` does it too, and so does a clearing of old tool results), the row notes **Context compacted at HH:MM** until the next turn: Claude Code reports no re-cache size until then. None of this raises an alert. The row refreshes live and flips to cold on its own when the lifetime passes, and the last state is kept across a Codeman restart so it is not blank until the session's next turn. Sessions without a statusline of their own also get a short `cache:until HH:MM` or `cache:cold` group in the terminal footer. The readout rides the plan usage chip's telemetry collection, which is on by default on desktop.
|
||||
|
||||
**Keep the cache warm while idle** is the switch under the readout. With it on, Codeman sends a one-line keep-alive turn (`Codeman cache keep-alive: no action needed, reply with just "ok".`) about five minutes before the cache would expire. Claude answers `ok`, the request refreshes the cache for another hour, and your next real prompt reads the conversation from cache instead of re-processing it. Measured on sessions of about 110k tokens, the keep-alive cost a cache read plus a one-word reply, a small fraction of the re-write it prevented.
|
||||
|
||||
It only fires when there is nothing else going on: the session is idle with an empty prompt (Claude Code's greyed-out prompt suggestion counts as empty), no permission or question dialog is open, nobody has typed in the last few minutes, no respawn loop, Ralph loop or waiting agent is driving the session, and the session is not paused on a usage limit. If any of those hold it waits a minute and looks again, and gives up a minute before the expiry rather than race it; a keep-alive that could not be sent is retried the same way. It also stops by itself a few hours after your last prompt (**Keep warm for at most**, under App Settings → System → Automation, default 2 hours), because the saving is concentrated in gaps of one to four hours and an overnight run of hourly keep-alives would cost more than the single cold turn in the morning. It stands down for the rest of a gap once the context has been compacted. The readout's Status line gains a `Keep-alive:` line that says what it is doing: the next keep-alive and when the window closes, waiting and why, sent at a time with a count, or stopped and why. A session started without the cache readout (Plan Usage off at launch, a workspace with its own statusLine, a remote or Docker session) cannot be kept warm: the switch is greyed out and cannot be switched on (one that is already on can still be switched off), and the Status line says why.
|
||||
|
||||
Recent Claude Code builds can compact a large idle session on their own shortly before the cache expires, which would leave only a summary to keep warm. So a session **started** with keep-warm on also launches with Claude Code's idle compaction switched off (Claude Code 2.1.290 or newer); Codeman then owns the idle policy for it. A session you switch on after it started is still kept warm, but Claude Code may compact it first until its next launch, and the Status line says so; one you switch off keeps Claude Code's idle compaction off until its next launch, and the Status line says that too. Sessions with keep-warm off launch exactly as before.
|
||||
|
||||
**Keep prompt caches warm** in the same Automation group is the default for sessions that have not chosen on their Respawn tab; the per-session switch always wins. A session another agent started (a worker) does not follow the default: most workers are never prompted again once they report done, so turn it on for one by hand if you will come back to it. The keep-alive turn is Codeman's, not yours: it does not restart the **Keep warm for at most** window, does not rename a tab, Read My Mind ignores it, and the response it ends with raises no alert, toast, push or webhook, nor a row in the Away digest or the session's Summary. It never clears an alert that was already waiting for you. It is refused on the five-minute cache lifetime, which is what an API key, a cloud provider or extra-usage credits get; a session on an API key can ask Claude Code for the hour with `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in its environment overrides.
|
||||
|
||||
## The plan usage chip
|
||||
|
||||
**Claude only.** A header chip showing live subscription usage, on by default on desktop and
|
||||
off on phones.
|
||||
A header chip showing live subscription usage: Claude (the 5-hour and weekly windows), Codex
|
||||
(its limits) and, while Copilot CLI is installed and signed in, Copilot's monthly premium-request
|
||||
quota. On by default on desktop and off on phones.
|
||||
|
||||
It works through a status line exporter that Codeman hands to `claude` as an ephemeral
|
||||
setting when it spawns the session, never written to disk, which posts Claude's own rate
|
||||
@@ -120,7 +129,8 @@ limit data back to Codeman. Your own status line (project-local, project, then
|
||||
`~/.claude/settings.json`) is wrapped and printed through, and a `claude` you run by hand
|
||||
outside Codeman sees nothing of it. Workspaces an older Codeman wrote the exporter into are
|
||||
cleaned up the first time a session starts there. Codex limits come from a read-only poll of
|
||||
its own app-server. Known limit: sessions inside a Docker case do not feed the chip yet.
|
||||
its own app-server, and the Copilot quota from a read-only request to `api.github.com` every
|
||||
10 minutes with Copilot's own token (on by default; Plan Usage Limits off stops it). Known limit: sessions inside a Docker case do not feed the chip yet.
|
||||
|
||||
The chip and the exporter are the same setting. Turning the chip on without the exporter
|
||||
would leave it showing a dash forever, so resolve it in one place: **App Settings**. A
|
||||
|
||||
@@ -57,11 +57,12 @@ default. See [Tile Grid](Tile-Grid).
|
||||
|
||||
## Everything else
|
||||
|
||||
| Shortcut | Action |
|
||||
| -------------- | ------------------------------- |
|
||||
| `Ctrl+Shift+V` | Toggle voice input. |
|
||||
| `Ctrl+?` | Shortcut reference overlay. |
|
||||
| `Escape` | Close panels and modals. |
|
||||
| Shortcut | Action |
|
||||
| ----------------- | ------------------------------------------------------------ |
|
||||
| `Ctrl+Shift+V` | Toggle voice input. |
|
||||
| `Ctrl+?` | Shortcut reference overlay. |
|
||||
| `Escape` | Close panels and modals. |
|
||||
| `Alt+←` / `Alt+→` | File Viewer window: back / forward between previewed files. |
|
||||
|
||||
## Rebinding
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@ a bottom sheet rather than a dropdown. On a folding phone (iPhone Duo) dialogs s
|
||||
the hinge, and opening or closing the device is treated as the device changing shape, never
|
||||
as the keyboard appearing.
|
||||
|
||||
**Swipe left and right** on the terminal to switch sessions.
|
||||
**Swipe left and right** on the terminal to switch sessions. A visible text selection pauses
|
||||
the swipe, and a terminal wider than the screen pans sideways instead.
|
||||
|
||||
## The home screen
|
||||
|
||||
@@ -131,11 +132,12 @@ already writing in the editor.
|
||||
|
||||
## Tapping, links and copying
|
||||
|
||||
- **Tap a link** in terminal output and it opens in a new tab. Same for a link in an agent's
|
||||
- **Tap a link** in terminal output and it opens in a new tab. Same for a web link in an agent's
|
||||
answer in the response viewer — it opens a tab rather than navigating the dashboard away,
|
||||
which on a phone would unload the whole session view.
|
||||
- **Tap a file path** an agent printed and the file-preview overlay opens (Markdown rendered,
|
||||
HTML as a page); a log or other plain-text path opens the log viewer. Works in scrolled-up
|
||||
- **Tap a file path** an agent printed, or a link to one in its answer (Codex writes
|
||||
`[report](/tmp/report.json)`), and the file-preview overlay opens (Markdown rendered, HTML
|
||||
as a page); a log or other plain-text path opens the log viewer. Works in scrolled-up
|
||||
transcript too.
|
||||
- A tap on the prose *beside* a link still places the cursor as usual, and a tap on a dialog's
|
||||
numbered choice still answers the dialog even when the row contains a path — the dialog wins,
|
||||
|
||||
+14
-2
@@ -101,12 +101,24 @@ Stated plainly, because a security page that only lists strengths is not useful:
|
||||
|
||||
## Privacy
|
||||
|
||||
No telemetry, no analytics, no phone-home. Codeman's only network traffic is between your
|
||||
browser and your server. Your agent CLI's traffic is its own, on your account.
|
||||
No telemetry, no analytics, no phone-home. Your agent CLI's traffic is its own, on your
|
||||
account. Beyond the traffic between your browser and your server, the server itself makes a few
|
||||
requests for features you use: the Codex usage poll, web push, and webhooks if you set one up.
|
||||
|
||||
The GitHub Copilot quota read behind the plan usage chip's **Copilot** row is **on by default
|
||||
while Copilot CLI is installed and signed in** (an absent plan-usage setting reads as on, even
|
||||
for a phone-only user whose chip is hidden). Every 10 minutes the server asks `api.github.com`,
|
||||
and nothing else, for your premium-request quota, with Copilot's own sign-in token. The token is
|
||||
never logged and redirects are refused. Turn off **Plan Usage Limits** in App Settings to stop it.
|
||||
|
||||
Two features send data outward, both off by default and both stated where they appear: voice
|
||||
dictation through your Claude login, and the Read My Mind prediction call.
|
||||
|
||||
[Config Backups](Config-Backups) copies some credential-bearing files (`.env`, `users.json`,
|
||||
`custom-model-hosts.json`, `push-keys.json`, `webhook.json`) into a backup folder. The default folder is inside
|
||||
the data directory, and every backup is mode `0700`/`0600`, so nothing new is exposed. Only a
|
||||
folder you choose yourself can move them somewhere less private; admin only in multi-user mode.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Never in a public issue.**
|
||||
|
||||
@@ -184,19 +184,27 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts
|
||||
|
||||
### System
|
||||
|
||||
`CLAUDE.md` template for new cases, default working directory, the image watcher, and
|
||||
`CLAUDE.md` template for new cases, default working directory, the image watcher, the prompt-cache keep-warm default (**Keep prompt caches warm**, off) and its cap (**Keep warm for at most**, 2 hours after a session's last prompt; see [Keeping Agents Running](Keeping-Agents-Running)), and
|
||||
Cloudflare tunnel controls including the tunnel URL. The **Diagnostics** group runs
|
||||
`codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office
|
||||
tools with their versions and install hints (admin only in multi-user mode). In multi-user
|
||||
mode, the **Users** administration entry is injected here.
|
||||
|
||||
The **Config backups** group controls timestamped copies of your configuration files: an
|
||||
on/off switch (on by default), the backup folder (default `~/.codeman/backups/config`), how
|
||||
many backups to keep (2 to 500, default 20) and how many days to keep them (0 to 3650, default
|
||||
30; 0 means no age limit). A value that is not saved in `settings.json` (a reset file) is whatever
|
||||
the newest backup in that folder recorded, and the default only when none did, plus **Back up now** and a list with **Restore**. These are
|
||||
server-side settings, read fresh. Details, naming and restore behaviour are on
|
||||
[Config Backups](Config-Backups).
|
||||
|
||||
## Session Options
|
||||
|
||||
Per session, from the tab.
|
||||
|
||||
| Panel | Contains |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------- |
|
||||
| **Respawn** | Auto-resume on usage limit, the respawn cycle configuration, presets, duration. See [Keeping Agents Running](Keeping-Agents-Running). |
|
||||
| **Respawn** | Auto-resume on usage limit, the prompt cache readout and keep-warm switch, the respawn cycle configuration, presets, duration. See [Keeping Agents Running](Keeping-Agents-Running). |
|
||||
| **Session** | Name, working directory, environment overrides, per-tab pop-out override. |
|
||||
| **Ralph / Todo** | Loop configuration, iteration and todo caps, circuit breaker reset. See [Autonomous Loops](Autonomous-Loops). |
|
||||
| **Summary** | What this session has done: tokens, activity, run summary. |
|
||||
@@ -221,6 +229,7 @@ Some things are configured before the server starts, not in the UI:
|
||||
| `CODEMAN_FILE_PICKER_ROOTS` | Extra roots for the path picker. |
|
||||
| `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledges exposing the server with no password. |
|
||||
| `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). |
|
||||
| `CODEMAN_MAX_SESSIONS` | Cap on concurrent sessions. 50 by default, 1 to 462 (a larger value is clamped to 462, anything that is not a positive integer falls back to 50). Read at startup. The 462 assumes the default `CODEMAN_MAX_WEBVIEWS` and no pinned closed sessions, which share the same 512-entry tab layout. In multi-user mode the per-user cap defaults to half of it unless `CODEMAN_MAX_SESSIONS_PER_USER` is set. |
|
||||
| `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. |
|
||||
| `CODEMAN_MAX_PASTE_IMAGE_BYTES` | Cap on one pasted, dropped or attached image or document. 50 MB by default. |
|
||||
| `CODEMAN_MAX_PASTE_VIDEO_BYTES` | Cap on one attached video (phone Attach, drop). 1 GB by default; videos stream to disk, so this bounds disk use, not memory. |
|
||||
@@ -243,3 +252,4 @@ Some things are configured before the server starts, not in the UI:
|
||||
- [The Dashboard](The-Dashboard) - what each control does once visible.
|
||||
- [Keeping Agents Running](Keeping-Agents-Running) - the Respawn panel in depth.
|
||||
- [Agent CLIs](Agent-CLIs) - model, effort, and permission modes.
|
||||
- [Config Backups](Config-Backups) - copies of your settings, and how to restore them.
|
||||
@@ -148,7 +148,7 @@ The right side of the header. Almost all of these are off until you enable them
|
||||
| CPU / MEM | On | Server resource use. Drawn as a compact pill by default; see Header Stats Style below. |
|
||||
| File Viewer | On | Toggles the file browser panel. |
|
||||
| Settings gear | Always on | App Settings. |
|
||||
| Plan usage chip | On, desktop only | Live Claude subscription usage. Claude-only, and needs its telemetry exporter, which the same setting installs. |
|
||||
| Plan usage chip | On, desktop only | Live subscription usage: Claude (needs its telemetry exporter, which the same setting installs), Codex limits and the Copilot monthly quota. |
|
||||
| Session Manager | Off | The full session list, live and historical. |
|
||||
| Approvals bell | Off | Cross-session queue of prompts waiting on a human. Appears only when the count is above zero. Never shown on phones. |
|
||||
| Read My Mind 🧠 | Off | Predicts your next prompt for this case. Claude-only. |
|
||||
@@ -235,10 +235,12 @@ Worth knowing:
|
||||
- **Wheel and touch scrolling** are forwarded into Claude's own transcript when a recent
|
||||
Claude runs fullscreen (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in
|
||||
`~/.claude/settings.json`), so the wheel scrolls the conversation rather than the terminal.
|
||||
Claude's default inline view keeps its history in the terminal and scrolls locally. `Shift+Wheel` is
|
||||
Codex with mouse tracking on gets the wheel forwarded like fullscreen Claude; otherwise
|
||||
it pages its transcript or scrolls locally. Claude's default inline view keeps its history
|
||||
in the terminal and scrolls locally. `Shift+Wheel` is
|
||||
always local scrollback. OpenCode's wheel and swipes page its own conversation
|
||||
(PageUp/PageDown), and so do newer Codex versions, which draw on the alternate screen
|
||||
and leave the terminal no history of its own (an older Codex that draws inline keeps
|
||||
(PageUp/PageDown), as do untracked fullscreen Codex versions that leave the terminal
|
||||
no history of its own (an older Codex that draws inline keeps
|
||||
its history in the terminal and scrolls locally). The first small movement of a
|
||||
gesture pages at once, then one page per half screen of travel; one finger on the
|
||||
screen is one gesture however slowly it moves. In a grid tile or the split view's
|
||||
|
||||
@@ -131,6 +131,32 @@ That prompt comes from Claude Code, not Codeman. Codeman's default is to start w
|
||||
permission prompts skipped, which is what the security model is built around. If you would
|
||||
rather it prompted, change **App Settings → Agents & CLIs → Claude → Startup Mode**.
|
||||
|
||||
### My settings were reset, or a CLI I enabled is gone
|
||||
|
||||
Something rewrote a file in `~/.codeman/` (a hand edit, a restart that loaded a bad file, a
|
||||
test run pointed at the real data directory). If [Config Backups](Config-Backups) was on,
|
||||
which is the default, you have copies:
|
||||
|
||||
1. Open **Settings → System → Config backups** and pick the newest backup from before the
|
||||
change. The list shows when each was made and what it holds.
|
||||
2. **Restore** it, reload the page, and restart Codeman if the response's `restartRequired`
|
||||
list is not empty.
|
||||
|
||||
A reset `settings.json` also drops the backup folder setting. If you use a custom folder, enter
|
||||
it again and save before looking for the list; until then the list shows the default folder.
|
||||
After a reinstall of the data directory the old backups are unlisted because the instance has a
|
||||
new id; see [Config Backups](Config-Backups) for how to make them visible again.
|
||||
|
||||
A backup is never dated earlier than the newest one, so if the clock was once set to a far
|
||||
future date, every later backup keeps that date. Delete the future-dated backup folder to
|
||||
return to normal dating.
|
||||
|
||||
Without the UI: the backups are plain folders in `~/.codeman/backups/config/`
|
||||
(`cfg-YYYYMMDD-HHMMSS`); copy the file you need back over the one in `~/.codeman/`.
|
||||
|
||||
Not covered: `state.json` (cron jobs and respawn presets live there). A backup only exists
|
||||
from the moment the feature first ran, so a reset that happened before that cannot be undone.
|
||||
|
||||
### Sessions vanished after a reboot
|
||||
|
||||
Expected. tmux does not survive a reboot, so the sessions are gone. Conversations are not:
|
||||
@@ -161,6 +187,20 @@ CODEMAN_INSTANCE=beta CODEMAN_PORT=5000 codeman web
|
||||
`codeman web -d` and `codeman service install` both refuse to start a second server on one
|
||||
data directory for exactly this reason.
|
||||
|
||||
### `Context size has been exceeded` from a local llama.cpp model
|
||||
|
||||
Sessions pointed at a llama.cpp or llama-swap endpoint (see [Custom Model
|
||||
Endpoints](Custom-Model-Endpoints)) fail with `500 Context size has been exceeded`, usually
|
||||
when Claude Code runs subagents, or an agent fails with `Prompt is too long` while the main
|
||||
session carries on. The server runs several parallel slots that share one pool of context,
|
||||
and every Claude Code request carries about 40K tokens of its own system prompt and tools, so
|
||||
a few concurrent requests use the pool up. Start the model with a single slot by adding
|
||||
`--parallel 1` to its launch command, or give it a context large enough that each slot holds
|
||||
40K tokens. Check the current count with `GET /props` on the server (on llama-swap,
|
||||
`GET /props?model=<id>` for a loaded model): `total_slots` is the number of slots. `n_ctx` is the
|
||||
shared context only when the slot count is automatic; with an explicit `--parallel N` it is the
|
||||
size of each slot.
|
||||
|
||||
## The terminal
|
||||
|
||||
### I cannot scroll back through history
|
||||
@@ -178,8 +218,8 @@ Things to try:
|
||||
|
||||
### The wheel does nothing in a Codex session
|
||||
|
||||
Codex ignores the mouse reports that forwarding would send, so Codeman does not forward
|
||||
there. Scrolling is local, and `Shift+Wheel` behaves the same way.
|
||||
Codex with mouse tracking on gets the wheel forwarded like fullscreen Claude; otherwise
|
||||
it pages its transcript or scrolls locally, while `Shift+Wheel` always uses local scrollback.
|
||||
|
||||
### Selected text is invisible on a light skin
|
||||
|
||||
|
||||
@@ -27,6 +27,11 @@ stream rather than server memory). Sensitive paths (`.env`, anything
|
||||
matching credentials, `~/.ssh`, AWS credentials) are blocked from download, and SVG and HTML
|
||||
are served as downloads rather than rendered, so they cannot execute in the page.
|
||||
|
||||
The preview window has **Back** and **Forward** buttons (also `Alt+←` and `Alt+→`) once you
|
||||
have opened a second file, so a link into another document is no longer one way. **Expand**
|
||||
fills the screen and is remembered per device, and the corner grip resizes the window. On a
|
||||
phone the window is always full screen, so Expand and the grip are hidden there.
|
||||
|
||||
Closing the preview pauses and unloads any playing media. A video that keeps playing after
|
||||
you close the panel means you are on an old version.
|
||||
|
||||
@@ -84,7 +89,8 @@ File paths in a session are links. That works in two places:
|
||||
|
||||
- **In the terminal**, on any absolute path an agent prints.
|
||||
- **In the response viewer**, where paths are usually written as prose or in backticks. They
|
||||
render as underlined monospace links.
|
||||
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.
|
||||
|
||||
Clicking one opens it in the preview: images and PDFs render, video and audio play with a
|
||||
working scrub bar, documents convert, text shows inline, Markdown renders and HTML opens as a
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
**Operating it**
|
||||
|
||||
- [Running As A Service](Running-As-A-Service)
|
||||
- [Config Backups](Config-Backups)
|
||||
- [Troubleshooting](Troubleshooting)
|
||||
- [FAQ](FAQ)
|
||||
- [Contributing](Contributing)
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.43.0",
|
||||
"version": "1.44.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.43.0",
|
||||
"version": "1.44.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.43.0",
|
||||
"version": "1.44.0",
|
||||
"description": "Self-hosted mission control for AI coding agents: run Claude Code, Codex, OpenCode, Gemini, DeepSeek, Grok and more 24/7 in tmux, from any device.",
|
||||
"mcpName": "io.github.Ark0N/codeman",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codeman",
|
||||
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||
"version": "1.43.0",
|
||||
"version": "1.44.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -70,7 +70,7 @@ later call opens with, and your first REAL call performs them anyway:
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
```
|
||||
|
||||
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
||||
@@ -98,8 +98,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
|
||||
mkdir -p "$(dirname "$PRE")"
|
||||
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
||||
# half-written file self-heals here instead of costing you a round trip to rm it.
|
||||
grep -qs '^CODEMAN_PREAMBLE=1.42.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- Codeman agent preamble 1.42.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
grep -qs '^CODEMAN_PREAMBLE=1.44.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- Codeman agent preamble 1.44.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
|
||||
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
|
||||
# from that body (test/agent-skill.test.ts pins it).
|
||||
@@ -339,6 +339,16 @@ spawn_workers() {
|
||||
# pane clearly finished means that profile, so switch that worker to markers.
|
||||
sendwait() {
|
||||
local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r c head n=0 tmp bg i
|
||||
# One line, like every other waiting sender (`toSingleLine`, `codeman agent send`,
|
||||
# the TUI): the server pastes a multi-line prompt as one block, and the loop below,
|
||||
# like the server's own submit check, cannot recognise a pasted block that is still
|
||||
# on the composer, so a prompt like `Review this:\n- item 1` would sit unsent for
|
||||
# the whole re-wait. Each line is trimmed, blank lines are dropped, the rest are
|
||||
# joined with a space. For a long multi-line task, write a file and send its path.
|
||||
case "$p" in *$'\n'*|*$'\r'*)
|
||||
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
|
||||
esac
|
||||
[ -n "$p" ] || { echo "sendwait: empty prompt" >&2; return 1; }
|
||||
# `wait:"stop,exit"`, never the `wait:true` default set: that set also carries
|
||||
# `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a
|
||||
# dsh worker whose TUI repaints rarely the session reads `idle` while the model
|
||||
@@ -410,10 +420,10 @@ last_text() {
|
||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||
# here would fail that match and rewrite this file on every single bootstrap.
|
||||
CODEMAN_PREAMBLE=1.42.0
|
||||
CODEMAN_PREAMBLE=1.44.0
|
||||
PREAMBLE
|
||||
)
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||
```
|
||||
|
||||
Every later Bash call that touches the API starts with the same two loader lines from
|
||||
@@ -464,7 +474,7 @@ and no per-call body to hand-build.
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
||||
# (a name may carry a mode: `beta:deepseek`, see below)
|
||||
T=('reply with one line: the absolute path of your working directory'
|
||||
@@ -587,7 +597,7 @@ One row per job. Acting on this table alone is correct; the §5 links are the de
|
||||
| know if it is stuck | `GET .../active-tools` and `GET .../run-summary` are structured and free; two `terminal?tail=` samples are the crude fallback | [§5.6](reference/verbs.md#56-alive-and-stuck) |
|
||||
| make a runaway worker stop | `POST .../input {"input":"\u001b"}` (ESC, **no** `\r`). Deleting the session would destroy the conversation instead | [§5.7](reference/verbs.md#57-interrupt-without-destroying) |
|
||||
| resume a worker halted on a usage limit | `POST .../auto-resume {"enabled":true}`. Respawn and Ralph are **not** the remedy: respawn runs `/clear` | [§5.8](reference/verbs.md#58-usage-limits) |
|
||||
| give a worker big input | write a file into its workspace with your own tools and send one short line pointing at it. The composer takes 65536 characters, single-line, newlines stripped | [§5.9](reference/verbs.md#59-big-input-via-the-workspace) |
|
||||
| give a worker big input | write a file into its workspace with your own tools and send one short line pointing at it. The composer takes 65536 characters, and `send` takes one line | [§5.9](reference/verbs.md#59-big-input-via-the-workspace) |
|
||||
| watch N workers at once | one in-flight wait per worker (per-session waiter cap 16); fan-out shapes differ for claude and shell | [§5.10](reference/verbs.md#510-fan-out) |
|
||||
| find yourself, list what exists | `GET /api/v1/sessions`, match your `$SELF` by **prefix** | [§5.11](reference/verbs.md#511-list-and-find-yourself) |
|
||||
| read or record what the user wants | `GET/PUT .../intent`, and `POST .../readmymind` to predict | [§5.12](reference/verbs.md#512-read-my-mind) |
|
||||
@@ -666,8 +676,9 @@ You are yourself a session on this server, and the API has **no undo**.
|
||||
user's repository state, so say that you did; **removing** one discards any
|
||||
uncommitted work inside it, so ask first ([§5.1](reference/verbs.md#51-where-to-spawn)).
|
||||
- Never `tmux kill-session`, `pkill tmux`, `pkill claude`. The API is the only interface.
|
||||
- Sessions count against a **global cap of 50** (and, in multi-user mode, a per-user
|
||||
cap of 25 that fires the same 409). Case creation is uncapped and writes real
|
||||
- Sessions count against a **global cap, 50 by default** (`CODEMAN_MAX_SESSIONS`), and,
|
||||
in multi-user mode, a per-user cap (half the global cap, 25 by default) that fires
|
||||
the same 409. Case creation is uncapped and writes real
|
||||
directories. Clean up every session you start, and never retry `quick-start` in a
|
||||
loop.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ---- Codeman agent preamble 1.42.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
# ---- Codeman agent preamble 1.44.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
|
||||
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
|
||||
# from that body (test/agent-skill.test.ts pins it).
|
||||
@@ -238,6 +238,16 @@ spawn_workers() {
|
||||
# pane clearly finished means that profile, so switch that worker to markers.
|
||||
sendwait() {
|
||||
local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r c head n=0 tmp bg i
|
||||
# One line, like every other waiting sender (`toSingleLine`, `codeman agent send`,
|
||||
# the TUI): the server pastes a multi-line prompt as one block, and the loop below,
|
||||
# like the server's own submit check, cannot recognise a pasted block that is still
|
||||
# on the composer, so a prompt like `Review this:\n- item 1` would sit unsent for
|
||||
# the whole re-wait. Each line is trimmed, blank lines are dropped, the rest are
|
||||
# joined with a space. For a long multi-line task, write a file and send its path.
|
||||
case "$p" in *$'\n'*|*$'\r'*)
|
||||
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
|
||||
esac
|
||||
[ -n "$p" ] || { echo "sendwait: empty prompt" >&2; return 1; }
|
||||
# `wait:"stop,exit"`, never the `wait:true` default set: that set also carries
|
||||
# `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a
|
||||
# dsh worker whose TUI repaints rarely the session reads `idle` while the model
|
||||
@@ -309,4 +319,4 @@ last_text() {
|
||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||
# here would fail that match and rewrite this file on every single bootstrap.
|
||||
CODEMAN_PREAMBLE=1.42.0
|
||||
CODEMAN_PREAMBLE=1.44.0
|
||||
@@ -113,7 +113,7 @@ Every JSON response: `{"success":true,"data":…}` or
|
||||
| `UNAUTHORIZED` | 401 | auth required or failed (send `-u user:password`). ⚠️ The 401 body is plain text, NOT this envelope, see [Auth and credentials](#auth-and-credentials) |
|
||||
| `FORBIDDEN` | 403 | authenticated but not permitted: an admin-only route in multi-user mode, a `workingDir`/case path outside your own workspace, or a shell session without the can-bypass-permissions grant. ⚠️ **Not** what an ownership miss on a session returns: a session you do not own answers 404 `NOT_FOUND`, identically to one that does not exist (deliberate, it leaks no existence) |
|
||||
| `NOT_FOUND` | 404 | no such session, or one this caller does not own. Also quick-start's answer for an unknown remote or docker host |
|
||||
| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global 50 (`MAX_CONCURRENT_SESSIONS`), and in multi-user mode the per-user cap, which defaults to half of that, **25** (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which |
|
||||
| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global cap, 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 462), and in multi-user mode the per-user cap, which defaults to half the global cap, **25** by default (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which |
|
||||
| `CONFLICT` / `ALREADY_EXISTS` | 409 | conflicts with current state |
|
||||
| `OPERATION_FAILED` | 422 | well-formed but could not be completed |
|
||||
| `RATE_LIMITED` | 429 | per-owner or process-wide waiter pool is full; back off, switching sessions will not help |
|
||||
@@ -367,7 +367,7 @@ name is in front" rather than "nothing is installed". `shell` has no CLI to prob
|
||||
is absent, `jq -r` prints the literal string `null`, and every later call then targets
|
||||
`/api/v1/sessions/null`, burning the full readiness budget and reporting jq noise
|
||||
instead of the real cause. The failure codes here are `SESSION_BUSY` (a **session** cap:
|
||||
the global 50, or the per-user 25 in multi-user mode, never the waiter cap),
|
||||
the global cap (50 by default), or the per-user cap (25 by default) in multi-user mode, never the waiter cap),
|
||||
`NOT_FOUND` (an unknown remote host or docker host named by the case), `FORBIDDEN`,
|
||||
`CONFLICT`, `OPERATION_FAILED` and `INVALID_INPUT`. None of them are retryable in a
|
||||
loop.
|
||||
@@ -401,7 +401,7 @@ differences that break copied code:
|
||||
`POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or
|
||||
`POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker.
|
||||
- Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's
|
||||
`SESSION_BUSY` (409), from the same global-50 / per-user-25 caps
|
||||
`SESSION_BUSY` (409), from the same global / per-user caps (50 / 25 by default)
|
||||
(`sessionCapacityMessage()` in `route-helpers.ts`).
|
||||
|
||||
⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit
|
||||
@@ -420,8 +420,9 @@ callers send no body at all.
|
||||
**or Enter is never sent**: the text is typed onto the worker's prompt and sits
|
||||
there unsubmitted. This is [symptom 1](#1-deliveredtrue-then-every-wait-times-out),
|
||||
the number-one silent failure.
|
||||
- `input` must be single-line (newlines are stripped). To send a bare Enter (confirm
|
||||
a dialog), send `{"input":"\r"}`.
|
||||
- With `useMux`, newlines in `input` are kept: the text goes in as one bracketed paste
|
||||
and the `\r` submits all lines (without `useMux`, multi-line input is written straight
|
||||
into the pane). To send a bare Enter (confirm a dialog), send `{"input":"\r"}`.
|
||||
- `input` is capped at **65536** characters. ⚠️ **Two caps disagree and the smaller one
|
||||
is the real one**: the Zod schema allows 100000 (`schemas.ts:1035`), so a 65537-to-100000
|
||||
character body passes validation and *then* 400s at the route against
|
||||
@@ -796,8 +797,8 @@ response echoes.
|
||||
| `match` string | 1–200 characters, literal only | 400; `regex=` is rejected outright |
|
||||
| `from=buffer` scan window | 256 KB tail of the terminal buffer | a marker older than that tail is invisible even with `from=buffer` |
|
||||
| wait-output snippet context | 80 characters either side | `wait.snippet` is bounded, not the whole line |
|
||||
| sessions, process-wide | 50 (`MAX_CONCURRENT_SESSIONS`) | 409 `SESSION_BUSY` on quick-start |
|
||||
| sessions, per user | 25 in multi-user mode (half the global cap) | the same 409, with a different message |
|
||||
| sessions, process-wide | 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 462) | 409 `SESSION_BUSY` on quick-start |
|
||||
| sessions, per user | half the global cap (25 by default) in multi-user mode | the same 409, with a different message |
|
||||
| SSE clients, process-wide | 100 (`MAX_SSE_CLIENTS`) | plain-text `503 Too many SSE connections`; shared with every browser tab |
|
||||
| active bash tools tracked | 20 per session | oldest entries drop off `active-tools` |
|
||||
| auth failures per IP | 10, decaying over 15 min | plain-text 429 with `Retry-After`; locks out the login path, so never loop a bad credential |
|
||||
|
||||
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||
```
|
||||
|
||||
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
||||
|
||||
@@ -101,8 +101,9 @@ Differences from `quick-start` worth knowing before you debug one:
|
||||
- hitting the session cap here is `OPERATION_FAILED`, where `quick-start` returns
|
||||
`SESSION_BUSY` for the identical condition.
|
||||
|
||||
`quick-start` failure codes are `SESSION_BUSY` (the global 50-session cap, or the
|
||||
per-user cap of 25 in multi-user mode), `FORBIDDEN`, `CONFLICT`, `NOT_FOUND` (a
|
||||
`quick-start` failure codes are `SESSION_BUSY` (the global session cap, 50 by default via
|
||||
`CODEMAN_MAX_SESSIONS`, or the per-user cap in multi-user mode, half the global cap,
|
||||
25 by default), `FORBIDDEN`, `CONFLICT`, `NOT_FOUND` (a
|
||||
remote or docker host named by the case no longer exists), `OPERATION_FAILED` and
|
||||
`INVALID_INPUT`. **None of them are retryable in a loop.** Always branch on
|
||||
`.success` before reading `.data.sessionId`: on failure the field is absent, `jq -r`
|
||||
@@ -303,10 +304,16 @@ duplicate resend costs nothing.
|
||||
Codeman types the text and sends Enter **only when the input contains a carriage
|
||||
return**; without it your command sits unsubmitted on the worker's prompt and
|
||||
everything downstream times out. No response field catches this: `delivered:true`
|
||||
means "written to the pane", **not** "submitted". Newlines are stripped, so input is
|
||||
single-line by construction. Build the body with `jq -n` for any prompt you did not
|
||||
author as a literal, because the inline `-d '{"input":"'"$P"'\r"}'` pattern breaks on
|
||||
the first double quote, backslash or `$` in a real prompt:
|
||||
means "written to the pane", **not** "submitted". With `useMux`, newlines are kept: the
|
||||
text goes in as one paste, and the `\r` submits all of it. **`sendwait` sends one line,
|
||||
though:** its Enter loop and the server's submit check cannot reliably recognise a
|
||||
multi-line paste, so a multi-line prompt to a freshly spawned worker could sit unsent in
|
||||
Claude Code's Enter-ignore window. `sendwait` therefore joins the lines with a space,
|
||||
which loses their layout; put long multi-line content in a file
|
||||
([§5.9](#59-big-input-via-the-workspace)). Build the body with `jq -n`
|
||||
for any prompt you did not author as a literal, because the inline
|
||||
`-d '{"input":"'"$P"'\r"}'` pattern breaks on the first double quote, backslash or `$`
|
||||
in a real prompt:
|
||||
|
||||
```bash
|
||||
BODY=$(jq -n --arg p "$PROMPT" '{input:($p+"\r"),useMux:true,clientId:"agent-1",seq:1,wait:true,waitTimeout:60000}')
|
||||
@@ -587,9 +594,10 @@ allowlist in §4.
|
||||
|
||||
### 5.9 Big input via the workspace
|
||||
|
||||
The composer is a single line capped at 65536 characters with newlines stripped, which
|
||||
makes it a bad channel for a spec, a diff or a file list. The workspace is the good
|
||||
one, and for a local or docker case you are on the same filesystem as the worker.
|
||||
The composer is capped at 65536 characters, and a multi-line paste cannot be confirmed
|
||||
as submitted, which makes it a bad channel for a spec, a diff or a file list. The
|
||||
workspace is the good one, and for a local or docker case you are on the same
|
||||
filesystem as the worker.
|
||||
|
||||
1. Write `TASK.md` into the worker's workspace with your own file tools. The path is
|
||||
`.data.casePath` from `quick-start`, or the `workingDir` you passed to
|
||||
@@ -599,10 +607,10 @@ one, and for a local or docker case you are on the same filesystem as the worker
|
||||
3. Wait on `DONE_<token>` with `wait-output` ([§5.5](#55-markers-for-hook-less-workers)),
|
||||
then read `RESULT.json` back with your own tools.
|
||||
|
||||
This sidesteps the byte cap, the newline stripping and the quoting hazards in one
|
||||
move, and it makes the marker **split by construction**: the token lives in the file,
|
||||
never in the line you type, so the echo of your own keystrokes cannot match it. The
|
||||
worker also gets to re-read the task instead of holding it in one echoed line.
|
||||
This sidesteps the byte cap, the unconfirmed multi-line paste and the quoting hazards
|
||||
in one move, and it makes the marker **split by construction**: the token lives in the
|
||||
file, never in the line you type, so the echo of your own keystrokes cannot match it.
|
||||
The worker also gets to re-read the task instead of holding it in one echoed line.
|
||||
|
||||
⚠️ Two places it does not work: a **remote-SSH case** runs on another host whose
|
||||
filesystem you cannot see, and any worker **currently editing** the directory you are
|
||||
|
||||
+47
-4
@@ -27,6 +27,13 @@ SESSIONS_FILE="${HOME}/.codeman/mux-sessions.json"
|
||||
CODEMAN_TMUX_SOCKET="${CODEMAN_TMUX_SOCKET:-codeman}"
|
||||
TMUX_CMD=(tmux -L "$CODEMAN_TMUX_SOCKET")
|
||||
|
||||
# Codeman keeps the prefix table on its socket down to send-prefix (browser tabs
|
||||
# are attach clients on the same server, src/tmux-prefix-table.ts), so Ctrl+B d
|
||||
# does not detach here. An attach claims F1 in the root table instead, like
|
||||
# `codeman tui` does, and only when nothing else holds it.
|
||||
DETACH_KEY="F1"
|
||||
CLAIMED_DETACH_KEY=0
|
||||
|
||||
|
||||
# Cached data
|
||||
CACHED_JSON=""
|
||||
@@ -36,12 +43,46 @@ REFRESH_INTERVAL=5 # Refresh data every 5 seconds
|
||||
|
||||
# Cleanup on exit
|
||||
cleanup() {
|
||||
release_detach_key
|
||||
tput cnorm 2>/dev/null
|
||||
stty echo 2>/dev/null
|
||||
echo ""
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Command bound to $DETACH_KEY in the root table; empty when the key is free
|
||||
detach_key_binding() {
|
||||
"${TMUX_CMD[@]}" list-keys -T root 2>/dev/null \
|
||||
| sed -nE "s/^bind-key +(-[^T ][^ ]* +)*-T root ${DETACH_KEY} +//p" | sed -E 's/ +$//'
|
||||
}
|
||||
|
||||
release_detach_key() {
|
||||
if [[ $CLAIMED_DETACH_KEY -eq 1 ]]; then
|
||||
[[ "$(detach_key_binding)" == "detach-client" ]] && "${TMUX_CMD[@]}" unbind-key -T root "$DETACH_KEY" 2>/dev/null
|
||||
CLAIMED_DETACH_KEY=0
|
||||
fi
|
||||
}
|
||||
|
||||
# Attach to a session with a key that gets you back out
|
||||
attach_session() {
|
||||
local mux_name=$1 bound status
|
||||
bound=$(detach_key_binding)
|
||||
if [[ -z "$bound" ]] && "${TMUX_CMD[@]}" bind-key -T root "$DETACH_KEY" detach-client 2>/dev/null; then
|
||||
CLAIMED_DETACH_KEY=1
|
||||
bound="detach-client"
|
||||
fi
|
||||
if [[ "$bound" == "detach-client" ]]; then
|
||||
echo -e "${CYAN}Attaching... (${DETACH_KEY} to detach)${NC}"
|
||||
else
|
||||
echo -e "${CYAN}Attaching... (${DETACH_KEY} is taken; detach from another shell: tmux -L $CODEMAN_TMUX_SOCKET detach-client -s $mux_name)${NC}"
|
||||
fi
|
||||
sleep 0.3
|
||||
"${TMUX_CMD[@]}" attach-session -t "$mux_name"
|
||||
status=$?
|
||||
release_detach_key
|
||||
return $status
|
||||
}
|
||||
|
||||
# Check dependencies
|
||||
check_dependencies() {
|
||||
if ! command -v jq &> /dev/null; then
|
||||
@@ -352,9 +393,7 @@ interactive_menu() {
|
||||
if check_alive "$mux_name"; then
|
||||
tput cnorm
|
||||
clear
|
||||
echo -e "${CYAN}Attaching... (Ctrl+B D to detach)${NC}"
|
||||
sleep 0.3
|
||||
"${TMUX_CMD[@]}" attach-session -t "$mux_name"
|
||||
attach_session "$mux_name"
|
||||
tput civis
|
||||
need_full_redraw=1
|
||||
force_refresh
|
||||
@@ -483,7 +522,11 @@ main() {
|
||||
[[ -z "${2:-}" ]] && { echo "Usage: $0 attach <N>"; exit 1; }
|
||||
force_refresh
|
||||
local mux_name=$(get_session_field $(($2-1)) "muxName")
|
||||
check_alive "$mux_name" && "${TMUX_CMD[@]}" attach-session -t "$mux_name" || echo "Session dead or not found"
|
||||
if check_alive "$mux_name"; then
|
||||
attach_session "$mux_name"
|
||||
else
|
||||
echo "Session dead or not found"
|
||||
fi
|
||||
;;
|
||||
kill)
|
||||
[[ -z "${2:-}" ]] && { echo "Usage: $0 kill <N|N,M|N-M>"; exit 1; }
|
||||
|
||||
+20
-9
@@ -70,7 +70,7 @@ later call opens with, and your first REAL call performs them anyway:
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
```
|
||||
|
||||
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
||||
@@ -98,8 +98,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
|
||||
mkdir -p "$(dirname "$PRE")"
|
||||
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
||||
# half-written file self-heals here instead of costing you a round trip to rm it.
|
||||
grep -qs '^CODEMAN_PREAMBLE=1.42.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- Codeman agent preamble 1.42.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
grep -qs '^CODEMAN_PREAMBLE=1.44.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- Codeman agent preamble 1.44.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
|
||||
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
|
||||
# from that body (test/agent-skill.test.ts pins it).
|
||||
@@ -339,6 +339,16 @@ spawn_workers() {
|
||||
# pane clearly finished means that profile, so switch that worker to markers.
|
||||
sendwait() {
|
||||
local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r c head n=0 tmp bg i
|
||||
# One line, like every other waiting sender (`toSingleLine`, `codeman agent send`,
|
||||
# the TUI): the server pastes a multi-line prompt as one block, and the loop below,
|
||||
# like the server's own submit check, cannot recognise a pasted block that is still
|
||||
# on the composer, so a prompt like `Review this:\n- item 1` would sit unsent for
|
||||
# the whole re-wait. Each line is trimmed, blank lines are dropped, the rest are
|
||||
# joined with a space. For a long multi-line task, write a file and send its path.
|
||||
case "$p" in *$'\n'*|*$'\r'*)
|
||||
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
|
||||
esac
|
||||
[ -n "$p" ] || { echo "sendwait: empty prompt" >&2; return 1; }
|
||||
# `wait:"stop,exit"`, never the `wait:true` default set: that set also carries
|
||||
# `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a
|
||||
# dsh worker whose TUI repaints rarely the session reads `idle` while the model
|
||||
@@ -410,10 +420,10 @@ last_text() {
|
||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||
# here would fail that match and rewrite this file on every single bootstrap.
|
||||
CODEMAN_PREAMBLE=1.42.0
|
||||
CODEMAN_PREAMBLE=1.44.0
|
||||
PREAMBLE
|
||||
)
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||
```
|
||||
|
||||
Every later Bash call that touches the API starts with the same two loader lines from
|
||||
@@ -464,7 +474,7 @@ and no per-call body to hand-build.
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
||||
# (a name may carry a mode: `beta:deepseek`, see below)
|
||||
T=('reply with one line: the absolute path of your working directory'
|
||||
@@ -587,7 +597,7 @@ One row per job. Acting on this table alone is correct; the §5 links are the de
|
||||
| know if it is stuck | `GET .../active-tools` and `GET .../run-summary` are structured and free; two `terminal?tail=` samples are the crude fallback | [§5.6](reference/verbs.md#56-alive-and-stuck) |
|
||||
| make a runaway worker stop | `POST .../input {"input":"\u001b"}` (ESC, **no** `\r`). Deleting the session would destroy the conversation instead | [§5.7](reference/verbs.md#57-interrupt-without-destroying) |
|
||||
| resume a worker halted on a usage limit | `POST .../auto-resume {"enabled":true}`. Respawn and Ralph are **not** the remedy: respawn runs `/clear` | [§5.8](reference/verbs.md#58-usage-limits) |
|
||||
| give a worker big input | write a file into its workspace with your own tools and send one short line pointing at it. The composer takes 65536 characters, single-line, newlines stripped | [§5.9](reference/verbs.md#59-big-input-via-the-workspace) |
|
||||
| give a worker big input | write a file into its workspace with your own tools and send one short line pointing at it. The composer takes 65536 characters, and `send` takes one line | [§5.9](reference/verbs.md#59-big-input-via-the-workspace) |
|
||||
| watch N workers at once | one in-flight wait per worker (per-session waiter cap 16); fan-out shapes differ for claude and shell | [§5.10](reference/verbs.md#510-fan-out) |
|
||||
| find yourself, list what exists | `GET /api/v1/sessions`, match your `$SELF` by **prefix** | [§5.11](reference/verbs.md#511-list-and-find-yourself) |
|
||||
| read or record what the user wants | `GET/PUT .../intent`, and `POST .../readmymind` to predict | [§5.12](reference/verbs.md#512-read-my-mind) |
|
||||
@@ -666,8 +676,9 @@ You are yourself a session on this server, and the API has **no undo**.
|
||||
user's repository state, so say that you did; **removing** one discards any
|
||||
uncommitted work inside it, so ask first ([§5.1](reference/verbs.md#51-where-to-spawn)).
|
||||
- Never `tmux kill-session`, `pkill tmux`, `pkill claude`. The API is the only interface.
|
||||
- Sessions count against a **global cap of 50** (and, in multi-user mode, a per-user
|
||||
cap of 25 that fires the same 409). Case creation is uncapped and writes real
|
||||
- Sessions count against a **global cap, 50 by default** (`CODEMAN_MAX_SESSIONS`), and,
|
||||
in multi-user mode, a per-user cap (half the global cap, 25 by default) that fires
|
||||
the same 409. Case creation is uncapped and writes real
|
||||
directories. Clean up every session you start, and never retry `quick-start` in a
|
||||
loop.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ---- Codeman agent preamble 1.42.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
# ---- Codeman agent preamble 1.44.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
|
||||
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
|
||||
# from that body (test/agent-skill.test.ts pins it).
|
||||
@@ -238,6 +238,16 @@ spawn_workers() {
|
||||
# pane clearly finished means that profile, so switch that worker to markers.
|
||||
sendwait() {
|
||||
local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r c head n=0 tmp bg i
|
||||
# One line, like every other waiting sender (`toSingleLine`, `codeman agent send`,
|
||||
# the TUI): the server pastes a multi-line prompt as one block, and the loop below,
|
||||
# like the server's own submit check, cannot recognise a pasted block that is still
|
||||
# on the composer, so a prompt like `Review this:\n- item 1` would sit unsent for
|
||||
# the whole re-wait. Each line is trimmed, blank lines are dropped, the rest are
|
||||
# joined with a space. For a long multi-line task, write a file and send its path.
|
||||
case "$p" in *$'\n'*|*$'\r'*)
|
||||
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
|
||||
esac
|
||||
[ -n "$p" ] || { echo "sendwait: empty prompt" >&2; return 1; }
|
||||
# `wait:"stop,exit"`, never the `wait:true` default set: that set also carries
|
||||
# `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a
|
||||
# dsh worker whose TUI repaints rarely the session reads `idle` while the model
|
||||
@@ -309,4 +319,4 @@ last_text() {
|
||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||
# here would fail that match and rewrite this file on every single bootstrap.
|
||||
CODEMAN_PREAMBLE=1.42.0
|
||||
CODEMAN_PREAMBLE=1.44.0
|
||||
@@ -113,7 +113,7 @@ Every JSON response: `{"success":true,"data":…}` or
|
||||
| `UNAUTHORIZED` | 401 | auth required or failed (send `-u user:password`). ⚠️ The 401 body is plain text, NOT this envelope, see [Auth and credentials](#auth-and-credentials) |
|
||||
| `FORBIDDEN` | 403 | authenticated but not permitted: an admin-only route in multi-user mode, a `workingDir`/case path outside your own workspace, or a shell session without the can-bypass-permissions grant. ⚠️ **Not** what an ownership miss on a session returns: a session you do not own answers 404 `NOT_FOUND`, identically to one that does not exist (deliberate, it leaks no existence) |
|
||||
| `NOT_FOUND` | 404 | no such session, or one this caller does not own. Also quick-start's answer for an unknown remote or docker host |
|
||||
| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global 50 (`MAX_CONCURRENT_SESSIONS`), and in multi-user mode the per-user cap, which defaults to half of that, **25** (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which |
|
||||
| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global cap, 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 462), and in multi-user mode the per-user cap, which defaults to half the global cap, **25** by default (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which |
|
||||
| `CONFLICT` / `ALREADY_EXISTS` | 409 | conflicts with current state |
|
||||
| `OPERATION_FAILED` | 422 | well-formed but could not be completed |
|
||||
| `RATE_LIMITED` | 429 | per-owner or process-wide waiter pool is full; back off, switching sessions will not help |
|
||||
@@ -367,7 +367,7 @@ name is in front" rather than "nothing is installed". `shell` has no CLI to prob
|
||||
is absent, `jq -r` prints the literal string `null`, and every later call then targets
|
||||
`/api/v1/sessions/null`, burning the full readiness budget and reporting jq noise
|
||||
instead of the real cause. The failure codes here are `SESSION_BUSY` (a **session** cap:
|
||||
the global 50, or the per-user 25 in multi-user mode, never the waiter cap),
|
||||
the global cap (50 by default), or the per-user cap (25 by default) in multi-user mode, never the waiter cap),
|
||||
`NOT_FOUND` (an unknown remote host or docker host named by the case), `FORBIDDEN`,
|
||||
`CONFLICT`, `OPERATION_FAILED` and `INVALID_INPUT`. None of them are retryable in a
|
||||
loop.
|
||||
@@ -401,7 +401,7 @@ differences that break copied code:
|
||||
`POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or
|
||||
`POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker.
|
||||
- Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's
|
||||
`SESSION_BUSY` (409), from the same global-50 / per-user-25 caps
|
||||
`SESSION_BUSY` (409), from the same global / per-user caps (50 / 25 by default)
|
||||
(`sessionCapacityMessage()` in `route-helpers.ts`).
|
||||
|
||||
⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit
|
||||
@@ -420,8 +420,9 @@ callers send no body at all.
|
||||
**or Enter is never sent**: the text is typed onto the worker's prompt and sits
|
||||
there unsubmitted. This is [symptom 1](#1-deliveredtrue-then-every-wait-times-out),
|
||||
the number-one silent failure.
|
||||
- `input` must be single-line (newlines are stripped). To send a bare Enter (confirm
|
||||
a dialog), send `{"input":"\r"}`.
|
||||
- With `useMux`, newlines in `input` are kept: the text goes in as one bracketed paste
|
||||
and the `\r` submits all lines (without `useMux`, multi-line input is written straight
|
||||
into the pane). To send a bare Enter (confirm a dialog), send `{"input":"\r"}`.
|
||||
- `input` is capped at **65536** characters. ⚠️ **Two caps disagree and the smaller one
|
||||
is the real one**: the Zod schema allows 100000 (`schemas.ts:1035`), so a 65537-to-100000
|
||||
character body passes validation and *then* 400s at the route against
|
||||
@@ -796,8 +797,8 @@ response echoes.
|
||||
| `match` string | 1–200 characters, literal only | 400; `regex=` is rejected outright |
|
||||
| `from=buffer` scan window | 256 KB tail of the terminal buffer | a marker older than that tail is invisible even with `from=buffer` |
|
||||
| wait-output snippet context | 80 characters either side | `wait.snippet` is bounded, not the whole line |
|
||||
| sessions, process-wide | 50 (`MAX_CONCURRENT_SESSIONS`) | 409 `SESSION_BUSY` on quick-start |
|
||||
| sessions, per user | 25 in multi-user mode (half the global cap) | the same 409, with a different message |
|
||||
| sessions, process-wide | 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 462) | 409 `SESSION_BUSY` on quick-start |
|
||||
| sessions, per user | half the global cap (25 by default) in multi-user mode | the same 409, with a different message |
|
||||
| SSE clients, process-wide | 100 (`MAX_SSE_CLIENTS`) | plain-text `503 Too many SSE connections`; shared with every browser tab |
|
||||
| active bash tools tracked | 20 per session | oldest entries drop off `active-tools` |
|
||||
| auth failures per IP | 10, decaying over 15 min | plain-text 429 with `Retry-After`; locks out the login path, so never loop a bad credential |
|
||||
|
||||
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||
```
|
||||
|
||||
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
||||
|
||||
@@ -101,8 +101,9 @@ Differences from `quick-start` worth knowing before you debug one:
|
||||
- hitting the session cap here is `OPERATION_FAILED`, where `quick-start` returns
|
||||
`SESSION_BUSY` for the identical condition.
|
||||
|
||||
`quick-start` failure codes are `SESSION_BUSY` (the global 50-session cap, or the
|
||||
per-user cap of 25 in multi-user mode), `FORBIDDEN`, `CONFLICT`, `NOT_FOUND` (a
|
||||
`quick-start` failure codes are `SESSION_BUSY` (the global session cap, 50 by default via
|
||||
`CODEMAN_MAX_SESSIONS`, or the per-user cap in multi-user mode, half the global cap,
|
||||
25 by default), `FORBIDDEN`, `CONFLICT`, `NOT_FOUND` (a
|
||||
remote or docker host named by the case no longer exists), `OPERATION_FAILED` and
|
||||
`INVALID_INPUT`. **None of them are retryable in a loop.** Always branch on
|
||||
`.success` before reading `.data.sessionId`: on failure the field is absent, `jq -r`
|
||||
@@ -303,10 +304,16 @@ duplicate resend costs nothing.
|
||||
Codeman types the text and sends Enter **only when the input contains a carriage
|
||||
return**; without it your command sits unsubmitted on the worker's prompt and
|
||||
everything downstream times out. No response field catches this: `delivered:true`
|
||||
means "written to the pane", **not** "submitted". Newlines are stripped, so input is
|
||||
single-line by construction. Build the body with `jq -n` for any prompt you did not
|
||||
author as a literal, because the inline `-d '{"input":"'"$P"'\r"}'` pattern breaks on
|
||||
the first double quote, backslash or `$` in a real prompt:
|
||||
means "written to the pane", **not** "submitted". With `useMux`, newlines are kept: the
|
||||
text goes in as one paste, and the `\r` submits all of it. **`sendwait` sends one line,
|
||||
though:** its Enter loop and the server's submit check cannot reliably recognise a
|
||||
multi-line paste, so a multi-line prompt to a freshly spawned worker could sit unsent in
|
||||
Claude Code's Enter-ignore window. `sendwait` therefore joins the lines with a space,
|
||||
which loses their layout; put long multi-line content in a file
|
||||
([§5.9](#59-big-input-via-the-workspace)). Build the body with `jq -n`
|
||||
for any prompt you did not author as a literal, because the inline
|
||||
`-d '{"input":"'"$P"'\r"}'` pattern breaks on the first double quote, backslash or `$`
|
||||
in a real prompt:
|
||||
|
||||
```bash
|
||||
BODY=$(jq -n --arg p "$PROMPT" '{input:($p+"\r"),useMux:true,clientId:"agent-1",seq:1,wait:true,waitTimeout:60000}')
|
||||
@@ -587,9 +594,10 @@ allowlist in §4.
|
||||
|
||||
### 5.9 Big input via the workspace
|
||||
|
||||
The composer is a single line capped at 65536 characters with newlines stripped, which
|
||||
makes it a bad channel for a spec, a diff or a file list. The workspace is the good
|
||||
one, and for a local or docker case you are on the same filesystem as the worker.
|
||||
The composer is capped at 65536 characters, and a multi-line paste cannot be confirmed
|
||||
as submitted, which makes it a bad channel for a spec, a diff or a file list. The
|
||||
workspace is the good one, and for a local or docker case you are on the same
|
||||
filesystem as the worker.
|
||||
|
||||
1. Write `TASK.md` into the worker's workspace with your own file tools. The path is
|
||||
`.data.casePath` from `quick-start`, or the `workingDir` you passed to
|
||||
@@ -599,10 +607,10 @@ one, and for a local or docker case you are on the same filesystem as the worker
|
||||
3. Wait on `DONE_<token>` with `wait-output` ([§5.5](#55-markers-for-hook-less-workers)),
|
||||
then read `RESULT.json` back with your own tools.
|
||||
|
||||
This sidesteps the byte cap, the newline stripping and the quoting hazards in one
|
||||
move, and it makes the marker **split by construction**: the token lives in the file,
|
||||
never in the line you type, so the echo of your own keystrokes cannot match it. The
|
||||
worker also gets to re-read the task instead of holding it in one echoed line.
|
||||
This sidesteps the byte cap, the unconfirmed multi-line paste and the quoting hazards
|
||||
in one move, and it makes the marker **split by construction**: the token lives in the
|
||||
file, never in the line you type, so the echo of your own keystrokes cannot match it.
|
||||
The worker also gets to re-read the task instead of holding it in one echoed line.
|
||||
|
||||
⚠️ Two places it does not work: a **remote-SSH case** runs on another host whose
|
||||
filesystem you cannot see, and any worker **currently editing** the directory you are
|
||||
|
||||
+8
-6
@@ -129,16 +129,18 @@ export function sendPromptFromArgs(words: readonly string[]): { text: string } |
|
||||
}
|
||||
|
||||
/**
|
||||
* Why `send` refuses this text, or `undefined` when it is printable. The composer
|
||||
* takes one line; the server strips `\r`/`\n` but everything else below 0x20 (and
|
||||
* DEL) reaches the pane as a keypress. None of that is a prompt.
|
||||
* Why `send` refuses this text, or `undefined` when it is printable. A line break is
|
||||
* refused for the reason below; everything else below 0x20 (and DEL) reaches the pane
|
||||
* as a keypress. None of that is a prompt.
|
||||
*/
|
||||
export function inputRefusal(text: string): string | undefined {
|
||||
if (text.length === 0) return 'refusing: empty input (use `interrupt` for ESC, `send <id> ""` is never a prompt)';
|
||||
// The composer is one line: the server strips newlines, which silently joins the
|
||||
// lines into one prompt, and a tab reaches the pane as a keypress (claude: mode toggle).
|
||||
// The server pastes a multi-line prompt as one block, but the check that re-presses
|
||||
// a stranded Enter cannot reliably recognise that block in the composer, so a wait
|
||||
// could burn its timeout on a prompt that never went out. A tab reaches the pane as
|
||||
// a keypress (claude: mode toggle).
|
||||
if (/[\n\r\t]/.test(text)) {
|
||||
return 'refusing: input must be a single line (the composer strips newlines and would join your lines) — join them yourself, or write a file into the workspace and send its path';
|
||||
return 'refusing: input must be a single line (a multi-line paste cannot be confirmed as submitted) — join them yourself, or write a file into the workspace and send its path';
|
||||
}
|
||||
// C0, DEL and C1 (U+0080–U+009F: an 8-bit CSI is still a CSI to a terminal).
|
||||
// eslint-disable-next-line no-control-regex
|
||||
|
||||
@@ -0,0 +1,948 @@
|
||||
/**
|
||||
* @fileoverview Config backups: timestamped snapshots of the files that hold what a user has
|
||||
* customized, kept in a default-but-configurable folder with configurable retention.
|
||||
*
|
||||
* Why it exists: those files are plain JSON in the data dir, rewritten by the server, the Settings UI,
|
||||
* the CLI and (once, by a mis-configured test run) by things that should never touch them, and there
|
||||
* was no copy to go back to. A snapshot is taken at startup and whenever the tracked files' content
|
||||
* changes, so the history is one entry per actual change rather than one per timer tick.
|
||||
*
|
||||
* Safety rules the code enforces rather than documents:
|
||||
* - The tracked file list is a fixed allowlist, never "everything in the data dir".
|
||||
* - Retention only ever deletes folders this module made (name pattern AND a parseable manifest), so
|
||||
* pointing the backup folder at a directory that holds other things cannot delete them.
|
||||
* - The newest snapshot is never pruned, whatever the retention numbers say.
|
||||
* - A snapshot is built in a temp folder and renamed into place, so a crash leaves no half snapshot.
|
||||
* - Restore verifies each file against the manifest's SHA-256 and takes a `pre-restore` snapshot first.
|
||||
* - Snapshot folders are 0700 and their files 0600: some tracked files carry credentials.
|
||||
*
|
||||
* Pure of the server: every function takes the directories it works in.
|
||||
*
|
||||
* @module config-backup
|
||||
*/
|
||||
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { isAbsolute, join, resolve, sep } from 'node:path';
|
||||
|
||||
/**
|
||||
* The files (relative to the data dir) a snapshot holds: what a user configured, not what the server
|
||||
* regenerates (session state, logs, the hook secret, docker seeds, caches).
|
||||
*
|
||||
* ⚠️ Not tracked: `state.json`. It also holds cron jobs and respawn presets, but it is rewritten with
|
||||
* every session event, so tracking it would make a snapshot per event and bury the real changes.
|
||||
*
|
||||
* ⚠️ `.env`, `users.json`, `custom-model-hosts.json` and `push-keys.json` carry credentials or keys.
|
||||
* They are already in the data dir with the same permissions; a snapshot folder is 0700 with 0600
|
||||
* files, so the default location widens nothing. A custom location is the user's own choice.
|
||||
*/
|
||||
export const CONFIG_BACKUP_FILES = [
|
||||
'settings.json',
|
||||
'clis.json',
|
||||
'custom-model-hosts.json',
|
||||
'webhook.json',
|
||||
'intents.json',
|
||||
'linked-cases.json',
|
||||
'remote-hosts.json',
|
||||
'remote-cases.json',
|
||||
'docker-hosts.json',
|
||||
'docker-cases.json',
|
||||
'webviews.json',
|
||||
'push-keys.json',
|
||||
'users.json',
|
||||
'.env',
|
||||
] as const;
|
||||
|
||||
export const CONFIG_BACKUP_DEFAULTS = { enabled: true, keepCount: 20, keepDays: 30 } as const;
|
||||
/** 1 would delete the state before every change, so a bad write could never be undone. */
|
||||
export const CONFIG_BACKUP_MIN_KEEP_COUNT = 2;
|
||||
export const CONFIG_BACKUP_MAX_KEEP_COUNT = 500;
|
||||
export const CONFIG_BACKUP_MAX_KEEP_DAYS = 3650;
|
||||
|
||||
/** `cfg-YYYYMMDD-HHMMSS`, plus `-2`, `-3`… if two land in one second. */
|
||||
const SNAPSHOT_ID_RE = /^cfg-\d{8}-\d{6}(?:-\d{1,3})?$/;
|
||||
const MANIFEST = 'manifest.json';
|
||||
|
||||
export interface ConfigBackupSettings {
|
||||
enabled: boolean;
|
||||
/** Absolute folder, or null for the default (`<data dir>/backups/config`). */
|
||||
dir: string | null;
|
||||
/** Keep at most this many snapshots (newest first). 2–500. */
|
||||
keepCount: number;
|
||||
/** Also drop snapshots older than this many days; 0 = no age limit. */
|
||||
keepDays: number;
|
||||
}
|
||||
|
||||
export interface ConfigBackupFile {
|
||||
name: string;
|
||||
bytes: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface ConfigBackupManifest {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
reason: string;
|
||||
appVersion: string;
|
||||
/** Digest of the set of files, so an unchanged state is not snapshotted twice. */
|
||||
hash: string;
|
||||
/** Id of the data dir that made it (see {@link configBackupOrigin}), so instances sharing one folder keep to their own snapshots. */
|
||||
origin?: string;
|
||||
/**
|
||||
* The retention in force when it was made. If `settings.json` later fails to parse or is reset,
|
||||
* the next pass prunes with these instead of the defaults, so one bad write cannot shorten a
|
||||
* history the user asked to keep.
|
||||
*/
|
||||
keepCount?: number;
|
||||
keepDays?: number;
|
||||
files: ConfigBackupFile[];
|
||||
}
|
||||
|
||||
export interface ConfigBackupSummary {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
reason: string;
|
||||
appVersion: string;
|
||||
files: Array<{ name: string; bytes: number }>;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
const intIn = (value: unknown, min: number, max: number, fallback: number): number => {
|
||||
const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : NaN;
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
return Math.min(max, Math.max(min, Math.floor(n)));
|
||||
};
|
||||
|
||||
/** `~`-expand and require an absolute path; anything else means "use the default". */
|
||||
export function normalizeBackupDir(raw: unknown, home: string = homedir()): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
let value = raw.trim();
|
||||
if (!value || value.includes('\0')) return null;
|
||||
if (value === '~') value = home;
|
||||
else if (value.startsWith('~/')) value = join(home, value.slice(2));
|
||||
if (!isAbsolute(value)) return null;
|
||||
return resolve(value);
|
||||
}
|
||||
|
||||
/** Read the four `configBackup*` settings out of a settings.json object, clamped and defaulted. */
|
||||
export function readConfigBackupSettings(raw: Record<string, unknown> | null | undefined): ConfigBackupSettings {
|
||||
const s = raw ?? {};
|
||||
return {
|
||||
// Absent means ON: the feature protects against an event nobody schedules.
|
||||
enabled: s.configBackupEnabled !== false,
|
||||
dir: normalizeBackupDir(s.configBackupDir),
|
||||
keepCount: intIn(
|
||||
s.configBackupKeepCount,
|
||||
CONFIG_BACKUP_MIN_KEEP_COUNT,
|
||||
CONFIG_BACKUP_MAX_KEEP_COUNT,
|
||||
CONFIG_BACKUP_DEFAULTS.keepCount
|
||||
),
|
||||
keepDays: intIn(s.configBackupKeepDays, 0, CONFIG_BACKUP_MAX_KEEP_DAYS, CONFIG_BACKUP_DEFAULTS.keepDays),
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultConfigBackupDir(dataDir: string): string {
|
||||
return join(dataDir, 'backups', 'config');
|
||||
}
|
||||
|
||||
/**
|
||||
* The folder snapshots go to: the configured one, or the default. A custom folder that IS the data dir
|
||||
* falls back to the default, because snapshot folders and the live files must not share a directory.
|
||||
*/
|
||||
export function resolveConfigBackupDir(settings: ConfigBackupSettings, dataDir: string): string {
|
||||
const fallback = defaultConfigBackupDir(dataDir);
|
||||
const dir = settings.dir;
|
||||
if (!dir) return fallback;
|
||||
const data = resolve(dataDir);
|
||||
if (dir === data) return fallback;
|
||||
return dir;
|
||||
}
|
||||
|
||||
const ORIGIN_FILE = 'config-backup-id';
|
||||
const ORIGIN_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
/**
|
||||
* Who owns a snapshot: a random id made once and kept in the data dir. A hostname would not do (a
|
||||
* Docker container gets a new one on every recreate, which orphaned every earlier snapshot), nor
|
||||
* would the path alone (two machines can share a `$HOME` path). It is a file of its own, not the
|
||||
* hook secret, and is not tracked or restored. If it cannot be written the id is not persisted, so
|
||||
* snapshots made meanwhile are not listed later; the data dir being unwritable breaks far more.
|
||||
*/
|
||||
export function configBackupOrigin(dataDir: string): string {
|
||||
const path = join(dataDir, ORIGIN_FILE);
|
||||
try {
|
||||
const existing = readFileSync(path, 'utf8').trim();
|
||||
if (ORIGIN_RE.test(existing)) return existing;
|
||||
} catch {
|
||||
/* missing: made below */
|
||||
}
|
||||
const id = randomUUID();
|
||||
try {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
writeFileSync(path, id + '\n', { mode: 0o600 });
|
||||
} catch {
|
||||
/* see above */
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** The id already made for this data dir, or null. Read-only: listing must not write a file. */
|
||||
export function readConfigBackupOrigin(dataDir: string): string | null {
|
||||
try {
|
||||
const existing = readFileSync(join(dataDir, ORIGIN_FILE), 'utf8').trim();
|
||||
return ORIGIN_RE.test(existing) ? existing : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const sha256 = (data: Buffer | string): string => createHash('sha256').update(data).digest('hex');
|
||||
|
||||
/**
|
||||
* Keys a tracked file rewrites on its own schedule, with no change to anything the user set, so
|
||||
* they must not make a snapshot look different. `custom-model-hosts.json` carries a
|
||||
* `lastDiscoveredAt` per host that the model-discovery poll refreshes every 5 minutes: before this,
|
||||
* the change check never matched and the snapshot folder filled with copies that differed by one
|
||||
* timestamp, evicting every older snapshot within about 100 minutes at the default keep count.
|
||||
* The snapshot itself still stores the file exactly as it was, and restore still verifies against
|
||||
* the file's own digest; only the "has anything changed?" comparison looks through these keys.
|
||||
*/
|
||||
const VOLATILE_KEYS: Readonly<Record<string, readonly string[]>> = {
|
||||
'custom-model-hosts.json': ['lastDiscoveredAt'],
|
||||
// Rewritten by activity, not by anything the user set: a Basic-auth login (at most once a minute
|
||||
// per user), a session started in another case, every captured prompt, and a Docker case's
|
||||
// conversation id after a /clear, and a web tab being opened.
|
||||
'users.json': ['lastLoginAt'],
|
||||
'settings.json': ['lastUsedCase'],
|
||||
'intents.json': ['recentPrompts', 'updatedAt'],
|
||||
'docker-cases.json': ['lastClaudeSessionId'],
|
||||
'webviews.json': ['lastOpenedAt'],
|
||||
};
|
||||
|
||||
function withoutKeys(value: unknown, drop: ReadonlySet<string>): unknown {
|
||||
if (Array.isArray(value)) return value.map((v) => withoutKeys(v, drop));
|
||||
if (value && typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (!drop.has(k)) out[k] = withoutKeys(v, drop);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Digest used only to decide whether two states are the same: the file's own, minus its volatile keys. */
|
||||
function changeDigest(name: string, data: Buffer, rawSha: string): string {
|
||||
const volatile = VOLATILE_KEYS[name];
|
||||
if (!volatile) return rawSha;
|
||||
try {
|
||||
return sha256(JSON.stringify(withoutKeys(JSON.parse(data.toString('utf8')), new Set(volatile))));
|
||||
} catch {
|
||||
return rawSha; // not JSON (or unreadable as such): compare the bytes, as for any other file
|
||||
}
|
||||
}
|
||||
|
||||
function stamp(now: number): string {
|
||||
const d = new Date(now);
|
||||
const p = (n: number, w = 2) => String(n).padStart(w, '0');
|
||||
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function readManifest(dir: string, id: string, origin?: string): ConfigBackupManifest | null {
|
||||
if (!SNAPSHOT_ID_RE.test(id)) return null;
|
||||
try {
|
||||
const m = JSON.parse(readFileSync(join(dir, id, MANIFEST), 'utf8')) as ConfigBackupManifest;
|
||||
if (!m || m.id !== id || !Array.isArray(m.files) || typeof m.createdAt !== 'number' || typeof m.hash !== 'string') {
|
||||
return null;
|
||||
}
|
||||
if (origin !== undefined && m.origin !== origin) return null;
|
||||
return m;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A folder's entry names; none when it cannot be read (absent, or not a folder). */
|
||||
function folderEntries(dir: string): string[] {
|
||||
try {
|
||||
return readdirSync(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The manifests of the snapshots this module made among `names` in `dir`, newest first. Foreign
|
||||
* folders and files are ignored, and so are other instances' snapshots when `origin` is given (see
|
||||
* {@link configBackupOrigin}). Each one is a file read, so a pass does this once and passes the
|
||||
* result on (see {@link runConfigBackupPass}).
|
||||
*/
|
||||
function manifestsIn(dir: string, names: readonly string[], origin?: string): ConfigBackupManifest[] {
|
||||
const out: ConfigBackupManifest[] = [];
|
||||
for (const id of names) {
|
||||
const m = readManifest(dir, id, origin);
|
||||
if (m) out.push(m);
|
||||
}
|
||||
return out.sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? 1 : -1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshots this module made in `dir`, newest first. Foreign folders and files are ignored, and so
|
||||
* are other instances' snapshots when `origin` is given (see {@link configBackupOrigin}).
|
||||
*/
|
||||
export function listConfigBackups(dir: string, origin?: string): ConfigBackupSummary[] {
|
||||
return manifestsIn(dir, folderEntries(dir), origin).map((m) => {
|
||||
const files = m.files.map((f) => ({ name: f.name, bytes: f.bytes }));
|
||||
return {
|
||||
id: m.id,
|
||||
createdAt: m.createdAt,
|
||||
reason: m.reason,
|
||||
appVersion: m.appVersion,
|
||||
files,
|
||||
bytes: files.reduce((n, f) => n + f.bytes, 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export type ConfigBackupResult =
|
||||
| { status: 'created'; id: string; files: number; bytes: number }
|
||||
| { status: 'unchanged'; id: string }
|
||||
| { status: 'empty' };
|
||||
|
||||
export interface CreateConfigBackupOptions {
|
||||
dataDir: string;
|
||||
backupDir: string;
|
||||
reason: string;
|
||||
appVersion?: string;
|
||||
/** Snapshot even if nothing changed (a manual request, a pre-restore safety copy). */
|
||||
force?: boolean;
|
||||
/** Recorded in the manifest, see {@link ConfigBackupManifest.keepCount}. */
|
||||
keepCount?: number;
|
||||
keepDays?: number;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
/** Snapshot the tracked files that exist, unless the latest snapshot already holds exactly this state. */
|
||||
export function createConfigBackup(opts: CreateConfigBackupOptions): ConfigBackupResult {
|
||||
return snapshotConfig(opts).result;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link createConfigBackup}, also returning the manifest it wrote, over this instance's snapshots
|
||||
* when the caller has already listed them (`listed`, newest first): one walk of the folder per pass.
|
||||
*/
|
||||
function snapshotConfig(
|
||||
opts: CreateConfigBackupOptions,
|
||||
listed?: { origin: string; existing: ConfigBackupManifest[] }
|
||||
): { result: ConfigBackupResult; manifest: ConfigBackupManifest | null } {
|
||||
const now = opts.now ?? Date.now();
|
||||
const present: Array<{ name: string; data: Buffer; sha: string; change: string }> = [];
|
||||
for (const name of CONFIG_BACKUP_FILES) {
|
||||
try {
|
||||
const path = join(opts.dataDir, name);
|
||||
if (!statSync(path).isFile()) continue;
|
||||
const data = readFileSync(path);
|
||||
const sha = sha256(data);
|
||||
present.push({ name, data, sha, change: changeDigest(name, data, sha) });
|
||||
} catch {
|
||||
/* absent or unreadable: simply not part of this snapshot */
|
||||
}
|
||||
}
|
||||
if (present.length === 0) return { result: { status: 'empty' }, manifest: null };
|
||||
|
||||
const hash = sha256(present.map((f) => `${f.name}\0${f.change}`).join('\n'));
|
||||
const origin = listed?.origin ?? configBackupOrigin(opts.dataDir);
|
||||
const existing = listed?.existing ?? manifestsIn(opts.backupDir, folderEntries(opts.backupDir), origin);
|
||||
if (!opts.force && existing[0] && existing[0].hash === hash) {
|
||||
return { result: { status: 'unchanged', id: existing[0].id }, manifest: null };
|
||||
}
|
||||
|
||||
// Never dated earlier than this instance's newest snapshot. One written while the clock read ahead
|
||||
// (a dual-boot machine, a VM resumed with a stale clock) would otherwise stay newest after the
|
||||
// correction, differ from every later pass, and have each one write a copy until real time caught up.
|
||||
const createdAt = existing[0] ? Math.max(now, existing[0].createdAt + 1) : now;
|
||||
mkdirSync(opts.backupDir, { recursive: true, mode: 0o700 });
|
||||
const base = `cfg-${stamp(createdAt)}`;
|
||||
let id = base;
|
||||
for (let n = 2; existsSync(join(opts.backupDir, id)); n++) id = `${base}-${n}`;
|
||||
|
||||
const tmp = join(opts.backupDir, `.tmp-${id}-${process.pid}`);
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
mkdirSync(tmp, { mode: 0o700 });
|
||||
const manifest: ConfigBackupManifest = {
|
||||
id,
|
||||
createdAt,
|
||||
reason: opts.reason,
|
||||
appVersion: opts.appVersion ?? '',
|
||||
hash,
|
||||
origin,
|
||||
...(opts.keepCount !== undefined && { keepCount: opts.keepCount }),
|
||||
...(opts.keepDays !== undefined && { keepDays: opts.keepDays }),
|
||||
files: present.map((f) => ({ name: f.name, bytes: f.data.length, sha256: f.sha })),
|
||||
};
|
||||
try {
|
||||
for (const f of present) writeFileSync(join(tmp, f.name), f.data, { mode: 0o600 });
|
||||
writeFileSync(join(tmp, MANIFEST), JSON.stringify(manifest, null, 2) + '\n', { mode: 0o600 });
|
||||
renameSync(tmp, join(opts.backupDir, id));
|
||||
try {
|
||||
chmodSync(join(opts.backupDir, id), 0o700);
|
||||
} catch {
|
||||
/* best effort on filesystems without modes */
|
||||
}
|
||||
} catch (err) {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
throw err;
|
||||
}
|
||||
const bytes = present.reduce((n, f) => n + f.data.length, 0);
|
||||
return { result: { status: 'created', id, files: present.length, bytes }, manifest };
|
||||
}
|
||||
|
||||
/** Remove `.tmp-cfg-*` build folders a crashed snapshot left behind (older than an hour, so a live one on a shared folder is safe). */
|
||||
export function sweepConfigBackupTemps(dir: string, now: number = Date.now()): void {
|
||||
sweepTemps(dir, folderEntries(dir), now);
|
||||
}
|
||||
|
||||
function sweepTemps(dir: string, names: readonly string[], now: number): void {
|
||||
for (const n of names) {
|
||||
if (!/^\.tmp-cfg-\d{8}-\d{6}(?:-\d{1,3})?-\d+$/.test(n)) continue;
|
||||
const st = lstatSync(join(dir, n), { throwIfNoEntry: false });
|
||||
if (st && now - st.mtimeMs > 3_600_000) rmSync(join(dir, n), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The retention recorded in this instance's newest snapshot, or null when it recorded none. `listed`
|
||||
* is this instance's manifests in `dir`, newest first, when the caller already read them.
|
||||
*/
|
||||
export function latestBackupRetention(
|
||||
dir: string,
|
||||
origin: string,
|
||||
listed?: readonly ConfigBackupManifest[]
|
||||
): { keepCount: number; keepDays: number } | null {
|
||||
// The newest manifest that recorded one: a snapshot from before this field existed must not hide it.
|
||||
for (const m of listed ?? manifestsIn(dir, folderEntries(dir), origin)) {
|
||||
if (typeof m.keepCount === 'number' && typeof m.keepDays === 'number') {
|
||||
return { keepCount: m.keepCount, keepDays: m.keepDays };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings in effect, with each retention key that `raw` (the parsed settings.json) lacks, as
|
||||
* after a reset, taken from what the newest backup recorded in `dir` instead of the defaults. It
|
||||
* looks at the retention keys only (not the folder), so re-entering a custom folder after a reset
|
||||
* finds that folder's own manifests. One place for the timer pass and for what the GET route
|
||||
* reports, so they cannot disagree.
|
||||
*/
|
||||
export function withRecordedRetention(
|
||||
settings: ConfigBackupSettings,
|
||||
raw: Record<string, unknown>,
|
||||
dir: string,
|
||||
origin: string | null,
|
||||
listed?: readonly ConfigBackupManifest[]
|
||||
): ConfigBackupSettings {
|
||||
const lacksCount = !('configBackupKeepCount' in raw);
|
||||
const lacksDays = !('configBackupKeepDays' in raw);
|
||||
if ((!lacksCount && !lacksDays) || !origin) return settings;
|
||||
const kept = latestBackupRetention(dir, origin, listed);
|
||||
if (!kept) return settings;
|
||||
return {
|
||||
...settings,
|
||||
...(lacksCount && { keepCount: kept.keepCount }),
|
||||
...(lacksDays && { keepDays: kept.keepDays }),
|
||||
};
|
||||
}
|
||||
|
||||
export interface PruneOptions {
|
||||
keepCount: number;
|
||||
keepDays: number;
|
||||
/** Only this instance's snapshots are pruned; omitted = every snapshot in the folder. */
|
||||
origin?: string;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply retention: keep the newest `keepCount`, and (when `keepDays` > 0) drop anything older than
|
||||
* that. The newest snapshot always survives. Returns the ids removed.
|
||||
*/
|
||||
export function pruneConfigBackups(dir: string, opts: PruneOptions): string[] {
|
||||
return pruneListed(dir, manifestsIn(dir, folderEntries(dir), opts.origin), opts);
|
||||
}
|
||||
|
||||
/** {@link pruneConfigBackups} over a listing already made (`all`, newest first). */
|
||||
function pruneListed(dir: string, all: ReadonlyArray<{ id: string; createdAt: number }>, opts: PruneOptions): string[] {
|
||||
const now = opts.now ?? Date.now();
|
||||
const cutoff = opts.keepDays > 0 ? now - opts.keepDays * 86_400_000 : null;
|
||||
const removed: string[] = [];
|
||||
all.forEach((b, index) => {
|
||||
if (index === 0) return;
|
||||
const tooMany = index >= Math.max(1, opts.keepCount);
|
||||
// A snapshot's state was live until the next one was made: age it from then. Aging by its own
|
||||
// createdAt deleted the last good copy on the first change after a quiet month.
|
||||
const tooOld = cutoff !== null && all[index - 1].createdAt < cutoff;
|
||||
if (!tooMany && !tooOld) return;
|
||||
// The listing already proved this is one of ours (name pattern + manifest).
|
||||
const target = join(dir, b.id);
|
||||
if (!target.startsWith(resolve(dir) + sep)) return;
|
||||
try {
|
||||
rmSync(target, { recursive: true, force: true });
|
||||
removed.push(b.id);
|
||||
} catch {
|
||||
/* leave it; the next pass retries */
|
||||
}
|
||||
});
|
||||
return removed;
|
||||
}
|
||||
|
||||
export interface ConfigBackupPassOptions {
|
||||
dataDir: string;
|
||||
backupDir: string;
|
||||
reason: string;
|
||||
appVersion?: string;
|
||||
/** Snapshot even if nothing changed (Back up now). */
|
||||
force?: boolean;
|
||||
/** As read from settings.json ({@link readConfigBackupSettings}), before the recorded-retention fallback. */
|
||||
settings: ConfigBackupSettings;
|
||||
/** The parsed settings.json (`{}` when it did not parse): the fallback tells an absent key from a set one. */
|
||||
raw: Record<string, unknown>;
|
||||
/** False when settings.json did not parse: the pass still snapshots, but deletes nothing on its say-so. */
|
||||
prune: boolean;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One pass of the timer or of Back up now: sweep stale temp folders, snapshot when the state changed,
|
||||
* apply retention. The folder is read and each manifest parsed ONCE, and that listing feeds the
|
||||
* recorded-retention fallback, the change check and the prune. It used to be listed for each of the
|
||||
* three, which on a network folder is every manifest read three times every five minutes.
|
||||
*/
|
||||
export function runConfigBackupPass(opts: ConfigBackupPassOptions): {
|
||||
result: ConfigBackupResult;
|
||||
removed: string[];
|
||||
} {
|
||||
const names = folderEntries(opts.backupDir);
|
||||
sweepTemps(opts.backupDir, names, Date.now());
|
||||
const origin = configBackupOrigin(opts.dataDir);
|
||||
const existing = manifestsIn(opts.backupDir, names, origin);
|
||||
// No retention in the file (reset, or unreadable): keep what the newest snapshot recorded.
|
||||
const { keepCount, keepDays } = withRecordedRetention(opts.settings, opts.raw, opts.backupDir, origin, existing);
|
||||
const { result, manifest } = snapshotConfig(
|
||||
{
|
||||
dataDir: opts.dataDir,
|
||||
backupDir: opts.backupDir,
|
||||
reason: opts.reason,
|
||||
appVersion: opts.appVersion,
|
||||
force: opts.force,
|
||||
keepCount,
|
||||
keepDays,
|
||||
now: opts.now,
|
||||
},
|
||||
{ origin, existing }
|
||||
);
|
||||
if (!opts.prune) return { result, removed: [] };
|
||||
// A snapshot made now is dated after existing[0], so it heads the listing retention counts.
|
||||
const all = manifest ? [manifest, ...existing] : existing;
|
||||
return { result, removed: pruneListed(opts.backupDir, all, { keepCount, keepDays, now: opts.now }) };
|
||||
}
|
||||
|
||||
export class ConfigBackupNotFoundError extends Error {}
|
||||
export class ConfigBackupCorruptError extends Error {}
|
||||
|
||||
/**
|
||||
* A restore that wrote some files and could not write others (a full disk, a directory where a file
|
||||
* should be). Every file passed verification first, so the ones written are the backup's: the caller
|
||||
* still has to reload what they feed and tell the user which files are which.
|
||||
*/
|
||||
export class ConfigBackupPartialRestoreError extends Error {
|
||||
constructor(
|
||||
readonly restored: string[],
|
||||
readonly failed: Array<{ name: string; error: string }>,
|
||||
readonly untouched: string[],
|
||||
readonly safetySnapshot: string | null
|
||||
) {
|
||||
const done = restored.length ? `${restored.join(', ')} restored` : 'nothing was restored';
|
||||
const undo = safetySnapshot ? `; the files from before the restore are in backup ${safetySnapshot}` : '';
|
||||
super(
|
||||
`Restore incomplete: could not write ${failed.map((f) => f.name).join(', ')} (${failed[0]?.error}); ${done}${undo}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore refuses what this module never writes. Snapshots are 0700/0600 real files, and a restore
|
||||
* writes the file as 0600: a symlink, or a file with a group/world bit, was planted or loosened
|
||||
* after the snapshot, and rewriting it as 0600 would turn it into trusted config (`clis.json`
|
||||
* selects the binaries Codeman spawns; `isUnsafePermissions` refuses the same bits there).
|
||||
* POSIX only, like `isUnsafePermissions`. The mode test is skipped (the symlink test is not) on a
|
||||
* filesystem that does not keep modes, see {@link folderKeepsModes}.
|
||||
*/
|
||||
function assertTrustedSnapshotEntry(path: string, label: string, checkMode: boolean): void {
|
||||
if (process.platform === 'win32') return;
|
||||
const st = lstatSync(path, { throwIfNoEntry: false });
|
||||
if (!st) return; // missing is reported by the read that follows
|
||||
if (st.isSymbolicLink()) throw new ConfigBackupCorruptError(`${label} is a symlink, so it is not used`);
|
||||
if (checkMode && (st.mode & 0o077) !== 0) {
|
||||
throw new ConfigBackupCorruptError(`${label} is readable or writable by group or others, so it is not used`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the backup folder's filesystem keeps a file's mode: vfat/exFAT, CIFS shares and WSL's
|
||||
* `/mnt/d` report 0755 or 0777 for everything, so the group/world test would refuse every backup
|
||||
* there even though snapshots succeed (`createConfigBackup` already expects mode-less filesystems).
|
||||
* Probed with a 0600 temp file made with `wx` under a random name; if the probe cannot run, the
|
||||
* strict test stays on.
|
||||
*/
|
||||
function folderKeepsModes(dir: string): boolean {
|
||||
if (process.platform === 'win32') return false;
|
||||
// A random name and 'wx' (fail if anything is there): a planted symlink or directory with a
|
||||
// guessable name must neither be written through nor break the probe. Only a file this call
|
||||
// created is removed.
|
||||
const probe = join(dir, `.mode-probe-${randomUUID()}`);
|
||||
let created = false;
|
||||
try {
|
||||
writeFileSync(probe, '', { mode: 0o600, flag: 'wx' });
|
||||
created = true;
|
||||
chmodSync(probe, 0o600);
|
||||
return (lstatSync(probe).mode & 0o777 & 0o077) === 0;
|
||||
} catch {
|
||||
return true;
|
||||
} finally {
|
||||
if (created) rmSync(probe, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** The folder's own backup settings stay as they are now: restoring an old `configBackupDir` hides the `pre-restore` copy. */
|
||||
const BACKUP_SETTING_KEYS = ['configBackupEnabled', 'configBackupDir', 'configBackupKeepCount', 'configBackupKeepDays'];
|
||||
|
||||
function keepCurrentBackupSettings(snapshot: Buffer, livePath: string): Buffer {
|
||||
try {
|
||||
const restored = JSON.parse(snapshot.toString('utf8')) as Record<string, unknown>;
|
||||
const live = JSON.parse(readFileSync(livePath, 'utf8')) as Record<string, unknown>;
|
||||
if (!restored || typeof restored !== 'object' || !live || typeof live !== 'object') return snapshot;
|
||||
let changed = false;
|
||||
// A reset live file lacks the retention keys. Taking "absent" for them would bring the defaults
|
||||
// back (20 copies, 30 days) on the next pass, so the backup's own retention stays.
|
||||
for (const key of BACKUP_SETTING_KEYS) {
|
||||
if (!(key in live) && (key === 'configBackupKeepCount' || key === 'configBackupKeepDays')) continue;
|
||||
if (JSON.stringify(restored[key]) === JSON.stringify(live[key])) continue;
|
||||
changed = true;
|
||||
if (key in live) restored[key] = live[key];
|
||||
else delete restored[key];
|
||||
}
|
||||
if (!changed) return snapshot; // byte-identical to the snapshot when the folder settings did not move
|
||||
return Buffer.from(JSON.stringify(restored, null, 2) + '\n');
|
||||
} catch {
|
||||
return snapshot; // not JSON, or no live file to take the values from
|
||||
}
|
||||
}
|
||||
|
||||
export interface RestoreResult {
|
||||
restored: string[];
|
||||
/** Tracked files present now that the snapshot did not hold; a restore leaves them as they are. */
|
||||
untouched: string[];
|
||||
/**
|
||||
* The snapshot holding the live files as they were before the restore: the `pre-restore` copy, or the
|
||||
* newest backup when it already held exactly that state. Null only when no tracked file existed.
|
||||
*/
|
||||
safetySnapshot: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a snapshot's files back into the data dir. Every file is checked against the manifest's digest
|
||||
* BEFORE anything is written, a `pre-restore` snapshot of the live files is taken first (unless the
|
||||
* newest backup already holds that state, which is then reported instead), and each file is replaced
|
||||
* atomically (temp + rename) with mode 0600. A file that cannot be written does not stop the others;
|
||||
* it throws {@link ConfigBackupPartialRestoreError} at the end, naming both lists.
|
||||
*/
|
||||
export function restoreConfigBackup(opts: {
|
||||
dataDir: string;
|
||||
backupDir: string;
|
||||
id: string;
|
||||
appVersion?: string;
|
||||
now?: number;
|
||||
/** Override the probe of whether the backup folder keeps file modes (tests; a mode-less filesystem). */
|
||||
keepsModes?: boolean;
|
||||
}): RestoreResult {
|
||||
const origin = readConfigBackupOrigin(opts.dataDir);
|
||||
const manifest = origin ? readManifest(opts.backupDir, opts.id, origin) : null;
|
||||
if (!manifest || !origin) throw new ConfigBackupNotFoundError(`No config backup named ${opts.id}`);
|
||||
|
||||
const allowed = new Set<string>(CONFIG_BACKUP_FILES);
|
||||
const loaded: Array<{ name: string; data: Buffer }> = [];
|
||||
const checkMode = opts.keepsModes ?? folderKeepsModes(opts.backupDir);
|
||||
assertTrustedSnapshotEntry(join(opts.backupDir, opts.id), `Backup ${opts.id}`, checkMode);
|
||||
for (const f of manifest.files) {
|
||||
if (!allowed.has(f.name)) continue;
|
||||
let data: Buffer;
|
||||
assertTrustedSnapshotEntry(join(opts.backupDir, opts.id, f.name), `${f.name} in ${opts.id}`, checkMode);
|
||||
try {
|
||||
data = readFileSync(join(opts.backupDir, opts.id, f.name));
|
||||
} catch {
|
||||
throw new ConfigBackupCorruptError(`${f.name} is missing from ${opts.id}`);
|
||||
}
|
||||
if (sha256(data) !== f.sha256)
|
||||
throw new ConfigBackupCorruptError(`${f.name} in ${opts.id} does not match its checksum`);
|
||||
loaded.push({ name: f.name, data });
|
||||
}
|
||||
if (loaded.length === 0) throw new ConfigBackupCorruptError(`${opts.id} holds no restorable files`);
|
||||
|
||||
// One walk of the folder serves both the recorded retention and the snapshot below.
|
||||
const existing = manifestsIn(opts.backupDir, folderEntries(opts.backupDir), origin);
|
||||
const { result: safety } = snapshotConfig(
|
||||
{
|
||||
dataDir: opts.dataDir,
|
||||
backupDir: opts.backupDir,
|
||||
reason: 'pre-restore',
|
||||
appVersion: opts.appVersion,
|
||||
// Not forced: when the live files equal the newest backup, that backup already is the undo copy,
|
||||
// and a forced duplicate would spend a retention slot on nothing.
|
||||
now: opts.now,
|
||||
// The retention in force, so this newest snapshot does not hide it from the next pass.
|
||||
...(latestBackupRetention(opts.backupDir, origin, existing) ?? {}),
|
||||
},
|
||||
{ origin, existing }
|
||||
);
|
||||
|
||||
mkdirSync(opts.dataDir, { recursive: true });
|
||||
const restored: string[] = [];
|
||||
const failed: Array<{ name: string; error: string }> = [];
|
||||
for (const f of loaded) {
|
||||
const target = join(opts.dataDir, f.name);
|
||||
const tmp = `${target}.restore-${process.pid}`;
|
||||
try {
|
||||
writeFileSync(tmp, f.name === 'settings.json' ? keepCurrentBackupSettings(f.data, target) : f.data, {
|
||||
mode: 0o600,
|
||||
});
|
||||
renameSync(tmp, target);
|
||||
restored.push(f.name);
|
||||
} catch (err) {
|
||||
try {
|
||||
rmSync(tmp, { force: true });
|
||||
} catch {
|
||||
/* the temp is a leftover at worst; the failure below is what matters */
|
||||
}
|
||||
// Keep going: each file is replaced on its own, and the files written before this one are the
|
||||
// backup's already. Stopping here used to answer a bare 500 that named neither list.
|
||||
failed.push({ name: f.name, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
// A restore never deletes: tracked files the snapshot did not hold stay as they are.
|
||||
const untouched = CONFIG_BACKUP_FILES.filter(
|
||||
(n) =>
|
||||
!restored.includes(n) &&
|
||||
!failed.some((x) => x.name === n) &&
|
||||
lstatSync(join(opts.dataDir, n), { throwIfNoEntry: false })?.isFile()
|
||||
);
|
||||
const safetySnapshot = safety.status === 'empty' ? null : safety.id;
|
||||
if (failed.length) throw new ConfigBackupPartialRestoreError(restored, failed, untouched, safetySnapshot);
|
||||
return { restored, untouched, safetySnapshot };
|
||||
}
|
||||
|
||||
/** The live files in the data dir, as a side of a diff. */
|
||||
export const CONFIG_DIFF_CURRENT = 'current';
|
||||
|
||||
export interface ConfigDiffChange {
|
||||
/** Dotted path inside the file (`a.b[0].c`), or the variable name for `.env`. */
|
||||
path: string;
|
||||
kind: 'added' | 'removed' | 'changed';
|
||||
/** JSON text of the value; absent when it did not exist on that side or `redacted` is set. */
|
||||
before?: string;
|
||||
after?: string;
|
||||
/** The value is a credential, so only the fact that it differs is reported. */
|
||||
redacted?: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigDiffFile {
|
||||
name: string;
|
||||
/** `added` = only in `to`, `removed` = only in `from`. */
|
||||
status: 'added' | 'removed' | 'changed' | 'unchanged';
|
||||
changes: ConfigDiffChange[];
|
||||
/** More changes than `MAX_DIFF_CHANGES`; the rest are not listed. */
|
||||
truncated: boolean;
|
||||
/** `keys` = per-key changes; `bytes` = the file is not JSON/env, so only "differs" is known. */
|
||||
detail: 'keys' | 'bytes';
|
||||
}
|
||||
|
||||
export interface ConfigDiff {
|
||||
from: string;
|
||||
to: string;
|
||||
files: ConfigDiffFile[];
|
||||
/** Files whose status is not `unchanged`. */
|
||||
changed: number;
|
||||
}
|
||||
|
||||
const MAX_DIFF_CHANGES = 200;
|
||||
const MAX_DIFF_VALUE_CHARS = 200;
|
||||
|
||||
/** Files whose every value is a credential or key: only names are ever shown. */
|
||||
const REDACT_ALL_FILES: ReadonlySet<string> = new Set([
|
||||
'.env',
|
||||
'users.json',
|
||||
'push-keys.json',
|
||||
'webhook.json',
|
||||
'intents.json',
|
||||
]);
|
||||
/** Elsewhere a value is hidden when its own key looks like a credential. */
|
||||
const SECRET_KEY_RE = /(token|secret|passw|credential|auth|key$)/i;
|
||||
|
||||
type Leaves = Map<string, string>;
|
||||
|
||||
function flattenJson(value: unknown, path: string, out: Leaves): void {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
value.forEach((v, i) => flattenJson(v, `${path}[${i}]`, out));
|
||||
} else if (value && typeof value === 'object' && Object.keys(value).length > 0) {
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
flattenJson(v, path ? `${path}.${k}` : k, out);
|
||||
}
|
||||
} else {
|
||||
out.set(path || '(root)', JSON.stringify(value) ?? 'null');
|
||||
}
|
||||
}
|
||||
|
||||
function parseEnv(text: string): Leaves {
|
||||
const out: Leaves = new Map();
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=(.*)$/.exec(line);
|
||||
if (m) out.set(m[1], m[2].trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Per-key view of a tracked file, or null when it is not JSON/env text. */
|
||||
function leavesOf(name: string, data: Buffer): Leaves | null {
|
||||
const text = data.toString('utf8');
|
||||
if (name === '.env') return parseEnv(text);
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
const volatile = VOLATILE_KEYS[name];
|
||||
const out: Leaves = new Map();
|
||||
flattenJson(volatile ? withoutKeys(parsed, new Set(volatile)) : parsed, '', out);
|
||||
return out;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A URL value loses its userinfo, query and fragment: they carry passwords and tokens. */
|
||||
function scrubUrl(jsonText: string): string {
|
||||
try {
|
||||
const v = JSON.parse(jsonText) as unknown;
|
||||
if (typeof v !== 'string' || !/^[a-z][a-z0-9+.-]*:\/\//i.test(v)) return jsonText;
|
||||
const u = new URL(v);
|
||||
if (!u.username && !u.password && !u.search && !u.hash) return jsonText;
|
||||
return JSON.stringify(`${u.protocol}//${u.host}${u.pathname}`);
|
||||
} catch {
|
||||
return jsonText;
|
||||
}
|
||||
}
|
||||
|
||||
const clip = (s: string): string => (s.length > MAX_DIFF_VALUE_CHARS ? `${s.slice(0, MAX_DIFF_VALUE_CHARS)}…` : s);
|
||||
|
||||
function diffLeaves(name: string, a: Leaves, b: Leaves): { changes: ConfigDiffChange[]; truncated: boolean } {
|
||||
const all = REDACT_ALL_FILES.has(name);
|
||||
const changes: ConfigDiffChange[] = [];
|
||||
for (const path of [...new Set([...a.keys(), ...b.keys()])].sort()) {
|
||||
const before = a.get(path);
|
||||
const after = b.get(path);
|
||||
if (before === after) continue;
|
||||
const kind = before === undefined ? 'added' : after === undefined ? 'removed' : 'changed';
|
||||
if (all || SECRET_KEY_RE.test(path.split('.').pop() ?? '')) {
|
||||
changes.push({ path, kind, redacted: true });
|
||||
} else {
|
||||
changes.push({
|
||||
path,
|
||||
kind,
|
||||
...(before !== undefined && { before: clip(scrubUrl(before)) }),
|
||||
...(after !== undefined && { after: clip(scrubUrl(after)) }),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { changes: changes.slice(0, MAX_DIFF_CHANGES), truncated: changes.length > MAX_DIFF_CHANGES };
|
||||
}
|
||||
|
||||
/** The tracked files of one side, verified: a snapshot's against its manifest, the live ones as they are. */
|
||||
function loadDiffSide(side: string, dataDir: string, backupDir: string): Map<string, Buffer> {
|
||||
const out = new Map<string, Buffer>();
|
||||
if (side === CONFIG_DIFF_CURRENT) {
|
||||
for (const name of CONFIG_BACKUP_FILES) {
|
||||
try {
|
||||
const path = join(dataDir, name);
|
||||
if (statSync(path).isFile()) out.set(name, readFileSync(path));
|
||||
} catch {
|
||||
/* absent or unreadable: not part of this side */
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const origin = readConfigBackupOrigin(dataDir);
|
||||
const manifest = origin ? readManifest(backupDir, side, origin) : null;
|
||||
if (!manifest) throw new ConfigBackupNotFoundError(`No config backup named ${side}`);
|
||||
const allowed = new Set<string>(CONFIG_BACKUP_FILES);
|
||||
const checkMode = folderKeepsModes(backupDir);
|
||||
assertTrustedSnapshotEntry(join(backupDir, side), `Backup ${side}`, checkMode);
|
||||
for (const f of manifest.files) {
|
||||
if (!allowed.has(f.name)) continue;
|
||||
let data: Buffer;
|
||||
assertTrustedSnapshotEntry(join(backupDir, side, f.name), `${f.name} in ${side}`, checkMode);
|
||||
try {
|
||||
data = readFileSync(join(backupDir, side, f.name));
|
||||
} catch {
|
||||
throw new ConfigBackupCorruptError(`${f.name} is missing from ${side}`);
|
||||
}
|
||||
if (sha256(data) !== f.sha256)
|
||||
throw new ConfigBackupCorruptError(`${f.name} in ${side} does not match its checksum`);
|
||||
out.set(f.name, data);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* What differs between two states of the tracked files; a state is a snapshot id or `current`
|
||||
* (the live files). Never returns the value of a credential: see REDACT_ALL_FILES and SECRET_KEY_RE.
|
||||
*/
|
||||
export function diffConfigBackups(opts: { dataDir: string; backupDir: string; from: string; to: string }): ConfigDiff {
|
||||
const a = loadDiffSide(opts.from, opts.dataDir, opts.backupDir);
|
||||
const b = loadDiffSide(opts.to, opts.dataDir, opts.backupDir);
|
||||
const files: ConfigDiffFile[] = [];
|
||||
for (const name of CONFIG_BACKUP_FILES) {
|
||||
const before = a.get(name);
|
||||
const after = b.get(name);
|
||||
if (!before && !after) continue;
|
||||
const base = { name, changes: [] as ConfigDiffChange[], truncated: false };
|
||||
if (before && after && sha256(before) === sha256(after)) {
|
||||
files.push({ ...base, status: 'unchanged', detail: 'keys' });
|
||||
continue;
|
||||
}
|
||||
const la = before ? leavesOf(name, before) : new Map<string, string>();
|
||||
const lb = after ? leavesOf(name, after) : new Map<string, string>();
|
||||
const status = !before ? 'added' : !after ? 'removed' : 'changed';
|
||||
if (!la || !lb) {
|
||||
files.push({ ...base, status, detail: 'bytes' });
|
||||
continue;
|
||||
}
|
||||
const { changes, truncated } = diffLeaves(name, la, lb);
|
||||
// Only volatile keys differed (a discovery timestamp): the same state as far as the user is concerned.
|
||||
files.push({
|
||||
name,
|
||||
status: changes.length || status !== 'changed' ? status : 'unchanged',
|
||||
changes,
|
||||
truncated,
|
||||
detail: 'keys',
|
||||
});
|
||||
}
|
||||
return { from: opts.from, to: opts.to, files, changed: files.filter((f) => f.status !== 'unchanged').length };
|
||||
}
|
||||
@@ -329,7 +329,12 @@ const CLAUDE: CliEntry = {
|
||||
'CLAUDE_CODE_MAX_CONTEXT_TOKENS',
|
||||
'CLAUDE_CONFIG_DIR',
|
||||
],
|
||||
gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } },
|
||||
// idleCompactionSetting: the `idleCompaction` settings key first ships in 2.1.290 (2.1.289
|
||||
// already idle-compacts with no opt-out); see TmuxManager._resolveIdleCompactionOptOut.
|
||||
gates: {
|
||||
nameFlag: { minVersion: '2.1.224', failClosed: true },
|
||||
idleCompactionSetting: { minVersion: '2.1.290', failClosed: true },
|
||||
},
|
||||
// claude reads `$CLAUDE_CONFIG_DIR/.claude.json` when that is set (checked in 2.1.289).
|
||||
mcpConfig: {
|
||||
path: '.claude.json',
|
||||
@@ -768,8 +773,8 @@ const CODEX: CliEntry = {
|
||||
transcript: 'codex-rollout',
|
||||
altScreen: 'strip-full',
|
||||
echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' },
|
||||
wheelForward: { mode: 'never' }, // #227: codex ignores SGR wheel reports, never forward
|
||||
// ...but it DOES page its transcript on PageUp/PageDown. Measured on 0.157.1 (local,
|
||||
wheelForward: { mode: 'always' }, // 0.163.0 fullscreen: measured SGR scrolling; require observed tracking
|
||||
// Without observed mouse tracking, retain PageUp/PageDown fallback. Measured on 0.157.1 (local,
|
||||
// alternate screen inside tmux, so xterm holds no history) and 0.160 (remote): PageUp
|
||||
// moved the visible transcript while SGR wheel reports and local scrolling did nothing.
|
||||
// This is the page-key fallback for a hollow pane, not wheel forwarding.
|
||||
|
||||
@@ -557,8 +557,8 @@ export interface CliCapabilities {
|
||||
*
|
||||
* ⚠ NOT wheel forwarding. `wheelForward` above sends SGR mouse wheel REPORTS; this sends
|
||||
* plain keys, and only when the local buffer is hollow (normal buffer, `baseY === 0`).
|
||||
* A CLI can ignore one and honour the other: codex ignores SGR wheel reports (so its
|
||||
* `wheelForward` stays 'never') yet pages on PageUp/PageDown.
|
||||
* A CLI can ignore one and honour the other: Codex without observed mouse tracking
|
||||
* uses this fallback, while fullscreen Codex 0.163.0 accepts SGR wheel reports.
|
||||
*
|
||||
* Absent means no paging, so a CLI nobody has measured keeps plain local scrolling.
|
||||
*/
|
||||
|
||||
@@ -18,11 +18,40 @@
|
||||
// Session Tracking Limits
|
||||
// ============================================================================
|
||||
|
||||
/** Default cap on concurrent sessions when `CODEMAN_MAX_SESSIONS` is not set. */
|
||||
const DEFAULT_MAX_CONCURRENT_SESSIONS = 50;
|
||||
|
||||
/**
|
||||
* Highest value `CODEMAN_MAX_SESSIONS` can raise the cap to. An owner's tab layout
|
||||
* holds at most `MAX_TAB_REFS` (512) refs, and those count saved web tabs as well
|
||||
* as sessions; an owner can save up to `MAX_WEBVIEWS` (50 by default) web tabs.
|
||||
* 512 - 50 = 462 keeps session creation answering with the capacity message
|
||||
* instead of failing with a 500 on the layout limit, at the default `MAX_WEBVIEWS`
|
||||
* and with no pinned closed sessions: a pinned session stays in the layout as a
|
||||
* stopped record after it is closed, and it shares the same 512-ref budget.
|
||||
* (`PUT /api/session-order` accepts up to 500 ids, which stays above this.)
|
||||
*/
|
||||
export const MAX_CONCURRENT_SESSIONS_CEILING = 462;
|
||||
|
||||
/**
|
||||
* Resolve the session cap from `CODEMAN_MAX_SESSIONS` (a positive integer,
|
||||
* clamped to {@link MAX_CONCURRENT_SESSIONS_CEILING}). Anything else (unset,
|
||||
* empty, 0, negative, fractional, not a number) falls back to
|
||||
* {@link DEFAULT_MAX_CONCURRENT_SESSIONS}, so a typo never locks session creation.
|
||||
*/
|
||||
function resolveMaxConcurrentSessions(): number {
|
||||
const raw = process.env.CODEMAN_MAX_SESSIONS?.trim();
|
||||
if (!raw || !/^\d+$/.test(raw)) return DEFAULT_MAX_CONCURRENT_SESSIONS;
|
||||
const n = Number(raw);
|
||||
return n >= 1 ? Math.min(n, MAX_CONCURRENT_SESSIONS_CEILING) : DEFAULT_MAX_CONCURRENT_SESSIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum concurrent sessions allowed.
|
||||
* Each session consumes significant resources (PTY, buffers, watchers).
|
||||
* 50 unless `CODEMAN_MAX_SESSIONS` says otherwise (1 to 462); read once at startup.
|
||||
*/
|
||||
export const MAX_CONCURRENT_SESSIONS = 50;
|
||||
export const MAX_CONCURRENT_SESSIONS = resolveMaxConcurrentSessions();
|
||||
|
||||
// ============================================================================
|
||||
// SSE Client Limits
|
||||
|
||||
@@ -97,7 +97,7 @@ const CRON_PSEUDO_FS_TREES: readonly string[] = ['/proc', '/sys', '/dev'];
|
||||
/** Sync blocklist for the create/update workingDir gate (no settings extras). */
|
||||
const CRON_WORKING_DIR_BLOCKED_TREES: readonly string[] = [...DEFAULT_BLOCKED_TREES, ...CRON_PSEUDO_FS_TREES];
|
||||
|
||||
/** Prompt delivery is single-line only (writeViaMux/Ink constraint). */
|
||||
/** Prompt delivery is single-line only: the submit check cannot reliably recognise a multi-line paste. */
|
||||
const HAS_NEWLINE = /[\r\n]/;
|
||||
|
||||
/** The three session calls prompt delivery needs, so it can be tested without a PTY. */
|
||||
@@ -536,11 +536,11 @@ export class CronService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the prompt text and enforces the single-line constraint: prompt
|
||||
* delivery rides writeViaMux/PTY writes where a newline is Enter, so a
|
||||
* multi-line prompt would be silently corrupted (typed mode fuses lines,
|
||||
* paste mode submits the first line and dribbles the rest in as separate
|
||||
* messages). Rather than mangle an unattended agent's instructions, fail the
|
||||
* Resolves the prompt text and enforces the single-line constraint: a job
|
||||
* fires unattended, and the submit check that re-presses an ignored Enter
|
||||
* cannot reliably recognise a multi-line paste in the composer (typed mode), so a
|
||||
* stranded prompt would go unnoticed; in paste mode the newlines reach the
|
||||
* CLI as keys. Rather than risk an unattended agent's instructions, fail the
|
||||
* run with a clear error. A prompt FILE may end with trailing newline(s)
|
||||
* (every editor writes one) — those are stripped before the check.
|
||||
*/
|
||||
|
||||
@@ -61,6 +61,22 @@ export interface CustomModelHost {
|
||||
* has no entry for simply gets no context-length env override applied — never a guess.
|
||||
*/
|
||||
modelContextLengths?: Record<string, number>;
|
||||
/**
|
||||
* Discovered number of parallel server slots per model id, keyed like `modelContextLengths`
|
||||
* and populated on the same loaded-models-only basis. Read from the launch command's
|
||||
* `--parallel`/`-np` when it states one, otherwise from llama.cpp's own `/props`
|
||||
* `total_slots` (a llama-server started without `--parallel` picks the count itself, so
|
||||
* the command cannot say). llama-server splits its context across these slots (by default
|
||||
* they share one unified KV pool) and Claude Code sends several requests at once (the
|
||||
* main session plus each parallel subagent), so with N slots each request can only rely
|
||||
* on roughly `contextLength / N` tokens. Used only by the context warning; a model this
|
||||
* has no entry for is treated as a single slot, never a guess. The division always uses
|
||||
* `modelContextLengths` (the launch command's figure, or `/props` only when the command states
|
||||
* none): `/props` never widens the pool. An explicit `--parallel N` whose context came from
|
||||
* `/props` is recorded as 1 slot, because that figure is already per slot, and that also
|
||||
* overwrites any count an earlier round stored.
|
||||
*/
|
||||
modelSlotCounts?: Record<string, number>;
|
||||
/**
|
||||
* Discovered file size (GB) per model id, keyed by the same strings as `models`.
|
||||
* Populated during discovery by parsing llama-swap's own `description` field for an
|
||||
|
||||
@@ -41,6 +41,7 @@ import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js';
|
||||
import { isNearStalledPath, probePath } from './utils/index.js';
|
||||
import { DEFAULT_KEEP_WARM_MAX_HOURS, type KeepWarmSettings } from './prompt-cache-keepwarm.js';
|
||||
|
||||
/**
|
||||
* Existence check for a WRITER. Unlike the bounded read-side probe (`probePath`),
|
||||
@@ -1144,6 +1145,41 @@ export async function readPinCliSessionNameEnabled(): Promise<boolean> {
|
||||
return settings.pinCliSessionName === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The prompt-cache keep-warm's app-wide default (`promptCacheKeepWarm`, absent = OFF) and its
|
||||
* cap (`promptCacheKeepWarmMaxHours`, hours after the last real prompt). Read FRESH like
|
||||
* {@link readPinCliSessionNameEnabled} at every claude create and respawn, which decides the
|
||||
* launch's `idleCompaction` opt-out.
|
||||
*/
|
||||
export async function readKeepWarmSettings(): Promise<KeepWarmSettings> {
|
||||
return keepWarmSettingsFrom(await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The same two keys for the keep-warm service, which keeps what it reads until the next
|
||||
* settings change. A missing file is the defaults, but any other read or parse failure rejects
|
||||
* rather than falling back: settings.json is rewritten in place, so a read can land mid-write,
|
||||
* and a fallback kept from it would hold keep-warm off until the next keep-warm settings change.
|
||||
*/
|
||||
export async function readKeepWarmSettingsOrThrow(): Promise<KeepWarmSettings> {
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(SETTINGS_PATH, 'utf-8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return keepWarmSettingsFrom({});
|
||||
throw err;
|
||||
}
|
||||
return keepWarmSettingsFrom(JSON.parse(text) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function keepWarmSettingsFrom(settings: Record<string, unknown>): KeepWarmSettings {
|
||||
const hours = settings?.promptCacheKeepWarmMaxHours;
|
||||
return {
|
||||
keepWarmDefault: settings?.promptCacheKeepWarm === true,
|
||||
maxHours: typeof hours === 'number' && Number.isFinite(hours) && hours > 0 ? hours : DEFAULT_KEEP_WARM_MAX_HOURS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the statusLine command to pass as an EPHEMERAL `claude --settings`
|
||||
* CLI flag for this one process (see buildSpawnCommandFromRegistry in
|
||||
|
||||
@@ -24,6 +24,7 @@ import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import { KEEP_WARM_NUDGE } from './prompt-cache-keepwarm.js';
|
||||
import type { IntentProfile, IntentPromptEntry } from './types/index.js';
|
||||
|
||||
// ========== Limits ==========
|
||||
@@ -62,6 +63,9 @@ export function isCapturablePrompt(text: string): boolean {
|
||||
if (text.startsWith('<system-reminder>')) return false;
|
||||
if (text.startsWith('Caveat: The messages below')) return false;
|
||||
if (text.startsWith('[Request interrupted')) return false;
|
||||
// Codeman's own prompt-cache keep-alive turn is not something the user meant. The exact
|
||||
// text, so a prompt of the user's that merely mentions it is still captured.
|
||||
if (text.trim() === KEEP_WARM_NUDGE) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -108,6 +112,11 @@ export class IntentStore {
|
||||
|
||||
// ----- Public API -----
|
||||
|
||||
/** Drop the in-memory copy so the next call re-reads intents.json (after a config restore replaced it). */
|
||||
reload(): void {
|
||||
this.profiles = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile for a session's case. Never persists on read: an absent
|
||||
* profile returns an empty transient one (`updatedAt: 0`).
|
||||
|
||||
+19
-4
@@ -61,9 +61,15 @@ export interface MuxSession {
|
||||
ralphEnabled?: boolean;
|
||||
/** Whether the current launch carries the statusLine exporter; set on every create and respawn. */
|
||||
statusLine?: StatusLineExporter;
|
||||
/**
|
||||
* Whether the current launch carries `idleCompaction: false` in its `--settings` JSON (keep-warm
|
||||
* on at launch, the exporter injected, Claude Code new enough); set on every create and respawn.
|
||||
*/
|
||||
idleCompactionOff?: boolean;
|
||||
/**
|
||||
* This record was rebuilt from the tmux socket rather than from Codeman's own
|
||||
* bookkeeping, so everything on it but the name and the pid is a guess. Its
|
||||
* bookkeeping, so everything on it but the name, the pid and, when tmux
|
||||
* reported a usable one, the directory is a guess. Its
|
||||
* synthetic `restored-<fragment>` id cannot find the session's `state.json`
|
||||
* entry either, which means a remote or docker session rediscovered this way
|
||||
* arrives with no `remote`/`docker` metadata and looks local. Anything that
|
||||
@@ -123,6 +129,10 @@ export interface CreateSessionOptions {
|
||||
effort?: EffortLevel;
|
||||
/** Claude advisor model, merged into the same `--settings` JSON (overridable via /advisor in-session) */
|
||||
advisorModel?: string;
|
||||
/** The session's own prompt-cache keep-warm choice (undefined = app default); decides the `idleCompaction` opt-out. */
|
||||
keepWarm?: boolean;
|
||||
/** Spawned by another session: it does not inherit the keep-warm app default (`keepWarmOn`). */
|
||||
spawnedByAgent?: boolean;
|
||||
/** tmux history-limit (scrollback lines) allocated when this session is created. */
|
||||
historyLimit?: number;
|
||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||
@@ -176,6 +186,10 @@ export interface RespawnPaneOptions {
|
||||
effort?: EffortLevel;
|
||||
/** Claude advisor model (preserved across respawns, merged into the same `--settings` JSON) */
|
||||
advisorModel?: string;
|
||||
/** The session's own prompt-cache keep-warm choice, re-read at every relaunch (see CreateSessionOptions). */
|
||||
keepWarm?: boolean;
|
||||
/** Spawned by another session (see CreateSessionOptions). */
|
||||
spawnedByAgent?: boolean;
|
||||
/** Original tmux history-limit retained for config parity; respawn cannot resize the existing pane. */
|
||||
historyLimit?: number;
|
||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||
@@ -415,11 +429,12 @@ export interface TerminalMultiplexer extends EventEmitter {
|
||||
captureActivePaneBuffer?(muxName: string, opts?: PaneCaptureOptions): string | null;
|
||||
|
||||
/**
|
||||
* Plain text of the visible frame: no styles, no cursor query, no repaint
|
||||
* reconstruction. Deliberately cheaper than `capturePaneBuffer` because idle
|
||||
* Plain text of the visible frame: no cursor query, no repaint
|
||||
* reconstruction, and no styles unless `styles` asks for them (SGR only, lines
|
||||
* still `\n`-separated). Deliberately cheaper than `capturePaneBuffer` because idle
|
||||
* detection calls it on a timer: it only needs to read what the CLI is
|
||||
* currently rendering, never to replay it into an xterm. Returns null when the
|
||||
* pane cannot be read.
|
||||
*/
|
||||
capturePaneText?(muxName: string, paneTarget?: string): string | null;
|
||||
capturePaneText?(muxName: string, paneTarget?: string, opts?: { styles?: boolean }): string | null;
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* @fileoverview Prompt-cache keep-warm: a one-line keep-alive turn shortly before an idle
|
||||
* claude session's prompt cache would expire, so the next real prompt reads the conversation
|
||||
* from cache instead of re-processing it at the cache-write rate.
|
||||
*
|
||||
* Why it exists: Claude Code gives a subscription's main conversation a one-hour cache and
|
||||
* lets it expire after an hour idle. Measured on real sessions, nearly every gap over an
|
||||
* hour went cold and the first turn back re-wrote the whole context at 2x; a keep-alive turn
|
||||
* costs a cache read plus a one-word reply, a small fraction of the miss it prevents, and the
|
||||
* payoff is concentrated in gaps of one to four hours, which is why there is a cap after the
|
||||
* last real prompt rather than an indefinite loop.
|
||||
*
|
||||
* Codeman owns the idle policy for these sessions (Discussion #554): a launch with keep-warm
|
||||
* on also carries `idleCompaction: false` in its one `--settings` JSON, so Claude Code's own
|
||||
* idle compaction (which fires at 90% of the TTL, a minute before the nudge) cannot replace
|
||||
* the conversation with a summary first. That is decided once per launch in
|
||||
* `TmuxManager._resolveIdleCompactionOptOut()` and recorded on the mux record; a session
|
||||
* switched on after launch is kept warm best-effort and says so.
|
||||
*
|
||||
* Where the facts come from: `Session.promptCache` (the statusline's `prompt_cache` object,
|
||||
* see usage-telemetry.ts) carries `expires_at`, the TTL and the tokens at stake, so nothing
|
||||
* here infers the lifetime from idle detection or the plan tier.
|
||||
*
|
||||
* Shape: `decideKeepWarm()` is the pure rulebook (every guard in one place, tested as a
|
||||
* table); `PromptCacheKeepWarmService` owns the per-session timer and the ports it needs
|
||||
* from the server. It never touches the session except through `beginKeepAliveTurn()` and
|
||||
* `writeViaMux()`: the window `beginKeepAliveTurn()` opens is what keeps the nudge off the
|
||||
* real-prompt clock and opens its stop hook and idle prompt pre-acknowledged
|
||||
* (hook-event-routes.ts). The nudge is still a submit, so it moves `lastSubmitAt`.
|
||||
*
|
||||
* @module prompt-cache-keepwarm
|
||||
*/
|
||||
|
||||
import type { KeepWarmStatus, PromptCacheStatus, StatusLineExporter } from './types/index.js';
|
||||
|
||||
/** The nudge. Under 100 chars on purpose: a longer burst reads as a paste to Claude Code. */
|
||||
export const KEEP_WARM_NUDGE = 'Codeman cache keep-alive: no action needed, reply with just "ok".';
|
||||
/** Nudge this long before `expires_at`. The nudge's own request is what refreshes the timer. */
|
||||
export const KEEP_WARM_LEAD_MS = 5 * 60_000;
|
||||
/** Re-check cadence while a transient guard (working, dialog, draft in the composer) defers. */
|
||||
export const KEEP_WARM_RETRY_MS = 60_000;
|
||||
/** Give up this close to expiry: a turn takes seconds to land and the Enter may be re-pressed. */
|
||||
export const KEEP_WARM_LAST_CHANCE_MS = 60_000;
|
||||
/** Below this many tokens at stake a cold rewrite is cheap and a turn is not worth it. */
|
||||
export const KEEP_WARM_MIN_TOKENS = 20_000;
|
||||
/** A person typed this recently: they may be about to submit, which refreshes the cache for free. */
|
||||
export const KEEP_WARM_RECENT_INPUT_MS = 3 * 60_000;
|
||||
/**
|
||||
* Ceiling on the keep-alive window. The window normally closes on the turn's own idle prompt
|
||||
* (Claude Code sends it about a minute after the turn ends, so a slow turn must not outlast a
|
||||
* short fixed window) or earlier, at any other submit; the ceiling covers a turn whose idle
|
||||
* prompt never arrives, such as a workspace without Codeman's hooks.
|
||||
*/
|
||||
export const KEEP_WARM_TURN_WINDOW_MS = 10 * 60_000;
|
||||
/** Default cap: hours after the last real prompt during which nudges are sent. */
|
||||
export const DEFAULT_KEEP_WARM_MAX_HOURS = 2;
|
||||
/** Longest delay `setTimeout` honours; Node fires anything longer after 1 ms. */
|
||||
const MAX_TIMER_MS = 2 ** 31 - 1;
|
||||
|
||||
/**
|
||||
* Whether keep-warm is on for a session: its own choice, else the app-wide default. A session
|
||||
* an agent spawned (`parentSessionId`) does not inherit the default: a worker that finished
|
||||
* its task is idle exactly like one that will be prompted again, and most never are, so only
|
||||
* its own switch keeps it warm. One function because the launch (the `idleCompaction`
|
||||
* opt-out) and the service must agree.
|
||||
*/
|
||||
export function keepWarmOn(choice: boolean | undefined, appDefault: boolean, spawnedByAgent: boolean): boolean {
|
||||
return choice ?? (appDefault && !spawnedByAgent);
|
||||
}
|
||||
|
||||
export interface KeepWarmFacts {
|
||||
now: number;
|
||||
/** The session's own choice; undefined = the app-wide default applies. */
|
||||
choice: boolean | undefined;
|
||||
appDefault: boolean;
|
||||
/** Spawned by another session (`parentSessionId`); see `keepWarmOn`. */
|
||||
spawnedByAgent: boolean;
|
||||
/** Whether this launch carries the statusLine exporter; undefined when unknown. */
|
||||
statusLine?: StatusLineExporter;
|
||||
cache: PromptCacheStatus | null | undefined;
|
||||
/** Idle by Codeman's own probe and not mid-turn. */
|
||||
idle: boolean;
|
||||
/** A pid and no recorded agent exit (`paneExit`: an exited agent's pane keeps its pid). */
|
||||
paneAlive: boolean;
|
||||
/** Auto-resume's pause, or a usage-limit message on screen whose reset is still ahead. */
|
||||
limitPaused: boolean;
|
||||
/** Respawn running, a Ralph loop active, a task assigned, or an agent long-polling the session. */
|
||||
automationActive: boolean;
|
||||
/** A permission, question or elicitation dialog is open (never the idle item). */
|
||||
pendingApproval: boolean;
|
||||
/** The composer line holds no text; null when the pane could not be read. */
|
||||
composerEmpty: boolean | null;
|
||||
/** Last keystroke a person sent, epoch ms; 0 when none seen. */
|
||||
lastHumanInputAt: number;
|
||||
/** Start of the keep-warm window: the last real (non-keep-alive) prompt, else the session start. */
|
||||
budgetAnchorAt: number;
|
||||
maxKeepWarmMs: number;
|
||||
}
|
||||
|
||||
export type KeepWarmDecision =
|
||||
| { action: 'nudge' }
|
||||
| { action: 'wait'; until: number; reason: string }
|
||||
| { action: 'stop'; reason: string };
|
||||
|
||||
const stop = (reason: string): KeepWarmDecision => ({ action: 'stop', reason });
|
||||
const wait = (until: number, reason: string): KeepWarmDecision => ({ action: 'wait', until, reason });
|
||||
|
||||
/**
|
||||
* The rulebook. Hard stops first (nothing to do until the next cache report or a flag
|
||||
* change), then the schedule, then the transient guards that defer by `KEEP_WARM_RETRY_MS`
|
||||
* and never past the last-chance line. Pure: `now` is passed in.
|
||||
*/
|
||||
export function decideKeepWarm(f: KeepWarmFacts): KeepWarmDecision {
|
||||
if (!keepWarmOn(f.choice, f.appDefault, f.spawnedByAgent)) {
|
||||
return stop(
|
||||
f.choice === undefined && f.appDefault
|
||||
? 'an agent-spawned session follows the app-wide default only when switched on for itself'
|
||||
: 'off'
|
||||
);
|
||||
}
|
||||
// Only a launch that carries the exporter ever reports `prompt_cache`; the readout names the
|
||||
// cause from `statusLineExporter`. This is also what stops a CLI without cache telemetry.
|
||||
if (f.statusLine !== 'injected') return stop(`no cache reports on this launch (${f.statusLine ?? 'unknown'})`);
|
||||
const c = f.cache;
|
||||
if (!c) return stop('no cache report yet');
|
||||
if (!c.warm || !c.expiresAt) return stop('cache is cold; the next prompt rebuilds it');
|
||||
// Compacted this gap (`/compact`, or Claude Code's own idle compaction on a launch without
|
||||
// the opt-out): the full context is already gone, so keeping the summary warm buys little.
|
||||
// The stamp is dropped at the next request, which re-opens the next gap.
|
||||
if (c.compactedAt != null) return stop('the context was compacted; resumes after the next prompt');
|
||||
if (c.ttl === '5m') {
|
||||
return stop('5-minute cache lifetime; a nudge every four minutes costs more than the miss it prevents');
|
||||
}
|
||||
if ((c.recacheTokensIfCold ?? 0) < KEEP_WARM_MIN_TOKENS) return stop('context too small to be worth a turn');
|
||||
if (!f.paneAlive) return stop('no live pane');
|
||||
if (f.limitPaused) return stop('usage-limit pause');
|
||||
if (f.now >= f.budgetAnchorAt + f.maxKeepWarmMs) return stop('keep-warm window after the last prompt has passed');
|
||||
const dueAt = c.expiresAt - KEEP_WARM_LEAD_MS;
|
||||
if (f.now < dueAt) return wait(dueAt, 'not due yet');
|
||||
const lastChance = c.expiresAt - KEEP_WARM_LAST_CHANCE_MS;
|
||||
if (f.now >= lastChance) return stop('too close to expiry to land a turn');
|
||||
const retryAt = Math.min(f.now + KEEP_WARM_RETRY_MS, lastChance);
|
||||
if (!f.idle) return wait(retryAt, 'session is working');
|
||||
if (f.pendingApproval) return wait(retryAt, 'a dialog is open');
|
||||
// Transient, and nothing else re-triggers an idle session: an agent's long-poll waiter ends
|
||||
// without a turn, and so does a capture that failed once.
|
||||
if (f.automationActive) return wait(retryAt, 'respawn, Ralph or an agent is driving the session');
|
||||
if (f.composerEmpty === null) return wait(retryAt, 'cannot read the composer');
|
||||
if (!f.composerEmpty) return wait(retryAt, 'the composer holds text');
|
||||
if (f.lastHumanInputAt && f.now - f.lastHumanInputAt < KEEP_WARM_RECENT_INPUT_MS) {
|
||||
return wait(retryAt, 'someone typed a moment ago');
|
||||
}
|
||||
return { action: 'nudge' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a session could be nudged once the settings are known: its own switch is not off, its
|
||||
* launch reports the cache, and the last report is warm. Only such a session has anything to
|
||||
* lose while the settings cannot be read, so only it retries on its own.
|
||||
*/
|
||||
function couldNudge(s: KeepWarmSessionView): boolean {
|
||||
return (
|
||||
s.keepWarmEnabled !== false &&
|
||||
s.statusLineExporter === 'injected' &&
|
||||
!!s.promptCache?.warm &&
|
||||
!!s.promptCache.expiresAt
|
||||
);
|
||||
}
|
||||
|
||||
/** What the service needs from a session; `Session` satisfies it structurally. */
|
||||
export interface KeepWarmSessionView {
|
||||
readonly id: string;
|
||||
readonly createdAt: number;
|
||||
readonly pid: number | null;
|
||||
/** Set when the agent exited in a pane that stays open (`remain-on-exit`); the pid stays. */
|
||||
readonly paneExit?: unknown;
|
||||
readonly parentSessionId?: string;
|
||||
readonly promptCache: PromptCacheStatus | null;
|
||||
readonly statusLineExporter?: StatusLineExporter;
|
||||
/** Whether this launch carries `idleCompaction: false` (`MuxSession.idleCompactionOff`). */
|
||||
readonly idleCompactionOptOut: boolean;
|
||||
readonly keepWarmEnabled: boolean | undefined;
|
||||
readonly isWorking: boolean;
|
||||
readonly isLimitPaused: boolean;
|
||||
readonly lastHumanInputAt: number;
|
||||
/** Last submit that was not the keep-alive's own (persisted): the cap's clock. */
|
||||
readonly lastRealPromptAt: number;
|
||||
isIdle(): boolean;
|
||||
beginKeepAliveTurn(windowMs: number): void;
|
||||
/** Close the window `beginKeepAliveTurn()` opened, when the nudge never went out. */
|
||||
endKeepAliveTurn(): void;
|
||||
writeViaMux(data: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface KeepWarmSettings {
|
||||
/** App-wide default for sessions that have not chosen (`promptCacheKeepWarm`). */
|
||||
keepWarmDefault: boolean;
|
||||
/** Cap in hours after the last real prompt (`promptCacheKeepWarmMaxHours`). */
|
||||
maxHours: number;
|
||||
}
|
||||
|
||||
export interface KeepWarmPorts {
|
||||
getSession(id: string): KeepWarmSessionView | undefined;
|
||||
sessionIds(): Iterable<string>;
|
||||
readSettings(): Promise<KeepWarmSettings>;
|
||||
isAutomationActive(id: string): boolean;
|
||||
hasPendingApproval(id: string): boolean;
|
||||
/**
|
||||
* Read the pane once a nudge is otherwise due: whether the composer is empty (null when it
|
||||
* cannot be read), and whether a usage-limit message with a reset still ahead is on screen
|
||||
* (auto-resume's own pause tracking only runs while auto-resume is on).
|
||||
*/
|
||||
readPane(session: KeepWarmSessionView): { composerEmpty: boolean | null; limitShown: boolean };
|
||||
/** Store and broadcast the status (the server dedupes against the session's last one). */
|
||||
publish(id: string, status: KeepWarmStatus): void;
|
||||
log?(message: string): void;
|
||||
}
|
||||
|
||||
/** The scheduler the routes see: one method per trigger, nothing else. */
|
||||
export interface KeepWarmScheduler {
|
||||
/** Re-evaluate a session: after a cache report, a flag change, or a settings change. Resolves once the status is published. */
|
||||
schedule(sessionId: string): Promise<void>;
|
||||
/** Re-read the app-wide default and cap, then re-evaluate every session (either one changed). */
|
||||
rescheduleAll(): void;
|
||||
/** The session is gone. */
|
||||
remove(sessionId: string): void;
|
||||
}
|
||||
|
||||
export class PromptCacheKeepWarmService implements KeepWarmScheduler {
|
||||
private readonly timers = new Map<string, NodeJS.Timeout>();
|
||||
private readonly tallies = new Map<string, { nudges: number; lastNudgeAt?: number }>();
|
||||
/** One evaluation per session; `remove()` and `stop()` cancel it at its next await. */
|
||||
private readonly inFlight = new Map<string, symbol>();
|
||||
/** The evaluation running per session, and the one follow-up queued behind it. */
|
||||
private readonly running = new Map<string, Promise<void>>();
|
||||
private readonly followUps = new Map<string, Promise<void>>();
|
||||
/**
|
||||
* The default and cap, read once and kept: every statusline report schedules an evaluation,
|
||||
* and reading settings.json on each would cost every install, the feature off included.
|
||||
* `rescheduleAll()` drops it, and the settings PUT calls that whenever either key changes.
|
||||
*/
|
||||
private settings: Promise<KeepWarmSettings> | undefined;
|
||||
/** The last settings read failure logged, so one that persists is logged once; cleared by a good read. */
|
||||
private settingsFailure: string | undefined;
|
||||
private stopped = false;
|
||||
|
||||
constructor(private readonly ports: KeepWarmPorts) {}
|
||||
|
||||
schedule(sessionId: string): Promise<void> {
|
||||
if (this.stopped) return Promise.resolve();
|
||||
const running = this.running.get(sessionId);
|
||||
if (running) {
|
||||
// A trigger landing mid-evaluation (a toggle while the nudge is written, a settings change)
|
||||
// carries what that evaluation read too early, so one follow-up runs after it, shared by
|
||||
// every trigger that arrives meanwhile.
|
||||
let next = this.followUps.get(sessionId);
|
||||
if (!next) {
|
||||
next = running.then(() => {
|
||||
// `remove()` drops the queued follow-up: a deleted session gets no evaluation.
|
||||
if (this.followUps.get(sessionId) !== next) return;
|
||||
this.followUps.delete(sessionId);
|
||||
return this.schedule(sessionId);
|
||||
});
|
||||
this.followUps.set(sessionId, next);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
const run = this.evaluate(sessionId)
|
||||
.catch((err) => {
|
||||
this.ports.log?.(`[KeepWarm] ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
// A failed write or pane read is transient, and an idle session gets no other trigger
|
||||
// until its next turn. (A failed settings read is handled in evaluate().)
|
||||
// Not after `remove()`, which drops this run: the session is being deleted.
|
||||
if (!this.stopped && this.running.get(sessionId) === run) this.arm(sessionId, Date.now() + KEEP_WARM_RETRY_MS);
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.running.get(sessionId) === run) this.running.delete(sessionId);
|
||||
});
|
||||
this.running.set(sessionId, run);
|
||||
return run;
|
||||
}
|
||||
|
||||
rescheduleAll(): void {
|
||||
this.settings = undefined;
|
||||
for (const id of this.ports.sessionIds()) void this.schedule(id);
|
||||
}
|
||||
|
||||
remove(sessionId: string): void {
|
||||
this.clearTimer(sessionId);
|
||||
this.tallies.delete(sessionId);
|
||||
this.inFlight.delete(sessionId);
|
||||
this.running.delete(sessionId);
|
||||
this.followUps.delete(sessionId);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
for (const id of [...this.timers.keys()]) this.clearTimer(id);
|
||||
}
|
||||
|
||||
private readSettings(): Promise<KeepWarmSettings> {
|
||||
if (!this.settings) {
|
||||
const read = this.ports.readSettings();
|
||||
this.settings = read;
|
||||
// A failed read is not kept: the next evaluation tries again.
|
||||
read.catch(() => {
|
||||
if (this.settings === read) this.settings = undefined;
|
||||
});
|
||||
}
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
private clearTimer(id: string): void {
|
||||
const t = this.timers.get(id);
|
||||
if (t) clearTimeout(t);
|
||||
this.timers.delete(id);
|
||||
}
|
||||
|
||||
private arm(id: string, at: number): void {
|
||||
this.clearTimer(id);
|
||||
const t = setTimeout(() => void this.schedule(id), Math.min(Math.max(0, at - Date.now()), MAX_TIMER_MS));
|
||||
t.unref?.();
|
||||
this.timers.set(id, t);
|
||||
}
|
||||
|
||||
private async evaluate(id: string): Promise<void> {
|
||||
if (this.stopped || this.inFlight.has(id)) return;
|
||||
const token = Symbol(id);
|
||||
this.inFlight.set(id, token);
|
||||
// A session removed (or the service stopped) during an await gets nothing after it: no
|
||||
// nudge, no tally, no timer, no status.
|
||||
const cancelled = () => this.stopped || this.inFlight.get(id) !== token;
|
||||
try {
|
||||
this.clearTimer(id);
|
||||
const session = this.ports.getSession(id);
|
||||
if (!session) return;
|
||||
let settings: KeepWarmSettings;
|
||||
try {
|
||||
settings = await this.readSettings();
|
||||
} catch (err) {
|
||||
// A read that lands mid-write is retried. A settings.json that stays broken (a hand edit
|
||||
// with a trailing comma) fails every read until it is fixed, so it is logged once rather
|
||||
// than once a minute per session, and only a session that could nudge retries.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message !== this.settingsFailure) {
|
||||
this.settingsFailure = message;
|
||||
this.ports.log?.(`[KeepWarm] ${id.slice(0, 8)}: cannot read the settings: ${message}`);
|
||||
}
|
||||
if (!cancelled() && couldNudge(session)) this.arm(id, Date.now() + KEEP_WARM_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
this.settingsFailure = undefined;
|
||||
if (cancelled()) return;
|
||||
const tally = this.tallies.get(id) ?? { nudges: 0 };
|
||||
const facts: KeepWarmFacts = {
|
||||
now: Date.now(),
|
||||
choice: session.keepWarmEnabled,
|
||||
appDefault: settings.keepWarmDefault,
|
||||
spawnedByAgent: !!session.parentSessionId,
|
||||
statusLine: session.statusLineExporter,
|
||||
cache: session.promptCache,
|
||||
idle: session.isIdle() && !session.isWorking,
|
||||
paneAlive: session.pid !== null && !session.paneExit,
|
||||
limitPaused: session.isLimitPaused,
|
||||
automationActive: this.ports.isAutomationActive(id),
|
||||
pendingApproval: this.ports.hasPendingApproval(id),
|
||||
// Optimistic until everything else says "due": the pane read is the one costly fact.
|
||||
composerEmpty: true,
|
||||
lastHumanInputAt: session.lastHumanInputAt,
|
||||
budgetAnchorAt: session.lastRealPromptAt || session.createdAt,
|
||||
maxKeepWarmMs: settings.maxHours * 3_600_000,
|
||||
};
|
||||
let decision = decideKeepWarm(facts);
|
||||
if (decision.action === 'nudge') {
|
||||
const pane = this.ports.readPane(session);
|
||||
decision = decideKeepWarm({
|
||||
...facts,
|
||||
composerEmpty: pane.composerEmpty,
|
||||
limitPaused: facts.limitPaused || pane.limitShown,
|
||||
});
|
||||
}
|
||||
const enabled = keepWarmOn(facts.choice, facts.appDefault, facts.spawnedByAgent);
|
||||
const base = {
|
||||
enabled,
|
||||
idleCompactionOff: session.idleCompactionOptOut,
|
||||
nudges: tally.nudges,
|
||||
lastNudgeAt: tally.lastNudgeAt,
|
||||
// Off, the window means nothing, and it moves with every real prompt: carrying it would
|
||||
// broadcast a change on every turn of every session.
|
||||
...(enabled && { budgetEndsAt: facts.budgetAnchorAt + facts.maxKeepWarmMs }),
|
||||
};
|
||||
if (decision.action === 'nudge') {
|
||||
session.beginKeepAliveTurn(KEEP_WARM_TURN_WINDOW_MS);
|
||||
const ok = await session.writeViaMux(`${KEEP_WARM_NUDGE}\r`);
|
||||
if (cancelled()) return;
|
||||
if (ok) {
|
||||
tally.nudges += 1;
|
||||
tally.lastNudgeAt = Date.now();
|
||||
this.tallies.set(id, tally);
|
||||
}
|
||||
this.ports.log?.(
|
||||
`[KeepWarm] ${ok ? 'nudged' : 'failed to nudge'} ${id.slice(0, 8)} with ${facts.cache?.recacheTokensIfCold ?? '?'} tokens at stake (nudge ${tally.nudges})`
|
||||
);
|
||||
// Sent: the nudge's own cache report re-schedules, and this timer is the fallback if none
|
||||
// arrives. Not sent: no turn is running, so the window closes now rather than at its
|
||||
// ceiling, and the retry lands inside the last minutes before the expiry, where a nudge
|
||||
// can still go out (a lead time later would be past it).
|
||||
let nextCheckAt = Date.now() + KEEP_WARM_LEAD_MS;
|
||||
if (!ok) {
|
||||
session.endKeepAliveTurn();
|
||||
const expiresAt = facts.cache?.expiresAt ?? 0;
|
||||
nextCheckAt = Math.min(Date.now() + KEEP_WARM_RETRY_MS, expiresAt - KEEP_WARM_LAST_CHANCE_MS);
|
||||
}
|
||||
this.arm(id, nextCheckAt);
|
||||
this.ports.publish(id, {
|
||||
...base,
|
||||
nudges: tally.nudges,
|
||||
lastNudgeAt: tally.lastNudgeAt,
|
||||
state: ok ? 'nudged' : 'waiting',
|
||||
nextCheckAt,
|
||||
reason: ok ? undefined : 'the keep-alive could not be written',
|
||||
});
|
||||
} else if (decision.action === 'wait') {
|
||||
this.arm(id, decision.until);
|
||||
this.ports.publish(id, {
|
||||
...base,
|
||||
state: decision.reason === 'not due yet' ? 'armed' : 'waiting',
|
||||
nextCheckAt: decision.until,
|
||||
reason: decision.reason === 'not due yet' ? undefined : decision.reason,
|
||||
});
|
||||
} else {
|
||||
this.ports.publish(id, { ...base, state: enabled ? 'stopped' : 'off', reason: decision.reason });
|
||||
}
|
||||
} finally {
|
||||
if (this.inFlight.get(id) === token) this.inFlight.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,13 @@ import { Session } from './session.js';
|
||||
import { AiIdleChecker, type AiCheckState } from './ai-idle-checker.js';
|
||||
import { AiPlanChecker } from './ai-plan-checker.js';
|
||||
import type { TeamWatcher } from './team-watcher.js';
|
||||
import { BufferAccumulator, ANSI_ESCAPE_PATTERN_SIMPLE, assertNever, CleanupManager } from './utils/index.js';
|
||||
import {
|
||||
BufferAccumulator,
|
||||
ANSI_ESCAPE_PATTERN_SIMPLE,
|
||||
assertNever,
|
||||
CleanupManager,
|
||||
toSingleLine,
|
||||
} from './utils/index.js';
|
||||
import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js';
|
||||
import {
|
||||
isCompletionMessage,
|
||||
@@ -1621,7 +1627,7 @@ export class RespawnController extends EventEmitter {
|
||||
this.config.interStepDelayMs,
|
||||
async () => {
|
||||
if (this._state === 'stopped') return;
|
||||
const prompt = this.config.kickstartPrompt!;
|
||||
const prompt = toSingleLine(this.config.kickstartPrompt!);
|
||||
this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`);
|
||||
await this.session.writeViaMux(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
|
||||
// COD-51: stop() may have run during the await; re-check before reviving the
|
||||
@@ -2826,12 +2832,13 @@ export class RespawnController extends EventEmitter {
|
||||
|
||||
// Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config
|
||||
const statusBlock = this.session.ralphTracker?.lastStatusBlock;
|
||||
let updatePrompt = this.config.updatePrompt;
|
||||
let updatePrompt = toSingleLine(this.config.updatePrompt);
|
||||
|
||||
if (statusBlock?.recommendation) {
|
||||
// Append RECOMMENDATION to the update prompt (single-line — writeViaMux breaks on newlines)
|
||||
// Append RECOMMENDATION to the update prompt, on the same single line
|
||||
// (see toSingleLine)
|
||||
const rec = statusBlock.recommendation.replace(/\n/g, ' ').substring(0, 200);
|
||||
updatePrompt = `${this.config.updatePrompt} (Claude's last recommendation: ${rec})`;
|
||||
updatePrompt = `${updatePrompt} (Claude's last recommendation: ${rec})`;
|
||||
this.logAction('ralph', `Using RECOMMENDATION: ${rec.substring(0, 50)}...`);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { detectUsageLimitPause } from './usage-limit-patterns.js';
|
||||
import { toSingleLine } from './utils/index.js';
|
||||
|
||||
// ============================================================================
|
||||
// Timing Constants
|
||||
@@ -408,7 +409,11 @@ export class SessionAutoOps extends EventEmitter {
|
||||
);
|
||||
|
||||
const action = async () => {
|
||||
const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r';
|
||||
// One line: this fires unattended, and a multi-line focus prompt would go out
|
||||
// as a multi-line paste (see toSingleLine).
|
||||
const compactCmd = this._autoCompactPrompt
|
||||
? `/compact ${toSingleLine(this._autoCompactPrompt)}\r`
|
||||
: '/compact\r';
|
||||
await this.callbacks.writeCommand(compactCmd);
|
||||
this.emit('autoCompact', {
|
||||
tokens: totalTokens,
|
||||
|
||||
@@ -68,6 +68,12 @@ export interface SpawnBridgeOptions {
|
||||
* rendered as two independent params.
|
||||
*/
|
||||
statusLineCommand?: string;
|
||||
/**
|
||||
* Add `idleCompaction: false` to the same `--settings` JSON: Codeman's prompt-cache keep-warm
|
||||
* owns the idle policy for this launch. Decided (keep-warm on, exporter injected, version gate
|
||||
* passed) by TmuxManager._resolveIdleCompactionOptOut, which records it on the mux record.
|
||||
*/
|
||||
idleCompactionOff?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,6 +174,20 @@ function resolveGatesPassed(entry: CliEntry, resolveVersion: () => string | null
|
||||
return passed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `mode`'s `capabilities.gates[gate]` passes, through the same fail-closed resolution
|
||||
* the launch template uses (an unknown version passes no gate). For a launch-time decision that
|
||||
* is also recorded, like the `idleCompaction` opt-out. `claudeCliVersion` as in
|
||||
* SpawnBridgeOptions: undefined probes the local CLI, null means unknown.
|
||||
*/
|
||||
export function launchGatePasses(mode: string, gate: string, claudeCliVersion?: string | null): boolean {
|
||||
const entry = getCli(mode);
|
||||
if (!entry?.capabilities.gates[gate]) return false;
|
||||
return resolveGatesPassed(entry, () =>
|
||||
claudeCliVersion !== undefined ? claudeCliVersion : getClaudeCliVersion()
|
||||
).has(gate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the spawn command for `entry` from the legacy option bag. Returns `undefined` for a
|
||||
* `shell`-kind entry (or any entry declaring no launch variants), which callers take as "fall
|
||||
@@ -208,10 +228,15 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri
|
||||
// rendering them as independent params would let the last one silently win. Claude-only in
|
||||
// practice: only claude's launch template renders this engine value, so another CLI's
|
||||
// session carrying an advisorModel launches exactly as before.
|
||||
// Key order (ultracode, advisorModel, statusLine) keeps a launch without an advisor
|
||||
// byte-identical to one from before the advisor existed.
|
||||
// Key order (ultracode, advisorModel, statusLine, idleCompaction) keeps a launch without an
|
||||
// advisor or keep-warm byte-identical to one from before either existed.
|
||||
const advisorSettings = buildAdvisorSettings(options.advisorModel);
|
||||
if ((effortFlag === '--settings' && effortValue) || advisorSettings.advisorModel || options.statusLineCommand) {
|
||||
if (
|
||||
(effortFlag === '--settings' && effortValue) ||
|
||||
advisorSettings.advisorModel ||
|
||||
options.statusLineCommand ||
|
||||
options.idleCompactionOff
|
||||
) {
|
||||
const settingsObj: Record<string, unknown> = {
|
||||
...(effortFlag === '--settings' && effortValue ? JSON.parse(effortValue) : {}),
|
||||
...advisorSettings,
|
||||
@@ -219,6 +244,7 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri
|
||||
if (options.statusLineCommand) {
|
||||
settingsObj.statusLine = { type: 'command', command: options.statusLineCommand };
|
||||
}
|
||||
if (options.idleCompactionOff) settingsObj.idleCompaction = false;
|
||||
engineValues.effortSettingsJson = JSON.stringify(settingsObj);
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,85 @@ export function promptStillInComposer(screen: string, prompt: string, glyph: str
|
||||
return head.length > 0 && composer.startsWith(head);
|
||||
}
|
||||
|
||||
/**
|
||||
* A styled frame as plain text. SGR goes first with its own pattern because tmux writes
|
||||
* colon sub-parameters (`ESC[4:3m`, a curly underline), which `stripAnsi` leaves behind.
|
||||
*/
|
||||
export function plainFrame(styled: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return stripAnsi(styled.replace(/\x1b\[[0-9;:]*m/g, ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* `screen` (a `capture-pane -p -e` frame) without the text Claude Code draws in an empty
|
||||
* composer that nobody typed: its prompt suggestion, drawn faint (SGR 2), and the suggestion's
|
||||
* first letter, which sits in the reverse-video cursor cell (SGR 7) right before the faint rest.
|
||||
* A typed draft is never faint, so a cursor cell followed by anything else is kept. Every line
|
||||
* break is kept, so line `n` here is line `n` of the plain frame, and plain text passes through
|
||||
* unchanged. Other escapes are left for `stripAnsi`.
|
||||
*/
|
||||
function withoutSuggestion(screen: string): string {
|
||||
let faint = false;
|
||||
let inverse = false;
|
||||
let cursorCell = '';
|
||||
let out = '';
|
||||
// eslint-disable-next-line no-control-regex
|
||||
for (const m of screen.matchAll(/\x1b\[([0-9;:]*)m|[\s\S]/gu)) {
|
||||
if (m[1] !== undefined) {
|
||||
const params = m[1].split(';');
|
||||
for (let i = 0; i < params.length; i++) {
|
||||
const p = params[i];
|
||||
if (p === '' || p === '0') faint = inverse = false;
|
||||
else if (p === '2') faint = true;
|
||||
else if (p === '22') faint = false;
|
||||
else if (p === '7') inverse = true;
|
||||
else if (p === '27') inverse = false;
|
||||
// An extended colour's own arguments (`38;5;n`, `38;2;r;g;b`) are not attributes.
|
||||
else if (p === '38' || p === '48' || p === '58') i += params[i + 1] === '5' ? 2 : params[i + 1] === '2' ? 4 : 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const ch = m[0];
|
||||
if (ch !== '\n' && faint) {
|
||||
cursorCell = '';
|
||||
continue;
|
||||
}
|
||||
out += cursorCell;
|
||||
cursorCell = '';
|
||||
if (ch !== '\n' && inverse) cursorCell = ch;
|
||||
else out += ch;
|
||||
}
|
||||
return out + cursorCell;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the composer holds nothing: the LAST `glyph` line after the glyph, and every
|
||||
* continuation line below it down to the composer's bottom rule (a draft that opens with a
|
||||
* newline leaves the glyph line itself blank). Undefined when there is no composer line or no
|
||||
* rule under it to bound the read (a shell, a dialog, a direct-PTY session): nothing can be
|
||||
* said. The prompt-cache keep-warm refuses to type unless this is true: its paste would land
|
||||
* in whatever draft sits there, and the Enter would submit both.
|
||||
*
|
||||
* Pass a STYLED frame (`capture-pane -p -e`): Claude Code's prompt suggestion (on by default)
|
||||
* fills an empty composer with faint text, and only the styling tells it from a typed draft.
|
||||
* The paste replaces a suggestion rather than joining it (measured on 2.1.300). A plain frame
|
||||
* still parses, but reads a suggestion as a draft.
|
||||
*/
|
||||
export function composerIsEmpty(screen: string, glyph: string): boolean | undefined {
|
||||
if (!glyph) return undefined;
|
||||
// The structure is read at column 0, before any trimming: Claude Code draws the glyph line and
|
||||
// both rules there and indents every continuation line, so a draft line that holds the glyph or
|
||||
// starts with a rule (` ❯`, ` ───`) can never pass for the composer's own edges.
|
||||
const lines = plainFrame(screen).split('\n');
|
||||
let at = lines.length - 1;
|
||||
while (at >= 0 && !lines[at].startsWith(glyph)) at--;
|
||||
if (at < 0) return undefined;
|
||||
const end = lines.findIndex((l, i) => i > at && l.startsWith('\u2500'));
|
||||
if (end < 0) return undefined;
|
||||
const typed = plainFrame(withoutSuggestion(screen)).split('\n');
|
||||
return compact(typed[at].slice(glyph.length) + typed.slice(at + 1, end).join('')).length === 0;
|
||||
}
|
||||
|
||||
export interface SubmitVerifierDeps {
|
||||
/** The rendered pane, or null when there is none to read. */
|
||||
capture: () => string | null | undefined;
|
||||
|
||||
+126
-6
@@ -65,6 +65,8 @@ import {
|
||||
type SessionWriteOptions,
|
||||
type PaneExit,
|
||||
type PromptCacheStatus,
|
||||
type StatusLineExporter,
|
||||
type KeepWarmStatus,
|
||||
} from './types.js';
|
||||
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
|
||||
import { claudeTranscriptExists } from './utils/claude-transcript.js';
|
||||
@@ -127,6 +129,7 @@ import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js';
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import { getCli } from './config/cli-registry/registry.js';
|
||||
import { SubmitVerifier } from './session-submit-verifier.js';
|
||||
import { KEEP_WARM_NUDGE } from './prompt-cache-keepwarm.js';
|
||||
import { compileVersionRegex } from './config/cli-registry/patterns.js';
|
||||
import { resolveSessionCliVersion } from './utils/cli-resolver.js';
|
||||
import {
|
||||
@@ -453,6 +456,15 @@ export interface ClaudeMessage {
|
||||
duration_ms?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a programmatic write leaves the keep-alive turn running: the nudge itself, or a bare
|
||||
* Enter (which submits nothing new). Anything else carrying an Enter starts a turn of its own.
|
||||
*/
|
||||
function isKeepAliveWrite(data: string): boolean {
|
||||
const text = data.replace(/[\r\n]/g, '').trim();
|
||||
return text === '' || text === KEEP_WARM_NUDGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event signatures emitted by the Session class.
|
||||
* Subscribe using `session.on('eventName', handler)`.
|
||||
@@ -597,6 +609,16 @@ export class Session extends EventEmitter {
|
||||
private _watching: string | null = null;
|
||||
/** Last `prompt_cache` report from the statusline exporter; null until Claude sends one. */
|
||||
private _promptCache: PromptCacheStatus | null = null;
|
||||
/** Prompt-cache keep-warm opt-in; undefined = follow the app-wide default. Persisted. */
|
||||
private _keepWarmEnabled: boolean | undefined;
|
||||
/** Last status the keep-warm service published for this session; ephemeral. */
|
||||
private _keepWarm: KeepWarmStatus | null = null;
|
||||
/** Until when the current turn counts as Codeman's own keep-alive (hooks open pre-acknowledged). */
|
||||
private _keepAliveTurnUntil = 0;
|
||||
/** Last keystroke a person sent through a `fromUser` write. */
|
||||
private _lastHumanInputAt = 0;
|
||||
/** Last submit that was not the keep-alive's own; the keep-warm cap's clock. Persisted. */
|
||||
private _lastRealPromptAt = 0;
|
||||
/** Lazily compiled `capabilities.workDetect.workingLine`. See _workingLinePattern(). */
|
||||
private _workingLineRe: RegExp | undefined = undefined;
|
||||
/** Lazily compiled `capabilities.workDetect.watchingLine`. See _watchingLinePattern(). */
|
||||
@@ -804,7 +826,7 @@ export class Session extends EventEmitter {
|
||||
|
||||
// The session that spawned this one (tab lineage lines). Resolved by the create
|
||||
// route before it reaches here, so this is always either an id that existed at
|
||||
// create time or undefined. Decoration only — see SessionState.parentSessionId.
|
||||
// create time or undefined. Decoration only (plus the keep-warm default); see SessionState.parentSessionId.
|
||||
private readonly _parentSessionId?: string;
|
||||
|
||||
// Session color for visual differentiation
|
||||
@@ -899,6 +921,10 @@ export class Session extends EventEmitter {
|
||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||
/** Restored wall-clock ms of the pane's last Enter (see `lastSubmitAt`). */
|
||||
lastSubmitAt?: number;
|
||||
/** Restored last non-keep-alive submit (see `lastRealPromptAt`). */
|
||||
lastRealPromptAt?: number;
|
||||
/** Restored prompt-cache keep-warm choice (see `keepWarmEnabled`). */
|
||||
keepWarmEnabled?: boolean;
|
||||
/** Restored conversation chain, oldest first (see `claudeSessionChain`). */
|
||||
claudeSessionChain?: string[];
|
||||
/** Restored agent-exit observation for this session's pane (see `paneExit`). */
|
||||
@@ -975,6 +1001,10 @@ export class Session extends EventEmitter {
|
||||
// response viewer re-derive the live conversation without waiting for the
|
||||
// user to type again.
|
||||
this._lastSubmitAt = config.lastSubmitAt ?? 0;
|
||||
// Restored so a restart neither reopens the keep-warm window nor drops the choice. A state.json
|
||||
// from before the field has only `lastSubmitAt`, and every submit then was a real one.
|
||||
this._lastRealPromptAt = config.lastRealPromptAt ?? config.lastSubmitAt ?? 0;
|
||||
this._keepWarmEnabled = config.keepWarmEnabled;
|
||||
// Restored chain: its tail is the conversation the CLI was actually on when
|
||||
// the server stopped, which outranks the launch id seeded just above. The
|
||||
// FIRST-HAND flag is deliberately NOT restored — a persisted claim is not a
|
||||
@@ -1479,12 +1509,17 @@ export class Session extends EventEmitter {
|
||||
* report. It rides `toState()` for the Session Options readout and is restored on a
|
||||
* Codeman restart (constructor `promptCache`) so the row is not blank until the pane's
|
||||
* next render; the readout is time-aware, so a value whose `expiresAt` has passed renders
|
||||
* as cold rather than as a stale warm.
|
||||
* as cold rather than as a stale warm, and a future one lets the keep-warm re-arm without a turn.
|
||||
*/
|
||||
get promptCache(): PromptCacheStatus | null {
|
||||
return this._promptCache;
|
||||
}
|
||||
|
||||
/** Whether the current launch carries the statusLine exporter (`MuxSession.statusLine`); undefined when unknown. */
|
||||
get statusLineExporter(): StatusLineExporter | undefined {
|
||||
return this._muxSession?.statusLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the prompt-cache report; true when it differs from the previous one. Stamps
|
||||
* `compactedAt` when this report ENTERS the window in which Claude Code reads
|
||||
@@ -1513,6 +1548,66 @@ export class Session extends EventEmitter {
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Prompt-cache keep-warm opt-in; undefined means the app-wide default applies. */
|
||||
get keepWarmEnabled(): boolean | undefined {
|
||||
return this._keepWarmEnabled;
|
||||
}
|
||||
|
||||
setKeepWarmEnabled(enabled: boolean | undefined): void {
|
||||
this._keepWarmEnabled = enabled;
|
||||
}
|
||||
|
||||
/** What the keep-warm service last published for this session, or null before its first evaluation. */
|
||||
get keepWarm(): KeepWarmStatus | null {
|
||||
return this._keepWarm;
|
||||
}
|
||||
|
||||
/** Replace the keep-warm status; true when it differs from the previous one. */
|
||||
setKeepWarmStatus(status: KeepWarmStatus): boolean {
|
||||
const changed = JSON.stringify(status) !== JSON.stringify(this._keepWarm);
|
||||
this._keepWarm = status;
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* True while the running turn is Codeman's own keep-alive nudge: its stop hook and idle
|
||||
* prompt open pre-acknowledged, and its submit moves `lastSubmitAt` (it IS a submit, and the
|
||||
* response viewer's conversation correlation needs it) but not `lastRealPromptAt`. The turn's
|
||||
* own idle prompt ends it (`endKeepAliveTurn()`), and so does any other submit: a `fromUser`
|
||||
* write, a programmatic write of anything but the nudge, or a `prompt_submitted` hook for
|
||||
* another prompt, so a prompt right after a nudge counts and alerts normally.
|
||||
*/
|
||||
get keepAliveTurnActive(): boolean {
|
||||
return Date.now() < this._keepAliveTurnUntil;
|
||||
}
|
||||
|
||||
beginKeepAliveTurn(windowMs: number): void {
|
||||
this._keepAliveTurnUntil = Date.now() + windowMs;
|
||||
}
|
||||
|
||||
endKeepAliveTurn(): void {
|
||||
this._keepAliveTurnUntil = 0;
|
||||
}
|
||||
|
||||
/** Last keystroke a person sent (`fromUser` write), epoch ms; 0 when none this process. */
|
||||
get lastHumanInputAt(): number {
|
||||
return this._lastHumanInputAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Last submit that was not the keep-alive's own, from any writer or the UserPromptSubmit hook
|
||||
* (a person typing in an attached tmux client reaches Codeman only through the hook); epoch
|
||||
* ms, 0 if none. The keep-warm cap counts from it; persisted so a restart keeps it.
|
||||
*/
|
||||
get lastRealPromptAt(): number {
|
||||
return this._lastRealPromptAt;
|
||||
}
|
||||
|
||||
/** Whether this launch carries `idleCompaction: false` (`MuxSession.idleCompactionOff`). */
|
||||
get idleCompactionOptOut(): boolean {
|
||||
return this._muxSession?.idleCompactionOff === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the session's process tree has active child processes beyond Claude itself.
|
||||
* Detects running bash tools, test suites, builds, servers, etc. that Claude spawned.
|
||||
@@ -2008,7 +2103,9 @@ export class Session extends EventEmitter {
|
||||
autoResumeEnabled: this._autoOps.autoResumeEnabled,
|
||||
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
|
||||
promptCache: this._promptCache ?? undefined,
|
||||
statusLineExporter: this._muxSession?.statusLine,
|
||||
statusLineExporter: this.statusLineExporter,
|
||||
keepWarmEnabled: this._keepWarmEnabled,
|
||||
keepWarm: this._keepWarm ?? undefined,
|
||||
imageWatcherEnabled: this._imageWatcherEnabled,
|
||||
pinned: this._pinned || undefined,
|
||||
pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined,
|
||||
@@ -2057,6 +2154,7 @@ export class Session extends EventEmitter {
|
||||
paneExit: this._paneExit ?? undefined,
|
||||
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
|
||||
lastSubmitAt: this._lastSubmitAt || undefined,
|
||||
lastRealPromptAt: this._lastRealPromptAt || undefined,
|
||||
// Only a chain the CLI's own hooks vouched for is persisted, and only when
|
||||
// the pane actually moved conversation. Its LAST entry is the live one, so
|
||||
// it is also what restores `claudeSessionId` across a restart — `start()`
|
||||
@@ -2425,6 +2523,8 @@ export class Session extends EventEmitter {
|
||||
unsetEnvKeys: this._pendingEnvUnsets.size > 0 ? [...this._pendingEnvUnsets] : undefined,
|
||||
effort: this._effort,
|
||||
advisorModel: this._advisorModel,
|
||||
keepWarm: this._keepWarmEnabled,
|
||||
spawnedByAgent: !!this._parentSessionId,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
@@ -2904,6 +3004,8 @@ export class Session extends EventEmitter {
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
advisorModel: this._advisorModel,
|
||||
keepWarm: this._keepWarmEnabled,
|
||||
spawnedByAgent: !!this._parentSessionId,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
@@ -4650,9 +4752,16 @@ export class Session extends EventEmitter {
|
||||
*/
|
||||
private _trackSubmit(data: string, options: SessionWriteOptions): string[] {
|
||||
const submitted = options.fromUser && this._acceptsPrompts ? this._submittedPromptTracker.feed(data) : [];
|
||||
if (data.includes('\r') || data.includes('\n')) {
|
||||
this._lastSubmitAt = Date.now();
|
||||
const hasEnter = data.includes('\r') || data.includes('\n');
|
||||
if (options.fromUser) {
|
||||
this._lastHumanInputAt = Date.now();
|
||||
// A person is here: whatever turn follows is theirs, not the keep-alive's.
|
||||
this._keepAliveTurnUntil = 0;
|
||||
} else if (hasEnter && !isKeepAliveWrite(data)) {
|
||||
// Anything else submitted (an agent, respawn, auto-resume) starts a turn of its own.
|
||||
this._keepAliveTurnUntil = 0;
|
||||
}
|
||||
if (hasEnter) this._stampSubmit();
|
||||
return submitted;
|
||||
}
|
||||
|
||||
@@ -4678,9 +4787,20 @@ export class Session extends EventEmitter {
|
||||
* `_trackSubmit` only sees input that flows through Codeman's write path, so
|
||||
* a pane the user drives by attaching to tmux directly never stamped this and
|
||||
* `lastSubmitAt` stayed 0 for its whole life.
|
||||
*
|
||||
* `prompt` is the hook's own prompt text: anything but the keep-alive's ends the keep-alive
|
||||
* window, because a person typing in an attached tmux client reaches Codeman only here, and
|
||||
* their prompt must count as real and alert normally.
|
||||
*/
|
||||
markPromptSubmitted(): void {
|
||||
markPromptSubmitted(prompt?: string): void {
|
||||
if (prompt !== undefined && prompt.trim() !== KEEP_WARM_NUDGE) this._keepAliveTurnUntil = 0;
|
||||
this._stampSubmit();
|
||||
}
|
||||
|
||||
/** Every submit moves `lastSubmitAt`; only one outside a keep-alive turn moves `lastRealPromptAt`. */
|
||||
private _stampSubmit(): void {
|
||||
this._lastSubmitAt = Date.now();
|
||||
if (!this.keepAliveTurnActive) this._lastRealPromptAt = this._lastSubmitAt;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -286,6 +286,9 @@ export class StateStore {
|
||||
if (this.state.tabLayouts !== undefined) {
|
||||
parts.push(`"tabLayouts":${JSON.stringify(this.state.tabLayouts)}`);
|
||||
}
|
||||
if (this.state.caseDefaultGroups !== undefined) {
|
||||
parts.push(`"caseDefaultGroups":${JSON.stringify(this.state.caseDefaultGroups)}`);
|
||||
}
|
||||
|
||||
return `{${parts.join(',')}}`;
|
||||
}
|
||||
@@ -710,6 +713,38 @@ export class StateStore {
|
||||
return { layouts: structuredClone(validated), sessionOrder: [...sessionOrder] };
|
||||
}
|
||||
|
||||
/**
|
||||
* An owner's default session group for a case: a group id, `null` for an
|
||||
* intentional Ungrouped choice, or `undefined` when unset. state.json is read
|
||||
* unvalidated, so anything but a string or null reads as unset.
|
||||
*/
|
||||
getCaseDefaultGroup(owner: string, caseName: string): string | null | undefined {
|
||||
const all = this.state.caseDefaultGroups;
|
||||
if (!all || typeof all !== 'object' || !Object.hasOwn(all, owner)) return undefined;
|
||||
const forOwner = all[owner];
|
||||
if (!forOwner || typeof forOwner !== 'object' || !Object.hasOwn(forOwner, caseName)) return undefined;
|
||||
const value = forOwner[caseName];
|
||||
return typeof value === 'string' || value === null ? value : undefined;
|
||||
}
|
||||
|
||||
/** Persist an owner's default session group for a case (`null` = Ungrouped). */
|
||||
setCaseDefaultGroup(owner: string, caseName: string, groupId: string | null): void {
|
||||
const all = this.state.caseDefaultGroups ?? {};
|
||||
const forOwner = Object.hasOwn(all, owner) ? all[owner] : {};
|
||||
this.state.caseDefaultGroups = { ...all, [owner]: { ...forOwner, [caseName]: groupId } };
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Forget an owner's default session group for a case (unset, not Ungrouped). */
|
||||
deleteCaseDefaultGroup(owner: string, caseName: string): void {
|
||||
if (this.getCaseDefaultGroup(owner, caseName) === undefined) return;
|
||||
const all = this.state.caseDefaultGroups ?? {};
|
||||
const forOwner = { ...all[owner] };
|
||||
delete forOwner[caseName];
|
||||
this.state.caseDefaultGroups = { ...all, [owner]: forOwner };
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Resets all state to initial values and saves immediately. */
|
||||
reset(): void {
|
||||
this.state = createInitialState();
|
||||
|
||||
@@ -50,6 +50,11 @@ interface TabLayoutServiceDeps {
|
||||
* from then on; on, it keeps following its parent until moved by hand.
|
||||
*/
|
||||
childrenFollowParent?(): boolean | Promise<boolean>;
|
||||
/**
|
||||
* Where per-case default session groups live (StateStore). Optional so a
|
||||
* harness without it simply never applies a case default.
|
||||
*/
|
||||
caseDefaults?: Pick<StateStore, 'getCaseDefaultGroup' | 'setCaseDefaultGroup' | 'deleteCaseDefaultGroup'>;
|
||||
now?: () => string;
|
||||
}
|
||||
|
||||
@@ -78,6 +83,17 @@ export interface RemovedTabLayoutSession {
|
||||
/** Where a newly created session asked to be placed. Advisory: an unknown group is ignored. */
|
||||
export interface SessionPlacementHint {
|
||||
tabGroupId?: string;
|
||||
/**
|
||||
* The case the session was started for. Its default session group (if any)
|
||||
* is the fallback when neither `tabGroupId` nor a parent places the session.
|
||||
*/
|
||||
caseName?: string;
|
||||
}
|
||||
|
||||
/** A case's default session group plus the owner's current named groups to choose from. */
|
||||
export interface CaseDefaultGroupView {
|
||||
groupId: string | null;
|
||||
groups: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,6 +174,20 @@ function pinCreatedChild(layout: TabLayout, sessionId: string, metadata: readonl
|
||||
return moveRef(pinned, { kind: 'session', id: sessionId }, { groupId: group?.id ?? null, index: to }, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the just-created session have a parent that places it (right after that
|
||||
* parent)? Mirrors the conditions under which normalization and
|
||||
* {@link pinCreatedChild} treat it as a child.
|
||||
*/
|
||||
function hasPlacingParent(layout: TabLayout, sessionId: string, metadata: readonly TabRefMetadata[]): boolean {
|
||||
const item = metadata.find((fact) => fact.kind === 'session' && fact.id === sessionId);
|
||||
const parentId = item?.parentSessionId;
|
||||
if (!item?.ownerValid || !parentId) return false;
|
||||
const parentValid = metadata.some((fact) => fact.kind === 'session' && fact.id === parentId && fact.ownerValid);
|
||||
const refs = [...layout.groups.flatMap((group) => group.refs), ...layout.ungrouped];
|
||||
return parentValid && refs.some((ref) => ref.kind === 'session' && ref.id === parentId);
|
||||
}
|
||||
|
||||
/** Move a just-created session to the end of `groupId`, or leave the layout alone. */
|
||||
function placeCreatedSession(
|
||||
layout: TabLayout,
|
||||
@@ -550,21 +580,79 @@ export class TabLayoutService {
|
||||
): Promise<TabLayout> {
|
||||
return this.withOwner(owner, async () => {
|
||||
const follow = sessionId ? (await this.deps.childrenFollowParent?.()) === true : true;
|
||||
const caseName = sessionId ? placement.caseName : undefined;
|
||||
const caseDefault = caseName ? this.deps.caseDefaults?.getCaseDefaultGroup(owner, caseName) : undefined;
|
||||
let staleCaseDefault = false;
|
||||
const rollback = register?.();
|
||||
let layout: TabLayout;
|
||||
try {
|
||||
const groupId = placement.tabGroupId;
|
||||
const adjust =
|
||||
sessionId && (groupId || !follow)
|
||||
sessionId && (groupId || !follow || typeof caseDefault === 'string')
|
||||
? (layout: TabLayout, metadata: readonly TabRefMetadata[]) => {
|
||||
const placed = groupId ? placeCreatedSession(layout, sessionId, groupId, metadata) : layout;
|
||||
// Precedence: explicit group > parent placement > case default > none.
|
||||
const explicit = groupId && layout.groups.some((group) => group.id === groupId) ? groupId : undefined;
|
||||
let placed = explicit ? placeCreatedSession(layout, sessionId, explicit, metadata) : layout;
|
||||
if (!explicit && typeof caseDefault === 'string') {
|
||||
if (!layout.groups.some((group) => group.id === caseDefault)) staleCaseDefault = true;
|
||||
else if (!hasPlacingParent(layout, sessionId, metadata))
|
||||
placed = placeCreatedSession(layout, sessionId, caseDefault, metadata);
|
||||
}
|
||||
return follow ? placed : pinCreatedChild(placed, sessionId, metadata);
|
||||
}
|
||||
: undefined;
|
||||
return await this.getUnlocked(owner, adjust);
|
||||
layout = await this.getUnlocked(owner, adjust);
|
||||
} catch (error) {
|
||||
if (typeof rollback === 'function') rollback();
|
||||
throw error;
|
||||
}
|
||||
// A default whose group was deleted is ignored above and forgotten here, only once the
|
||||
// creation's own write has landed, and outside the rollback: the session is committed by now,
|
||||
// and nothing this cleanup does may drop it from the live map.
|
||||
if (staleCaseDefault && caseName) this.deps.caseDefaults?.deleteCaseDefaultGroup(owner, caseName);
|
||||
return layout;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A case's default session group and the owner's named groups to choose from.
|
||||
* A stored id that is no longer one of this owner's groups is reported as
|
||||
* `null` (Ungrouped), which is also where creation would put it. Read-only:
|
||||
* the next creation for the case forgets the stale id.
|
||||
*/
|
||||
async caseDefaultGroup(owner: string, caseName: string): Promise<CaseDefaultGroupView> {
|
||||
return this.withOwner(owner, async () => {
|
||||
const { authoritative } = await this.prepareUnlocked(owner);
|
||||
const groups = authoritative.groups.map((group) => ({ id: group.id, name: group.name }));
|
||||
const stored = this.deps.caseDefaults?.getCaseDefaultGroup(owner, caseName);
|
||||
const known = typeof stored === 'string' && groups.some((group) => group.id === stored);
|
||||
return { groupId: known ? stored : null, groups };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a case's default session group: one of this owner's current groups, or
|
||||
* `null` for Ungrouped. Only future creations read it; nothing is moved.
|
||||
* Throws {@link TabLayoutValidationError} for an id not in the owner's layout.
|
||||
*/
|
||||
async setCaseDefaultGroup(owner: string, caseName: string, groupId: string | null): Promise<CaseDefaultGroupView> {
|
||||
return this.withOwner(owner, async () => {
|
||||
const caseDefaults = this.deps.caseDefaults;
|
||||
if (!caseDefaults) throw new Error('Case default groups are not available');
|
||||
const { authoritative } = await this.prepareUnlocked(owner);
|
||||
const groups = authoritative.groups.map((group) => ({ id: group.id, name: group.name }));
|
||||
if (groupId !== null && !groups.some((group) => group.id === groupId)) {
|
||||
throw new TabLayoutValidationError('Unknown tab group');
|
||||
}
|
||||
caseDefaults.setCaseDefaultGroup(owner, caseName, groupId);
|
||||
return { groupId, groups };
|
||||
});
|
||||
}
|
||||
|
||||
/** Forget a case's default session group (the case was deleted or unlinked). */
|
||||
async forgetCaseDefaultGroup(owner: string, caseName: string): Promise<void> {
|
||||
await this.withOwner(owner, async () => {
|
||||
this.deps.caseDefaults?.deleteCaseDefaultGroup(owner, caseName);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+173
-33
@@ -27,7 +27,7 @@ import { execSync, exec, execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
import { existsSync, readFileSync, mkdirSync } from 'node:fs';
|
||||
import { existsSync, readFileSync, mkdirSync, realpathSync } from 'node:fs';
|
||||
import { writeFile, rename } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
@@ -67,12 +67,15 @@ import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js';
|
||||
import {
|
||||
buildSpawnCommandFromRegistry,
|
||||
configSetenvValues,
|
||||
launchGatePasses,
|
||||
legacyConfigForMode,
|
||||
} from './session-cli-registry-bridge.js';
|
||||
import { keepWarmOn } from './prompt-cache-keepwarm.js';
|
||||
import type { CliEntry } from './config/cli-registry/types.js';
|
||||
import {
|
||||
resolveStatusLineCliCommand,
|
||||
readPlanUsageTelemetryEnabled,
|
||||
readKeepWarmSettings,
|
||||
findEffectiveUserStatusLineCommand,
|
||||
} from './hooks-config.js';
|
||||
import {
|
||||
@@ -125,6 +128,7 @@ import {
|
||||
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import { ensureDeepSeekStatusShim } from './deepseek-status-shim.js';
|
||||
import { restrictTmuxPrefixTable } from './tmux-prefix-table.js';
|
||||
|
||||
/** How long a cached process snapshot stays usable. */
|
||||
const PROC_SNAPSHOT_TTL_MS = 2000;
|
||||
@@ -183,6 +187,22 @@ const DEFAULT_REMOTE_RECONNECT_INTERVAL_MS = 5000;
|
||||
/** Stable cwd for tmux server/pane launch; actual session cwd is reached inside the pane. */
|
||||
const TMUX_LAUNCH_CWD = '/tmp';
|
||||
|
||||
/**
|
||||
* TMUX_LAUNCH_CWD in every spelling a pane can report it. tmux reads a pane's
|
||||
* directory from its process, which comes back resolved (`/private/tmp` on
|
||||
* macOS, where `/tmp` is a symlink), so the literal and the resolved spelling
|
||||
* both count. Computed once.
|
||||
*/
|
||||
const TMUX_LAUNCH_CWD_SPELLINGS: ReadonlySet<string> = (() => {
|
||||
const spellings = new Set([TMUX_LAUNCH_CWD]);
|
||||
try {
|
||||
spellings.add(realpathSync(TMUX_LAUNCH_CWD));
|
||||
} catch {
|
||||
// Unresolvable launch directory: the literal spelling is all there is.
|
||||
}
|
||||
return spellings;
|
||||
})();
|
||||
|
||||
/** Claude Code native macOS recommendation for avoiding low nofile startup failures. */
|
||||
export const CLAUDE_CODE_NOFILE_LIMIT = 2147483646;
|
||||
|
||||
@@ -252,10 +272,14 @@ const PANE_LIST_SEP = '|';
|
||||
* know a variable substitutes the empty string rather than failing, which is how
|
||||
* tmux 3.2a answers `#{pane_dead_signal}` (added in 3.4), and the parser reads a
|
||||
* short row as "pid known, deadness unknown" rather than discarding it.
|
||||
*
|
||||
* `#{pane_current_path}` comes last on purpose: a directory name may contain the
|
||||
* separator, and only the final field can absorb it without ambiguity.
|
||||
*/
|
||||
const PANE_LIST_FORMAT =
|
||||
`#{session_name}${PANE_LIST_SEP}#{pane_pid}` +
|
||||
`${PANE_LIST_SEP}#{pane_dead}${PANE_LIST_SEP}#{pane_dead_status}${PANE_LIST_SEP}#{pane_dead_signal}`;
|
||||
`${PANE_LIST_SEP}#{pane_dead}${PANE_LIST_SEP}#{pane_dead_status}${PANE_LIST_SEP}#{pane_dead_signal}` +
|
||||
`${PANE_LIST_SEP}#{pane_current_path}`;
|
||||
|
||||
/**
|
||||
* 构建 pane 启动前的 nofile 修复命令。
|
||||
@@ -282,6 +306,8 @@ export interface PaneRow {
|
||||
exitStatus?: number;
|
||||
/** `#{pane_dead_signal}` — the killing signal, absent before tmux 3.4 and when unsignalled. */
|
||||
exitSignal?: number;
|
||||
/** `#{pane_current_path}` — the pane's current directory, absent when tmux reported none. */
|
||||
currentPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -347,6 +373,8 @@ export function parsePaneRows(output: string): PaneRow[] {
|
||||
dead: deadFlag === '1' ? true : deadFlag === '0' ? false : undefined,
|
||||
exitStatus: paneField(fields, 3),
|
||||
exitSignal: paneField(fields, 4),
|
||||
// Everything after the fifth separator, so a path containing `|` stays whole.
|
||||
currentPath: fields.slice(5).join(PANE_LIST_SEP) || undefined,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
@@ -1070,6 +1098,28 @@ export function buildCodexCommand(config?: CodexConfig): string {
|
||||
return buildSpawnCommandFromRegistry(entry, { mode: 'codex', sessionId: '', codexConfig: config }) ?? 'codex';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a launch carries `idleCompaction: false` in its one `--settings` JSON: keep-warm on
|
||||
* at launch (`keepWarmOn`), the statusLine exporter injected (without its `prompt_cache` reports
|
||||
* keep-warm cannot run, and the opt-out alone would only cost the user Claude Code's own idle
|
||||
* compaction), and the CLI's `idleCompactionSetting` gate passed, fail-closed like `--name`:
|
||||
* an unknown version means no key. Pure; `claudeCliVersion` as in buildSpawnCommand.
|
||||
*/
|
||||
export function decideIdleCompactionOptOut(o: {
|
||||
mode: SessionMode;
|
||||
statusLine: StatusLineExporter | undefined;
|
||||
keepWarm: boolean | undefined;
|
||||
keepWarmDefault: boolean;
|
||||
spawnedByAgent: boolean | undefined;
|
||||
claudeCliVersion?: string | null;
|
||||
}): boolean {
|
||||
return (
|
||||
o.statusLine === 'injected' &&
|
||||
keepWarmOn(o.keepWarm, o.keepWarmDefault, !!o.spawnedByAgent) &&
|
||||
launchGatePasses(o.mode, 'idleCompactionSetting', o.claudeCliVersion)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSpawnCommand(options: {
|
||||
mode: SessionMode;
|
||||
sessionId: string;
|
||||
@@ -1091,6 +1141,8 @@ export function buildSpawnCommand(options: {
|
||||
advisorModel?: string;
|
||||
/** Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the exporter. Claude only. */
|
||||
statusLineCommand?: string;
|
||||
/** Add `idleCompaction: false` to the launch's one `--settings` JSON (see decideIdleCompactionOptOut). */
|
||||
idleCompactionOff?: boolean;
|
||||
/** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). By default only a user-chosen name; `pinCliSessionName` pins every tab name: see `Session.cliPinnedName`. */
|
||||
sessionName?: string;
|
||||
/**
|
||||
@@ -2266,6 +2318,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return { command, state: command ? 'injected' : enabled ? 'workspace-statusline' : 'collection-off' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this launch carries `idleCompaction: false` (decideIdleCompactionOptOut), with the
|
||||
* keep-warm app default read fresh like the plan-usage switch above. Logged when keep-warm is
|
||||
* on but the version gate keeps the key off, so a session that may still idle-compact says why.
|
||||
*/
|
||||
private async _resolveIdleCompactionOptOut(
|
||||
mode: SessionMode,
|
||||
statusLine: StatusLineExporter | undefined,
|
||||
keepWarm: boolean | undefined,
|
||||
spawnedByAgent: boolean | undefined
|
||||
): Promise<boolean> {
|
||||
if (statusLine !== 'injected') return false; // no settings read for a launch that can never carry it
|
||||
const { keepWarmDefault } = await readKeepWarmSettings();
|
||||
const off = decideIdleCompactionOptOut({ mode, statusLine, keepWarm, keepWarmDefault, spawnedByAgent });
|
||||
if (!off && keepWarmOn(keepWarm, keepWarmDefault, !!spawnedByAgent)) {
|
||||
console.log(
|
||||
`[TmuxManager] Keep-warm is on but Claude Code's version is unknown or predates the idleCompaction setting; launching without the opt-out`
|
||||
);
|
||||
}
|
||||
return off;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the user's own REAL statusLine command (found by
|
||||
* findEffectiveUserStatusLineCommand) via tmux setenv, so the shared
|
||||
@@ -2326,6 +2400,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
envOverrides,
|
||||
effort,
|
||||
advisorModel,
|
||||
keepWarm,
|
||||
spawnedByAgent,
|
||||
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||||
remote,
|
||||
docker,
|
||||
@@ -2389,6 +2465,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
workingDir,
|
||||
!!(remote || docker)
|
||||
);
|
||||
const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent);
|
||||
// The user's own REAL statusLine, if any (walked via Claude Code's own
|
||||
// settings precedence) — exported below so the shared exporter script
|
||||
// can wrap it. Only worth discovering when we're actually injecting.
|
||||
@@ -2413,6 +2490,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
effort,
|
||||
advisorModel,
|
||||
statusLineCommand,
|
||||
idleCompactionOff,
|
||||
sessionName: cliName,
|
||||
});
|
||||
|
||||
@@ -2467,6 +2545,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
/* Non-critical */
|
||||
}
|
||||
|
||||
// Browser keystrokes reach the attach client, so tmux's stock prefix table would
|
||||
// let Ctrl+B ( or s move a tab onto another session. Before the agent starts.
|
||||
restrictTmuxPrefixTable(this.tmux());
|
||||
|
||||
// Per-CLI env: API keys, config blobs, config-sourced vars, status bridges. All of
|
||||
// it is registry data, so this is one unconditional call rather than a per-mode ladder.
|
||||
this._configureCliEnv(
|
||||
@@ -2554,6 +2636,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
attached: false,
|
||||
name,
|
||||
statusLine,
|
||||
idleCompactionOff,
|
||||
};
|
||||
|
||||
this.sessions.set(sessionId, session);
|
||||
@@ -2664,6 +2747,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
unsetEnvKeys,
|
||||
effort,
|
||||
advisorModel,
|
||||
keepWarm,
|
||||
spawnedByAgent,
|
||||
remote,
|
||||
docker,
|
||||
cliName,
|
||||
@@ -2684,6 +2769,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
workingDir,
|
||||
!!(remote || docker)
|
||||
);
|
||||
const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent);
|
||||
const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined;
|
||||
|
||||
const baseCmd = buildSpawnCommand({
|
||||
@@ -2705,6 +2791,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
effort,
|
||||
advisorModel,
|
||||
statusLineCommand,
|
||||
idleCompactionOff,
|
||||
sessionName: cliName,
|
||||
});
|
||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||
@@ -2747,8 +2834,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
this.clearPaneExit(muxName);
|
||||
const pid = this.getPanePid(muxName);
|
||||
if (pid) session.pid = pid;
|
||||
if (session.statusLine !== statusLine) {
|
||||
if (session.statusLine !== statusLine || session.idleCompactionOff !== idleCompactionOff) {
|
||||
session.statusLine = statusLine;
|
||||
session.idleCompactionOff = idleCompactionOff;
|
||||
this.saveSessions();
|
||||
}
|
||||
return pid;
|
||||
@@ -3115,13 +3203,32 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// socket tag to reconcile and no cross-socket ambiguity that could mark a
|
||||
// live session dead (the root cause of vanished/duplicate tabs).
|
||||
let active: Map<string, number>;
|
||||
const paneDirs = new Map<string, string>();
|
||||
try {
|
||||
const output = execSync(`${this.tmux()} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
});
|
||||
// Not trimmed: the directory is the last field, so trimming would cut a
|
||||
// trailing space off the last row's path. parsePaneRows skips empty lines.
|
||||
const rows = parsePaneRows(output);
|
||||
active = new Map(rows.map((row) => [row.sessionName, row.pid]));
|
||||
// A discovered session's directory, from its first pane with a usable path.
|
||||
// The path is admitted only if it passes the same check createSession and
|
||||
// respawnPane apply, since it was set by whoever owns the pane, not by us.
|
||||
// The launch directory counts as no answer: every pane starts there, and a
|
||||
// remote or docker pane never cds out of it, so for such a session it is
|
||||
// not the workspace — and a rediscovered record cannot tell it is one.
|
||||
for (const row of rows) {
|
||||
if (
|
||||
row.currentPath &&
|
||||
!TMUX_LAUNCH_CWD_SPELLINGS.has(row.currentPath) &&
|
||||
!paneDirs.has(row.sessionName) &&
|
||||
isValidPath(row.currentPath)
|
||||
) {
|
||||
paneDirs.set(row.sessionName, row.currentPath);
|
||||
}
|
||||
}
|
||||
// The same read answers both questions, so recovery starts with a pane-exit
|
||||
// reading rather than waiting for the first stats tick — which may never
|
||||
// come, since the collector only starts when boot found a live session.
|
||||
@@ -3133,6 +3240,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
active = new Map();
|
||||
}
|
||||
|
||||
// Sessions that survived an upgrade may sit on a server whose prefix table was never
|
||||
// reduced (createSession does it for new ones), so cover them at boot too. Only with
|
||||
// panes listed: on an empty socket `list-keys` spins up a throwaway server instead.
|
||||
if (active.size > 0) restrictTmuxPrefixTable(this.tmux());
|
||||
|
||||
// Check tracked sessions against the live pane list.
|
||||
for (const [sessionId, session] of this.sessions) {
|
||||
const pid = active.get(session.muxName);
|
||||
@@ -3175,11 +3287,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
muxName: sessionName,
|
||||
pid,
|
||||
createdAt: Date.now(),
|
||||
workingDir: process.cwd(),
|
||||
// Where the pane actually works, when tmux said; the server's cwd is only a fallback.
|
||||
workingDir: paneDirs.get(sessionName) ?? process.cwd(),
|
||||
mode: 'claude',
|
||||
attached: false,
|
||||
name: `Restored: ${sessionName}`,
|
||||
// Every field above except the name and the pid is a guess: this record
|
||||
// Every field above except the name, the pid and, when tmux reported a
|
||||
// usable one, the directory is a guess: this record
|
||||
// was rebuilt from the socket because Codeman's own bookkeeping did not
|
||||
// have it. The synthetic id also cannot find the session's state.json
|
||||
// entry, so a remote or docker session rediscovered this way arrives
|
||||
@@ -3827,7 +3941,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* - Text followed by `\r` goes in as ONE bracketed paste (buildPasteTextCommands),
|
||||
* so Codex cannot fold the Enter into a paste burst; a single character is typed
|
||||
* with `send-keys -l` instead, so a dialog answer selects (submitsAsKeys)
|
||||
* - Text without `\r` is typed literally with `send-keys -l` (no key interpretation)
|
||||
* - Single-line text without `\r` is typed literally with `send-keys -l` (no key
|
||||
* interpretation)
|
||||
* - A `\n` inside the text is kept (`\r\n` loses only its `\r`), and multi-line text
|
||||
* always goes in as ONE bracketed paste, with or without a following Enter: inside
|
||||
* the paste markers a CLI that enabled bracketed paste receives the line breaks as
|
||||
* pasted text, where typed as keys each one could submit a line on its own
|
||||
* - `Enter` key is sent as a SEPARATE tmux invocation after a small delay
|
||||
* - Ink (Claude CLI) needs text and Enter split to avoid treating Enter as a newline
|
||||
*/
|
||||
@@ -3840,8 +3959,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return false;
|
||||
}
|
||||
|
||||
// TEST MODE: No-op — don't send input to real tmux sessions
|
||||
if (IS_TEST_MODE) {
|
||||
// TEST MODE: No-op — don't send input to real tmux sessions. The gate lives in
|
||||
// inputRunner() so a test subclass can record what WOULD reach tmux.
|
||||
const run = this.inputRunner();
|
||||
if (!run) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3856,7 +3977,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
try {
|
||||
const hasCarriageReturn = input.includes('\r');
|
||||
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
|
||||
// Leading and trailing blank lines are dropped, so neither `"\nfix it\r"` nor
|
||||
// `" \nfix it\r"` pastes an empty first line (nor `"fix it\n\r"` an empty last
|
||||
// one). The leading match ends at a line break, so the first real line keeps its
|
||||
// indentation.
|
||||
const textPart = input
|
||||
.replace(/\r/g, '')
|
||||
.replace(/^\s*\n/, '')
|
||||
.trimEnd();
|
||||
|
||||
if (textPart && hasCarriageReturn) {
|
||||
// Send text first, then Enter as a SEPARATE tmux command after a short delay.
|
||||
@@ -3868,31 +3996,29 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// selection dialog ignores a pasted `3` and Enter then confirms the
|
||||
// highlighted option (see submitsAsKeys).
|
||||
if (submitsAsKeys(textPart)) {
|
||||
await execAsync(
|
||||
`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
|
||||
{ timeout: EXEC_TIMEOUT_MS }
|
||||
await run(
|
||||
`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`
|
||||
);
|
||||
} else {
|
||||
await pasteText(
|
||||
(cmd) => execAsync(cmd, { timeout: EXEC_TIMEOUT_MS }),
|
||||
buildPasteTextCommands(this.tmux(), session.muxName, textPart)
|
||||
);
|
||||
await pasteText(run, buildPasteTextCommands(this.tmux(), session.muxName, textPart));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
await run(`${this.tmux()} send-keys -t "${session.muxName}" Enter`);
|
||||
} else if (textPart.includes('\n')) {
|
||||
// Multi-line text, no Enter: pasted, because typed as keys every `\n` would
|
||||
// reach the pane as a key the CLI may read as submit. A pane that has not
|
||||
// enabled bracketed paste (POSIX sh, macOS /bin/bash 3.2) still gets every line
|
||||
// break as a CR, so there every line but the last runs; before line breaks were
|
||||
// kept, the joined text was typed and nothing ran.
|
||||
await pasteText(run, buildPasteTextCommands(this.tmux(), session.muxName, textPart));
|
||||
} else if (textPart) {
|
||||
// Text only, no Enter
|
||||
await execAsync(
|
||||
`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
|
||||
{ timeout: EXEC_TIMEOUT_MS }
|
||||
await run(
|
||||
`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`
|
||||
);
|
||||
} else if (hasCarriageReturn) {
|
||||
// Enter only
|
||||
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
await run(`${this.tmux()} send-keys -t "${session.muxName}" Enter`);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -3902,6 +4028,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How {@link sendInput} reaches tmux, or null where it must not (vitest). Split
|
||||
* out like {@link readPaneRows}: with the test-mode gate at the top of sendInput,
|
||||
* no test could see which commands a submit actually sends.
|
||||
*/
|
||||
protected inputRunner(): ((cmd: string) => Promise<unknown>) | null {
|
||||
if (IS_TEST_MODE) return null;
|
||||
return (cmd) => execAsync(cmd, { timeout: EXEC_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
// ========== Pane Methods (for Agent Team teammate panes) ==========
|
||||
|
||||
/**
|
||||
@@ -4007,7 +4143,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
/**
|
||||
* Send input to a specific pane within a tmux session.
|
||||
* Uses the same literal text approach as sendInput() but targets a specific pane.
|
||||
* Types the text literally with `send-keys -l`, line breaks stripped, then Enter
|
||||
* when the input carried a `\r`. Unlike sendInput(), it neither pastes nor keeps
|
||||
* the line breaks of multi-line text.
|
||||
*/
|
||||
sendInputToPane(muxName: string, paneTarget: string, input: string): boolean {
|
||||
if (IS_TEST_MODE) return true;
|
||||
@@ -4084,17 +4222,19 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
/**
|
||||
* Plain visible-frame text for the working/idle probe (see `session.ts`).
|
||||
*
|
||||
* One `capture-pane` and nothing else: no `-e` styles, no `display-message`
|
||||
* cursor query, no repaint reconstruction: this feeds a regex, not a
|
||||
* terminal. Returns null in tests (no tmux) so callers fall back to their
|
||||
* stream heuristics rather than reading an empty screen as "not working".
|
||||
* One `capture-pane` and nothing else: no `display-message` cursor query, no
|
||||
* repaint reconstruction: this feeds a regex, not a terminal. `styles` adds
|
||||
* `-e` for a reader that must tell faint text from typed text (the keep-warm
|
||||
* composer check); lines stay `\n`-separated either way. Returns null in tests
|
||||
* (no tmux) so callers fall back to their stream heuristics rather than
|
||||
* reading an empty screen as "not working".
|
||||
*/
|
||||
capturePaneText(muxName: string, paneTarget?: string): string | null {
|
||||
capturePaneText(muxName: string, paneTarget?: string, opts?: { styles?: boolean }): string | null {
|
||||
if (IS_TEST_MODE) return null;
|
||||
const target = resolveTmuxPaneTarget(muxName, paneTarget);
|
||||
if (!target) return null;
|
||||
try {
|
||||
return execSync(`${this.tmux()} capture-pane -p -t ${shellescape(target)}`, {
|
||||
return execSync(`${this.tmux()} capture-pane -p${opts?.styles ? ' -e' : ''} -t ${shellescape(target)}`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @fileoverview Keep tmux's prefix table on Codeman's socket down to `send-prefix`.
|
||||
*
|
||||
* Codeman drives tmux through commands, never through key bindings, but every
|
||||
* keystroke typed into a browser terminal reaches the `tmux attach-session`
|
||||
* client behind it. With tmux's stock prefix table that client obeys the whole
|
||||
* menu: Ctrl+B then `(`, `)`, `L`, `s` or `w` runs `switch-client` /
|
||||
* `choose-tree` and re-attaches the tab's client to a DIFFERENT session, so the
|
||||
* tab shows, and types into, another session's agent while Codeman still labels
|
||||
* it as the original. Ctrl+B `d` detaches the client, `$` renames the session
|
||||
* under Codeman's feet, `:` opens tmux's command prompt. Ctrl+B is not a rare
|
||||
* key in a terminal (readline backward-char, Claude Code's background chord),
|
||||
* and whatever is typed next lands in that menu.
|
||||
*
|
||||
* The prefix itself stays: Claude Code running inside tmux asks for
|
||||
* "ctrl+b ctrl+b" to background a command, which only works through
|
||||
* `send-prefix`. So the table keeps exactly that one binding, on whatever key
|
||||
* the socket's prefix is (the socket reads the user's `~/.tmux.conf`).
|
||||
*
|
||||
* `codeman tui` shares the socket (src/tui/tui-app.ts, beginAttachHandoff). For
|
||||
* the length of an attach it claims a held-Ctrl detach alias (`C-d`) in the
|
||||
* prefix table, and its one-key way out (F1) and Alt+1..9 in the `root` table.
|
||||
* `root` is never touched here, and a `C-<letter> detach-client` binding is left
|
||||
* alone (and put back when a reset clears the rest of the table around it), so
|
||||
* creating a session while a TUI attach is open does not take the attach's way
|
||||
* out away. The reset only runs while the table holds anything else, i.e. once
|
||||
* per tmux server in practice.
|
||||
*
|
||||
* That tolerance is server-wide like every key table: while such an alias is
|
||||
* bound, a browser tab's client can detach itself with it too. The same goes
|
||||
* for the `root` keys an attach claims, including Alt+1..9, which run
|
||||
* `switch-client`: a tab that receives Esc then a digit within tmux's
|
||||
* `escape-time` is moved onto another session for as long as those bindings
|
||||
* exist. Leaving `root` alone keeps the attach working; it does not close that.
|
||||
*
|
||||
* @module tmux-prefix-table
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import { shellescape } from './remote-hosts.js';
|
||||
|
||||
/** tmux's stock prefix, used when `show-options` cannot be read. */
|
||||
const DEFAULT_PREFIX = 'C-b';
|
||||
|
||||
/** One `list-keys -T prefix` line: key and command. */
|
||||
const PREFIX_LINE = /^bind-key\s+(?:-\S+\s+)*?-T\s+prefix\s+(\S+)\s+(.+)$/;
|
||||
|
||||
/** The held-Ctrl detach alias `codeman tui` claims during an attach (heldCtrlAlias). */
|
||||
const TUI_DETACH_ALIAS = /^C-[a-z]$/;
|
||||
|
||||
/**
|
||||
* A key as `list-keys` prints it, without tmux's backslash escaping: `show-options`
|
||||
* prints the prefix `C-\` as is, while the listing shows `C-\\` (and `\#`, `\$`, `\"`).
|
||||
*/
|
||||
function unescapeListedKey(key: string): string {
|
||||
return key.replace(/\\(.)/g, '$1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the prefix table (as printed by `tmux list-keys -T prefix`) still holds
|
||||
* a binding other than `<prefix> send-prefix` or a TUI detach alias, or lacks
|
||||
* `<prefix> send-prefix`. Unparseable output counts as "needs a reset":
|
||||
* resetting is idempotent, skipping one leaves `switch-client` reachable from
|
||||
* the browser. A prefix of `None` expects no `send-prefix` binding at all.
|
||||
*/
|
||||
export function prefixTableNeedsReset(listKeysOutput: string, prefix: string = DEFAULT_PREFIX): boolean {
|
||||
let hasSendPrefix = false;
|
||||
for (const raw of listKeysOutput.split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const match = PREFIX_LINE.exec(line);
|
||||
if (!match) return true;
|
||||
const key = unescapeListedKey(match[1]);
|
||||
const command = match[2].trim();
|
||||
if (command === 'send-prefix' && key === prefix) {
|
||||
hasSendPrefix = true;
|
||||
continue;
|
||||
}
|
||||
if (command === 'detach-client' && TUI_DETACH_ALIAS.test(key) && key !== prefix) continue;
|
||||
return true;
|
||||
}
|
||||
return prefix === 'None' ? false : !hasSendPrefix;
|
||||
}
|
||||
|
||||
/** The `C-<letter> detach-client` bindings in a `list-keys -T prefix` listing (see prefixTableNeedsReset). */
|
||||
function tuiDetachAliases(listKeysOutput: string, prefix: string): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const raw of listKeysOutput.split('\n')) {
|
||||
const match = PREFIX_LINE.exec(raw.trim());
|
||||
if (!match) continue;
|
||||
const key = unescapeListedKey(match[1]);
|
||||
if (match[2].trim() === 'detach-client' && TUI_DETACH_ALIAS.test(key) && key !== prefix) keys.push(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** Runs one tmux command line and returns its stdout. */
|
||||
export type TmuxRunner = (command: string) => string;
|
||||
|
||||
const defaultRunner: TmuxRunner = (command) =>
|
||||
execSync(command, { encoding: 'utf8', timeout: EXEC_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
|
||||
/**
|
||||
* Reduce the prefix table of the tmux server behind `tmux` (e.g. `tmux -L 'codeman'`)
|
||||
* to `<prefix> send-prefix`, when it holds anything more. Never throws: a failure
|
||||
* only leaves tmux's stock bindings in place. Returns whether a reset ran.
|
||||
*/
|
||||
export function restrictTmuxPrefixTable(tmux: string, run: TmuxRunner = defaultRunner): boolean {
|
||||
let prefix = DEFAULT_PREFIX;
|
||||
try {
|
||||
prefix = run(`${tmux} show-options -gv prefix`).trim() || DEFAULT_PREFIX;
|
||||
} catch {
|
||||
/* keep tmux's stock prefix */
|
||||
}
|
||||
let listing: string | null;
|
||||
try {
|
||||
listing = run(`${tmux} list-keys -T prefix`);
|
||||
} catch {
|
||||
listing = null; // an empty table makes list-keys fail on some tmux builds
|
||||
}
|
||||
if (listing !== null && !prefixTableNeedsReset(listing, prefix)) return false;
|
||||
try {
|
||||
run(`${tmux} unbind-key -a -T prefix`);
|
||||
} catch {
|
||||
/* an empty table makes unbind-key -a fail too ("table prefix doesn't exist"); nothing to clear */
|
||||
}
|
||||
try {
|
||||
if (prefix !== 'None') run(`${tmux} bind-key -T prefix ${shellescape(prefix)} send-prefix`);
|
||||
// `unbind-key -a` also took a running TUI attach's held-Ctrl way out; give it back.
|
||||
for (const key of tuiDetachAliases(listing ?? '', prefix)) {
|
||||
run(`${tmux} bind-key -T prefix ${shellescape(key)} detach-client`);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+22
-9
@@ -317,6 +317,18 @@ export function detachChord(prefix?: string, key?: string): string {
|
||||
return `${formatPrefixKey(prefix)} then ${key || DEFAULT_DETACH_KEY}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chord an attach advertises when it could not claim the one-key way out.
|
||||
*
|
||||
* With no key bound to `detach-client` in the prefix table there is no stock
|
||||
* `d` to name: Codeman keeps only `send-prefix` there (src/tmux-prefix-table.ts),
|
||||
* so the held-Ctrl alias the attach claimed is the chord that works.
|
||||
*/
|
||||
export function fallbackDetachChord(prefix?: string, detachKey?: string, heldAlias?: string): string {
|
||||
if (!detachKey && heldAlias) return detachChord(prefix, formatPrefixKey(heldAlias));
|
||||
return detachChord(prefix, detachKey);
|
||||
}
|
||||
|
||||
/** `#` opens `#[…]`/`#{…}` in a tmux format, so a name carrying one must double it. */
|
||||
function escapeTmuxFormat(value: string): string {
|
||||
return value.replace(/#/g, '##');
|
||||
@@ -355,10 +367,10 @@ export function buildAttachBanner(options: {
|
||||
/** Alt+1..9 really switch sessions, so the bar may say so. */
|
||||
switchKeys?: boolean;
|
||||
}): Record<string, string> {
|
||||
const chord = escapeTmuxFormat(detachChord(options.prefix, options.detachKey));
|
||||
const chord = escapeTmuxFormat(fallbackDetachChord(options.prefix, options.detachKey, options.heldAlias));
|
||||
// Named on the bar because it is what people actually type: keeping Ctrl held
|
||||
// is the common way to press this, and the bar has to say that it works.
|
||||
const held = options.heldAlias ? escapeTmuxFormat(formatPrefixKey(options.heldAlias)) : '';
|
||||
const held = options.heldAlias && options.detachKey ? escapeTmuxFormat(formatPrefixKey(options.heldAlias)) : '';
|
||||
const alias = held ? ` (or ${held})` : '';
|
||||
const label = escapeTmuxFormat(truncateLabel((options.label ?? '').trim(), ATTACH_BANNER_LABEL_MAX));
|
||||
// ONE option, not `status-left`/`status-right`/`status-style`: `status-format[0]`
|
||||
@@ -624,7 +636,7 @@ export async function beginAttachHandoff(
|
||||
if (target === muxName) await client.followAttachingClient(target);
|
||||
}
|
||||
return {
|
||||
chord: oneKey ? ONE_KEY_DETACH : detachChord(prefix, detachKey),
|
||||
chord: oneKey ? ONE_KEY_DETACH : fallbackDetachChord(prefix, detachKey, claimed && alias ? alias : undefined),
|
||||
async restore(): Promise<void> {
|
||||
for (const key of switchKeys) await client.unbindSwitchKey(key);
|
||||
if (oneKey) await client.unbindDetachKey(ONE_KEY_DETACH, 'root');
|
||||
@@ -702,7 +714,7 @@ export interface TuiKeymapContext {
|
||||
/** False in degraded mode, where the only verb that works is attach. */
|
||||
server: boolean;
|
||||
approval?: TuiApprovalKeys;
|
||||
/** tmux's detach chord as this socket reports it. Defaults to the stock `Ctrl+B D`. */
|
||||
/** How the last attach said to leave it. Defaults to F1, the key an attach claims. */
|
||||
detach?: string;
|
||||
/** A dead-row card is offering `r` to resume, so the footer has to say so. */
|
||||
resumeOffer?: boolean;
|
||||
@@ -761,7 +773,7 @@ export function helpKeysFor(glyphs: TuiGlyphSet, context: TuiKeymapContext): Arr
|
||||
['tab', 'next session'],
|
||||
// The one key that is not the TUI's: an attach hands the terminal to tmux,
|
||||
// and leaving it is the question every first attach asks.
|
||||
[context.detach ?? detachChord(), 'detach from an attached session, back to here'],
|
||||
[context.detach ?? ONE_KEY_DETACH, 'detach from an attached session, back to here'],
|
||||
];
|
||||
if (context.server) {
|
||||
keys.push(
|
||||
@@ -1109,11 +1121,12 @@ class TuiApp {
|
||||
private readonly glyphs: TuiGlyphSet;
|
||||
private readonly socket = resolveTmuxSocketName();
|
||||
/**
|
||||
* How to leave an attach, in the local tmux's own prefix. Read per attach
|
||||
* (a session can override the prefix) and remembered so the help overlay
|
||||
* names the real chord even before the first attach.
|
||||
* How to leave an attach. Read per attach (F1 may be the user's, a session
|
||||
* can override the prefix) and remembered for the help overlay; until the
|
||||
* first attach it names F1, what an attach claims unless the user's config
|
||||
* already holds it.
|
||||
*/
|
||||
private detachChordLabel = detachChord();
|
||||
private detachChordLabel: string = ONE_KEY_DETACH;
|
||||
/** True for the length of one resume. The only thing standing between a resume and a loop. */
|
||||
private resuming = false;
|
||||
|
||||
|
||||
@@ -704,8 +704,9 @@ export class TuiClient {
|
||||
*
|
||||
* Two hard rules from CLAUDE.md, both enforced here so no caller can get them
|
||||
* wrong: the payload must END with `\r` or the server never issues Enter and
|
||||
* the text sits unsubmitted, and embedded newlines are stripped rather than
|
||||
* sent (multi-line input breaks Ink, and the server would join the lines).
|
||||
* the text sits unsubmitted, and embedded newlines become spaces rather than
|
||||
* being sent (the server would paste them as one block, which its submit
|
||||
* check cannot reliably recognise in the composer).
|
||||
* The `clientId`/`seq` pair makes delivery exactly-once, so a retry after a
|
||||
* dropped connection cannot type the prompt twice.
|
||||
*/
|
||||
|
||||
@@ -121,6 +121,12 @@ export interface AppState {
|
||||
sessionOrder?: string[];
|
||||
/** Owner-scoped authoritative grouped tab layouts. */
|
||||
tabLayouts?: Record<string, TabLayout>;
|
||||
/**
|
||||
* Per-case default session group, keyed by the tab-layout owner key, then case
|
||||
* name. A group id from that owner's layout, or null for an intentional
|
||||
* Ungrouped choice. Read and written only through TabLayoutService.
|
||||
*/
|
||||
caseDefaultGroups?: Record<string, Record<string, string | null>>;
|
||||
}
|
||||
|
||||
// ========== Default Configuration ==========
|
||||
|
||||
+41
-1
@@ -797,6 +797,33 @@ export interface PromptCacheStatus {
|
||||
*/
|
||||
export type StatusLineExporter = 'injected' | 'collection-off' | 'workspace-statusline' | 'remote';
|
||||
|
||||
/**
|
||||
* What the prompt-cache keep-warm (`prompt-cache-keepwarm.ts`) is doing for a session, for
|
||||
* the Session Options readout. Ephemeral: published through `toState()` and the
|
||||
* `session:keepWarm` event, never restored.
|
||||
*/
|
||||
export interface KeepWarmStatus {
|
||||
/** Effective opt-in: the session's own flag, else the app-wide default (see `keepWarmOn`). */
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Whether this launch carries `idleCompaction: false` (`MuxSession.idleCompactionOff`). False
|
||||
* on a session switched on after launch: Claude Code's own idle compaction may still run
|
||||
* until its next launch.
|
||||
*/
|
||||
idleCompactionOff: boolean;
|
||||
/** `armed` = waiting for the due time, `waiting` = due but deferred by a guard, `nudged` = sent, waiting for the next cache report. */
|
||||
state: 'off' | 'armed' | 'waiting' | 'nudged' | 'stopped';
|
||||
/** Next evaluation, epoch ms (armed, waiting, nudged). */
|
||||
nextCheckAt?: number;
|
||||
/** Why it is waiting or stopped, in words. */
|
||||
reason?: string;
|
||||
/** Nudges sent this session so far. */
|
||||
nudges: number;
|
||||
lastNudgeAt?: number;
|
||||
/** When the keep-warm window after the last real prompt closes, epoch ms. */
|
||||
budgetEndsAt?: number;
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
/** Unique session identifier */
|
||||
id: string;
|
||||
@@ -819,7 +846,9 @@ export interface SessionState {
|
||||
*
|
||||
* ⚠️ UI DECORATION ONLY — it draws the lineage lines between tabs. It is never an
|
||||
* ownership, permission, or lifecycle signal: a child outlives its parent, and an
|
||||
* unresolvable value is dropped rather than failing the spawn.
|
||||
* unresolvable value is dropped rather than failing the spawn. Its one non-visual reader
|
||||
* picks a default, nothing more: a session an agent spawned does not inherit the app-wide
|
||||
* prompt-cache keep-warm default (`keepWarmOn`), though its own switch still works.
|
||||
*/
|
||||
parentSessionId?: string;
|
||||
/** ID of currently assigned task, null if none */
|
||||
@@ -858,6 +887,10 @@ export interface SessionState {
|
||||
promptCache?: PromptCacheStatus;
|
||||
/** Why a cache report will or will not arrive (see `StatusLineExporter`); absent when unknown. */
|
||||
statusLineExporter?: StatusLineExporter;
|
||||
/** Prompt-cache keep-warm opt-in; absent = follow the app-wide `promptCacheKeepWarm` default. */
|
||||
keepWarmEnabled?: boolean;
|
||||
/** What the keep-warm is doing right now; ephemeral, never restored. */
|
||||
keepWarm?: KeepWarmStatus;
|
||||
/** Pinned to the top of the session manager list (COD-139) */
|
||||
pinned?: boolean;
|
||||
/** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */
|
||||
@@ -972,6 +1005,13 @@ export interface SessionState {
|
||||
* again until the pane's own Enter is known.
|
||||
*/
|
||||
lastSubmitAt?: number;
|
||||
/**
|
||||
* Wall-clock ms of the last submit that was NOT the prompt-cache keep-alive
|
||||
* (Session.lastRealPromptAt): the keep-warm cap's clock. Persisted so a restart
|
||||
* does not reopen the window, which `lastSubmitAt` cannot do since every nudge
|
||||
* moves it.
|
||||
*/
|
||||
lastRealPromptAt?: number;
|
||||
/**
|
||||
* Claude conversations this pane has been on, oldest first, current last.
|
||||
* Written ONLY from a first-hand `UserPromptSubmit`/`Stop` hook payload —
|
||||
|
||||
+66
-2
@@ -40,12 +40,20 @@ export interface UsageWindow {
|
||||
usedPercentage: number;
|
||||
/** Epoch MILLISECONDS when the window resets (statusline reports seconds). */
|
||||
resetAt: number;
|
||||
/**
|
||||
* Absolute counts, for a window measured in requests rather than as a bare percent (Copilot's
|
||||
* monthly premium-request quota). Absent for the Claude and Codex windows.
|
||||
*/
|
||||
used?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** Normalized telemetry Codeman broadcasts to the UI. */
|
||||
export interface StatusTelemetry {
|
||||
fiveHour?: UsageWindow;
|
||||
sevenDay?: UsageWindow;
|
||||
/** A calendar-month quota (GitHub Copilot's premium requests); not a rolling 5h/7d window. */
|
||||
monthly?: UsageWindow;
|
||||
/** Context-window percent used, 0–100 (bonus field from the same payload). */
|
||||
contextUsedPercentage?: number;
|
||||
/** Session cost in USD (bonus field). */
|
||||
@@ -196,6 +204,51 @@ export function parseCodexRateLimitsResponse(value: unknown): StatusTelemetry |
|
||||
return telemetry.fiveHour || telemetry.sevenDay ? telemetry : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize GitHub Copilot's quota snapshot (`GET /copilot_internal/user`) into the chip's monthly
|
||||
* window. Only `premium_interactions` is a metered quota: `chat` and `completions` report
|
||||
* `unlimited: true` on every plan seen, and an unlimited premium quota has nothing to meter, so it
|
||||
* yields null and the chip shows no Copilot row rather than a bar stuck at 0%.
|
||||
*/
|
||||
export function parseCopilotQuotaResponse(value: unknown): StatusTelemetry | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const raw = value as {
|
||||
quota_snapshots?: {
|
||||
premium_interactions?: {
|
||||
entitlement?: unknown;
|
||||
remaining?: unknown;
|
||||
percent_remaining?: unknown;
|
||||
unlimited?: unknown;
|
||||
};
|
||||
};
|
||||
quota_reset_date_utc?: unknown;
|
||||
quota_reset_date?: unknown;
|
||||
};
|
||||
const snap = raw.quota_snapshots?.premium_interactions;
|
||||
if (!snap || typeof snap !== 'object' || snap.unlimited === true) return null;
|
||||
// A zero (or negative) quota that is not 'unlimited' would show a stuck 0% or 100% row.
|
||||
if (typeof snap.entitlement === 'number' && snap.entitlement <= 0) return null;
|
||||
|
||||
const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
|
||||
const limit = finite(snap.entitlement) && snap.entitlement > 0 ? snap.entitlement : undefined;
|
||||
let usedPct: number | null = null;
|
||||
if (finite(snap.percent_remaining)) usedPct = 100 - snap.percent_remaining;
|
||||
else if (limit !== undefined && finite(snap.remaining)) usedPct = (1 - snap.remaining / limit) * 100;
|
||||
if (usedPct === null) return null;
|
||||
|
||||
const resetRaw = typeof raw.quota_reset_date_utc === 'string' ? raw.quota_reset_date_utc : raw.quota_reset_date;
|
||||
const parsedReset = typeof resetRaw === 'string' ? Date.parse(resetRaw) : NaN;
|
||||
const monthly: UsageWindow = {
|
||||
usedPercentage: clampPct(usedPct),
|
||||
resetAt: Number.isFinite(parsedReset) && parsedReset > 0 ? parsedReset : 0,
|
||||
};
|
||||
if (limit !== undefined && finite(snap.remaining)) {
|
||||
monthly.limit = limit;
|
||||
monthly.used = Math.max(0, Math.round(limit - snap.remaining));
|
||||
}
|
||||
return { monthly };
|
||||
}
|
||||
|
||||
/**
|
||||
* Current-session status for the in-terminal statusline footer. This is the
|
||||
* "status of the current session" the user sees in Claude's footer — distinct
|
||||
@@ -286,10 +339,21 @@ export function formatSessionStatusText(s: SessionStatus | null, now: number = D
|
||||
* localStorage write + identical chip re-render each time.
|
||||
*/
|
||||
export function telemetrySignature(t: StatusTelemetry): string {
|
||||
return JSON.stringify([
|
||||
const parts: Array<number | null> = [
|
||||
t.fiveHour ? Math.round(t.fiveHour.usedPercentage) : null,
|
||||
t.fiveHour?.resetAt ?? null,
|
||||
t.sevenDay ? Math.round(t.sevenDay.usedPercentage) : null,
|
||||
t.sevenDay?.resetAt ?? null,
|
||||
]);
|
||||
];
|
||||
// Appended only when present, so the Claude and Codex signatures are byte-identical to before.
|
||||
// The limit is part of it: a plan change mid-month (300 to 1500 with 100 used) keeps `used` and the reset.
|
||||
if (t.monthly) {
|
||||
parts.push(
|
||||
Math.round(t.monthly.usedPercentage),
|
||||
t.monthly.used ?? null,
|
||||
t.monthly.limit ?? null,
|
||||
t.monthly.resetAt
|
||||
);
|
||||
}
|
||||
return JSON.stringify(parts);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* @fileoverview GitHub Copilot's monthly premium-request quota, for the header plan-usage chip.
|
||||
*
|
||||
* The Copilot CLI has no scriptable usage command (`copilot billing` exists only inside the TUI), but
|
||||
* the same quota snapshot the editors show is `GET https://api.github.com/copilot_internal/user`,
|
||||
* readable with the CLI's own sign-in token. The endpoint is not part of GitHub's documented REST API,
|
||||
* so this is read-only, best-effort and silent: any failure (offline, 401, a changed shape) yields
|
||||
* `null` and the chip simply has no Copilot row.
|
||||
*
|
||||
* ⚠️ The token is never logged, never put in an error and only ever sent to api.github.com over HTTPS
|
||||
* (`redirect: 'error'`, so a redirect cannot carry it elsewhere).
|
||||
*
|
||||
* @module utils/copilot-plan-usage
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { parseCopilotQuotaResponse, type StatusTelemetry } from '../usage-telemetry.js';
|
||||
|
||||
export const COPILOT_QUOTA_URL = 'https://api.github.com/copilot_internal/user';
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** Env names Copilot itself reads for a headless sign-in, in its own precedence order. */
|
||||
const TOKEN_ENV_KEYS = ['COPILOT_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN'] as const;
|
||||
|
||||
/** The CLI writes `// ...` header comments above the JSON in config.json. */
|
||||
function parseCommentedJson(raw: string): unknown {
|
||||
return JSON.parse(
|
||||
raw
|
||||
.split('\n')
|
||||
.filter((line) => !line.trimStart().startsWith('//'))
|
||||
.join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sign-in token Copilot would use: a token in the environment first, then the plain-text
|
||||
* `authTokens` entry the CLI keeps in `<COPILOT_HOME or ~/.copilot>/config.json` when no OS keychain
|
||||
* is available. Only github.com sign-ins are read from the file (the quota URL is github.com's).
|
||||
*/
|
||||
export async function resolveCopilotToken(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
home: string = homedir()
|
||||
): Promise<string | null> {
|
||||
for (const key of TOKEN_ENV_KEYS) {
|
||||
const value = env[key]?.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
const dir = env.COPILOT_HOME?.trim() || join(home, '.copilot');
|
||||
try {
|
||||
const config = parseCommentedJson(await readFile(join(dir, 'config.json'), 'utf8')) as {
|
||||
lastLoggedInUser?: { host?: string; login?: string };
|
||||
authTokens?: Record<string, { token?: unknown } | undefined>;
|
||||
};
|
||||
const { host, login } = config.lastLoggedInUser ?? {};
|
||||
if (host !== 'https://github.com' || !login || !config.authTokens) return null;
|
||||
for (const key of [`${host}:${login}`, `${host}:${login}:github`]) {
|
||||
const token = config.authTokens[key]?.token;
|
||||
if (typeof token === 'string' && token.trim()) return token.trim();
|
||||
}
|
||||
} catch {
|
||||
/* no config, unreadable, or not JSON: no token */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ReadCopilotPlanUsageOptions {
|
||||
/** Defaults to `resolveCopilotToken()`. */
|
||||
token?: string | null;
|
||||
/** Injectable for tests. */
|
||||
fetchFn?: typeof fetch;
|
||||
}
|
||||
|
||||
/** The monthly premium-request window, or null when there is no token, no meter or any failure. */
|
||||
export async function readCopilotPlanUsage(opts: ReadCopilotPlanUsageOptions = {}): Promise<StatusTelemetry | null> {
|
||||
const token = opts.token === undefined ? await resolveCopilotToken() : opts.token;
|
||||
if (!token) return null;
|
||||
const doFetch = opts.fetchFn ?? fetch;
|
||||
try {
|
||||
const response = await doFetch(COPILOT_QUOTA_URL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'codeman',
|
||||
},
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
return parseCopilotQuotaResponse(await response.json());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CopilotPlanUsageGates {
|
||||
/** `showPlanUsageLimits` as the server reads it (`readPlanUsageTelemetryEnabled()`: absent = on). */
|
||||
planUsageEnabled: () => Promise<boolean>;
|
||||
/** `resolveCopilotToken()`: env vars and a file read, nothing spawned. */
|
||||
resolveToken: () => Promise<string | null>;
|
||||
/** `isCopilotAvailable()`: its resolver may run a synchronous login-shell probe. */
|
||||
copilotInstalled: () => boolean;
|
||||
read: (token: string) => Promise<StatusTelemetry | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's poll: Copilot's quota, or null when Plan Usage Limits is off, there is no sign-in
|
||||
* token or the CLI is not installed. Cheapest gate first, so a host with no Copilot sign-in never
|
||||
* runs the CLI resolver on each tick, and nothing at all is read while Plan Usage Limits is off.
|
||||
*/
|
||||
export async function readCopilotPlanUsageIfEnabled(gates: CopilotPlanUsageGates): Promise<StatusTelemetry | null> {
|
||||
if (!(await gates.planUsageEnabled())) return null;
|
||||
const token = await gates.resolveToken();
|
||||
if (!token || !gates.copilotInstalled()) return null;
|
||||
return gates.read(token);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export { MAX_SESSION_TOKENS } from './token-validation.js';
|
||||
export { isSafePushEndpoint } from './push-endpoint-validation.js';
|
||||
export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
|
||||
export { assertNever } from './type-safety.js';
|
||||
export { toSingleLine } from './single-line.js';
|
||||
export { wrapWithNice } from './nice-wrapper.js';
|
||||
export { resolveLocalShell, loginShellArgs } from './shell-resolver.js';
|
||||
export {
|
||||
@@ -75,6 +76,7 @@ export {
|
||||
getCopilotNotFoundMessage,
|
||||
getCopilotCliVersion,
|
||||
} from './copilot-cli-resolver.js';
|
||||
export { readCopilotPlanUsage, resolveCopilotToken } from './copilot-plan-usage.js';
|
||||
export {
|
||||
boundedPathExists,
|
||||
describeUnknownPath,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @fileoverview Collapse a prompt onto the one line an unattended sender writes.
|
||||
*
|
||||
* @module utils/single-line
|
||||
*/
|
||||
|
||||
/**
|
||||
* A prompt as one line: each line is trimmed, blank lines are dropped, and the rest
|
||||
* are joined with a single space; a prompt without a line break is returned as is.
|
||||
*
|
||||
* For the senders that run unattended through `writeViaMux` (respawn's update and
|
||||
* kickstart steps, auto-compact). The mux pastes a multi-line prompt as one block,
|
||||
* and the check that re-presses a stranded Enter cannot reliably recognise a
|
||||
* multi-line paste in the composer, so nobody would notice a prompt left unsubmitted.
|
||||
* Cron rejects multi-line prompts for the same reason; these senders join them
|
||||
* instead, so a saved multi-line setting is still sent rather than refused.
|
||||
*
|
||||
* Split and trim, which is linear, rather than one regex replace that folds the
|
||||
* whitespace around each break: that regex backtracks quadratically over a long
|
||||
* whitespace run (50,000 spaces took over a second).
|
||||
*/
|
||||
export function toSingleLine(prompt: string): string {
|
||||
if (!/[\r\n]/.test(prompt)) return prompt;
|
||||
return prompt
|
||||
.split(/[\r\n]+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
+3
-3
@@ -14,7 +14,7 @@
|
||||
|
||||
**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, `getDefaultSettings()`; a touchscreen laptop keeps ON), desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-browser persistence (`codeman:tile-grid`, localStorage only: cells with `null` holes, `count`, focus, user zoom, divider fractions, never content; written on every change, kept by EVERY close, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js): the stored grid EXACTLY as the user left it (owner decision 11: `restoreTileGridCells`, never filled to the count nor trimmed to the window; a cell whose session is gone since is filled from the ranking, a hole the user made stays), else an open split's two, else the open sessions as `rankTileSessions` orders them (working first, keyed off `lastSubmitAt` ONLY, then needing input, then most recent; `_mobileOverviewState`'s states), the active one focused, filled to the remembered count (`codeman:tile-count`, default 6, at most `_tileGridLimit()`) by `tileGridSetForCount`, never a menu. ⚠️ `removeTile(..., { gone: true })` (a session deleted, popped out or refused) keeps the stored `count` so the next open refills that cell; a removal by hand lowers it. Right-click is the 2 / 4 / 6 count menu (`openTileCountMenu`, owner decision 10), which owns its Escape. The button has no native title: its hover card (`_installTileGridHint`, `#tileGridHint`) says it, is its `aria-describedby` (always present, hidden, kept current) and hides in the capture phase on any press, click or right-click, so the menu never opens beside it. ⚠️ The open, the count menu and the toggle's close animate opacity and transform only (the close leaves an inert cloned still copy until the single view's selection settles, at most 700 ms); nothing moves under `prefers-reduced-motion`. ⚠️ Opening builds one tile terminal per frame (`_connectTilesPaced`), so `openTileGrid` returns before the terminals exist: focus is handed over in `_connectTile` (`focusOnConnect`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): `grid.cells` (id or `null`) is the one source of truth, `grid.ids` a derived getter, never written; the shape still comes from the tile count, a shape change goes through `fitTileCells`, and the stored `ids` carry the cells with `null` holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. SuchLine truncated
|
||||
|
||||
**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](../../docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection)
|
||||
**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. ⚠️ A sideways drag belongs to the session swipe (`SwipeHandler`, mobile-handlers.js): the touch handler locks the dominant axis on the first travel past the tap slop (`panAxis`: `y` scrolls, `x` pans a terminal wider than its box, `swipe` otherwise) and publishes it as `app._terminalGestureAxis`; a `swipe` drag neither scrolls nor ends as a tap on the session being left until it passes the swipe's own vertical drift limit (`SwipeHandler.maxVerticalDrift`), where it is handed back to the scroll as `y` (a scroll that opens on a thumb arc), and the swipe stands aside for `x`/`y`, a touch selection, a second finger and surfaces with controls of their own (`IGNORE_SELECTOR`; the phone overview and welcome screen are deliberately not listed). A swipe is 80 px within 500 ms with at most 50 px of vertical drift at any point; a touch starting outside the terminal (the home screen) resets the published axis, and a lift the terminal consumed (`defaultPrevented`, its selection gesture) is never a swipe (`test/session-swipe-gesture.test.ts`). Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](../../docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection)
|
||||
|
||||
**Auto Copy (copy-on-select)** (`autoCopySelection`, per-device, default OFF): a finished terminal selection lands on the clipboard with no keystroke. ⚠️ Copy at the END of a gesture, never in `onSelectionChange` (per-cell); it only arms `_autoCopyPending` and a document-level `mouseup` flushes. ⚠️ The flush must be SYNCHRONOUS in the handler (both clipboard paths need user activation); never defer it to a timer. ⚠️ Touch needs its own calls from `_endTouchSelectionGesture()`/`_selectTouchSelectionLine()` (no mouseup arrives). ⚠️ Unlike `copyTerminalSelection()`, never clear the selection or focus the terminal; restore prior focus. Guards are pure in `decideAutoCopy()` (constants.js, 1M-char cap, refused not truncated). Tests: `test/terminal-auto-copy.test.ts`. → [architecture-invariants#auto-copy-copy-on-select](../../docs/architecture-invariants.md#auto-copy-copy-on-select)
|
||||
|
||||
@@ -70,13 +70,13 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Shell keyboard accessory bar + one-shot Ctrl** (`keyboard-accessory.js`): a shell-mode session swaps the mobile accessory bar for terminal controls; `setMode()` records `extendedKeyboardBar` as the base layout and `refreshForActiveSession()` resolves base-vs-shell. ⚠️ Ctrl is a one-shot modifier applied in `terminal.onData` (after `shouldSuppressTerminalQueryResponse`, before every send path), and must skip `isTerminalFocusOrMouseReport()` chunks. ⚠️ It must disarm on use, second tap, any other accessory key, session switch, keyboard dismissal and layout swap. ⚠️ `_handleCjkInput()` must apply it too (the CJK textarea bypasses onData). Mapping: `ctrlByteFor()`. ⚠️ mobile.css's light-skin repaint must keep excluding `.accessory-btn:not(.armed)` or the armed state is invisible. → [architecture-invariants#shell-keyboard-accessory-bar-and-one-shot-ctrl](../../docs/architecture-invariants.md#shell-keyboard-accessory-bar-and-one-shot-ctrl)
|
||||
|
||||
**Mobile prompt composer** (`keyboard-accessory.js`): the agent bars' Paste key is **Compose**, a native multiline dialog where only **Send** submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (`_takePendingLocalEcho`), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (`_composerDrafts`), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via `_sendInputAsync` WITHOUT `useMux`, then a separate delayed Enter WITH it; never `terminal.paste()`, and the frame must never take the mux fallback (it strips newlines). ⚠️ `_composerMaxLength` must stay derived from `MAX_INPUT_LENGTH` minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. Test: `test/mobile-prompt-composer.test.ts`. → [architecture-invariants#mobile-prompt-composer](../../docs/architecture-invariants.md#mobile-prompt-composer)
|
||||
**Mobile prompt composer** (`keyboard-accessory.js`): the agent bars' Paste key is **Compose**, a native multiline dialog where only **Send** submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (`_takePendingLocalEcho`), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (`_composerDrafts`), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via `_sendInputAsync` WITHOUT `useMux`, then a separate delayed Enter WITH it; never `terminal.paste()`, and the frame must never take the mux fallback (`TmuxManager.sendInput()` strips every `\r`, the frame's line separator, so the lines weld into one inside a second pair of markers and get submitted). ⚠️ `_composerMaxLength` must stay derived from `MAX_INPUT_LENGTH` minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. Test: `test/mobile-prompt-composer.test.ts`. → [architecture-invariants#mobile-prompt-composer](../../docs/architecture-invariants.md#mobile-prompt-composer)
|
||||
|
||||
**Terminal resilience**: a replay clear is the queued in-stream `\x1bc` in `_resetTerminalForReplay()`, never `reset()`/`clear()` (queued bytes fuse into the snapshot); the renderer watchdog `_kickRenderer()` reads xterm privates, pinned by `test/xterm-private-api.test.ts` against the resolved lockfile version; every terminal capture fetch has a deadline that covers the BODY (`_fetchTerminalCapture`). → [architecture-invariants#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines](../../docs/architecture-invariants.md#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines)
|
||||
|
||||
**WebSocket output-gap reconcile** (`_wsOutputGapSession`, app.js): output frames carry no sequence number, so an unintentional WS close while SSE stays up marks the session and the next open reconciles. ⚠️ The marker is cleared only once a repaint actually happened (`_markTerminalBufferReconciled()`, never in a `finally`). → [architecture-invariants#websocket-output-gap-reconcile](../../docs/architecture-invariants.md#websocket-output-gap-reconcile)
|
||||
|
||||
**Dismissing the on-screen keyboard** (`terminal-ui.js`): two gestures blur the terminal's hidden textarea. (1) `_installMobileKeyboardDismiss()`, a document `touchend` that must never fire inside `#terminalContainer` or on a control (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, via `closest()`; roving-tabindex items sit at `tabindex=-1`, so the grouped rail's are listed as `[role="treeitem"]`). (2) In `_handleMobileTerminalTap`, a second tap on inert `content` blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in `touchend`: both classifiers must share one threshold (`TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`), and multi-touch is never a tap. ⚠️ CI cannot see the only test for (1): run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. → [architecture-invariants#dismissing-the-on-screen-keyboard](../../docs/architecture-invariants.md#dismissing-the-on-screen-keyboard)
|
||||
**Dismissing the on-screen keyboard** (`terminal-ui.js`): two gestures blur the terminal's hidden textarea. (1) `_installMobileKeyboardDismiss()`, a document `touchend` that must never fire inside `#terminalContainer` or on a control (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, via `closest()`; roving-tabindex items sit at `tabindex=-1`, so the grouped rail's are listed as `[role="treeitem"]`). (2) In `_handleMobileTerminalTap`, a second tap on inert `content` blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in `touchend`: both classifiers must share one threshold (`TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`), and multi-touch is never a tap. ⚠️ An accessory key that keeps the keyboard up (`ACCESSORY_REFOCUS_ACTIONS`, keyboard-accessory.js) cancels its `mousedown` default, so the press does not blur the terminal; the click-time `terminal.focus()` stays as the fallback. Letting the tap blur and refocusing afterwards made every such key start a dismissal, a blur/focus pair per tap. A first tap on clear/compact only arms the confirm and is treated the same (`test/accessory-keeps-terminal-focus.test.ts`). ⚠️ CI cannot see the only test for (1): run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. → [architecture-invariants#dismissing-the-on-screen-keyboard](../../docs/architecture-invariants.md#dismissing-the-on-screen-keyboard)
|
||||
|
||||
**Phone toolbar: Enter replaces Shell** (post-1.8.0): inside `@media (max-width: 599px)` `btn-shell` is `display:none` and `btn-enter` takes its slot (`order: 4`); starting a shell moved into the Run dropdown (`Terminal / Shell` → `setRunMode('shell')` → `run()` → `runShell()`, button label "Run SH"). `runMode` is `z.string().max(20)` server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged.
|
||||
|
||||
|
||||
@@ -123,6 +123,12 @@ interface NotePromptArgs {
|
||||
* `ApprovalItem.answerInTerminal`.
|
||||
*/
|
||||
answerInTerminal?: boolean;
|
||||
/**
|
||||
* An idle prompt that should open ALREADY acknowledged for a reason other than
|
||||
* `watching`, e.g. `cache keep-alive` when the turn that just ended was Codeman's own
|
||||
* prompt-cache nudge. Same consequence as `watching`: the item exists, only its alert is spent.
|
||||
*/
|
||||
preAcknowledged?: string;
|
||||
/** Returns the raw (ANSI-bearing) pane frame, or null when unavailable. */
|
||||
capture?: () => string | null;
|
||||
/**
|
||||
@@ -285,9 +291,9 @@ export class ApprovalInbox {
|
||||
cwd: args.cwd,
|
||||
...(args.answerInTerminal ? { answerInTerminal: true } : {}),
|
||||
};
|
||||
if (args.kind === 'idle' && args.watching) {
|
||||
if (args.kind === 'idle' && (args.preAcknowledged || args.watching)) {
|
||||
item.acknowledgedAt = item.createdAt;
|
||||
item.acknowledgedReason = `watching ${args.watching}`;
|
||||
item.acknowledgedReason = args.preAcknowledged ?? `watching ${args.watching}`;
|
||||
}
|
||||
if (!args.capture && args.context) item.context = normalizeCapturedFrame(args.context);
|
||||
this.applyCapture(item, args.capture);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @fileoverview Process-wide last-known plan-usage telemetry (account-global).
|
||||
*
|
||||
* The Claude status-telemetry route and host Codex poll merge their latest
|
||||
* The Claude status-telemetry route and the host Codex and Copilot polls merge their latest
|
||||
* values here. The SSE init snapshot (`getLightState`) replays the combined
|
||||
* value so the header "Plan Usage Limits" chip shows immediately on a fresh
|
||||
* page load / SSE reconnect — before either source emits another sample, and
|
||||
@@ -15,8 +15,14 @@
|
||||
let latest: Record<string, unknown> | null = null;
|
||||
|
||||
export function setLatestPlanUsage(value: Record<string, unknown>): Record<string, unknown> {
|
||||
// The Claude status route replaces the Claude part; the host-polled providers ride along.
|
||||
const codex = latest?.codex;
|
||||
latest = { ...value, ...(codex !== undefined ? { codex } : {}) };
|
||||
const copilot = latest?.copilot;
|
||||
latest = {
|
||||
...value,
|
||||
...(codex !== undefined ? { codex } : {}),
|
||||
...(copilot !== undefined ? { copilot } : {}),
|
||||
};
|
||||
return latest;
|
||||
}
|
||||
|
||||
@@ -25,6 +31,11 @@ export function setLatestCodexPlanUsage(value: object | null): Record<string, un
|
||||
return latest;
|
||||
}
|
||||
|
||||
export function setLatestCopilotPlanUsage(value: object | null): Record<string, unknown> {
|
||||
latest = { ...(latest ?? {}), copilot: value };
|
||||
return latest;
|
||||
}
|
||||
|
||||
export function getLatestPlanUsage(): Record<string, unknown> | null {
|
||||
return latest;
|
||||
}
|
||||
@@ -15,3 +15,4 @@ export type { AuthPort } from './auth-port.js';
|
||||
export type { OrchestratorPort } from './orchestrator-port.js';
|
||||
export type { CronPort } from './cron-port.js';
|
||||
export type { TabLayoutPort } from './tab-layout-port.js';
|
||||
export type { KeepWarmPort } from './keep-warm-port.js';
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* @fileoverview Port for the prompt-cache keep-warm scheduler (`prompt-cache-keepwarm.ts`).
|
||||
* Routes only ever trigger a re-evaluation; the rulebook and the timers stay in the service.
|
||||
*/
|
||||
|
||||
import type { KeepWarmScheduler } from '../../prompt-cache-keepwarm.js';
|
||||
|
||||
export interface KeepWarmPort {
|
||||
readonly keepWarm: KeepWarmScheduler;
|
||||
}
|
||||
@@ -13,7 +13,9 @@ export interface SessionPort {
|
||||
/**
|
||||
* 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
|
||||
* group is not in the owner's layout). Resolves to the committed layout.
|
||||
* group is not in the owner's layout); without one (and without a parent that
|
||||
* places it), `placement.caseName`'s default session group does the same.
|
||||
* Resolves to the committed layout.
|
||||
*/
|
||||
addSession(session: Session, placement?: SessionPlacementHint): Promise<TabLayout>;
|
||||
cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise<void>;
|
||||
|
||||
+80
-12
@@ -194,6 +194,7 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'],
|
||||
[SSE_EVENTS.SESSION_STATUS_TELEMETRY, '_onSessionStatusTelemetry'],
|
||||
[SSE_EVENTS.SESSION_PROMPT_CACHE, '_onSessionPromptCache'],
|
||||
[SSE_EVENTS.SESSION_KEEP_WARM, '_onSessionKeepWarm'],
|
||||
|
||||
// Scheduled runs
|
||||
[SSE_EVENTS.SCHEDULED_CREATED, '_onScheduledCreated'],
|
||||
@@ -685,6 +686,14 @@ const DEFAULT_SHORTCUTS = [
|
||||
label: 'Close Panels',
|
||||
displayBindings: ['Escape'],
|
||||
},
|
||||
// Handled by the File Viewer's own capture-phase listener while it is open
|
||||
// (_bindFilePreviewChrome, panels-ui.js), never through the registry.
|
||||
{
|
||||
id: 'file-preview-history',
|
||||
group: 'Panels',
|
||||
label: 'File Viewer Back / Forward',
|
||||
displayBindings: ['Alt/Option+ArrowLeft', 'Alt/Option+ArrowRight'],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -2959,6 +2968,12 @@ class CodemanApp {
|
||||
* output. Subtrees already inside an `<a>` are skipped so an autolinked URL
|
||||
* is never re-cut, and the anchor's textContent is the path verbatim, so
|
||||
* "copy code" still yields exactly what the agent printed.
|
||||
*
|
||||
* Before that walk it also rewrites an existing anchor whose href is a path
|
||||
* (no scheme, not `//host`, not a bare `#fragment`) into the same `a.rv-path`
|
||||
* shape, on purpose WITHOUT `FILE_PATH_LINK_PATTERN`'s root and extension
|
||||
* filter: the agent chose that href as a link target, and left alone it opens
|
||||
* as a same-origin URL (a 404 page in a new tab).
|
||||
*/
|
||||
_linkifyFilePaths(root) {
|
||||
if (!root || typeof document === 'undefined') return;
|
||||
@@ -2966,6 +2981,36 @@ class CodemanApp {
|
||||
// out of the middle of rendering a message.
|
||||
if (typeof findFilePathLinks !== 'function') return;
|
||||
|
||||
// A markdown link whose destination is a path (`[report](/home/me/run/x.json)`,
|
||||
// Codex's usual way of citing a file) resolved against Codeman's own origin
|
||||
// and opened a 404 in a new tab. Give it the linkified-path shape instead;
|
||||
// links with a scheme or `//host` keep their own behaviour.
|
||||
for (const a of root.querySelectorAll('a[href]')) {
|
||||
// The URL parser drops tabs and newlines, so `http	://` is still http.
|
||||
const href = (a.getAttribute('href') || '').replace(/[\t\n\r]/g, '');
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//')) continue;
|
||||
// `#L42`, `?plain=1` and a `:42:7` suffix name a spot in the file, not the
|
||||
// file. The suffix is cut before decoding, so an encoded `%3A` stays a name.
|
||||
let path = href.split('#')[0].split('?')[0].replace(/:\d+(?::\d+)?$/, '');
|
||||
try {
|
||||
path = decodeURIComponent(path);
|
||||
} catch {
|
||||
/* malformed escape: keep the path as written */
|
||||
}
|
||||
// A bare `#fragment` (and every `.rv-path`, whose href is `#`) ends up empty.
|
||||
if (!path) continue;
|
||||
// rv-path-link keeps the label in the prose font (styles.css): it is a label,
|
||||
// not a path, and break-all would split it mid-word on a phone.
|
||||
a.className = 'rv-path rv-path-link';
|
||||
a.dataset.path = path;
|
||||
a.dataset.linkified = '1';
|
||||
if (path.endsWith('/') || findFilePathLinks(path).some((f) => f.folder && f.path === path)) a.dataset.folder = '1';
|
||||
a.title = path;
|
||||
a.setAttribute('href', '#');
|
||||
a.removeAttribute('target');
|
||||
a.removeAttribute('rel');
|
||||
}
|
||||
|
||||
// Collect first: replacing a node while the walker is positioned on it
|
||||
// invalidates the traversal.
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
@@ -3469,8 +3514,10 @@ class CodemanApp {
|
||||
this.sendPendingCtrlL(data.id);
|
||||
if (data.id === this.activeSessionId) this._updateLocalEchoState();
|
||||
}
|
||||
// Start stuck detection timer (only if no respawn running)
|
||||
if (!this.respawnStatus[data.id]?.enabled) {
|
||||
// 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.
|
||||
if (!data.keepAlive && !this.respawnStatus[data.id]?.enabled) {
|
||||
const threshold = this.notificationManager?.preferences?.stuckThresholdMs || 600000;
|
||||
clearTimeout(this.idleTimers.get(data.id));
|
||||
this.idleTimers.set(data.id, setTimeout(() => {
|
||||
@@ -3492,8 +3539,9 @@ class CodemanApp {
|
||||
this.sendPendingCtrlL(data.id);
|
||||
if (data.id === this.activeSessionId) this._updateLocalEchoState();
|
||||
}
|
||||
// Clear stuck detection timer
|
||||
const timer = this.idleTimers.get(data.id);
|
||||
// Clear stuck detection timer, unless this is Codeman's own keep-alive turn, which is
|
||||
// no sign of life from the agent's side.
|
||||
const timer = data.keepAlive ? null : this.idleTimers.get(data.id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.idleTimers.delete(data.id);
|
||||
@@ -3557,7 +3605,7 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Claude + Codex plan usage limits — account-global, so the latest sample
|
||||
// Claude, Codex and Copilot plan usage limits — account-global, so the latest sample
|
||||
// drives the shared header chip.
|
||||
_onSessionStatusTelemetry(data) {
|
||||
this.updatePlanUsageChip(data);
|
||||
@@ -3617,19 +3665,27 @@ class CodemanApp {
|
||||
};
|
||||
// The provider label only earns its space when there is more than one
|
||||
// provider to tell apart: a machine with Claude alone shows bare windows.
|
||||
const hasWindows = (usage) => pct(usage?.fiveHour) !== null || pct(usage?.sevenDay) !== null;
|
||||
const labelled = hasWindows(data) && hasWindows(data.codex);
|
||||
const hasWindows = (usage) =>
|
||||
pct(usage?.fiveHour) !== null || pct(usage?.sevenDay) !== null || pct(usage?.monthly) !== null;
|
||||
const labelled = [data, data.codex, data.copilot].filter(hasWindows).length > 1;
|
||||
const row = (provider, usage, idle) => {
|
||||
// hasWindows() gates the row, so a placeholder can only ever appear
|
||||
// ALONGSIDE a real reading — a provider reporting nothing still renders
|
||||
// nothing, never a row of em dashes.
|
||||
if (!hasWindows(usage)) return '';
|
||||
const windows = [seg('5h', pct(usage?.fiveHour), idle), seg('7d', pct(usage?.sevenDay), idle)].filter(Boolean);
|
||||
// `mo` is Copilot's calendar-month premium-request quota; it is the only window that provider has.
|
||||
const windows = [
|
||||
seg('5h', pct(usage?.fiveHour), idle),
|
||||
seg('7d', pct(usage?.sevenDay), idle),
|
||||
seg('mo', pct(usage?.monthly), false),
|
||||
].filter(Boolean);
|
||||
if (!windows.length) return '';
|
||||
const label = labelled ? `<span class="pu-provider">${provider}</span>` : '';
|
||||
return `<span class="pu-row">${label}<span class="pu-windows">${windows.join('<span class="pu-sep">·</span>')}</span></span>`;
|
||||
};
|
||||
const rows = [row('Claude', data, true), row('Codex', data.codex, false)].filter(Boolean);
|
||||
const rows = [row('Claude', data, true), row('Codex', data.codex, false), row('Copilot', data.copilot, false)].filter(
|
||||
Boolean
|
||||
);
|
||||
chip.innerHTML = rows.length ? rows.join('') : '—';
|
||||
const resetStr = (w) => (w && w.resetAt ? new Date(w.resetAt).toLocaleString() : '—');
|
||||
const details = (provider, usage, idle) => {
|
||||
@@ -3639,10 +3695,20 @@ class CodemanApp {
|
||||
if (five !== null) lines.push(`5-hour limit: ${five}% used (resets ${resetStr(usage.fiveHour)})`);
|
||||
else if (idle && seven !== null) lines.push('5-hour limit: no active session window');
|
||||
if (seven !== null) lines.push(`Weekly limit: ${seven}% used (resets ${resetStr(usage.sevenDay)})`);
|
||||
const month = pct(usage?.monthly);
|
||||
if (month !== null) {
|
||||
// Counts are coerced finite numbers, so interpolating them stays XSS-safe like the rest.
|
||||
const m = usage.monthly;
|
||||
const counts =
|
||||
Number.isFinite(m.used) && Number.isFinite(m.limit) ? `${Math.round(m.used)} of ${Math.round(m.limit)} requests, ` : '';
|
||||
lines.push(`Premium requests this month: ${month}% used (${counts}resets ${resetStr(m)})`);
|
||||
}
|
||||
return lines.length ? `${provider} plan usage\n${lines.join('\n')}` : '';
|
||||
};
|
||||
chip.title =
|
||||
[details('Claude', data, true), details('Codex', data.codex, false)].filter(Boolean).join('\n\n') ||
|
||||
[details('Claude', data, true), details('Codex', data.codex, false), details('Copilot', data.copilot, false)]
|
||||
.filter(Boolean)
|
||||
.join('\n\n') ||
|
||||
'Plan usage limits';
|
||||
}
|
||||
|
||||
@@ -3938,8 +4004,8 @@ class CodemanApp {
|
||||
// Both transports refuse a frame over the server's limit (issue #484), and a
|
||||
// refused frame used to sit at the head of the durable queue for good. So an
|
||||
// oversized paste goes out as several in-limit frames, delivered in seq order
|
||||
// as one contiguous stream. A mux write is line-oriented (it strips newlines
|
||||
// and sends Enter on its own), so it is never split: refuse it instead.
|
||||
// as one contiguous stream. A mux write is one paste that sends Enter on its
|
||||
// own, so it is never split: refuse it instead.
|
||||
const limit = window.CodemanInputLimit;
|
||||
if (limit && input.length > limit.FRAME_MAX_CHARS) {
|
||||
if (useMux || input.length > limit.PASTE_MAX_CHARS) {
|
||||
@@ -7692,6 +7758,8 @@ class CodemanApp {
|
||||
this._tabLayoutEditor = null;
|
||||
}
|
||||
this.tabLayout = next;
|
||||
// An open Case settings popover lists the groups as its Default session group options.
|
||||
this._refreshCaseDefaultGroupOptions?.(next);
|
||||
const storage = this._getTabCollapseStorage();
|
||||
const collapsed = storage && next
|
||||
? window.CodemanTabLayout.loadCollapsedGroupIds(storage, next.groups.map((group) => group.id))
|
||||
|
||||
@@ -733,7 +733,7 @@ function computeConnectionLossUi(input) {
|
||||
// it says why instead of "not reported yet" forever.
|
||||
const PROMPT_CACHE_NOT_COLLECTED = {
|
||||
'collection-off':
|
||||
'Not collected: Plan Usage was off when this session started. Turn it on in App Settings; sessions started after that report it.',
|
||||
'Not collected: Plan Usage was off when this session started. Turn it on in App Settings → Header & Panels, then start a new session; this one cannot pick it up.',
|
||||
'workspace-statusline':
|
||||
"Not collected: this workspace's .claude/settings.local.json sets its own statusLine, which Codeman never replaces.",
|
||||
remote: 'Not collected for remote and Docker sessions.',
|
||||
@@ -763,6 +763,51 @@ function formatPromptCacheStatus(cache, now, statusLine) {
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
// Session Options → Respawn → Prompt cache → Status: the keep-warm line under the cache
|
||||
// readout, saying what the keep-warm service is doing (`session.keepWarm`, published by the
|
||||
// server on `session:keepWarm`), compact and dot-separated. Empty when there is nothing to
|
||||
// say: switched off, not published yet, or a launch without the statusLine exporter, where
|
||||
// the readout's own "Not collected" sentence on the same row already says why and
|
||||
// session-ui.js greys the switch. Pure: no DOM, `now` is passed in.
|
||||
function formatKeepWarmStatus(status, now, statusLine) {
|
||||
if (PROMPT_CACHE_NOT_COLLECTED[statusLine]) return '';
|
||||
if (!status || typeof status.state !== 'string') return '';
|
||||
const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const keepWarmWindow = Number.isFinite(status.budgetEndsAt)
|
||||
? status.budgetEndsAt > now
|
||||
? `stops ${clock(status.budgetEndsAt)}`
|
||||
: 'window closed'
|
||||
: '';
|
||||
// Keep-warm switched on or off after launch: the `idleCompaction` opt-out rides only a launch.
|
||||
const idleCompaction =
|
||||
status.enabled && status.idleCompactionOff === false
|
||||
? 'idle compaction on until relaunch'
|
||||
: !status.enabled && status.idleCompactionOff === true
|
||||
? 'idle compaction off until relaunch'
|
||||
: '';
|
||||
let parts;
|
||||
switch (status.state) {
|
||||
case 'off': {
|
||||
const reason = status.reason === 'off' ? '' : status.reason;
|
||||
if (!reason && !idleCompaction) return '';
|
||||
parts = ['off', reason, idleCompaction];
|
||||
break;
|
||||
}
|
||||
case 'armed':
|
||||
parts = [`next ${clock(status.nextCheckAt)}`, keepWarmWindow, idleCompaction];
|
||||
break;
|
||||
case 'waiting':
|
||||
parts = ['waiting', status.reason || 'retrying', `retry ${clock(status.nextCheckAt)}`, keepWarmWindow, idleCompaction];
|
||||
break;
|
||||
case 'nudged':
|
||||
parts = [`sent ${clock(status.lastNudgeAt)} (${status.nudges}\u00d7)`, keepWarmWindow, idleCompaction];
|
||||
break;
|
||||
default:
|
||||
parts = ['stopped', status.reason || 'nothing to do', idleCompaction];
|
||||
}
|
||||
return `Keep-alive: ${parts.filter(Boolean).join(' \u00b7 ')}`;
|
||||
}
|
||||
|
||||
// SSE staleness policy: is this stream a zombie?
|
||||
//
|
||||
// An EventSource that stops delivering does not always error. A proxy that
|
||||
@@ -1503,6 +1548,8 @@ if (typeof window !== 'undefined') {
|
||||
};
|
||||
window.CodemanPromptCache = {
|
||||
format: formatPromptCacheStatus,
|
||||
formatKeepWarm: formatKeepWarmStatus,
|
||||
keepWarmUnavailable: (statusLine) => !!PROMPT_CACHE_NOT_COLLECTED[statusLine],
|
||||
};
|
||||
window.CodemanSessionOrder = {
|
||||
RANK: SESSION_ACTIVITY_RANK,
|
||||
@@ -1699,6 +1746,7 @@ const SSE_EVENTS = {
|
||||
SESSION_RUNNING: 'session:running',
|
||||
SESSION_STATUS_TELEMETRY: 'session:statusTelemetry',
|
||||
SESSION_PROMPT_CACHE: 'session:promptCache',
|
||||
SESSION_KEEP_WARM: 'session:keepWarm',
|
||||
|
||||
// Scheduled runs
|
||||
SCHEDULED_CREATED: 'scheduled:created',
|
||||
|
||||
@@ -678,6 +678,9 @@
|
||||
'Agent Teams': '智能体团队',
|
||||
'Claude Model': 'Claude 模型',
|
||||
'1M Opus Context': 'Opus 100 万上下文',
|
||||
'Default session group': '默认会话分组',
|
||||
'New sessions for this case land in this group': '此案例的新会话将放入此分组',
|
||||
'Could not save the default session group.': '无法保存默认会话分组。',
|
||||
'Remote auto-reconnect': '远程自动重连',
|
||||
'Pin tab names on the CLI': '在 CLI 上固定标签名',
|
||||
"Launch every Claude session with its tab name, so the CLI's prompt label and /resume list match your tabs. Claude then stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. Applies from the next session start or respawn, no server restart needed.":
|
||||
@@ -962,6 +965,19 @@
|
||||
'Enable Respawn': '启用重生',
|
||||
'Stop Respawn': '停止重生',
|
||||
'Auto-resume when usage limit resets': '使用限制重置后自动继续',
|
||||
// Prompt-cache keep-warm (Session Options → Respawn, App Settings → System → Automation, session-ui.js toasts).
|
||||
'Keep the cache warm while idle': '空闲时保持缓存预热',
|
||||
"Sends a one-line keep-alive just before the cache expires, so your next prompt still reads from cache. Runs only while idle, up to the limit in App Settings → System → Automation; switch on before launch to also stop Claude Code's own idle compaction.":
|
||||
'在缓存即将过期前发送一行保活消息,让您的下一条提示仍能从缓存读取。仅在空闲时运行,最长不超过“应用设置 → 系统 → 自动化”中的上限;在启动前开启还会关闭 Claude Code 自带的空闲压缩。',
|
||||
'Keep prompt caches warm': '保持提示缓存预热',
|
||||
"Default for Claude sessions that have not chosen on their Respawn tab (a session an agent started needs its own switch): shortly before an idle session's prompt cache would expire, send a one-line keep-alive turn so the next prompt still reads the conversation from cache instead of re-processing it.":
|
||||
'未在“重生”标签页中单独设置的 Claude 会话的默认值(由智能体启动的会话需单独开启):在空闲会话的提示缓存即将过期前发送一行保活消息,让下一条提示仍从缓存读取对话,而不必重新处理。',
|
||||
'Keep warm for at most': '最长保持预热',
|
||||
'Hours after your last prompt in a session during which keep-alives are sent; past that the cache is left to expire. A keep-alive costs a small fraction of the re-write it prevents, so a few hours of them pay off, an overnight gap does not.':
|
||||
'会话中最后一条提示之后发送保活消息的小时数;超过之后任由缓存过期。一次保活的开销只是它所避免的重新写入的一小部分,因此几个小时的保活是值得的,整夜的空档则不值得。',
|
||||
hours: '小时',
|
||||
'Prompt-cache keep-warm enabled': '提示缓存预热已开启',
|
||||
'Prompt-cache keep-warm disabled': '提示缓存预热已关闭',
|
||||
'Auto-restart sessions when context fills up (usually not needed)': '上下文已满时自动重启会话(通常不需要)',
|
||||
'Auto-Compact': '自动压缩',
|
||||
'Auto-Clear': '自动清空',
|
||||
@@ -1039,6 +1055,12 @@
|
||||
'Source type filter': '来源类型筛选',
|
||||
'Copy content': '复制内容',
|
||||
'Edit file': '编辑文件',
|
||||
'Back (Alt+←)': '后退 (Alt+←)',
|
||||
'Forward (Alt+→)': '前进 (Alt+→)',
|
||||
'Back to the previous file': '返回上一个文件',
|
||||
'Forward to the next file': '前进到下一个文件',
|
||||
Expand: '展开',
|
||||
'Drag to resize': '拖动以调整大小',
|
||||
'Rendered markdown': '渲染 Markdown',
|
||||
'Line numbers': '行号',
|
||||
'Wrap lines': '自动换行',
|
||||
@@ -1235,6 +1257,7 @@
|
||||
'Voice input (Ctrl+Shift+V)': '语音输入(Ctrl+Shift+V)',
|
||||
'Insert Newline': '插入换行',
|
||||
'Close Panels': '关闭面板',
|
||||
'File Viewer Back / Forward': '文件查看器后退 / 前进',
|
||||
'Previous / Next Session': '上一个 / 下一个会话',
|
||||
'Next Session': '下一个会话',
|
||||
'Previous Session': '上一个会话',
|
||||
|
||||
@@ -197,7 +197,7 @@
|
||||
<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>
|
||||
<button class="btn-icon-header btn-tile-grid btn-tile-grid--hidden" onclick="app.toggleTileGrid()" oncontextmenu="app.openTileCountMenu(event)" aria-describedby="tileGridHint" aria-label="Tiles: show several sessions side by side (right-click for how many)" 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="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg></button>
|
||||
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><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"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
|
||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex plan usage limits">—</div>
|
||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude, Codex and Copilot plan usage limits">—</div>
|
||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
|
||||
<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="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
|
||||
@@ -614,6 +614,8 @@
|
||||
<div class="file-preview-header">
|
||||
<span class="file-preview-title" id="filePreviewTitle">file.ts</span>
|
||||
<div class="file-preview-actions">
|
||||
<button class="btn-icon-sm" id="filePreviewBackBtn" onclick="app.stepFilePreviewHistory(-1)" title="Back (Alt+←)" aria-label="Back to the previous file" hidden disabled>←</button>
|
||||
<button class="btn-icon-sm" id="filePreviewForwardBtn" onclick="app.stepFilePreviewHistory(1)" title="Forward (Alt+→)" aria-label="Forward to the next file" hidden disabled>→</button>
|
||||
<button class="btn-icon-sm" id="filePreviewPrevBtn" onclick="app.stepFolderPreview(-1)" title="Previous file in folder (←)" aria-label="Previous file in folder" hidden>‹</button>
|
||||
<button class="btn-icon-sm" id="filePreviewNextBtn" onclick="app.stepFolderPreview(1)" title="Next file in folder (→)" aria-label="Next file in folder" hidden>›</button>
|
||||
<button class="btn-icon-sm" id="filePreviewFolderBtn" onclick="app.openFolderOfPreview()" title="Show folder" aria-label="Show folder" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
|
||||
@@ -624,6 +626,7 @@
|
||||
<button class="btn-icon-sm file-preview-edit-btn" id="filePreviewEditBtn" onclick="app.enterFilePreviewEdit()" title="Edit file" aria-label="Edit file" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></svg></button>
|
||||
<button class="btn-icon-sm" onclick="app.copyFilePreviewContent()" title="Copy content">⎘</button>
|
||||
<button class="btn-icon-sm" id="filePreviewDetachBtn" onclick="app.detachFilePreview()" title="Open in new tab" aria-label="Open in new tab" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button>
|
||||
<button class="btn-icon-sm" id="filePreviewExpandBtn" onclick="app.toggleFilePreviewExpanded()" title="Expand" aria-label="Expand" aria-pressed="false"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg></button>
|
||||
<button class="btn-icon-sm" onclick="app.closeFilePreview()" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -635,6 +638,7 @@
|
||||
<button class="file-preview-editbar-btn file-preview-editbar-btn--save" id="filePreviewSaveBtn" onclick="app.saveFilePreviewEdit()" disabled>Save</button>
|
||||
</div>
|
||||
<div class="file-preview-footer" id="filePreviewFooter"></div>
|
||||
<div class="file-preview-resize" id="filePreviewResize" title="Drag to resize" aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -774,6 +778,11 @@
|
||||
<span>1M Opus Context</span>
|
||||
</label>
|
||||
<span class="form-hint">Use 1M token context window for new sessions</span>
|
||||
<label class="case-default-group-label" for="caseDefaultGroup">Default session group</label>
|
||||
<select id="caseDefaultGroup" class="case-default-group-select" onchange="app.onCaseDefaultGroupChanged(this)">
|
||||
<option value="">Ungrouped</option>
|
||||
</select>
|
||||
<span class="form-hint">New sessions for this case land in this group</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile-only case button + gear -->
|
||||
@@ -818,6 +827,11 @@
|
||||
<span>1M Opus Context</span>
|
||||
</label>
|
||||
<span class="form-hint">Use 1M token context window</span>
|
||||
<label class="case-default-group-label" for="caseDefaultGroupMobile">Default session group</label>
|
||||
<select id="caseDefaultGroupMobile" class="case-default-group-select" onchange="app.onCaseDefaultGroupChanged(this)">
|
||||
<option value="">Ungrouped</option>
|
||||
</select>
|
||||
<span class="form-hint">New sessions for this case land in this group</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -922,6 +936,7 @@
|
||||
<div><kbd>Ctrl</kbd>+<kbd>?</kbd></div><div>Show Shortcuts</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>?</kbd></div><div>Show Shortcuts</div>
|
||||
<div><kbd>Escape</kbd></div><div>Close Panels</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>ArrowLeft</kbd> / <kbd>Alt/Option</kbd>+<kbd>ArrowRight</kbd></div><div>File Viewer Back / Forward</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -1334,8 +1349,19 @@
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Status</span>
|
||||
<span class="set-row-desc" id="promptCacheStatus" data-i18n-skip>Not reported yet.</span>
|
||||
<span class="set-row-desc" id="keepWarmStatus" data-i18n-skip hidden></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Per-session opt-in for the keep-alive nudge (prompt-cache-keepwarm.ts);
|
||||
the default for sessions that have not chosen lives in App Settings →
|
||||
Automation. Its status shares the Status row above: session-ui.js updateKeepWarmStatus. -->
|
||||
<div class="set-row">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Keep the cache warm while idle</span>
|
||||
<span class="set-row-desc">Sends a one-line keep-alive just before the cache expires, so your next prompt still reads from cache. Runs only while idle, up to the limit in App Settings → System → Automation; switch on before launch to also stop Claude Code's own idle compaction.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="modalKeepWarmEnabled" onchange="app.autoSaveKeepWarm()"><span class="slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3242,6 +3268,48 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group" id="configBackupGroup">
|
||||
<div class="set-group-head"><h4>Config backups</h4><span class="set-scope">server</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="config backup snapshot settings clis restore retention keep">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Back up my configuration</span>
|
||||
<span class="set-row-desc">Keeps timestamped copies of your settings, CLI overrides, custom endpoints, webhook, linked cases and (in multi-user mode) accounts. A copy is made at startup and whenever one of them changes. Some of these files hold credentials, so each copy is private to this user (mode 0700/0600).</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsConfigBackup"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="config backup folder location path directory">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Backup folder</span>
|
||||
<span class="set-row-desc">An absolute path (a leading <code>~</code> is expanded). Leave empty for the default shown. Only folders Codeman created there are ever pruned.</span>
|
||||
</div>
|
||||
<input type="text" id="appSettingsConfigBackupDir" class="set-input" placeholder="default: ~/.codeman/backups/config" data-i18n-skip>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="config backup retention keep count snapshots">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Keep this many backups</span>
|
||||
<span class="set-row-desc">2 to 500 (with 1, every change would delete the state before it). The newest backup is always kept.</span>
|
||||
</div>
|
||||
<input type="number" id="appSettingsConfigBackupKeepCount" oninput="this.dataset.touched=1" class="set-input" min="2" max="500" step="1" placeholder="20">
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="config backup retention keep days age">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Delete backups older than (days)</span>
|
||||
<span class="set-row-desc">A backup is removed once a newer one has existed this many days. 0 keeps backups until the count above pushes them out.</span>
|
||||
</div>
|
||||
<input type="number" id="appSettingsConfigBackupKeepDays" oninput="this.dataset.touched=1" class="set-input" min="0" max="3650" step="1" placeholder="30">
|
||||
</div>
|
||||
<div class="set-row" data-search="config backup now restore list">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Backups</span>
|
||||
<span class="set-row-desc">Folder and retention changes apply after you save. Restoring puts a backup's files back and makes a copy of the current ones first. Compare shows what changed between two backups, or between a backup and the running config; credential values are never shown.</span>
|
||||
</div>
|
||||
<button class="btn-toolbar btn-sm btn-primary" id="configBackupNowBtn" onclick="app.backupConfigNow()">Back up now</button>
|
||||
</div>
|
||||
<div id="configBackupList" class="set-note" data-i18n-skip>Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Automation</h4><span class="set-scope">synced</span></div>
|
||||
<div class="set-group-body">
|
||||
@@ -3252,6 +3320,23 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsImageWatcherEnabled"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="prompt cache keep warm keep-alive nudge idle expire">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Keep prompt caches warm</span>
|
||||
<span class="set-row-desc">Default for Claude sessions that have not chosen on their Respawn tab (a session an agent started needs its own switch): shortly before an idle session's prompt cache would expire, send a one-line keep-alive turn so the next prompt still reads the conversation from cache instead of re-processing it.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsPromptCacheKeepWarm"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="prompt cache keep warm hours limit cap window">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Keep warm for at most</span>
|
||||
<span class="set-row-desc">Hours after your last prompt in a session during which keep-alives are sent; past that the cache is left to expire. A keep-alive costs a small fraction of the re-write it prevents, so a few hours of them pay off, an overnight gap does not.</span>
|
||||
</div>
|
||||
<div class="set-row-actions">
|
||||
<input type="number" id="appSettingsPromptCacheKeepWarmMaxHours" class="set-num" min="0.5" max="24" step="0.5" value="2">
|
||||
<span class="set-unit">hours</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -647,6 +647,9 @@ const COMPOSER_INPUT_FRAME_LIMIT = 64 * 1024;
|
||||
const COMPOSER_PASTE_START = '\x1b[200~';
|
||||
const COMPOSER_PASTE_END = '\x1b[201~';
|
||||
|
||||
// Accessory keys that act on the terminal and leave the keyboard up.
|
||||
const ACCESSORY_REFOCUS_ACTIONS = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'shift-left', 'shift-right', 'ctrl', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']);
|
||||
|
||||
/**
|
||||
* KeyboardAccessoryBar - Quick action buttons shown above keyboard when typing.
|
||||
*/
|
||||
@@ -802,6 +805,21 @@ const KeyboardAccessoryBar = {
|
||||
this.syncCodexKeys();
|
||||
this._syncComposerDraftIndicator();
|
||||
|
||||
// A key that keeps the keyboard up must not take focus on press at all.
|
||||
// Letting the tap blur the terminal and refocusing it in the click handler
|
||||
// (kept below as the fallback) made every such key start a keyboard
|
||||
// dismissal: a blur/focus pair per tap. Same pattern as the Attach input
|
||||
// (image-input.js). A first tap on clear/compact only arms the confirm.
|
||||
this.element.addEventListener('mousedown', (e) => {
|
||||
const btn = e.target.closest('.accessory-btn');
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.action;
|
||||
if (ACCESSORY_REFOCUS_ACTIONS.has(action) ||
|
||||
((action === 'clear' || action === 'compact') && this._confirmAction !== action)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Add click handlers — preventDefault stops event from reaching terminal
|
||||
this.element.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.accessory-btn');
|
||||
@@ -813,8 +831,7 @@ const KeyboardAccessoryBar = {
|
||||
this.handleAction(action, btn);
|
||||
|
||||
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
|
||||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'shift-left', 'shift-right', 'ctrl', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']);
|
||||
if (refocusActions.has(action) ||
|
||||
if (ACCESSORY_REFOCUS_ACTIONS.has(action) ||
|
||||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
app.terminal.focus();
|
||||
@@ -1415,9 +1432,10 @@ const KeyboardAccessoryBar = {
|
||||
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => {
|
||||
// Hand the text BACK to the terminal prompt so both surfaces hold the same
|
||||
// prompt, the mirror of the adoption composePrompt() does on open. Only a
|
||||
// single-line draft can make the trip: the prompt transport is single-line
|
||||
// and strips embedded newlines, so a multi-line draft would arrive mangled
|
||||
// and instead stays parked as a draft (the dot on the Compose key shows it).
|
||||
// single-line draft can make the trip: the terminal prompt is one line and
|
||||
// its text reaches the PTY as typed input, where an embedded newline is a
|
||||
// key, so a multi-line draft would arrive mangled and instead stays parked
|
||||
// as a draft (the dot on the Compose key shows it).
|
||||
const text = textarea.value;
|
||||
if (!text || text.includes('\n')) {
|
||||
close({ focusTerminal: true });
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* toolbar/accessory bar repositioning, terminal resize on keyboard open/close,
|
||||
* and input scroll-into-view. Uses 100px threshold for iOS address bar drift.
|
||||
* - SwipeHandler — Horizontal swipe detection on the terminal area for session switching.
|
||||
* 80px minimum distance, 300ms maximum time, 100px max vertical drift.
|
||||
* 80px minimum distance, 500ms maximum time, 50px max vertical drift.
|
||||
*
|
||||
* All three have init()/cleanup() lifecycle methods. They are re-initialized after SSE
|
||||
* reconnect (in handleInit) to prevent stale closures.
|
||||
@@ -758,14 +758,24 @@ const SwipeHandler = {
|
||||
startY: 0,
|
||||
startTime: 0,
|
||||
minSwipeDistance: 80, // Minimum pixels for a valid swipe
|
||||
maxSwipeTime: 300, // Maximum ms for a swipe gesture
|
||||
maxVerticalDrift: 100, // Max vertical movement allowed
|
||||
// 300ms rejected an unhurried but deliberate swipe outright, which read as an
|
||||
// unresponsive gesture rather than as "too slow".
|
||||
maxSwipeTime: 500, // Maximum ms for a swipe gesture
|
||||
maxVerticalDrift: 50, // Max vertical movement allowed, at any point of the gesture
|
||||
|
||||
_touchStartHandler: null,
|
||||
_touchMoveHandler: null,
|
||||
_touchEndHandler: null,
|
||||
_element: null,
|
||||
_ignoreGesture: false,
|
||||
|
||||
/**
|
||||
* Surfaces inside `.main` that own their own gestures or controls: a swipe
|
||||
* there must not switch sessions. The phone overview and welcome screen are
|
||||
* deliberately NOT listed: swiping from home into a session is a feature.
|
||||
*/
|
||||
IGNORE_SELECTOR: '.tile-grid, .term-select-bar, #cjkInput, .history-trunc-bar',
|
||||
|
||||
/** Initialize swipe handling */
|
||||
init() {
|
||||
// Only on touch devices
|
||||
@@ -776,8 +786,10 @@ const SwipeHandler = {
|
||||
|
||||
this._element = terminal;
|
||||
this._touchStartHandler = (e) => this.onTouchStart(e);
|
||||
this._touchMoveHandler = (e) => this.onTouchMove(e);
|
||||
this._touchEndHandler = (e) => this.onTouchEnd(e);
|
||||
terminal.addEventListener('touchstart', this._touchStartHandler, { passive: true });
|
||||
terminal.addEventListener('touchmove', this._touchMoveHandler, { passive: true });
|
||||
terminal.addEventListener('touchend', this._touchEndHandler, { passive: true });
|
||||
},
|
||||
|
||||
@@ -785,9 +797,11 @@ const SwipeHandler = {
|
||||
cleanup() {
|
||||
if (this._element && this._touchStartHandler) {
|
||||
this._element.removeEventListener('touchstart', this._touchStartHandler);
|
||||
this._element.removeEventListener('touchmove', this._touchMoveHandler);
|
||||
this._element.removeEventListener('touchend', this._touchEndHandler);
|
||||
}
|
||||
this._touchStartHandler = null;
|
||||
this._touchMoveHandler = null;
|
||||
this._touchEndHandler = null;
|
||||
this._element = null;
|
||||
},
|
||||
@@ -797,20 +811,43 @@ const SwipeHandler = {
|
||||
// here. Swiping across the open session drawer — the natural "dismiss it"
|
||||
// gesture — would otherwise fire nextSession() and drop the user into a
|
||||
// session they never tapped.
|
||||
// The terminal's touch handler publishes the axis it locks for a gesture
|
||||
// that starts on it; a touch that starts elsewhere in `.main` (the home
|
||||
// screen) must not inherit the last one, or a terminal scroll a moment
|
||||
// earlier vetoes a swipe from home. Reset before any early return.
|
||||
if (typeof app !== 'undefined') app._terminalGestureAxis = null;
|
||||
this._ignoreGesture = !!e.target?.closest?.('.session-sidebar');
|
||||
if (this._ignoreGesture) return;
|
||||
if (!e.touches || e.touches.length !== 1) return;
|
||||
// A second finger makes this a different gesture (pinch, two-finger scroll):
|
||||
// never measure a later lift against the first finger's start. Stays set
|
||||
// until the next touchstart, so the first finger's lift cannot clear it.
|
||||
if (!e.touches || e.touches.length !== 1) {
|
||||
this._ignoreGesture = true;
|
||||
return;
|
||||
}
|
||||
this._ignoreGesture = !!e.target?.closest?.(this.IGNORE_SELECTOR);
|
||||
this.startX = e.touches[0].clientX;
|
||||
this.startY = e.touches[0].clientY;
|
||||
this.startTime = Date.now();
|
||||
},
|
||||
|
||||
onTouchMove(e) {
|
||||
if (this._ignoreGesture || !e.touches || e.touches.length !== 1) return;
|
||||
if (Math.abs(e.touches[0].clientY - this.startY) > this.maxVerticalDrift) this._ignoreGesture = true;
|
||||
},
|
||||
|
||||
onTouchEnd(e) {
|
||||
if (this._ignoreGesture) {
|
||||
this._ignoreGesture = false;
|
||||
return;
|
||||
}
|
||||
if (this._ignoreGesture) return;
|
||||
if (!e.changedTouches || e.changedTouches.length !== 1) return;
|
||||
if (typeof app === 'undefined') return;
|
||||
// The terminal's own touch handler (terminal-ui.js) runs first, on the same
|
||||
// gesture: a drag it locked as a scroll or a pan is not a session swipe,
|
||||
// and neither is a lift it consumed (its long-press selection gesture
|
||||
// preventDefaults the touchend, even on blank space where no selection
|
||||
// survives the lift). A swipe must not extend or drop a touch selection.
|
||||
if (e.defaultPrevented) return;
|
||||
if (app._terminalGestureAxis === 'y' || app._terminalGestureAxis === 'x') return;
|
||||
if (app._touchSelecting || app._touchSelectionActive) return;
|
||||
|
||||
const endX = e.changedTouches[0].clientX;
|
||||
const endY = e.changedTouches[0].clientY;
|
||||
@@ -824,13 +861,8 @@ const SwipeHandler = {
|
||||
if (deltaY > this.maxVerticalDrift) return; // Too much vertical movement
|
||||
if (Math.abs(deltaX) < this.minSwipeDistance) return; // Too short
|
||||
|
||||
// Valid swipe detected
|
||||
if (deltaX > 0) {
|
||||
// Swipe right -> previous session
|
||||
if (typeof app !== 'undefined') app.prevSession();
|
||||
} else {
|
||||
// Swipe left -> next session
|
||||
if (typeof app !== 'undefined') app.nextSession();
|
||||
}
|
||||
// Valid swipe detected. Swipe right -> previous session, left -> next.
|
||||
if (deltaX > 0) app.prevSession();
|
||||
else app.nextSession();
|
||||
},
|
||||
};
|
||||
@@ -2298,6 +2298,19 @@ html.mobile-init .file-browser-panel {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* Expand and the resize grip would only shrink the full-screen window (96vw x 94vh,
|
||||
or a px size left by a drag in landscape), so a phone ignores both. */
|
||||
.file-preview-window,
|
||||
.file-preview-window.expanded {
|
||||
width: 100vw !important;
|
||||
height: var(--app-height, 100vh) !important;
|
||||
}
|
||||
|
||||
#filePreviewExpandBtn,
|
||||
.file-preview-resize {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-preview-editbar {
|
||||
padding-bottom: calc(0.4rem + var(--safe-area-bottom));
|
||||
}
|
||||
|
||||
+152
-1
@@ -33,8 +33,11 @@ const FILE_PREVIEW_PREF_KEYS = {
|
||||
htmlRendered: 'codeman:filePreviewHtmlRendered',
|
||||
lineNumbers: 'codeman:filePreviewLineNumbers',
|
||||
wrap: 'codeman:filePreviewWrap',
|
||||
expanded: 'codeman:filePreviewExpanded',
|
||||
};
|
||||
const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, htmlRendered: true, lineNumbers: false, wrap: true };
|
||||
const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, htmlRendered: true, lineNumbers: false, wrap: true, expanded: false };
|
||||
// Back/forward list of previews opened from one overlay session (links between documents).
|
||||
const FILE_PREVIEW_HISTORY_MAX = 50;
|
||||
const AWAY_DIGEST_SECTIONS = [
|
||||
['needsAttention', 'Needs Attention'],
|
||||
['completed', 'Completed'],
|
||||
@@ -4108,6 +4111,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
if (!overlay || !bodyEl) return;
|
||||
|
||||
this._recordFilePreviewHistory(filePath, sessionId, attachmentId, options);
|
||||
this._bindFilePreviewChrome();
|
||||
|
||||
// Only the newest load may write: paging ←/→ through a folder starts the next
|
||||
// load while the previous one still awaits its fetch, and its late answer would
|
||||
// put the previous file's text under the next file's title.
|
||||
@@ -4133,6 +4139,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Show overlay with loading state
|
||||
overlay.classList.add('visible');
|
||||
this._applyFilePreviewExpanded();
|
||||
titleEl.textContent = filePath;
|
||||
bodyEl.innerHTML = '<div class="binary-message">Loading...</div>';
|
||||
footerEl.textContent = '';
|
||||
@@ -4537,6 +4544,148 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._openFolderGridFile(next);
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// File Viewer window: back/forward between previews, expand, resize
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Remember a preview in the back/forward list. A navigation that came FROM the
|
||||
* list (stepFilePreviewHistory) is not recorded again, and neither is a reload
|
||||
* of the file already showing (the MD/Page toggles reopen the same file). Opening
|
||||
* something new while standing mid-list drops the forward entries, as a browser does.
|
||||
*/
|
||||
_recordFilePreviewHistory(filePath, sessionId, attachmentId, options) {
|
||||
const h = (this.filePreviewHistory = this.filePreviewHistory || { entries: [], index: -1 });
|
||||
if (!this._filePreviewHistoryStepping) {
|
||||
const cur = h.entries[h.index];
|
||||
const same =
|
||||
cur && cur.filePath === filePath && cur.sessionId === sessionId && (cur.attachmentId || null) === (attachmentId || null);
|
||||
if (!same) {
|
||||
h.entries.length = h.index + 1;
|
||||
h.entries.push({ filePath, sessionId, attachmentId: attachmentId || null, options });
|
||||
if (h.entries.length > FILE_PREVIEW_HISTORY_MAX) h.entries.shift();
|
||||
h.index = h.entries.length - 1;
|
||||
}
|
||||
}
|
||||
this._updateFilePreviewHistoryNav();
|
||||
},
|
||||
|
||||
/** Back (-1) / forward (+1) through the previews opened in this overlay session. */
|
||||
stepFilePreviewHistory(delta) {
|
||||
const h = this.filePreviewHistory;
|
||||
if (!h) return;
|
||||
const target = h.index + delta;
|
||||
if (target < 0 || target >= h.entries.length) return;
|
||||
if (this.filePreviewEdit?.dirty && !confirm('Discard unsaved changes?')) return;
|
||||
h.index = target;
|
||||
const e = h.entries[target];
|
||||
// openFilePreview records synchronously, before its first await, so the flag
|
||||
// only needs to cover the call itself.
|
||||
this._filePreviewHistoryStepping = true;
|
||||
try {
|
||||
// A folder-grid page goes through the grid so index, lastIndex and the highlighted
|
||||
// tile follow; otherwise the next ‹ › would continue from the newer file.
|
||||
const folderFile = e.options?.folderFile;
|
||||
if (folderFile && folderFile.folderId === this.folderGrid?.folderId) {
|
||||
const index = this.folderGrid.files.findIndex((f) => f.name === folderFile.name);
|
||||
if (index !== -1) return this._openFolderGridFile(index);
|
||||
}
|
||||
return this.openFilePreview(e.filePath, e.sessionId, e.attachmentId, e.options || {});
|
||||
} finally {
|
||||
this._filePreviewHistoryStepping = false;
|
||||
}
|
||||
},
|
||||
|
||||
/** Buttons show once there is somewhere to go; each is disabled at its end of the list. */
|
||||
_updateFilePreviewHistoryNav() {
|
||||
const h = this.filePreviewHistory;
|
||||
const multi = !!h && h.entries.length > 1;
|
||||
const back = this.$('filePreviewBackBtn');
|
||||
const fwd = this.$('filePreviewForwardBtn');
|
||||
if (back) {
|
||||
back.hidden = !multi;
|
||||
back.disabled = !multi || h.index <= 0;
|
||||
}
|
||||
if (fwd) {
|
||||
fwd.hidden = !multi;
|
||||
fwd.disabled = !multi || h.index >= h.entries.length - 1;
|
||||
}
|
||||
},
|
||||
|
||||
/** Expand button: fill the screen, remembered across opens. */
|
||||
toggleFilePreviewExpanded() {
|
||||
this._setFilePreviewPref('expanded', !this._filePreviewPref('expanded'));
|
||||
this._applyFilePreviewExpanded();
|
||||
},
|
||||
|
||||
_applyFilePreviewExpanded() {
|
||||
const on = this._filePreviewPref('expanded');
|
||||
const win = this.$('filePreviewOverlay')?.querySelector?.('.file-preview-window');
|
||||
win?.classList?.toggle('expanded', on);
|
||||
// The label stays "Expand" and aria-pressed carries the state, like MD, Lines and
|
||||
// Wrap, so a screen reader never announces "Restore size, pressed".
|
||||
this.$('filePreviewExpandBtn')?.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
},
|
||||
|
||||
/**
|
||||
* One-time wiring for the window chrome: Alt+←/→ for back/forward (capture phase
|
||||
* so the terminal behind the overlay never types the arrow) and the corner grip.
|
||||
*/
|
||||
_bindFilePreviewChrome() {
|
||||
if (this._filePreviewChromeBound) return;
|
||||
this._filePreviewChromeBound = true;
|
||||
document.addEventListener(
|
||||
'keydown',
|
||||
(ev) => {
|
||||
if (!ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey) return;
|
||||
if (ev.key !== 'ArrowLeft' && ev.key !== 'ArrowRight') return;
|
||||
if (!this.$('filePreviewOverlay')?.classList.contains('visible')) return;
|
||||
const t = ev.target;
|
||||
if (t && (t.isContentEditable || (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) && !t.classList?.contains('xterm-helper-textarea')))) return;
|
||||
// Swallowed even at the ends of the list: otherwise the terminal behind the overlay
|
||||
// types it as word-left/right, and on a header button the browser's own Alt+← goes Back.
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const h = this.filePreviewHistory;
|
||||
const delta = ev.key === 'ArrowLeft' ? -1 : 1;
|
||||
if (!h || h.index + delta < 0 || h.index + delta >= h.entries.length) return;
|
||||
this.stepFilePreviewHistory(delta);
|
||||
},
|
||||
true
|
||||
);
|
||||
this._bindFilePreviewResize();
|
||||
},
|
||||
|
||||
/** Drag the corner grip to size the window; the window is centred, so the delta counts twice. */
|
||||
_bindFilePreviewResize() {
|
||||
const grip = this.$('filePreviewResize');
|
||||
const win = grip?.parentElement;
|
||||
if (!grip || !win) return;
|
||||
grip.addEventListener('pointerdown', (ev) => {
|
||||
if (ev.button !== 0) return; // a right-click on the grip must not start a resize
|
||||
ev.preventDefault();
|
||||
const startX = ev.clientX;
|
||||
const startY = ev.clientY;
|
||||
const startW = win.offsetWidth;
|
||||
const startH = win.offsetHeight;
|
||||
grip.setPointerCapture?.(ev.pointerId);
|
||||
const move = (e) => {
|
||||
const w = Math.max(320, Math.min(window.innerWidth * 0.96, startW + 2 * (e.clientX - startX)));
|
||||
const h = Math.max(240, Math.min(window.innerHeight * 0.94, startH + 2 * (e.clientY - startY)));
|
||||
win.style.width = `${Math.round(w)}px`;
|
||||
win.style.height = `${Math.round(h)}px`;
|
||||
};
|
||||
const up = () => {
|
||||
grip.removeEventListener('pointermove', move);
|
||||
grip.removeEventListener('pointerup', up);
|
||||
grip.removeEventListener('pointercancel', up);
|
||||
};
|
||||
grip.addEventListener('pointermove', move);
|
||||
grip.addEventListener('pointerup', up);
|
||||
grip.addEventListener('pointercancel', up);
|
||||
});
|
||||
},
|
||||
|
||||
/** "Show folder" in the preview: the grid of the previewed file's folder. */
|
||||
openFolderOfPreview() {
|
||||
const target = this.filePreviewFolderTarget;
|
||||
@@ -4617,6 +4766,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Back on the grid (if one is open underneath): its current tile stays marked.
|
||||
if (this.folderGrid) this.folderGrid.index = null;
|
||||
this._updateFolderPreviewNav(null);
|
||||
this.filePreviewHistory = null;
|
||||
this._updateFilePreviewHistoryNav();
|
||||
const overlay = this.$('filePreviewOverlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('visible');
|
||||
|
||||
@@ -1114,11 +1114,33 @@ Object.assign(CodemanApp.prototype, {
|
||||
* history yet for compaction to trim. Same promise-based pattern as
|
||||
* `_confirmModelSwap`; `_resolveContextWarningConfirm` settles it.
|
||||
*/
|
||||
_confirmContextWarning(modelId, contextLength, minSafeContextTokens) {
|
||||
_confirmContextWarning(modelId, contextLength, minSafeContextTokens, slotCount) {
|
||||
const modal = document.getElementById('customModelContextWarningModal');
|
||||
const messageEl = document.getElementById('customModelContextWarningMessage');
|
||||
if (messageEl) {
|
||||
const known = typeof contextLength === 'number';
|
||||
const slots = Number.isFinite(slotCount) && slotCount > 1 ? Math.floor(slotCount) : 1;
|
||||
// Enough total context, but split across parallel server slots: llama-server shares its
|
||||
// context between --parallel slots, and this CLI sends concurrent requests (the main
|
||||
// session plus each parallel subagent), so several 40k-token requests spend the shared
|
||||
// pool and the server answers HTTP 500 "Context size has been exceeded".
|
||||
if (known && slots > 1 && contextLength >= minSafeContextTokens) {
|
||||
messageEl.textContent =
|
||||
`${modelId}'s ${contextLength.toLocaleString()} tokens of context are split across ${slots} parallel ` +
|
||||
`server slots, about ${Math.floor(contextLength / slots).toLocaleString()} each. This CLI ` +
|
||||
`needs roughly ${minSafeContextTokens.toLocaleString()}+ tokens just for its own system prompt and tools on ` +
|
||||
`every request, and it sends several requests at once (the main session plus each parallel subagent). ` +
|
||||
`Concurrent requests can use up the shared context, and the server then fails them with ` +
|
||||
`"Context size has been exceeded".\n\n` +
|
||||
`To fix this, start the model with a single slot: add "--parallel 1" to its llama-swap cmd. The active ` +
|
||||
`conversation then gets the whole context, other requests queue instead of failing, and the CLI's ` +
|
||||
`repeated prompt prefix stays cached. Alternatively give the model a larger context (raise --fit-ctx or ` +
|
||||
`--ctx-size) so every slot has at least ${minSafeContextTokens.toLocaleString()} tokens.`;
|
||||
modal?.classList.add('active');
|
||||
return new Promise((resolve) => {
|
||||
this._resolveContextWarningConfirmPromise = resolve;
|
||||
});
|
||||
}
|
||||
messageEl.textContent =
|
||||
`${modelId} is configured with ` +
|
||||
(known ? `only ${contextLength.toLocaleString()} tokens of` : 'an unknown (too small)') +
|
||||
@@ -1129,7 +1151,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
`instead of relying on auto-fit (--fit-ctx), which optimizes for the biggest MODEL that fits, not the ` +
|
||||
`biggest CONTEXT — e.g. add "-c 65536" (or as large a --ctx-size as your hardware holds) to its llama-swap ` +
|
||||
`config entry. A smaller model at a much larger explicit context often fits in the same VRAM a bigger ` +
|
||||
`model's auto-fit context gets shrunk to make room for.`;
|
||||
`model's auto-fit context gets shrunk to make room for.` +
|
||||
(slots > 1
|
||||
? `\n\nThis server also runs ${slots} parallel slots that share that context; add "--parallel 1" to ` +
|
||||
`the same cmd so a single conversation gets all of it.`
|
||||
: '');
|
||||
}
|
||||
modal?.classList.add('active');
|
||||
return new Promise((resolve) => {
|
||||
@@ -1254,8 +1280,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
let answered = {};
|
||||
let data = await post(bodyObj);
|
||||
if (data?.data?.requiresContextWarning) {
|
||||
const { modelId, contextLength, minSafeContextTokens } = data.data;
|
||||
const proceed = await this._confirmContextWarning(modelId, contextLength, minSafeContextTokens);
|
||||
const { modelId, contextLength, minSafeContextTokens, slotCount } = data.data;
|
||||
const proceed = await this._confirmContextWarning(
|
||||
modelId,
|
||||
contextLength,
|
||||
minSafeContextTokens,
|
||||
slotCount
|
||||
);
|
||||
if (!proceed) {
|
||||
this._lastCustomModelLaunchResult = undefined;
|
||||
return { success: false, error: 'Launch cancelled — context window too small' };
|
||||
@@ -1371,7 +1402,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const proceed = await this._confirmContextWarning(
|
||||
payload.modelId,
|
||||
payload.contextLength,
|
||||
payload.minSafeContextTokens
|
||||
payload.minSafeContextTokens,
|
||||
payload.slotCount
|
||||
);
|
||||
if (!proceed) {
|
||||
switchingToast?.dismiss();
|
||||
@@ -2631,6 +2663,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('modalAutoResumeEnabled').checked = session.autoResumeEnabled ?? false;
|
||||
this.updateAutoResumeStatus(sessionId);
|
||||
this.updatePromptCacheStatus(sessionId);
|
||||
// Effective opt-in: the server publishes it in the status (session flag, else the app default).
|
||||
document.getElementById('modalKeepWarmEnabled').checked =
|
||||
session.keepWarm?.enabled ?? session.keepWarmEnabled ?? false;
|
||||
this.updateKeepWarmStatus(sessionId);
|
||||
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
|
||||
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
|
||||
|
||||
@@ -2915,6 +2951,59 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.updatePromptCacheStatus(sessionId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Session Options → Respawn → Prompt cache: the keep-warm line on the Status row (what the
|
||||
* keep-warm service is doing, `session.keepWarm`, worded by `CodemanPromptCache.formatKeepWarm`,
|
||||
* hidden when it has nothing to say) and the state of the "Keep the cache warm while idle" switch.
|
||||
*/
|
||||
updateKeepWarmStatus(sessionId) {
|
||||
const el = document.getElementById('keepWarmStatus');
|
||||
if (!el || this.editingSessionId !== sessionId) return;
|
||||
const session = this.sessions.get(sessionId);
|
||||
const status = session?.keepWarm;
|
||||
const text = window.CodemanPromptCache.formatKeepWarm(status, Date.now(), session?.statusLineExporter);
|
||||
el.textContent = text;
|
||||
el.hidden = !text;
|
||||
// A launch without cache reports can never keep warm: grey the row and refuse to switch it on,
|
||||
// but a switch that is already on stays reachable so it can be turned off. Same inert-switch
|
||||
// pattern as the context switch in settings-ui.js _applyModelSelection().
|
||||
const box = document.getElementById('modalKeepWarmEnabled');
|
||||
if (!box) return;
|
||||
const inert = window.CodemanPromptCache.keepWarmUnavailable(session?.statusLineExporter);
|
||||
box.closest('.set-row')?.classList.toggle('set-row-disabled', inert);
|
||||
box.disabled = inert && !box.checked;
|
||||
},
|
||||
|
||||
_onSessionKeepWarm(data) {
|
||||
const { sessionId, ...status } = data;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
session.keepWarm = status;
|
||||
if (this.editingSessionId === sessionId) {
|
||||
const box = document.getElementById('modalKeepWarmEnabled');
|
||||
if (box) box.checked = status.enabled;
|
||||
}
|
||||
this.updateKeepWarmStatus(sessionId);
|
||||
},
|
||||
|
||||
async autoSaveKeepWarm() {
|
||||
if (!this.editingSessionId) return;
|
||||
const enabled = document.getElementById('modalKeepWarmEnabled').checked;
|
||||
try {
|
||||
// _apiPost unwraps the envelope; the route answers with the status for the NEW choice.
|
||||
const data = await this._apiPost(`/api/sessions/${this.editingSessionId}/keep-warm`, { enabled });
|
||||
const session = this.sessions.get(this.editingSessionId);
|
||||
if (session) {
|
||||
session.keepWarmEnabled = enabled;
|
||||
if (data?.keepWarm?.status) session.keepWarm = data.keepWarm.status;
|
||||
}
|
||||
this.updateKeepWarmStatus(this.editingSessionId);
|
||||
this.showToast(`Prompt-cache keep-warm ${enabled ? 'enabled' : 'disabled'}`, 'success');
|
||||
} catch (err) {
|
||||
this.showToast('Failed to toggle keep-warm: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async toggleSessionImageWatcher() {
|
||||
if (!this.editingSessionId) return;
|
||||
const enabled = document.getElementById('modalImageWatcherEnabled').checked;
|
||||
@@ -3272,6 +3361,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('caseAgentTeams').checked = settings.agentTeams;
|
||||
document.getElementById('caseOpusContext1m').checked = settings.opusContext1m;
|
||||
popover.classList.remove('hidden');
|
||||
this._loadCaseDefaultGroup();
|
||||
|
||||
// Close on outside click (one-shot listener)
|
||||
const closeHandler = (e) => {
|
||||
@@ -3320,6 +3410,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('caseAgentTeamsMobile').checked = settings.agentTeams;
|
||||
document.getElementById('caseOpusContext1mMobile').checked = settings.opusContext1m;
|
||||
popover.classList.remove('hidden');
|
||||
this._loadCaseDefaultGroup();
|
||||
|
||||
const closeHandler = (e) => {
|
||||
if (!popover.contains(e.target) && !e.target.classList.contains('btn-case-settings-mobile')) {
|
||||
@@ -3346,6 +3437,103 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (desktopOpusCheckbox) desktopOpusCheckbox.checked = settings.opusContext1m;
|
||||
},
|
||||
|
||||
// ── Default session group (server-side, per owner + case) ──
|
||||
//
|
||||
// Unlike the two checkboxes above (this browser's localStorage), the default
|
||||
// group lives on the server, because the server places a new session in it in
|
||||
// the same layout write that registers it. The value is a group ID; '' in the
|
||||
// <select> is Ungrouped (null on the wire). Desktop and phone popovers mirror.
|
||||
|
||||
_caseDefaultGroupSelects() {
|
||||
return ['caseDefaultGroup', 'caseDefaultGroupMobile']
|
||||
.map((id) => document.getElementById(id))
|
||||
.filter(Boolean);
|
||||
},
|
||||
|
||||
/** Rebuild both selectors from `groups`, keeping `groupId` if it still exists. */
|
||||
_renderCaseDefaultGroupSelects(groups, groupId) {
|
||||
const known = groups.some((group) => group.id === groupId);
|
||||
const value = known ? groupId : '';
|
||||
for (const select of this._caseDefaultGroupSelects()) {
|
||||
const ungrouped = document.createElement('option');
|
||||
ungrouped.value = '';
|
||||
ungrouped.textContent = 'Ungrouped';
|
||||
const options = groups.map((group) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = group.id;
|
||||
option.textContent = group.name;
|
||||
option.setAttribute('data-i18n-skip', '');
|
||||
return option;
|
||||
});
|
||||
select.replaceChildren(ungrouped, ...options);
|
||||
select.value = value;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
|
||||
/** Read the selected case's default group from the server (popover open). */
|
||||
async _loadCaseDefaultGroup() {
|
||||
const caseName = document.getElementById('quickStartCase')?.value;
|
||||
const selects = this._caseDefaultGroupSelects();
|
||||
if (!caseName || selects.length === 0) return;
|
||||
for (const select of selects) select.disabled = true;
|
||||
const data = await this._apiJson(`/api/cases/${encodeURIComponent(caseName)}/default-group`);
|
||||
if (document.getElementById('quickStartCase')?.value !== caseName) return; // case switched meanwhile
|
||||
const groups = Array.isArray(data?.groups) ? data.groups : [];
|
||||
const groupId = typeof data?.groupId === 'string' ? data.groupId : null;
|
||||
this._caseDefaultGroupState = { caseName, groupId, groups };
|
||||
this._renderCaseDefaultGroupSelects(groups, groupId);
|
||||
for (const select of selects) select.disabled = !data;
|
||||
},
|
||||
|
||||
/** Save a selector change; on refusal restore the last confirmed value. */
|
||||
async onCaseDefaultGroupChanged(select) {
|
||||
const state = this._caseDefaultGroupState;
|
||||
if (!state) return;
|
||||
const groupId = select.value || null;
|
||||
const selects = this._caseDefaultGroupSelects();
|
||||
for (const other of selects) {
|
||||
other.value = select.value;
|
||||
other.disabled = true;
|
||||
}
|
||||
const res = await this._apiPut(`/api/cases/${encodeURIComponent(state.caseName)}/default-group`, { groupId });
|
||||
let saved = null;
|
||||
if (res?.ok) {
|
||||
try {
|
||||
saved = (await res.json())?.data ?? null;
|
||||
} catch {
|
||||
saved = null;
|
||||
}
|
||||
}
|
||||
if (saved) {
|
||||
state.groupId = typeof saved.groupId === 'string' ? saved.groupId : null;
|
||||
if (Array.isArray(saved.groups)) state.groups = saved.groups;
|
||||
} else {
|
||||
this.showToast?.('Could not save the default session group.', 'error');
|
||||
}
|
||||
this._renderCaseDefaultGroupSelects(state.groups, state.groupId);
|
||||
for (const other of selects) other.disabled = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* The tab layout changed (a group was created, renamed or deleted): redraw the
|
||||
* options from it. A deleted selection shows as Ungrouped, which is where the
|
||||
* server now places new sessions (it forgets the stale ID at the next creation).
|
||||
* Every create, delete and reorder broadcasts a layout, so an unchanged group
|
||||
* list is left alone: rebuilding it would reset a dropdown the user has open.
|
||||
*/
|
||||
_refreshCaseDefaultGroupOptions(layout) {
|
||||
const state = this._caseDefaultGroupState;
|
||||
if (!state || !layout || !Array.isArray(layout.groups)) return;
|
||||
const groups = layout.groups.map((group) => ({ id: group.id, name: group.name }));
|
||||
const unchanged = groups.length === state.groups.length
|
||||
&& groups.every((group, i) => group.id === state.groups[i].id && group.name === state.groups[i].name);
|
||||
if (unchanged) return;
|
||||
state.groups = groups;
|
||||
const value = this._renderCaseDefaultGroupSelects(state.groups, state.groupId);
|
||||
if (!value) state.groupId = null;
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Create Case Modal
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -72,6 +72,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
_onHookStop(data) {
|
||||
// The end of Codeman's own keep-alive turn (prompt-cache keep-warm): nothing finished
|
||||
// that a person asked for, and an idle alert still pending is theirs to spend, so this
|
||||
// neither clears it nor raises "Response Complete".
|
||||
if (data.acknowledgedReason) return;
|
||||
// Clear all pending hooks when Claude finishes responding
|
||||
if (data.sessionId) {
|
||||
this.clearPendingHooks(data.sessionId);
|
||||
@@ -551,6 +555,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._mcpSyncSavedOn = settings.mcpSyncEnabled === true;
|
||||
document.getElementById('appSettingsMcpSync').checked = this._mcpSyncSavedOn;
|
||||
this.applyMcpSyncVisibility();
|
||||
// Config backups: absent means ON, 20 copies, 30 days.
|
||||
document.getElementById('appSettingsConfigBackup').checked = settings.configBackupEnabled !== false;
|
||||
document.getElementById('appSettingsConfigBackupDir').value = settings.configBackupDir || '';
|
||||
document.getElementById('appSettingsConfigBackupKeepCount').value = settings.configBackupKeepCount ?? 20;
|
||||
document.getElementById('appSettingsConfigBackupKeepDays').value = settings.configBackupKeepDays ?? 30;
|
||||
this.loadConfigBackups();
|
||||
this._applyDoctorAdminGate();
|
||||
this.loadWebhook();
|
||||
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
|
||||
@@ -597,6 +607,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true;
|
||||
document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true;
|
||||
document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false;
|
||||
// Prompt-cache keep-warm: synced, default OFF, cap default 2 h (DEFAULT_KEEP_WARM_MAX_HOURS).
|
||||
document.getElementById('appSettingsPromptCacheKeepWarm').checked = settings.promptCacheKeepWarm === true;
|
||||
document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value = settings.promptCacheKeepWarmMaxHours ?? 2;
|
||||
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
|
||||
this.loadTunnelStatus();
|
||||
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||
@@ -1358,10 +1371,178 @@ Object.assign(CodemanApp.prototype, {
|
||||
* wired to `codeman:me` for the same late-resolving role as the groups above.
|
||||
*/
|
||||
_applyDoctorAdminGate() {
|
||||
const group = document.getElementById('doctorGroup');
|
||||
if (!group) return;
|
||||
const me = window.__codemanUser || {};
|
||||
group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
|
||||
for (const id of ['doctorGroup', 'configBackupGroup']) {
|
||||
const group = document.getElementById(id);
|
||||
if (group) group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
|
||||
}
|
||||
},
|
||||
|
||||
// ── Config backups ──────────────────────────────────────────────────────────
|
||||
// Server-side files, so these read the SAVED settings: a folder or retention edit applies after Save.
|
||||
|
||||
/**
|
||||
* The retention keys a Save sends: a value only once it was saved before or edited here. After a reset
|
||||
* the form merely DISPLAYS what the passes use (the retention the backups recorded), and writing that back
|
||||
* would replace it with whatever a different folder or an older default says.
|
||||
*/
|
||||
_configBackupRetentionPayload(prev) {
|
||||
const count = document.getElementById('appSettingsConfigBackupKeepCount');
|
||||
const days = document.getElementById('appSettingsConfigBackupKeepDays');
|
||||
const out = {};
|
||||
if (prev.configBackupKeepCount !== undefined || count.dataset.touched) {
|
||||
out.configBackupKeepCount = Math.min(500, Math.max(2, parseInt(count.value, 10) || 20));
|
||||
}
|
||||
if (prev.configBackupKeepDays !== undefined || days.dataset.touched) {
|
||||
const n = parseInt(days.value, 10);
|
||||
out.configBackupKeepDays = Math.min(3650, Math.max(0, Number.isNaN(n) ? 30 : n));
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
/** Fill the backup list, and show the default folder as the input's placeholder. */
|
||||
async loadConfigBackups() {
|
||||
const out = this.$('configBackupList');
|
||||
if (!out) return;
|
||||
const res = await this._api('/api/config-backups');
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
out.textContent = body?.error || 'Backups are not available here (admin only in multi-user mode).';
|
||||
return;
|
||||
}
|
||||
const data = body.data;
|
||||
const dirInput = this.$('appSettingsConfigBackupDir') || document.getElementById('appSettingsConfigBackupDir');
|
||||
if (dirInput && data.defaultDir) dirInput.placeholder = `default: ${data.defaultDir}`;
|
||||
// A reset settings.json has none of the four keys, but the passes keep the retention the newest backup
|
||||
// recorded: show that, or a Save would write 20 / 30 over it.
|
||||
const saved = this.loadAppSettingsFromStorage();
|
||||
const countInput = document.getElementById('appSettingsConfigBackupKeepCount');
|
||||
const daysInput = document.getElementById('appSettingsConfigBackupKeepDays');
|
||||
if (countInput && saved.configBackupKeepCount === undefined && data.settings) countInput.value = data.settings.keepCount;
|
||||
if (daysInput && saved.configBackupKeepDays === undefined && data.settings) daysInput.value = data.settings.keepDays;
|
||||
if (!data.backups.length) {
|
||||
out.innerHTML = `No backups yet in <code>${escapeHtml(data.dir)}</code>.`;
|
||||
return;
|
||||
}
|
||||
const kb = (n) => (n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`);
|
||||
this._configBackups = data.backups;
|
||||
const rows = data.backups.map((b, i) => {
|
||||
const when = new Date(b.createdAt).toLocaleString();
|
||||
const names = b.files.map((f) => escapeHtml(f.name)).join(', ');
|
||||
const id = escapeHtml(b.id);
|
||||
// The list is newest first, so the next row is the backup this one followed.
|
||||
const prev = data.backups[i + 1];
|
||||
const vsPrev = prev ? `<button class="btn-toolbar btn-sm" onclick="app.showConfigDiff('${escapeHtml(prev.id)}','${id}')">Changes in this backup</button>` : '';
|
||||
return `<li><b>${escapeHtml(when)}</b> <span class="set-row-desc">(${escapeHtml(b.reason)}, ${kb(b.bytes)})</span>
|
||||
<button class="btn-toolbar btn-sm" onclick="app.restoreConfigBackup('${id}')">Restore</button>
|
||||
<button class="btn-toolbar btn-sm" onclick="app.showConfigDiff('${id}','current')">Compare with current</button>
|
||||
${vsPrev}
|
||||
<div class="set-row-desc">${names}</div></li>`;
|
||||
});
|
||||
const options = (sel) => [`<option value="current"${sel === 'current' ? ' selected' : ''}>Current config</option>`]
|
||||
.concat(data.backups.map((b) => `<option value="${escapeHtml(b.id)}"${sel === b.id ? ' selected' : ''}>${escapeHtml(new Date(b.createdAt).toLocaleString())} (${escapeHtml(b.reason)})</option>`))
|
||||
.join('');
|
||||
const picker = data.backups.length
|
||||
? `<div class="set-row-desc">Compare <select id="configDiffFrom">${options(data.backups[0].id)}</select> with <select id="configDiffTo">${options('current')}</select>
|
||||
<button class="btn-toolbar btn-sm" onclick="app.showConfigDiff(app.$('configDiffFrom').value, app.$('configDiffTo').value)">Show changes</button></div>`
|
||||
: '';
|
||||
out.innerHTML = `<div>${data.backups.length} backup${data.backups.length === 1 ? '' : 's'} in <code>${escapeHtml(data.dir)}</code></div>${picker}<ul>${rows.join('')}</ul><div id="configBackupDiff"></div>`;
|
||||
},
|
||||
|
||||
/** What differs between two states of the config files: a backup id or `current` (the running config). */
|
||||
async showConfigDiff(from, to) {
|
||||
const out = this.$('configBackupDiff');
|
||||
if (!out) return;
|
||||
out.textContent = 'Comparing…';
|
||||
const res = await this._api(`/api/config-backups/diff?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
out.textContent = body?.error || 'Could not compare these';
|
||||
return;
|
||||
}
|
||||
const label = (id) => {
|
||||
if (id === 'current') return 'the current config';
|
||||
const b = (this._configBackups || []).find((x) => x.id === id);
|
||||
return b ? new Date(b.createdAt).toLocaleString() : id;
|
||||
};
|
||||
const d = body.data;
|
||||
const verb = { added: 'added', removed: 'removed', changed: 'changed' };
|
||||
const files = d.files.filter((f) => f.status !== 'unchanged').map((f) => {
|
||||
let detail;
|
||||
if (f.detail === 'bytes') {
|
||||
detail = '<div class="set-row-desc">Differs (not a key/value file, so no detail).</div>';
|
||||
} else {
|
||||
const rows = f.changes.map((c) => {
|
||||
const val = (v) => `<code>${escapeHtml(v ?? '')}</code>`;
|
||||
const text = c.redacted ? `${verb[c.kind]} <span class="set-row-desc">(value hidden: credential)</span>`
|
||||
: c.kind === 'added' ? `added ${val(c.after)}`
|
||||
: c.kind === 'removed' ? `removed (was ${val(c.before)})`
|
||||
: `${val(c.before)} → ${val(c.after)}`;
|
||||
return `<li><code>${escapeHtml(c.path)}</code>: ${text}</li>`;
|
||||
});
|
||||
const more = f.truncated ? '<li class="set-row-desc">More changes not shown.</li>' : '';
|
||||
detail = f.changes.length ? `<ul>${rows.join('')}${more}</ul>` : '';
|
||||
}
|
||||
const whole = f.status === 'added' ? ' (new file)' : f.status === 'removed' ? ' (file gone)' : '';
|
||||
return `<div><b>${escapeHtml(f.name)}</b>${whole}${detail}</div>`;
|
||||
});
|
||||
const same = d.files.filter((f) => f.status === 'unchanged').map((f) => escapeHtml(f.name));
|
||||
out.innerHTML = `<div><b>${escapeHtml(label(d.from))}</b> → <b>${escapeHtml(label(d.to))}</b>: ${
|
||||
d.changed ? `${d.changed} file${d.changed === 1 ? '' : 's'} differ` : 'no differences'}</div>${files.join('')}${
|
||||
same.length ? `<div class="set-row-desc">Unchanged: ${same.join(', ')}</div>` : ''}`;
|
||||
},
|
||||
|
||||
async backupConfigNow() {
|
||||
const btn = this.$('configBackupNowBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const res = await this._apiPost('/api/config-backups', {});
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
this.showToast(body?.error || 'Backup failed', 'error');
|
||||
} else if (body.data.status === 'empty') {
|
||||
this.showToast('Nothing to back up yet', 'warning');
|
||||
} else {
|
||||
this.showToast(`Backed up ${body.data.files} file${body.data.files === 1 ? '' : 's'}`, 'success');
|
||||
}
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
this.loadConfigBackups();
|
||||
}
|
||||
},
|
||||
|
||||
async restoreConfigBackup(id) {
|
||||
const names = ((this._configBackups || []).find((b) => b.id === id)?.files || []).map((f) => f.name);
|
||||
const secret = names.filter((n) => ['.env', 'users.json', 'push-keys.json', 'custom-model-hosts.json', 'webhook.json'].includes(n));
|
||||
const list = names.length ? `\n\nFiles replaced: ${names.join(', ')}` : '';
|
||||
const warn = secret.length ? `\n\nThis rolls back credentials and accounts (${secret.join(', ')}): passwords, disabled accounts and keys return to how they were then.` : '';
|
||||
if (!confirm(`Restore this backup? Its files replace the current ones (a copy of the current files is made first). Some files only take effect after a restart.${list}${warn}`)) return;
|
||||
const res = await this._apiPost(`/api/config-backups/${encodeURIComponent(id)}/restore`, {});
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
const ok = res && res.ok && body && body.success !== false;
|
||||
const d = body?.data;
|
||||
// A restore that failed partway still answers with what it wrote (`restored`, `failed`, `safetySnapshot`):
|
||||
// those files are the backup's now and need the same refill as a full restore.
|
||||
if (!ok && !Array.isArray(d?.restored)) {
|
||||
this.showToast(body?.error || 'Restore failed', 'error');
|
||||
return;
|
||||
}
|
||||
const restart = d.restartRequired?.length ? ` Restart Codeman to apply: ${d.restartRequired.join(', ')}.` : '';
|
||||
// The form still holds the pre-restore values, and Save would PUT them over the restored file.
|
||||
if (d.restored.includes('settings.json')) {
|
||||
await this.loadAppSettingsFromServer();
|
||||
this.openAppSettings();
|
||||
}
|
||||
if (ok) {
|
||||
this.showToast(`Restored ${d.restored.length} file${d.restored.length === 1 ? '' : 's'}.${restart}`, 'success');
|
||||
} else {
|
||||
this.showToast(`${body.error || 'Restore failed partway'}.${restart}`, 'error');
|
||||
}
|
||||
this.loadConfigBackups();
|
||||
},
|
||||
|
||||
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
|
||||
@@ -2737,6 +2918,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
customModelEndpointsEnabled: document.getElementById('appSettingsCustomModelEndpoints').checked,
|
||||
cliManagementEnabled: document.getElementById('appSettingsCliManagement').checked,
|
||||
mcpSyncEnabled: document.getElementById('appSettingsMcpSync').checked,
|
||||
configBackupEnabled: document.getElementById('appSettingsConfigBackup').checked,
|
||||
configBackupDir: document.getElementById('appSettingsConfigBackupDir').value.trim(),
|
||||
...this._configBackupRetentionPayload(_prev),
|
||||
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
|
||||
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
@@ -2760,6 +2944,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||||
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
|
||||
promptCacheKeepWarm: document.getElementById('appSettingsPromptCacheKeepWarm').checked,
|
||||
promptCacheKeepWarmMaxHours:
|
||||
// Clamped to the schema's range: one value outside it fails the whole save.
|
||||
Math.min(24, Math.max(0.5, parseFloat(document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value) || 2)),
|
||||
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
|
||||
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
|
||||
autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked,
|
||||
|
||||
@@ -6669,6 +6669,27 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Default session group selector (Case settings, desktop and phone popovers) */
|
||||
.case-default-group-label {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.case-default-group-select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 3px 6px;
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text);
|
||||
background: var(--bg-input, var(--bg-card));
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.case-settings-popover .form-hint {
|
||||
margin-top: 0.2rem;
|
||||
font-size: 0.6rem;
|
||||
@@ -12223,10 +12244,15 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
}
|
||||
|
||||
.file-preview-window {
|
||||
width: 80vw;
|
||||
max-width: 900px;
|
||||
height: 80vh;
|
||||
max-height: 700px;
|
||||
position: relative;
|
||||
width: min(80vw, 900px);
|
||||
height: min(80vh, 700px);
|
||||
/* A dragged size (inline width/height) and the expanded state may exceed the
|
||||
default 900x700, but never the screen. */
|
||||
max-width: 96vw;
|
||||
max-height: 94vh;
|
||||
min-width: min(320px, 96vw);
|
||||
min-height: min(240px, 94vh);
|
||||
background: var(--floating-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
@@ -12238,6 +12264,36 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Expanded: fills the screen (a dragged size is ignored while it is on). */
|
||||
.file-preview-window.expanded {
|
||||
width: 96vw !important;
|
||||
height: 94vh !important;
|
||||
}
|
||||
|
||||
/* Corner grip (panels-ui.js _bindFilePreviewResize). The window is centred, so a
|
||||
drag moves both edges: the handler doubles the pointer delta to keep the corner
|
||||
under the pointer. Only phones hide it (with Expand, mobile.css under 600px), since
|
||||
the window is full screen there; tablets show and use it. */
|
||||
.file-preview-resize {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: nwse-resize;
|
||||
touch-action: none;
|
||||
background: linear-gradient(135deg, transparent 50%, var(--text-muted, #888) 50%, var(--text-muted, #888) 58%, transparent 58%, transparent 72%, var(--text-muted, #888) 72%, var(--text-muted, #888) 80%, transparent 80%);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.file-preview-resize:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.file-preview-window.expanded .file-preview-resize {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -14926,6 +14982,14 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* A markdown link whose target is a path (`[Our incident record](/tmp/x.json)`)
|
||||
takes the rv-path shape for the click delegate, but its text is a label, not a
|
||||
path: keep it in the prose font and never break it mid-word. */
|
||||
.rv-text a.rv-path.rv-path-link {
|
||||
font: inherit;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
/* Tables — scroll wrapper keeps table proper while allowing horizontal overflow */
|
||||
.rv-table-wrap {
|
||||
margin: 1em 0;
|
||||
|
||||
@@ -18,13 +18,14 @@
|
||||
* What it does carry over from the primary pane, through the primary pane's
|
||||
* own code aimed at THIS pane (its terminal, its session, never the active
|
||||
* one):
|
||||
* - SGR wheel forwarding (_maybeForwardWheelToCli): Claude's fullscreen
|
||||
* renderer scrolls its own transcript on SGR wheel reports, while this
|
||||
* xterm holds only replayed repaint frames, so the wheel goes to the CLI
|
||||
* as reports at the pointer's cell in this pane, through the primary
|
||||
* pane's forwarding gate and its encoding (sgrWheelReports). Shift+wheel
|
||||
* scrolls the local scrollback itself (_maybeScrollLocalOnShift), as the
|
||||
* primary pane does, since xterm turns it into a horizontal no-op.
|
||||
* - SGR wheel forwarding (_maybeForwardWheelToCli): a tracked CLI
|
||||
* (fullscreen Claude, tracked Codex) scrolls its own transcript on SGR
|
||||
* wheel reports, while this xterm holds only replayed repaint frames, so
|
||||
* the wheel goes to the CLI as reports at the pointer's cell in this pane,
|
||||
* through the primary pane's forwarding gate and its encoding
|
||||
* (sgrWheelReports). Shift+wheel scrolls the local scrollback itself
|
||||
* (_maybeScrollLocalOnShift), as the primary pane does, since xterm turns
|
||||
* it into a horizontal no-op.
|
||||
* - Hollow-buffer paging (#555): a CLI that draws in place (opencode and codex
|
||||
* on the alternate screen, Claude's repaint mode) leaves the xterm no
|
||||
* scrollback, so the wheel pages the CLI's own transcript with
|
||||
@@ -51,7 +52,7 @@
|
||||
*
|
||||
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
|
||||
* @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, SHELL_LOAD_BASE_QUERY, SHELL_LOAD_QUERY, TERMINAL_CHUNK_SIZE)
|
||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelDeltaWholeLines/sgrWheelReports/pageKeysForGesture, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick, app.tabAlerts)
|
||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelTicksForGesture/coalesceScrollBytes/sgrWheelReports/pageKeysForGesture, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick, app.tabAlerts)
|
||||
* @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands)
|
||||
* @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js
|
||||
*/
|
||||
@@ -229,6 +230,7 @@
|
||||
}
|
||||
|
||||
async connect() {
|
||||
this._wheelNativeStarted = false;
|
||||
const savedFontSize = this.fontSize ?? parseInt(localStorage.getItem('codeman-font-size'), 10);
|
||||
this.terminal = new Terminal({
|
||||
theme: { ...global.codemanCurrentXtermTheme() },
|
||||
@@ -1051,7 +1053,7 @@
|
||||
// Capture phase, because xterm's own wheel handler stopPropagation()s every
|
||||
// event it consumes, so a bubbling listener here would never see the wheel
|
||||
// while the pane still has scrollback to scroll. Not passive: the three
|
||||
// routes this pane takes over, forwarding the wheel to Claude's fullscreen
|
||||
// routes this pane takes over, forwarding the wheel to a tracked CLI's
|
||||
// renderer (_maybeForwardWheelToCli), paging a hollow buffer's CLI
|
||||
// transcript (_maybePageCliTranscript) and Shift+wheel's local scrollback
|
||||
// (_maybeScrollLocalOnShift), are consumed right here (preventDefault plus
|
||||
@@ -1073,11 +1075,11 @@
|
||||
|
||||
// SGR wheel forwarding, the twin of the primary pane's capture-phase wheel
|
||||
// handler and _forwardScrollToApp (terminal-ui.js; keep them in step).
|
||||
// Claude's fullscreen renderer (claude 2.1.187+ while its mouse tracking is
|
||||
// on, cliMouseTracking) scrolls its own transcript on SGR wheel reports,
|
||||
// Fullscreen Claude (2.1.187+) and measured tracked Codex scroll their own
|
||||
// transcripts on SGR wheel reports while cliMouseTracking is on,
|
||||
// while this xterm holds only Codeman's replayed repaint frames (tmux keeps
|
||||
// no history for such a pane). Left to xterm, the wheel dragged those stale
|
||||
// frames, Claude's pinned input box with them, up the tile, or scrolled
|
||||
// frames, including the CLI's pinned input box, up the tile, or scrolled
|
||||
// nothing at all. The gate is the primary pane's own, asked for THIS pane
|
||||
// (its terminal, its session, never the active one), so the CLI rules stay
|
||||
// in terminal-ui.js and this file names no CLI; the reports go to this
|
||||
@@ -1090,19 +1092,24 @@
|
||||
if (this._destroyed || !this.terminal || !ev) return false;
|
||||
const app = global.app;
|
||||
const input = global.CodemanTerminalInput;
|
||||
if (!app?._shouldForwardWheelToApp || !input?.sgrWheelReports || !input.wheelDeltaWholeLines) return false;
|
||||
if (!app?._shouldForwardWheelToApp || !input?.sgrWheelReports || !input.wheelTicksForGesture) return false;
|
||||
// xterm's own encoder forwards the wheel while the CLI's tracking reaches
|
||||
// it, and its alt-scroll owns the alternate buffer, as in the primary pane.
|
||||
const tracking = this.terminal.modes?.mouseTrackingMode;
|
||||
if (tracking && tracking !== 'none') return false;
|
||||
if (this.terminal.buffer?.active?.type === 'alternate') return false;
|
||||
if (!app._shouldForwardWheelToApp(ev, { terminal: this.terminal, sessionId: this.sessionId })) return false;
|
||||
if (ev.ctrlKey || Math.abs(ev.deltaX || 0) > Math.abs(ev.deltaY || 0)) return true;
|
||||
// SGR coordinates address the live screen, so a report from a scrolled-up
|
||||
// viewport would hit-test another row: snap home first (_forwardScrollToApp).
|
||||
if (!app._terminalViewportAtBottom?.(this.terminal)) this.terminal.scrollToBottom?.();
|
||||
const lines = input.wheelDeltaWholeLines(ev, this.terminal.rows);
|
||||
const lines = input.wheelDeltaLines(ev, this.terminal.rows);
|
||||
if (!lines) return true;
|
||||
if (this._wheelTickGesture?.dir && this._wheelTickGesture.dir !== Math.sign(lines)) this._scrollBytes = '';
|
||||
const step = input.wheelTicksForGesture(this._wheelTickGesture, lines, performance.now());
|
||||
this._wheelTickGesture = step.state;
|
||||
const pos = app._clientPointToCell?.(ev.clientX, ev.clientY, this.terminal);
|
||||
const bytes = input.sgrWheelReports(lines, pos);
|
||||
const bytes = input.sgrWheelReports(step.ticks, pos);
|
||||
if (bytes) this._queueScrollBytes(bytes);
|
||||
return true;
|
||||
}
|
||||
@@ -1180,9 +1187,9 @@
|
||||
const tracking = this.terminal.modes?.mouseTrackingMode;
|
||||
if (tracking && tracking !== 'none') return false;
|
||||
const target = { terminal: this.terminal, sessionId: this.sessionId };
|
||||
// A wheel for Claude's fullscreen renderer was forwarded as SGR reports
|
||||
// before this ran (_maybeForwardWheelToCli); the gate is repeated so a
|
||||
// forwarding session is never paged.
|
||||
// A wheel for any tracked CLI (a session the forwarding gate accepts) was
|
||||
// forwarded as SGR reports before this ran (_maybeForwardWheelToCli); the
|
||||
// gate is repeated so a forwarding session is never paged.
|
||||
if (app._shouldForwardWheelToApp?.(ev, target)) return false;
|
||||
if (!app._localScrollbackIsHollow?.({ ...target, localRows: this._localRows() })) return false;
|
||||
// Only from the live screen. The one gate the primary pane never needs: a
|
||||
@@ -1202,6 +1209,8 @@
|
||||
// session, which is usually not the active one: a page key would move its
|
||||
// selector. Consumed, so xterm does not scroll the rows under it either.
|
||||
if (app.tabAlerts?.get(this.sessionId) === 'action') return true;
|
||||
if (ev.ctrlKey || Math.abs(ev.deltaX || 0) > Math.abs(ev.deltaY || 0)) return true;
|
||||
if (this._pageKeyGesture?.dir && this._pageKeyGesture.dir !== Math.sign(lines)) this._scrollBytes = '';
|
||||
const step = input.pageKeysForGesture(this._pageKeyGesture, ev, lines, this.terminal.rows, performance.now());
|
||||
this._pageKeyGesture = step.state;
|
||||
if (step.keys) this._queueScrollBytes(step.keys);
|
||||
@@ -1209,16 +1218,26 @@
|
||||
}
|
||||
|
||||
// Coalesces the scroll bytes (SGR wheel reports and page keys alike) into
|
||||
// one send per 40 ms, bounded at 512 bytes so a fling cannot build a backlog
|
||||
// that keeps scrolling after it stops. A narrow
|
||||
// one send per 40 ms after the leading native report, preserving upstream
|
||||
// report density and cancelling only unsent movement on reversal. A narrow
|
||||
// twin of the primary pane's _queueScrollBytes / _flushWheelSgrQueue
|
||||
// (terminal-ui.js; keep the two in step), which flushes to the active
|
||||
// session only. Sent ephemeral (no seq, never persisted) to THIS pane's
|
||||
// session, over this pane's socket while it is open.
|
||||
_queueScrollBytes(data) {
|
||||
if (!data || this._destroyed) return;
|
||||
if (this._scrollBytes.length > 512) return;
|
||||
this._scrollBytes += data;
|
||||
this._scrollBytes = global.CodemanTerminalInput.coalesceScrollBytes(this._scrollBytes, data);
|
||||
// Tiles have a fixed session: the leading report is once per connect(),
|
||||
// unlike the primary pane, which resets it on every tab selection.
|
||||
if (data.startsWith('\x1b[<') && !this._wheelNativeStarted) {
|
||||
this._wheelNativeStarted = true;
|
||||
if (this._scrollFlushTimer) clearTimeout(this._scrollFlushTimer);
|
||||
this._scrollFlushTimer = null;
|
||||
const bytes = this._scrollBytes;
|
||||
this._scrollBytes = '';
|
||||
global.app?._sendInputEphemeral?.(this.sessionId, bytes);
|
||||
return;
|
||||
}
|
||||
if (this._scrollFlushTimer) return;
|
||||
this._scrollFlushTimer = setTimeout(() => {
|
||||
this._scrollFlushTimer = null;
|
||||
|
||||
+147
-84
@@ -121,17 +121,7 @@
|
||||
: delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad)
|
||||
}
|
||||
|
||||
// The same travel rounded to whole lines for the SGR wheel reports: a pure
|
||||
// horizontal swipe is 0 (nothing to send), and anything else moves at least
|
||||
// one line, so the small pixel deltas of a precision touchpad still scroll.
|
||||
// The body of the primary pane's _wheelScrollLines.
|
||||
function wheelDeltaWholeLines(ev, rows) {
|
||||
const lines = wheelDeltaLines(ev, rows);
|
||||
if (!lines) return 0;
|
||||
return Math.round(lines) || (lines > 0 ? 1 : -1);
|
||||
}
|
||||
|
||||
// Ticks one gesture batch may report: Claude applies its own scroll-speed
|
||||
// Ticks one input event may report: Claude applies its own scroll-speed
|
||||
// multiplier and acceleration on top, so a bigger batch only overshoots.
|
||||
const SGR_WHEEL_MAX_TICKS = 5;
|
||||
|
||||
@@ -147,6 +137,43 @@
|
||||
return `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks);
|
||||
}
|
||||
|
||||
// A deliberate opening event preserves upstream rounding and pre-pays at
|
||||
// least one tick, so isolated clicks retain their travel. The signed remainder
|
||||
// is repaid by subsequent same-direction travel: ten 0.1-line events still total one tick. Debt and
|
||||
// fractions expire with a 150ms gesture gap or reversal; a reversal within
|
||||
// the gesture accumulates without another pre-payment (like page gestures).
|
||||
function wheelTicksForGesture(state, lines, now) {
|
||||
if (!Number.isFinite(lines) || !lines) return { state: state || {}, ticks: 0 };
|
||||
const dir = Math.sign(lines);
|
||||
const fresh = !state || typeof state.lastAt !== 'number' || now - state.lastAt > PAGE_KEY_GESTURE_GAP_MS;
|
||||
const reversed = state?.dir && state.dir !== dir;
|
||||
if (fresh && Math.abs(lines) >= PAGE_KEY_MIN_START_ROWS) {
|
||||
const rounded = dir * Math.max(1, Math.round(Math.abs(lines)));
|
||||
return {
|
||||
state: { dir, lastAt: now, pending: lines - rounded },
|
||||
ticks: dir * Math.min(Math.abs(rounded), SGR_WHEEL_MAX_TICKS),
|
||||
};
|
||||
}
|
||||
const total = (fresh || reversed ? 0 : state.pending || 0) + lines;
|
||||
const whole = Math.trunc(total + Math.sign(total) * 1e-9);
|
||||
return {
|
||||
state: { dir, lastAt: now, pending: Math.abs(total - whole) < 1e-9 ? 0 : total - whole },
|
||||
ticks: whole === 0 ? 0 : Math.sign(whole) * Math.min(Math.abs(whole), SGR_WHEEL_MAX_TICKS),
|
||||
};
|
||||
}
|
||||
|
||||
// Only unsent scroll input can be cancelled. Preserve upstream report density
|
||||
// within each batch, and replace an obsolete direction immediately.
|
||||
// Local and remote transports share the same direction-aware gesture queue.
|
||||
function coalesceScrollBytes(queued, data) {
|
||||
const direction = (bytes) => bytes?.match(/\x1b\[<(64|65);|\x1b\[(5|6)~/)?.[0];
|
||||
const previous = direction(queued);
|
||||
const next = direction(data);
|
||||
const combined = previous && previous === next ? queued + data : data;
|
||||
const reports = combined.match(/\x1b\[<(?:64|65);\d+;\d+M|\x1b\[[56]~/g) || [];
|
||||
return reports.join('');
|
||||
}
|
||||
|
||||
// Rows of travel per page key for a terminal `rows` tall (half a screen).
|
||||
function pageKeyRows(rows) {
|
||||
return Math.max(2, Math.round((rows || 24) * PAGE_KEY_SCREEN_FRACTION));
|
||||
@@ -388,9 +415,10 @@
|
||||
PAGE_KEY_SCREEN_FRACTION,
|
||||
PAGE_KEY_MAX_PER_BATCH,
|
||||
wheelDeltaLines,
|
||||
wheelDeltaWholeLines,
|
||||
SGR_WHEEL_MAX_TICKS,
|
||||
sgrWheelReports,
|
||||
wheelTicksForGesture,
|
||||
coalesceScrollBytes,
|
||||
pageKeysForTravel,
|
||||
pageKeysForGesture,
|
||||
PAGE_KEY_GESTURE_GAP_MS,
|
||||
@@ -1098,7 +1126,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// and stops propagation, keeping xterm's scroller out of it entirely.
|
||||
// Local scrolling goes through terminal.scrollLines() — buffer-level, so
|
||||
// it keeps working after resets — with our own deltaMode normalization
|
||||
// (_wheelScrollLines) covering Firefox's line-unit wheels. Two cases still
|
||||
// (_wheelScrollLinesFloat) covering Firefox's line-unit wheels. Two cases still
|
||||
// belong to xterm and are passed through untouched:
|
||||
// - mouseTrackingMode active: xterm's own encoder forwards the wheel to
|
||||
// the PTY (htop/vim with mouse on in a shell pane);
|
||||
@@ -1114,8 +1142,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (this._shouldForwardWheelToApp(ev)) {
|
||||
if (ev.ctrlKey || Math.abs(ev.deltaX || 0) > Math.abs(ev.deltaY || 0)) return;
|
||||
this._logScrollRouting('forward-sgr');
|
||||
this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollLines(ev));
|
||||
this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollLinesFloat(ev));
|
||||
return;
|
||||
}
|
||||
// Local scrolling accumulates FRACTIONAL lines: a macOS trackpad emits
|
||||
@@ -1141,9 +1170,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
// (_shouldForwardWheelToApp), a touch drag must go there too, or every
|
||||
// phone/tablet swipe scrolls the local buffer of stale repaint frames and
|
||||
// drags the CLI's pinned input box off the screen (issue #205's mobile
|
||||
// half). Same gate, so Shift has no touch analog but the local-scrollback
|
||||
// opt-out setting and the version/tracking gate apply to touch exactly as they
|
||||
// do to the wheel — including the PageUp/PageDown fallback the wheel uses
|
||||
// half). Touch asks _shouldForwardTouchToApp, which is the wheel's gate
|
||||
// plus one branch of its own: a drag also forwards as SGR wheel reports
|
||||
// while xterm itself tracks the mouse with SGR encoding (an app in the
|
||||
// pane asked for it), since the wheel needs no help there (xterm encodes
|
||||
// it) but a drag does. Shift has no touch analog, but the local-scrollback
|
||||
// opt-out setting and the version/tracking gate apply to touch as they
|
||||
// do to the wheel, including the PageUp/PageDown fallback the wheel uses
|
||||
// when that gate is false and there is no local scrollback to scroll
|
||||
// (_maybePageCliTranscript), which is what keeps a swipe from being a
|
||||
// complete no-op on a phone.
|
||||
@@ -1168,7 +1201,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Momentum phase — convert pixel velocity to lines
|
||||
const lines = Math.round(velocity / cellHeight());
|
||||
if (lines !== 0) {
|
||||
if (this._shouldForwardWheelToApp({ shiftKey: false })) {
|
||||
if (this._shouldForwardTouchToApp()) {
|
||||
// Flick momentum keeps feeding the CLI's transcript from the last
|
||||
// touch point; the 40ms coalescer batches the per-frame reports.
|
||||
this._forwardScrollToApp(touchLastX, touchLastY, lines);
|
||||
@@ -1196,8 +1229,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
let longPressStartY = 0;
|
||||
let touchStartY = 0;
|
||||
let touchStartX = 0;
|
||||
// 'x' | 'y' | null — locked on the first travel past the tap slop, so a
|
||||
// diagonal drag cannot pan and scroll at the same time.
|
||||
// 'x' | 'y' | 'swipe' | null — locked on the first travel past the tap
|
||||
// slop, so a diagonal drag cannot pan and scroll at the same time. 'swipe'
|
||||
// is a sideways drag on a terminal that cannot pan: it is the session
|
||||
// swipe's (SwipeHandler, mobile-handlers.js), so it neither scrolls nor
|
||||
// ends as a tap on the session being left, until it drifts past the
|
||||
// swipe's vertical limit and becomes a 'y' scroll. Published per gesture as
|
||||
// `this._terminalGestureAxis`, which SwipeHandler reads to stand aside
|
||||
// for a scroll or a pan.
|
||||
let panAxis = null;
|
||||
/**
|
||||
* Can this gesture pan sideways? Only while the terminal is wider than
|
||||
@@ -1242,6 +1281,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
touchStartY = touchLastY;
|
||||
touchStartX = touchLastX;
|
||||
panAxis = null;
|
||||
this._terminalGestureAxis = null;
|
||||
velocity = 0;
|
||||
pixelAccum = 0;
|
||||
isTouching = true;
|
||||
@@ -1315,10 +1355,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!didScroll) {
|
||||
const travelY = Math.abs(touchY - touchStartY);
|
||||
const travelX = Math.abs(touchX - touchStartX);
|
||||
const sideways = canPanHorizontally() && travelX >= TAP_THRESHOLD;
|
||||
if (travelY >= TAP_THRESHOLD || sideways) {
|
||||
if (travelY >= TAP_THRESHOLD || travelX >= TAP_THRESHOLD) {
|
||||
didScroll = true;
|
||||
panAxis = sideways && travelX > travelY ? 'x' : 'y';
|
||||
panAxis = travelX > travelY ? (canPanHorizontally() ? 'x' : 'swipe') : 'y';
|
||||
this._terminalGestureAxis = panAxis;
|
||||
}
|
||||
}
|
||||
// Below the tap threshold, treat the gesture as a potential tap:
|
||||
@@ -1329,6 +1369,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
// fling, so a jittery tap would both position the cursor AND scroll.
|
||||
if (!didScroll) return;
|
||||
ev.preventDefault();
|
||||
if (panAxis === 'swipe') {
|
||||
// Past the swipe's own vertical drift limit the session swipe has
|
||||
// given up, so the drag is a scroll after all (a thumb arc whose
|
||||
// first move leaned sideways): hand it to 'y'.
|
||||
const drift = typeof SwipeHandler !== 'undefined' ? SwipeHandler.maxVerticalDrift : 50;
|
||||
if (Math.abs(touchY - touchStartY) <= drift) return;
|
||||
panAxis = 'y';
|
||||
this._terminalGestureAxis = 'y';
|
||||
touchLastX = touchX;
|
||||
touchLastY = touchY;
|
||||
return;
|
||||
}
|
||||
if (panAxis === 'x') {
|
||||
// Pan the container, and touch nothing the vertical path owns —
|
||||
// no pixelAccum, no velocity, so touchend cannot turn a sideways
|
||||
@@ -1347,7 +1399,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const ch = cellHeight();
|
||||
const lines = Math.trunc(pixelAccum / ch);
|
||||
if (lines !== 0) {
|
||||
if (this._shouldForwardWheelToApp({ shiftKey: false })) {
|
||||
if (this._shouldForwardTouchToApp()) {
|
||||
this._logScrollRouting('forward-sgr');
|
||||
this._forwardScrollToApp(touchLastX, touchLastY, lines);
|
||||
} else if (!this._maybePageCliTranscript({ shiftKey: false, touchId: touchContactId }, lines)) {
|
||||
@@ -5786,41 +5838,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
return true;
|
||||
},
|
||||
|
||||
// Wheel forwarding gate for the container wheel handler: no Shift override,
|
||||
// xterm's own encoder dormant, viewport at the bottom, and a TUI VERIFIED to
|
||||
// scroll its transcript on SGR wheel reports, which today is claude 2.1.187+
|
||||
// with mouse tracking on (fullscreen) and nothing else (older Claude Code
|
||||
// captures wheel as select-menu option navigation, an unknown version is
|
||||
// treated as older, and inline Claude ignores it). Gemini and codex are
|
||||
// strip modes too but keep the local wheel — taps/clicks are still forwarded
|
||||
// for them (harmless no-ops at worst).
|
||||
//
|
||||
// Codex USED to forward here and was the #227 regression (DodgyBadger, Codex
|
||||
// latest / Chrome / Win11: dead wheel in codex, working scrollbar drag).
|
||||
// Measured on codex-cli 0.147.0 in a bare tmux: it never enables mouse
|
||||
// tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change
|
||||
// NOTHING on screen — it runs an inline viewport (`alternate_on=0`) and pushes
|
||||
// its transcript into the terminal's own scrollback (tmux `history_size`
|
||||
// grows), so there is no in-app pager to drive and local scrollback IS the
|
||||
// codex transcript. Forwarding therefore swallowed every tick.
|
||||
// Wheel delta → whole scroll lines. macOS trackpads turn Shift+two-finger
|
||||
// scroll into a HORIZONTAL wheel (deltaY≈0, deltaX carries the magnitude), and
|
||||
// Shift routes the wheel to local scrollback (_shouldForwardWheelToApp returns
|
||||
// false on Shift). So under Shift, read whichever axis dominates — otherwise
|
||||
// deltaY≈0 collapses to a fixed ±1 line/tick and the gesture can't page through
|
||||
// history on a trackpad (issue #154). Non-Shift and mouse-wheel paths are
|
||||
// unchanged (they carry deltaY). The `|| ±1` keeps sub-25px deltas moving.
|
||||
//
|
||||
// `deltaMode` says what UNIT the delta is in, and ignoring it made every
|
||||
// non-pixel browser scroll ~4x too slowly: Firefox reports DOM_DELTA_LINE (1)
|
||||
// with deltaY≈3 per notch, so the pixel math rounded to 0 and fell through to
|
||||
// the ±1 fallback — one line per notch, versus 4-5 for Chrome's ~110px. In
|
||||
// Claude mode the same value also capped the forwarded SGR report at one tick.
|
||||
_wheelScrollLines(ev) {
|
||||
// Pure horizontal swipe: 0, never the ±1 fallback (wheelDeltaWholeLines).
|
||||
return window.CodemanTerminalInput.wheelDeltaWholeLines(ev, this.terminal?.rows);
|
||||
},
|
||||
|
||||
/** Unrounded variant for the smooth local-scroll path, which accumulates
|
||||
* sub-line fractions across events instead of forcing every tiny trackpad
|
||||
* delta to a whole ±1 line. Same unit handling and Shift-axis trap. The
|
||||
@@ -5830,6 +5847,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
return window.CodemanTerminalInput.wheelDeltaLines(ev, this.terminal?.rows);
|
||||
},
|
||||
|
||||
// The touch listeners' forwarding gate: the wheel's gate below, plus one
|
||||
// branch of its own. While xterm itself tracks the mouse (an app in the pane
|
||||
// asked for it, a shell's htop or vim), xterm encodes a native wheel event on
|
||||
// its own, but the touch listeners consume finger drags themselves, so those
|
||||
// are forwarded explicitly. Scrolling locally instead shows nothing useful in
|
||||
// either kind of session: on a direct PTY the app draws on xterm's alternate
|
||||
// screen, which scrollLines() cannot move, and in a tmux-backed session xterm
|
||||
// stays in the normal buffer, so a local scroll only drags stale tmux
|
||||
// repaints up the screen.
|
||||
_shouldForwardTouchToApp() {
|
||||
if (this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback) return false;
|
||||
const mode = this.terminal?.modes?.mouseTrackingMode;
|
||||
// The existing synthetic sender uses SGR; tracking alone does not imply
|
||||
// that encoding (legacy/X10 applications must not receive SGR bytes).
|
||||
const sgr = this.terminal?._core?.coreMouseService?.activeEncoding === 'SGR';
|
||||
return !!(mode && mode !== 'none' && mode !== 'x10' && sgr) || this._shouldForwardWheelToApp({ shiftKey: false });
|
||||
},
|
||||
|
||||
// `target` ({ terminal, sessionId }) asks the question for a TerminalTile:
|
||||
// its own terminal's tracking mode and its own session, never the active one.
|
||||
// Either field left out means the primary pane's.
|
||||
@@ -5860,11 +5895,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
// same reports by hand, but only while the CLI is actually tracking the mouse. Not gated on a
|
||||
// version; a stale-false flag falls through to the PageUp/PageDown fallback like claude's.
|
||||
if (sessionMode === 'copilot') return session?.cliMouseTracking === true;
|
||||
if (sessionMode !== 'claude') return false;
|
||||
if (!this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
|
||||
// Only while Claude is actually listening for the mouse. In its default
|
||||
// inline renderer (2.1.280 measured: alternate_on=0, mouse_any_flag=0) the
|
||||
// transcript lives in real scrollback, like codex, and SGR wheel reports are
|
||||
if (sessionMode !== 'claude' && sessionMode !== 'codex') return false;
|
||||
if (sessionMode === 'claude' && !this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
|
||||
// Only while Claude or Codex is actually listening for the mouse. Claude's
|
||||
// inline renderer (2.1.280 measured: alternate_on=0, mouse_any_flag=0), like
|
||||
// older inline Codex, keeps its transcript in real scrollback; SGR reports are
|
||||
// ignored, so forwarding made every swipe and wheel tick dead. Fullscreen
|
||||
// (CLAUDE_CODE_NO_FLICKER=1, or "tui": "fullscreen" in ~/.claude/settings.json)
|
||||
// turns on alt-screen + mode 1003/1006, which the server records as
|
||||
@@ -5895,34 +5930,54 @@ Object.assign(CodemanApp.prototype, {
|
||||
// trackpad emits dozens of wheel events per second and each send becomes a
|
||||
// tmux send-keys on the server — unbatched, a single flick would spawn a
|
||||
// process storm. Per-event tick count is capped (Claude applies its own
|
||||
// scroll-speed multiplier and acceleration on top), and the queue is bounded
|
||||
// so a wild scroll can't build a backlog that keeps scrolling after the finger
|
||||
// stops. Flushed via _sendInputEphemeral — loss-tolerant, off the durable queue.
|
||||
// scroll-speed multiplier and acceleration on top). Batching is bounded by
|
||||
// the 40ms window and the per-event cap, not an aggregate byte/tick limit.
|
||||
// Flushed via _sendInputEphemeral — loss-tolerant, off the durable queue.
|
||||
// The encoding is the pure CodemanTerminalInput.sgrWheelReports, which a
|
||||
// TerminalTile calls with its own cell (TerminalTile._maybeForwardWheelToCli).
|
||||
_sendSyntheticSgrWheel(clientX, clientY, lines) {
|
||||
if (!this.activeSessionId || !lines) return;
|
||||
const pos = this._clientPointToCell(clientX, clientY);
|
||||
if (!pos) return;
|
||||
this._queueScrollBytes(window.CodemanTerminalInput.sgrWheelReports(lines, pos));
|
||||
const input = window.CodemanTerminalInput;
|
||||
const state = this._wheelTickSession === this.activeSessionId ? this._wheelTickGesture : null;
|
||||
if (state?.dir && state.dir !== Math.sign(lines)) this._wheelSgrQueue = '';
|
||||
const step = input.wheelTicksForGesture(state, lines, performance.now());
|
||||
this._wheelTickSession = this.activeSessionId;
|
||||
this._wheelTickGesture = step.state;
|
||||
this._queueScrollBytes(input.sgrWheelReports(step.ticks, pos));
|
||||
},
|
||||
|
||||
/**
|
||||
* Shared 40ms coalescer for every byte a scroll gesture sends to the PTY (SGR
|
||||
* Coalesces scroll input into 40ms batches after the first native report (SGR
|
||||
* wheel reports and the PageUp/PageDown fallback alike). Each flush becomes a
|
||||
* tmux send-keys server-side, so per-event writes would spawn a process storm
|
||||
* on a single flick; the queue is bounded so a wild scroll can't build a
|
||||
* backlog that keeps scrolling after the finger stops.
|
||||
* on a single flick. Per-event report limits preserve the upstream wheel/touch
|
||||
* scaling; a change of direction discards only movement that is still unsent.
|
||||
*
|
||||
* A TerminalTile keeps its own narrow twin (TerminalTile._queueScrollBytes,
|
||||
* terminal-tile.js: same 40ms window, same 512-byte bound) because this queue
|
||||
* terminal-tile.js: same 40ms window and report limits) because this queue
|
||||
* flushes to the active session only; keep the two in step.
|
||||
*/
|
||||
_queueScrollBytes(data) {
|
||||
if (!data || !this.activeSessionId) return;
|
||||
const queued = this._wheelSgrQueue || '';
|
||||
if (queued.length > 512) return;
|
||||
this._wheelSgrQueue = queued + data;
|
||||
// The first native report of a newly selected tab must not wait behind
|
||||
// replay/atlas work for a 40ms timer (measured 60-250ms in practice).
|
||||
// Selection generation also covers A -> B -> A when B was never scrolled.
|
||||
const firstNativeForVisit = data.startsWith('\x1b[<') &&
|
||||
(this._wheelNativeStartSession !== this.activeSessionId ||
|
||||
this._wheelNativeStartGeneration !== this._selectGeneration);
|
||||
const queued = !firstNativeForVisit && this._wheelQueueSession === this.activeSessionId
|
||||
? this._wheelSgrQueue || '' : '';
|
||||
this._wheelQueueSession = this.activeSessionId;
|
||||
this._wheelSgrQueue = window.CodemanTerminalInput.coalesceScrollBytes(queued, data);
|
||||
if (firstNativeForVisit) {
|
||||
this._wheelNativeStartSession = this.activeSessionId;
|
||||
this._wheelNativeStartGeneration = this._selectGeneration;
|
||||
if (this._wheelSgrFlushTimer) clearTimeout(this._wheelSgrFlushTimer);
|
||||
this._flushWheelSgrQueue();
|
||||
return;
|
||||
}
|
||||
if (this._wheelSgrFlushTimer) return;
|
||||
this._wheelSgrFlushTimer = setTimeout(() => this._flushWheelSgrQueue(), 40);
|
||||
},
|
||||
@@ -5942,9 +5997,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
* PageUp/PageDown keys (`messages_page_up/down`) but IGNORES SGR wheel
|
||||
* reports — six `\x1b[<64;…M` reports against an idle pane left the capture
|
||||
* byte-identical — so paging is the only gesture that reaches it;
|
||||
* - `codex`, which draws on tmux's alternate screen too (0.157.1, 0.160), which
|
||||
* the full strip hides from xterm, and pages on PageUp/PageDown while SGR
|
||||
* wheel reports do nothing.
|
||||
* - `codex` with no observed mouse tracking. Native tracked fullscreen
|
||||
* Codex uses fractional SGR reports; this remains its fallback capability.
|
||||
*
|
||||
* ⚠️ Which modes page is read from `window.__codemanTranscriptPageKeys`, the
|
||||
* list the server builds from the `transcriptPageKeys` CAPABILITY, never an id
|
||||
@@ -5976,8 +6030,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
* unset (the inline renderer, or fullscreen right after a server restart), the
|
||||
* user turned on "Wheel scrolls local history" (which pins the wheel to a buffer
|
||||
* that, for a repaint-mode CLI, is empty: the setting's footgun), or the CLI is
|
||||
* one that never fills the buffer and never accepts the wheel (opencode, codex:
|
||||
* both ignore SGR wheel reports, #227, and page on PageUp/PageDown). Before
|
||||
* one that never fills the buffer and does not accept the wheel (OpenCode,
|
||||
* or Codex without observed mouse tracking). Those modes page instead. Before
|
||||
* this, all of those produced a completely dead gesture; the #205 reporter
|
||||
* proved the keyboard route works by paging back through intact text with Fn+Up.
|
||||
* This is not wheel forwarding: it sends plain keys, and only over a hollow buffer.
|
||||
@@ -6021,6 +6075,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
*/
|
||||
_maybePageCliTranscript(ev, lines) {
|
||||
if (!lines || ev?.shiftKey || !this.activeSessionId) return false;
|
||||
if (ev?.ctrlKey || Math.abs(ev?.deltaX || 0) > Math.abs(ev?.deltaY || 0)) return this._localScrollbackIsHollow();
|
||||
if (!this._localScrollbackIsHollow()) return false;
|
||||
// An open dialog in THIS pane's session (the active one): a page key would
|
||||
// move its selector. Consumed, so the buffer does not scroll under it either.
|
||||
@@ -6033,6 +6088,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._pageKeySession = this.activeSessionId;
|
||||
this._pageKeyGesture = null;
|
||||
}
|
||||
if (this._pageKeyGesture?.dir && this._pageKeyGesture.dir !== Math.sign(lines)) this._wheelSgrQueue = '';
|
||||
const step = window.CodemanTerminalInput.pageKeysForGesture(
|
||||
this._pageKeyGesture,
|
||||
ev,
|
||||
@@ -6060,19 +6116,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
const session = this.sessions?.get(sessionId);
|
||||
const optOut = !!this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback;
|
||||
const tracking = this.terminal?.modes?.mouseTrackingMode || 'none';
|
||||
// The encoding xterm would report in (DEFAULT, SGR, SGR_PIXELS): touch
|
||||
// forwards to xterm's own tracking only when it is SGR
|
||||
// (_shouldForwardTouchToApp).
|
||||
const encoding = this.terminal?._core?.coreMouseService?.activeEncoding || 'unknown';
|
||||
// xterm's own mode above stays 'none' for a strip mode (the server removes
|
||||
// the DECSETs), so the CLI's real tracking state is reported separately.
|
||||
const cliTracking = session?.cliMouseTracking === true;
|
||||
const baseY = this.terminal?.buffer?.active?.baseY ?? -1;
|
||||
const signature =
|
||||
`${decision}|${session?.mode}|${session?.cliVersion}|${optOut}|${tracking}|${cliTracking}|${baseY > 0}`;
|
||||
`${decision}|${session?.mode}|${session?.cliVersion}|${optOut}|` +
|
||||
`${tracking}|${encoding}|${cliTracking}|${baseY > 0}`;
|
||||
if (!this._scrollRoutingLogged) this._scrollRoutingLogged = new Map();
|
||||
if (this._scrollRoutingLogged.get(sessionId) === signature) return;
|
||||
this._scrollRoutingLogged.set(sessionId, signature);
|
||||
console.log(
|
||||
`[scroll] ${sessionId} → ${decision} (mode=${session?.mode || '?'}, cliVersion=${session?.cliVersion || 'unknown'}, ` +
|
||||
`localScrollbackOptOut=${optOut}, mouseTracking=${tracking}, cliMouseTracking=${cliTracking}, ` +
|
||||
`localScrollbackRows=${baseY})`
|
||||
`localScrollbackOptOut=${optOut}, mouseTracking=${tracking}, mouseEncoding=${encoding}, ` +
|
||||
`cliMouseTracking=${cliTracking}, localScrollbackRows=${baseY})`
|
||||
);
|
||||
},
|
||||
|
||||
@@ -6083,7 +6144,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Ephemeral (fire-and-forget): wheel reports are loss-tolerant, so they skip
|
||||
// the durable seq/ACK queue — no localStorage churn, no "Nb queued" flicker
|
||||
// in the connection indicator on every scroll tick.
|
||||
if (data && this.activeSessionId) this._sendInputEphemeral(this.activeSessionId, data);
|
||||
if (data && this.activeSessionId && this._wheelQueueSession === this.activeSessionId) {
|
||||
this._sendInputEphemeral(this.activeSessionId, data);
|
||||
}
|
||||
},
|
||||
|
||||
// Desktop counterpart of the touchend tap branch: hand-encode an SGR report
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"version": "1.43.0",
|
||||
"version": "1.44.0",
|
||||
"highlights": [
|
||||
"Security notices for the CLIs you run",
|
||||
"GitHub Copilot CLI is a run mode (#592)",
|
||||
"Pin tab names on the CLI (#646, optional)",
|
||||
"Antigravity tabs show when they are working (#648)",
|
||||
"Custom-folder cases below the cases folder (#567)",
|
||||
"A steady WS pill (#647)",
|
||||
"Clickable diff and patch files (#644)",
|
||||
"Mixed uploads say what they skipped (#651)"
|
||||
"Config backups (#661)",
|
||||
"Keep an idle Claude session's prompt cache warm (#666, opt-in)",
|
||||
"A default tab group per case (#676)",
|
||||
"A File Viewer you can resize, with Back and Forward (#659)",
|
||||
"Copilot's premium-request quota in the plan-usage chip (#662)",
|
||||
"More (or fewer) sessions with `CODEMAN_MAX_SESSIONS` (#668)",
|
||||
"Multi-line prompts keep their line breaks (#670)",
|
||||
"A warning for llama.cpp slots that split the context too thin (#658)"
|
||||
]
|
||||
}
|
||||
Loaded 100 of 187 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user