mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
85
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a017e9a8e0 | ||
|
|
65ddedd1d4 | ||
|
|
8b23f3e260 | ||
|
|
02b0e27898 | ||
|
|
9d664ffe01 | ||
|
|
a28b04c368 | ||
|
|
e35b68e253 | ||
|
|
77d9ad59f7 | ||
|
|
d9eeb039db | ||
|
|
bd61735393 | ||
|
|
5b667264b4 | ||
|
|
e3d5fd90cd | ||
|
|
713f632a64 | ||
|
|
92b5dfacb0 | ||
|
|
890a1b0902 | ||
|
|
a360763890 | ||
|
|
57899f879e | ||
|
|
d4fe3afc9d | ||
|
|
77fcd65b4a | ||
|
|
2b57c595df | ||
|
|
070e8da81b | ||
|
|
0e82443222 | ||
|
|
323730a29d | ||
|
|
5ac516dd3b | ||
|
|
5130ca6633 | ||
|
|
b87bc6871b | ||
|
|
c367b12f77 | ||
|
|
797f0d387c | ||
|
|
88243e9ffa | ||
|
|
0a5bc1ac2e | ||
|
|
e8a93ada1f | ||
|
|
a164c07f92 | ||
|
|
4f2dfb4e6d | ||
|
|
344e93c824 | ||
|
|
f1b7283393 | ||
|
|
a49be03f96 | ||
|
|
7fde978ce8 | ||
|
|
8ee7926e27 | ||
|
|
82b090c74a | ||
|
|
2f9663e389 | ||
|
|
61d22eee1c | ||
|
|
92af855ce4 | ||
|
|
cfc8fe7e41 | ||
|
|
80397fe140 | ||
|
|
7991f481b6 | ||
|
|
bca1b764cc | ||
|
|
1c1773278f | ||
|
|
327e440607 | ||
|
|
a2aaea3c0e | ||
|
|
9f5010aa51 | ||
|
|
f33b37c008 | ||
|
|
097d585278 | ||
|
|
8285fff91c | ||
|
|
51957e2ed4 | ||
|
|
8ad2215118 | ||
|
|
3d8ffcb9a2 | ||
|
|
06febfa032 | ||
|
|
7e4914d991 | ||
|
|
6f7add7ce4 | ||
|
|
eeb5f9d0b2 | ||
|
|
2ab21c1b32 | ||
|
|
550e08a791 | ||
|
|
99ad9cb236 | ||
|
|
823f56a243 | ||
|
|
72fd231d11 | ||
|
|
65d19c725e | ||
|
|
66eb01ba8f | ||
|
|
1125f7c1c5 | ||
|
|
ccfda623fe | ||
|
|
3eff1feb5d | ||
|
|
5969a1df96 | ||
|
|
47ee49128c | ||
|
|
8e5e207386 | ||
|
|
5452ad5c5a | ||
|
|
23ab2e77fd | ||
|
|
3685ad85bc | ||
|
|
06e7cbe286 | ||
|
|
8b20f5b1f8 | ||
|
|
2f83a37c6d | ||
|
|
34c12ca18b | ||
|
|
e2f750cb30 | ||
|
|
bb45909169 | ||
|
|
c98a59d709 | ||
|
|
bc55b6b0da | ||
|
|
15eebde832 |
+12
-4
@@ -69,10 +69,18 @@ shared-host, multi-user, or tunneled deployments.
|
||||
- **Multi-instance tmux socket is process-wide.** Two Codeman instances on the same `CODEMAN_INSTANCE` share a tmux socket and can attach each other's live sessions — isolate with distinct `CODEMAN_INSTANCE` values.
|
||||
- **The live log-tail route reads `/var/log` and `~/logs`** in addition to the session working directory (read-only) — a deliberate choice for tailing system/app logs. On a password-protected remote deployment an authenticated user can therefore read those roots outside their session. See `docs/security-architecture.md` §5.
|
||||
|
||||
Recent hardening (this release): web-push subscription endpoints are restricted
|
||||
to https public hosts (SSRF guard — rejects internal/metadata IPs, validated at
|
||||
subscribe and send time), and tmux session names discovered on the shared socket
|
||||
are validated against the safe-name pattern before reaching any shell call site.
|
||||
- **The web-tab proxy fetches from the server's network position.** Any authenticated user can save a dashboard URL on loopback or a private range and have Codeman relay to it; that is the feature. Link-local and cloud-metadata addresses are the only refused targets (see below). On a shared host, restrict who holds an account.
|
||||
|
||||
Recent hardening (2026-09-04): the web-tab proxy, its "Test" probe and its
|
||||
WebSocket relay refuse link-local and cloud-metadata targets (`169.254.0.0/16`,
|
||||
`fe80::/10`, `fd00:ec2::254`, `168.63.129.16`, `100.100.100.200`,
|
||||
`metadata.google.internal`), judged on the RESOLVED address so a DNS name pointing
|
||||
there is refused too; proxy capabilities are revoked on logout, admin logout and
|
||||
user deletion; proxied responses carry `Referrer-Policy: same-origin`. Earlier:
|
||||
web-push subscription endpoints are restricted to https public hosts (SSRF guard,
|
||||
rejects internal/metadata IP literals, validated at subscribe and send time), and
|
||||
tmux session names discovered on the shared socket are validated against the
|
||||
safe-name pattern before reaching any shell call site.
|
||||
|
||||
For the detailed rationale, defenses, and recommended secure setups, see
|
||||
[`docs/security-architecture.md`](../docs/security-architecture.md).
|
||||
|
||||
+135
@@ -1,5 +1,140 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.27.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Session lists that answer "which of these wants me next?", loopback links that work from a phone, and a batch of input and remote-session fixes.
|
||||
|
||||
**The vertical tab rail sorts by activity and wears the home screen's cards.** A new per-device setting (App Settings → Appearance → Tabs → **Vertical Rail Order**, default _By activity_) orders rail rows with the same comparator both home screens use: whatever is blocked on you first, then whatever has been running longest, then the most recently quiet. Detailed rail rows become cards, with the state dot keeping its working ring and gaining the home rail's green halo. ⚠️ Existing vertical-rail users get sorting on upgrade, and a self-sorting list cannot also be drag-reorderable: choose _Manual_ to get your own order and drag-reordering back. The lineage bracket also moves 4px further from the rail's left edge, where its glow was being clipped by the window frame.
|
||||
|
||||
**The Claude Response Viewer's brief view shows the whole last turn.** It used to render one row, so the eye button often showed the "Done." tail of an answer whose substance was in the rows above it. A multi-row turn now also opens at its newest text instead of its first narration line.
|
||||
|
||||
**A `localhost` link in agent output opens as a proxied web tab.** An agent prints `http://localhost:5173/` and you tap it on a phone: that address only exists on the Codeman box, so the link was a guaranteed connection error from any other device. It now opens through the proxy, reusing a saved dashboard for the same dev server (one tab per server, not per host spelling) or saving one under its `host:port`. LAN and tailnet addresses still open directly, and on the box itself every link opens directly. `*.localhost` is deliberately not auto-routed: it is the only spelling that is a DNS name rather than an address literal, and these links come from agent output; add such a dashboard by hand instead. Trusted (non-sandboxed) dashboards are likewise never auto-reused by a tapped link.
|
||||
|
||||
**Remote omp and remote claude sessions continue their conversation across a respawn or reattach.** Remote claude now launches an idempotent `--session-id || --resume` pair and remote omp respawns with `--continue`, instead of starting a fresh conversation each time. An omp session id is never resolved from the local `~/.omp` for a remote session, which would have pinned an unrelated local conversation.
|
||||
|
||||
**Android and IME keyboards no longer drop committed characters.** Chrome on Android delivers a `composed: true` input event preceded by a keydown, which is exactly the shape xterm refuses to forward, so the character vanished. A recovery controller forwards it when, and only when, xterm produced nothing for that keystroke, so dictation and soft-keyboard input cannot be delivered twice either.
|
||||
|
||||
### Thanks
|
||||
- **@shenlvkang-collab** for the Response Viewer last-turn fix (#400) and for loopback links as web tabs (#401), both carefully measured, #400 against 285 real transcripts.
|
||||
- **@timkjr** for remote-omp resume/continue through respawn and reattach (#362), including dropping a half that had already landed and verifying the merge kept none of it.
|
||||
- **@aakhter** for the Android/IME input recovery (#388), and in particular for finding that an earlier version of their own browser test was passing vacuously, and saying so.
|
||||
|
||||
## 1.26.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Terminal rendering fixes, a Ctrl+V paste fix, an iOS Safari toolbar fix, a 2GB download cap, and a Blur entrance animation.
|
||||
|
||||
### Terminal rendering
|
||||
|
||||
Three independent causes behind #398, where opening a session rendered a frame with characters spliced into each other and left the caret on the composer's border instead of its input line, until the CLI next wrote anything:
|
||||
- **The full-history replay now keeps row alignment** (#395). The linear capture path never restored the cursor, so every cursor-relative update the CLI sent afterwards was measured from the status line instead of the pane's real position, and four transforms that each can delete a line (trailing-blank stripping, redraw-bloat stripping, the pre-banner trim, leading-whitespace removal) shifted the frame out from under it. The full-history path now appends the pane's own cursor position and keeps every row, so row N of the reply is row N of the pane. The visible-frame and tail paths are untouched.
|
||||
- **The first fit waits for the terminal font** (#396). A cell measured against a fallback font gives the wrong column and row count, so the pane was sized twice and the CLI repainted for a shape that no longer matched the frame on screen. `selectSession` now holds for the font before measuring, bounded at 2s so a font that never arrives cannot strand a session, and it ends by re-measuring explicitly — `FitAddon.proposeDimensions()` divides by a cached cell size and nothing in it listens for font loading, so waiting alone would still divide by the fallback cell.
|
||||
- **A detached session's own window owns its pane size** (#397). Popping a session out left both windows sizing one PTY, and the dashboard's terminal is narrower than the popup because the session rail takes width the popup does not have, so the CLI drew frames that fit neither. The dashboard now withholds the resize send (never the local reflow) for a session showing in its own window, and takes sizing back on redock.
|
||||
|
||||
### Other fixes
|
||||
- **Ctrl+V no longer pastes twice** (#394). One keypress delivered two paste events to the clipboard trap: Firefox dispatches a trusted event for `document.execCommand('paste')` and then returns `false`, and the key's own default action fires another, because xterm's custom key handler returns false without cancelling the keydown. Right-click → Paste has no keydown, which is why only the keyboard duplicated. The trap now consumes exactly one event per keypress.
|
||||
- **iOS Safari: the phone toolbar sits on Safari's bottom bar** (#391, #392). The toolbar was lifted by `100vh - --app-height`, which on iPhone Safari measures the bar's collapsible height rather than an overlap — fixed elements there already stop above the bar — leaving an empty ~40px band and padding the terminal by the same amount. The lift is now `--chrome-overlap` (`innerHeight` minus the visual viewport height), which is 0 on iPhone Safari and equals the real overlap anywhere fixed elements do land behind the chrome.
|
||||
|
||||
### Downloads
|
||||
|
||||
`file-raw`, the attachment `/raw` route and `GET /api/download` now cap at **2GB** instead of 50MB, configurable via `CODEMAN_MAX_DOWNLOAD_BYTES` (`0` = unlimited). The old cap was memory protection for a `readFile()` that no longer exists: those bodies stream and answer `Range` requests, so size costs a read stream rather than RSS (measured: a 600MB download moved peak RSS by ~37MB), and all the cap still did was refuse legitimate downloads of build artifacts, videos and archives. `/api/download` was the last route that really did buffer the whole file; it now streams, advertises `Accept-Ranges` and is resumable. Refusals move from `400` to `413`, the correct status for the case.
|
||||
|
||||
### Blur entrance animation
|
||||
|
||||
A new opt-in `Blur` style on all four entrance surfaces (tabs, agent windows, the terminal pane, connection lines), plus a `Soft focus` theme that sets all four: an iOS-style focus pull where the thing arrives out of focus and the blur fades off it as the opacity comes up. App Settings → Appearance → Entrance Animations, or mix per surface at `?animlab=1`. Entrance animations stay off by default, so an untouched install is unchanged.
|
||||
|
||||
### Maintainer tooling
|
||||
|
||||
The PR bot now fails fast when the review model's budget is spent, instead of hanging a review for the full 40-minute timeout and burning its retry cap.
|
||||
|
||||
### Thanks
|
||||
- @irisitymichaelgrundberg for #394, #395, #396 and #397, and for the #398 investigation that separated three causes behind one symptom
|
||||
- @JDProfresh for reporting #391 and fixing it in #392
|
||||
|
||||
## 1.26.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Codex sessions no longer report idle for their entire life, and Codex conversations now appear in Past Sessions and can be resumed.
|
||||
|
||||
**Per-CLI work detection (#385, irisitymichaelgrundberg).** The composer glyph and the working status line are now registry data (`capabilities.workDetect`) rather than Claude constants. Claude keeps its exact current pair, Codex declares `›` plus its `esc to interrupt` footer, and any CLI that declares neither falls back to Claude's, which is what every session used before. Work detection had been gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` from the moment it started. `workingLine` is config-supplied and its compiled pattern runs on the PTY hot path, so it goes through `compileVersionRegex()` in both the schema refine and the runtime compile: a nested quantifier there would backtrack on the event loop for the whole server. The Codex footer is matched case-insensitively on the E, so a future version capitalising it cannot make the fix silently inert.
|
||||
|
||||
**Codex conversations in Past Sessions (#386, irisitymichaelgrundberg).** A bounded scanner reads codex's `~/.codex/sessions` rollout store, so the unified session list now merges three transcript stores rather than one (Claude's `~/.claude/projects`, omp's `~/.omp/agent/sessions`, codex's `~/.codex/sessions`). A scanned row carries a `resumeId`, the rollout's own thread id, which lets it resume through `codexConfig.resumeSessionId`; a live session never carries one, so a row without it stays a genuinely fresh session. Live and resumed Codex sessions fold into their rollout row through the existing alias map, including a `session_meta.originator` match for fresh panes, so a conversation never shows up twice. The phone overview carries `resumeId` through its own row projection, without which a tapped Codex past row started a fresh session on a thread already on disk.
|
||||
|
||||
### Thanks
|
||||
- @irisitymichaelgrundberg for both PRs (#385, #386), and for turning a full review round on #386 in a day.
|
||||
|
||||
## 1.26.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Tag the case directories agent workers create, and clean up what they leave behind.
|
||||
|
||||
A long agent orchestration creates one case directory per worker, and deleting the
|
||||
sessions never removed them, so `~/codeman-cases` filled with scratch folders that
|
||||
looked exactly like real projects.
|
||||
- A case directory `POST /api/quick-start` **creates** for an agent-driven spawn now
|
||||
carries a `.codeman-agent-case.json` marker recording when it was made, by whom,
|
||||
from which session, and in which mode. Only the branch that creates the directory
|
||||
writes it, so a linked case, a cloned repo or any pre-existing path is never
|
||||
labelled, and deleting the marker file adopts a scratch case as a real one.
|
||||
- The label comes from the new `X-Codeman-Agent-Origin` header that the packaged agent
|
||||
skill sets on its shared curl invocation (preamble 1.22.0), or an `agentOrigin` body
|
||||
field, falling back to a resolved `parentSessionId` so workers spawned by an older
|
||||
skill copy are still labelled.
|
||||
- `GET /api/cases` publishes it as `agentCreated`, and the new read-only
|
||||
`GET /api/cases/agent-created` lists the scratch cases with `inUse` (a live session
|
||||
is still working in it) and `modifiedAt`.
|
||||
- Add Case -> Manage badges every agent-created case and adds a sticky **Clean up**
|
||||
entry point that names each directory in its confirmation and skips any case a
|
||||
running session is using. Removal still goes through `DELETE /api/cases/:name`.
|
||||
- The agent skill's per-session preamble cache (`~/.cache/codeman-agent-<id>.sh`) is
|
||||
now removed with the session and swept at boot. One was written per Claude session
|
||||
and nothing ever deleted them (236 orphans on a working machine); the sweep keeps
|
||||
every live session's file and only takes orphans older than seven days.
|
||||
|
||||
## 1.25.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Codeman can be mounted under a sub-path behind a reverse proxy (#381, @mtiller). `--base-url /codeman` (or `CODEMAN_BASE_URL`) makes the server strip the prefix on the way in, rebase redirects on the way out, inject `<base>` and `window.__CODEMAN_BASE__` into the shell, and route web-tab proxying and WebSocket upgrades under the mount, so one TLS name can front several apps. A root install is byte-identical to before. Applied on top: the crash-diag beacon stays under the mount (sendBeacon is not fetch, so the base-aware wrapper never saw it), the test suite strips `CODEMAN_BASE_URL`, and a wiring test boots a real server under a prefix.
|
||||
|
||||
A case can attach to a container that is already running (#357, @dignfei). `DockerCase.owned:false` mirrors the remote-SSH attach contract: Codeman only execs into such a container, never creates, starts, stops, removes, pauses or commits it, with the refusal enforced at string-construction time so no caller bug can reach `docker stop`. The Add Case dialog gets an attach panel with a container picker, the run menu takes its mode availability from the CLIs actually present in the container, and adoption is admin-only in multi-user mode. Three gaps closed after review: export no longer pauses or commits an adopted container, a freshly linked owned case no longer hides every agent mode behind a probe of a container that does not exist yet, and multi-user gating is explicit.
|
||||
|
||||
The Claude response viewer renders one message per model message (#369, @shenlvkang-collab). The reader used to fuse every assistant row between two human prompts into one card and never read the attachment rows that hold a prompt typed mid-turn; measured over 57 real transcripts it now shows 1,806 messages instead of 356 and recovers 162 absorbed user prompts, with the assistant text unchanged row for row.
|
||||
|
||||
A Claude pane learns its live conversation from the CLI's own `UserPromptSubmit` hook (#367, @shenlvkang-collab). The conversation id used to be re-derived by correlating `~/.claude/history.jsonl` against a stamp only Codeman's own input path set, so a pane driven straight from tmux stayed pinned to its launch conversation forever. The hook reports the id first-hand, addressed by the pane's own `$CODEMAN_SESSION_ID`, and the chain of conversations is persisted so a restart re-pins the right one. The new `hook:prompt_submitted` SSE event is registered (158 = 158), and it lands in the run summary only when the conversation actually moved.
|
||||
|
||||
The Add Case modal can be submitted from a phone again (#368, @shenlvkang-collab). Since 1.16.4 the layout below 860px hid the modal footer, which held the only Create/Clone/Link button. A header submit button now sits beside the close button, dims while a submit is pending, and a static test pins the contract so it cannot silently disappear again.
|
||||
|
||||
The Link Existing case picker opens in the Codeman Cases directory instead of Home (#383, @opticon454). Under Docker the two are unrelated trees and Home holds nothing but dot directories, so the picker showed no cases at all. The fallback chain is now Current Folder, then Codeman Cases, then `/mnt/d`, then the first root.
|
||||
|
||||
A PR review bot for the maintainer (`scripts/pr-bot/`, guide in `docs/pr-bot.md`). It reviews every open pull request in its own Codeman session inside a private clone and reports the verdict, ranked findings and a recommendation to Telegram with action buttons; merge, close, post-comment and approve-CI happen only from a confirmed tap. Maintainer tooling, not part of the server or the CLI.
|
||||
|
||||
### Thanks
|
||||
- @mtiller for the reverse-proxy base URL (#381).
|
||||
- @dignfei for attaching cases to running containers (#357).
|
||||
- @shenlvkang-collab for the response viewer fix (#369), the first-hand conversation hook (#367) and the phone Add Case fix (#368).
|
||||
- @opticon454 for the case picker default (#383).
|
||||
|
||||
## 1.24.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- The web-tab proxy refuses link-local and cloud-metadata targets. Its Test probe, the proxy itself and the WebSocket relay accepted any http(s) host, so a saved dashboard URL could reach `169.254.169.254` (in decimal, hex, IPv6-mapped or DNS-name form) through a capability and no cookie. Loopback and RFC1918 addresses stay allowed on purpose, since a localhost Grafana is the feature; only link-local and the fixed cloud-metadata addresses are refused, at the schema, at every connect site, and through a DNS lookup hook that judges the resolved addresses, which is what closes DNS rebinding. Adds `undici` so the proxy runs its fetch through its own agent.
|
||||
|
||||
Proxy capabilities are revoked on logout. `revokeOwner()` had shipped with no caller, so a leaked proxy URL stayed valid for as long as anything kept polling it. `POST /api/logout`, the admin forced logout and user deletion now revoke the capabilities they should, and proxied responses carry `Referrer-Policy: same-origin` with the upstream's own policy dropped, so a dashboard on a loose referrer policy cannot hand the capability to a third-party host it links to.
|
||||
|
||||
The Docker Compose deployment updates itself from App Settings again (#373, @opticon454). The checkout Compose builds from is bind-mounted at `/opt/codeman`, so an update's `git checkout` and rebuild land on the host and survive container recreation; build artefacts live in named volumes so container-compiled native modules never enter the host checkout; the image keeps devDependencies and a build toolchain; and the restart is the server exiting under `restart: unless-stopped`. An in-place update applies code only, so the updater refuses a release that changes `server.Dockerfile` or `docker-compose.yaml`, or that adds keys to `.env.example` the user's `.env` has no value for (Compose interpolates an unset variable to the empty string and starts anyway), and points at `docker/Start-Codeman.sh` on the host instead. The four global agent CLIs in the image are pinned. A follow-up makes the final step fail safe: the server exits only when the Compose file declares `CODEMAN_RESTART_BY_EXIT=1` or the daemon confirms an auto-restart policy, and otherwise the build is staged for a manual restart, so a container nothing would restart is never taken down. Details in `docs/docker-self-update.md`.
|
||||
|
||||
The test suite strips `CODEMAN_INSTANCE`, `CODEMAN_DATA_DIR` and `CODEMAN_TMUX_SOCKET` before any application module loads (#371, @opticon454), with a two-half test whose static half reads `test/setup.ts` so a dropped line fails everywhere. This replaces the throwaway data dir #356 had set for the same variable.
|
||||
|
||||
### Thanks
|
||||
- @opticon454 for the Compose self-update (#373) and the test isolation fix (#371).
|
||||
|
||||
## 1.24.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -691,6 +691,7 @@ The web UI remains the primary surface; see **[docs/tui.md](docs/tui.md)** for t
|
||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right |
|
||||
| `Ctrl/Cmd+C` | Copy selection, or interrupt when nothing is selected |
|
||||
| `Ctrl+Shift+C` | Copy selection (never interrupts) |
|
||||
| `Ctrl/Cmd+V` | Paste, or upload a clipboard image and paste its path |
|
||||
| `Ctrl/Cmd+L` | Clear terminal |
|
||||
| `Ctrl+Shift+R` | Restore terminal size |
|
||||
| `Ctrl+Shift+V` | Toggle voice input |
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"scripts/*.mjs",
|
||||
"scripts/*.js",
|
||||
"scripts/watch-subagents.ts",
|
||||
"scripts/pr-bot/main.ts",
|
||||
"scripts/remotion/Root.tsx",
|
||||
"scripts/remotion/index.ts",
|
||||
"test/**/*.test.ts",
|
||||
|
||||
@@ -26,6 +26,7 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/opencode-resize.test.ts',
|
||||
'test/webgl-fallback.test.ts',
|
||||
'test/terminal-copy-shortcut.test.ts',
|
||||
'test/terminal-keycode229-recovery.browser.test.ts',
|
||||
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "..",
|
||||
"noEmit": true,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"sourceMap": false
|
||||
},
|
||||
"include": ["../scripts/pr-bot/**/*.ts"]
|
||||
}
|
||||
@@ -23,13 +23,6 @@ export default defineConfig({
|
||||
include: ['test/**/*.test.ts'],
|
||||
exclude: [...configDefaults.exclude, ...NON_CI_TEST_GLOBS],
|
||||
setupFiles: ['./test/setup.ts'],
|
||||
// SAFETY: force every worker's data dir away from prod `~/.codeman`. Route
|
||||
// tests (e.g. session-routes-workspace-hooks) write remote-hosts.json into
|
||||
// `getDataDir()`; without this a bare run clobbers the production host
|
||||
// registry (found 2026-08-29). `/tmp` is fine here — the tree is throwaway.
|
||||
env: {
|
||||
CODEMAN_DATA_DIR: '/tmp/codeman-vitest-data',
|
||||
},
|
||||
fileParallelism: false,
|
||||
testTimeout: 30000,
|
||||
teardownTimeout: 60000,
|
||||
|
||||
@@ -21,13 +21,6 @@ export default defineConfig({
|
||||
environment: 'node',
|
||||
include: ['test/**/*.test.ts'],
|
||||
setupFiles: ['./test/setup.ts'],
|
||||
// SAFETY: force every worker's data dir away from prod `~/.codeman`. Route
|
||||
// tests (e.g. session-routes-workspace-hooks) write remote-hosts.json into
|
||||
// `getDataDir()`; without this a bare run clobbers the production host
|
||||
// registry (found 2026-08-29). `/tmp` is fine here — the tree is throwaway.
|
||||
env: {
|
||||
CODEMAN_DATA_DIR: '/tmp/codeman-vitest-data',
|
||||
},
|
||||
// Run test files sequentially to respect mux session limits
|
||||
// Individual tests within files still run in parallel where safe
|
||||
fileParallelism: false,
|
||||
|
||||
@@ -20,6 +20,13 @@ CODEMAN_RUNTIME_USER=opencode
|
||||
# directory in the container.
|
||||
CODEMAN_APPDATA_PATH=/mnt/user/appdata/Coding/codeman
|
||||
|
||||
# Optional. Absolute host path of this Codeman checkout, mounted at
|
||||
# /opt/codeman so App Settings -> Updates can update Codeman in place. The Bash
|
||||
# start script detects it from the compose file's own location, so it only needs
|
||||
# setting for direct `docker compose` use or a checkout kept elsewhere. Point it
|
||||
# at a directory that is not a git checkout and in-app updates are unavailable.
|
||||
# CODEMAN_REPO_PATH=/mnt/user/appdata/Coding/codeman/app
|
||||
|
||||
# Required for Docker cases. This must be an absolute path on the Docker host.
|
||||
# Codeman and each isolated case use this same path, so it cannot be a
|
||||
# container-only path such as /home/opencode/codeman-cases.
|
||||
|
||||
@@ -26,6 +26,18 @@ Codeman, Claude, OpenCode, and other local sessions run as the unprivileged acco
|
||||
|
||||
To retain Docker-case support without root when running Compose directly, set `DOCKER_SOCKET_GID` to the numeric group ID of the host socket. On a standard Linux Docker host, obtain it with `stat -c '%g' /var/run/docker.sock`. The Bash start script detects it automatically.
|
||||
|
||||
## Updating
|
||||
|
||||
Use **App Settings → Updates** in the web UI. The checkout Compose builds from is
|
||||
also mounted at `/opt/codeman`, so an update's `git checkout` and rebuild persist
|
||||
on the host, and the server exiting is what restarts the container onto the new
|
||||
build.
|
||||
|
||||
Releases that change `server.Dockerfile`, `docker-compose.yaml`, or add a key to
|
||||
`.env.example` cannot be applied that way — the updater detects them, names what
|
||||
changed, and asks you to run `Start-Codeman.sh` here on the host instead. Details:
|
||||
[`../docs/docker-self-update.md`](../docs/docker-self-update.md).
|
||||
|
||||
## Application data storage
|
||||
|
||||
The default configuration uses a host-folder bind mount:
|
||||
|
||||
@@ -70,4 +70,60 @@ fi
|
||||
|
||||
export DOCKER_SOCKET_GID=${socket_ids##*:}
|
||||
|
||||
repo_path=${CODEMAN_REPO_PATH:-$(cd -- "$script_dir/.." && pwd)}
|
||||
if [[ ! -d "$repo_path" ]]; then
|
||||
printf 'Error: CODEMAN_REPO_PATH is not a directory: %s\n' "$repo_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
export CODEMAN_REPO_PATH="$repo_path"
|
||||
|
||||
# The in-app updater runs `git checkout` and `npm install` against this checkout
|
||||
# as PUID:PGID. If the directory belongs to someone else, git refuses outright
|
||||
# ("detected dubious ownership") and the update fails at the first step — so warn
|
||||
# here, where the fix is obvious, rather than in a failed update hours later.
|
||||
if repo_owner=$(stat -c '%u' -- "$repo_path" 2>/dev/null || stat -f '%u' "$repo_path" 2>/dev/null); then
|
||||
if [[ "$repo_owner" != "$PUID" ]]; then
|
||||
printf 'Warning: %s is owned by UID %s but Codeman runs as UID %s.\n' "$repo_path" "$repo_owner" "$PUID" >&2
|
||||
printf 'In-app updates will fail until the ownership matches. Codeman itself still starts.\n' >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -d "$repo_path/.git" ]]; then
|
||||
printf 'Note: %s is not a git checkout, so in-app updates are unavailable.\n' "$repo_path" >&2
|
||||
fi
|
||||
|
||||
# Record what the container is about to be built and created FROM. The in-app
|
||||
# updater compares these against the release it wants to apply: a release that
|
||||
# changes either file cannot be applied by the container restarting itself (a
|
||||
# restart reuses the existing image and config), so it is refused and the user
|
||||
# is sent back here. Written on every start, so the baseline always describes
|
||||
# the container that is actually running. See docs/docker-self-update.md.
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256_of() { sha256sum -- "$1" | cut -d' ' -f1; }
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
sha256_of() { shasum -a 256 -- "$1" | cut -d' ' -f1; }
|
||||
else
|
||||
sha256_of() { printf ''; }
|
||||
fi
|
||||
|
||||
dockerfile_sha=$(sha256_of "$script_dir/server.Dockerfile")
|
||||
compose_sha=$(sha256_of "$compose_file")
|
||||
if [[ -n "$dockerfile_sha" && -n "$compose_sha" ]]; then
|
||||
# $CODEMAN_APPDATA_PATH is mounted at the runtime account's home, so this is
|
||||
# dataPath('docker-env-applied.json') as the server inside the container sees it.
|
||||
state_dir="$appdata_path/.codeman"
|
||||
mkdir -p -- "$state_dir"
|
||||
printf '{\n "dockerfileSha256": "%s",\n "composeSha256": "%s"\n}\n' \
|
||||
"$dockerfile_sha" "$compose_sha" >"$state_dir/docker-env-applied.json.tmp"
|
||||
mv -- "$state_dir/docker-env-applied.json.tmp" "$state_dir/docker-env-applied.json"
|
||||
# A root-run start (common on Unraid) would otherwise leave a root-owned
|
||||
# `.codeman` on a FIRST start, before the container has created it as PUID,
|
||||
# and the unprivileged server could then never write its own state there.
|
||||
if [[ "$EUID" == '0' ]]; then
|
||||
chown -- "$PUID:$PGID" "$state_dir" "$state_dir/docker-env-applied.json"
|
||||
fi
|
||||
else
|
||||
printf 'Warning: no sha256 tool found; in-app updates will not detect environment changes.\n' >&2
|
||||
fi
|
||||
|
||||
exec docker compose --env-file "$env_file" -f "$compose_file" up --build -d
|
||||
|
||||
@@ -15,6 +15,17 @@ services:
|
||||
ports:
|
||||
- "${CODEMAN_PORT}:${CODEMAN_PORT}"
|
||||
environment:
|
||||
# Tells the self-updater to restart by exiting (the restart policy below
|
||||
# relaunches it) rather than by looking for an init system that is not
|
||||
# here. Also set in the image; repeated so a container started without the
|
||||
# image default still self-identifies.
|
||||
CODEMAN_IN_CONTAINER: "1"
|
||||
# This file sets `restart: unless-stopped` below, so the updater may restart
|
||||
# the server by EXITING. Declared here and only here, never in the image: a
|
||||
# container started by plain `docker run` has no restart policy unless the
|
||||
# operator gave it one, and there the updater asks the daemon instead and
|
||||
# stages the update for a manual restart when it cannot get an answer.
|
||||
CODEMAN_RESTART_BY_EXIT: "1"
|
||||
CODEMAN_DOCKER_BRIDGE_HOOKS: ${CODEMAN_DOCKER_BRIDGE_HOOKS}
|
||||
# Host-side equivalent of the runtime user's HOME. Docker case seed,
|
||||
# credential and hook mounts are translated into the daemon namespace.
|
||||
@@ -48,6 +59,32 @@ services:
|
||||
- type: bind
|
||||
source: ${DOCKER_SOCKET}
|
||||
target: /var/run/docker.sock
|
||||
# The application source, so App Settings -> Updates can update in place.
|
||||
# This is the SAME checkout used as the build context above, mounted over
|
||||
# the image's baked copy: a `git checkout` performed inside the container
|
||||
# then lands on the host and survives the container being recreated.
|
||||
# Without it the pull would go to the container's writable layer and be
|
||||
# silently discarded by the next `up`. See docs/docker-self-update.md.
|
||||
# Defaults to `..` — the build context above — which Compose resolves
|
||||
# against the project directory, so plain `docker compose up` works with
|
||||
# no extra configuration. Set CODEMAN_REPO_PATH only to point elsewhere.
|
||||
- type: bind
|
||||
source: ${CODEMAN_REPO_PATH:-..}
|
||||
target: /opt/codeman
|
||||
# Build artefacts live in named volumes layered OVER the repo bind mount,
|
||||
# so `npm install` and `npm run build` inside the container never write
|
||||
# into the host checkout. That keeps container-compiled native modules
|
||||
# (node-pty is built from source here) out of a checkout that may also be
|
||||
# used to run Codeman natively, and keeps `git status` clean. Docker seeds
|
||||
# an EMPTY named volume from the image, so the first start inherits the
|
||||
# image's already-built node_modules and dist rather than paying for a
|
||||
# bootstrap build.
|
||||
- type: volume
|
||||
source: codeman-node-modules
|
||||
target: /opt/codeman/node_modules
|
||||
- type: volume
|
||||
source: codeman-dist
|
||||
target: /opt/codeman/dist
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
security_opt:
|
||||
@@ -63,3 +100,12 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
volumes:
|
||||
# Container-owned build artefacts. They persist across container recreation,
|
||||
# so an in-app update's `npm install` output is not thrown away by the next
|
||||
# `up`, and they are seeded from the image on first use. Removing them (or
|
||||
# `docker compose down -v`) is the supported reset: the next start rebuilds
|
||||
# from the image.
|
||||
codeman-node-modules:
|
||||
codeman-dist:
|
||||
|
||||
@@ -12,9 +12,12 @@ WORKDIR /opt/codeman
|
||||
|
||||
COPY . .
|
||||
|
||||
# devDependencies are deliberately KEPT (no `npm prune --omit=dev`). The in-app
|
||||
# updater rebuilds from inside this container, and `npm run build` is tsc +
|
||||
# esbuild — both devDependencies. Pruning them saves image size and takes the
|
||||
# self-updater with it. See docs/docker-self-update.md.
|
||||
RUN npm ci \
|
||||
&& npm run build \
|
||||
&& npm prune --omit=dev --ignore-scripts \
|
||||
&& npm cache clean --force
|
||||
|
||||
# The Docker CLI talks to the host daemon through the socket mounted by
|
||||
@@ -25,13 +28,21 @@ ARG CODEMAN_RUNTIME_USER=opencode
|
||||
ARG PUID=1000
|
||||
ARG PGID=1000
|
||||
|
||||
# python3/make/g++ are here for the SELF-UPDATER, not for this build. An update
|
||||
# runs `npm install` inside the running container, and node-pty ships no Linux
|
||||
# prebuild, so a release that bumps it compiles from source right here. Without
|
||||
# a toolchain that install fails and the update rolls back — every time, on the
|
||||
# releases that need it most. Same reason install.sh installs one on bare hosts.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
g++ \
|
||||
git \
|
||||
make \
|
||||
openssh-client \
|
||||
procps \
|
||||
python3 \
|
||||
ripgrep \
|
||||
tmux \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -59,11 +70,23 @@ COPY --from=docker:29-cli \
|
||||
|
||||
# Keep credentials out of the image. Users authenticate these CLIs at runtime
|
||||
# through Codeman sessions, and the configured host bind mount retains state.
|
||||
#
|
||||
# ⚠️ PINNED ON PURPOSE. Unpinned, the agent CLI versions a user ends up with are
|
||||
# a function of WHEN their image was built, not of any commit — so a Codeman
|
||||
# release that depends on newer CLI behaviour (the trust-dialog handling is
|
||||
# pinned to Claude Code 2.1.252's layout; wheel forwarding to >= 2.1.187) breaks
|
||||
# on an older image with no diff anywhere to explain why. In-app updates make
|
||||
# rebuilds RARER, which makes that drift worse. Pinning turns "this release needs
|
||||
# a newer CLI" into a Dockerfile change, which the updater's environment gate
|
||||
# already detects and refuses (docs/docker-self-update.md).
|
||||
#
|
||||
# Bump these deliberately, in a release. `--no-cache` is still needed to rebuild
|
||||
# this layer when only the pins change upstream.
|
||||
RUN npm install --global \
|
||||
@anthropic-ai/claude-code \
|
||||
@google/gemini-cli \
|
||||
@openai/codex \
|
||||
opencode-ai \
|
||||
@anthropic-ai/claude-code@2.1.258 \
|
||||
@google/gemini-cli@0.58.0 \
|
||||
@openai/codex@0.152.1 \
|
||||
opencode-ai@1.18.26 \
|
||||
&& npm cache clean --force
|
||||
|
||||
# Keep the web server and every local Codeman session unprivileged. PUID and
|
||||
@@ -103,7 +126,12 @@ WORKDIR /opt/codeman
|
||||
|
||||
COPY --from=build /opt/codeman /opt/codeman
|
||||
|
||||
ENV CODEMAN_PORT=3000 \
|
||||
# CODEMAN_IN_CONTAINER tells the self-updater it must restart by exiting rather
|
||||
# than by asking an init system that is not here (src/web/self-update.ts).
|
||||
# NODE_ENV stays `production`; the updater passes `npm install --include=dev`
|
||||
# explicitly, since that value would otherwise omit the build toolchain.
|
||||
ENV CODEMAN_IN_CONTAINER=1 \
|
||||
CODEMAN_PORT=3000 \
|
||||
HOME=/home/${CODEMAN_RUNTIME_USER} \
|
||||
NODE_ENV=production
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -36,12 +36,19 @@ interface CliEntry {
|
||||
launch: CliLaunch; // the structured argv template
|
||||
env: CliEnv; // exports, tmux setenv keys, the env-override allowlist
|
||||
capabilities: CliCapabilities; // what every call site reads instead of the id
|
||||
// .workDetect?: { promptGlyph, workingLine } — how this CLI's pane shows work
|
||||
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
|
||||
}
|
||||
```
|
||||
|
||||
`capabilities` is the important part. It is what `isExternalCliMode()`, `isAltScreenStripMode()`, `hooksAvailableForMode()` and every other former per-mode branch actually read.
|
||||
|
||||
### Regexes that come from config
|
||||
|
||||
Two capability fields carry a regular expression an override file can set: `discovery.version.regex` and `capabilities.workDetect.workingLine`. Both go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
|
||||
|
||||
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
|
||||
|
||||
### Three capabilities that must stay independent
|
||||
|
||||
`external`, `hooks` and `altScreen` describe three different, deliberately unequal sets, and deriving any one from another has already shipped a bug. `shell` has no hooks but is **not** an external CLI, so a hooks predicate written as `!isExternalCliMode()` accepted `until=stop` on a shell session and then blocked the caller for their entire timeout. `deepseek` is the mirror image: it IS external and it DOES have hooks.
|
||||
|
||||
@@ -78,6 +78,49 @@ curl -X POST localhost:3000/api/cases/docker-link -d '{"name":"sandbox","hostId"
|
||||
curl -X POST localhost:3000/api/quick-start -d '{"caseName":"sandbox","mode":"claude"}'
|
||||
```
|
||||
|
||||
## Attach to a container you already run
|
||||
|
||||
The tab's **Attach to an existing container** toggle points a case at a container **you**
|
||||
built and run. Codeman only ever `docker exec`s into it: it never creates, starts, stops,
|
||||
restarts or removes it, and it seeds no credentials into it, so the CLIs inside must already
|
||||
be installed and logged in. A missing or stopped container is an error to report, not a state
|
||||
to fix — start it yourself and reopen the session.
|
||||
|
||||
- **Container Name** is a picker over the engine's containers that you can also type into
|
||||
(the engine may be remote, or the container may not exist yet when you fill the form).
|
||||
Stopped containers are listed too, sorted last and labelled, so "mine isn't here" is never
|
||||
a dead end.
|
||||
- **Container Workdir** is a path that must already exist **inside** the container. Adoption
|
||||
mounts nothing, so it need not match the host workspace path; **Browse** lists directories
|
||||
inside the container itself. Without this check, a wrong path fails at launch as a bare
|
||||
`execvp failed` inside the pane.
|
||||
- **Workspace Path** is still a real host directory. It backs file previews, attachments and
|
||||
watchers exactly as it does for an owned case, but here it is only a mirror: nothing is
|
||||
bind-mounted, so point it at whatever host directory your container already exposes.
|
||||
- **Check container** runs a read-only preflight and reports what is inside before you commit
|
||||
to a case name (running or not, tmux present, which CLIs resolved).
|
||||
- **Run modes come from the container**, not the host: a host with no `claude` still offers
|
||||
Claude if the container ships it, and a mode the container lacks is hidden.
|
||||
- Claude is launched **without** `--dangerously-skip-permissions` when the container's exec
|
||||
user is root, because Claude Code refuses that flag as root and the refusal is only visible
|
||||
inside the container.
|
||||
- Image, network and resource settings disappear from the form: they describe a
|
||||
`docker create` that adoption never runs.
|
||||
|
||||
Recreate is refused for an adopted case, full-image export is refused (it would commit a
|
||||
container that is not ours), unlinking the case leaves the container running, and the boot
|
||||
reaper skips it. Workspace-only export still works and never pauses the container.
|
||||
|
||||
Equivalent API:
|
||||
|
||||
```bash
|
||||
curl -X POST localhost:3000/api/docker-cases/adopt-preflight -d '{"hostId":"local","container":"my-dev-box","containerWorkdir":"/workspace"}'
|
||||
curl -X POST localhost:3000/api/cases/docker-adopt -d '{"name":"devbox","hostId":"local","container":"my-dev-box","hostWorkspacePath":"/home/you/projects/devbox","containerWorkdir":"/workspace"}'
|
||||
```
|
||||
|
||||
In multi-user mode adoption is **admin-only**, unlike `docker-link`: an adopted container's
|
||||
mounts belong to whoever built it, so one mounting `/` would hand the adopter the whole host.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
- **Reconnect after a Codeman restart** lands back in the same live agent (the in-container tmux survives).
|
||||
|
||||
@@ -61,6 +61,16 @@ If `docker info` reports `SwapLimit=false`, set `CODEMAN_DOCKER_DISABLE_SWAP_LIM
|
||||
|
||||
If that directory was created by an earlier root-running image, change its ownership to the configured `PUID:PGID` before starting this version. This preserves existing CLI credentials and session state while allowing the unprivileged runtime account to use them.
|
||||
|
||||
## Updating
|
||||
|
||||
Codeman updates itself from **App Settings → Updates**, as it does on a bare host. The checkout mounted at `/opt/codeman` is the same directory Compose builds from, so the update's `git checkout` and rebuild land on the host and survive container recreation; the restart is the server exiting, which `restart: unless-stopped` turns into a relaunch on the new build.
|
||||
|
||||
That applies application code only. A release that changes `docker/server.Dockerfile`, `docker/docker-compose.yaml`, or adds a key to `docker/.env.example` needs the image rebuilt or the container recreated, which a container cannot do to itself. The updater detects each case and refuses with a message naming what changed; run `docker/Start-Codeman.sh` on the host to apply those.
|
||||
|
||||
`CODEMAN_REPO_PATH` overrides which checkout is mounted. It defaults to the compose project's parent directory, so it normally needs no setting. Point it at a directory that is not a git checkout and in-app updates are reported as unavailable.
|
||||
|
||||
Full detail, including the fingerprint baseline and the troubleshooting table: [`docker-self-update.md`](docker-self-update.md).
|
||||
|
||||
## Docker cases
|
||||
|
||||
The default socket path is `/var/run/docker.sock`, which works with a standard Linux Docker Engine. The Bash start script detects its numeric group ID. When running Compose directly, set `DOCKER_SOCKET_GID`, for example using `stat -c '%g' /var/run/docker.sock`, so the unprivileged `CODEMAN_RUNTIME_USER` account can create Docker cases. Docker Desktop users should set `DOCKER_SOCKET` in `docker/.env` only when their Docker installation exposes a different compatible socket path.
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
# Self-update in the Docker Compose deployment
|
||||
|
||||
Codeman running as a container updates itself from **App Settings → Updates**, the
|
||||
same place and the same button as a bare-host install. This document explains how
|
||||
that works, what it deliberately refuses to do, and how to recover when it stops.
|
||||
|
||||
The bare-host updater is documented in
|
||||
[`architecture-invariants.md#self-update`](architecture-invariants.md#self-update);
|
||||
this file covers only what the container changes.
|
||||
|
||||
## The short version
|
||||
|
||||
| Change in the release | Applied by |
|
||||
| -------------------------------- | ------------------------------------------------ |
|
||||
| Application code | The in-app updater |
|
||||
| `docker/server.Dockerfile` | `docker/Start-Codeman.sh` on the host |
|
||||
| `docker/docker-compose.yaml` | `docker/Start-Codeman.sh` on the host |
|
||||
| New key in `docker/.env.example` | Add it to `docker/.env`, then `Start-Codeman.sh` |
|
||||
|
||||
The in-app updater detects all three of the bottom rows itself and refuses with a
|
||||
message naming what changed, so you never have to work out which case you are in.
|
||||
|
||||
## Why the container needs its own path
|
||||
|
||||
The bare-host updater does `git checkout <tag> && npm install && npm run build`,
|
||||
then asks systemd or launchd to restart the service. Two of those assumptions are
|
||||
false in a container:
|
||||
|
||||
1. **There is no init system.** A container's supervisor is the Docker daemon,
|
||||
which acts on the container, not on processes inside it.
|
||||
2. **The image is immutable.** A `git pull` into the image's baked `/opt/codeman`
|
||||
would land in the container's writable layer, survive `docker restart`, and be
|
||||
silently discarded by the next `docker compose up`.
|
||||
|
||||
Both are solved by configuration rather than by a second updater:
|
||||
|
||||
- **The checkout is a host bind mount.** `docker-compose.yaml` mounts the repo
|
||||
(the same directory used as the build context) over `/opt/codeman`, so the
|
||||
updater's `git checkout` writes to the host filesystem and survives the
|
||||
container being recreated.
|
||||
- **The restart is the server exiting.** `restart: unless-stopped` relaunches the
|
||||
container whenever its main process ends, including on a clean exit — so the
|
||||
updater's final step is to signal the server, and Docker starts it again on the
|
||||
freshly built `dist/`.
|
||||
|
||||
Everything else — the release-tag channel, the auto-stash, the atomic
|
||||
`update-status.json` the browser polls across the connection drop, the boot-time
|
||||
reconcile that flips `restarting` to `completed` — is the existing machinery,
|
||||
unchanged. The container path is a new `SupervisorKind`, not a new updater.
|
||||
|
||||
## What the pieces are
|
||||
|
||||
| Piece | Role |
|
||||
| ---------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| Repo bind mount at `/opt/codeman` | Makes the pull persistent. Without it, self-update is unavailable. |
|
||||
| `codeman-node-modules`, `codeman-dist` volumes | Container-owned build artefacts, layered over the bind mount. |
|
||||
| `CODEMAN_IN_CONTAINER=1` | Tells `detectSupervisor()` to restart by exiting. |
|
||||
| `restart: unless-stopped` | Turns that exit into a restart. Verified before every update. |
|
||||
| `CODEMAN_RESTART_BY_EXIT=1` | The Compose file's declaration of that policy, so the updater may exit even with no Docker socket. |
|
||||
| Toolchain + devDependencies in the image | Lets `npm install` and `npm run build` run inside the container. |
|
||||
| `docker-env-applied.json` | Fingerprint baseline, written by `Start-Codeman.sh` on every start. |
|
||||
|
||||
### Why build artefacts are in named volumes
|
||||
|
||||
`node_modules` and `dist` are mounted as named volumes **on top of** the repo bind
|
||||
mount. Without that, an update's `npm install` would write into the host checkout,
|
||||
leaving container-compiled native modules (node-pty builds from source here) in a
|
||||
directory that may also be used to run Codeman natively, and leaving `git status`
|
||||
permanently noisy.
|
||||
|
||||
Docker seeds an empty named volume from the image, so the first start inherits the
|
||||
image's already-built `node_modules` and `dist` and pays no bootstrap cost.
|
||||
`docker compose down -v` is the supported reset: the next start re-seeds them.
|
||||
|
||||
### Why the runtime image carries a build toolchain
|
||||
|
||||
`npm run build` is `tsc` plus `esbuild`, both devDependencies, so the image no
|
||||
longer runs `npm prune --omit=dev`. And `npm install` may rebuild node-pty, which
|
||||
ships no Linux prebuild, so `python3`, `make` and `g++` are installed as well.
|
||||
|
||||
This is the real cost of in-place updates: a noticeably larger image than a
|
||||
runtime-only one. It buys an update that takes about a minute instead of a full
|
||||
image rebuild, and it is why `NODE_ENV=production` is paired with an explicit
|
||||
`npm install --include=dev` in the updater.
|
||||
|
||||
## The environment gate
|
||||
|
||||
An in-place update applies **code only**. A restarted container reuses its existing
|
||||
image and configuration, so a release that changes the environment cannot take
|
||||
effect that way — and would half-apply: new code against an old environment. The
|
||||
updater therefore checks the **target release's own files**, read straight out of
|
||||
git with `git show <tag>:<path>` before anything is checked out.
|
||||
|
||||
### 1. `server.Dockerfile` changed, so the image must be rebuilt
|
||||
|
||||
Compared by sha256 against the fingerprint `Start-Codeman.sh` recorded when the
|
||||
running container was built.
|
||||
|
||||
### 2. `docker-compose.yaml` changed, so the container must be recreated
|
||||
|
||||
Same mechanism. A restart cannot pick up a new mount, port or environment
|
||||
variable; only recreating the container can.
|
||||
|
||||
### 3. `.env.example` gained keys your `.env` has no value for
|
||||
|
||||
The check that matters most, because **Compose will not tell you**. An unset
|
||||
`${VAR}` interpolates to the empty string; Compose prints a warning to a terminal
|
||||
nobody is watching and starts anyway. A new required setting therefore arrives as
|
||||
a silently blank environment variable and misbehaves later, far from the cause.
|
||||
The updater names the missing keys instead.
|
||||
|
||||
Commented-out lines in `.env.example` are deliberately *not* keys — that is how
|
||||
the file marks optional overrides such as `# PUID=1000`, and counting them would
|
||||
block updates on settings you are meant to leave alone.
|
||||
|
||||
### 4. A restart policy that would not bring the container back
|
||||
|
||||
Before signalling the server, the updater asks the Docker daemon for its own
|
||||
container's restart policy. If it is `no`, the update is refused: applying it
|
||||
would take Codeman down and leave no UI to recover from.
|
||||
|
||||
If the policy cannot be read at all (no Docker socket mounted) the update is
|
||||
still allowed, but the final step changes: the server exits only when the
|
||||
Compose file declared `CODEMAN_RESTART_BY_EXIT=1` (the shipped one does, because
|
||||
it is the file that sets `restart: unless-stopped`) or the daemon confirmed an
|
||||
auto-restart policy. Otherwise the build completes and the panel asks you to
|
||||
restart the container by hand. A container started by plain `docker run` with no
|
||||
restart policy therefore gets a staged update, never an outage.
|
||||
|
||||
### What the gate deliberately does not do
|
||||
|
||||
Every unknown fails **open**:
|
||||
|
||||
- A missing fingerprint baseline (a container started before this feature existed)
|
||||
is not treated as a change, or those installs could never update at all.
|
||||
- An unreadable `.env`, an unreachable Docker socket, or a target tag whose files
|
||||
cannot be read all yield "no blocker" rather than a refusal.
|
||||
|
||||
The one place an unknown does NOT fail open is the kill itself: with neither the
|
||||
Compose declaration nor a daemon answer, the updater stages the build and asks
|
||||
for a manual restart rather than exiting a server nothing may bring back.
|
||||
|
||||
The gate catches a specific, detectable class of mistake; it is not a last line of
|
||||
defence. It is also re-evaluated server-side on `POST /api/system/update`, so
|
||||
hiding the button in the UI is a courtesy rather than the control.
|
||||
|
||||
## The one residual risk
|
||||
|
||||
The gate is derived from the diff, so it cannot see a release that needs a newer
|
||||
environment **without changing any of those files** — for example, code that
|
||||
depends on newer agent-CLI behaviour.
|
||||
|
||||
That is why the four global CLIs in `server.Dockerfile` are **pinned**. Unpinned,
|
||||
the versions a user ends up with are a function of when their image was built
|
||||
rather than of any commit, and in-app updates make rebuilds rarer, which makes
|
||||
that drift worse over time. Pinned, "this release needs a newer CLI" becomes a
|
||||
Dockerfile change, which check 1 already detects. Bump them deliberately, as part
|
||||
of a release.
|
||||
|
||||
The complementary merge-side guard is `test/docker-compose-env-parity.test.ts`,
|
||||
which fails CI when a variable is added to `docker-compose.yaml` without an entry
|
||||
in `.env.example`, or the reverse.
|
||||
|
||||
## Sequence of an in-place update
|
||||
|
||||
1. **Check** — `GET /api/system/update/check` finds the latest release tag, fetches
|
||||
that one ref so the gate can read the target's files, and returns any blockers.
|
||||
2. **Start** — `POST /api/system/update` re-evaluates the gate, writes `queued` to
|
||||
`update-status.json`, stages `self-update.sh` outside the repo and runs it.
|
||||
3. **Apply** — stash if dirty, fetch the tag, check it out, `npm install
|
||||
--include=dev`, `npm run build`. A failure at any step rolls back to the
|
||||
previous commit, rebuilds it and reports `failed`; the server is never
|
||||
restarted into a broken build.
|
||||
4. **Restart** — write the terminal `restarting` marker, then signal the server.
|
||||
The container exits and Docker restarts it.
|
||||
5. **Reconcile** — the rebooted server compares its own version against the target
|
||||
and flips the status to `completed` or `failed`. The browser, still polling,
|
||||
picks that up.
|
||||
|
||||
Step 4 kills the updater script along with the container — unlike the systemd
|
||||
path, it does not outlive the restart. That is safe only because the terminal
|
||||
marker is written first, which is why nothing may be appended after the kill.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"This install can't update itself (unknown)"** — the repo bind mount is missing,
|
||||
so the container is running the baked image copy. Check `CODEMAN_REPO_PATH` and
|
||||
confirm the mounted directory really contains `.git`.
|
||||
|
||||
**The update fails immediately with a git ownership or permission error** — the
|
||||
mounted checkout belongs to a different user than the one Codeman runs as
|
||||
(`PUID`), so git refuses it as "dubious ownership". `Start-Codeman.sh` warns
|
||||
about this at start; fix it by chowning the checkout to the same account that
|
||||
owns `CODEMAN_APPDATA_PATH`.
|
||||
|
||||
**A rebuild is reported as required every time** — the fingerprint baseline does
|
||||
not match the checkout. `Start-Codeman.sh` writes it on every start, so start
|
||||
through that script rather than a bare `docker compose up` after either file
|
||||
changes.
|
||||
|
||||
**Codeman does not come back after an update** — the build succeeded, since the
|
||||
updater gates the restart on it, so read the container logs with `docker compose
|
||||
logs codeman`. To roll back, check out the previous tag in the host checkout and
|
||||
run `docker/Start-Codeman.sh`.
|
||||
|
||||
**The update failed during `npm install`** — most likely a native rebuild with no
|
||||
toolchain, meaning the image predates the toolchain being added. Rebuild once from
|
||||
the host and the in-app path works from then on.
|
||||
|
||||
**Resetting the build artefacts** — `docker compose down -v`, then
|
||||
`Start-Codeman.sh`. This discards the named volumes and re-seeds them from a fresh
|
||||
image.
|
||||
|
||||
## Disabling it
|
||||
|
||||
Set `CODEMAN_DISABLE_SELF_UPDATE=1` in `docker/.env` and pass it through in the
|
||||
compose file's `environment:` block. The Updates panel then reports that in-app
|
||||
updates are disabled, and the host-side script is the only way to update.
|
||||
@@ -153,6 +153,14 @@ shared nor seeded.
|
||||
include `~/.local/bin`. Per-session config and `envOverrides` do not cross ssh and are
|
||||
rejected rather than silently ignored; use the per-host command override instead.
|
||||
|
||||
⚠️ A **respawn or reattach** of a remote omp session runs `omp --continue`, not a
|
||||
bare `omp`, so it lands back in the same conversation. It is deliberately
|
||||
`--continue` rather than the exact `--resume <id>` the local and docker paths
|
||||
pin: `omp-session-resolver.ts` only ever reads THIS host's `~/.omp/agent/sessions/`,
|
||||
and a remote conversation's session file lives on the remote host under the
|
||||
remote user's home, so resolving locally would pin a stranger's id. See
|
||||
[Respawn / reattach continuation](remote-sessions.md#respawn--reattach-continuation).
|
||||
|
||||
## Known gaps
|
||||
|
||||
- **No idle/completion hook.** Idle detection falls back to output-stabilization
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
# PR bot: automatic pull-request reviews, reported over Telegram
|
||||
|
||||
The PR bot is maintainer tooling that lives in `scripts/pr-bot/`. It watches the
|
||||
repository's open pull requests, reviews each one in a Codeman claude session running in
|
||||
a private clone of the repository, and sends the verdict to a Telegram chat with the ranked
|
||||
findings, a recommendation and action buttons. The maintainer decides what happens next
|
||||
from the phone: merge, post the drafted review comment, close, approve a waiting CI run,
|
||||
or ask the reviewer session a follow-up question.
|
||||
|
||||
It reviews on its own. It never writes to GitHub on its own.
|
||||
|
||||
## How a review runs
|
||||
|
||||
1. Every poll (default 10 minutes) the bot lists open PRs with `gh`. A PR is queued
|
||||
when its head commit differs from the one last reviewed, so a push re-reviews and an
|
||||
untouched PR is never reviewed twice. Draft PRs and bot PRs are skipped. The backlog
|
||||
is ordered mergeable-and-small first, conflicting-and-huge last.
|
||||
2. The PR head is fetched into a private ref (`refs/pr-bot/<n>`) of the main repository
|
||||
and checked out (detached) in a private clone under
|
||||
`~/.codeman/pr-bot/worktrees/pr-<n>`, made with `git clone --shared` so the object
|
||||
store stays shared and nothing is duplicated. The maintainer's own checkout is never
|
||||
checked out or reset by the bot. A clone rather than a linked worktree because Claude
|
||||
Code reads a linked worktree's project settings from the MAIN checkout, whose model
|
||||
pin would silently override the bot's. `node_modules` is a symlink to the main
|
||||
checkout's tree when the PR itself leaves the dependency files untouched (judged
|
||||
against the PR's merge base, not against current master), and a real `npm ci`
|
||||
otherwise (the symlink is unlinked first, so npm can never write through it; an
|
||||
install interrupted by a restart is discarded, never reused).
|
||||
3. A review brief is written to `~/.codeman/pr-bot/jobs/pr-<n>/brief.md`: the PR
|
||||
metadata, CI state, mergeability, the file list, the body verbatim, the ground rules
|
||||
(nothing reaches GitHub, no installs, no builds, no services, never port 3000), the
|
||||
review protocol (CLAUDE.md and CONTRIBUTING first, then correctness, security,
|
||||
invariants, tests, contract, scope), the checks to run, the verdict vocabulary and
|
||||
the exact JSON to produce.
|
||||
4. A Codeman session named `prbot-<n>` is created in the clone over the HTTP API,
|
||||
the composer is awaited (the folder-trust dialog is read off the screen and answered
|
||||
one key at a time), and one prompt points the session at the brief. The bot waits on
|
||||
the `stop`/`blocked`/`exit` hook signals, never on the heuristic `idle`, with a hard
|
||||
timeout (default 40 minutes).
|
||||
5. The session writes `report.json` and `report.md` next to the brief and replies
|
||||
`REVIEW COMPLETE`. The bot parses the JSON leniently, records the Claude session id
|
||||
for follow-ups, deletes the Codeman session, keeps the clone, and sends the
|
||||
summary to Telegram. Reviews run one at a time.
|
||||
|
||||
Verdicts: `merge`, `merge-with-fixes`, `request-changes`, `close`, `needs-discussion`.
|
||||
Findings are ranked `blocker` / `major` / `minor` / `nit`, each with file and line.
|
||||
|
||||
## The Telegram side
|
||||
|
||||
Each review arrives as one message: PR number and title, author, size, CI state,
|
||||
mergeability, the verdict with confidence, the summary, the top findings, the checks
|
||||
that were run, the recommendation, and buttons:
|
||||
|
||||
| Button / command | What it does |
|
||||
| --- | --- |
|
||||
| 📄 Full report · `/report N` | Sends `report.md` (as a file when long). |
|
||||
| 💬 Draft comment · `/draft N` | Shows the comment drafted for the contributor. Nothing is posted. |
|
||||
| 📮 Post comment · `/post N` | Shows the draft again and asks for confirmation, then posts it under your GitHub account. |
|
||||
| ✅ Merge · `/merge N` | Re-checks mergeability and CI, lists warnings (red CI, new commits since the review, a non-merge verdict), asks for confirmation, then merges with a merge commit. Refuses a conflicting PR. |
|
||||
| 🗑 Close · `/close N reason` | Asks for the closing comment if none was given, asks for confirmation, then closes with that comment. |
|
||||
| ▶️ Approve CI run · `/approve N` | Approves a workflow run that GitHub holds for a first-time contributor. Shown only when one is waiting. |
|
||||
| 🔁 Re-review · `/review N` | Queues a fresh review at the front of the queue. |
|
||||
| `/ask N question`, or reply to any review message | Resumes the reviewer's Claude conversation in the same clone and relays the answer. It can inspect, run checks, or make uncommitted changes there; it still never pushes. |
|
||||
| `/status` · `/scan` · `/pause` · `/resume` · `/help` | Housekeeping. |
|
||||
|
||||
Merge, close and post always take a second tap. Confirmations expire after 15 minutes.
|
||||
Only messages from the configured chat are acted on; anyone else gets silence.
|
||||
|
||||
When a PR is merged or closed, the bot announces it, removes the clone and the
|
||||
private ref, and keeps the record.
|
||||
|
||||
## Setup
|
||||
|
||||
Requirements on the machine that runs the bot: a running Codeman (the sessions are
|
||||
spawned there), `gh` logged in as the account that should merge and comment, `git`,
|
||||
Node 22, and the repository checkout with its `node_modules`.
|
||||
|
||||
Config is `~/.codeman/pr-bot.env` (`KEY=VALUE`, keep it mode 0600). The Telegram token
|
||||
and chat id are read from the existing notifier bot's env file
|
||||
(`~/codeman-cases/telegram/.env`) when present, so on the maintainer's machine no key
|
||||
has to be copied; set them here to use a different bot.
|
||||
|
||||
| Key | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `TELEGRAM_BOT_TOKEN` | from the shared env file | BotFather token. |
|
||||
| `TELEGRAM_CHAT_ID` | from the shared env file | The one chat that receives reports and may issue commands. |
|
||||
| `GITHUB_REPO` | `Ark0N/Codeman` | `owner/name`. |
|
||||
| `CODEMAN_API_URL` | `https://127.0.0.1:3000` | The Codeman that spawns the review sessions. A self-signed certificate is accepted. |
|
||||
| `CODEMAN_USERNAME` / `CODEMAN_PASSWORD` | unset | Only when that Codeman has a password. |
|
||||
| `PR_BOT_POLL_INTERVAL` | `600` | Seconds between GitHub polls (minimum 60). |
|
||||
| `PR_BOT_MAIN_CHECKOUT` | the repo this script is in | The repository the clones share objects with and fetch from. |
|
||||
| `PR_BOT_DATA_DIR` | `~/.codeman/pr-bot` | State, briefs, reports, clones. |
|
||||
| `PR_BOT_MODEL` | unset (the session default) | Codeman `modelOverride` for the review sessions, e.g. `opus[1m]`. ⚠️ Pick a model whose budget can absorb a re-review of every open PR on every head commit: when it runs out, Claude Code answers the limit **inside the turn** and the reviewer has nothing to write. The bot now names that failure in seconds (`findModelLimitNotice`) instead of burning the whole `PR_BOT_REVIEW_TIMEOUT`, and a limit does not spend the per-head retry budget, so the queue resumes by itself once the budget does. |
|
||||
| `PR_BOT_EFFORT` | unset | Codeman `effort` for the review sessions. |
|
||||
| `PR_BOT_REVIEW_TIMEOUT` | `40` | Minutes before a review is abandoned. |
|
||||
| `PR_BOT_FOLLOWUP_TIMEOUT` | `20` | Minutes before a follow-up is abandoned. |
|
||||
| `PR_BOT_AUTO_REVIEW` | `1` | `0` reviews only on `/review N`. |
|
||||
| `PR_BOT_REVIEW_DRAFTS` | `0` | `1` reviews draft PRs too. |
|
||||
| `PR_BOT_TELEGRAM_ENV_FILE` | `~/codeman-cases/telegram/.env` | Where the shared token and chat id are read from. |
|
||||
|
||||
```bash
|
||||
npm run pr-bot -- check # config, gh, git, Codeman, Telegram, open PR count
|
||||
npm run pr-bot -- scan # the open PRs in review order, with what is new
|
||||
npm run pr-bot -- review 383 --no-telegram # one review now, printed instead of sent
|
||||
npm run pr-bot -- run # the daemon
|
||||
npm run pr-bot -- install-service # systemd user unit codeman-pr-bot, enabled and started
|
||||
npm run pr-bot -- status # what the state file knows
|
||||
tail -f ~/.codeman/pr-bot/bot.log # the service logs to a file, not the journal
|
||||
```
|
||||
|
||||
## Safety properties worth knowing before changing it
|
||||
|
||||
- **GitHub writes happen in exactly one place** (`runConfirmed` in `bot.ts`) and only
|
||||
after a confirmation tap on a nonce that expires. The review session's brief forbids
|
||||
`gh` writes, pushes and merges, and the session has no reason to have the token
|
||||
anyway: it runs as the same user as the maintainer's own sessions, so the prompt rule
|
||||
is the guard, and the clone's checkout is detached so an accidental push has no
|
||||
branch to land on.
|
||||
- **The maintainer's checkout is shared with other agent sessions**, so the bot never
|
||||
runs `git checkout`, `reset`, `stash` or `clean` there. It only fetches into
|
||||
`refs/pr-bot/*` there; everything else happens inside the per-PR clone.
|
||||
- **The clones are `git clone --shared`.** Their objects live in the main checkout, so
|
||||
the `refs/pr-bot/<n>` ref there is what keeps a PR's commits safe from `git gc`; it
|
||||
is deleted together with the clone when the PR closes.
|
||||
- **`node_modules` may be a symlink into the live checkout.** The brief forbids
|
||||
installs, and `worktree.ts` unlinks the symlink before any `npm ci`. `src/web/public/vendor`
|
||||
is copied per file, never linked, because postinstall regenerates it in place.
|
||||
- **Sessions are named `prbot-<n>`** and tracked by id; the bot deletes only those, on
|
||||
completion, on shutdown, and (by name) as a sweep at startup after a crash. It never
|
||||
touches the maintainer's `w<n>-*` sessions.
|
||||
- **Readiness and end-of-turn follow the codeman skill's rules**: composer first
|
||||
(`shift+tab` in the pane), trust dialog read from the screen, `stop,blocked,exit`
|
||||
signals rather than `idle`. A session that asks a question is reported as a failed
|
||||
review with the pane's last lines, not left hanging.
|
||||
- **Telegram input is data.** Command parsing is a fixed grammar; free text is only ever
|
||||
relayed to a reviewer session as the maintainer's own follow-up, or used as a closing
|
||||
comment after confirmation.
|
||||
|
||||
Tests: `test/pr-bot-report.test.ts` (parsing, formatting, CI classification, command
|
||||
grammar, trust-dialog reader, config), `test/pr-bot-state.test.ts`, and
|
||||
`test/pr-bot-commands.test.ts` (the command and confirmation flows against a stubbed
|
||||
`gh` and Telegram: a GitHub write happens once, after the tap, never for a foreign chat
|
||||
or a reused nonce). Type-checked by
|
||||
`npm run typecheck` through `config/tsconfig.pr-bot.json`, linted and formatted with
|
||||
the main sources.
|
||||
+50
-1
@@ -30,7 +30,7 @@ Types live in `src/types/session.ts`; persistence in `src/remote-hosts.ts`.
|
||||
| `RemoteHost` (extends `RemoteSshOptions`) | A saved host: `id`, `label`, `host`, `username`, `port?`, `commands?` (per-mode launch command override). |
|
||||
| `RemoteCase` | A working directory on a host: `name`, `type: 'remote'`, `hostId`, `remotePath`. |
|
||||
| `SessionRemote` (extends `RemoteSshOptions`) | The resolved bundle stamped onto a live session: host coordinates + `remotePath` + `commands`, plus **`owned?`** and **`remoteSessionName?`** (COD-105 — see [Ownership](#ownership-launched-vs-discovered-and-attached-cod-105)). Built by `toSessionRemote(host, case)` (sets `owned: true`) for the launch path, or `toAttachedSessionRemote(host, name, path)` (sets `owned: false`) for the attach path. Both copy the advanced SSH options through so every connection is identical. |
|
||||
| `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini' \| 'antigravity' \| 'pi' \| 'grok'>` — the modes that can run remotely. |
|
||||
| `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini' \| 'antigravity' \| 'pi' \| 'grok' \| 'deepseek' \| 'omp'>` — the modes that can run remotely. |
|
||||
| `RemoteSessionInfo` (COD-105) | One discovered remote tmux session: `name` (always `codeman-*`), `attached` (a client is connected), `created` (epoch s), `windows`. Returned by `listRemoteCodemanSessions()`. |
|
||||
|
||||
Persistence is two flat JSON arrays in the instance data dir:
|
||||
@@ -116,6 +116,11 @@ Key points:
|
||||
the agent. The per-mode command comes from `remote.commands?.[mode]` or
|
||||
`defaultRemoteCommandForMode(mode)` (`exec claude` / `exec opencode` /
|
||||
`exec codex` / `exec gemini` / `exec agy` / `exec bash -l`).
|
||||
⚠️ **claude and omp no longer take that path**: both have their own arm in
|
||||
`buildRemoteLaunchCommand` so a respawn can continue the same conversation
|
||||
(see [Respawn / reattach continuation](#respawn--reattach-continuation)), and
|
||||
because the claude arm is an `a || b` pair under `-c`, its pane PID is the
|
||||
**login shell**, not the agent.
|
||||
- The **whole tmux invocation is a single shell-quoted ssh argument**, and the
|
||||
pane command is independently quoted, so a `remotePath` with spaces is safe.
|
||||
- Connection options come from the **same `buildSshConnectionArgs(remote)`** as
|
||||
@@ -202,6 +207,50 @@ The early return is a structural guarantee that **no code path can ever issue a
|
||||
remote `kill-session` for a session we don't own** — the only `kill-session` run is
|
||||
on the local socket, which never reaches the remote socket.
|
||||
|
||||
## Respawn / reattach continuation
|
||||
|
||||
A dropped connection or a dead pane must reconnect to the **same conversation**,
|
||||
not launch a fresh one — the whole point of a durable remote session.
|
||||
|
||||
- **Claude**: the launch command is idempotent — `claude --session-id <id> ||
|
||||
claude --resume <id>` (see `buildRemoteLaunchCommand`'s claude branch). The
|
||||
first run creates the conversation under the deterministic session id; every
|
||||
later reattach/respawn re-runs the same line, `--session-id` fails
|
||||
("already in use"), and the `||` fallback resumes it.
|
||||
- **OMP**: `omp` has no equivalent idempotent single-line form, so
|
||||
`Session._pinOmpRespawnId()` resolves and pins an explicit `--resume <id>`
|
||||
before a respawn (mirroring the local/docker builders, rendered through the
|
||||
same `buildSpawnCommandFromRegistry` engine — not a hand-rolled command and
|
||||
not `appendResumeFlag()`, which is docker-only and cannot work here: appending
|
||||
a flag after the quoted `-c 'omp'` hands the id to the login shell as `$0`
|
||||
instead of to `omp`). ⚠️ **The resolver only ever reads THIS host's local
|
||||
`~/.omp/agent/sessions/`**, which is meaningless for a remote session — the
|
||||
conversation and its session file live on the remote host, under the remote
|
||||
user's home. For a remote session, `_pinOmpRespawnId()` therefore skips local
|
||||
resolution entirely and falls back to `omp`'s own ambiguous `--continue`
|
||||
(`ompConfig.continueSession`), which the remote pane command already renders.
|
||||
This is a known, accepted degradation versus the local/docker paths' exact
|
||||
`--resume` pin — safe in practice because each remote respawn talks to
|
||||
exactly one remote pane's own omp history, so "most recent" is normally
|
||||
correct, but it can drift the same way `--continue` always could if two
|
||||
remote sessions ever share one remote directory.
|
||||
|
||||
## Auto-reconnect vs. a clean agent exit
|
||||
|
||||
`remoteAutoReconnect` (default ON) watches for a dropped SSH connection and
|
||||
reconnects with bounded backoff. It must **never** revive a session whose agent
|
||||
exited cleanly (Ctrl-C, Ctrl-D, `exit`) — that tears down the durable remote
|
||||
tmux session itself, and a transport-level `isPaneDead()` cannot tell that apart
|
||||
from a plain network drop. `remoteTmuxSessionAlive()` (#355) resolves this by
|
||||
probing the remote host directly: `tmux -L codeman-remote has-session -t
|
||||
codeman-ssh-<id8>` over the same `buildSshConnectionArgs` as launch, classified
|
||||
by **exit status alone** (`classifyRemoteAliveExit`: `0` = alive, ssh's `255` or
|
||||
a timeout = unknown, anything else = gone) — `has-session` prints nothing on
|
||||
success, so reading stdout would misclassify every live session as gone. An
|
||||
unreachable host answers "unknown", which also means do not revive. The answer
|
||||
is cached per session and cleared whenever the pane is next seen alive, so a
|
||||
stale `true` from one transport drop can never revive the NEXT clean exit.
|
||||
|
||||
## API
|
||||
|
||||
Routes are registered in `src/web/routes/case-routes.ts`:
|
||||
|
||||
@@ -312,8 +312,8 @@ TOCTOU window.
|
||||
| Route | Cap | Notes |
|
||||
|-------|-----|-------|
|
||||
| `file-content` | 10 MB | text preview |
|
||||
| `file-raw` | 50 MB | inline MIME map; **`X-Content-Type-Options: nosniff` on all responses**; streamed, `Range`-aware (206 slices come from the same validated path, and the cap is checked before the range) |
|
||||
| `POST /api/download` | 50 MB | forced `attachment`; sensitive‑path blocklist |
|
||||
| `file-raw` | 2 GB (`CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) | inline MIME map; **`X-Content-Type-Options: nosniff` on all responses**; streamed, `Range`-aware (206 slices come from the same validated path, and the cap is checked before the range) |
|
||||
| `GET /api/download` | same cap | forced `attachment`; sensitive‑path blocklist; streamed, `Range`-aware |
|
||||
|
||||
### SVG / content‑type XSS
|
||||
|
||||
@@ -340,7 +340,7 @@ the attachment guard below.
|
||||
|
||||
Live external attachments (`src/attachment-registry.ts`) mint an `att_<uuid>` id
|
||||
for a host file so browser requests carry the id, never an absolute path. Serving
|
||||
is by id (`GET /api/sessions/:id/attachments/:attachmentId/raw`, 50 MB cap,
|
||||
is by id (`GET /api/sessions/:id/attachments/:attachmentId/raw`, same download cap,
|
||||
`nosniff`) and re‑resolves the symlink + re‑checks the **attachment guard**
|
||||
(`src/config/attachment-guard.ts`: the shared sensitive‑path blocklist **plus**
|
||||
the `/root` and `/etc` trees, extendable via `attachmentBlockedPaths` /
|
||||
@@ -518,7 +518,7 @@ A saved dashboard URL renders as a tab, served through Codeman's own origin at `
|
||||
|
||||
- **The proxy is exempt from cookie auth and the Origin/CSRF guard, and that is deliberate.** The iframe is sandboxed without `allow-same-origin`, so it is opaque‑origin: its requests are cross‑site, meaning the `SameSite=lax` session cookie is never attached and its writes and WS upgrades arrive with `Origin: null`. The credential is instead a 192‑bit capability in the path, minted only by an authenticated `POST /api/webviews/:id/open`, held in memory (a restart invalidates every one), rolling TTL, bound to the minting user, and granting nothing but "relay bytes to this one saved URL". ⚠️ **The Host allowlist is NOT bypassed**, so DNS‑rebinding protection is unaffected. A second `Referer`‑keyed form exists for root‑absolute assets and is the only exemption decided by a request‑supplied header, so it is fenced to safe methods on non‑`/api`, non‑`/ws`, non‑`/q` paths. Edges pinned by `test/webview-auth-exemption.test.ts`.
|
||||
- **Sandboxed by default; `allow-same-origin` is an explicit per‑dashboard opt‑in.** A proxied page is same‑origin with Codeman, so without the sandbox its JavaScript could read the Codeman document and call the agent‑spawning API. ⚠️ In BOTH modes the `Authorization` header and the `codeman_session` cookie are stripped before the upstream request, because a trusted (same‑origin) frame makes the browser attach Codeman's own Basic‑auth credentials to every proxied request; forwarding them would hand `CODEMAN_PASSWORD` to the dashboard.
|
||||
- **Not an open relay, and not a privilege boundary.** `resolveUpstreamUrl()` refuses anything leaving the saved origin, and cross‑origin redirects are handed back unchanged rather than followed. The proxy does reach whatever the SERVER can reach, which is not an escalation for someone who already commands `--dangerously-skip-permissions` agents, but in multi‑user mode it means a non‑admin's dashboard is fetched from the server's network position. Saved URLs are validated to plain http(s) with no embedded credentials, and there is deliberately **no magic‑link path**: terminal output can never create a webview (the mistake the attachment scanner had to be walled off from).
|
||||
- **Not an open relay, and not a privilege boundary.** `resolveUpstreamUrl()` refuses anything leaving the saved origin, and cross‑origin redirects are handed back unchanged rather than followed. The proxy does reach whatever the SERVER can reach, which is not an escalation for someone who already commands `--dangerously-skip-permissions` agents, but in multi‑user mode it means a non‑admin's dashboard is fetched from the server's network position. Saved URLs are validated to plain http(s) with no embedded credentials, and there is deliberately **no magic‑link path**: terminal output can never create a webview (the mistake the attachment scanner had to be walled off from). The one refused destination class is link‑local and cloud‑metadata addresses (`169.254.0.0/16`, `fe80::/10`, `fd00:ec2::254`, `168.63.129.16`, `100.100.100.200`, `metadata.google.internal`): `webview-egress-policy.ts` refuses them at save time, and `webview-egress.ts` re‑judges the RESOLVED address at connect time through a `lookup` hook on the proxy's undici Agent and on its WebSocket client, so a DNS name pointing into those ranges is refused as well. Loopback and RFC1918 stay allowed on purpose. Capabilities are revoked on logout, admin logout and user deletion, and proxied responses carry `Referrer-Policy: same-origin` so a dashboard cannot hand the capability‑bearing URL to a third‑party host it links.
|
||||
|
||||
---
|
||||
|
||||
@@ -529,6 +529,7 @@ A saved dashboard URL renders as a tab, served through Codeman's own origin at `
|
||||
| `CODEMAN_PASSWORD` (+ `CODEMAN_USERNAME`) | Enable HTTP Basic auth |
|
||||
| `--host` / `CODEMAN_HOST` | Bind host (default `127.0.0.1`) |
|
||||
| `CODEMAN_ALLOWED_HOSTS` | Extra `Host`/`Origin` allowlist entries for reverse proxies (comma‑separated; exact host, or leading‑dot `.suffix` for subdomains) — see §3 |
|
||||
| `--base-url` / `CODEMAN_BASE_URL` | Sub‑path prefix Codeman is mounted under behind a reverse proxy, e.g. `/codeman` (default `/`); the proxy must forward the prefix unchanged. Independent of `CODEMAN_ALLOWED_HOSTS` |
|
||||
| `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledge an unauthenticated non‑loopback bind (downgrades the warning) |
|
||||
| `--https` | Enable TLS (adds HSTS) |
|
||||
| `CODEMAN_INSTANCE` | Scope tmux socket + data dir for isolation |
|
||||
|
||||
+46
-4
@@ -52,6 +52,37 @@ sandbox, cookies, CORS, CSP, or any reverse proxy sitting in front of Codeman, s
|
||||
passing Test does not guarantee the embedded page will render (see the
|
||||
cookie-authenticated reverse proxy caveat below).
|
||||
|
||||
## Links to `localhost` from another device
|
||||
|
||||
An agent prints `http://localhost:5173/` (a dev server, a preview, a report it just
|
||||
served) and you tap it on your phone. That address only exists on the Codeman box, so
|
||||
the phone's browser can never load it — but the web-tab proxy fetches from the server,
|
||||
where it works.
|
||||
|
||||
So a **loopback** link (`localhost`, `127.0.0.0/8`, `0.0.0.0`, `::1`) clicked
|
||||
in the terminal or in the Response Viewer opens as a **proxied web tab** whenever the
|
||||
Codeman page itself is not on that box. A saved proxied dashboard on the same origin is
|
||||
reused (one tab per dev server, with the link's own path opened inside it, and one tab
|
||||
per dev server rather than per host spelling, so `localhost:5173` and `127.0.0.1:5173`
|
||||
share it); otherwise one is saved under its `host:port` so it is in the Run dropdown
|
||||
next time, and a toast tells you it was saved. Sandboxed by default, like any other web
|
||||
tab.
|
||||
|
||||
⚠️ **`*.localhost` is deliberately not auto-routed**, even though a browser treats it as
|
||||
loopback. Every other name in that list is an address literal that can only mean this
|
||||
box; a `*.localhost` DNS name is not one, and on a resolver with a search domain
|
||||
configured `evil.localhost` can be retried as `evil.localhost.<search domain>`, which
|
||||
someone else can control. Since the links come from agent output, one tap would then
|
||||
make Codeman fetch an agent-chosen origin server-side and save it. If you really run
|
||||
`api.localhost` dev hosts, add that dashboard by hand: doing so is an explicit action,
|
||||
which is the difference that matters here. A **trusted** (non-sandboxed) dashboard is
|
||||
likewise never auto-reused by a tapped link, for the same reason.
|
||||
|
||||
Only loopback is routed this way. A LAN or tailnet address (`192.168.…`, `100.…`,
|
||||
`box.ts.net`) may well be reachable from the device — a VPN, the same Wi-Fi — and a
|
||||
direct open is the cheaper, richer path, so those links still open in a new browser tab.
|
||||
On the box itself (a browser on `localhost`) every link opens directly.
|
||||
|
||||
## The sandbox, and when to turn it off
|
||||
|
||||
Because a proxied dashboard is served from Codeman's own address, it is
|
||||
@@ -161,10 +192,21 @@ then every API call fails, which looks like the dashboard being broken.
|
||||
then streams the body without any time bound; a header timeout is logged
|
||||
server-side and answered as a 502 that names the limit. WebSocket handshakes use
|
||||
the separate `CODEMAN_WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS` (default 30s).
|
||||
- **Not a security boundary.** The proxy reaches whatever the Codeman server can
|
||||
reach. That is not an escalation for someone who already commands
|
||||
`--dangerously-skip-permissions` agents, but in multi-user mode it does mean a
|
||||
non-admin user's dashboard is fetched from the server's network position.
|
||||
- **Not a security boundary, with one carve-out.** The proxy reaches whatever the
|
||||
Codeman server can reach (a `localhost` dashboard is the point), so it is not an
|
||||
escalation for someone who already commands `--dangerously-skip-permissions`
|
||||
agents, but in multi-user mode it does mean a non-admin user's dashboard is
|
||||
fetched from the server's network position. The carve-out: link-local and
|
||||
cloud-metadata addresses (`169.254.0.0/16`, `fe80::/10`, `fd00:ec2::254`,
|
||||
Azure's `168.63.129.16`, Alibaba's `100.100.100.200`, the
|
||||
`metadata.google.internal` alias) are refused at save time AND at connect
|
||||
time, judged on the address a name actually resolves to. Nothing anyone embeds
|
||||
as a dashboard lives there; an instance's IAM credentials do.
|
||||
- **The proxy URL is a bearer credential.** `/webview/<cap>/...` needs no cookie,
|
||||
so treat it like a password. It is revoked when you log out, when an admin logs
|
||||
you out, and when your account is deleted, and it expires after 12 hours
|
||||
without use. Proxied responses carry `Referrer-Policy: same-origin`, so a
|
||||
dashboard that links to third-party sites does not hand them the URL.
|
||||
|
||||
## Where the code lives
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ works, slash commands included.
|
||||
| `Shift+Enter` / `Ctrl+Enter` | Newline without sending. |
|
||||
| `Ctrl+C` | Copy if text is selected, otherwise interrupt. |
|
||||
| `Ctrl+Shift+C` | Copy, never interrupts. |
|
||||
| `Ctrl+V` | Paste. A clipboard image uploads instead. |
|
||||
| `Ctrl+L` | Clear the terminal. |
|
||||
|
||||
### Exactly-once delivery
|
||||
|
||||
@@ -25,6 +25,7 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
|
||||
| `Ctrl+Enter` | Same. |
|
||||
| `Ctrl+C` | Copy the selection, or interrupt when nothing is selected. |
|
||||
| `Ctrl+Shift+C` | Copy the selection. Never interrupts. |
|
||||
| `Ctrl+V` | Paste. An image on the clipboard uploads and pastes its file path instead. |
|
||||
| `Ctrl+L` | Clear the terminal. |
|
||||
| `Ctrl+Shift+R` | Restore terminal size. |
|
||||
| `Ctrl` `+` / `Ctrl` `-` | Font size. |
|
||||
|
||||
@@ -167,6 +167,47 @@ and is not one.
|
||||
Also make sure the proxy forwards WebSocket upgrades. The terminal is a WebSocket, and the
|
||||
upgrade runs the same Host and Origin checks, closing with code `4003` on failure.
|
||||
|
||||
### Mounting under a sub-path
|
||||
|
||||
By default Codeman assumes it is served at the origin root (`/`). To mount it under a
|
||||
sub-path — e.g. `https://example.com/codeman/` — start it with `--base-url` (or the
|
||||
`CODEMAN_BASE_URL` env var):
|
||||
|
||||
```bash
|
||||
codeman web --base-url /codeman
|
||||
# or
|
||||
CODEMAN_BASE_URL=/codeman codeman web
|
||||
```
|
||||
|
||||
The value is a plain path prefix; `/` (the default) means "mounted at the root". With a
|
||||
prefix set, Codeman emits every URL — the HTML shell and its assets, API/SSE/WebSocket
|
||||
calls, redirects, the PWA manifest and the service worker — under that prefix, so a browser
|
||||
loading `https://example.com/codeman/` stays inside the mount.
|
||||
|
||||
**Forward the prefix unchanged — do NOT strip it.** Codeman expects the proxy to pass the
|
||||
full path (including `/codeman/`) straight through. A minimal nginx block:
|
||||
|
||||
```nginx
|
||||
location /codeman/ {
|
||||
proxy_pass http://127.0.0.1:3000; # note: no trailing slash — keep the /codeman/ prefix
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade; # WebSocket
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
```
|
||||
|
||||
Notes and current limits:
|
||||
|
||||
- The prefix must still be paired with `CODEMAN_ALLOWED_HOSTS` for your domain, exactly as
|
||||
above — the two are independent.
|
||||
- Health checks, Claude Code hooks and the docker bridge connect to the raw port directly
|
||||
(bypassing the proxy), so Codeman also keeps answering at the un-prefixed paths on the port
|
||||
itself. Nothing about those flows changes.
|
||||
- **Web-tab (dashboard) proxying** is base-path aware: proxied dashboards have their injected
|
||||
`<base>` tag, root-absolute asset rewrites, runtime `fetch`/XHR shim, `Set-Cookie` paths, and
|
||||
redirects all rebased onto the mount, so they load the same under `--base-url` as at the root.
|
||||
|
||||
## Session cookies and rate limits
|
||||
|
||||
The first request prompts for HTTP Basic credentials. On success the server issues an opaque
|
||||
@@ -198,6 +239,7 @@ for the full guide.
|
||||
| Symptom | Cause and fix |
|
||||
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `403 host not allowed` | Your domain is not in the allowlist. Set `CODEMAN_ALLOWED_HOSTS`. |
|
||||
| Assets 404 / blank page under a sub-path | Start Codeman with `--base-url /<prefix>` and have the proxy forward the prefix unchanged (don't strip it). |
|
||||
| Phone shows the login page but the terminal never connects | The proxy is not forwarding WebSocket upgrades. |
|
||||
| Browser warns about the certificate | Expected with `--https` and its self-signed certificate. Tailscale gives you a real one instead. |
|
||||
| LAN IP does not respond, but a tunnel to the same box works | The server is bound to loopback. That is the default. A tunnel reaches it; a LAN browser cannot. |
|
||||
|
||||
@@ -19,7 +19,9 @@ It renders what it can:
|
||||
| PDF and Office documents | Converted for preview when a converter is available. |
|
||||
| Anything else | Download. |
|
||||
|
||||
Caps: 10 MB for text preview, 50 MB for raw and download. Sensitive paths (`.env`, anything
|
||||
Caps: 10 MB for text preview, 2 GB for raw and download (set `CODEMAN_MAX_DOWNLOAD_BYTES`
|
||||
to change it, `0` for no limit — these bodies are streamed, so a large file costs a read
|
||||
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.
|
||||
|
||||
|
||||
Generated
+12
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.24.6",
|
||||
"version": "1.27.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.24.6",
|
||||
"version": "1.27.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -32,6 +32,7 @@
|
||||
"jpeg-js": "^0.4.4",
|
||||
"node-pty": "^1.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"undici": "^6.28.0",
|
||||
"uuid": "^14.0.0",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.21.0",
|
||||
@@ -11550,6 +11551,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
|
||||
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
|
||||
+9
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.24.6",
|
||||
"version": "1.27.0",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -28,18 +28,19 @@
|
||||
"test:mobile": "vitest run --config test/mobile/vitest.config.ts",
|
||||
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
|
||||
"fix:node-pty": "node scripts/fix-node-pty.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
|
||||
"lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' --fix",
|
||||
"format": "prettier --write 'src/**/*.ts' 'src/web/public/**/*.{js,css,html,json}'",
|
||||
"format:check": "prettier --check 'src/**/*.ts' 'src/web/public/**/*.{js,css,html,json}'",
|
||||
"typecheck": "tsc --noEmit && tsc -p config/tsconfig.pr-bot.json",
|
||||
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts' 'scripts/pr-bot/**/*.ts'",
|
||||
"lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' 'scripts/pr-bot/**/*.ts' --fix",
|
||||
"format": "prettier --write 'src/**/*.ts' 'scripts/pr-bot/**/*.ts' 'src/web/public/**/*.{js,css,html,json}'",
|
||||
"format:check": "prettier --check 'src/**/*.ts' 'scripts/pr-bot/**/*.ts' 'src/web/public/**/*.{js,css,html,json}'",
|
||||
"check:public-assets": "node scripts/check-public-assets.mjs",
|
||||
"capture:subagents": "node scripts/capture-subagent-screenshots.mjs",
|
||||
"changeset": "changeset",
|
||||
"version-packages": "changeset version && npm install --package-lock-only && node scripts/check-lockfile-sync.mjs",
|
||||
"check:lockfile": "node scripts/check-lockfile-sync.mjs",
|
||||
"knip": "npx --yes knip@latest --config config/knip.json",
|
||||
"release": "changeset publish"
|
||||
"release": "changeset publish",
|
||||
"pr-bot": "tsx scripts/pr-bot/main.ts"
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
@@ -102,6 +103,7 @@
|
||||
"jpeg-js": "^0.4.4",
|
||||
"node-pty": "^1.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"undici": "^6.28.0",
|
||||
"uuid": "^14.0.0",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.21.0",
|
||||
|
||||
@@ -83,6 +83,7 @@ appendFileSync(
|
||||
|
||||
// 4. Minify frontend assets
|
||||
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
|
||||
run('minify terminal-keycode229-recovery.js', 'npx esbuild dist/web/public/terminal-keycode229-recovery.js --minify --outfile=dist/web/public/terminal-keycode229-recovery.js --allow-overwrite');
|
||||
run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite');
|
||||
run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite');
|
||||
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
|
||||
@@ -110,6 +111,7 @@ console.log('\n[build] content-hash cache busting');
|
||||
'notification-manager.js',
|
||||
'keyboard-accessory.js',
|
||||
'input-cjk.js',
|
||||
'terminal-keycode229-recovery.js',
|
||||
'sanitize-html.js',
|
||||
'app.js',
|
||||
'tab-rail-resize.js',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* @fileoverview Codeman HTTP client for the PR bot: spawn a claude session in a
|
||||
* directory, wait until its composer is up, run one prompt to the END of its turn,
|
||||
* read the answer, delete the session.
|
||||
*
|
||||
* This is the `skills/codeman` §0 preamble translated to TypeScript, and it keeps
|
||||
* the traps that preamble documents:
|
||||
* - readiness is the rendered composer (`shift+tab` in the pane), never `idle`;
|
||||
* - the folder-trust dialog is READ off the screen and answered one keystroke at a
|
||||
* time (Claude Code 2.1.252 highlights "No, exit" by default, so a blind Enter kills
|
||||
* the session);
|
||||
* - send-and-wait waits on `stop,blocked,exit`, never on the flapping `idle`, with a
|
||||
* short first wait, one Enter nudge for a stranded prompt, and tagged-duplicate
|
||||
* resends that re-wait without retyping (the server treats an already-applied
|
||||
* (clientId, seq) frame as "wait only");
|
||||
* - the bot deletes only sessions it created, by exact id.
|
||||
*
|
||||
* The production server is HTTPS with a self-signed certificate on loopback, so the
|
||||
* undici Agent skips certificate verification for that one connection.
|
||||
*/
|
||||
import { Agent, fetch as undiciFetch } from 'undici';
|
||||
|
||||
export interface CodemanClientOptions {
|
||||
apiUrl: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface CreateSessionOptions {
|
||||
workingDir: string;
|
||||
name: string;
|
||||
modelOverride?: string;
|
||||
effort?: string;
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
|
||||
export interface WaitResult {
|
||||
ended: boolean;
|
||||
timedOut: boolean;
|
||||
signal?: string;
|
||||
}
|
||||
|
||||
export interface SessionRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
pid: number | null;
|
||||
claudeSessionId?: string | null;
|
||||
workingDir: string;
|
||||
mode: string;
|
||||
}
|
||||
|
||||
export type TurnOutcome =
|
||||
| { kind: 'stop' }
|
||||
| { kind: 'blocked' }
|
||||
| { kind: 'exit' }
|
||||
| { kind: 'timeout' }
|
||||
| { kind: 'limit'; message: string };
|
||||
|
||||
/**
|
||||
* Claude Code answers a spent model budget INSIDE the turn ("You've reached your Fable
|
||||
* limit. Run /usage-credits to continue or switch models with /model.") and then simply
|
||||
* sits there with nothing to write. Measured 2026-09-08: four reviews each burned their
|
||||
* whole 40-minute deadline and reported a bare "timed out without a report", which reads
|
||||
* as a hung reviewer rather than an account that needs attention, and the retries spent
|
||||
* the per-head budget so the PRs would not have been picked up again once credits
|
||||
* returned. Matching the notice turns 40 silent minutes into a named failure in seconds.
|
||||
*
|
||||
* Deliberately model-agnostic: the same sentence is printed for every model, and the
|
||||
* apostrophe is typographic on the pane, so neither the model name nor `'` is matched.
|
||||
*/
|
||||
const MODEL_LIMIT_PATTERN = /reached your [^\n]{0,40}?\blimit\b|\/usage-credits/i;
|
||||
|
||||
/**
|
||||
* Thrown instead of a plain Error when a review died on a spent model budget, so the
|
||||
* caller can tell an account condition apart from a review that genuinely failed.
|
||||
*/
|
||||
export class ModelLimitError extends Error {
|
||||
override readonly name = 'ModelLimitError';
|
||||
}
|
||||
|
||||
/** The limit notice as one clean line, or undefined if the screen does not carry it. */
|
||||
export function findModelLimitNotice(screen: string): string | undefined {
|
||||
const line = stripAnsi(screen)
|
||||
.split('\n')
|
||||
.find((l) => MODEL_LIMIT_PATTERN.test(l));
|
||||
return line?.replace(/^[\s>|]*(?:\u23bf|\u2514|\u256d|\u2570|\u23a2|\u2502|\u23bd)?\s*/u, '').trim() || undefined;
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
export function stripAnsi(text: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return text.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '').replace(/\x1b[()][AB0]/g, '');
|
||||
}
|
||||
|
||||
/** Which key answers the trust dialog right now, read from the rendered pane. */
|
||||
export function trustDialogKey(screen: string): 'confirm' | 'move' | null {
|
||||
const compact = stripAnsi(screen).replace(/\s+/g, '');
|
||||
const matches = compact.match(/❯[0-9.]*(yes,itrustthisfolder|no,exit)/gi);
|
||||
if (!matches || matches.length === 0) return null;
|
||||
const last = matches[matches.length - 1].toLowerCase();
|
||||
return last.includes('yes,') ? 'confirm' : 'move';
|
||||
}
|
||||
|
||||
export class CodemanClient {
|
||||
// headersTimeout/bodyTimeout default to 300 s in undici, which is shorter than one
|
||||
// long-poll slice on the wait endpoints (up to 580 s): the first review died at
|
||||
// exactly five minutes with a bare "fetch failed". The per-request AbortSignal is
|
||||
// the only ceiling here.
|
||||
private readonly agent = new Agent({ connect: { rejectUnauthorized: false }, headersTimeout: 0, bodyTimeout: 0 });
|
||||
private readonly authHeader?: string;
|
||||
|
||||
constructor(private readonly opts: CodemanClientOptions) {
|
||||
if (opts.password) {
|
||||
this.authHeader = 'Basic ' + Buffer.from(`${opts.username || 'admin'}:${opts.password}`).toString('base64');
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
query?: Record<string, string | number | undefined>,
|
||||
timeoutMs = 60_000
|
||||
): Promise<T> {
|
||||
const url = new URL(this.opts.apiUrl + path);
|
||||
for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined) url.searchParams.set(k, String(v));
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (this.authHeader) headers.Authorization = this.authHeader;
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
let res;
|
||||
try {
|
||||
res = await undiciFetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
dispatcher: this.agent,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
} catch (err) {
|
||||
const cause = (err as { cause?: { message?: string; code?: string } }).cause;
|
||||
const detail = cause ? ` (${cause.code ?? ''} ${cause.message ?? ''})`.replace(/\(\s+/, '(').trim() : '';
|
||||
throw new Error(`${method} ${path}: ${(err as Error).message}${detail}`);
|
||||
}
|
||||
const text = await res.text();
|
||||
let json: { success?: boolean; data?: T; error?: string; errorCode?: string } & Record<string, unknown> = {};
|
||||
try {
|
||||
json = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
throw new Error(`${method} ${path}: non-JSON ${res.status} response: ${text.slice(0, 200)}`);
|
||||
}
|
||||
if (!res.ok || json.success === false) {
|
||||
throw new Error(
|
||||
`${method} ${path}: ${res.status} ${json.errorCode ?? ''} ${json.error ?? text.slice(0, 200)}`.trim()
|
||||
);
|
||||
}
|
||||
// Most routes use the {success, data} envelope; a few legacy GETs return the raw shape.
|
||||
return (json.success === true && json.data !== undefined ? json.data : json) as T;
|
||||
}
|
||||
|
||||
async status(): Promise<{ version?: string }> {
|
||||
return this.request<{ version?: string }>('GET', '/api/status');
|
||||
}
|
||||
|
||||
async listSessions(): Promise<SessionRecord[]> {
|
||||
const data = await this.request<SessionRecord[] | { sessions: SessionRecord[] }>('GET', '/api/sessions');
|
||||
return Array.isArray(data) ? data : (data.sessions ?? []);
|
||||
}
|
||||
|
||||
async getSession(id: string): Promise<SessionRecord> {
|
||||
return this.request<SessionRecord>('GET', `/api/sessions/${id}`);
|
||||
}
|
||||
|
||||
/** Create + start. Creation alone leaves pid null and no pane, so the two are one step here. */
|
||||
async createInteractiveSession(opts: CreateSessionOptions): Promise<string> {
|
||||
const created = await this.request<{ session: { id: string } }>('POST', '/api/sessions', {
|
||||
workingDir: opts.workingDir,
|
||||
mode: 'claude',
|
||||
name: opts.name,
|
||||
modelOverride: opts.modelOverride,
|
||||
effort: opts.effort,
|
||||
resumeSessionId: opts.resumeSessionId,
|
||||
});
|
||||
const id = created.session?.id;
|
||||
if (!id) throw new Error('POST /api/sessions returned no session id');
|
||||
await this.request('POST', `/api/sessions/${id}/interactive`, {});
|
||||
return id;
|
||||
}
|
||||
|
||||
async deleteSession(id: string): Promise<void> {
|
||||
if (!id || id.length < 8) throw new Error(`refusing to delete session "${id}"`);
|
||||
await this.request('DELETE', `/api/sessions/${id}`);
|
||||
}
|
||||
|
||||
async waitOutput(id: string, match: string, from: 'now' | 'buffer', timeoutMs: number): Promise<boolean> {
|
||||
const data = await this.request<{ wait?: { matched?: boolean } }>(
|
||||
'GET',
|
||||
`/api/sessions/${id}/wait-output`,
|
||||
undefined,
|
||||
{ match, from, timeout: timeoutMs },
|
||||
timeoutMs + 15_000
|
||||
);
|
||||
return Boolean(data.wait?.matched);
|
||||
}
|
||||
|
||||
async waitSignal(id: string, until: string, timeoutMs: number): Promise<WaitResult> {
|
||||
const data = await this.request<{ wait?: WaitResult }>(
|
||||
'GET',
|
||||
`/api/sessions/${id}/wait`,
|
||||
undefined,
|
||||
{ until, timeout: timeoutMs },
|
||||
timeoutMs + 15_000
|
||||
);
|
||||
return data.wait ?? { ended: false, timedOut: true };
|
||||
}
|
||||
|
||||
async terminalText(id: string): Promise<string> {
|
||||
const data = await this.request<{ terminalBuffer?: string }>('GET', `/api/sessions/${id}/terminal`, undefined, {
|
||||
full: '1',
|
||||
});
|
||||
return data.terminalBuffer ?? '';
|
||||
}
|
||||
|
||||
async sendKeys(id: string, input: string, clientId: string, seq: number): Promise<void> {
|
||||
await this.request('POST', `/api/sessions/${id}/input`, { input, useMux: true, clientId, seq });
|
||||
}
|
||||
|
||||
async lastResponse(id: string): Promise<string> {
|
||||
const data = await this.request<{ text?: string }>('GET', `/api/sessions/${id}/last-response`);
|
||||
return data.text ?? '';
|
||||
}
|
||||
|
||||
/** Composer wait, trust-dialog fallback, composer wait again. Throws when the pane never gets there. */
|
||||
async ensureReady(id: string, log: (m: string) => void): Promise<void> {
|
||||
if (await this.waitOutput(id, 'shift+tab', 'buffer', 5000)) return;
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const key = trustDialogKey(await this.terminalText(id));
|
||||
if (!key) break;
|
||||
log(`trust dialog on screen: ${key === 'confirm' ? 'Enter' : 'arrow down'}`);
|
||||
await this.sendKeys(id, key === 'confirm' ? '\r' : '\x1b[B', `prbot-trust-${id}`, i);
|
||||
if (key === 'confirm') break;
|
||||
await sleep(1000);
|
||||
}
|
||||
if (await this.waitOutput(id, 'shift+tab', 'buffer', 45_000)) return;
|
||||
throw new Error('the session never drew its composer (no `shift+tab` in the pane after 50s)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send ONE prompt and block until the turn ends, the session blocks on a question,
|
||||
* the pane exits, or `deadlineMs` passes. `isDone` lets the caller finish early on
|
||||
* an out-of-band signal (the report file appearing), which also covers a stop edge
|
||||
* that fired between two waits.
|
||||
*/
|
||||
async runTurn(
|
||||
id: string,
|
||||
prompt: string,
|
||||
opts: { deadlineMs: number; isDone?: () => boolean; log: (m: string) => void }
|
||||
): Promise<TurnOutcome> {
|
||||
if (prompt.includes('\n'))
|
||||
throw new Error('runTurn prompts must be single-line (embedded newlines are stripped by tmux)');
|
||||
const clientId = `prbot-${id}`;
|
||||
const seq = Math.floor(Date.now() / 1000);
|
||||
const frame = { input: prompt + '\r', useMux: true, clientId, seq, wait: 'stop,blocked,exit', waitTimeout: 20_000 };
|
||||
const started = Date.now();
|
||||
const post = (body: unknown, timeout: number) =>
|
||||
this.request<{ delivered?: boolean; wait?: WaitResult }>(
|
||||
'POST',
|
||||
`/api/sessions/${id}/input`,
|
||||
body,
|
||||
undefined,
|
||||
timeout + 15_000
|
||||
);
|
||||
|
||||
let r = await post(frame, 20_000);
|
||||
if (!r.delivered) throw new Error('the prompt was not delivered (pane dead?)');
|
||||
let wait = r.wait;
|
||||
let nudged = false;
|
||||
// Only consulted when the turn produced nothing, so a review that merely QUOTES the
|
||||
// notice in its report cannot be mistaken for one that hit it.
|
||||
const limitNotice = async (): Promise<string | undefined> =>
|
||||
findModelLimitNotice(await this.terminalText(id).catch(() => ''));
|
||||
while (true) {
|
||||
if (wait && !wait.timedOut) {
|
||||
const outcome = toOutcome(wait);
|
||||
if (outcome.kind === 'stop' && !opts.isDone?.()) {
|
||||
const limit = await limitNotice();
|
||||
if (limit) return { kind: 'limit', message: limit };
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
if (opts.isDone?.()) return { kind: 'stop' };
|
||||
const limit = await limitNotice();
|
||||
if (limit) return { kind: 'limit', message: limit };
|
||||
const remaining = opts.deadlineMs - (Date.now() - started);
|
||||
if (remaining <= 0) return { kind: 'timeout' };
|
||||
if (!nudged) {
|
||||
// An Ink repaint occasionally eats the Enter: a bare \r is the missing key when
|
||||
// the prompt is stranded and a no-op when the turn is genuinely running.
|
||||
nudged = true;
|
||||
await this.sendKeys(id, '\r', clientId, seq + 1);
|
||||
}
|
||||
const slice = Math.min(remaining, 580_000);
|
||||
opts.log(`still working (${Math.round((Date.now() - started) / 60_000)} min)`);
|
||||
r = await post({ ...frame, waitTimeout: slice }, slice);
|
||||
wait = r.wait;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toOutcome(wait: WaitResult): TurnOutcome {
|
||||
const signal = wait.signal ?? '';
|
||||
if (signal === 'blocked') return { kind: 'blocked' };
|
||||
if (signal === 'exit') return { kind: 'exit' };
|
||||
return { kind: 'stop' };
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* @fileoverview PR bot configuration.
|
||||
*
|
||||
* Read from `~/.codeman/pr-bot.env` (KEY=VALUE lines, mode 0600, the same shape as
|
||||
* the data dir's `.env`) with the process environment layered on top, then validated
|
||||
* into a typed config. `parseEnvFile` and `buildConfig` are pure so the validation
|
||||
* rules are unit-testable without touching the filesystem.
|
||||
*
|
||||
* Nothing here reads Codeman's own settings: the bot is maintainer tooling that
|
||||
* drives a running Codeman over HTTP, it is not part of the server.
|
||||
*/
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { dirname, join, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
export interface PrBotConfig {
|
||||
/** Telegram bot token from BotFather. */
|
||||
telegramBotToken: string;
|
||||
/** The ONE chat the bot talks to and accepts commands from. Everything else is ignored. */
|
||||
telegramChatId: string;
|
||||
/** `owner/name` of the repository whose PRs are reviewed. */
|
||||
githubRepo: string;
|
||||
/** Codeman server the review sessions are spawned on. */
|
||||
codemanApiUrl: string;
|
||||
codemanUsername?: string;
|
||||
codemanPassword?: string;
|
||||
/** How often open PRs are listed. */
|
||||
pollIntervalMs: number;
|
||||
/** The maintainer's checkout; worktrees are added from its git dir. Never checked out by the bot. */
|
||||
mainCheckout: string;
|
||||
/** State, reports and worktrees live under here. */
|
||||
dataDir: string;
|
||||
worktreesDir: string;
|
||||
/** Optional model / effort for the review sessions (Codeman `modelOverride` / `effort`). */
|
||||
model?: string;
|
||||
effort?: string;
|
||||
/** Hard ceiling for one review turn. */
|
||||
reviewTimeoutMs: number;
|
||||
/** Hard ceiling for one follow-up turn. */
|
||||
followupTimeoutMs: number;
|
||||
/** When false, PRs are only reviewed on an explicit `/review N`. */
|
||||
autoReview: boolean;
|
||||
/** Draft PRs are skipped unless this is on. */
|
||||
reviewDrafts: boolean;
|
||||
}
|
||||
|
||||
export const CONFIG_FILE_NAME = 'pr-bot.env';
|
||||
|
||||
/**
|
||||
* The maintainer's existing Telegram notifier bot (a separate, send-only process)
|
||||
* keeps its token and chat id here. The PR bot shares that bot identity by default,
|
||||
* so it reads those two keys from the same file rather than making anyone copy a
|
||||
* secret around. Override with `PR_BOT_TELEGRAM_ENV_FILE`.
|
||||
*/
|
||||
export const DEFAULT_TELEGRAM_ENV_FILE = join('codeman-cases', 'telegram', '.env');
|
||||
const SHARED_TELEGRAM_KEYS = ['TELEGRAM_BOT_TOKEN', 'TELEGRAM_CHAT_ID'] as const;
|
||||
|
||||
/** The keys the env file understands, for `check` and the docs. */
|
||||
export const CONFIG_KEYS = [
|
||||
'TELEGRAM_BOT_TOKEN',
|
||||
'TELEGRAM_CHAT_ID',
|
||||
'GITHUB_REPO',
|
||||
'CODEMAN_API_URL',
|
||||
'CODEMAN_USERNAME',
|
||||
'CODEMAN_PASSWORD',
|
||||
'PR_BOT_POLL_INTERVAL',
|
||||
'PR_BOT_MAIN_CHECKOUT',
|
||||
'PR_BOT_DATA_DIR',
|
||||
'PR_BOT_MODEL',
|
||||
'PR_BOT_EFFORT',
|
||||
'PR_BOT_REVIEW_TIMEOUT',
|
||||
'PR_BOT_FOLLOWUP_TIMEOUT',
|
||||
'PR_BOT_AUTO_REVIEW',
|
||||
'PR_BOT_REVIEW_DRAFTS',
|
||||
'PR_BOT_TELEGRAM_ENV_FILE',
|
||||
] as const;
|
||||
|
||||
/** Parse `KEY=VALUE` lines. Comments, blanks, `export ` prefixes and matching quotes are handled. */
|
||||
export function parseEnvFile(text: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const eq = line.indexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
const key = line
|
||||
.slice(0, eq)
|
||||
.trim()
|
||||
.replace(/^export\s+/, '');
|
||||
let value = line.slice(eq + 1).trim();
|
||||
if (value.length >= 2) {
|
||||
const first = value[0];
|
||||
const last = value[value.length - 1];
|
||||
if ((first === '"' && last === '"') || (first === "'" && last === "'")) value = value.slice(1, -1);
|
||||
}
|
||||
if (/^[A-Z_][A-Z0-9_]*$/.test(key)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function intFrom(raw: string | undefined, fallback: number, min: number): number {
|
||||
const n = parseInt(raw ?? '', 10);
|
||||
if (!Number.isFinite(n) || n <= 0) return fallback;
|
||||
return Math.max(min, n);
|
||||
}
|
||||
|
||||
function flagFrom(raw: string | undefined, fallback: boolean): boolean {
|
||||
if (raw === undefined || raw === '') return fallback;
|
||||
return !['0', 'false', 'no', 'off'].includes(raw.trim().toLowerCase());
|
||||
}
|
||||
|
||||
/** Build the typed config from an env map. Throws with every missing key named at once. */
|
||||
export function buildConfig(
|
||||
env: Record<string, string | undefined>,
|
||||
defaults: { home: string; repoRoot: string }
|
||||
): PrBotConfig {
|
||||
const missing: string[] = [];
|
||||
const telegramBotToken = env.TELEGRAM_BOT_TOKEN?.trim() ?? '';
|
||||
const telegramChatId = env.TELEGRAM_CHAT_ID?.trim() ?? '';
|
||||
if (!telegramBotToken) missing.push('TELEGRAM_BOT_TOKEN');
|
||||
if (!telegramChatId) missing.push('TELEGRAM_CHAT_ID');
|
||||
if (missing.length) throw new Error(`pr-bot config is missing: ${missing.join(', ')}`);
|
||||
|
||||
const githubRepo = env.GITHUB_REPO?.trim() || 'Ark0N/Codeman';
|
||||
if (!/^[\w.-]+\/[\w.-]+$/.test(githubRepo)) throw new Error(`GITHUB_REPO must be owner/name, got "${githubRepo}"`);
|
||||
|
||||
const codemanApiUrl = (env.CODEMAN_API_URL?.trim() || 'https://127.0.0.1:3000').replace(/\/+$/, '');
|
||||
if (!/^https?:\/\//.test(codemanApiUrl))
|
||||
throw new Error(`CODEMAN_API_URL must be http(s)://..., got "${codemanApiUrl}"`);
|
||||
|
||||
const dataDir = resolve(env.PR_BOT_DATA_DIR?.trim() || join(defaults.home, '.codeman', 'pr-bot'));
|
||||
const mainCheckout = resolve(env.PR_BOT_MAIN_CHECKOUT?.trim() || defaults.repoRoot);
|
||||
|
||||
return {
|
||||
telegramBotToken,
|
||||
telegramChatId,
|
||||
githubRepo,
|
||||
codemanApiUrl,
|
||||
codemanUsername: env.CODEMAN_USERNAME?.trim() || undefined,
|
||||
codemanPassword: env.CODEMAN_PASSWORD || undefined,
|
||||
pollIntervalMs: intFrom(env.PR_BOT_POLL_INTERVAL, 600, 60) * 1000,
|
||||
mainCheckout,
|
||||
dataDir,
|
||||
worktreesDir: join(dataDir, 'worktrees'),
|
||||
model: env.PR_BOT_MODEL?.trim() || undefined,
|
||||
effort: env.PR_BOT_EFFORT?.trim() || undefined,
|
||||
reviewTimeoutMs: intFrom(env.PR_BOT_REVIEW_TIMEOUT, 40, 5) * 60_000,
|
||||
followupTimeoutMs: intFrom(env.PR_BOT_FOLLOWUP_TIMEOUT, 20, 2) * 60_000,
|
||||
autoReview: flagFrom(env.PR_BOT_AUTO_REVIEW, true),
|
||||
reviewDrafts: flagFrom(env.PR_BOT_REVIEW_DRAFTS, false),
|
||||
};
|
||||
}
|
||||
|
||||
/** The repository this script lives in (scripts/pr-bot/ -> repo root). */
|
||||
export function scriptRepoRoot(): string {
|
||||
return resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
}
|
||||
|
||||
export function configFilePath(): string {
|
||||
return join(process.env.CODEMAN_DATA_DIR || join(homedir(), '.codeman'), CONFIG_FILE_NAME);
|
||||
}
|
||||
|
||||
export function telegramEnvFilePath(fromFile: Record<string, string>): string {
|
||||
return resolve(
|
||||
process.env.PR_BOT_TELEGRAM_ENV_FILE ||
|
||||
fromFile.PR_BOT_TELEGRAM_ENV_FILE ||
|
||||
join(homedir(), DEFAULT_TELEGRAM_ENV_FILE)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Layers, lowest first: the shared Telegram notifier's `.env` (token + chat id only),
|
||||
* then `~/.codeman/pr-bot.env`, then the process environment, so a one-off
|
||||
* `PR_BOT_MODEL=... npx tsx ...` wins over everything.
|
||||
*/
|
||||
export function loadConfig(): PrBotConfig {
|
||||
const file = configFilePath();
|
||||
const fromFile = existsSync(file) ? parseEnvFile(readFileSync(file, 'utf8')) : {};
|
||||
const sharedFile = telegramEnvFilePath(fromFile);
|
||||
const shared = existsSync(sharedFile) ? parseEnvFile(readFileSync(sharedFile, 'utf8')) : {};
|
||||
const merged: Record<string, string | undefined> = {};
|
||||
for (const key of SHARED_TELEGRAM_KEYS) if (shared[key]) merged[key] = shared[key];
|
||||
Object.assign(merged, fromFile);
|
||||
for (const key of CONFIG_KEYS) {
|
||||
const v = process.env[key];
|
||||
if (v !== undefined && v !== '') merged[key] = v;
|
||||
}
|
||||
try {
|
||||
return buildConfig(merged, { home: homedir(), repoRoot: scriptRepoRoot() });
|
||||
} catch (err) {
|
||||
throw new Error(`${(err as Error).message} (config file: ${file}; shared Telegram env: ${sharedFile})`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* @fileoverview GitHub access for the PR bot, entirely through the `gh` CLI.
|
||||
*
|
||||
* `gh` carries the maintainer's own login, so the bot needs no token of its own and
|
||||
* every write (merge, close, comment, CI approval) lands under that account. That is
|
||||
* why every write here is only ever reached from an explicit, confirmed Telegram
|
||||
* command (see bot.ts); nothing in this file is called on a timer.
|
||||
*
|
||||
* `classifyCi` and `latestRunPerWorkflow` are pure and unit-tested.
|
||||
*/
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface PrSummary {
|
||||
number: number;
|
||||
title: string;
|
||||
author: string;
|
||||
headSha: string;
|
||||
baseRef: string;
|
||||
headRef: string;
|
||||
isDraft: boolean;
|
||||
mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN';
|
||||
mergeState: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
changedFiles: number;
|
||||
updatedAt: string;
|
||||
url: string;
|
||||
isCrossRepository: boolean;
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
export interface PrFile {
|
||||
path: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
export interface PrDetail extends PrSummary {
|
||||
body: string;
|
||||
files: PrFile[];
|
||||
authorAssociation: string;
|
||||
linkedIssues: { number: number; title: string }[];
|
||||
commitCount: number;
|
||||
commentCount: number;
|
||||
reviewDecision: string;
|
||||
headRepo: string;
|
||||
}
|
||||
|
||||
export interface WorkflowRun {
|
||||
id: number;
|
||||
name: string;
|
||||
status: string;
|
||||
conclusion: string | null;
|
||||
}
|
||||
|
||||
export type CiState = 'passed' | 'failed' | 'pending' | 'awaiting-approval' | 'none';
|
||||
|
||||
export interface CiStatus {
|
||||
state: CiState;
|
||||
runs: WorkflowRun[];
|
||||
}
|
||||
|
||||
const PR_LIST_FIELDS =
|
||||
'number,title,author,headRefOid,baseRefName,headRefName,isDraft,mergeable,mergeStateStatus,additions,deletions,changedFiles,updatedAt,url,isCrossRepository,labels';
|
||||
|
||||
export async function gh(args: string[], opts: { timeoutMs?: number; input?: string } = {}): Promise<string> {
|
||||
const child = execFileAsync('gh', args, {
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
timeout: opts.timeoutMs ?? 60_000,
|
||||
env: { ...process.env, GH_PROMPT_DISABLED: '1', GH_NO_UPDATE_NOTIFIER: '1' },
|
||||
});
|
||||
if (opts.input !== undefined && child.child.stdin) {
|
||||
child.child.stdin.end(opts.input);
|
||||
}
|
||||
const { stdout } = await child;
|
||||
return stdout;
|
||||
}
|
||||
|
||||
interface RawPr {
|
||||
number: number;
|
||||
title: string;
|
||||
author?: { login?: string };
|
||||
headRefOid: string;
|
||||
baseRefName: string;
|
||||
headRefName: string;
|
||||
isDraft: boolean;
|
||||
mergeable: string;
|
||||
mergeStateStatus: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
changedFiles: number;
|
||||
updatedAt: string;
|
||||
url: string;
|
||||
isCrossRepository: boolean;
|
||||
labels?: { name: string }[];
|
||||
}
|
||||
|
||||
function toSummary(raw: RawPr): PrSummary {
|
||||
const mergeable = raw.mergeable === 'MERGEABLE' || raw.mergeable === 'CONFLICTING' ? raw.mergeable : 'UNKNOWN';
|
||||
return {
|
||||
number: raw.number,
|
||||
title: raw.title ?? '',
|
||||
author: raw.author?.login ?? 'unknown',
|
||||
headSha: raw.headRefOid,
|
||||
baseRef: raw.baseRefName,
|
||||
headRef: raw.headRefName,
|
||||
isDraft: Boolean(raw.isDraft),
|
||||
mergeable,
|
||||
mergeState: raw.mergeStateStatus ?? 'UNKNOWN',
|
||||
additions: raw.additions ?? 0,
|
||||
deletions: raw.deletions ?? 0,
|
||||
changedFiles: raw.changedFiles ?? 0,
|
||||
updatedAt: raw.updatedAt ?? '',
|
||||
url: raw.url,
|
||||
isCrossRepository: Boolean(raw.isCrossRepository),
|
||||
labels: (raw.labels ?? []).map((l) => l.name),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listOpenPrs(repo: string): Promise<PrSummary[]> {
|
||||
const out = await gh(['pr', 'list', '--repo', repo, '--state', 'open', '--limit', '100', '--json', PR_LIST_FIELDS]);
|
||||
const raw = JSON.parse(out) as RawPr[];
|
||||
return raw.map(toSummary);
|
||||
}
|
||||
|
||||
export async function getPrDetail(repo: string, number: number): Promise<PrDetail> {
|
||||
const fields = `${PR_LIST_FIELDS},body,files,commits,comments,reviewDecision,closingIssuesReferences,headRepository,headRepositoryOwner`;
|
||||
const out = await gh(['pr', 'view', String(number), '--repo', repo, '--json', fields]);
|
||||
const raw = JSON.parse(out) as RawPr & {
|
||||
body?: string;
|
||||
files?: { path: string; additions: number; deletions: number }[];
|
||||
commits?: unknown[];
|
||||
comments?: unknown[];
|
||||
reviewDecision?: string;
|
||||
closingIssuesReferences?: { number: number; title: string }[];
|
||||
headRepository?: { name?: string };
|
||||
headRepositoryOwner?: { login?: string };
|
||||
};
|
||||
let authorAssociation = 'NONE';
|
||||
try {
|
||||
const assoc = await gh(['api', `repos/${repo}/pulls/${number}`, '--jq', '.author_association']);
|
||||
authorAssociation = assoc.trim() || 'NONE';
|
||||
} catch {
|
||||
// Metadata only; a failed lookup must not fail the review.
|
||||
}
|
||||
const owner = raw.headRepositoryOwner?.login;
|
||||
const name = raw.headRepository?.name;
|
||||
return {
|
||||
...toSummary(raw),
|
||||
body: raw.body ?? '',
|
||||
files: (raw.files ?? []).map((f) => ({ path: f.path, additions: f.additions ?? 0, deletions: f.deletions ?? 0 })),
|
||||
authorAssociation,
|
||||
linkedIssues: (raw.closingIssuesReferences ?? []).map((i) => ({ number: i.number, title: i.title })),
|
||||
commitCount: raw.commits?.length ?? 0,
|
||||
commentCount: raw.comments?.length ?? 0,
|
||||
reviewDecision: raw.reviewDecision ?? '',
|
||||
headRepo: owner && name ? `${owner}/${name}` : '',
|
||||
};
|
||||
}
|
||||
|
||||
/** The API returns newest first; keep only the newest run of each workflow. */
|
||||
export function latestRunPerWorkflow(runs: WorkflowRun[]): WorkflowRun[] {
|
||||
const seen = new Set<string>();
|
||||
const out: WorkflowRun[] = [];
|
||||
for (const run of runs) {
|
||||
if (seen.has(run.name)) continue;
|
||||
seen.add(run.name);
|
||||
out.push(run);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse workflow runs into one word the report can show. `action_required` is
|
||||
* the fork-PR case where GitHub waits for a maintainer to approve the run: the PR
|
||||
* looks unchecked and stays that way until someone clicks, so it gets its own state.
|
||||
*/
|
||||
export function classifyCi(runs: WorkflowRun[]): CiState {
|
||||
const latest = latestRunPerWorkflow(runs);
|
||||
if (latest.length === 0) return 'none';
|
||||
if (latest.some((r) => r.conclusion === 'action_required')) return 'awaiting-approval';
|
||||
if (latest.some((r) => ['queued', 'in_progress', 'waiting', 'pending', 'requested'].includes(r.status)))
|
||||
return 'pending';
|
||||
if (latest.some((r) => ['failure', 'timed_out', 'cancelled', 'startup_failure'].includes(r.conclusion ?? '')))
|
||||
return 'failed';
|
||||
if (latest.every((r) => ['success', 'skipped', 'neutral'].includes(r.conclusion ?? ''))) return 'passed';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
export async function getCiStatus(repo: string, headSha: string): Promise<CiStatus> {
|
||||
const out = await gh([
|
||||
'api',
|
||||
`repos/${repo}/actions/runs?head_sha=${headSha}&event=pull_request&per_page=30`,
|
||||
'--jq',
|
||||
'[.workflow_runs[] | {id, name, status, conclusion}]',
|
||||
]);
|
||||
const runs = JSON.parse(out) as WorkflowRun[];
|
||||
return { state: classifyCi(runs), runs: latestRunPerWorkflow(runs) };
|
||||
}
|
||||
|
||||
export async function approveWorkflowRun(repo: string, runId: number): Promise<void> {
|
||||
await gh(['api', '-X', 'POST', `repos/${repo}/actions/runs/${runId}/approve`]);
|
||||
}
|
||||
|
||||
/** Merge commits, matching the repository's history (`Merge pull request #N from ...`). */
|
||||
export async function mergePr(repo: string, number: number): Promise<string> {
|
||||
return gh(['pr', 'merge', String(number), '--repo', repo, '--merge'], { timeoutMs: 120_000 });
|
||||
}
|
||||
|
||||
export async function closePr(repo: string, number: number, comment: string): Promise<string> {
|
||||
const args = ['pr', 'close', String(number), '--repo', repo];
|
||||
if (comment.trim()) args.push('--comment', comment);
|
||||
return gh(args);
|
||||
}
|
||||
|
||||
export async function commentPr(repo: string, number: number, body: string): Promise<string> {
|
||||
return gh(['pr', 'comment', String(number), '--repo', repo, '--body-file', '-'], { input: body });
|
||||
}
|
||||
|
||||
export async function ghAuthOk(): Promise<boolean> {
|
||||
try {
|
||||
await gh(['auth', 'status']);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env -S npx tsx
|
||||
/**
|
||||
* @fileoverview CLI entry for the PR bot.
|
||||
*
|
||||
* npx tsx scripts/pr-bot/main.ts run # the daemon (what the service runs)
|
||||
* npx tsx scripts/pr-bot/main.ts check # config, gh, Codeman, Telegram, git
|
||||
* npx tsx scripts/pr-bot/main.ts scan # list open PRs and what would be queued
|
||||
* npx tsx scripts/pr-bot/main.ts review N [--no-telegram] # one review, now
|
||||
* npx tsx scripts/pr-bot/main.ts status # what the state file knows
|
||||
* npx tsx scripts/pr-bot/main.ts notify N # resend PR N's review message to Telegram
|
||||
* npx tsx scripts/pr-bot/main.ts install-service # systemd user unit, enabled + started
|
||||
* npx tsx scripts/pr-bot/main.ts uninstall-service
|
||||
*
|
||||
* User guide: docs/pr-bot.md
|
||||
*/
|
||||
import { execFileSync } from 'child_process';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { PrBot, type TelegramLike } from './bot.js';
|
||||
import { CodemanClient } from './codeman-client.js';
|
||||
import { configFilePath, loadConfig, type PrBotConfig } from './config.js';
|
||||
import { ghAuthOk, listOpenPrs } from './github.js';
|
||||
import { orderBacklog } from './report.js';
|
||||
import { StateStore } from './state.js';
|
||||
import { TelegramClient } from './telegram.js';
|
||||
|
||||
const SERVICE_NAME = 'codeman-pr-bot';
|
||||
|
||||
function log(msg: string): void {
|
||||
console.log(`${new Date().toISOString()} ${msg}`);
|
||||
}
|
||||
|
||||
/** Prints what the bot would have sent; used by `review --no-telegram`. */
|
||||
class ConsoleTelegram implements TelegramLike {
|
||||
private nextId = 1;
|
||||
isOurChat(): boolean {
|
||||
return true;
|
||||
}
|
||||
async sendMessage(text: string): Promise<number> {
|
||||
console.log(`\n--- telegram (html) ---\n${text}\n---`);
|
||||
return this.nextId++;
|
||||
}
|
||||
async sendPlain(text: string): Promise<number> {
|
||||
console.log(`\n--- telegram (plain) ---\n${text}\n---`);
|
||||
return this.nextId++;
|
||||
}
|
||||
async editReplyMarkup(): Promise<void> {}
|
||||
async deleteMessage(): Promise<void> {}
|
||||
async answerCallback(): Promise<void> {}
|
||||
async sendDocument(filename: string, content: string): Promise<void> {
|
||||
console.log(`\n--- telegram document ${filename} (${content.length} chars) ---`);
|
||||
}
|
||||
async getUpdates(): Promise<[]> {
|
||||
return [];
|
||||
}
|
||||
async setMyCommands(): Promise<void> {}
|
||||
}
|
||||
|
||||
function makeCodeman(cfg: PrBotConfig): CodemanClient {
|
||||
return new CodemanClient({ apiUrl: cfg.codemanApiUrl, username: cfg.codemanUsername, password: cfg.codemanPassword });
|
||||
}
|
||||
|
||||
export function logFilePath(cfg: PrBotConfig): string {
|
||||
return join(cfg.dataDir, 'bot.log');
|
||||
}
|
||||
|
||||
function unitFile(cfg: PrBotConfig): string {
|
||||
const tsx = join(cfg.mainCheckout, 'node_modules', '.bin', 'tsx');
|
||||
// A user service gets a minimal PATH, which is where `gh` (and an nvm/Homebrew
|
||||
// node) are not: the first run failed its scan with `spawn gh ENOENT`. Bake the
|
||||
// installing shell's PATH in, as `codeman service install` does.
|
||||
const seen = new Set<string>();
|
||||
const path = (process.env.PATH || '/usr/local/bin:/usr/bin:/bin')
|
||||
.split(':')
|
||||
.filter((p) => p && !p.endsWith('/node_modules/.bin') && !seen.has(p) && seen.add(p))
|
||||
.join(':');
|
||||
return `[Unit]
|
||||
Description=Codeman PR review bot (Telegram)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=300
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=${cfg.mainCheckout}
|
||||
ExecStart=${tsx} scripts/pr-bot/main.ts run
|
||||
Restart=always
|
||||
RestartSec=15
|
||||
Environment=HOME=${homedir()}
|
||||
Environment=NODE_ENV=production
|
||||
Environment=PATH=${path}
|
||||
# A file rather than the journal: on some boxes \`journalctl --user\` cannot read
|
||||
# the user journal at all, and a review bot whose logs cannot be found is not
|
||||
# debuggable from a phone.
|
||||
StandardOutput=append:${logFilePath(cfg)}
|
||||
StandardError=append:${logFilePath(cfg)}
|
||||
SyslogIdentifier=${SERVICE_NAME}
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
`;
|
||||
}
|
||||
|
||||
async function cmdCheck(): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
console.log(
|
||||
`config file: ${configFilePath()}${existsSync(configFilePath()) ? '' : ' (absent, defaults + shared Telegram env)'}`
|
||||
);
|
||||
console.log(`repo: ${cfg.githubRepo}`);
|
||||
console.log(`codeman: ${cfg.codemanApiUrl}`);
|
||||
console.log(`main checkout: ${cfg.mainCheckout}`);
|
||||
console.log(`data dir: ${cfg.dataDir}`);
|
||||
console.log(`model: ${cfg.model ?? '(session default)'}, effort: ${cfg.effort ?? '(default)'}`);
|
||||
console.log(
|
||||
`poll: every ${cfg.pollIntervalMs / 60_000} min; review timeout ${cfg.reviewTimeoutMs / 60_000} min; auto-review ${cfg.autoReview}`
|
||||
);
|
||||
let ok = true;
|
||||
const step = async (name: string, fn: () => Promise<string>) => {
|
||||
try {
|
||||
console.log(`✔ ${name}: ${await fn()}`);
|
||||
} catch (err) {
|
||||
ok = false;
|
||||
console.log(`✘ ${name}: ${(err as Error).message}`);
|
||||
}
|
||||
};
|
||||
await step('gh auth', async () =>
|
||||
(await ghAuthOk()) ? 'logged in' : Promise.reject(new Error('run `gh auth login`'))
|
||||
);
|
||||
await step('git', async () =>
|
||||
execFileSync('git', ['-C', cfg.mainCheckout, 'rev-parse', '--git-dir'], { encoding: 'utf8' }).trim()
|
||||
);
|
||||
await step('codeman', async () => {
|
||||
const s = await makeCodeman(cfg).status();
|
||||
return `up (version ${s.version ?? 'unknown'})`;
|
||||
});
|
||||
await step('telegram', async () => {
|
||||
const me = await new TelegramClient(cfg.telegramBotToken, cfg.telegramChatId).getMe();
|
||||
return `@${me.username ?? '?'} for chat ${cfg.telegramChatId}`;
|
||||
});
|
||||
await step('open PRs', async () => `${(await listOpenPrs(cfg.githubRepo)).length}`);
|
||||
if (!ok) process.exit(1);
|
||||
}
|
||||
|
||||
async function cmdScan(): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
const store = new StateStore(join(cfg.dataDir, 'state.json'));
|
||||
const open = await listOpenPrs(cfg.githubRepo);
|
||||
const rows = orderBacklog(open).map((pr) => {
|
||||
const rec = store.pr(pr.number);
|
||||
const state =
|
||||
rec?.reviewedSha === pr.headSha ? `reviewed (${rec?.verdict ?? '?'})` : rec?.reviewedSha ? 'updated' : 'new';
|
||||
const flags = [pr.isDraft ? 'draft' : '', pr.mergeable === 'CONFLICTING' ? 'conflicts' : '']
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
return `#${pr.number}\t${state}\t+${pr.additions}/-${pr.deletions}\t${pr.author}\t${pr.title}${flags ? ` [${flags}]` : ''}`;
|
||||
});
|
||||
console.log(`${open.length} open PRs in review order:\n${rows.join('\n')}`);
|
||||
}
|
||||
|
||||
async function cmdStatus(): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
const store = new StateStore(join(cfg.dataDir, 'state.json'));
|
||||
console.log(`paused: ${store.state.paused}; telegram offset: ${store.state.telegramOffset}`);
|
||||
for (const rec of Object.values(store.state.prs).sort((a, b) => b.number - a.number)) {
|
||||
console.log(
|
||||
`#${rec.number}\t${rec.status}\t${rec.verdict ?? '-'}\t${rec.reviewedSha?.slice(0, 8) ?? '-'}\t${rec.author}\t${rec.title}${
|
||||
rec.lastError ? `\n\t${rec.lastError.split('\n')[0]}` : ''
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdReview(args: string[]): Promise<void> {
|
||||
const number = parseInt(args.find((a) => /^\d+$/.test(a)) ?? '', 10);
|
||||
if (!Number.isFinite(number)) throw new Error('usage: review <pr-number> [--no-telegram]');
|
||||
const cfg = loadConfig();
|
||||
const telegram = args.includes('--no-telegram')
|
||||
? new ConsoleTelegram()
|
||||
: new TelegramClient(cfg.telegramBotToken, cfg.telegramChatId);
|
||||
const bot = new PrBot(cfg, { telegram, codeman: makeCodeman(cfg), log });
|
||||
const rec = await bot.reviewPr(number);
|
||||
console.log(
|
||||
`\n#${number}: ${rec.status}${rec.verdict ? ` (${rec.verdict})` : ''}${rec.lastError ? `\n${rec.lastError}` : ''}`
|
||||
);
|
||||
if (rec.reportMdPath) console.log(`report: ${rec.reportMdPath}`);
|
||||
process.exit(rec.status === 'reviewed' ? 0 : 1);
|
||||
}
|
||||
|
||||
async function cmdNotify(args: string[]): Promise<void> {
|
||||
const number = parseInt(args[0] ?? '', 10);
|
||||
if (!Number.isFinite(number)) throw new Error('usage: notify <pr-number>');
|
||||
const cfg = loadConfig();
|
||||
const bot = new PrBot(cfg, {
|
||||
telegram: new TelegramClient(cfg.telegramBotToken, cfg.telegramChatId),
|
||||
codeman: makeCodeman(cfg),
|
||||
log,
|
||||
});
|
||||
const rec = bot.store.pr(number);
|
||||
if (!rec?.report) throw new Error(`no review of #${number} in ${cfg.dataDir}`);
|
||||
await bot.sendSummary(rec);
|
||||
console.log(`sent the review message for #${number}`);
|
||||
}
|
||||
|
||||
async function cmdRun(): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
const bot = new PrBot(cfg, {
|
||||
telegram: new TelegramClient(cfg.telegramBotToken, cfg.telegramChatId),
|
||||
codeman: makeCodeman(cfg),
|
||||
log,
|
||||
});
|
||||
let stopping = false;
|
||||
const shutdown = (signal: string) => {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
log(`${signal}: stopping`);
|
||||
bot
|
||||
.stop()
|
||||
.catch((err) => log(`stop: ${(err as Error).message}`))
|
||||
.finally(() => process.exit(0));
|
||||
};
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
log(`starting: repo ${cfg.githubRepo}, codeman ${cfg.codemanApiUrl}, data ${cfg.dataDir}`);
|
||||
await bot.start();
|
||||
}
|
||||
|
||||
function cmdInstallService(): void {
|
||||
const cfg = loadConfig();
|
||||
const dir = join(homedir(), '.config', 'systemd', 'user');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const path = join(dir, `${SERVICE_NAME}.service`);
|
||||
mkdirSync(cfg.dataDir, { recursive: true });
|
||||
writeFileSync(path, unitFile(cfg));
|
||||
execFileSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'inherit' });
|
||||
execFileSync('systemctl', ['--user', 'enable', SERVICE_NAME], { stdio: 'inherit' });
|
||||
// `restart` rather than `enable --now`: a re-install must pick up the new unit.
|
||||
execFileSync('systemctl', ['--user', 'restart', SERVICE_NAME], { stdio: 'inherit' });
|
||||
console.log(`installed ${path}\nlogs: tail -f ${logFilePath(cfg)}`);
|
||||
}
|
||||
|
||||
function cmdUninstallService(): void {
|
||||
const path = join(homedir(), '.config', 'systemd', 'user', `${SERVICE_NAME}.service`);
|
||||
execFileSync('systemctl', ['--user', 'disable', '--now', SERVICE_NAME], { stdio: 'inherit' });
|
||||
if (existsSync(path)) execFileSync('rm', ['-f', path]);
|
||||
execFileSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'inherit' });
|
||||
console.log(`removed ${SERVICE_NAME}`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [cmd = 'run', ...rest] = process.argv.slice(2);
|
||||
switch (cmd) {
|
||||
case 'run':
|
||||
return cmdRun();
|
||||
case 'check':
|
||||
return cmdCheck();
|
||||
case 'scan':
|
||||
return cmdScan();
|
||||
case 'status':
|
||||
return cmdStatus();
|
||||
case 'review':
|
||||
return cmdReview(rest);
|
||||
case 'notify':
|
||||
return cmdNotify(rest);
|
||||
case 'install-service':
|
||||
return cmdInstallService();
|
||||
case 'uninstall-service':
|
||||
return cmdUninstallService();
|
||||
default:
|
||||
console.error(
|
||||
'usage: main.ts run | check | scan | status | review <N> [--no-telegram] | install-service | uninstall-service'
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error((err as Error).stack ?? String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* @fileoverview Pure report handling: parse the reviewer's JSON (leniently, it is
|
||||
* model output), render the Telegram summary (HTML, under the 4096-char cap), the
|
||||
* status list, the inline keyboard, and the backlog order. Unit-tested.
|
||||
*/
|
||||
import type { CiState, PrSummary } from './github.js';
|
||||
import { VERDICTS, type Verdict } from './review-task.js';
|
||||
|
||||
export type Severity = 'blocker' | 'major' | 'minor' | 'nit';
|
||||
|
||||
export interface Finding {
|
||||
severity: Severity;
|
||||
title: string;
|
||||
file?: string;
|
||||
line?: number;
|
||||
detail: string;
|
||||
invariant?: string;
|
||||
}
|
||||
|
||||
export interface CheckResult {
|
||||
name: string;
|
||||
command?: string;
|
||||
result: 'pass' | 'fail' | 'skipped';
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface ReviewReport {
|
||||
verdict: Verdict;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
summary: string;
|
||||
changes: string[];
|
||||
findings: Finding[];
|
||||
checks: CheckResult[];
|
||||
scope: 'focused' | 'mixed';
|
||||
risk: string;
|
||||
recommendation: string;
|
||||
draftComment: string;
|
||||
assumptions: string[];
|
||||
}
|
||||
|
||||
export const TELEGRAM_MAX = 4096;
|
||||
/** Leave room for HTML tags the counter cannot see and for the keyboard-less fallback. */
|
||||
const SUMMARY_BUDGET = 3600;
|
||||
|
||||
const SEVERITY_ORDER: Severity[] = ['blocker', 'major', 'minor', 'nit'];
|
||||
const SEVERITY_ICON: Record<Severity, string> = { blocker: '🔴', major: '🟠', minor: '🟡', nit: '⚪' };
|
||||
const VERDICT_LABEL: Record<Verdict, string> = {
|
||||
merge: '✅ MERGE',
|
||||
'merge-with-fixes': '🟢 MERGE WITH FIXES',
|
||||
'request-changes': '🟠 REQUEST CHANGES',
|
||||
close: '❌ CLOSE',
|
||||
'needs-discussion': '💬 NEEDS DISCUSSION',
|
||||
};
|
||||
const CI_LABEL: Record<CiState, string> = {
|
||||
passed: 'CI ✅',
|
||||
failed: 'CI ❌',
|
||||
pending: 'CI ⏳',
|
||||
'awaiting-approval': 'CI ⏸ needs your approval',
|
||||
none: 'CI none',
|
||||
};
|
||||
|
||||
export function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function str(v: unknown, fallback = ''): string {
|
||||
return typeof v === 'string' ? v : fallback;
|
||||
}
|
||||
|
||||
function strList(v: unknown): string[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter((x): x is string => typeof x === 'string' && x.trim().length > 0);
|
||||
}
|
||||
|
||||
/** Extract the first JSON object from text that may carry fences or prose around it. */
|
||||
export function extractJsonObject(text: string): unknown {
|
||||
const trimmed = text.trim();
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||
if (fence) {
|
||||
try {
|
||||
return JSON.parse(fence[1]);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
const start = trimmed.indexOf('{');
|
||||
const end = trimmed.lastIndexOf('}');
|
||||
if (start >= 0 && end > start) {
|
||||
try {
|
||||
return JSON.parse(trimmed.slice(start, end + 1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Normalize model output into a ReviewReport. Returns null only when there is no verdict at all. */
|
||||
export function parseReport(raw: unknown): ReviewReport | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const o = raw as Record<string, unknown>;
|
||||
const verdictRaw = str(o.verdict).trim().toLowerCase().replace(/[_ ]/g, '-');
|
||||
const verdict = (VERDICTS as readonly string[]).includes(verdictRaw) ? (verdictRaw as Verdict) : null;
|
||||
if (!verdict) return null;
|
||||
const confidenceRaw = str(o.confidence).trim().toLowerCase();
|
||||
const confidence = confidenceRaw === 'high' || confidenceRaw === 'low' ? confidenceRaw : 'medium';
|
||||
|
||||
const findings: Finding[] = [];
|
||||
if (Array.isArray(o.findings)) {
|
||||
for (const f of o.findings) {
|
||||
if (!f || typeof f !== 'object') continue;
|
||||
const fo = f as Record<string, unknown>;
|
||||
const sevRaw = str(fo.severity).trim().toLowerCase();
|
||||
const severity = (SEVERITY_ORDER as string[]).includes(sevRaw) ? (sevRaw as Severity) : 'minor';
|
||||
const title = str(fo.title).trim();
|
||||
if (!title) continue;
|
||||
const line = typeof fo.line === 'number' && Number.isFinite(fo.line) ? Math.trunc(fo.line) : undefined;
|
||||
findings.push({
|
||||
severity,
|
||||
title,
|
||||
file: str(fo.file).trim() || undefined,
|
||||
line,
|
||||
detail: str(fo.detail).trim(),
|
||||
invariant: str(fo.invariant).trim() || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
findings.sort((a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity));
|
||||
|
||||
const checks: CheckResult[] = [];
|
||||
if (Array.isArray(o.checks)) {
|
||||
for (const c of o.checks) {
|
||||
if (!c || typeof c !== 'object') continue;
|
||||
const co = c as Record<string, unknown>;
|
||||
const name = str(co.name).trim();
|
||||
if (!name) continue;
|
||||
const resRaw = str(co.result).trim().toLowerCase();
|
||||
const result = resRaw === 'pass' || resRaw === 'fail' ? resRaw : 'skipped';
|
||||
checks.push({
|
||||
name,
|
||||
command: str(co.command).trim() || undefined,
|
||||
result,
|
||||
notes: str(co.notes).trim() || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
verdict,
|
||||
confidence,
|
||||
summary: str(o.summary).trim(),
|
||||
changes: strList(o.changes),
|
||||
findings,
|
||||
checks,
|
||||
scope: str(o.scope).trim().toLowerCase() === 'mixed' ? 'mixed' : 'focused',
|
||||
risk: str(o.risk).trim(),
|
||||
recommendation: str(o.recommendation).trim(),
|
||||
draftComment: str(o.draftComment).trim(),
|
||||
assumptions: strList(o.assumptions),
|
||||
};
|
||||
}
|
||||
|
||||
export function countBySeverity(findings: Finding[]): Record<Severity, number> {
|
||||
const out: Record<Severity, number> = { blocker: 0, major: 0, minor: 0, nit: 0 };
|
||||
for (const f of findings) out[f.severity]++;
|
||||
return out;
|
||||
}
|
||||
|
||||
function findingLine(f: Finding): string {
|
||||
const where = f.file ? ` <code>${escapeHtml(f.file)}${f.line ? `:${f.line}` : ''}</code>` : '';
|
||||
return `${SEVERITY_ICON[f.severity]} ${escapeHtml(f.title)}${where}`;
|
||||
}
|
||||
|
||||
function checksLine(checks: CheckResult[]): string {
|
||||
if (!checks.length) return '';
|
||||
const parts = checks.map((c) => {
|
||||
const icon = c.result === 'pass' ? '✅' : c.result === 'fail' ? '❌' : '⏭';
|
||||
return `${escapeHtml(c.name)} ${icon}`;
|
||||
});
|
||||
return `<b>Checks:</b> ${parts.join(' · ')}`;
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
if (text.length <= max) return text;
|
||||
return text.slice(0, Math.max(0, max - 1)).trimEnd() + '…';
|
||||
}
|
||||
|
||||
export interface SummaryMeta {
|
||||
ci: CiState;
|
||||
/** Time the review took, for the footer. */
|
||||
durationMin?: number;
|
||||
}
|
||||
|
||||
/** The message the maintainer reads on the phone. HTML parse mode. */
|
||||
export function formatTelegramSummary(pr: PrSummary, report: ReviewReport, meta: SummaryMeta): string {
|
||||
const header =
|
||||
`🔍 <b>PR #${pr.number}</b> · ${escapeHtml(truncate(pr.title, 120))}\n` +
|
||||
`<i>by ${escapeHtml(pr.author)} · +${pr.additions}/−${pr.deletions} · ${pr.changedFiles} files · ${CI_LABEL[meta.ci]} · ${
|
||||
pr.mergeable === 'CONFLICTING'
|
||||
? 'conflicts ⚠️'
|
||||
: pr.mergeable === 'MERGEABLE'
|
||||
? 'mergeable'
|
||||
: 'mergeability unknown'
|
||||
}${pr.isDraft ? ' · draft' : ''}</i>\n` +
|
||||
`<a href="${escapeHtml(pr.url)}">${escapeHtml(pr.url)}</a>\n`;
|
||||
const verdict = `\n<b>${VERDICT_LABEL[report.verdict]}</b> <i>(confidence ${report.confidence}${report.scope === 'mixed' ? ', mixed scope' : ''})</i>\n`;
|
||||
const summary = report.summary ? `\n${escapeHtml(report.summary)}\n` : '';
|
||||
|
||||
const counts = countBySeverity(report.findings);
|
||||
const countStr = SEVERITY_ORDER.filter((s) => counts[s] > 0)
|
||||
.map((s) => `${counts[s]} ${s}${counts[s] === 1 ? '' : 's'}`)
|
||||
.join(', ');
|
||||
const findingsHeader = report.findings.length ? `\n<b>Findings</b> (${countStr}):\n` : '\n<b>Findings:</b> none\n';
|
||||
|
||||
const checks = checksLine(report.checks);
|
||||
const recommendation = report.recommendation ? `\n<b>Recommendation:</b> ${escapeHtml(report.recommendation)}\n` : '';
|
||||
const footer = meta.durationMin !== undefined ? `\n<i>review took ${meta.durationMin} min</i>` : '';
|
||||
|
||||
const fixed = header + verdict + summary + findingsHeader;
|
||||
const tail = (checks ? `\n${checks}\n` : '') + recommendation + footer;
|
||||
let budget = SUMMARY_BUDGET - fixed.length - tail.length;
|
||||
|
||||
const lines: string[] = [];
|
||||
let shown = 0;
|
||||
for (const f of report.findings) {
|
||||
const line = findingLine(f) + '\n';
|
||||
if (line.length > budget) break;
|
||||
lines.push(line);
|
||||
budget -= line.length;
|
||||
shown++;
|
||||
}
|
||||
const hidden = report.findings.length - shown;
|
||||
const more = hidden > 0 ? `<i>… ${hidden} more in the full report</i>\n` : '';
|
||||
return fixed + lines.join('') + more + tail;
|
||||
}
|
||||
|
||||
export function formatReviewFailure(
|
||||
pr: Pick<PrSummary, 'number' | 'title' | 'author' | 'url'>,
|
||||
reason: string
|
||||
): string {
|
||||
return (
|
||||
`⚠️ <b>PR #${pr.number}</b> · ${escapeHtml(truncate(pr.title, 120))}\n` +
|
||||
`<i>by ${escapeHtml(pr.author)}</i>\n<a href="${escapeHtml(pr.url)}">${escapeHtml(pr.url)}</a>\n\n` +
|
||||
`The review did not complete: ${escapeHtml(truncate(reason, 1500))}\n\n` +
|
||||
`Use /review ${pr.number} to try again.`
|
||||
);
|
||||
}
|
||||
|
||||
/** Split on line boundaries so no chunk exceeds Telegram's cap. */
|
||||
export function splitTelegramMessage(text: string, max = TELEGRAM_MAX): string[] {
|
||||
if (text.length <= max) return [text];
|
||||
const chunks: string[] = [];
|
||||
let current = '';
|
||||
for (const line of text.split('\n')) {
|
||||
let piece = line;
|
||||
while (piece.length > max) {
|
||||
if (current) {
|
||||
chunks.push(current);
|
||||
current = '';
|
||||
}
|
||||
chunks.push(piece.slice(0, max));
|
||||
piece = piece.slice(max);
|
||||
}
|
||||
const candidate = current ? `${current}\n${piece}` : piece;
|
||||
if (candidate.length > max) {
|
||||
chunks.push(current);
|
||||
current = piece;
|
||||
} else {
|
||||
current = candidate;
|
||||
}
|
||||
}
|
||||
if (current) chunks.push(current);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export interface InlineButton {
|
||||
text: string;
|
||||
callback_data: string;
|
||||
}
|
||||
|
||||
/** Callback data is capped at 64 bytes by Telegram; these stay far under it. */
|
||||
export function buildReportKeyboard(prNumber: number, opts: { ci: CiState; hasDraft: boolean }): InlineButton[][] {
|
||||
const rows: InlineButton[][] = [
|
||||
[
|
||||
{ text: '📄 Full report', callback_data: `report:${prNumber}` },
|
||||
...(opts.hasDraft ? [{ text: '💬 Draft comment', callback_data: `draft:${prNumber}` }] : []),
|
||||
{ text: '🔁 Re-review', callback_data: `review:${prNumber}` },
|
||||
],
|
||||
[
|
||||
{ text: '✅ Merge', callback_data: `merge:${prNumber}` },
|
||||
...(opts.hasDraft ? [{ text: '📮 Post comment', callback_data: `post:${prNumber}` }] : []),
|
||||
{ text: '🗑 Close', callback_data: `close:${prNumber}` },
|
||||
],
|
||||
];
|
||||
if (opts.ci === 'awaiting-approval')
|
||||
rows.push([{ text: '▶️ Approve CI run', callback_data: `approveci:${prNumber}` }]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function confirmKeyboard(action: string, prNumber: number, nonce: string): InlineButton[][] {
|
||||
return [
|
||||
[
|
||||
{ text: `Yes, ${action} #${prNumber}`, callback_data: `confirm:${action}:${prNumber}:${nonce}` },
|
||||
{ text: 'Cancel', callback_data: `cancel:${action}:${prNumber}:${nonce}` },
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
export interface StatusRow {
|
||||
number: number;
|
||||
title: string;
|
||||
author: string;
|
||||
verdict?: Verdict;
|
||||
status: string;
|
||||
ci?: CiState;
|
||||
mergeable: PrSummary['mergeable'];
|
||||
isDraft: boolean;
|
||||
}
|
||||
|
||||
export function formatStatusList(rows: StatusRow[], paused: boolean): string {
|
||||
if (!rows.length) return 'No open pull requests.';
|
||||
const lines = rows.map((r) => {
|
||||
const v = r.verdict
|
||||
? VERDICT_LABEL[r.verdict].split(' ')[0]
|
||||
: r.status === 'reviewing'
|
||||
? '⏳'
|
||||
: r.status === 'queued'
|
||||
? '🕓'
|
||||
: '·';
|
||||
const flags = [
|
||||
r.ci ? CI_LABEL[r.ci].replace('CI ', '') : '',
|
||||
r.mergeable === 'CONFLICTING' ? 'conflicts' : '',
|
||||
r.isDraft ? 'draft' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
return `${v} <b>#${r.number}</b> ${escapeHtml(truncate(r.title, 60))} <i>(${escapeHtml(r.author)}${flags ? `; ${flags}` : ''})</i>`;
|
||||
});
|
||||
return `${paused ? '⏸ auto-review paused\n' : ''}<b>Open PRs (${rows.length})</b>\n${lines.join('\n')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backlog order for a fresh sweep: the ones you can act on first (mergeable, small),
|
||||
* conflicting and huge ones last. Ties keep the newer PR first.
|
||||
*/
|
||||
export function orderBacklog<T extends Pick<PrSummary, 'number' | 'mergeable' | 'additions' | 'deletions'>>(
|
||||
prs: T[]
|
||||
): T[] {
|
||||
const size = (p: T) => p.additions + p.deletions;
|
||||
return [...prs].sort((a, b) => {
|
||||
const ca = a.mergeable === 'CONFLICTING' ? 1 : 0;
|
||||
const cb = b.mergeable === 'CONFLICTING' ? 1 : 0;
|
||||
if (ca !== cb) return ca - cb;
|
||||
const sa = size(a);
|
||||
const sb = size(b);
|
||||
if (sa !== sb) return sa - sb;
|
||||
return b.number - a.number;
|
||||
});
|
||||
}
|
||||
|
||||
export function verdictLabel(v: Verdict): string {
|
||||
return VERDICT_LABEL[v];
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* @fileoverview The review brief handed to each reviewer session, and the follow-up
|
||||
* brief. Pure: the bot writes the result to a file and sends the session one short
|
||||
* line pointing at it (prompts are single-line over tmux, and a brief this size
|
||||
* belongs on disk anyway).
|
||||
*
|
||||
* The brief is opinionated on purpose. It names the repository's own rules (CLAUDE.md,
|
||||
* CONTRIBUTING.md), the checks to run, the verdict vocabulary, and the exact JSON the
|
||||
* bot parses. Everything the maintainer would say out loud before delegating a
|
||||
* review lives here.
|
||||
*/
|
||||
import type { CiStatus, PrDetail } from './github.js';
|
||||
|
||||
export const VERDICTS = ['merge', 'merge-with-fixes', 'request-changes', 'close', 'needs-discussion'] as const;
|
||||
export type Verdict = (typeof VERDICTS)[number];
|
||||
|
||||
export interface ReviewBriefInput {
|
||||
pr: PrDetail;
|
||||
ci: CiStatus;
|
||||
mergeBase: string;
|
||||
worktreeDir: string;
|
||||
mainCheckout: string;
|
||||
reportJsonPath: string;
|
||||
reportMdPath: string;
|
||||
}
|
||||
|
||||
function ciLine(ci: CiStatus): string {
|
||||
const detail = ci.runs.map((r) => `${r.name}: ${r.conclusion ?? r.status}`).join(', ');
|
||||
switch (ci.state) {
|
||||
case 'passed':
|
||||
return `passed (${detail})`;
|
||||
case 'failed':
|
||||
return `FAILED (${detail}); read the failing job's log with \`gh run view <id> --log-failed\` before you trust or dismiss it`;
|
||||
case 'pending':
|
||||
return `still running (${detail})`;
|
||||
case 'awaiting-approval':
|
||||
return 'never ran: the workflow is waiting for a maintainer to approve it (first-time contributor), so run the checks yourself';
|
||||
default:
|
||||
return 'no workflow runs found for this head (a conflicting PR gets no CI at all); run the checks yourself';
|
||||
}
|
||||
}
|
||||
|
||||
export function buildReviewBrief(input: ReviewBriefInput): string {
|
||||
const { pr, ci, mergeBase, worktreeDir, mainCheckout, reportJsonPath, reportMdPath } = input;
|
||||
const files = pr.files.map((f) => `- \`${f.path}\` (+${f.additions}/-${f.deletions})`).join('\n');
|
||||
const linked = pr.linkedIssues.length
|
||||
? pr.linkedIssues.map((i) => `- #${i.number} ${i.title}`).join('\n')
|
||||
: '- none linked';
|
||||
const mergeability =
|
||||
pr.mergeable === 'CONFLICTING'
|
||||
? 'CONFLICTING with master. It cannot be merged as-is and GitHub runs no CI for it. Review the PR head as it stands, and say in the report whether the conflicts look mechanical or structural (`git merge-tree` against origin/master helps).'
|
||||
: pr.mergeable === 'MERGEABLE'
|
||||
? 'mergeable'
|
||||
: 'unknown (GitHub has not computed it yet)';
|
||||
|
||||
return `# Review brief: PR #${pr.number} ${pr.title}
|
||||
|
||||
You are reviewing a pull request against Codeman on behalf of the maintainer. You are
|
||||
in a private clone at \`${worktreeDir}\`, checked out (detached) at the PR head. The
|
||||
maintainer reads your report on a phone and decides what happens next, so write for
|
||||
someone who has not seen the diff.
|
||||
|
||||
## Ground rules (read twice)
|
||||
|
||||
- Nothing you do here reaches GitHub. Do NOT push, comment, merge, close, label, or
|
||||
create anything with \`gh\`; \`gh\` is for READING only (\`gh pr view\`, \`gh run view\`,
|
||||
\`gh api\` GETs).
|
||||
- Do NOT run \`npm install\`, \`npm ci\`, \`npm update\` or \`npm run build\`: \`node_modules\`
|
||||
may be a symlink into the maintainer's live checkout. Everything else in package.json
|
||||
scripts is fine (\`npm run typecheck\`, \`npm run lint\`, \`npm test -- <file>\`, ...).
|
||||
- Do NOT restart, stop or install any service, and never bind port 3000: the
|
||||
maintainer's production Codeman runs there. Test ports are 3150 and up.
|
||||
- \`${mainCheckout}\` is the maintainer's shared checkout. You may READ it for comparison;
|
||||
never run a git command there that changes anything (no checkout, reset, stash, clean).
|
||||
- Stay inside this clone for writes. Do not create files elsewhere except the two
|
||||
report files named below.
|
||||
- Do not ask questions. Nobody is watching this session. Where something is ambiguous,
|
||||
decide, and list the assumption in the report.
|
||||
|
||||
## The pull request
|
||||
|
||||
- **#${pr.number}** ${pr.title}
|
||||
- Author: ${pr.author} (${pr.authorAssociation.toLowerCase().replace(/_/g, ' ')})${pr.headRepo ? `, from \`${pr.headRepo}\`` : ''}
|
||||
- URL: ${pr.url}
|
||||
- Base: \`${pr.baseRef}\` at merge base \`${mergeBase.slice(0, 12)}\`; head: \`${pr.headSha.slice(0, 12)}\` (${pr.commitCount} commits)
|
||||
- Size: +${pr.additions} / -${pr.deletions} across ${pr.changedFiles} files
|
||||
- Mergeability: ${mergeability}
|
||||
- CI: ${ciLine(ci)}
|
||||
- Draft: ${pr.isDraft ? 'yes' : 'no'}; existing comments: ${pr.commentCount}${pr.labels.length ? `; labels: ${pr.labels.join(', ')}` : ''}
|
||||
|
||||
### Linked issues
|
||||
${linked}
|
||||
|
||||
### Files changed
|
||||
${files || '- (none reported)'}
|
||||
|
||||
### PR description, verbatim
|
||||
\`\`\`text
|
||||
${pr.body.trim() || '(empty)'}
|
||||
\`\`\`
|
||||
|
||||
## How to review
|
||||
|
||||
1. Read \`CLAUDE.md\` at the root and \`.github/CONTRIBUTING.md\`. Most review feedback on
|
||||
this repository traces back to a rule already written there, and a change that
|
||||
contradicts one of those rules is a finding even when the code works. Open the
|
||||
\`docs/architecture-invariants.md\` sections the change touches.
|
||||
2. Understand the change: \`git log --oneline ${mergeBase.slice(0, 12)}..HEAD\` and
|
||||
\`git diff ${mergeBase.slice(0, 12)}..HEAD\`. Read the surrounding code, not only the
|
||||
hunks: the file's \`@fileoverview\` first, then the call sites of anything changed.
|
||||
3. Look for, in this order: correctness bugs (wrong logic, races, missed error paths,
|
||||
lost state across restart); security (auth and ownership checks, path confinement,
|
||||
the env-prefix allowlist, shell/command injection, SSRF, secrets on the command
|
||||
line or in state files); violations of CLAUDE.md rules (cite the rule); behaviour
|
||||
changes without tests; contract changes (\`/api/v1\` paths, response envelope,
|
||||
\`errorCode\` values, SSE event names are public and stable, see
|
||||
\`docs/versioning-policy.md\`); scope (one change per PR: flag unrelated changes
|
||||
bundled in); docs and registries that must move with the code (CLAUDE.md and
|
||||
architecture-invariants when a rule changes, \`sse-events.ts\` and \`constants.js\`
|
||||
parity, \`docs/api-reference.md\`); housekeeping that does not belong in a PR
|
||||
(version bumps, CHANGELOG edits, files pulled back into Prettier's scope, committed
|
||||
vendor bundles, changeset files are fine).
|
||||
4. Run the checks and record what you ran and what came back:
|
||||
\`npm run typecheck\`, \`npm run lint\`, \`npm run check:frontend-syntax\`,
|
||||
\`npm run format:check\`, then the tests covering the touched areas
|
||||
(\`npm test -- test/<file>.test.ts\`, several files at once is fine). Run the full
|
||||
\`npm test\` when the change is broad or touches shared infrastructure (session,
|
||||
tmux, routes, state); it takes minutes, which is acceptable. A red check that is
|
||||
also red on origin/master is not the PR's fault: say so rather than blaming it.
|
||||
Other test suites may be running on this machine at the same time and they share
|
||||
the 3150+ port range, so re-run a failed file on its own (\`npm test -- <file>\`)
|
||||
before you read an EADDRINUSE or a timeout as the PR's regression.
|
||||
5. Verify before you report. A finding that could be a misread must be confirmed by
|
||||
reading the full code path, by a tiny test, or by running it. Every finding names a
|
||||
file and line. Rank: **blocker** (must be fixed before merge: data loss, security,
|
||||
breaks a documented invariant, breaks the build or tests), **major** (should be
|
||||
fixed: a real bug in an edge the PR introduces, a missing test for new behaviour),
|
||||
**minor**, **nit**.
|
||||
6. Judge the PR, not the author. Contributors here are volunteers and the maintainer
|
||||
thanks them by name in every release; be exact and be kind.
|
||||
|
||||
## Verdict vocabulary
|
||||
|
||||
- \`merge\`: no blockers or majors, checks green; merge as-is.
|
||||
- \`merge-with-fixes\`: mergeable, but with small things the maintainer would rather fix
|
||||
at merge time than round-trip (list them so they can be applied on top).
|
||||
- \`request-changes\`: blockers or majors the author should fix.
|
||||
- \`close\`: wrong direction, superseded, or not wanted; say what should happen instead.
|
||||
- \`needs-discussion\`: a design question the maintainer must answer before anyone
|
||||
spends more time (name the question).
|
||||
|
||||
## Output, mandatory
|
||||
|
||||
Write BOTH files, then reply with exactly one line: \`REVIEW COMPLETE\`.
|
||||
|
||||
1. \`${reportJsonPath}\`: a single JSON object, no markdown fences, this shape:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"verdict": "merge | merge-with-fixes | request-changes | close | needs-discussion",
|
||||
"confidence": "high | medium | low",
|
||||
"summary": "Two or three sentences: what the PR does, and the review's bottom line.",
|
||||
"changes": ["one bullet per thing the PR actually changes"],
|
||||
"findings": [
|
||||
{
|
||||
"severity": "blocker | major | minor | nit",
|
||||
"title": "one line",
|
||||
"file": "path/from/repo/root.ts",
|
||||
"line": 123,
|
||||
"detail": "what is wrong, why it matters, what to do instead",
|
||||
"invariant": "the CLAUDE.md / CONTRIBUTING rule it breaks, or omit"
|
||||
}
|
||||
],
|
||||
"checks": [
|
||||
{ "name": "typecheck", "command": "npm run typecheck", "result": "pass | fail | skipped", "notes": "" }
|
||||
],
|
||||
"_checks_note": "result is from the PR's point of view: a regression test you deliberately ran against master to prove it fails is a pass (say so in notes), a red run caused by another suite on the machine is skipped with the reason, only a genuine problem with the PR is fail",
|
||||
"scope": "focused | mixed",
|
||||
"risk": "One or two sentences naming the judgment calls a second reviewer should look at.",
|
||||
"recommendation": "Two to four sentences for the maintainer: what to do next and why.",
|
||||
"draftComment": "A comment to the contributor, in markdown, ready to post (rules below).",
|
||||
"assumptions": ["anything you had to decide alone"]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
2. \`${reportMdPath}\`: the full report in markdown for the maintainer, in this order:
|
||||
what the PR does; the verdict with the reasoning; findings in severity order with
|
||||
file:line and the fix; checks run with results; CLAUDE.md rules touched; scope and
|
||||
risk; recommendation; assumptions. Include the diff stat. No length limit, but no
|
||||
padding either.
|
||||
|
||||
### Draft comment rules
|
||||
|
||||
The draft is written AS the maintainer TO the contributor and must stand alone: the
|
||||
reader has not seen this brief. Open by thanking them and saying in one sentence what
|
||||
the PR does. Then the findings that need action, each with file:line and the concrete
|
||||
ask, blockers first. Close with what happens next (merge after fixes, will fix at merge
|
||||
time, and so on). When the verdict is \`merge\`, the whole comment is a short thank-you
|
||||
naming anything you would touch at merge time. Plain markdown. No em-dashes (use
|
||||
commas, colons or parentheses). No emojis. No "Generated with Claude Code" or similar
|
||||
attribution line. No hedging words. The maintainer reads it before it is posted and may
|
||||
edit it.
|
||||
`;
|
||||
}
|
||||
|
||||
/** Sent as ONE line; the brief above is on disk. */
|
||||
export function reviewKickoffLine(briefPath: string): string {
|
||||
return `Read ${briefPath} and carry out the review it describes. Do not ask questions. Finish by writing both report files it names, then reply with exactly: REVIEW COMPLETE`;
|
||||
}
|
||||
|
||||
export function followupKickoffLine(followupPath: string): string {
|
||||
return `Read ${followupPath}: it holds a follow-up from the maintainer about the pull request you reviewed. Do what it asks within the ground rules of the original brief (no pushing, no gh writes, no npm install, no builds, no services), then answer in plain text. Do not ask questions.`;
|
||||
}
|
||||
|
||||
export function buildFollowupBrief(input: {
|
||||
prNumber: number;
|
||||
title: string;
|
||||
instruction: string;
|
||||
worktreeDir: string;
|
||||
reportMdPath: string;
|
||||
briefPath: string;
|
||||
}): string {
|
||||
return `# Follow-up on PR #${input.prNumber} ${input.title}
|
||||
|
||||
The maintainer read your review report (\`${input.reportMdPath}\`; the original brief is
|
||||
\`${input.briefPath}\`, and its ground rules still apply: nothing reaches GitHub, no
|
||||
installs, no builds, no services, writes stay inside \`${input.worktreeDir}\`).
|
||||
|
||||
Their message:
|
||||
|
||||
\`\`\`text
|
||||
${input.instruction.trim()}
|
||||
\`\`\`
|
||||
|
||||
Answer concisely and concretely, for a phone screen: lead with the answer, then the
|
||||
evidence (commands run, file:line). If the message asks you to change code, make the
|
||||
change in this clone, run the relevant checks, and describe the diff (\`git diff
|
||||
--stat\` plus the essential hunks). Keep the changes uncommitted unless asked to commit;
|
||||
never push. If it asks for something outside the ground rules, say so and stop.
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @fileoverview The bot's persisted state: one record per PR (what was reviewed at
|
||||
* which head, the parsed report, the Claude session to resume for follow-ups, the
|
||||
* Telegram messages that belong to it), the Telegram update offset, pending
|
||||
* confirmations, and the pause flag. One JSON file, written atomically (tmp + rename)
|
||||
* with mode 0600, since reports quote code and draft comments.
|
||||
*/
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import type { CiState, PrSummary } from './github.js';
|
||||
import type { ReviewReport } from './report.js';
|
||||
import type { Verdict } from './review-task.js';
|
||||
|
||||
export type PrStatus = 'new' | 'queued' | 'reviewing' | 'reviewed' | 'failed' | 'skipped' | 'closed';
|
||||
|
||||
export interface PrRecord {
|
||||
number: number;
|
||||
title: string;
|
||||
author: string;
|
||||
url: string;
|
||||
headSha: string;
|
||||
isDraft: boolean;
|
||||
mergeable: PrSummary['mergeable'];
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
changedFiles?: number;
|
||||
status: PrStatus;
|
||||
ci?: CiState;
|
||||
reviewedSha?: string;
|
||||
reviewedAt?: string;
|
||||
reviewDurationMin?: number;
|
||||
verdict?: Verdict;
|
||||
report?: ReviewReport;
|
||||
briefPath?: string;
|
||||
reportJsonPath?: string;
|
||||
reportMdPath?: string;
|
||||
/** The Claude conversation to resume for follow-ups. */
|
||||
claudeSessionId?: string;
|
||||
/** The live Codeman session while a turn is running; cleared afterwards. */
|
||||
activeSessionId?: string;
|
||||
worktreeDir?: string;
|
||||
telegramMessageId?: number;
|
||||
lastError?: string;
|
||||
/** Consecutive failed attempts at `failedSha`; the scan stops auto-retrying at MAX_AUTO_RETRIES. */
|
||||
failedAttempts?: number;
|
||||
failedSha?: string;
|
||||
closedAs?: 'merged' | 'closed';
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PendingConfirm {
|
||||
action: 'merge' | 'close' | 'post';
|
||||
prNumber: number;
|
||||
createdAt: string;
|
||||
messageId?: number;
|
||||
/** Closing comment for `close`. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface BotState {
|
||||
version: 1;
|
||||
paused: boolean;
|
||||
telegramOffset: number;
|
||||
prs: Record<string, PrRecord>;
|
||||
pending: Record<string, PendingConfirm>;
|
||||
/** Telegram message id -> PR number, so a reply to any of the bot's messages finds its PR. */
|
||||
messages: Record<string, number>;
|
||||
/** Telegram message id -> PR number for "reply with the closing reason" prompts. */
|
||||
reasonPrompts: Record<string, number>;
|
||||
}
|
||||
|
||||
export function emptyState(): BotState {
|
||||
return { version: 1, paused: false, telegramOffset: 0, prs: {}, pending: {}, messages: {}, reasonPrompts: {} };
|
||||
}
|
||||
|
||||
const MAX_MESSAGE_MAP = 2000;
|
||||
|
||||
export class StateStore {
|
||||
state: BotState;
|
||||
|
||||
constructor(private readonly path: string) {
|
||||
this.state = emptyState();
|
||||
if (existsSync(path)) {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<BotState>;
|
||||
this.state = { ...emptyState(), ...parsed, version: 1 };
|
||||
} catch (err) {
|
||||
throw new Error(`state file ${path} is unreadable: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
save(): void {
|
||||
mkdirSync(dirname(this.path), { recursive: true });
|
||||
this.pruneMessageMap();
|
||||
const tmp = join(dirname(this.path), `.state.${process.pid}.${Date.now()}.tmp`);
|
||||
writeFileSync(tmp, JSON.stringify(this.state, null, 2), { mode: 0o600 });
|
||||
renameSync(tmp, this.path);
|
||||
}
|
||||
|
||||
pr(number: number): PrRecord | undefined {
|
||||
return this.state.prs[String(number)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a PR's metadata, keeping its review. Mutates the EXISTING record in place:
|
||||
* a review in flight holds a reference to it, and a scan that replaced the object
|
||||
* with a copy made that review write its verdict into an orphan (first daemon run:
|
||||
* PR 363 reported to Telegram, state still said `reviewing`).
|
||||
*/
|
||||
upsertPr(summary: PrSummary): PrRecord {
|
||||
const key = String(summary.number);
|
||||
const existing = this.state.prs[key];
|
||||
const record: PrRecord = existing ?? {
|
||||
number: summary.number,
|
||||
title: summary.title,
|
||||
author: summary.author,
|
||||
url: summary.url,
|
||||
headSha: summary.headSha,
|
||||
isDraft: summary.isDraft,
|
||||
mergeable: summary.mergeable,
|
||||
status: 'new',
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
record.title = summary.title;
|
||||
record.author = summary.author;
|
||||
record.url = summary.url;
|
||||
record.headSha = summary.headSha;
|
||||
record.isDraft = summary.isDraft;
|
||||
record.mergeable = summary.mergeable;
|
||||
record.additions = summary.additions;
|
||||
record.deletions = summary.deletions;
|
||||
record.changedFiles = summary.changedFiles;
|
||||
if (record.status === 'closed') {
|
||||
// Reopened.
|
||||
record.status = record.reviewedSha ? 'reviewed' : 'new';
|
||||
record.closedAs = undefined;
|
||||
}
|
||||
record.updatedAt = new Date().toISOString();
|
||||
this.state.prs[key] = record;
|
||||
return record;
|
||||
}
|
||||
|
||||
openPrs(): PrRecord[] {
|
||||
return Object.values(this.state.prs)
|
||||
.filter((r) => r.status !== 'closed')
|
||||
.sort((a, b) => b.number - a.number);
|
||||
}
|
||||
|
||||
rememberMessage(messageId: number, prNumber: number): void {
|
||||
this.state.messages[String(messageId)] = prNumber;
|
||||
}
|
||||
|
||||
prForMessage(messageId: number | undefined): number | undefined {
|
||||
if (messageId === undefined) return undefined;
|
||||
return this.state.messages[String(messageId)];
|
||||
}
|
||||
|
||||
private pruneMessageMap(): void {
|
||||
const keys = Object.keys(this.state.messages);
|
||||
if (keys.length <= MAX_MESSAGE_MAP) return;
|
||||
// Message ids grow monotonically per chat; drop the oldest.
|
||||
keys.sort((a, b) => Number(a) - Number(b));
|
||||
for (const key of keys.slice(0, keys.length - MAX_MESSAGE_MAP)) delete this.state.messages[key];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @fileoverview Minimal Telegram Bot API client (long polling, no webhook: the box sits
|
||||
* behind Tailscale) plus the pure command / callback parsers.
|
||||
*
|
||||
* Only updates from the configured chat are ever acted on; everything else is dropped
|
||||
* without an answer, so a stranger who finds the bot gets silence, not a menu.
|
||||
*/
|
||||
|
||||
export interface TelegramMessage {
|
||||
message_id: number;
|
||||
chat: { id: number | string };
|
||||
from?: { id: number; username?: string };
|
||||
text?: string;
|
||||
reply_to_message?: { message_id: number; text?: string };
|
||||
}
|
||||
|
||||
export interface TelegramCallbackQuery {
|
||||
id: string;
|
||||
from: { id: number; username?: string };
|
||||
message?: TelegramMessage;
|
||||
data?: string;
|
||||
}
|
||||
|
||||
export interface TelegramUpdate {
|
||||
update_id: number;
|
||||
message?: TelegramMessage;
|
||||
callback_query?: TelegramCallbackQuery;
|
||||
}
|
||||
|
||||
export interface SendOptions {
|
||||
replyMarkup?: unknown;
|
||||
replyToMessageId?: number;
|
||||
disablePreview?: boolean;
|
||||
}
|
||||
|
||||
export class TelegramClient {
|
||||
private readonly base: string;
|
||||
|
||||
constructor(
|
||||
token: string,
|
||||
private readonly chatId: string
|
||||
) {
|
||||
this.base = `https://api.telegram.org/bot${token}`;
|
||||
}
|
||||
|
||||
private async call<T>(method: string, body?: Record<string, unknown>, timeoutMs = 30_000): Promise<T> {
|
||||
const res = await fetch(`${this.base}/${method}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
const json = (await res.json()) as { ok: boolean; result?: T; description?: string };
|
||||
if (!json.ok) throw new Error(`telegram ${method}: ${json.description ?? res.status}`);
|
||||
return json.result as T;
|
||||
}
|
||||
|
||||
isOurChat(chatId: number | string | undefined): boolean {
|
||||
return chatId !== undefined && String(chatId) === this.chatId;
|
||||
}
|
||||
|
||||
async getMe(): Promise<{ username?: string }> {
|
||||
return this.call<{ username?: string }>('getMe');
|
||||
}
|
||||
|
||||
async sendMessage(text: string, opts: SendOptions = {}): Promise<number> {
|
||||
const result = await this.call<{ message_id: number }>('sendMessage', {
|
||||
chat_id: this.chatId,
|
||||
text,
|
||||
parse_mode: 'HTML',
|
||||
disable_web_page_preview: opts.disablePreview ?? true,
|
||||
reply_markup: opts.replyMarkup,
|
||||
reply_to_message_id: opts.replyToMessageId,
|
||||
});
|
||||
return result.message_id;
|
||||
}
|
||||
|
||||
/** Plain text, no parse mode: for content the bot did not write (reviewer answers, drafts). */
|
||||
async sendPlain(text: string, opts: SendOptions = {}): Promise<number> {
|
||||
const result = await this.call<{ message_id: number }>('sendMessage', {
|
||||
chat_id: this.chatId,
|
||||
text,
|
||||
disable_web_page_preview: opts.disablePreview ?? true,
|
||||
reply_markup: opts.replyMarkup,
|
||||
reply_to_message_id: opts.replyToMessageId,
|
||||
});
|
||||
return result.message_id;
|
||||
}
|
||||
|
||||
async editReplyMarkup(messageId: number, replyMarkup: unknown): Promise<void> {
|
||||
try {
|
||||
await this.call('editMessageReplyMarkup', {
|
||||
chat_id: this.chatId,
|
||||
message_id: messageId,
|
||||
reply_markup: replyMarkup,
|
||||
});
|
||||
} catch (err) {
|
||||
// "message is not modified" is Telegram's way of saying the keyboard already looks like that.
|
||||
if (!String(err).includes('not modified')) throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMessage(messageId: number): Promise<void> {
|
||||
try {
|
||||
await this.call('deleteMessage', { chat_id: this.chatId, message_id: messageId });
|
||||
} catch {
|
||||
// Already gone, or older than Telegram allows a bot to delete; the message was informational.
|
||||
}
|
||||
}
|
||||
|
||||
async answerCallback(callbackId: string, text?: string): Promise<void> {
|
||||
await this.call('answerCallbackQuery', { callback_query_id: callbackId, text });
|
||||
}
|
||||
|
||||
async sendDocument(filename: string, content: string, caption?: string): Promise<void> {
|
||||
const form = new FormData();
|
||||
form.set('chat_id', this.chatId);
|
||||
if (caption) form.set('caption', caption);
|
||||
form.set('document', new Blob([content], { type: 'text/markdown' }), filename);
|
||||
const res = await fetch(`${this.base}/sendDocument`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
const json = (await res.json()) as { ok: boolean; description?: string };
|
||||
if (!json.ok) throw new Error(`telegram sendDocument: ${json.description ?? res.status}`);
|
||||
}
|
||||
|
||||
async getUpdates(offset: number, timeoutSec: number): Promise<TelegramUpdate[]> {
|
||||
return this.call<TelegramUpdate[]>(
|
||||
'getUpdates',
|
||||
{ offset, timeout: timeoutSec, allowed_updates: ['message', 'callback_query'] },
|
||||
(timeoutSec + 15) * 1000
|
||||
);
|
||||
}
|
||||
|
||||
async setMyCommands(commands: { command: string; description: string }[]): Promise<void> {
|
||||
await this.call('setMyCommands', { commands });
|
||||
}
|
||||
}
|
||||
|
||||
export interface ParsedCommand {
|
||||
command: string;
|
||||
prNumber?: number;
|
||||
rest: string;
|
||||
}
|
||||
|
||||
/** `/merge 381 force` -> {command:'merge', prNumber:381, rest:'force'}; `/help@botname` is handled. */
|
||||
export function parseCommand(text: string | undefined): ParsedCommand | null {
|
||||
if (!text) return null;
|
||||
const m = text.trim().match(/^\/([a-zA-Z_]+)(?:@\w+)?(?:\s+([\s\S]*))?$/);
|
||||
if (!m) return null;
|
||||
const command = m[1].toLowerCase();
|
||||
const argText = (m[2] ?? '').trim();
|
||||
const numMatch = argText.match(/^#?(\d+)\b\s*([\s\S]*)$/);
|
||||
if (numMatch) return { command, prNumber: parseInt(numMatch[1], 10), rest: numMatch[2].trim() };
|
||||
return { command, rest: argText };
|
||||
}
|
||||
|
||||
export interface ParsedCallback {
|
||||
action: string;
|
||||
prNumber: number;
|
||||
nonce?: string;
|
||||
/** For confirm/cancel: the action being confirmed. */
|
||||
target?: string;
|
||||
}
|
||||
|
||||
export function parseCallback(data: string | undefined): ParsedCallback | null {
|
||||
if (!data) return null;
|
||||
const parts = data.split(':');
|
||||
if (parts[0] === 'confirm' || parts[0] === 'cancel') {
|
||||
if (parts.length !== 4) return null;
|
||||
const prNumber = parseInt(parts[2], 10);
|
||||
if (!Number.isFinite(prNumber)) return null;
|
||||
return { action: parts[0], target: parts[1], prNumber, nonce: parts[3] };
|
||||
}
|
||||
if (parts.length !== 2) return null;
|
||||
const prNumber = parseInt(parts[1], 10);
|
||||
if (!Number.isFinite(prNumber)) return null;
|
||||
return { action: parts[0], prNumber };
|
||||
}
|
||||
|
||||
/** Find the PR number a report message is about, from its first line (`🔍 PR #381 · ...`). */
|
||||
export function prNumberFromMessageText(text: string | undefined): number | null {
|
||||
if (!text) return null;
|
||||
const m = text.match(/PR #(\d+)/);
|
||||
return m ? parseInt(m[1], 10) : null;
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* @fileoverview Per-PR checkouts for the review sessions.
|
||||
*
|
||||
* The maintainer's checkout is SHARED with other agent sessions (CLAUDE.md, Session
|
||||
* Safety), so the bot never runs `git checkout` there. It fetches the PR head into a
|
||||
* private ref (`refs/pr-bot/<n>`) of the main repository, which anchors the objects,
|
||||
* and checks the PR out in a private clone under the bot's own data dir; every
|
||||
* in-tree git command runs with `-C <clone>`.
|
||||
*
|
||||
* Why a `git clone --shared` and not a linked worktree: Claude Code resolves a linked
|
||||
* worktree's project settings through the git common dir, i.e. the MAIN checkout's
|
||||
* `.claude/settings.local.json`, whose model pin then silently overrides anything
|
||||
* written into the worktree (measured 2026-09-05: a worktree pinned to
|
||||
* `claude-fable-5-1` reported `claude-opus-5[1m]`, the main checkout's pin). A shared clone has its own
|
||||
* project root, so Codeman's `modelOverride` and hooks land where the CLI reads them,
|
||||
* while `objects/info/alternates` keeps the object store shared (no duplication).
|
||||
*
|
||||
* Dependencies: a clone has no `node_modules`. When the PR leaves the lockfile
|
||||
* untouched, `node_modules` is a SYMLINK to the main checkout's tree (read-only use:
|
||||
* tsc, vitest, eslint). When the PR changes dependencies, the symlink is unlinked
|
||||
* first and `npm ci` installs a real tree, so npm can never write through the link
|
||||
* into the live server's modules. `src/web/public/vendor` is COPIED per file, never
|
||||
* linked: postinstall regenerates it in place, and a link would let a PR's bundle
|
||||
* overwrite the bundle the production server is serving.
|
||||
*/
|
||||
import { execFile } from 'child_process';
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'fs';
|
||||
import { join } from 'path';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface WorktreeInfo {
|
||||
dir: string;
|
||||
headSha: string;
|
||||
mergeBase: string;
|
||||
deps: 'linked' | 'installed' | 'kept';
|
||||
}
|
||||
|
||||
export type Logger = (msg: string) => void;
|
||||
|
||||
async function git(args: string[], cwd: string, timeoutMs = 120_000): Promise<string> {
|
||||
const { stdout } = await execFileAsync('git', args, { cwd, maxBuffer: 64 * 1024 * 1024, timeout: timeoutMs });
|
||||
return stdout;
|
||||
}
|
||||
|
||||
export function prRef(prNumber: number): string {
|
||||
return `refs/pr-bot/${prNumber}`;
|
||||
}
|
||||
|
||||
/** The upstream master, as fetched into the main repository, mirrored into the clone. */
|
||||
const MASTER_REF = 'refs/remotes/origin/master';
|
||||
|
||||
export function worktreeDirFor(worktreesDir: string, prNumber: number): string {
|
||||
return join(worktreesDir, `pr-${prNumber}`);
|
||||
}
|
||||
|
||||
const DEP_FILES = [
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'packages/xterm-zerolag-input/package.json',
|
||||
'packages/gesture-control/package.json',
|
||||
];
|
||||
|
||||
async function originUrl(mainCheckout: string): Promise<string> {
|
||||
return (await git(['remote', 'get-url', 'origin'], mainCheckout)).trim();
|
||||
}
|
||||
|
||||
/** A linked worktree from the first version of this file: `.git` is a FILE there. */
|
||||
function isLegacyWorktree(dir: string): boolean {
|
||||
const dotGit = join(dir, '.git');
|
||||
try {
|
||||
return statSync(dotGit).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isOwnClone(dir: string): boolean {
|
||||
try {
|
||||
return statSync(join(dir, '.git')).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch the PR head, (re)create the clone at it, and make node_modules usable. */
|
||||
export async function preparePrWorktree(opts: {
|
||||
mainCheckout: string;
|
||||
worktreesDir: string;
|
||||
prNumber: number;
|
||||
/** Reset a reused clone to the fetched head (drops edits a follow-up may have made). */
|
||||
reset: boolean;
|
||||
log: Logger;
|
||||
}): Promise<WorktreeInfo> {
|
||||
const { mainCheckout, worktreesDir, prNumber, log } = opts;
|
||||
const ref = prRef(prNumber);
|
||||
const dir = worktreeDirFor(worktreesDir, prNumber);
|
||||
mkdirSync(worktreesDir, { recursive: true });
|
||||
|
||||
log(`fetching origin master + pull/${prNumber}/head`);
|
||||
await git(
|
||||
['fetch', '--quiet', 'origin', `+refs/heads/master:${MASTER_REF}`, `+refs/pull/${prNumber}/head:${ref}`],
|
||||
mainCheckout,
|
||||
300_000
|
||||
);
|
||||
const headSha = (await git(['rev-parse', ref], mainCheckout)).trim();
|
||||
|
||||
if (existsSync(dir) && isLegacyWorktree(dir)) {
|
||||
log(`replacing the linked worktree at ${dir} with a clone`);
|
||||
await git(['worktree', 'remove', '--force', dir], mainCheckout).catch(() =>
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
);
|
||||
await git(['worktree', 'prune'], mainCheckout);
|
||||
}
|
||||
if (existsSync(dir) && !isOwnClone(dir)) {
|
||||
log(`removing stale directory ${dir}`);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
if (!existsSync(dir)) {
|
||||
log(`cloning (shared objects) into ${dir}`);
|
||||
await git(['clone', '--quiet', '--shared', '--no-checkout', mainCheckout, dir], mainCheckout, 300_000);
|
||||
// `origin` of the clone should mean GitHub, like everywhere else, not the main
|
||||
// checkout's path; the refs below are fetched from the main checkout by path.
|
||||
await git(['remote', 'set-url', 'origin', await originUrl(mainCheckout)], dir);
|
||||
}
|
||||
// Mirror the two refs from the main repository (objects are already reachable via
|
||||
// alternates, so this only moves refs). `+` because both can move backwards.
|
||||
await git(['fetch', '--quiet', mainCheckout, `+${MASTER_REF}:${MASTER_REF}`, `+${ref}:${ref}`], dir);
|
||||
const current = (await git(['rev-parse', '--verify', '--quiet', 'HEAD'], dir).catch(() => '')).trim();
|
||||
if (current !== headSha) {
|
||||
log(`checking out ${headSha.slice(0, 8)}${current ? ` (was ${current.slice(0, 8)})` : ''}`);
|
||||
await git(['checkout', '--quiet', '--detach', ref], dir);
|
||||
}
|
||||
if (opts.reset) {
|
||||
await git(['reset', '--hard', '--quiet', ref], dir);
|
||||
}
|
||||
|
||||
const mergeBase = (await git(['merge-base', MASTER_REF, 'HEAD'], dir)).trim();
|
||||
const deps = await ensureDependencies({ mainCheckout, dir, ref, mergeBase, log });
|
||||
ensureVendorCopy(mainCheckout, dir, log);
|
||||
return { dir, headSha, mergeBase, deps };
|
||||
}
|
||||
|
||||
/** Written into a clone's own node_modules once `npm ci` has finished; its absence means a half install. */
|
||||
const INSTALL_MARKER = '.pr-bot-installed';
|
||||
|
||||
async function ensureDependencies(opts: {
|
||||
mainCheckout: string;
|
||||
dir: string;
|
||||
ref: string;
|
||||
mergeBase: string;
|
||||
log: Logger;
|
||||
}): Promise<WorktreeInfo['deps']> {
|
||||
const { mainCheckout, dir, ref, mergeBase, log } = opts;
|
||||
const target = join(dir, 'node_modules');
|
||||
// Against the MERGE BASE, not master: master's own version bumps since the PR
|
||||
// branched would otherwise make every older PR look like a dependency change and
|
||||
// cost a full npm ci each. Only what the PR itself did to the dependency files counts.
|
||||
let depsChanged = false;
|
||||
try {
|
||||
await git(['diff', '--quiet', mergeBase, ref, '--', ...DEP_FILES], mainCheckout);
|
||||
} catch {
|
||||
depsChanged = true;
|
||||
}
|
||||
|
||||
let existing = existsSync(target) || isSymlink(target) ? lstatSync(target) : null;
|
||||
if (existing?.isDirectory() && !existsSync(join(target, INSTALL_MARKER))) {
|
||||
// A real tree without the marker is an install that was interrupted (service
|
||||
// restart mid `npm ci`); never trust it.
|
||||
log('discarding an incomplete node_modules install');
|
||||
rmSync(target, { recursive: true, force: true });
|
||||
existing = null;
|
||||
}
|
||||
if (!depsChanged) {
|
||||
if (existing?.isSymbolicLink()) return 'linked';
|
||||
if (existing?.isDirectory()) return 'kept';
|
||||
symlinkSync(join(mainCheckout, 'node_modules'), target, 'dir');
|
||||
log('node_modules linked to the main checkout (dependencies unchanged by the PR)');
|
||||
return 'linked';
|
||||
}
|
||||
|
||||
// The PR changes dependencies: a real install, and NEVER through the symlink.
|
||||
if (existing?.isSymbolicLink()) unlinkSync(target);
|
||||
if (existing?.isDirectory()) return 'kept';
|
||||
log('the PR changes dependencies: running npm ci in the clone (this can take minutes)');
|
||||
await execFileAsync('npm', ['ci', '--no-audit', '--no-fund', '--loglevel=error'], {
|
||||
cwd: dir,
|
||||
timeout: 20 * 60_000,
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
writeFileSync(join(target, INSTALL_MARKER), new Date().toISOString());
|
||||
return 'installed';
|
||||
}
|
||||
|
||||
function isSymlink(path: string): boolean {
|
||||
try {
|
||||
return lstatSync(path).isSymbolicLink();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureVendorCopy(mainCheckout: string, dir: string, log: Logger): void {
|
||||
const rel = join('src', 'web', 'public', 'vendor');
|
||||
const src = join(mainCheckout, rel);
|
||||
const dst = join(dir, rel);
|
||||
if (!existsSync(src)) return;
|
||||
// Two of the vendor files are tracked in git, so the directory already exists in a
|
||||
// fresh checkout; copy whatever is MISSING (the postinstall-built xterm bundles).
|
||||
mkdirSync(dst, { recursive: true });
|
||||
let copied = 0;
|
||||
for (const entry of readdirSync(src)) {
|
||||
const target = join(dst, entry);
|
||||
if (existsSync(target)) continue;
|
||||
cpSync(join(src, entry), target, { recursive: true });
|
||||
copied++;
|
||||
}
|
||||
if (copied) log(`${copied} vendor bundle(s) copied from the main checkout`);
|
||||
}
|
||||
|
||||
export async function removePrWorktree(opts: {
|
||||
mainCheckout: string;
|
||||
worktreesDir: string;
|
||||
prNumber: number;
|
||||
log: Logger;
|
||||
}): Promise<void> {
|
||||
const dir = worktreeDirFor(opts.worktreesDir, opts.prNumber);
|
||||
if (existsSync(dir)) {
|
||||
opts.log(`removing ${dir}`);
|
||||
if (isLegacyWorktree(dir)) {
|
||||
await git(['worktree', 'remove', '--force', dir], opts.mainCheckout).catch(() => undefined);
|
||||
await git(['worktree', 'prune'], opts.mainCheckout).catch(() => undefined);
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
try {
|
||||
await git(['update-ref', '-d', prRef(opts.prNumber)], opts.mainCheckout);
|
||||
} catch {
|
||||
// The ref may never have been created; nothing to delete.
|
||||
}
|
||||
}
|
||||
+55
-7
@@ -7,18 +7,25 @@
|
||||
# the repo (the server stages it at ~/.codeman/self-update-runner.sh) — `git
|
||||
# checkout` rewrites the in-repo copy and bash reads scripts lazily.
|
||||
#
|
||||
# ⚠️ The `docker-compose` supervisor is the exception to "outlives": there the
|
||||
# restart IS the container exiting, which kills this script too. That is safe
|
||||
# because the terminal "restarting" marker is written before the kill and the
|
||||
# rebooted server reconciles it — but nothing may be added after that kill.
|
||||
#
|
||||
# Reports progress by writing ~/.codeman/update-status.json atomically; the
|
||||
# browser polls GET /api/system/update/status across the restart drop. The
|
||||
# freshly-booted server reconciles the final "restarting" → "completed"/"failed".
|
||||
#
|
||||
# Cross-platform: restarts via systemd (Linux), launchd (macOS), or prints a
|
||||
# manual command (foreground installs). Linux launches inside a transient
|
||||
# systemd scope so `systemctl restart codeman-web` can't kill it mid-build.
|
||||
# Cross-platform: restarts via systemd (Linux), launchd (macOS), a container exit
|
||||
# under Docker Compose (the restart policy relaunches it), or prints a manual
|
||||
# command (foreground installs). Linux launches inside a transient systemd scope
|
||||
# so `systemctl restart codeman-web` can't kill it mid-build.
|
||||
#
|
||||
# Args (all from the server, never user input — tag is validated server-side):
|
||||
# --repo <dir> --tag <codeman@X.Y.Z> --supervisor <systemd|launchd|none>
|
||||
# --repo <dir> --tag <codeman@X.Y.Z> --supervisor <systemd|launchd|docker-compose|none>
|
||||
# --status-file <path> --update-id <uuid> --from-version <ver> --node <path>
|
||||
# --log <path> [--prev-sha <sha>] [--stash]
|
||||
# --log <path> [--prev-sha <sha>] [--stash] [--server-pid <pid>]
|
||||
# [--restart-by-exit 0|1] (docker-compose only: may we exit the server?)
|
||||
#
|
||||
set -uo pipefail
|
||||
|
||||
@@ -32,6 +39,7 @@ REPO=""
|
||||
TAG=""
|
||||
SUPERVISOR="none"
|
||||
SERVER_PID=""
|
||||
RESTART_BY_EXIT="0"
|
||||
STATUS_FILE=""
|
||||
UPDATE_ID=""
|
||||
FROM_VERSION=""
|
||||
@@ -52,6 +60,7 @@ while [[ $# -gt 0 ]]; do
|
||||
--log) LOG="$2"; shift 2 ;;
|
||||
--prev-sha) PREV_SHA="$2"; shift 2 ;;
|
||||
--server-pid) SERVER_PID="$2"; shift 2 ;;
|
||||
--restart-by-exit) RESTART_BY_EXIT="$2"; shift 2 ;;
|
||||
--stash) DO_STASH=1; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
@@ -144,7 +153,7 @@ rollback_and_fail() {
|
||||
echo "[self-update] $msg — rolling back to ${PREV_SHA:-<none>}"
|
||||
if [[ -n "$PREV_SHA" ]]; then
|
||||
git checkout --force "$PREV_SHA" >/dev/null 2>&1 || true
|
||||
npm install --no-fund --no-audit >/dev/null 2>&1 || true
|
||||
npm install --no-fund --no-audit --include=dev >/dev/null 2>&1 || true
|
||||
npm run build >/dev/null 2>&1 || true
|
||||
fi
|
||||
fail "$msg — rolled back to the previous version" "$msg"
|
||||
@@ -176,7 +185,9 @@ write_status "checkout" "Checking out $TAG…"
|
||||
git -c advice.detachedHead=false checkout --force "$TAG" || rollback_and_fail "Could not check out $TAG"
|
||||
|
||||
# 4) Install dependencies (heartbeat keeps the UI live during this slow step).
|
||||
run_step "installing" "Installing dependencies" npm install --no-fund --no-audit \
|
||||
# --include=dev: tsc and esbuild are devDependencies, and the Compose image sets
|
||||
# NODE_ENV=production, which would otherwise omit them and fail the build below.
|
||||
run_step "installing" "Installing dependencies" npm install --no-fund --no-audit --include=dev \
|
||||
|| rollback_and_fail "Dependency install failed"
|
||||
|
||||
# 5) Build (gate the restart on success — never restart into a torn dist/).
|
||||
@@ -200,6 +211,43 @@ case "$SUPERVISOR" in
|
||||
|| fail "Build succeeded but launchd restart failed" "launchctl"
|
||||
}
|
||||
;;
|
||||
docker-compose)
|
||||
# In the Compose deployment there is no init system to ask: the "restart" is
|
||||
# the server EXITING, so the container's `restart: unless-stopped` policy
|
||||
# relaunches it on the dist/ we just built. The repo and dist/ live on host
|
||||
# mounts, so the new build survives the container being replaced.
|
||||
#
|
||||
# ⚠️ This script dies WITH the container it is restarting — it is a child of
|
||||
# the server process, not a survivor like the systemd-scope path. That is
|
||||
# fine, and load-bearing: the terminal "restarting" marker is already written
|
||||
# above, and the freshly-booted server reconciles it. Nothing may be appended
|
||||
# after the kill that the update depends on.
|
||||
#
|
||||
# ⚠️ The server is signalled by PID rather than `docker restart`: this
|
||||
# container's own Docker CLI talks to the HOST daemon, and a self-directed
|
||||
# restart there races the client's own death. Exiting is the one path that
|
||||
# needs no cooperation from anything outside the container.
|
||||
#
|
||||
# ⚠️ Only when the SERVER said the container comes back (`--restart-by-exit 1`:
|
||||
# the Compose file declared it, or the daemon reported an auto-restart policy).
|
||||
# An unknown policy stages the build and asks for a restart instead. Exiting
|
||||
# blind would take a container the daemon does not restart down for good,
|
||||
# with no UI left to recover it from.
|
||||
if [[ "$RESTART_BY_EXIT" != "1" ]]; then
|
||||
MANUAL_CMD="docker restart \$(hostname) # from the Docker host"
|
||||
write_status "completed-needs-manual-restart" "Update built — restart the Codeman container to apply v$TO_VERSION."
|
||||
echo "[self-update] docker-compose: restart-by-exit not confirmed — not exiting; manual restart required"
|
||||
exit 0
|
||||
fi
|
||||
if [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null; then
|
||||
: # container exit + restart policy take it from here
|
||||
else
|
||||
MANUAL_CMD="docker restart \$(hostname) # from the Docker host"
|
||||
write_status "completed-needs-manual-restart" "Update staged — restart the Codeman container to apply v$TO_VERSION."
|
||||
echo "[self-update] docker-compose: could not signal server pid '$SERVER_PID' — manual restart required"
|
||||
exit 0
|
||||
fi
|
||||
;;
|
||||
launchd-daemon)
|
||||
# System-level KeepAlive LaunchDaemon (headless Mac): kickstarting the system
|
||||
# domain needs root, but we don't need it — kill the server and launchd
|
||||
|
||||
+14
-10
@@ -47,7 +47,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.21.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.22.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
|
||||
@@ -75,8 +75,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.21.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- Codeman agent preamble 1.21.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
grep -qs '^CODEMAN_PREAMBLE=1.22.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- Codeman agent preamble 1.22.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||
@@ -96,7 +96,10 @@ AUTH=(); [ -n "${CODEMAN_PASSWORD:-}" ] && AUTH=(-u "${CODEMAN_USERNAME:-admin}:
|
||||
# draw the lineage. Set once here and every present and future create call carries it;
|
||||
# it is ignored on every other endpoint. Purely cosmetic (see §5.1) and it can never
|
||||
# fail a spawn, so there is no case where you would want to leave it off.
|
||||
CURL=(curl -sk "${AUTH[@]}" -H "X-Codeman-Parent-Session: $SELF")
|
||||
# X-Codeman-Agent-Origin: marks a case directory a spawn CREATES as agent scratch, so the
|
||||
# user can find and delete it long after your workers are gone (§5.14). Same deal: set
|
||||
# once, cosmetic, never fails a spawn, and it labels only directories Codeman creates.
|
||||
CURL=(curl -sk "${AUTH[@]}" -H "X-Codeman-Parent-Session: $SELF" -H "X-Codeman-Agent-Origin: codeman-skill")
|
||||
CID=codeman-agent-1 # FIXED literal, never "agent-$$": see below
|
||||
|
||||
# Fail-CLOSED session delete. The DELETE lives INSIDE the guard on purpose: the older
|
||||
@@ -322,10 +325,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.21.0
|
||||
CODEMAN_PREAMBLE=1.22.0
|
||||
PREAMBLE
|
||||
)
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.21.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.22.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
|
||||
@@ -376,7 +379,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.21.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.22.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'
|
||||
@@ -441,7 +444,8 @@ Four things this block leans on, each one link away, no detour needed to run it:
|
||||
strands the prompt on the composer until a bare `\r` follows: all three are reasons
|
||||
to let `sendwait` build the call rather than hand-rolling it.
|
||||
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
|
||||
- Deleting the sessions does **not** remove the case directories: §5.14.
|
||||
- Deleting the sessions does **not** remove the case directories. They are marked as
|
||||
agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
|
||||
|
||||
### DeepSeek Harness workers
|
||||
|
||||
@@ -494,7 +498,7 @@ One row per job. Acting on this table alone is correct; the §5 links are the de
|
||||
| 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) |
|
||||
| talk to a claude worker directly | `ListAgents` / `SendMessage`, when the feature is on at both ends | [§5.13](reference/verbs.md#513-messaging-claude-workers) |
|
||||
| clean up | `delete_session "$SID"` per id you created. Case directories and git worktrees are **not** removed with it | [§5.14](reference/verbs.md#514-clean-up) |
|
||||
| clean up | `delete_session "$SID"` per id you created. Case directories and git worktrees are **not** removed with it; `GET /api/v1/cases/agent-created` lists the scratch case dirs your spawns left behind, for you to report | [§5.14](reference/verbs.md#514-clean-up) |
|
||||
|
||||
## 3. Rules digest
|
||||
|
||||
@@ -595,7 +599,7 @@ these**; open the one row you actually hit.
|
||||
| [5.11 List and find yourself](reference/verbs.md#511-list-and-find-yourself) | enumerate sessions, or match `$SELF` by prefix |
|
||||
| [5.12 Read My Mind](reference/verbs.md#512-read-my-mind) | read or record what the user wants for a case |
|
||||
| [5.13 Messaging claude workers](reference/verbs.md#513-messaging-claude-workers) | `ListAgents` / `SendMessage` instead of the HTTP path |
|
||||
| [5.14 Clean up](reference/verbs.md#514-clean-up) | what deleting a session does **not** remove |
|
||||
| [5.14 Clean up](reference/verbs.md#514-clean-up) | what deleting a session does **not** remove, and how to list the case dirs you left |
|
||||
|
||||
## 6. Setup and auth
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ---- Codeman agent preamble 1.21.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
# ---- Codeman agent preamble 1.22.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||
@@ -18,7 +18,10 @@ AUTH=(); [ -n "${CODEMAN_PASSWORD:-}" ] && AUTH=(-u "${CODEMAN_USERNAME:-admin}:
|
||||
# draw the lineage. Set once here and every present and future create call carries it;
|
||||
# it is ignored on every other endpoint. Purely cosmetic (see §5.1) and it can never
|
||||
# fail a spawn, so there is no case where you would want to leave it off.
|
||||
CURL=(curl -sk "${AUTH[@]}" -H "X-Codeman-Parent-Session: $SELF")
|
||||
# X-Codeman-Agent-Origin: marks a case directory a spawn CREATES as agent scratch, so the
|
||||
# user can find and delete it long after your workers are gone (§5.14). Same deal: set
|
||||
# once, cosmetic, never fails a spawn, and it labels only directories Codeman creates.
|
||||
CURL=(curl -sk "${AUTH[@]}" -H "X-Codeman-Parent-Session: $SELF" -H "X-Codeman-Agent-Origin: codeman-skill")
|
||||
CID=codeman-agent-1 # FIXED literal, never "agent-$$": see below
|
||||
|
||||
# Fail-CLOSED session delete. The DELETE lives INSIDE the guard on purpose: the older
|
||||
@@ -244,4 +247,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.21.0
|
||||
CODEMAN_PREAMBLE=1.22.0
|
||||
|
||||
@@ -283,6 +283,8 @@ than into an existing checkout.
|
||||
| create a session in an arbitrary directory (no case, **no PTY**, id at `.data.session.id`) | `POST /api/v1/sessions`, then `POST /api/v1/sessions/:id/interactive` or `.../shell` to start it, see [Starting a worker](#starting-a-worker) |
|
||||
| send input | `POST /api/v1/sessions/:id/input` |
|
||||
| **read a worker's answer** (claude/codex/deepseek) | `GET /api/v1/sessions/:id/last-response` → `.data.{text,timestamp}`, clean transcript text, no TUI noise. ⚠️ **Poll it**, see [symptom 7](#7-last-response-returns-an-empty-string-right-after-stop) |
|
||||
| read the whole conversation | `GET /api/v1/sessions/:id/last-response?context=full` → `.data.messages[]`. ⚠️ **Only `{role,text}` is present for every mode.** `kind`/`label` come from claude (`prompt`/`response`), deepseek and the pane parser (which also emit `status`/`tool`) but NOT from codex; `timestamp` from claude and codex but not deepseek/pane; `turn` and `queued:true` (a prompt typed while the agent was working) from claude only. `.data.text` is unchanged by `context=full` — it stays the last assistant message, never `messages[-1]` |
|
||||
| read the last **answered turn** (claude only) | `GET /api/v1/sessions/:id/last-response?context=turn` → `.data.messages[]` holds every assistant message of the most recent turn that has one (the whole answer, not just its final row); `.data.text` is still the last assistant row. Other modes answer `text` only, with no `messages` |
|
||||
| read terminal (tail is in **BYTES**, raw ANSI) | `GET /api/v1/sessions/:id/terminal?tail=3000` → `.data.terminalBuffer`, for *diagnosis* (unsubmitted prompt?), not for reading answers |
|
||||
| full tmux scrollback (context bomb; post-mortems only) | `GET /api/v1/sessions/:id/terminal?full=1` |
|
||||
| background agents, one session | `GET /api/v1/sessions/:id/subagents` |
|
||||
@@ -363,6 +365,15 @@ the global 50, or the per-user 25 in multi-user mode, never the waiter cap),
|
||||
`CONFLICT`, `OPERATION_FAILED` and `INVALID_INPUT`. None of them are retryable in a
|
||||
loop.
|
||||
|
||||
⚠️ A case directory quick-start **creates** for you is labelled agent-created (a
|
||||
`.codeman-agent-case.json` marker, written because the §0 preamble sends
|
||||
`X-Codeman-Agent-Origin`), which is what lets the user find it afterwards:
|
||||
`GET /api/v1/cases/agent-created` returns `.data.cases[]` of
|
||||
`{name, path, createdAt, createdBy, parentSessionId, inUse, modifiedAt}`, newest first,
|
||||
read-only, scoped to the caller's own case space. Report it when you finish; deleting is
|
||||
`DELETE /api/v1/cases/:name` and is the user's call by name ([§5.14](verbs.md#514-clean-up)).
|
||||
A directory that already existed is never labelled.
|
||||
|
||||
⚠️ `caseName` resolves through the linked-cases registry first, so a name that happens
|
||||
to match a case the user linked in lands in that **real repo**, not a fresh scratch
|
||||
directory. Pick distinctive scratch names, and use a linked name deliberately when you
|
||||
|
||||
@@ -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.18.3 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.22.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
|
||||
|
||||
@@ -407,7 +407,17 @@ done
|
||||
printf '%s\n' "$TXT"
|
||||
```
|
||||
|
||||
`.data` is `{text, timestamp}`. ⚠️ **On a hook-less workspace this reads the PREVIOUS
|
||||
`.data` is `{text, timestamp}`. Add `?context=full` for the whole conversation in
|
||||
`.data.messages[]`. ⚠️ **The four readers do not emit the same fields — only `{role, text}`
|
||||
is guaranteed.** `kind`/`label` come from claude (`prompt`/`response`), deepseek and the pane
|
||||
parser (the last two also emit `status`/`tool`), but **not** from codex; `timestamp` comes
|
||||
from claude and codex but not from deepseek or the pane parser. A claude worker additionally
|
||||
carries `turn` (a run of same-speaker messages inside one `turn` is one utterance split into
|
||||
segments, not separate exchanges) and `queued: true` on a prompt the user typed while the
|
||||
agent was still working. Filter on `role`, not on `kind`, unless you know the mode.
|
||||
`.data.text` does not change under `context=full`: it stays the
|
||||
last **assistant** message, so never read it as `messages[-1]`, which can be a prompt.
|
||||
⚠️ **On a hook-less workspace this reads the PREVIOUS
|
||||
turn.** `last-response` returns whatever the transcript last flushed, so it is only as
|
||||
correct as your end-of-turn signal: pair it with a `stop` signal or a marker, never
|
||||
with a bare `idle` ([§5.1](#51-where-to-spawn)). ⚠️ **Poll it, do not read it once.** `text` is written
|
||||
@@ -721,6 +731,21 @@ Deleting a session ends the agent and its pane. It does **not** remove:
|
||||
it, and ask before running `git worktree remove`, which discards uncommitted work
|
||||
inside it.
|
||||
|
||||
Those case directories are **labelled** rather than left anonymous. A directory
|
||||
`quick-start` creates for a spawn carrying the preamble's `X-Codeman-Agent-Origin`
|
||||
header gets a `.codeman-agent-case.json` marker, which is what puts it in the web UI's
|
||||
agent-case cleanup list (Add Case → Manage) and in:
|
||||
|
||||
```bash
|
||||
"${CURL[@]}" "$API/api/v1/cases/agent-created" | jq -r '.data.cases[] | "\(.name)\t\(.createdAt)\tinUse=\(.inUse)"'
|
||||
```
|
||||
|
||||
Read-only, scoped to the user's own case space, and `inUse` is true while a live
|
||||
session is still working in that directory. Report that list when you finish a run
|
||||
with workers, so the user knows exactly what to sweep; the deletion is still theirs to
|
||||
ask for by name. Only a directory Codeman **created** is ever labelled, so a linked
|
||||
case, a cloned repo or a worktree never appears there.
|
||||
|
||||
Confirm cleanup with `GET /api/v1/sessions`, never with `/api/v1/sessions/unified`
|
||||
(that one folds in transcript history from the whole machine and will keep showing
|
||||
your worker forever).
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* @fileoverview The marker file that records a case directory as one Codeman scaffolded
|
||||
* FOR an agent-spawned session, so scratch worker workspaces can be told apart from the
|
||||
* user's real projects long after the sessions that created them are gone.
|
||||
*
|
||||
* Why a file in the case directory rather than a central registry in `~/.codeman`:
|
||||
* the thing being labelled is a directory on the user's disk, and the label has to
|
||||
* survive everything that can happen to Codeman's own state (a wiped data dir, a
|
||||
* different instance, a hand-moved case). A registry would also need stale-entry
|
||||
* pruning and owner scoping of its own, while a marker is deleted by the same `rm -rf`
|
||||
* that deletes the case, and is discoverable by a user who just runs `ls -a`.
|
||||
*
|
||||
* ⚠️ Written ONLY on the path that CREATES the directory (`POST /api/quick-start`'s
|
||||
* `!existsSync` branch). A linked case, a cloned repo, a git worktree or any other
|
||||
* pre-existing directory must never be labelled agent-created: the label drives a
|
||||
* cleanup affordance, and mislabelling someone's repo there is the one failure mode
|
||||
* that costs real work. `POST /api/sessions` takes an existing `workingDir` and so
|
||||
* writes no marker at all, by construction.
|
||||
*
|
||||
* ⚠️ Reading is strict and total: anything that does not parse as a version-1 marker
|
||||
* (truncated write, hand-edited junk, a user's unrelated file of the same name) reads
|
||||
* as "not agent-created" rather than as a partially-trusted entry. A marker is
|
||||
* metadata; deleting the file is the supported way to adopt a scratch case as a real
|
||||
* one, which is what the `note` field written into it tells the user.
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/** Marker filename inside the case directory. Dot-prefixed so it stays out of the way. */
|
||||
export const AGENT_CASE_MARKER_FILE = '.codeman-agent-case.json';
|
||||
|
||||
/** Current marker schema version. A marker of any other version reads as absent. */
|
||||
export const AGENT_CASE_MARKER_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Origin recorded when a create request carried a resolvable spawning session but no
|
||||
* explicit origin of its own (an agent driving the API by hand, or an older copy of
|
||||
* the skill). Nothing in the browser UI sets lineage, so this really does mean "another
|
||||
* session spawned this", not "a human clicked Run".
|
||||
*/
|
||||
export const AGENT_ORIGIN_SPAWNED_BY_SESSION = 'agent-session';
|
||||
|
||||
/** Origin the packaged agent skill sends on its shared curl invocation. */
|
||||
export const AGENT_ORIGIN_CODEMAN_SKILL = 'codeman-skill';
|
||||
|
||||
/** Longest accepted origin token (the value is echoed into the UI and the marker). */
|
||||
const MAX_ORIGIN_LENGTH = 32;
|
||||
|
||||
/** Longest accepted free-text field read back out of a marker. */
|
||||
const MAX_MARKER_FIELD_LENGTH = 200;
|
||||
|
||||
/** Lowercase token: what an origin may look like on the wire and on disk. */
|
||||
const AGENT_ORIGIN_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
||||
|
||||
/** Explains the file to whoever finds it in their case directory. */
|
||||
const MARKER_NOTE =
|
||||
'Created by a Codeman agent worker (see the Manage tab in Add Case). ' +
|
||||
'Delete this file to keep the case out of the agent-case cleanup list; ' +
|
||||
'deleting the whole directory removes the case.';
|
||||
|
||||
/**
|
||||
* What a case directory records about the agent spawn that created it.
|
||||
* Every field beyond `version`/`createdAt`/`createdBy` is decoration for the cleanup UI.
|
||||
*/
|
||||
export interface AgentCaseMarker {
|
||||
version: typeof AGENT_CASE_MARKER_VERSION;
|
||||
/** ISO timestamp of the spawn that created the directory. */
|
||||
createdAt: string;
|
||||
/** Who asked: `codeman-skill`, `agent-session`, or another caller's own token. */
|
||||
createdBy: string;
|
||||
/** Full id of the session that spawned the worker, when one resolved. */
|
||||
parentSessionId?: string;
|
||||
/** That session's display name at spawn time, so the user recognises it later. */
|
||||
parentSessionName?: string;
|
||||
/** Run mode the worker was started in (`claude`, `deepseek`, …). */
|
||||
mode?: string;
|
||||
/** Owner the case was created for, in multi-user mode. */
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an origin token coming off the wire (`agentOrigin` body field or the
|
||||
* `X-Codeman-Agent-Origin` header). Returns `undefined` for anything that is not a
|
||||
* short lowercase token — the value reaches the UI and a JSON file, so it is
|
||||
* allowlisted rather than escaped at each use.
|
||||
*/
|
||||
export function normalizeAgentOrigin(raw: unknown): string | undefined {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
const value = raw.trim().toLowerCase();
|
||||
if (!value || value.length > MAX_ORIGIN_LENGTH) return undefined;
|
||||
return AGENT_ORIGIN_PATTERN.test(value) ? value : undefined;
|
||||
}
|
||||
|
||||
/** Trim an optional free-text marker field to something safe to store and render. */
|
||||
function normalizeField(raw: unknown): string | undefined {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
const value = raw.trim();
|
||||
return value ? value.slice(0, MAX_MARKER_FIELD_LENGTH) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a marker from a spawn's details. Pure, so the route can hand it straight to
|
||||
* the writer and the tests can assert on the shape without touching a disk.
|
||||
*/
|
||||
export function buildAgentCaseMarker(input: {
|
||||
createdBy: string;
|
||||
createdAt?: Date;
|
||||
parentSessionId?: string;
|
||||
parentSessionName?: string;
|
||||
mode?: string;
|
||||
owner?: string;
|
||||
}): AgentCaseMarker {
|
||||
const marker: AgentCaseMarker = {
|
||||
version: AGENT_CASE_MARKER_VERSION,
|
||||
createdAt: (input.createdAt ?? new Date()).toISOString(),
|
||||
createdBy: normalizeAgentOrigin(input.createdBy) ?? AGENT_ORIGIN_SPAWNED_BY_SESSION,
|
||||
};
|
||||
const parentSessionId = normalizeField(input.parentSessionId);
|
||||
const parentSessionName = normalizeField(input.parentSessionName);
|
||||
const mode = normalizeField(input.mode);
|
||||
const owner = normalizeField(input.owner);
|
||||
if (parentSessionId) marker.parentSessionId = parentSessionId;
|
||||
if (parentSessionName) marker.parentSessionName = parentSessionName;
|
||||
if (mode) marker.mode = mode;
|
||||
if (owner) marker.owner = owner;
|
||||
return marker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse marker JSON. Returns `null` for anything that is not a well-formed version-1
|
||||
* marker, including a valid-JSON object of the wrong shape — see the strictness note
|
||||
* in the file header.
|
||||
*/
|
||||
export function parseAgentCaseMarker(raw: string): AgentCaseMarker | null {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.version !== AGENT_CASE_MARKER_VERSION) return null;
|
||||
|
||||
const createdAt = normalizeField(record.createdAt);
|
||||
const createdBy = normalizeAgentOrigin(record.createdBy);
|
||||
if (!createdAt || !createdBy || Number.isNaN(Date.parse(createdAt))) return null;
|
||||
|
||||
return buildAgentCaseMarker({
|
||||
createdBy,
|
||||
createdAt: new Date(createdAt),
|
||||
parentSessionId: normalizeField(record.parentSessionId),
|
||||
parentSessionName: normalizeField(record.parentSessionName),
|
||||
mode: normalizeField(record.mode),
|
||||
owner: normalizeField(record.owner),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the marker into `casePath`. Best-effort by design: the marker is metadata for
|
||||
* a later cleanup, and a failed write must never fail the worker spawn that is the
|
||||
* point of the request. Returns whether it landed.
|
||||
*/
|
||||
export async function writeAgentCaseMarker(casePath: string, marker: AgentCaseMarker): Promise<boolean> {
|
||||
try {
|
||||
const body = JSON.stringify({ ...marker, note: MARKER_NOTE }, null, 2);
|
||||
await writeFile(join(casePath, AGENT_CASE_MARKER_FILE), `${body}\n`, 'utf-8');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the marker out of `casePath`, or `null` if there isn't a valid one. */
|
||||
export async function readAgentCaseMarker(casePath: string): Promise<AgentCaseMarker | null> {
|
||||
try {
|
||||
return parseAgentCaseMarker(await readFile(join(casePath, AGENT_CASE_MARKER_FILE), 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+24
-2
@@ -16,6 +16,7 @@ import { isAbsolute, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import { casePath } from './config/cases-dir.js';
|
||||
import { assertValidBasePath } from './config/base-path.js';
|
||||
import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js';
|
||||
import { getSessionManager } from './session-manager.js';
|
||||
import { getTaskQueue } from './task-queue.js';
|
||||
@@ -843,6 +844,11 @@ function addWebLaunchOptions(cmd: Command): Command {
|
||||
.option('-H, --host <host>', 'Host to bind to', process.env.CODEMAN_HOST || '127.0.0.1')
|
||||
.option('-p, --port <port>', 'Port to listen on (env: CODEMAN_PORT)', process.env.CODEMAN_PORT || '3000')
|
||||
.option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)')
|
||||
.option(
|
||||
'--base-url <path>',
|
||||
'Sub-path Codeman is mounted under behind a reverse proxy, e.g. /codeman (env: CODEMAN_BASE_URL)',
|
||||
process.env.CODEMAN_BASE_URL || '/'
|
||||
)
|
||||
.option('--title-hostname <hostname>', 'Override the hostname shown in the browser title')
|
||||
.option(
|
||||
'--allow-unauthenticated-network',
|
||||
@@ -859,6 +865,7 @@ function toWebLaunchOptions(options: {
|
||||
host: string;
|
||||
port: string;
|
||||
https?: boolean;
|
||||
baseUrl?: string;
|
||||
titleHostname?: string;
|
||||
allowUnauthenticatedNetwork?: boolean;
|
||||
multiuser?: boolean;
|
||||
@@ -868,10 +875,18 @@ function toWebLaunchOptions(options: {
|
||||
console.error(palette.err(`✗ Invalid port: ${options.port}`));
|
||||
process.exit(1);
|
||||
}
|
||||
let basePath: string;
|
||||
try {
|
||||
basePath = assertValidBasePath(options.baseUrl);
|
||||
} catch (err) {
|
||||
console.error(palette.err(`✗ ${err instanceof Error ? err.message : String(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
return {
|
||||
host: options.host,
|
||||
port,
|
||||
https: !!options.https,
|
||||
basePath,
|
||||
titleHostname: options.titleHostname,
|
||||
allowUnauthenticatedNetwork: !!options.allowUnauthenticatedNetwork,
|
||||
multiuser: !!options.multiuser,
|
||||
@@ -961,14 +976,21 @@ webCmd.action(async (options) => {
|
||||
const https = launch.https;
|
||||
const titleHostname = options.titleHostname;
|
||||
const allowUnauthenticatedNetwork = launch.allowUnauthenticatedNetwork ?? false;
|
||||
const basePath = launch.basePath ?? '';
|
||||
// Single source of truth for subsystems that read it directly (e.g. renderers).
|
||||
if (basePath) process.env.CODEMAN_BASE_URL = basePath;
|
||||
const displayHost = host === '0.0.0.0' ? 'localhost' : host;
|
||||
|
||||
console.log(palette.info(`Starting Codeman web interface on ${displayHost}:${port}${https ? ' (HTTPS)' : ''}...`));
|
||||
console.log(
|
||||
palette.info(
|
||||
`Starting Codeman web interface on ${displayHost}:${port}${basePath ? basePath + '/' : ''}${https ? ' (HTTPS)' : ''}...`
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
// The server prints its own "running at" line (it also covers the daemon and
|
||||
// service launch paths), so this one used to be a duplicate of it.
|
||||
const server = await startWebServer(port, https, false, host, titleHostname, allowUnauthenticatedNetwork);
|
||||
const server = await startWebServer(port, https, false, host, titleHostname, allowUnauthenticatedNetwork, basePath);
|
||||
if (https) {
|
||||
console.log(palette.warn(' Note: Accept the self-signed certificate in your browser on first visit'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* @fileoverview Scan `~/.codex/sessions/<yyyy>/<mm>/<dd>/rollout-*.jsonl` for Past
|
||||
* Sessions rows, the codex analog of what `scanOmpSessionsHistory()`
|
||||
* (omp-transcript.ts) does for omp and `scanProjectDir()` (session-routes.ts)
|
||||
* does for Claude's own `~/.claude/projects` transcripts.
|
||||
*
|
||||
* Without this a codex conversation is invisible to Codeman the moment its
|
||||
* session record goes away, even though codex itself never forgot it: the
|
||||
* unified list is built from `~/.claude/projects` plus omp's own store, and
|
||||
* codex writes to neither. A user who wanted to pick a codex thread back up had
|
||||
* to find its id by hand and pass `codexConfig.resumeSessionId` to the API.
|
||||
*
|
||||
* ## Why this reads windows rather than whole files
|
||||
*
|
||||
* An omp session file is the conversation only, so its scanner reads each file
|
||||
* whole. A codex rollout is not comparable: it carries every reasoning block and
|
||||
* every tool call, and its `session_meta` line alone embeds the full base
|
||||
* instructions. Measured on a real store of 519 rollouts, the median file is
|
||||
* 407 KiB, the 90th percentile 1.3 MiB and the largest 25 MiB, for 381 MiB in
|
||||
* total. So this reads a head window for the identity and the opening prompt,
|
||||
* and a tail window for the most recent one.
|
||||
*
|
||||
* The head budget is 128 KiB because `session_meta` runs to roughly 19 KiB and
|
||||
* the first real user message lands near 69 KiB behind it, both measured on
|
||||
* codex 0.152.1.
|
||||
*
|
||||
* ## Where the prompt text comes from
|
||||
*
|
||||
* Codex has emitted user input under three shapes, and this reads all of them,
|
||||
* preferring the ones that carry real input only:
|
||||
*
|
||||
* - `event_msg` / `item_completed` with an `item.type` of `UserMessage`, which
|
||||
* is what codex 0.152.1 writes.
|
||||
* - `event_msg` / `user_message`, which older versions wrote.
|
||||
* - `response_item` rows with `role: 'user'`, the last resort. These mix real
|
||||
* input with injected context (AGENTS.md, environment context, compaction
|
||||
* summaries), so they are read only when neither shape above appears, and
|
||||
* the obvious injections are dropped.
|
||||
*
|
||||
* @module codex-transcript
|
||||
*/
|
||||
|
||||
import { open, readdir, stat } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { LRUMap } from './utils/lru-map.js';
|
||||
|
||||
/** Covers `session_meta` (~19 KiB) plus the first user message (~69 KiB behind it). */
|
||||
const HEAD_BYTES = 131072;
|
||||
|
||||
/** Enough to hold the last few turns' worth of lines without re-reading the file. */
|
||||
const TAIL_BYTES = 65536;
|
||||
|
||||
/**
|
||||
* Newest rollouts to REPORT. Counted in emitted rows, not files scanned: the
|
||||
* store is mostly sub-agent threads this never returns, so capping files first
|
||||
* would spend the budget on rows nobody sees.
|
||||
*/
|
||||
const MAX_ROLLOUTS = 400;
|
||||
|
||||
/**
|
||||
* How many emitted rows also get a tail read for `lastPrompt`. The head read is
|
||||
* cached (see below) but the tail cannot be, because appending to a rollout is
|
||||
* exactly what changes it, so this is the one genuinely per-request cost and it
|
||||
* stays bounded. Counted in emitted rows for the same reason as above — against
|
||||
* file index a store of sub-agent threads spends the whole budget before the
|
||||
* first row that needed it.
|
||||
*/
|
||||
const MAX_TAIL_READS = 100;
|
||||
|
||||
/** Directory nesting under `sessions/` is year/month/day; stop well past that. */
|
||||
const MAX_WALK_DEPTH = 5;
|
||||
|
||||
/** A rollout shorter than this cannot hold a complete `session_meta` line. */
|
||||
const MIN_ROLLOUT_BYTES = 100;
|
||||
|
||||
export interface CodexHistorySession {
|
||||
/** The rollout's own thread id — the token `codex resume <id>` expects. */
|
||||
sessionId: string;
|
||||
/**
|
||||
* `session_meta.originator`, which codex stamps from
|
||||
* CODEX_INTERNAL_ORIGINATOR_OVERRIDE — `codeman_<sessionId>` for every pane
|
||||
* Codeman spawns. The only link between a FRESH codex pane and the rollout it
|
||||
* is writing, since such a pane knows no thread id of its own.
|
||||
*/
|
||||
originator?: string;
|
||||
workingDir: string;
|
||||
sizeBytes: number;
|
||||
/** ISO timestamp, from the file's own mtime. */
|
||||
lastModified: string;
|
||||
firstPrompt?: string;
|
||||
lastPrompt?: string;
|
||||
}
|
||||
|
||||
/** The half of a rollout that never changes once codex has written it. */
|
||||
interface RolloutIdentity {
|
||||
threadId?: string;
|
||||
cwd?: string;
|
||||
/** `'subagent'` marks a thread codex spawned for itself. */
|
||||
threadSource?: string;
|
||||
/** `codeman_<sessionId>` for a pane Codeman spawned; codex's own default otherwise. */
|
||||
originator?: string;
|
||||
firstPrompt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `session_meta` is written once and never rewritten — the same fact
|
||||
* `readCodexRolloutMetaCached()` in session-routes.ts relies on — so a path's
|
||||
* identity is cached, and a rescan costs a `stat` per file plus head reads for
|
||||
* rollouts this process has not seen before.
|
||||
*
|
||||
* ⚠️ The first user message is NOT written up front: codex writes it when the
|
||||
* user submits. Caching before then pins `firstPrompt: undefined` for the life
|
||||
* of the process, and every scan of the home screen, the command palette and the
|
||||
* search-index refresh can land in that window — so the row reads as having no
|
||||
* prompt until a restart. `shouldCacheIdentity()` is the guard.
|
||||
*
|
||||
* Bounded, unlike a plain Map: this process runs for days and every sub-agent
|
||||
* rollout adds an entry. Same reason and same size as `codexRolloutMetaCache`.
|
||||
*/
|
||||
const identityCache = new LRUMap<string, RolloutIdentity>({ maxSize: 4096 });
|
||||
|
||||
/**
|
||||
* Is this identity settled enough to keep?
|
||||
*
|
||||
* A known `firstPrompt` settles it. So does a head read that FILLED its window,
|
||||
* which means the prompt is genuinely not in the first `HEAD_BYTES` rather than
|
||||
* not written yet. A short file with no prompt is the ambiguous case — codex is
|
||||
* still to write one — so that one is re-read next scan.
|
||||
*/
|
||||
function shouldCacheIdentity(identity: RolloutIdentity, fileSize: number): boolean {
|
||||
if (!identity.threadId) return false;
|
||||
return identity.firstPrompt !== undefined || fileSize >= HEAD_BYTES;
|
||||
}
|
||||
|
||||
function codexSessionsRoot(): string {
|
||||
const home = process.env.CODEX_HOME || join(homedir(), '.codex');
|
||||
return join(home, 'sessions');
|
||||
}
|
||||
|
||||
/** Read at most `bytes` from the front of a file. Returns '' when unreadable. */
|
||||
async function readHead(path: string, bytes: number): Promise<string> {
|
||||
const fh = await open(path, 'r').catch(() => null);
|
||||
if (!fh) return '';
|
||||
try {
|
||||
const buf = Buffer.alloc(bytes);
|
||||
const { bytesRead } = await fh.read(buf, 0, bytes, 0);
|
||||
return buf.subarray(0, bytesRead).toString('utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
} finally {
|
||||
await fh.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read at most `bytes` from the end of a file, dropping the leading partial
|
||||
* line so every line handed back parses.
|
||||
*/
|
||||
async function readTail(path: string, size: number, bytes: number): Promise<string> {
|
||||
const fh = await open(path, 'r').catch(() => null);
|
||||
if (!fh) return '';
|
||||
try {
|
||||
const want = Math.min(bytes, size);
|
||||
const buf = Buffer.alloc(want);
|
||||
const { bytesRead } = await fh.read(buf, 0, want, size - want);
|
||||
const text = buf.subarray(0, bytesRead).toString('utf-8');
|
||||
if (want >= size) return text; // whole file, nothing was cut
|
||||
const nl = text.indexOf('\n');
|
||||
return nl === -1 ? '' : text.slice(nl + 1);
|
||||
} catch {
|
||||
return '';
|
||||
} finally {
|
||||
await fh.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Flatten codex's message content, which is a string or an array of text blocks. */
|
||||
function contentText(content: unknown): string {
|
||||
if (typeof content === 'string') return content.trim();
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.filter(
|
||||
(b): b is { text: string } => !!b && typeof b === 'object' && typeof (b as { text?: unknown }).text === 'string'
|
||||
)
|
||||
.map((b) => b.text)
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** One line's user-prompt text, whichever of the three shapes it is. */
|
||||
function userPromptFromLine(entry: {
|
||||
type?: string;
|
||||
payload?: {
|
||||
type?: string;
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
message?: unknown;
|
||||
item?: { type?: string; content?: unknown };
|
||||
};
|
||||
}): { text: string; injectionProne: boolean } | null {
|
||||
const p = entry.payload;
|
||||
if (!p) return null;
|
||||
|
||||
if (entry.type === 'event_msg' && p.type === 'item_completed' && p.item?.type === 'UserMessage') {
|
||||
const text = contentText(p.item.content);
|
||||
return text ? { text, injectionProne: false } : null;
|
||||
}
|
||||
if (entry.type === 'event_msg' && p.type === 'user_message') {
|
||||
const text = typeof p.message === 'string' ? p.message.trim() : contentText(p.message);
|
||||
return text ? { text, injectionProne: false } : null;
|
||||
}
|
||||
if (entry.type === 'response_item' && p.role === 'user') {
|
||||
const text = contentText(p.content);
|
||||
return text ? { text, injectionProne: true } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injected context rather than something the user typed. Codex prepends the
|
||||
* repository's AGENTS.md and wraps environment context in a tag, and both arrive
|
||||
* as `response_item` user rows.
|
||||
*/
|
||||
function isInjectedContext(text: string): boolean {
|
||||
return text.startsWith('#') || text.startsWith('<');
|
||||
}
|
||||
|
||||
/** Collapse to one line and cap, so a row carries a title rather than an essay. */
|
||||
function asPreview(text: string): string {
|
||||
const flat = text.replace(/\s+/g, ' ').trim();
|
||||
return flat.length > 200 ? `${flat.slice(0, 200)}…` : flat;
|
||||
}
|
||||
|
||||
/** Parse a head window into the facts about a rollout that never change. */
|
||||
function parseIdentity(head: string): RolloutIdentity {
|
||||
const out: RolloutIdentity = {};
|
||||
let fallback: string | undefined;
|
||||
for (const line of head.split('\n')) {
|
||||
if (!line) continue;
|
||||
let entry: {
|
||||
type?: string;
|
||||
payload?: {
|
||||
id?: string;
|
||||
session_id?: string;
|
||||
cwd?: string;
|
||||
thread_source?: string;
|
||||
originator?: string;
|
||||
type?: string;
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
message?: unknown;
|
||||
item?: { type?: string; content?: unknown };
|
||||
};
|
||||
};
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
continue; // truncated tail of the window, or a malformed line
|
||||
}
|
||||
const p = entry.payload;
|
||||
if (entry.type === 'session_meta' && p) {
|
||||
out.threadId ??= p.id || p.session_id;
|
||||
out.cwd ??= p.cwd;
|
||||
out.threadSource ??= p.thread_source;
|
||||
out.originator ??= p.originator;
|
||||
} else if (entry.type === 'turn_context' && p) {
|
||||
out.cwd ??= p.cwd;
|
||||
}
|
||||
if (out.firstPrompt) continue;
|
||||
const prompt = userPromptFromLine(entry);
|
||||
if (!prompt) continue;
|
||||
if (!prompt.injectionProne) {
|
||||
out.firstPrompt = asPreview(prompt.text);
|
||||
} else if (!fallback && !isInjectedContext(prompt.text)) {
|
||||
fallback = asPreview(prompt.text);
|
||||
}
|
||||
}
|
||||
out.firstPrompt ??= fallback;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The most recent user prompt in a tail window, or undefined. */
|
||||
function parseLastPrompt(tail: string): string | undefined {
|
||||
let best: string | undefined;
|
||||
let fallback: string | undefined;
|
||||
for (const line of tail.split('\n')) {
|
||||
if (!line) continue;
|
||||
try {
|
||||
const prompt = userPromptFromLine(JSON.parse(line));
|
||||
if (!prompt) continue;
|
||||
if (!prompt.injectionProne) best = asPreview(prompt.text);
|
||||
else if (!isInjectedContext(prompt.text)) fallback = asPreview(prompt.text);
|
||||
} catch {
|
||||
// Malformed line — keep scanning.
|
||||
}
|
||||
}
|
||||
return best ?? fallback;
|
||||
}
|
||||
|
||||
/** Every rollout file under `sessions/`, newest first. */
|
||||
async function listRollouts(root: string): Promise<Array<{ path: string; mtimeMs: number; size: number }>> {
|
||||
const files: Array<{ path: string; mtimeMs: number; size: number }> = [];
|
||||
const walk = async (dir: string, depth: number): Promise<void> => {
|
||||
if (depth > MAX_WALK_DEPTH) return;
|
||||
const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
|
||||
if (!entries) return;
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(full, depth + 1);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
|
||||
const st = await stat(full).catch(() => null);
|
||||
if (!st || st.size < MIN_ROLLOUT_BYTES) continue;
|
||||
files.push({ path: full, mtimeMs: st.mtimeMs, size: st.size });
|
||||
}
|
||||
};
|
||||
await walk(root, 0);
|
||||
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex conversations on this host, newest first, for the unified session list.
|
||||
*
|
||||
* Sub-agent threads are left out: codex spawns them for itself, they are not
|
||||
* something a person picks back up, and on a real store they outnumber the
|
||||
* threads that are.
|
||||
*/
|
||||
export async function scanCodexSessionsHistory(): Promise<CodexHistorySession[]> {
|
||||
const files = await listRollouts(codexSessionsRoot());
|
||||
const out: CodexHistorySession[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (out.length >= MAX_ROLLOUTS) break;
|
||||
|
||||
let identity = identityCache.get(file.path);
|
||||
if (!identity) {
|
||||
identity = parseIdentity(await readHead(file.path, HEAD_BYTES));
|
||||
if (shouldCacheIdentity(identity, file.size)) identityCache.set(file.path, identity);
|
||||
}
|
||||
if (!identity.threadId || identity.threadSource === 'subagent') continue;
|
||||
// A row with no directory has nowhere to resume INTO, and emitting an empty
|
||||
// one makes a click post `workingDir: ''`. omp drops such a row; so does this.
|
||||
if (!identity.cwd) continue;
|
||||
|
||||
const lastPrompt =
|
||||
out.length < MAX_TAIL_READS ? parseLastPrompt(await readTail(file.path, file.size, TAIL_BYTES)) : undefined;
|
||||
|
||||
out.push({
|
||||
sessionId: identity.threadId,
|
||||
originator: identity.originator,
|
||||
workingDir: identity.cwd,
|
||||
sizeBytes: file.size,
|
||||
lastModified: new Date(file.mtimeMs).toISOString(),
|
||||
firstPrompt: identity.firstPrompt,
|
||||
lastPrompt: lastPrompt ?? identity.firstPrompt,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which codex thread each Codeman-spawned pane is writing, keyed by Codeman
|
||||
* session id.
|
||||
*
|
||||
* Codeman spawns every codex pane with
|
||||
* CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codeman_<sessionId>, and codex stamps that
|
||||
* into `session_meta.originator`. That is the ONLY link between a fresh codex
|
||||
* pane and the rollout it is writing: such a pane knows no thread id of its own,
|
||||
* so it cannot be folded into its own Past-Sessions row from its own side.
|
||||
*
|
||||
* Newest wins. `/new` typed inside the codex TUI leaves several rollouts sharing
|
||||
* one originator, and the pane is on the most recent — so this expects `rows`
|
||||
* newest-first, as `scanCodexSessionsHistory()` returns them.
|
||||
*/
|
||||
export function codexThreadBySessionId(rows: CodexHistorySession[]): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const row of rows) {
|
||||
const owner = /^codeman_(.+)$/.exec(row.originator ?? '')?.[1];
|
||||
if (owner && !out.has(owner)) out.set(owner, row.sessionId);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Test seam: drop the per-path identity cache. */
|
||||
export function __clearCodexIdentityCache(): void {
|
||||
identityCache.clear();
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @fileoverview Reverse-proxy base-path support — the single source of truth for
|
||||
* the URL prefix Codeman is mounted under.
|
||||
*
|
||||
* When Codeman runs behind a reverse proxy at a sub-path (e.g. `/codeman/`), the
|
||||
* proxy forwards the FULL request path INCLUDING that prefix (it does not strip
|
||||
* it). Every URL the server emits to the browser (the HTML shell, redirects,
|
||||
* the manifest/service-worker) and every URL the browser builds (fetch/SSE/WS)
|
||||
* must therefore carry the prefix too.
|
||||
*
|
||||
* This module normalizes the operator-supplied value (`--base-url` / the
|
||||
* `CODEMAN_BASE_URL` env var) into ONE canonical form used everywhere:
|
||||
* - `''` — mounted at the origin root (the default, `/`)
|
||||
* - `/foo` — mounted at a sub-path (leading slash, NO trailing slash)
|
||||
*
|
||||
* Keeping the normalized form free of a trailing slash means `basePath + '/api/x'`
|
||||
* and `basePath + '/'` both compose cleanly, and `''` degrades to the historical
|
||||
* root behavior with no special-casing at the call sites.
|
||||
*
|
||||
* @module config/base-path
|
||||
*/
|
||||
|
||||
/**
|
||||
* A normalized base path is either empty (root) or one-or-more `/segment`
|
||||
* groups, where a segment is a conservative, proxy-safe subset of path
|
||||
* characters. This deliberately excludes anything that could change routing
|
||||
* meaning (`?`, `#`, `:`, whitespace, `%`) so the prefix is a plain path.
|
||||
*/
|
||||
const VALID_BASE_PATH = /^(?:\/[A-Za-z0-9._~-]+)+$/;
|
||||
|
||||
/**
|
||||
* Normalize an operator-supplied base path into the canonical form.
|
||||
*
|
||||
* Accepts loose input (`codeman`, `/codeman`, `/codeman/`, `//codeman//`) and
|
||||
* returns `''` for root or `/codeman` otherwise. Does NOT validate the character
|
||||
* set — call {@link assertValidBasePath} (or {@link isValidBasePath}) for that.
|
||||
*/
|
||||
export function normalizeBasePath(input: string | undefined | null): string {
|
||||
if (input === undefined || input === null) return '';
|
||||
let p = String(input).trim();
|
||||
if (p === '' || p === '/') return '';
|
||||
if (!p.startsWith('/')) p = '/' + p;
|
||||
p = p.replace(/\/{2,}/g, '/'); // collapse duplicate slashes
|
||||
p = p.replace(/\/+$/, ''); // drop trailing slash(es)
|
||||
return p;
|
||||
}
|
||||
|
||||
/** True if `normalized` is a legal canonical base path (`''` or `/seg[/seg...]`). */
|
||||
export function isValidBasePath(normalized: string): boolean {
|
||||
return normalized === '' || VALID_BASE_PATH.test(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize AND validate, throwing a human-readable error on bad input. Used by
|
||||
* the CLI so a typo (`--base-url /a b`, `--base-url ?x`) fails loudly at startup
|
||||
* instead of silently producing broken URLs.
|
||||
*/
|
||||
export function assertValidBasePath(input: string | undefined | null): string {
|
||||
const normalized = normalizeBasePath(input);
|
||||
if (!isValidBasePath(normalized)) {
|
||||
throw new Error(
|
||||
`Invalid --base-url ${JSON.stringify(input)}: use a plain path like "/codeman" ` +
|
||||
`(letters, digits, and ._~- in each segment).`
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join the base path onto a root-absolute application path (`/api/x` → `/base/api/x`).
|
||||
*
|
||||
* Leaves alone anything that is not a root-absolute app path: empty strings,
|
||||
* protocol-relative (`//host`) and absolute URLs (`http://`, `ws://`, `data:`),
|
||||
* fragments/queries, and paths already carrying the prefix. This is the one
|
||||
* function the whole codebase routes URL construction through.
|
||||
*/
|
||||
export function joinBasePath(basePath: string, path: string): string {
|
||||
if (!basePath) return path;
|
||||
if (typeof path !== 'string' || path.length === 0) return path;
|
||||
if (!path.startsWith('/')) return path; // relative / fragment / query — resolved against <base>
|
||||
if (path.startsWith('//')) return path; // protocol-relative
|
||||
if (path === basePath || path.startsWith(basePath + '/') || path.startsWith(basePath + '?')) {
|
||||
return path; // already prefixed
|
||||
}
|
||||
return basePath + path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the base path off an INCOMING request URL so internal routing stays
|
||||
* prefix-agnostic. Requests that arrive WITHOUT the prefix (health checks,
|
||||
* hooks, the docker bridge — all of which hit the raw port, bypassing the proxy)
|
||||
* are returned unchanged, so the server answers at both `/api/x` and
|
||||
* `/base/api/x`.
|
||||
*/
|
||||
export function stripBasePath(basePath: string, url: string): string {
|
||||
if (!basePath) return url;
|
||||
if (url === basePath) return '/';
|
||||
if (url.startsWith(basePath + '/')) return url.slice(basePath.length);
|
||||
if (url.startsWith(basePath + '?')) return '/' + url.slice(basePath.length);
|
||||
return url;
|
||||
}
|
||||
@@ -112,3 +112,50 @@ export const FILE_PEEK_BYTES = 8 * 1024 - 1; // 8KB (inclusive end offset)
|
||||
* Override: CODEMAN_MAX_PASTE_IMAGE_BYTES (bytes)
|
||||
*/
|
||||
export const MAX_PASTE_IMAGE_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_IMAGE_BYTES || '') || 50 * 1024 * 1024; // 50MB
|
||||
|
||||
// ============================================================================
|
||||
// File Download Limits
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parse a byte-limit env var, where `0` explicitly means "no limit".
|
||||
*
|
||||
* The `parseInt(...) || default` idiom used elsewhere in this file cannot
|
||||
* express that: it treats 0 as falsy and silently restores the default.
|
||||
*/
|
||||
function parseByteLimitEnv(raw: string | undefined, fallback: number): number {
|
||||
if (raw === undefined || raw.trim() === '') return fallback;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return fallback;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum size (bytes) of a file served by the raw/download file routes:
|
||||
* `GET /api/sessions/:id/file-raw` (the Files panel's download link and the
|
||||
* file-preview overlay), the attachment `/raw` route, and `GET /api/download`.
|
||||
*
|
||||
* ⚠️ This is a sanity bound, NOT memory protection. All three bodies are
|
||||
* STREAMED and `Range`-aware (`sendFileBody` in file-routes.ts), so a large
|
||||
* file costs one read stream rather than its size in RSS. The historical 50MB
|
||||
* cap predates that streaming rewrite and its "prevent memory exhaustion"
|
||||
* comment described a `readFile()` that no longer exists — all it did was
|
||||
* refuse legitimate downloads of build artifacts, videos and archives.
|
||||
*
|
||||
* Set `CODEMAN_MAX_DOWNLOAD_BYTES=0` to remove the cap entirely.
|
||||
* Override: CODEMAN_MAX_DOWNLOAD_BYTES (bytes)
|
||||
*/
|
||||
export const MAX_FILE_DOWNLOAD_BYTES = parseByteLimitEnv(
|
||||
process.env.CODEMAN_MAX_DOWNLOAD_BYTES,
|
||||
2 * 1024 * 1024 * 1024 // 2GB
|
||||
);
|
||||
|
||||
/** True when `size` exceeds the download cap (a cap of 0 means unlimited). */
|
||||
export function exceedsDownloadLimit(size: number): boolean {
|
||||
return MAX_FILE_DOWNLOAD_BYTES > 0 && size > MAX_FILE_DOWNLOAD_BYTES;
|
||||
}
|
||||
|
||||
/** Human-readable "File too large (…)" message for a refused download. */
|
||||
export function downloadTooLargeMessage(size: number): string {
|
||||
return `File too large (${Math.round(size / 1024 / 1024)}MB > ${Math.round(MAX_FILE_DOWNLOAD_BYTES / 1024 / 1024)}MB limit). Raise or remove it with CODEMAN_MAX_DOWNLOAD_BYTES (0 = unlimited).`;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { TOKEN_PATTERNS } from './patterns.js';
|
||||
import { compileVersionRegex, TOKEN_PATTERNS } from './patterns.js';
|
||||
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
|
||||
|
||||
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
|
||||
@@ -274,6 +274,23 @@ const capabilitiesSchema = z
|
||||
effort: z.boolean(),
|
||||
agentSkillInjection: z.boolean(),
|
||||
statusLineTelemetry: z.boolean(),
|
||||
workDetect: z
|
||||
.object({
|
||||
promptGlyph: z.string().min(1).max(8),
|
||||
// Config-supplied regex, so it goes through the same guard as `version.regex`:
|
||||
// ~/.codeman/clis.json can set this, and the compiled pattern runs on the PTY
|
||||
// hot path, where a nested quantifier would be a ReDoS against the event loop.
|
||||
// A broken pattern must also fail at LOAD time rather than inside a data handler.
|
||||
workingLine: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(src) => compileVersionRegex(src) !== null,
|
||||
'workingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
|
||||
),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
model: z
|
||||
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
|
||||
.strict(),
|
||||
@@ -322,7 +339,7 @@ const commandLine = z
|
||||
);
|
||||
|
||||
const overlayTargetSchema = z.union([
|
||||
z.object({ command: commandLine.optional() }).strict(),
|
||||
z.object({ command: commandLine.optional(), rootCommand: commandLine.optional() }).strict(),
|
||||
z.object({ disabled: z.literal(true) }).strict(),
|
||||
]);
|
||||
|
||||
|
||||
@@ -183,6 +183,13 @@ const CLAUDE: CliEntry = {
|
||||
},
|
||||
capabilities: {
|
||||
external: false,
|
||||
// The historical hard-coded pair, now stated as data. `workingLine` matches both the
|
||||
// `✻ Actualizing… (39s · ↓ 2.0k tokens)` status line and the bare `esc to interrupt`
|
||||
// footer, because tmux repaints partially and only one of the two may land in a chunk.
|
||||
workDetect: {
|
||||
promptGlyph: '❯',
|
||||
workingLine: String.raw`…\s*\((?:\d+h\s+)?(?:\d+m\s+)?\d+s\b|esc to interrupt`,
|
||||
},
|
||||
requiresMux: false,
|
||||
// Claude installs Codeman's own hooks block into every workspace it runs in, so its
|
||||
// stop/idle signals are unconditional — no per-session veto, unlike deepseek's bridge.
|
||||
@@ -210,7 +217,10 @@ const CLAUDE: CliEntry = {
|
||||
// (no trust-folder/permission prompt that nothing on that side can answer). A per-host
|
||||
// `commands.claude` override, or the docker multi-user clamp, stays the escape hatch.
|
||||
remote: { command: 'claude --dangerously-skip-permissions' },
|
||||
docker: { command: 'claude --dangerously-skip-permissions' },
|
||||
// ⚠️ As root the flag is not merely unnecessary, it is REFUSED ("cannot be used with
|
||||
// root/sudo privileges"), and only inside the container — so an adopted root container
|
||||
// would just show a dead pane. Drop it there and let claude ask.
|
||||
docker: { command: 'claude --dangerously-skip-permissions', rootCommand: 'claude' },
|
||||
// Claude's docker/remote credential handling has its own dedicated code path
|
||||
// (claudeDockerPaneCommand, artifacts at docker-hosts.ts:537-575) — no generic credStore.
|
||||
},
|
||||
@@ -415,6 +425,11 @@ const CODEX: CliEntry = {
|
||||
},
|
||||
capabilities: {
|
||||
...agentDefaults(),
|
||||
// Codex draws `› Ask Codex to do anything` on its composer row and
|
||||
// `Working (2m 49s • esc to interrupt)` above it while a turn runs. It animates no
|
||||
// braille spinner, and it never prints `esc to interrupt` at rest, so that phrase
|
||||
// alone separates a running turn from an idle one.
|
||||
workDetect: { promptGlyph: '›', workingLine: '[Ee]sc to interrupt' },
|
||||
transcript: 'codex-rollout',
|
||||
altScreen: 'strip-full',
|
||||
echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' },
|
||||
|
||||
@@ -306,6 +306,29 @@ export interface CliCapabilities {
|
||||
* independent — see this interface's own doc comment.
|
||||
*/
|
||||
external: boolean;
|
||||
/**
|
||||
* How to read this CLI's own TUI for whether it is mid-turn.
|
||||
*
|
||||
* Codeman infers a working agent from the pane, so the two strings it needs are the
|
||||
* ones that differ per CLI: the glyph on the composer row, and the status line the CLI
|
||||
* draws while a turn runs. Holding them here is what lets a non-Claude CLI report work
|
||||
* at all — `external` used to gate the whole detector, so every external CLI reported
|
||||
* itself permanently idle even mid-turn.
|
||||
*
|
||||
* `promptGlyph` only ARMS the idle confirmation and is never on its own evidence that a
|
||||
* turn ended, because a CLI redraws its composer throughout a turn. `workingLine` is
|
||||
* the evidence, and `_confirmIdle` consults it before believing the pane went quiet.
|
||||
*
|
||||
* An entry that omits this field keeps Codeman's historical behaviour: the Claude glyph
|
||||
* arms the confirmation and the Claude working line answers it. Leave it out for a CLI
|
||||
* whose TUI nobody has characterised, and its sessions report work exactly as before.
|
||||
*/
|
||||
workDetect?: {
|
||||
/** The glyph this CLI draws on its composer row, e.g. Claude's `❯`, Codex's `›`. */
|
||||
promptGlyph: string;
|
||||
/** Source of a regex matching the status line this CLI draws while a turn runs. */
|
||||
workingLine: string;
|
||||
};
|
||||
/** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */
|
||||
requiresMux: boolean;
|
||||
/**
|
||||
@@ -442,7 +465,15 @@ export interface CliOverlays {
|
||||
* all (docker for `shell`) — distinct from "no override", which still gets a default.
|
||||
*/
|
||||
remote?: { command?: string } | { disabled: true };
|
||||
docker?: { command?: string } | { disabled: true };
|
||||
/**
|
||||
* `rootCommand` is the same invocation for a container whose exec user is uid 0. Only
|
||||
* declare it when the normal `command` would be REFUSED as root: claude's carries
|
||||
* `--dangerously-skip-permissions`, which Claude Code rejects outright under root, and
|
||||
* the rejection is visible only inside the container, so the pane dies with no clue on
|
||||
* the outside. Codeman's own base image runs a non-root user and never selects this; an
|
||||
* ADOPTED container belongs to its owner and is frequently root. Absent = use `command`.
|
||||
*/
|
||||
docker?: { command?: string; rootCommand?: string } | { disabled: true };
|
||||
/**
|
||||
* ⚠️ DECLARED-FOR-LATER, unlike `remote`/`docker` above, which are live.
|
||||
*
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface WebLaunchOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
https: boolean;
|
||||
/** Reverse-proxy sub-path prefix (normalized: '' for root, or '/foo'). */
|
||||
basePath?: string;
|
||||
titleHostname?: string;
|
||||
allowUnauthenticatedNetwork?: boolean;
|
||||
multiuser?: boolean;
|
||||
@@ -87,6 +89,7 @@ export interface DaemonStatus {
|
||||
export function buildWebArgs(options: WebLaunchOptions): string[] {
|
||||
const args = ['web', '--host', options.host, '--port', String(options.port)];
|
||||
if (options.https) args.push('--https');
|
||||
if (options.basePath) args.push('--base-url', options.basePath);
|
||||
if (options.titleHostname) args.push('--title-hostname', options.titleHostname);
|
||||
if (options.allowUnauthenticatedNetwork) args.push('--allow-unauthenticated-network');
|
||||
if (options.multiuser) args.push('--multiuser');
|
||||
|
||||
@@ -30,6 +30,7 @@ import { spawn } from 'node:child_process';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import type { DockerEngine, SessionDocker } from './types.js';
|
||||
import { runWithConversionLimit } from './document-conversion-limiter.js';
|
||||
import { isAdoptedContainer } from './docker-hosts.js';
|
||||
|
||||
const IS_TEST_MODE = !!process.env.VITEST;
|
||||
|
||||
@@ -287,7 +288,13 @@ export async function exportDockerCase(params: {
|
||||
const bundlePath = join(exportsDir, exportBundleName(caseName, timestamp, mode));
|
||||
const stageDir = join(exportsDir, `.stage-${caseName}-${timestamp}`);
|
||||
mkdirSync(stageDir, { recursive: true });
|
||||
const wasRunning = await isContainerRunning(argv, docker.containerName);
|
||||
// ⚠️ NEVER pause an ADOPTED container. The freeze exists only to make the committed
|
||||
// image and the workspace tar mutually consistent, and it is a lifecycle mutation on a
|
||||
// container that belongs to the user — it stops their processes for however long the
|
||||
// tar takes. A workspace-only export of an adopted case therefore accepts a live
|
||||
// filesystem, the same guarantee `tar` gives on any running host directory. Full-image
|
||||
// export is refused for an adopted case at the route, before reaching here.
|
||||
const wasRunning = !isAdoptedContainer(docker) && (await isContainerRunning(argv, docker.containerName));
|
||||
let commitTag: string | undefined;
|
||||
|
||||
try {
|
||||
|
||||
+323
-6
@@ -24,7 +24,7 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { getCli } from './config/cli-registry/registry.js';
|
||||
import { enabledCliIds, getCli } from './config/cli-registry/registry.js';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
import { createHash } from 'node:crypto';
|
||||
@@ -55,6 +55,30 @@ export const DEFAULT_AGENT_IMAGE = 'codeman/agent:base';
|
||||
/** HOME inside the base image (the `agent` user). Cred mounts + hook-secret land under it. */
|
||||
export const CONTAINER_HOME = '/home/agent';
|
||||
|
||||
/**
|
||||
* Modes the adoption preflight probes for inside an existing container, derived from the
|
||||
* CLI registry so a newly-enabled CLI is probed without a second list to remember.
|
||||
*
|
||||
* No arm for `shell` here: it declares no binary, so `probeAdoptableContainer` drops it
|
||||
* from the `command -v` list and reports it available unconditionally, which is the same
|
||||
* answer a special case would have produced.
|
||||
*/
|
||||
export function dockerAdoptProbeModes(): SessionMode[] {
|
||||
return enabledCliIds() as SessionMode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The BINARY a mode looks for inside a container. ⚠️ NOT always the mode name:
|
||||
* `antigravity` ships as `agy` and `deepseek` as `dsh`, so probing by mode name
|
||||
* would report those two as missing on a container that has them. Same source
|
||||
* `probeDockerCliVersion` reads, and the same one `defaultDockerCommandForMode`
|
||||
* launches from — a local table here duplicated the registry with nothing
|
||||
* keeping the two in step.
|
||||
*/
|
||||
function containerBinaryFor(mode: SessionMode): string | undefined {
|
||||
return getCli(mode)?.discovery.binaries[0];
|
||||
}
|
||||
|
||||
/** Per-case container name prefix. The `case` letters deliberately do NOT matter to
|
||||
* tmux; this is a DOCKER name (`^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`), and case names are
|
||||
* already validated `^[a-zA-Z0-9_-]+$`, so `codeman-case-<name>` is always valid. */
|
||||
@@ -142,14 +166,21 @@ export function dockerContainerName(caseName: string): string {
|
||||
* nothing keeping the two in step. `shell` is the one arm still written here, because it is
|
||||
* the entry that declares `docker: { disabled: true }` — a container has no per-user login
|
||||
* shell to resolve, so it gets a plain `bash -l` rather than a CLI invocation.
|
||||
*
|
||||
* ⚠️ `runsAsRoot` selects the overlay's `rootCommand` when it declares one. Claude Code
|
||||
* REFUSES `--dangerously-skip-permissions` under uid 0 ("cannot be used with root/sudo
|
||||
* privileges", still true in 2.1.261), and the refusal is only visible INSIDE the
|
||||
* container, so the pane just dies. Our own base image runs a non-root user and never hits
|
||||
* it; an ADOPTED container's user belongs to its owner and is frequently root. Which flag
|
||||
* to drop is a per-CLI fact, so it lives in the registry rather than in a branch here.
|
||||
*/
|
||||
export function defaultDockerCommandForMode(mode: SessionMode): string {
|
||||
export function defaultDockerCommandForMode(mode: SessionMode, runsAsRoot = false): string {
|
||||
const entry = getCli(mode);
|
||||
const overlay = entry?.overlays.docker;
|
||||
if (!entry || (overlay && 'disabled' in overlay)) return 'exec bash -l';
|
||||
// Mirrors the LOCAL default for each CLI; claude's carries
|
||||
// `--dangerously-skip-permissions` so the in-container agent runs non-interactively.
|
||||
const cli = overlay?.command ?? entry.discovery.binaries[0];
|
||||
const cli = (runsAsRoot ? overlay?.rootCommand : undefined) ?? overlay?.command ?? entry.discovery.binaries[0];
|
||||
return cli ? `exec ${cli}` : 'exec bash -l';
|
||||
}
|
||||
|
||||
@@ -253,7 +284,22 @@ export function toSessionDocker(host: DockerHost, dockerCase: DockerCase): Sessi
|
||||
extraCreateArgs: host.extraCreateArgs,
|
||||
extraExecArgs: host.extraExecArgs,
|
||||
};
|
||||
return { ...base, configHash: dockerConfigHash(base) };
|
||||
// `owned` is deliberately applied AFTER the hash: dockerConfigHash() picks an
|
||||
// explicit field list, so ownership can never shift an existing case's hash and
|
||||
// mass-trip the drift gate.
|
||||
const session: SessionDocker = { ...base, configHash: dockerConfigHash(base) };
|
||||
if (dockerCase.owned === false) session.owned = false;
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* An ADOPTED container is one the user built and runs themselves. Codeman may
|
||||
* only exec into it; it must never create, start, stop, restart or remove it.
|
||||
* Every lifecycle branch routes through this one predicate so a new call site
|
||||
* cannot silently opt out.
|
||||
*/
|
||||
export function isAdoptedContainer(docker: Pick<SessionDocker, 'owned'>): boolean {
|
||||
return docker.owned === false;
|
||||
}
|
||||
|
||||
// ========== Shell escaping ==========
|
||||
@@ -754,9 +800,15 @@ export interface DockerDriftStatus {
|
||||
* daemon down) means there is nothing to drift. No-op under VITEST.
|
||||
*/
|
||||
export async function checkDockerConfigDrift(
|
||||
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName' | 'configHash'>
|
||||
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName' | 'configHash' | 'owned'>
|
||||
): Promise<DockerDriftStatus> {
|
||||
if (IS_TEST_MODE) return { exists: false, running: false, drifted: false };
|
||||
// An ADOPTED container carries no `codeman.confighash` label — it was never
|
||||
// created from our config — so every comparison would report drift and the
|
||||
// launch gate would demand a recreate we are not allowed to perform. Ownership
|
||||
// of its configuration belongs to the user; report "no drift" and never offer
|
||||
// to rebuild it.
|
||||
if (isAdoptedContainer(docker)) return { exists: true, running: false, drifted: false };
|
||||
const argv = dockerEngineArgv(docker);
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
@@ -784,8 +836,15 @@ export async function checkDockerConfigDrift(
|
||||
* case's lastClaudeSessionId. No-op under VITEST.
|
||||
*/
|
||||
export async function removeDockerContainer(
|
||||
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName'>
|
||||
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName' | 'owned'>
|
||||
): Promise<void> {
|
||||
// Fail CLOSED at the lowest layer: an adopted container is the user's, and no
|
||||
// caller — recreate-on-drift, case delete, a future teardown — may remove it.
|
||||
if (isAdoptedContainer(docker)) {
|
||||
throw new Error(
|
||||
`Refusing to remove adopted container "${docker.containerName}": Codeman does not own its lifecycle.`
|
||||
);
|
||||
}
|
||||
if (IS_TEST_MODE) return;
|
||||
const argv = dockerEngineArgv(docker);
|
||||
await execFileAsync(argv[0], [...argv.slice(1), 'rm', '-f', docker.containerName], { timeout: 30_000 });
|
||||
@@ -1031,6 +1090,255 @@ export async function checkDockerTmuxAvailable(
|
||||
}
|
||||
}
|
||||
|
||||
/** Preflight facts about an ALREADY-RUNNING container the user wants to adopt. */
|
||||
export interface AdoptedContainerProbe {
|
||||
ok: boolean;
|
||||
exists: boolean;
|
||||
running: boolean;
|
||||
/** The container's own image ref (informational — we never enforce ours on it). */
|
||||
image?: string;
|
||||
/** `command -v tmux` inside the container; required for durable sessions. */
|
||||
tmuxPath?: string;
|
||||
/** Modes whose CLI resolved inside the container (`command -v <mode>`). */
|
||||
availableModes?: SessionMode[];
|
||||
/** Whether the requested working directory exists INSIDE the container. */
|
||||
workdirExists?: boolean;
|
||||
/** Whether the container's exec user is root (uid 0). */
|
||||
runsAsRoot?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** One container on the engine, as offered to the adoption picker. */
|
||||
export interface DockerContainerInfo {
|
||||
name: string;
|
||||
image: string;
|
||||
running: boolean;
|
||||
/** Engine's own status string, e.g. "Up 3 hours" / "Exited (0) 2 days ago". */
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* List the engine's containers for the adoption picker (mirror of
|
||||
* `listRemoteCodemanSessions`). Read-only and NEVER throws: an unreachable
|
||||
* daemon, a missing engine or zero containers all return `[]`, because this
|
||||
* feeds a convenience picker whose input the user can always type by hand.
|
||||
*
|
||||
* Stopped containers ARE included, sorted after running ones and carrying their
|
||||
* status: adoption requires a running container, but hiding a stopped one turns
|
||||
* "my container is not in the list" into a dead end with no explanation, while
|
||||
* showing `my-box (Exited (0) 2 days ago)` says exactly what to fix.
|
||||
*/
|
||||
export async function listDockerContainers(
|
||||
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>
|
||||
): Promise<DockerContainerInfo[]> {
|
||||
if (IS_TEST_MODE) return [];
|
||||
const argv = dockerEngineArgv(docker);
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
argv[0],
|
||||
[...argv.slice(1), 'ps', '-a', '--format', '{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}'],
|
||||
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
|
||||
);
|
||||
const rows = stdout
|
||||
.split('\n')
|
||||
.map((line) => line.split('\t'))
|
||||
.filter((parts) => parts.length >= 4 && parts[0])
|
||||
.map(([name, image, state, status]) => ({
|
||||
name,
|
||||
image: image || '',
|
||||
running: state === 'running',
|
||||
status: status || '',
|
||||
}));
|
||||
// Running first, then by name, so the containers a user can actually adopt
|
||||
// are the ones at the top of the list.
|
||||
return rows.sort((a, b) => Number(b.running) - Number(a.running) || a.name.localeCompare(b.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preflight an EXISTING container for adoption. Read-only by construction: it
|
||||
* runs `inspect` plus one `exec` of `command -v`, and never creates, starts or
|
||||
* modifies anything. Refusing here is what keeps the failure at link time — a
|
||||
* clear message — instead of at session launch, where the only alternatives
|
||||
* would be a dead pane or starting a container we do not own.
|
||||
*
|
||||
* `--pull=never` is irrelevant here: adoption never touches images. The image
|
||||
* ref is reported only so the UI can show what the user is attaching to.
|
||||
*/
|
||||
export async function probeAdoptableContainer(
|
||||
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName'>,
|
||||
modes: SessionMode[] = [],
|
||||
containerWorkdir?: string
|
||||
): Promise<AdoptedContainerProbe> {
|
||||
if (IS_TEST_MODE) {
|
||||
return {
|
||||
ok: true,
|
||||
exists: true,
|
||||
running: true,
|
||||
tmuxPath: '/usr/bin/tmux',
|
||||
availableModes: modes,
|
||||
workdirExists: true,
|
||||
};
|
||||
}
|
||||
const argv = dockerEngineArgv(docker);
|
||||
let running = false;
|
||||
let image: string | undefined;
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
argv[0],
|
||||
[...argv.slice(1), 'inspect', '-f', '{{.State.Running}}\t{{.Config.Image}}', docker.containerName],
|
||||
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
|
||||
);
|
||||
const [state = '', img = ''] = stdout.trim().split('\t');
|
||||
running = state === 'true';
|
||||
image = img || undefined;
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
exists: false,
|
||||
running: false,
|
||||
error: `container "${docker.containerName}" not found (adoption never creates a container — start it yourself first)`,
|
||||
};
|
||||
}
|
||||
if (!running) {
|
||||
return {
|
||||
ok: false,
|
||||
exists: true,
|
||||
running: false,
|
||||
image,
|
||||
error: `container "${docker.containerName}" exists but is not running (Codeman never starts a container it does not own — start it yourself, then retry)`,
|
||||
};
|
||||
}
|
||||
// One exec resolves tmux plus every requested CLI, so adoption costs a single
|
||||
// round trip. Binaries are fixed mode names, never user input.
|
||||
// A mode with no binary of its own (`shell`) is dropped: there is nothing to look up,
|
||||
// and `command -v ''` would make the whole probe meaningless.
|
||||
const wanted = modes.filter((m) => !!containerBinaryFor(m));
|
||||
const probes = ['tmux', ...wanted.map((m) => containerBinaryFor(m) as string)];
|
||||
// `; exit 0` is load-bearing: the script's status is its LAST command's, so a
|
||||
// missing final CLI made the whole `sh -lc` exit 1 and the probe reported
|
||||
// "could not exec into the container" for a container that was perfectly fine.
|
||||
// Absence of a CLI is data here, not failure — only a real exec error is.
|
||||
const steps = probes.map((bin) => `command -v ${bin} >/dev/null 2>&1 && echo ${bin}`);
|
||||
// The workdir is checked INSIDE the container, and that is a fact independent
|
||||
// of hostWorkspacePath: an owned container gets the host dir bind-mounted at the
|
||||
// same absolute path at create time, but adoption mounts nothing, so the two
|
||||
// paths only coincide if the user mounted it there themselves. `docker exec
|
||||
// --workdir <missing>` fails with an OCI chdir error the pane surfaces as a bare
|
||||
// "execvp failed", so it is resolved here into an actionable message.
|
||||
if (containerWorkdir) steps.push(`[ -d ${shellescape(containerWorkdir)} ] && echo __workdir__`);
|
||||
// Claude Code REFUSES --dangerously-skip-permissions as root. Our own base
|
||||
// image runs a non-root user so an owned container never hits it; an adopted
|
||||
// container's user belongs to its owner and is frequently root.
|
||||
steps.push(`[ "$(id -u)" = 0 ] && echo __root__`);
|
||||
const script = `${steps.join('; ')}; exit 0`;
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
argv[0],
|
||||
[...argv.slice(1), 'exec', docker.containerName, 'sh', '-lc', script],
|
||||
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
|
||||
);
|
||||
const found = new Set(
|
||||
stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
if (!found.has('tmux')) {
|
||||
return {
|
||||
ok: false,
|
||||
exists: true,
|
||||
running: true,
|
||||
image,
|
||||
error: `container "${docker.containerName}" has no tmux (required for durable sessions; install it inside the container)`,
|
||||
};
|
||||
}
|
||||
const workdirExists = containerWorkdir ? found.has('__workdir__') : undefined;
|
||||
if (containerWorkdir && !workdirExists) {
|
||||
return {
|
||||
ok: false,
|
||||
exists: true,
|
||||
running: true,
|
||||
image,
|
||||
workdirExists: false,
|
||||
error: `"${containerWorkdir}" does not exist inside container "${docker.containerName}". Adoption mounts nothing, so the container workdir must already exist there — set it to a path inside the container (it need not match the host workspace path).`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
exists: true,
|
||||
running: true,
|
||||
image,
|
||||
tmuxPath: 'tmux',
|
||||
availableModes: modes.filter((m) => {
|
||||
const bin = containerBinaryFor(m);
|
||||
return bin ? found.has(bin) : true; // `shell` needs no binary
|
||||
}),
|
||||
workdirExists,
|
||||
runsAsRoot: found.has('__root__'),
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, exists: true, running: true, image, error: `could not exec into the container: ${msg}` };
|
||||
}
|
||||
}
|
||||
|
||||
/** One directory listing from INSIDE a container, shaped like the host picker's. */
|
||||
export interface DockerBrowseResult {
|
||||
path: string;
|
||||
parent: string | null;
|
||||
entries: Array<{ name: string; path: string; type: 'directory' | 'file' }>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* List a directory INSIDE a container, for the adoption form's container-workdir
|
||||
* picker. The host filesystem picker cannot serve this: the path lives in the
|
||||
* container, and for an adopted container nothing is mounted at a matching host
|
||||
* location, so the user would otherwise be typing a path blind.
|
||||
*
|
||||
* Read-only: one `ls` through `docker exec`, no writes, no lifecycle. The path
|
||||
* is shell-escaped like every other value this module interpolates, and output
|
||||
* is parsed as NUL-free lines with a leading type marker so a filename with
|
||||
* spaces survives.
|
||||
*/
|
||||
export async function browseInContainer(
|
||||
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName'>,
|
||||
path: string
|
||||
): Promise<DockerBrowseResult> {
|
||||
const target = path && path.startsWith('/') ? path : '/';
|
||||
const parent = target === '/' ? null : target.replace(/\/+$/, '').split('/').slice(0, -1).join('/') || '/';
|
||||
if (IS_TEST_MODE) return { path: target, parent, entries: [] };
|
||||
const argv = dockerEngineArgv(docker);
|
||||
// `-p` marks directories with a trailing slash; `-A` shows dotfiles but not
|
||||
// the . and .. entries the picker navigates with its own Up control.
|
||||
const script = `cd ${shellescape(target)} 2>/dev/null && ls -Ap 2>/dev/null || echo __ERR__`;
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
argv[0],
|
||||
[...argv.slice(1), 'exec', docker.containerName, 'sh', '-lc', script],
|
||||
{ timeout: DOCKER_PROBE_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 }
|
||||
);
|
||||
if (stdout.includes('__ERR__')) return { path: target, parent, entries: [], error: 'Not a readable directory' };
|
||||
const base = target.endsWith('/') ? target : `${target}/`;
|
||||
const entries = stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((name) => {
|
||||
const isDir = name.endsWith('/');
|
||||
const clean = isDir ? name.slice(0, -1) : name;
|
||||
return { name: clean, path: `${base}${clean}`, type: (isDir ? 'directory' : 'file') as 'directory' | 'file' };
|
||||
})
|
||||
.sort((a, b) => Number(b.type === 'directory') - Number(a.type === 'directory') || a.name.localeCompare(b.name));
|
||||
return { path: target, parent, entries };
|
||||
} catch (err) {
|
||||
return { path: target, parent, entries: [], error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the host's IP on the default docker bridge (the address a container
|
||||
* reaches as `host.docker.internal`), so the server can bind a hooks-only listener
|
||||
@@ -1093,9 +1401,18 @@ export async function reapOrphanedDockerContainers(
|
||||
}
|
||||
const cases = await readDockerCases(configDir);
|
||||
const expected = new Set(cases.map((c) => c.container ?? dockerContainerName(c.name)));
|
||||
// ADOPTED containers are never reapable, and this guard is deliberately
|
||||
// independent of the two conditions that already cover them (we never applied
|
||||
// the `codeman.managed=1` label filtered on above, and they are referenced by a
|
||||
// live case so they are in `expected`). An adopted container is the user's
|
||||
// property; it must survive even if a future edit narrows either condition.
|
||||
const adopted = new Set(
|
||||
cases.filter((item) => item.owned === false).map((item) => item.container ?? dockerContainerName(item.name))
|
||||
);
|
||||
const reaped: string[] = [];
|
||||
for (const { name, inst } of rows) {
|
||||
if (inst !== instance) continue; // only THIS instance's containers
|
||||
if (adopted.has(name)) continue; // never reap a container we do not own
|
||||
if (expected.has(name)) continue; // still referenced by a live case
|
||||
try {
|
||||
await execFileAsync(bin, ['rm', '-f', name], { timeout: DOCKER_PROBE_TIMEOUT_MS });
|
||||
|
||||
+114
-6
@@ -366,19 +366,33 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
|
||||
// never lands in this config and rotation needs no respawn. If the var/file is
|
||||
// missing the header is empty — the middleware then allows the request only on
|
||||
// the plain loopback bypass (tunnel down), same as pre-secret behavior.
|
||||
const curlCmd = (event: HookEventType) =>
|
||||
const curlCmd = (event: HookEventType, options: { discardStdout?: boolean } = {}) =>
|
||||
`HOOK_DATA=$(cat 2>/dev/null || echo '{}'); ` +
|
||||
`printf '{"event":"${event}","sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ` +
|
||||
// `-k`, same as the statusline exporter: CODEMAN_API_URL is loopback HTTPS with
|
||||
// a self-signed cert on --https/tailscale installs. Without it curl exits 60,
|
||||
// the `|| true` swallows it, and ALL SIX hook events die silently: respawn loses
|
||||
// its definitive idle signals and the wait endpoints lose stop/blocked.
|
||||
`curl -sk -X POST "$CODEMAN_API_URL/api/hook-event" ` +
|
||||
`curl -sk ${options.discardStdout ? '-o /dev/null ' : ''}-X POST "$CODEMAN_API_URL/api/hook-event" ` +
|
||||
`-H 'Content-Type: application/json' ` +
|
||||
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
|
||||
`--data @- ` +
|
||||
`2>/dev/null || true`;
|
||||
|
||||
// The same POST with stdout DISCARDED, via curl's own `-o`. UserPromptSubmit is
|
||||
// one of the hook events whose stdout Claude Code injects into the model's
|
||||
// context (the CLI's own hook reference: "Exit code 0 - stdout shown to
|
||||
// Claude"), so an undiscarded curl pastes Codeman's `{"success":true,…}`
|
||||
// envelope into the user's prompt on every single turn.
|
||||
// ⚠️ It MUST be curl's flag, not a trailing redirect. `curlCmd` already ends
|
||||
// `… 2>/dev/null || true`, and in `pipeline || true >/dev/null` the shell binds
|
||||
// the redirection to `true` — which never runs on the success path — so the
|
||||
// envelope still reaches stdout. Verified in dash and bash.
|
||||
// ⚠️ The flag is opt-in so the other events' command text stays byte-identical:
|
||||
// their stdout feeds the SSE stream harmlessly, and changing it would rewrite
|
||||
// every workspace's settings file for no gain.
|
||||
const curlCmdSilent = (event: HookEventType) => curlCmd(event, { discardStdout: true });
|
||||
|
||||
return {
|
||||
hooks: {
|
||||
Notification: [
|
||||
@@ -410,6 +424,16 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
|
||||
hooks: [{ type: 'command', command: curlCmd('stop'), timeout: HOOK_TIMEOUT_SECONDS }],
|
||||
},
|
||||
],
|
||||
// The pane's LIVE conversation id, reported by the CLI process itself.
|
||||
// Without it the response viewer has to guess which `<uuid>.jsonl` a pane
|
||||
// is on after a `/clear`, and the only anchor it can guess from is an
|
||||
// Enter that went THROUGH Codeman — so a user who attaches to tmux
|
||||
// directly never gets one and stays pinned to the launch conversation.
|
||||
UserPromptSubmit: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: curlCmdSilent('prompt_submitted'), timeout: HOOK_TIMEOUT_SECONDS }],
|
||||
},
|
||||
],
|
||||
SubagentStop: [
|
||||
{
|
||||
hooks: [
|
||||
@@ -735,9 +759,22 @@ export async function refreshStaleCodemanHooks(casePath: string): Promise<void>
|
||||
// Approvals Inbox needs the elicitation_complete/elicitation_response
|
||||
// matchers; their absence marks a pre-inbox hooks block.
|
||||
const hasElicitationComplete = hooksJson.includes('elicitation_complete');
|
||||
// The UserPromptSubmit event is what gives a tmux-driven pane a first-hand
|
||||
// conversation id; its absence marks a pre-prompt_submitted hooks block.
|
||||
// ⚠️ No surrounding quotes: `hooksJson` is JSON.stringify'd, so the marker
|
||||
// inside the command reads \"prompt_submitted\" and a quoted needle never
|
||||
// matches — which would make this gate permanently false and rewrite every
|
||||
// workspace's settings file on every Claude spawn. The sibling markers are
|
||||
// quote-free for the same reason.
|
||||
const hasPromptSubmit = hooksJson.includes('prompt_submitted');
|
||||
if (
|
||||
!isOurs ||
|
||||
(hasSecret && hasBackgroundWake && hasSubagentStopGuard && hasElicitationComplete && !hasTlsFlaglessCurl)
|
||||
(hasSecret &&
|
||||
hasBackgroundWake &&
|
||||
hasSubagentStopGuard &&
|
||||
hasElicitationComplete &&
|
||||
hasPromptSubmit &&
|
||||
!hasTlsFlaglessCurl)
|
||||
)
|
||||
return;
|
||||
const generated = generateHooksConfig();
|
||||
@@ -1029,9 +1066,80 @@ export async function installAgentSkillInto(skillDir: string): Promise<AgentSkil
|
||||
*/
|
||||
export async function seedAgentSessionPreamble(sessionId: string): Promise<void> {
|
||||
const content = await readFile(join(agentSkillSourceDir(), 'preamble.sh'), 'utf-8');
|
||||
const cacheDir = process.env.XDG_CACHE_HOME || join(homedir(), '.cache');
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
await writeFile(join(cacheDir, `codeman-agent-${sessionId}.sh`), content, { mode: 0o600 });
|
||||
await mkdir(agentPreambleCacheDir(), { recursive: true });
|
||||
await writeFile(agentPreamblePath(sessionId), content, { mode: 0o600 });
|
||||
}
|
||||
|
||||
/** Where the preamble caches live. One formula, shared by seed / remove / prune. */
|
||||
function agentPreambleCacheDir(): string {
|
||||
return process.env.XDG_CACHE_HOME || join(homedir(), '.cache');
|
||||
}
|
||||
|
||||
/** `codeman-agent-<sessionId>.sh` in that directory. */
|
||||
function agentPreamblePath(sessionId: string): string {
|
||||
return join(agentPreambleCacheDir(), `codeman-agent-${sessionId}.sh`);
|
||||
}
|
||||
|
||||
/** Matches exactly what seedAgentSessionPreamble writes, and nothing else in ~/.cache. */
|
||||
const AGENT_PREAMBLE_FILE_PATTERN = /^codeman-agent-(.+)\.sh$/;
|
||||
|
||||
/** How long a preamble cache with no live session behind it is kept before the sweep takes it. */
|
||||
export const AGENT_PREAMBLE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Drop one session's preamble cache. Called when a session is deleted, which is the
|
||||
* precise counterpart to seeding it at create: one file per claude session was being
|
||||
* written and nothing ever removed them (236 leftovers measured on a working machine,
|
||||
* the oldest three weeks old). Best-effort — a file that will not delete is litter,
|
||||
* never a reason to fail a teardown.
|
||||
*/
|
||||
export async function removeAgentSessionPreamble(sessionId: string): Promise<void> {
|
||||
await unlink(agentPreamblePath(sessionId)).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep preamble caches left by sessions that are gone: the delete path above covers
|
||||
* an orderly teardown, and this covers everything else (a crash, a killed server, a
|
||||
* session deleted by an older build, another instance's leftovers).
|
||||
*
|
||||
* ⚠️ Two guards, and both matter: a file whose session is in `keepSessionIds` is never
|
||||
* touched however old it is, and everything else needs `maxAgeMs` of age on top. A live
|
||||
* session's cache is load-bearing — remove it and the skill's two-line loader fails its
|
||||
* version check mid-run — and the age floor is what keeps a session belonging to
|
||||
* ANOTHER instance (whose ids this process cannot see) out of the blast radius. Losing
|
||||
* one is degradation rather than breakage: the §0 fallback block rewrites it.
|
||||
*
|
||||
* Returns how many it removed. Best-effort throughout; a missing cache dir is 0.
|
||||
*/
|
||||
export async function pruneAgentSessionPreambles(
|
||||
keepSessionIds: Iterable<string>,
|
||||
maxAgeMs: number = AGENT_PREAMBLE_MAX_AGE_MS
|
||||
): Promise<number> {
|
||||
const cacheDir = agentPreambleCacheDir();
|
||||
const keep = new Set(keepSessionIds);
|
||||
const cutoff = Date.now() - maxAgeMs;
|
||||
let removed = 0;
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(cacheDir);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const sessionId = AGENT_PREAMBLE_FILE_PATTERN.exec(entry)?.[1];
|
||||
if (!sessionId || keep.has(sessionId)) continue;
|
||||
const path = join(cacheDir, entry);
|
||||
try {
|
||||
if ((await lstat(path)).mtimeMs > cutoff) continue;
|
||||
await unlink(path);
|
||||
removed++;
|
||||
} catch {
|
||||
/* best-effort — a vanished or unreadable file is not our problem */
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -137,7 +137,12 @@ export interface RespawnPaneOptions {
|
||||
|
||||
/** Options for pane buffer capture (COD-47 full-history mode). */
|
||||
export interface PaneCaptureOptions {
|
||||
/** Capture the entire tmux scrollback instead of just the visible frame. */
|
||||
/**
|
||||
* Capture the entire scrollback instead of just the visible frame, as linear
|
||||
* text ending with a cursor move back to the pane's caret position. An
|
||||
* implementation returns '' when the pane holds nothing visible, which the
|
||||
* caller reads as "nothing to replay" and keeps its existing history.
|
||||
*/
|
||||
fullHistory?: boolean;
|
||||
/** Bound the full-history capture to this many scrollback lines (`-S -<N>`). */
|
||||
historyLimitLines?: number;
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
* @fileoverview Pure merge/filter logic for the unified session list (COD-121).
|
||||
*
|
||||
* Combines four read-only views of a session — live (in-memory `Session`),
|
||||
* persisted (`state.json`), transcript history (`~/.claude/projects`), and the
|
||||
* lifecycle audit log — plus mux process stats, into one de-duplicated list
|
||||
* keyed by sessionId. Transcript-history rows are keyed by the Claude
|
||||
* conversation UUID (the `.jsonl` filename stem), which diverges from the
|
||||
* Codeman id for resumed sessions — an alias map (claudeSessionId → Codeman id,
|
||||
* built from the live/persisted views) folds them into the owning session item.
|
||||
* persisted (`state.json`), transcript history, and the lifecycle audit log —
|
||||
* plus mux process stats, into one de-duplicated list keyed by sessionId.
|
||||
*
|
||||
* Transcript history is not one source but three, because the CLIs keep their
|
||||
* conversations in their own stores: Claude's `~/.claude/projects`, omp's
|
||||
* `~/.omp/agent/sessions` and codex's `~/.codex/sessions`. Each row is keyed by
|
||||
* whatever id that CLI names the conversation with, which diverges from the
|
||||
* Codeman id for a resumed session and for every non-Claude one — an alias map
|
||||
* (claudeSessionId → Codeman id, built from the live/persisted views) folds them
|
||||
* into the owning session item.
|
||||
* Higher-precedence sources overwrite scalar fields when present
|
||||
* (history < lifecycle < persisted < live), while the `sources` array
|
||||
* always accumulates every contributing view. A "meaningfulness floor" drops
|
||||
@@ -39,6 +43,11 @@ export type UnifiedSessionItem = {
|
||||
/** Main repo root a worktree belongs to (#266). */
|
||||
worktreeRepo?: string;
|
||||
remote?: boolean;
|
||||
/**
|
||||
* Token this row's CLI resumes by, when that is not `sessionId`. Set only from
|
||||
* a transcript scanner — see the field of the same name on `HistoryInput`.
|
||||
*/
|
||||
resumeId?: string;
|
||||
/** Pinned to the top of the session manager list (COD-139). */
|
||||
pinned?: boolean;
|
||||
/** When the session was pinned (epoch ms) — orders the pinned group desc. */
|
||||
@@ -100,12 +109,21 @@ export type HistoryInput = {
|
||||
worktreeName?: string;
|
||||
worktreeRepo?: string;
|
||||
/**
|
||||
* Set only by a non-claude transcript source (currently omp); the Claude
|
||||
* scanner never stamps this; the meaningfulness floor below still counts a
|
||||
* row with a `mode` as real, since that also signals "not claude" — see
|
||||
* where it's read below for the isReal check this touches.
|
||||
* Set only by a non-claude transcript source (currently omp and codex); the
|
||||
* Claude scanner never stamps this; the meaningfulness floor below still
|
||||
* counts a row with a `mode` as real, since that also signals "not claude" —
|
||||
* see where it's read below for the isReal check this touches.
|
||||
*/
|
||||
mode?: string;
|
||||
/**
|
||||
* The token this CLI's own resume command expects, when it is NOT the row's
|
||||
* `sessionId`. Codex names a thread by an id of its own that lives in the
|
||||
* rollout, and a live codex session's `sessionId` is Codeman's uuid instead —
|
||||
* so a resume that reused `sessionId` would ask codex for a thread that does
|
||||
* not exist. Only a transcript scanner sets this, which is what keeps the two
|
||||
* kinds of row apart.
|
||||
*/
|
||||
resumeId?: string;
|
||||
};
|
||||
|
||||
/** Mux process-stat view. */
|
||||
@@ -186,6 +204,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
// transcript source (currently only omp) does, so a history-only row
|
||||
// still gets a mode badge instead of reading as claude by default.
|
||||
overwrite(item, 'mode', h.mode);
|
||||
overwrite(item, 'resumeId', h.resumeId);
|
||||
const ms = Date.parse(h.lastModified);
|
||||
if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms;
|
||||
}
|
||||
|
||||
+204
-36
@@ -106,6 +106,7 @@ import {
|
||||
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 { compileVersionRegex } from './config/cli-registry/patterns.js';
|
||||
import { resolveSessionCliVersion } from './utils/cli-resolver.js';
|
||||
import {
|
||||
buildInteractiveArgs,
|
||||
@@ -156,6 +157,11 @@ const WIRE_ACTIVITY_SETTLE_MS = 15_000;
|
||||
/** Graceful shutdown delay when stopping session (100ms) */
|
||||
const GRACEFUL_SHUTDOWN_DELAY_MS = 100;
|
||||
|
||||
// Conversations kept in a pane's chain. A pane that /clears repeatedly would
|
||||
// otherwise grow state.json without bound; 32 covers any real session's history
|
||||
// and the oldest entries are the ones whose transcripts Claude Code has pruned.
|
||||
const MAX_CLAUDE_SESSION_CHAIN = 32;
|
||||
|
||||
// Filter out terminal focus escape sequences (focus in/out reports)
|
||||
// ^[[I (focus in), ^[[O (focus out), and the enable/disable sequences
|
||||
// eslint-disable-next-line no-control-regex
|
||||
@@ -438,6 +444,17 @@ export class Session extends EventEmitter {
|
||||
private _wireActivityAt: number;
|
||||
private _wireActivitySettleUntil: number;
|
||||
private _claudeSessionId: string | null = null;
|
||||
// Set only when the id came from the CLI's own UserPromptSubmit/Stop hook
|
||||
// payload, keyed on this pane's $CODEMAN_SESSION_ID. That binding is a fact,
|
||||
// not a correlation: it never consults cwd, so a sibling pane on the same
|
||||
// folder cannot steal it. Runtime-only — a restart must re-earn it from the
|
||||
// next hook rather than trust a persisted claim.
|
||||
private _claudeSessionIdIsFirstHand = false;
|
||||
// Conversations this pane has been on, oldest first, current last. Grows only
|
||||
// through a first-hand adoption, so it can never splice in a foreign
|
||||
// conversation. Persisted, because `/clear` is otherwise unrecoverable: the
|
||||
// predecessor id exists nowhere else once the pane moves on.
|
||||
private _claudeSessionChain: string[] = [];
|
||||
private _totalCost: number = 0;
|
||||
private _messages: ClaudeMessage[] = [];
|
||||
private _lineBuffer: string = '';
|
||||
@@ -465,6 +482,8 @@ export class Session extends EventEmitter {
|
||||
private _activityStreak: ActivityStreak | null = null; // Unbroken run of PTY repaints (working detection)
|
||||
private _lastPaneProbeAt = 0; // Throttle for the tmux screen probe
|
||||
private _lastPaneProbeWorking: boolean | null = null; // Its last verdict (null = could not read)
|
||||
/** Lazily compiled `capabilities.workDetect.workingLine`. See _workingLinePattern(). */
|
||||
private _workingLineRe: RegExp | undefined = undefined;
|
||||
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
|
||||
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
|
||||
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
|
||||
@@ -660,6 +679,8 @@ export class Session extends EventEmitter {
|
||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||
/** Restored wall-clock ms of the pane's last Enter (see `lastSubmitAt`). */
|
||||
lastSubmitAt?: number;
|
||||
/** Restored conversation chain, oldest first (see `claudeSessionChain`). */
|
||||
claudeSessionChain?: string[];
|
||||
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
|
||||
lastActivityAt?: number;
|
||||
/** Remote execution metadata for sessions launched through SSH inside local tmux. */
|
||||
@@ -703,19 +724,33 @@ export class Session extends EventEmitter {
|
||||
this._wireActivityAt = config.lastActivityAt || Date.now();
|
||||
this._wireActivitySettleUntil = config.lastActivityAt ? Date.now() + WIRE_ACTIVITY_SETTLE_MS : 0;
|
||||
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
||||
// For omp, `claudeSessionId` doubles as the generic "external transcript id"
|
||||
// alias key mergeUnifiedSessions() folds a history row into its owning
|
||||
// session by: omp mints its OWN uuid, unrelated to this Codeman id, so
|
||||
// without this an omp conversation's Past-Sessions row (keyed by omp's
|
||||
// id) would never merge with its own live/persisted row (keyed by this
|
||||
// id) — it would just show up a second time.
|
||||
this._claudeSessionId = config.resumeSessionId || config.ompConfig?.resumeSessionId || this.id;
|
||||
// For omp and codex, `claudeSessionId` doubles as the generic "external
|
||||
// transcript id" alias key mergeUnifiedSessions() folds a history row into
|
||||
// its owning session by: each mints its OWN thread id, unrelated to this
|
||||
// Codeman id, so without this the conversation's Past-Sessions row (keyed by
|
||||
// that thread id) would never merge with its own live/persisted row (keyed
|
||||
// by this id) — it would just show up a second time. For codex a duplicate
|
||||
// is worse than cosmetic: the stale row still resumes, so clicking it starts
|
||||
// a SECOND `codex resume` on a thread already open in another pane.
|
||||
//
|
||||
// This covers a RESUMED codex session, which knows its thread id up front. A
|
||||
// fresh one learns its id only once codex writes the rollout, so it is folded
|
||||
// from the other side — see the originator stamping in `gatherUnifiedInputs()`.
|
||||
this._claudeSessionId =
|
||||
config.resumeSessionId || config.ompConfig?.resumeSessionId || config.codexConfig?.resumeSessionId || this.id;
|
||||
// Restored from state.json on boot recovery. start() resets _claudeSessionId
|
||||
// to the launch id even when re-attaching to a mux session whose CLI has
|
||||
// moved on (a `/clear` before the restart), so this anchor is what lets the
|
||||
// response viewer re-derive the live conversation without waiting for the
|
||||
// user to type again.
|
||||
this._lastSubmitAt = config.lastSubmitAt ?? 0;
|
||||
// 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
|
||||
// fact, so the pane re-earns the guess-free path from its next hook.
|
||||
this._claudeSessionChain = Array.isArray(config.claudeSessionChain) ? [...config.claudeSessionChain] : [];
|
||||
const restoredConversation = this._claudeSessionChain[this._claudeSessionChain.length - 1];
|
||||
if (restoredConversation) this._claudeSessionId = restoredConversation;
|
||||
this._mux = config.mux || null;
|
||||
this._useMux = config.useMux ?? (this._mux !== null && this._mux.isAvailable());
|
||||
this._muxSession = config.muxSession || null;
|
||||
@@ -914,6 +949,20 @@ export class Session extends EventEmitter {
|
||||
return this._claudeSessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `claudeSessionId` came from the CLI's own hook payload rather than
|
||||
* from the launch config or a history correlation. The response viewer uses it
|
||||
* to skip guessing entirely — see resolveActiveClaudeSessionIdFromHistory().
|
||||
*/
|
||||
get claudeSessionIdIsFirstHand(): boolean {
|
||||
return this._claudeSessionIdIsFirstHand;
|
||||
}
|
||||
|
||||
/** Conversations this pane has been on, oldest first, current last. */
|
||||
get claudeSessionChain(): readonly string[] {
|
||||
return this._claudeSessionChain;
|
||||
}
|
||||
|
||||
/** Docker execution metadata when this session runs inside a container, else undefined. */
|
||||
get docker(): SessionDocker | undefined {
|
||||
return this._docker;
|
||||
@@ -971,11 +1020,38 @@ export class Session extends EventEmitter {
|
||||
// payload). In interactive PTY mode Claude CLI emits no JSON to stdout, so
|
||||
// `_handleJsonMessage` never sees `session_id`; hooks are the only signal
|
||||
// that conveys a post-/clear conversation switch.
|
||||
adoptClaudeSessionId(newId: string): void {
|
||||
if (!newId || newId === this._claudeSessionId) return;
|
||||
//
|
||||
// `firstHand` marks an id that came from the CLI process itself — a hook
|
||||
// payload whose delivery was keyed on this pane's $CODEMAN_SESSION_ID. Only
|
||||
// those extend the chain: a history-correlated guess must never be able to
|
||||
// write a foreign conversation into this pane's permanent record.
|
||||
adoptClaudeSessionId(newId: string, options: { firstHand?: boolean } = {}): void {
|
||||
if (!newId) return;
|
||||
if (options.firstHand) {
|
||||
this._claudeSessionIdIsFirstHand = true;
|
||||
this._recordClaudeSessionInChain(newId);
|
||||
}
|
||||
if (newId === this._claudeSessionId) return;
|
||||
this._claudeSessionId = newId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append to the conversation chain, oldest first. A repeat of the current tail
|
||||
* is a no-op (every prompt in a conversation reports the same id), and an id
|
||||
* already in the chain moves to the tail rather than duplicating, which is
|
||||
* what a `/resume` back to an earlier conversation does.
|
||||
*/
|
||||
private _recordClaudeSessionInChain(id: string): void {
|
||||
if (this._claudeSessionChain[this._claudeSessionChain.length - 1] === id) return;
|
||||
const existing = this._claudeSessionChain.indexOf(id);
|
||||
if (existing !== -1) this._claudeSessionChain.splice(existing, 1);
|
||||
this._claudeSessionChain.push(id);
|
||||
// A pane that /clears in a loop must not grow this without bound.
|
||||
if (this._claudeSessionChain.length > MAX_CLAUDE_SESSION_CHAIN) {
|
||||
this._claudeSessionChain.splice(0, this._claudeSessionChain.length - MAX_CLAUDE_SESSION_CHAIN);
|
||||
}
|
||||
}
|
||||
|
||||
/** The tmux session name, if the session is running inside a mux */
|
||||
get muxName(): string | null {
|
||||
return this._muxSession?.muxName ?? null;
|
||||
@@ -1409,6 +1485,12 @@ export class Session extends EventEmitter {
|
||||
respawnBlocked: this._respawnBlocked || undefined,
|
||||
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
|
||||
lastSubmitAt: this._lastSubmitAt || 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()`
|
||||
// resets that field to the launch id at three separate points, which is
|
||||
// why a recovered pane otherwise shows its pre-/clear transcript forever.
|
||||
claudeSessionChain: this._claudeSessionChain.length > 0 ? [...this._claudeSessionChain] : undefined,
|
||||
// envOverrides intentionally NOT on the public SessionState type — they must not
|
||||
// leak into SSE / GET /api/sessions broadcasts (schema allows OPENCODE_*, which
|
||||
// can carry secrets). For disk persistence, session-manager calls
|
||||
@@ -1693,6 +1775,19 @@ export class Session extends EventEmitter {
|
||||
// (reported live 2026-08-27, fixed in 13a19f79); this guard keeps that
|
||||
// fix intact now that resolution has moved out of the eager options build.
|
||||
if (!this._muxSession) return;
|
||||
// `resolveAndClaimOmpSessionId` scans THIS HOST's `~/.omp/agent/sessions/`, which is
|
||||
// meaningless for a remote session — the conversation and its session file live on the
|
||||
// remote host, under the REMOTE user's home. Worse than a no-op: `this.workingDir` for a
|
||||
// remote session is the remote path (e.g. `/home/user/dotfiles`), so if the local machine
|
||||
// happens to have its own omp history under a directory that mangles to the same name,
|
||||
// this would silently claim and pin a COMPLETELY UNRELATED local session's id onto a
|
||||
// remote respawn. Skip straight to the CLI's own `--continue` fallback, which the remote
|
||||
// pane command already renders (see buildRemoteLaunchCommand's omp branch) — safe there
|
||||
// because each remote respawn talks to exactly one remote pane's own omp history.
|
||||
if (this._remote) {
|
||||
this._ompConfig = { ...this._ompConfig, continueSession: true };
|
||||
return;
|
||||
}
|
||||
const resolvedId = resolveAndClaimOmpSessionId(this.workingDir);
|
||||
if (resolvedId) {
|
||||
this._ompConfig = { ...this._ompConfig, resumeSessionId: resolvedId };
|
||||
@@ -1948,6 +2043,11 @@ export class Session extends EventEmitter {
|
||||
}, REMOTE_CLI_VERSION_PROBE_DELAY_MS);
|
||||
}
|
||||
|
||||
// ⚠️ Hoisted, because the "third reset point" below runs unconditionally
|
||||
// AFTER the mux branch and would otherwise stomp the restored conversation
|
||||
// straight back to the launch id.
|
||||
let restoredConversation: string | undefined;
|
||||
|
||||
// If mux wrapping is enabled, create or attach to a mux session
|
||||
if (this._useMux && this._mux) {
|
||||
try {
|
||||
@@ -1988,8 +2088,23 @@ export class Session extends EventEmitter {
|
||||
// this to omp's own session uuid — that already-resolved id must win
|
||||
// over the generic `this.id` fallback, or this line clobbers it back
|
||||
// to the Codeman id
|
||||
// on every single respawn.
|
||||
this._claudeSessionId = this._resumeSessionId || this._ompConfig?.resumeSessionId || this.id;
|
||||
// on every single respawn. codex needs the same fallback for the same
|
||||
// reason: its thread id lives in `_codexConfig`, so without it every
|
||||
// respawn drops a resumed codex session's alias and its Past-Sessions
|
||||
// row springs back as a duplicate that still resumes.
|
||||
// ⚠️ A RESTORED mux session is the one case where the launch id is a
|
||||
// lie: the CLI never stopped, so a `/clear` before the Codeman restart
|
||||
// already moved it to a conversation `this.id` knows nothing about. The
|
||||
// persisted chain's tail is that conversation, reported first-hand by
|
||||
// the CLI's own hook, so it outranks every fallback here. A NEW pane has
|
||||
// an empty chain and falls through to the resume/alias fallbacks.
|
||||
restoredConversation = isRestored ? this._claudeSessionChain[this._claudeSessionChain.length - 1] : undefined;
|
||||
this._claudeSessionId =
|
||||
restoredConversation ||
|
||||
this._resumeSessionId ||
|
||||
this._ompConfig?.resumeSessionId ||
|
||||
this._codexConfig?.resumeSessionId ||
|
||||
this.id;
|
||||
|
||||
// For NEW mux sessions: wait for readiness then clean buffer
|
||||
// For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch
|
||||
@@ -2090,10 +2205,19 @@ export class Session extends EventEmitter {
|
||||
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
||||
// Mirrors the mux branch above and must not clobber it: this line runs
|
||||
// unconditionally after both the mux and direct-PTY paths, so it also needs
|
||||
// the ompConfig fallback or it stomps the mux branch's correctly-resolved
|
||||
// OMP alias back to this.id on every mux/plain-reattach boot recovery
|
||||
// (the "third reset point" — see DECISIONS.md).
|
||||
this._claudeSessionId = this._resumeSessionId || this._ompConfig?.resumeSessionId || this.id;
|
||||
// the ompConfig and codexConfig fallbacks or it stomps the mux branch's
|
||||
// correctly-resolved OMP/codex alias back to this.id on every mux/plain-
|
||||
// reattach boot recovery (the "third reset point" — see DECISIONS.md).
|
||||
// For the same reason it needs `restoredConversation`: on a RESTORED mux
|
||||
// attach the CLI never stopped and may have `/clear`ed before the restart,
|
||||
// so the launch id is a lie and the chain's tail is the live conversation.
|
||||
// It is empty on every other path, so those paths keep the alias chain.
|
||||
this._claudeSessionId =
|
||||
restoredConversation ||
|
||||
this._resumeSessionId ||
|
||||
this._ompConfig?.resumeSessionId ||
|
||||
this._codexConfig?.resumeSessionId ||
|
||||
this.id;
|
||||
|
||||
this._pid = this.ptyProcess.pid;
|
||||
console.log('[Session] Interactive PTY spawned with PID:', this._pid);
|
||||
@@ -2298,12 +2422,14 @@ export class Session extends EventEmitter {
|
||||
* @param data raw PTY chunk, ANSI included
|
||||
*/
|
||||
private _detectInteractiveActivity(data: string): void {
|
||||
// The prompt line contains "❯" when Claude is waiting for input. It only ARMS
|
||||
// the check and is NOT evidence the turn ended: Claude redraws the composer
|
||||
// about once a second all the way through a turn, which is exactly how a
|
||||
// working session used to flip to idle two seconds in. _confirmIdle() waits
|
||||
// for the pane to actually go quiet before believing it.
|
||||
if (data.includes('❯')) {
|
||||
const workDetect = getCli(this.mode)?.capabilities.workDetect;
|
||||
// The composer row carries this glyph when the CLI is waiting for input. It only
|
||||
// ARMS the check and is NOT evidence the turn ended: a CLI redraws its composer
|
||||
// about once a second all the way through a turn, which is exactly how a working
|
||||
// session used to flip to idle two seconds in. _confirmIdle() waits for the pane to
|
||||
// actually go quiet before believing it. A CLI that declares no glyph keeps Claude's,
|
||||
// which is the glyph every such session has been armed by until now.
|
||||
if (data.includes(workDetect?.promptGlyph ?? '❯')) {
|
||||
// Only start a new timeout if we're not already awaiting idle confirmation.
|
||||
// This prevents status bar redraws (which include the prompt) from resetting it.
|
||||
if (!this._awaitingIdleConfirmation) {
|
||||
@@ -2322,9 +2448,10 @@ export class Session extends EventEmitter {
|
||||
// new status line does not rescue it either (tmux repaints partially, so the
|
||||
// complete line reaches the PTY only every few tens of seconds). An unbroken run
|
||||
// of repaints is the signal that survives. See session-activity.ts for the
|
||||
// measurement. Claude only: an external CLI's TUI has no ❯, so nothing would
|
||||
// ever arm the idle confirmation and such a session would latch busy forever.
|
||||
if (!isExternalCliMode(this.mode)) {
|
||||
// measurement. This needs a pane Codeman can read: without a glyph to arm the idle
|
||||
// confirmation, a session latches busy forever. A CLI that declares work detection
|
||||
// supplies its own glyph, and the non-external modes keep the run they always had.
|
||||
if (workDetect || !isExternalCliMode(this.mode)) {
|
||||
this._activityStreak = trackActivityStreak(this._activityStreak, Date.now());
|
||||
// A streak is the TRIGGER to look, not the verdict: typing into the composer
|
||||
// also produces a steady stream of repaints. The screen settles it, and only
|
||||
@@ -2359,10 +2486,30 @@ export class Session extends EventEmitter {
|
||||
if (now - this._lastPaneProbeAt < PANE_PROBE_MIN_INTERVAL_MS) return this._lastPaneProbeWorking;
|
||||
this._lastPaneProbeAt = now;
|
||||
const text = this._mux.capturePaneText?.(this._muxSession.muxName) ?? null;
|
||||
this._lastPaneProbeWorking = text === null ? null : CLAUDE_WORKING_LINE_PATTERN.test(text);
|
||||
this._lastPaneProbeWorking = text === null ? null : this._workingLinePattern().test(text);
|
||||
return this._lastPaneProbeWorking;
|
||||
}
|
||||
|
||||
/**
|
||||
* The regex matching this CLI's "a turn is running" status line.
|
||||
*
|
||||
* Compiled once per session and cached: `_probePaneWorking` runs it against a whole
|
||||
* pane capture on a timer, and the throttled text detector runs it against every
|
||||
* accumulated chunk. A CLI that declares no pattern falls back to Claude's, which is
|
||||
* the pattern every session used before the registry carried one.
|
||||
*/
|
||||
private _workingLinePattern(): RegExp {
|
||||
if (this._workingLineRe === undefined) {
|
||||
const src = getCli(this.mode)?.capabilities.workDetect?.workingLine;
|
||||
// Same guard the schema applies, not a second opinion: `compileVersionRegex()` is
|
||||
// what keeps a nested quantifier out of this pattern, and this one runs on the PTY
|
||||
// hot path. It returns null rather than throwing, and Claude's pattern is the
|
||||
// fallback every session used before the registry carried one.
|
||||
this._workingLineRe = (src ? compileVersionRegex(src) : null) ?? CLAUDE_WORKING_LINE_PATTERN;
|
||||
}
|
||||
return this._workingLineRe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the pane as working. Idempotent: `working` is emitted on the transition
|
||||
* only, so the per-chunk detectors can all call it freely.
|
||||
@@ -2434,6 +2581,11 @@ export class Session extends EventEmitter {
|
||||
*/
|
||||
private _maybeCaptureOmpSessionId(): void {
|
||||
if (getCli(this.mode)?.capabilities.transcript !== 'omp-jsonl' || this._claudeSessionId !== this.id) return;
|
||||
// Same host-local-filesystem trap as `_pinOmpRespawnId`: the omp session file for a
|
||||
// remote session lives on the remote host, not here, so scanning locally risks aliasing
|
||||
// this session onto an unrelated local omp conversation that happens to mangle to the
|
||||
// same directory name. Never resolvable from here — skip.
|
||||
if (this._remote) return;
|
||||
try {
|
||||
const resolvedId = resolveAndClaimOmpSessionId(this.workingDir);
|
||||
if (resolvedId) {
|
||||
@@ -2451,10 +2603,6 @@ export class Session extends EventEmitter {
|
||||
* PTY data chunk. Receives accumulated raw data to process in one batch.
|
||||
*/
|
||||
private _processExpensiveParsers(rawData: string): void {
|
||||
// Skip Claude-specific parsers for external CLI sessions (Ralph tracker,
|
||||
// BashToolParser, token + CLI-info parsing all depend on Claude's output format).
|
||||
if (isExternalCliMode(this.mode)) return;
|
||||
|
||||
// Lazy ANSI strip: only compute cleanData when a consumer actually needs it.
|
||||
let _cleanData: string | null = null;
|
||||
const getCleanData = (): string => {
|
||||
@@ -2464,6 +2612,19 @@ export class Session extends EventEmitter {
|
||||
return _cleanData;
|
||||
};
|
||||
|
||||
// Work detection by status line, ahead of the external-CLI gate below. The pattern
|
||||
// comes from the CLI's own registry entry, so this is the one parser here that is not
|
||||
// Claude-specific — and it sat under that gate, which is why an external CLI reported
|
||||
// itself idle through an entire turn. Guarded on the descriptor so a CLI without one
|
||||
// still skips the ANSI strip the gate used to save it.
|
||||
if (!this._isWorking && getCli(this.mode)?.capabilities.workDetect) {
|
||||
if (this._workingLinePattern().test(getCleanData())) this._markWorking();
|
||||
}
|
||||
|
||||
// Skip Claude-specific parsers for external CLI sessions (Ralph tracker,
|
||||
// BashToolParser, token + CLI-info parsing all depend on Claude's output format).
|
||||
if (isExternalCliMode(this.mode)) return;
|
||||
|
||||
// Forward to Ralph tracker to detect Ralph loops and todos
|
||||
// (opencode sessions already returned early at line 1209)
|
||||
if (this._ralphTracker.enabled || !this._ralphTracker.autoEnableDisabled) {
|
||||
@@ -2495,16 +2656,13 @@ export class Session extends EventEmitter {
|
||||
this.parseTaskDescriptionsFromTerminalData(getCleanData());
|
||||
}
|
||||
|
||||
// Work detection (text-based, needs clean data: the status line is coloured,
|
||||
// so raw data has escape sequences between the `…` and the elapsed timer).
|
||||
// Only check if a faster path didn't already trigger working state.
|
||||
// Legacy gerunds, Claude-only. The status-line pattern above already ran for every
|
||||
// CLI that declares one, so this adds only the older wording. Current Claude
|
||||
// randomizes the word ("Actualizing…", "Finagling…"), so these catch a fraction of
|
||||
// turns; the pattern above and the activity streak carry the rest.
|
||||
if (!this._isWorking) {
|
||||
const cleanData = getCleanData();
|
||||
if (
|
||||
CLAUDE_WORKING_LINE_PATTERN.test(cleanData) ||
|
||||
// Legacy gerunds. Current Claude randomizes the word ("Actualizing…",
|
||||
// "Finagling…"), so these catch only a fraction of turns; the pattern
|
||||
// above and the activity streak carry the rest.
|
||||
cleanData.includes('Thinking') ||
|
||||
cleanData.includes('Writing') ||
|
||||
cleanData.includes('Reading') ||
|
||||
@@ -3220,6 +3378,16 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A prompt was submitted, reported by the CLI's own UserPromptSubmit hook.
|
||||
* `_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.
|
||||
*/
|
||||
markPromptSubmitted(): void {
|
||||
this._lastSubmitAt = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-client highest-applied input sequence, for exactly-once input delivery.
|
||||
* Keyed by the web client's stable `clientId`. Bounded so many devices over a
|
||||
|
||||
+240
-55
@@ -528,6 +528,73 @@ export function normalizeScrollbackEol(buffer: string): string {
|
||||
return buffer.replace(/\r?\n/g, '\r\n');
|
||||
}
|
||||
|
||||
/** Pane geometry and caret position, as `display-message` reports them. */
|
||||
interface PaneCursorGeometry {
|
||||
cols: number;
|
||||
rows: number;
|
||||
cursorX: number;
|
||||
cursorY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the pane's cursor and size, or null when tmux cannot say.
|
||||
*
|
||||
* Every field is validated together: a caller that gets a value back can place
|
||||
* a caret with it, and one that gets null must not try.
|
||||
*/
|
||||
export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = run().trim();
|
||||
} catch (cursorErr) {
|
||||
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
|
||||
return null;
|
||||
}
|
||||
const [cursorX, cursorY, cols, rows] = raw.split(/\s+/).map((value) => parseInt(value, 10));
|
||||
if (
|
||||
!Number.isFinite(cursorX) ||
|
||||
!Number.isFinite(cursorY) ||
|
||||
!Number.isFinite(cols) ||
|
||||
!Number.isFinite(rows) ||
|
||||
cursorX < 0 ||
|
||||
cursorY < 0 ||
|
||||
cols <= 0 ||
|
||||
rows <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { cols, rows, cursorX, cursorY };
|
||||
}
|
||||
|
||||
/** SGR attributes, which is all `capture-pane -e` emits. */
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CAPTURE_STYLE_SEQUENCE = /\x1b\[[0-9;:]*m/g;
|
||||
|
||||
/** Whether a capture holds anything a reader would see, styles discounted. */
|
||||
export function hasVisibleContent(capture: string): boolean {
|
||||
return /\S/.test(capture.replace(CAPTURE_STYLE_SEQUENCE, ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the caret back where the pane has it, counting UP from the bottom of what
|
||||
* was just replayed.
|
||||
*
|
||||
* Relative rather than absolute (`CUP`) on purpose. `\x1b[<row>;<col>H` numbers
|
||||
* rows from the top of the browser's screen, so it only lands correctly while
|
||||
* the browser's row count equals the pane's — and it need not, because
|
||||
* `resizeWindow` fires its tmux resize without waiting, so a capture can be
|
||||
* taken before a requested resize has been applied. Counting up from the last
|
||||
* replayed row is anchored to the content instead, which is the thing both ends
|
||||
* genuinely share.
|
||||
*/
|
||||
export function formatCursorRestore(geometry: PaneCursorGeometry): string {
|
||||
const up = Math.max(0, geometry.rows - 1 - geometry.cursorY);
|
||||
const right = Math.max(0, geometry.cursorX);
|
||||
// `\r` first so the column is known: the replay leaves the caret wherever the
|
||||
// last row's text ended.
|
||||
return `${up > 0 ? `\x1b[${up}A` : ''}\r${right > 0 ? `\x1b[${right}C` : ''}`;
|
||||
}
|
||||
|
||||
export function formatPaneSnapshot(
|
||||
lines: string[],
|
||||
geometry: { cols: number; rows: number; cursorX: number; cursorY: number }
|
||||
@@ -760,8 +827,11 @@ export function buildRemoteLaunchCommand(options: {
|
||||
sessionId: string;
|
||||
claudeMode?: ClaudeMode;
|
||||
allowedTools?: string;
|
||||
/** OMP only — resume/continue overrides for the remote omp relaunch (dead-pane respawn). */
|
||||
ompConfig?: OmpConfig;
|
||||
resumeSessionId?: string;
|
||||
}): string {
|
||||
const { mode, remote, sessionId, claudeMode, allowedTools } = options;
|
||||
const { mode, remote, sessionId, claudeMode, allowedTools, ompConfig, resumeSessionId } = options;
|
||||
// §6.3: honor the session's EFFECTIVE claude permission mode on remote instead of
|
||||
// hardcoding --dangerously-skip-permissions, so a non-granted multi-user user's
|
||||
// downgraded 'auto' actually reaches the remote agent (the default command otherwise
|
||||
@@ -770,11 +840,66 @@ export function buildRemoteLaunchCommand(options: {
|
||||
// `defaultRemoteCommandForMode`: `claude` lives under a per-user PATH entry that
|
||||
// only an interactive login shell resolves (see that function's comment).
|
||||
const override = remote.commands?.[mode];
|
||||
const modeCommand = override
|
||||
? override
|
||||
: mode === 'claude'
|
||||
? remoteLoginShellCommand(`claude${buildClaudePermissionFlags(claudeMode, allowedTools)}`)
|
||||
: defaultRemoteCommandForMode(mode);
|
||||
let modeCommand: string;
|
||||
if (override) {
|
||||
modeCommand = override;
|
||||
} else if (mode === 'claude') {
|
||||
// Deterministic conversation pinning for SSH-remote claude (mirrors the
|
||||
// docker-claude shape in claudeDockerPaneCommand, INCLUDING the distinct
|
||||
// resumeId branch it declares — this used to only mirror the same-id
|
||||
// fallback shape, silently dropping an explicit resumeSessionId that
|
||||
// differs from sessionId, e.g. a resume-from-history launch): the FIRST
|
||||
// run creates the conversation under --session-id <sessionId>; a respawn
|
||||
// / reattach re-runs the same idempotent command, --session-id exits
|
||||
// non-zero ("already in use"), and the `||` fallback RESUMES that same
|
||||
// conversation. Without a pinned id, every reattach relaunched a bare
|
||||
// `claude` and started a NEW conversation (found live 2026-08-29: remote
|
||||
// claude ctrl-d / ctrl-c relaunched a fresh session). A per-host
|
||||
// `commands.claude` override stays authoritative (admin's explicit
|
||||
// choice) and skips this entirely.
|
||||
const permFlags = buildClaudePermissionFlags(claudeMode, allowedTools);
|
||||
const cmd = `claude${permFlags}`;
|
||||
// Defense in depth, mirroring claudeDockerPaneCommand's own belt-and-braces check:
|
||||
// sessionId is server-minted and always safe in practice, but this command is built
|
||||
// as a single shellescaped string and then executed as shell code on the remote
|
||||
// host, so an unsafe value here is validated rather than trusted.
|
||||
if (!RESUME_ID_SAFE.test(sessionId)) {
|
||||
modeCommand = remoteLoginShellCommand(cmd);
|
||||
} else {
|
||||
const rid = resumeSessionId && RESUME_ID_SAFE.test(resumeSessionId) ? resumeSessionId : undefined;
|
||||
modeCommand = remoteLoginShellCommand(
|
||||
rid && rid !== sessionId
|
||||
? `${cmd} --resume ${rid} || ${cmd} --session-id ${sessionId}`
|
||||
: `${cmd} --session-id ${sessionId} || ${cmd} --resume ${sessionId}`
|
||||
);
|
||||
}
|
||||
} else if (mode === 'omp') {
|
||||
// Remote OMP respawn must RESUME the same conversation instead of
|
||||
// relaunching fresh (found live 2026-08-29: remote ctrl-c/ctrl-d relaunched
|
||||
// a brand-new omp session). The pinned id, when known, is passed as an
|
||||
// explicit --resume; otherwise fall back to omp's own "most recent"
|
||||
// --continue so a dead-pane respawn still lands back in the conversation.
|
||||
// Rendered through the CLI registry (buildSpawnCommandFromRegistry), the
|
||||
// SAME mode-agnostic path local/docker spawns use — not appendResumeFlag(),
|
||||
// which would hand the id to the login shell as $0 after the quoted `-c
|
||||
// 'omp'`, and not a raw buildOmpCommand() call, which the registry refactor
|
||||
// (#347) deleted. Gives every registry CLI with a resume form this
|
||||
// behaviour for free, and the flags can't drift from the local builder.
|
||||
const ompEntry = getCli('omp');
|
||||
const ompCmd = ompEntry
|
||||
? (buildSpawnCommandFromRegistry(ompEntry, {
|
||||
mode: 'omp',
|
||||
sessionId,
|
||||
ompConfig: {
|
||||
...ompConfig,
|
||||
resumeSessionId: resumeSessionId || ompConfig?.resumeSessionId,
|
||||
},
|
||||
}) ?? 'omp')
|
||||
: 'omp';
|
||||
modeCommand = remoteLoginShellCommand(ompCmd);
|
||||
} else {
|
||||
modeCommand = defaultRemoteCommandForMode(mode);
|
||||
}
|
||||
const remoteName = remoteTmuxSessionName(sessionId);
|
||||
|
||||
// Innermost: the command tmux runs in the new pane. Run via `/bin/sh -c` by
|
||||
@@ -951,14 +1076,23 @@ export interface DockerLaunchOptions {
|
||||
export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
||||
const { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies } = opts;
|
||||
const base = buildDockerBaseArgs(docker).join(' ');
|
||||
const createArgs = buildDockerCreateArgs(createContext).join(' ');
|
||||
// ADOPTED container (docker.owned === false): the user built it and runs it, so
|
||||
// this chain may only LOOK and then exec. No image check (the image is theirs),
|
||||
// no create, and above all no `start` — starting a container we do not own is
|
||||
// exactly the lifecycle mutation adoption promises never to perform. A missing
|
||||
// or stopped container fails closed with an actionable message instead.
|
||||
const adopted = docker.owned === false;
|
||||
// Built lazily: an adopted case has no meaningful create-config, so computing
|
||||
// create args for it would demand a context the adopt path never assembles.
|
||||
const createArgs = adopted ? '' : buildDockerCreateArgs(createContext).join(' ');
|
||||
const name = shellescape(docker.containerName);
|
||||
const workdir = shellescape(docker.containerWorkdir);
|
||||
const image = shellescape(docker.image);
|
||||
const dkrName = dockerTmuxSessionName(sessionId);
|
||||
const sid = sessionId.slice(0, 8);
|
||||
|
||||
let modeCommand = docker.commands?.[mode as DockerCommandMode] || defaultDockerCommandForMode(mode);
|
||||
let modeCommand =
|
||||
docker.commands?.[mode as DockerCommandMode] || defaultDockerCommandForMode(mode, !!docker.runsAsRoot);
|
||||
if (mode === 'claude') {
|
||||
modeCommand = claudeDockerPaneCommand(modeCommand, sessionId, resumeSessionId);
|
||||
} else if (resumeSessionId) {
|
||||
@@ -994,7 +1128,16 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
||||
);
|
||||
const startFailMsg = shellescape(`Codeman: container ${docker.containerName} failed to start (docker daemon down?)`);
|
||||
|
||||
const imageCheck = `${base} image inspect ${image} >/dev/null 2>&1 || { echo ${imageMissingMsg}; exit 1; }`;
|
||||
const notFoundMsg = shellescape(
|
||||
`Codeman: container ${docker.containerName} not found. Adopted containers are never created by Codeman - start it yourself, then reopen this session.`
|
||||
);
|
||||
const notRunningMsg = shellescape(
|
||||
`Codeman: container ${docker.containerName} is not running. Codeman never starts a container it does not own - start it yourself, then reopen this session.`
|
||||
);
|
||||
|
||||
const imageCheck = adopted
|
||||
? ''
|
||||
: `${base} image inspect ${image} >/dev/null 2>&1 || { echo ${imageMissingMsg}; exit 1; }`;
|
||||
// create-if-missing (idempotent): reconnect / boot recovery re-runs this exact
|
||||
// chain. A daemon without swap accounting warns whenever --memory is present,
|
||||
// even when --memory-swap is omitted. In compatibility mode, retain the memory
|
||||
@@ -1011,14 +1154,27 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
||||
`elif ${base} inspect ${name} >/dev/null 2>&1; then ${removeCreateOutput}; ` +
|
||||
`else ${filteredCreateOutput} >&2; ${removeCreateOutput}; false; fi; }`
|
||||
: `${base} ${createArgs}`;
|
||||
const ensure = `${base} inspect ${name} >/dev/null 2>&1 || ${createCommand}`;
|
||||
const start = `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`;
|
||||
const ensure = adopted
|
||||
? `${base} inspect ${name} >/dev/null 2>&1 || { echo ${notFoundMsg}; exit 1; }`
|
||||
: `${base} inspect ${name} >/dev/null 2>&1 || ${createCommand}`;
|
||||
// ⚠️ No double quotes and no `$(…)` in the ADOPTED arms. This whole chain is
|
||||
// embedded in an outer `bash -c "…"`, so an unescaped `"` closes that string early,
|
||||
// the rest is re-tokenized, and tmux fails to exec with a bare `execvp(3) failed`.
|
||||
// A `grep -qx` pipeline reads the same answer using only the single-quoted form
|
||||
// every other line in this builder already uses.
|
||||
const start = adopted
|
||||
? `${base} inspect -f ${shellescape('{{.State.Running}}')} ${name} 2>/dev/null | grep -qx true || { echo ${notRunningMsg}; exit 1; }`
|
||||
: `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`;
|
||||
// Seed writable credential config from read-only host mounts ONCE per container
|
||||
// (guarded by [ -e ] so reconnects never clobber in-container config; `cp -a` for
|
||||
// whole-dir credential seeds). mkdir -p the parent so a file seed works even when
|
||||
// no sibling share-mount pre-created the dir. Paths are fixed CONTAINER_HOME
|
||||
// constants (no shell metachars), so the whole inner command is shell-quoted once.
|
||||
const seedSteps = (seedCopies ?? []).map((s) => {
|
||||
// An ADOPTED container gets NO seed copies: those read from create-time
|
||||
// read-only mounts that do not exist here, and writing host credentials into a
|
||||
// container the user owns is a mutation adoption does not permit. Its CLIs must
|
||||
// already be authenticated inside it.
|
||||
const seedSteps = (adopted ? [] : (seedCopies ?? [])).map((s) => {
|
||||
const cp = s.recursive ? 'cp -a' : 'cp';
|
||||
const parent = s.to.slice(0, s.to.lastIndexOf('/'));
|
||||
return `mkdir -p ${parent} 2>/dev/null; [ -e ${s.to} ] || ${cp} ${s.from} ${s.to} 2>/dev/null || true`;
|
||||
@@ -1026,7 +1182,7 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
||||
const innerCmd = seedSteps.length ? `${seedSteps.join(' ; ')} ; ${tmuxInvocation}` : tmuxInvocation;
|
||||
const execCmd = `exec ${base} exec -it --workdir ${workdir} ${execEnvFlags.join(' ')} ${name} sh -lc ${shellescape(innerCmd)}`;
|
||||
|
||||
return [imageCheck, ensure, start, execCmd].join(' ; ');
|
||||
return [imageCheck, ensure, start, execCmd].filter(Boolean).join(' ; ');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1042,13 +1198,29 @@ export function buildDockerKillCommand(options: { docker: SessionDocker; session
|
||||
return `${base} exec ${shellescape(docker.containerName)} tmux -L ${DOCKER_TMUX_SOCKET} kill-session -t ${shellescape(dkrName)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard for the two builders that mutate CONTAINER lifecycle. They are pure
|
||||
* string builders, so refusing here means an adopted container cannot even have
|
||||
* a stop/remove command constructed for it — there is no shape of caller bug
|
||||
* that turns into a `docker stop`/`rm` on something we do not own.
|
||||
*/
|
||||
function assertOwnedContainer(docker: SessionDocker, action: string): void {
|
||||
if (docker.owned === false) {
|
||||
throw new Error(
|
||||
`Refusing to ${action} adopted container "${docker.containerName}": Codeman does not own its lifecycle.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Explicit container stop (frees RAM/CPU; conversation resumes on next launch via --resume). */
|
||||
export function buildDockerStopCommand(docker: SessionDocker): string {
|
||||
assertOwnedContainer(docker, 'stop');
|
||||
return `${buildDockerBaseArgs(docker).join(' ')} stop -t 10 ${shellescape(docker.containerName)}`;
|
||||
}
|
||||
|
||||
/** Explicit container removal (case-delete). Destroys in-image state; bind mounts survive. */
|
||||
export function buildDockerRemoveCommand(docker: SessionDocker): string {
|
||||
assertOwnedContainer(docker, 'remove');
|
||||
return `${buildDockerBaseArgs(docker).join(' ')} rm -f ${shellescape(docker.containerName)}`;
|
||||
}
|
||||
|
||||
@@ -1195,6 +1367,9 @@ function buildRemoteSessionCommand(options: {
|
||||
sessionId: string;
|
||||
claudeMode?: ClaudeMode;
|
||||
allowedTools?: string;
|
||||
/** OMP only — resume/continue overrides for a remote omp relaunch. */
|
||||
ompConfig?: OmpConfig;
|
||||
resumeSessionId?: string;
|
||||
}): string {
|
||||
const { remote, sessionId } = options;
|
||||
if (remote.owned === false) {
|
||||
@@ -1725,7 +1900,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// `missingCliMessage()` returns null for a mode with no binary to find (`shell`), and
|
||||
// carries bounded PATH/login-shell/search-dir diagnostics so the error says where we
|
||||
// actually looked.
|
||||
if (!cliDir) {
|
||||
//
|
||||
// ⚠️ Skipped entirely for a DOCKER session: the CLI runs INSIDE the container, so the
|
||||
// host does not need it at all. Demanding it here threw for a host without the binary,
|
||||
// the catch fell back to a direct PTY, and that PTY tried to exec the CLI on the HOST —
|
||||
// surfacing as a bare `execvp(3) failed: No such file or directory` with nothing
|
||||
// pointing at the real cause. The container's own CLIs are verified by the adoption
|
||||
// preflight / image gate before launch instead.
|
||||
const cliRunsInContainer = !!docker;
|
||||
if (!cliRunsInContainer && !cliDir) {
|
||||
const message = missingCliMessage(mode);
|
||||
if (message) throw new Error(message);
|
||||
}
|
||||
@@ -1760,7 +1943,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const fullCmd = docker
|
||||
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
|
||||
: remote
|
||||
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools })
|
||||
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools, ompConfig, resumeSessionId })
|
||||
: localFullCmd;
|
||||
|
||||
// Create tmux session in three steps to handle cold-start (no server running)
|
||||
@@ -2011,7 +2194,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const fullCmd = docker
|
||||
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
|
||||
: remote
|
||||
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools })
|
||||
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools, ompConfig, resumeSessionId })
|
||||
: localFullCmd;
|
||||
|
||||
try {
|
||||
@@ -3144,11 +3327,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* the browser xterm reproduces the live frame. Used for fast tab switches.
|
||||
* - Full history (`opts.fullHistory`): `capture-pane -p -e -J -S -<N>` grabs
|
||||
* the tmux scrollback (COD-47, bounded to the configured history limit),
|
||||
* returned as linear scrollback text with SGR codes preserved (NOT
|
||||
* repositioned — a multi-screen history can't be painted into a single
|
||||
* visible frame, so the snapshot repaint is skipped). `-J` re-joins lines
|
||||
* hard-wrapped at the pane width so they reflow in the browser xterm.
|
||||
* Used for full page reloads so the user gets back their scroll history.
|
||||
* returned as linear scrollback text with SGR codes preserved. Rows are not
|
||||
* repainted at absolute positions — a multi-screen history can't be painted
|
||||
* into a single visible frame — but the capture DOES end with a cursor move
|
||||
* putting the caret back where the pane has it, counted up from the last
|
||||
* replayed row. `-J` re-joins lines hard-wrapped at the pane width so they
|
||||
* reflow in the browser xterm. Used for full page reloads so the user gets
|
||||
* back their scroll history. Returns '' for a pane holding nothing visible,
|
||||
* so the caller keeps whatever history it already had.
|
||||
* Caveat: lines tmux has already evicted past its history-limit are gone.
|
||||
*/
|
||||
/**
|
||||
@@ -3207,42 +3393,41 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
execOpts.maxBuffer =
|
||||
(opts?.maxCaptureBytes ?? DEFAULT_TERMINAL_BUFFER_MAX_BYTES) + FULL_HISTORY_CAPTURE_SLACK_BYTES;
|
||||
}
|
||||
const buffer = execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts).replace(
|
||||
/\n+$/g,
|
||||
''
|
||||
);
|
||||
// Full-history spans many screens — return it as raw linear scrollback
|
||||
// rather than repainting rows at single-screen absolute positions. tmux
|
||||
// joins scrollback rows with a bare `\n`; normalize to `\r\n` so a fresh
|
||||
// xterm (convertEol:false) starts each replayed line at column 0 instead
|
||||
// of staircasing diagonally (COD-138).
|
||||
if (fullHistory) {
|
||||
return normalizeScrollbackEol(buffer);
|
||||
}
|
||||
try {
|
||||
const cursor = execSync(
|
||||
const rawCapture = execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts);
|
||||
// Query the cursor BEFORE deciding anything else. On the full-history path
|
||||
// it settles both how the capture is trimmed and whether a cursor move is
|
||||
// appended, and those two have to agree: trailing blank rows are only safe
|
||||
// to keep when a move follows to put the caret back above them.
|
||||
const geometry = queryPaneCursor(() =>
|
||||
execSync(
|
||||
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`,
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}
|
||||
).trim();
|
||||
const [cursorX, cursorY, cols, rows] = cursor.split(/\s+/).map((value) => parseInt(value, 10));
|
||||
if (
|
||||
Number.isFinite(cursorX) &&
|
||||
Number.isFinite(cursorY) &&
|
||||
Number.isFinite(cols) &&
|
||||
Number.isFinite(rows) &&
|
||||
cursorX >= 0 &&
|
||||
cursorY >= 0 &&
|
||||
cols > 0 &&
|
||||
rows > 0
|
||||
) {
|
||||
return formatPaneSnapshot(buffer.split('\n'), { cols, rows, cursorX, cursorY });
|
||||
}
|
||||
} catch (cursorErr) {
|
||||
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
)
|
||||
);
|
||||
|
||||
if (fullHistory) {
|
||||
// Without geometry there is no cursor move, so fall back to the old trim.
|
||||
// Keeping the blank rows here would park the caret at the bottom of the
|
||||
// pane with nothing to correct it — worse than not trying at all.
|
||||
if (!geometry) return normalizeScrollbackEol(rawCapture.replace(/\n+$/g, ''));
|
||||
// Take the line terminator off and nothing else. The trailing blank rows
|
||||
// that remain are the real bottom of the screen, and the cursor move
|
||||
// below counts up from it. tmux joins rows with a bare `\n`; normalize to
|
||||
// `\r\n` so a fresh xterm (convertEol:false) starts each replayed line at
|
||||
// column 0 instead of staircasing diagonally (COD-138).
|
||||
const trimmed = rawCapture.replace(/\n$/, '');
|
||||
// An all-blank pane has to keep reading as "nothing to replay". The caller
|
||||
// treats an empty string as "capture unavailable" and keeps the byte
|
||||
// history; blank rows plus a cursor move are not empty, so without this a
|
||||
// blank pane REPLACES that history with a blank screen — the downgrade
|
||||
// `_replayWouldShrinkBuffer` exists to refuse, arriving from the server
|
||||
// side where that guard cannot see it.
|
||||
if (!hasVisibleContent(trimmed)) return '';
|
||||
return `${normalizeScrollbackEol(trimmed)}${formatCursorRestore(geometry)}`;
|
||||
}
|
||||
|
||||
const buffer = rawCapture.replace(/\n+$/g, '');
|
||||
if (geometry) return formatPaneSnapshot(buffer.split('\n'), geometry);
|
||||
// Cursor query failed or geometry was invalid, so we skip the absolute-
|
||||
// positioned snapshot repaint and fall back to the raw capture. Normalize
|
||||
// its bare `\n` line endings to `\r\n` so the replay doesn't staircase
|
||||
|
||||
@@ -110,6 +110,10 @@ export type HookEventType =
|
||||
| 'stop'
|
||||
| 'teammate_idle'
|
||||
| 'task_completed'
|
||||
// Claude Code's UserPromptSubmit. The payload's `session_id` is the pane's
|
||||
// LIVE conversation id, reported by the CLI process itself, so it survives a
|
||||
// `/clear` without any cwd/timestamp correlation.
|
||||
| 'prompt_submitted'
|
||||
// No Claude Code hook behind this one: it is the DeepSeek status bridge's
|
||||
// "a turn STARTED" report (see deepseek-status-shim.ts). Keep in step with
|
||||
// HookEventSchema in web/schemas.ts.
|
||||
@@ -153,6 +157,20 @@ export interface CaseInfo {
|
||||
location?: 'local' | 'linked-local' | 'remote' | 'docker';
|
||||
/** Whether this is a linked local folder */
|
||||
linked?: boolean;
|
||||
/**
|
||||
* Present when Codeman scaffolded this case directory for an AGENT-spawned session
|
||||
* (the packaged skill's workers, or any spawn naming a parent session), read back
|
||||
* from the case's own marker file — see `src/agent-case-marker.ts`. Absent for every
|
||||
* case a human created, linked or cloned, which is what makes it usable as the
|
||||
* "safe to clean up" signal in the Manage tab.
|
||||
*/
|
||||
agentCreated?: {
|
||||
createdAt: string;
|
||||
createdBy: string;
|
||||
parentSessionId?: string;
|
||||
parentSessionName?: string;
|
||||
mode?: string;
|
||||
};
|
||||
/** Remote case metadata for display and session creation */
|
||||
remote?: {
|
||||
hostId: string;
|
||||
@@ -167,9 +185,46 @@ export interface CaseInfo {
|
||||
image?: string;
|
||||
path: string;
|
||||
network?: string;
|
||||
/**
|
||||
* CLIs available INSIDE the container. A container case runs its agents in
|
||||
* the container, so HOST CLI availability says nothing about what it can
|
||||
* run. Absent = unknown (an owned container runs our base image, which ships
|
||||
* every CLI), which the UI reads as "do not gate".
|
||||
*/
|
||||
availableModes?: string[];
|
||||
/**
|
||||
* `false` for an ADOPTED container (mirror of `DockerCase.owned`); absent = owned.
|
||||
*
|
||||
* ⚠️ The UI needs this to read a FAILED container probe correctly. For an adopted
|
||||
* case a missing container is a real fault worth reporting, because the user is the
|
||||
* only one who can start it. For an owned case it is the NORMAL state before the
|
||||
* first session: the container is created on demand by the launch chain, so treating
|
||||
* "not found" as a fault there hid every agent mode behind an error telling the user
|
||||
* to start a container Codeman was about to create itself.
|
||||
*/
|
||||
owned?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One agent-created case as `GET /api/cases/agent-created` reports it: the cleanup
|
||||
* view over `CaseInfo.agentCreated`, with the two facts a human needs before deleting
|
||||
* a directory — whether an agent is still working in it, and when it was last touched.
|
||||
*/
|
||||
export interface AgentCaseSummary {
|
||||
name: string;
|
||||
path: string;
|
||||
createdAt: string;
|
||||
createdBy: string;
|
||||
parentSessionId?: string;
|
||||
parentSessionName?: string;
|
||||
mode?: string;
|
||||
/** A live session's working directory is this case — deleting it would pull the rug. */
|
||||
inUse: boolean;
|
||||
/** Directory mtime, so "nothing has touched this in a week" is answerable. */
|
||||
modifiedAt?: string;
|
||||
}
|
||||
|
||||
// ========== Error Handling Utilities ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -244,6 +244,33 @@ export interface DockerCase {
|
||||
containerWorkdir?: string;
|
||||
/** Container name (default codeman-case-<slug>). */
|
||||
container?: string;
|
||||
/**
|
||||
* Whether THIS Codeman created the container (mirror of `SessionRemote.owned`).
|
||||
*
|
||||
* - `true` (default for cases Codeman linked/quick-created): we own the
|
||||
* container; drift may recreate it, case-delete may `docker rm -f` it, and
|
||||
* the launch chain may create + start it.
|
||||
* - `false` (ADOPTED: an already-running container the user built and runs
|
||||
* themselves): Codeman must never create, start, stop, restart or remove it.
|
||||
* The launch chain fails closed when the container is missing or not running
|
||||
* instead of touching its lifecycle, drift is not evaluated (there is no
|
||||
* `codeman.confighash` label to compare), and no credential seed is copied
|
||||
* into its HOME. Only the in-container tmux session is ever created or
|
||||
* killed — exactly the `owned:false` remote-SSH contract.
|
||||
*
|
||||
* Absent is treated as owned (cases persisted before this field existed were
|
||||
* all created by us).
|
||||
*/
|
||||
owned?: boolean;
|
||||
/**
|
||||
* CLIs found INSIDE the container by the adoption preflight. A container case
|
||||
* runs its agents in the container, so host CLI availability says nothing about
|
||||
* what this case can run — the base image ships every CLI, and an adopted
|
||||
* container ships whatever its owner installed. Absent = unknown (owned cases,
|
||||
* or a case linked before this field existed), which callers read as "do not
|
||||
* gate".
|
||||
*/
|
||||
availableModes?: SessionMode[];
|
||||
/** Last captured Claude conversation id, replayed via --resume on a fresh launch. */
|
||||
lastClaudeSessionId?: string;
|
||||
}
|
||||
@@ -276,6 +303,19 @@ export interface SessionDocker {
|
||||
extraExecArgs?: string[];
|
||||
/** Stable hash of the drift-relevant create args (recreate-on-drift detection). */
|
||||
configHash?: string;
|
||||
/**
|
||||
* Whether the container's exec user is root. Claude Code REFUSES
|
||||
* `--dangerously-skip-permissions` as root, and an adopted container's user
|
||||
* belongs to its owner, so the flag is omitted rather than letting the pane
|
||||
* die with a message only visible inside the container.
|
||||
*/
|
||||
runsAsRoot?: boolean;
|
||||
/**
|
||||
* Mirror of `DockerCase.owned`, flattened onto the live session so every
|
||||
* lifecycle decision (launch chain, drift, stop, remove) can see it without
|
||||
* re-reading docker-cases.json. Absent = owned. See `DockerCase.owned`.
|
||||
*/
|
||||
owned?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -648,6 +688,15 @@ export interface SessionState {
|
||||
* again until the pane's own Enter is known.
|
||||
*/
|
||||
lastSubmitAt?: number;
|
||||
/**
|
||||
* Claude conversations this pane has been on, oldest first, current last.
|
||||
* Written ONLY from a first-hand `UserPromptSubmit`/`Stop` hook payload —
|
||||
* never from the history correlation — so it cannot record a sibling pane's
|
||||
* conversation. Persisted because `/clear` is otherwise unrecoverable: once
|
||||
* the pane moves on, the predecessor id exists nowhere else, and the last
|
||||
* entry is what re-pins `claudeSessionId` past `start()`'s three resets.
|
||||
*/
|
||||
claudeSessionChain?: string[];
|
||||
/**
|
||||
* PTY-exit circuit breaker tripped — respawn blocked until an explicit restart
|
||||
* (COD-118). Runtime-only: never restored on boot (fresh server = fresh breaker).
|
||||
|
||||
+54
-3
@@ -7,6 +7,10 @@
|
||||
* (see `dataPath('update-status.json')`) that the browser polls across the
|
||||
* restart boundary.
|
||||
*
|
||||
* The Docker Compose deployment updates in place too (same script, same status
|
||||
* file) — see `docs/docker-self-update.md` for how the container restarts itself
|
||||
* and what the environment gate refuses.
|
||||
*
|
||||
* Backend logic: `src/web/self-update.ts`. Routes: `src/web/routes/system-routes.ts`
|
||||
* (`/api/system/update/check`, `POST /api/system/update`, `/api/system/update/status`).
|
||||
*
|
||||
@@ -17,11 +21,56 @@
|
||||
* Which init system supervises the running server (decides how we restart it).
|
||||
* `launchd-daemon` = a KeepAlive system-level LaunchDaemon (headless Macs, no GUI
|
||||
* login): restart works by killing the server and letting launchd respawn it.
|
||||
* `docker-compose` = the Compose deployment (`docker/docker-compose.yaml`): the
|
||||
* "restart" is the server exiting so the container's `restart: unless-stopped`
|
||||
* policy relaunches it on the freshly built `dist/`.
|
||||
*/
|
||||
export type SupervisorKind = 'systemd' | 'launchd' | 'launchd-daemon' | 'none';
|
||||
export type SupervisorKind = 'systemd' | 'launchd' | 'launchd-daemon' | 'docker-compose' | 'none';
|
||||
|
||||
/** How Codeman was installed — only `git` installs can self-update in place. */
|
||||
export type InstallKind = 'git' | 'npm' | 'unknown';
|
||||
/**
|
||||
* How Codeman was installed. `git` and `docker-compose` can self-update in
|
||||
* place; `docker-compose` is a git checkout bind-mounted into the container, so
|
||||
* the pull/build happen on the host filesystem and survive container recreation.
|
||||
*/
|
||||
export type InstallKind = 'git' | 'docker-compose' | 'npm' | 'unknown';
|
||||
|
||||
/**
|
||||
* Why an in-place container update is refused. Each is derived mechanically from
|
||||
* the target release's own files — nothing here depends on a human remembering
|
||||
* to declare something at release time.
|
||||
*
|
||||
* - `dockerfile-changed` / `compose-changed`: the release changes the ENVIRONMENT,
|
||||
* which a self-restart cannot apply (a restart reuses the existing container's
|
||||
* image and config). Needs a rebuild + recreate from the host.
|
||||
* - `env-keys-missing`: the release's `docker/.env.example` gained keys the user's
|
||||
* `docker/.env` has no value for. Compose interpolates an unset `${VAR}` to the
|
||||
* EMPTY STRING and starts anyway, so without this check a new required setting
|
||||
* arrives as a silently blank env var.
|
||||
* - `no-auto-restart`: the container's restart policy would not bring it back
|
||||
* after the server exits, so applying the update would take Codeman down.
|
||||
*/
|
||||
export type EnvironmentBlockerKind = 'dockerfile-changed' | 'compose-changed' | 'env-keys-missing' | 'no-auto-restart';
|
||||
|
||||
/** One reason an in-place container update is refused, with UI-ready text. */
|
||||
export interface EnvironmentBlocker {
|
||||
kind: EnvironmentBlockerKind;
|
||||
/** One-line explanation shown in App Settings → Updates. */
|
||||
message: string;
|
||||
/** Optional specifics (e.g. the names of the missing env keys). */
|
||||
details?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of the environment gate for a candidate release. `checked: false` means
|
||||
* the gate did not run (not a container install, or the target tag's files could
|
||||
* not be read) — callers must not treat that as "no blockers".
|
||||
*/
|
||||
export interface EnvironmentGate {
|
||||
checked: boolean;
|
||||
blockers: EnvironmentBlocker[];
|
||||
/** The host command that resolves every blocker. */
|
||||
hostCommand: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle of a single update run. `idle`/`completed`/`failed`/
|
||||
@@ -96,6 +145,8 @@ export interface UpdateCheckResult {
|
||||
/** epoch ms of the check. */
|
||||
checkedAt: number;
|
||||
source: 'github-api' | 'git-ls-remote' | 'none';
|
||||
/** Environment gate for THIS candidate release (container installs only). */
|
||||
environment?: EnvironmentGate;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,23 @@ import { join, sep } from 'node:path';
|
||||
/** A real OMP session file is `<ISO-ish-timestamp>_<uuid>.jsonl`; only the uuid matters here. */
|
||||
const OMP_SESSION_FILE_PATTERN = /^.+_([a-zA-Z0-9-]+)\.jsonl$/;
|
||||
|
||||
/**
|
||||
* Strip a trailing `/` from a workingDir unless it is the root itself.
|
||||
*
|
||||
* Case paths routinely end in `/` — a remote case's `remotePath` is stored
|
||||
* verbatim (e.g. `/home/user/dotfiles/`) — but omp persists sessions under
|
||||
* the slash-less mangle (`-dotfiles`) with a header `cwd` of
|
||||
* `/home/user/dotfiles`. Without normalization, the trailing slash survives
|
||||
* the mangle (`-dotfiles-`), `readdirSync` returns null for a directory that
|
||||
* exists, and OMP respawn pinning silently degrades to the ambiguous
|
||||
* `--continue` (found live 2026-08-29: a remote OMP ctrl-c relaunched a fresh
|
||||
* conversation instead of resuming). Exported so the same normalization is
|
||||
* used for the header-`cwd` comparison in {@link resolveAndClaimOmpSessionId}.
|
||||
*/
|
||||
export function stripTrailingSlash(workingDir: string): string {
|
||||
return workingDir.length > 1 && workingDir.endsWith('/') ? workingDir.slice(0, -1) : workingDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors `omp`'s own directory mangling. Confirmed empirically against real
|
||||
* `~/.omp/agent/sessions/` directory names (2026-08-27): unlike Claude Code's
|
||||
@@ -45,8 +62,9 @@ export function mangleOmpWorkingDir(workingDir: string): string {
|
||||
// omp's actual behavior on a symlinked-home setup; guessing wrong here would
|
||||
// trade one silent mismatch for a different one.
|
||||
const home = homedir();
|
||||
const normalized = stripTrailingSlash(workingDir);
|
||||
const relative =
|
||||
workingDir === home || workingDir.startsWith(home + sep) ? workingDir.slice(home.length) : workingDir;
|
||||
normalized === home || normalized.startsWith(home + sep) ? normalized.slice(home.length) : normalized;
|
||||
return relative.replace(/\//g, '-');
|
||||
}
|
||||
|
||||
@@ -183,7 +201,9 @@ export function resolveAndClaimOmpSessionId(workingDir: string): string | null {
|
||||
}
|
||||
if (mtimeMs <= newestMtime) continue;
|
||||
const header = readOmpSessionHeader(filePath);
|
||||
if (!header || header.cwd !== workingDir || claimedOmpSessionIds.has(header.id)) continue;
|
||||
// Compare against the slash-normalized workingDir: the session's own
|
||||
// workingDir may carry a trailing slash while omp's header cwd never does.
|
||||
if (!header || header.cwd !== stripTrailingSlash(workingDir) || claimedOmpSessionIds.has(header.id)) continue;
|
||||
newestMtime = mtimeMs;
|
||||
newestId = header.id;
|
||||
}
|
||||
|
||||
+17
-11
@@ -142,7 +142,9 @@ function isPasswordChangeExempt(req: FastifyRequest): boolean {
|
||||
* match the prefix at all. The Host allowlist is NOT bypassed, so DNS-rebinding
|
||||
* protection still applies to these requests.
|
||||
*/
|
||||
function hasValidWebviewCapability(req: FastifyRequest): boolean {
|
||||
function hasValidWebviewCapability(req: FastifyRequest, basePath = ''): boolean {
|
||||
// req.url is already base-stripped by the server's rewriteUrl, so the path form
|
||||
// needs no base; the Referer form below is browser-supplied and does.
|
||||
const url = (req.url ?? '').split('?')[0];
|
||||
|
||||
const fromPath = capabilityFromProxyPath(url);
|
||||
@@ -167,7 +169,10 @@ function hasValidWebviewCapability(req: FastifyRequest): boolean {
|
||||
// class the 404 relay could never rescue. See matchesRegisteredRoute.
|
||||
if (matchesRegisteredRoute(req, url)) return false;
|
||||
|
||||
const fromReferer = capabilityFromReferer(typeof req.headers.referer === 'string' ? req.headers.referer : undefined);
|
||||
const fromReferer = capabilityFromReferer(
|
||||
typeof req.headers.referer === 'string' ? req.headers.referer : undefined,
|
||||
basePath
|
||||
);
|
||||
return !!fromReferer && webviewCapabilities.resolve(fromReferer) !== undefined;
|
||||
}
|
||||
|
||||
@@ -210,7 +215,7 @@ function matchesRegisteredRoute(req: FastifyRequest, url: string): boolean {
|
||||
*
|
||||
* @returns AuthState for lifecycle management (dispose on server stop)
|
||||
*/
|
||||
export function registerAuthMiddleware(app: FastifyInstance, https: boolean): AuthState {
|
||||
export function registerAuthMiddleware(app: FastifyInstance, https: boolean, basePath = ''): AuthState {
|
||||
const state: AuthState = {
|
||||
authSessions: null,
|
||||
authFailures: null,
|
||||
@@ -270,7 +275,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
ttlMs: AUTH_FAILURE_WINDOW_MS,
|
||||
refreshOnGet: false,
|
||||
});
|
||||
registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, state.userFailures);
|
||||
registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, state.userFailures, basePath);
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -293,7 +298,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
}
|
||||
|
||||
// Web-tab proxy, authenticated by the capability in the path, not the cookie.
|
||||
if (hasValidWebviewCapability(req)) {
|
||||
if (hasValidWebviewCapability(req, basePath)) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
@@ -381,7 +386,8 @@ function registerMultiUserAuthHook(
|
||||
authSessions: StaleExpirationMap<string, AuthSessionRecord>,
|
||||
authFailures: StaleExpirationMap<string, number>,
|
||||
hookSecretFailures: StaleExpirationMap<string, number>,
|
||||
userFailures: StaleExpirationMap<string, number>
|
||||
userFailures: StaleExpirationMap<string, number>,
|
||||
basePath = ''
|
||||
): void {
|
||||
const setSessionCookie = (reply: FastifyReply, token: string) =>
|
||||
reply.setCookie(AUTH_COOKIE_NAME, token, {
|
||||
@@ -432,7 +438,7 @@ function registerMultiUserAuthHook(
|
||||
// `req.authUser` stays undefined here on purpose: the proxy handler enforces
|
||||
// ownership against the identity BOUND TO THE CAPABILITY, which is stricter
|
||||
// than re-deriving it from a request that carries no credentials.
|
||||
if (hasValidWebviewCapability(req)) return;
|
||||
if (hasValidWebviewCapability(req, basePath)) return;
|
||||
|
||||
const clientIp = req.ip;
|
||||
|
||||
@@ -552,7 +558,7 @@ const SAFE_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
*
|
||||
* WebSocket upgrades are validated separately in the ws route handler.
|
||||
*/
|
||||
export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPolicy): void {
|
||||
export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPolicy, basePath = ''): void {
|
||||
app.addHook('onRequest', (req, reply, done) => {
|
||||
const policy = getPolicy();
|
||||
if (!isAllowedRequestHost(req.headers.host, policy)) {
|
||||
@@ -568,7 +574,7 @@ export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPol
|
||||
if (
|
||||
!SAFE_HTTP_METHODS.has(req.method) &&
|
||||
!isAllowedRequestOrigin(req.headers.origin, policy) &&
|
||||
!hasValidWebviewCapability(req)
|
||||
!hasValidWebviewCapability(req, basePath)
|
||||
) {
|
||||
reply.code(403).send('Forbidden: cross-site request blocked');
|
||||
return;
|
||||
@@ -580,7 +586,7 @@ export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPol
|
||||
/**
|
||||
* Register security headers and CORS middleware on every response.
|
||||
*/
|
||||
export function registerSecurityHeaders(app: FastifyInstance, https: boolean): void {
|
||||
export function registerSecurityHeaders(app: FastifyInstance, https: boolean, basePath = ''): void {
|
||||
// Gesture-control overlay (opt-in via CODEMAN_GESTURE=1) runs MediaPipe, which
|
||||
// needs WebAssembly eval (script-src) and blob workers (worker-src). Its wasm
|
||||
// runtime + model are self-hosted under /gesture/ (same-origin, covered by
|
||||
@@ -634,7 +640,7 @@ export function registerSecurityHeaders(app: FastifyInstance, https: boolean): v
|
||||
// net::ERR_FAILED while the page itself renders fine (script/css/img loads
|
||||
// are not CORS-checked). Falling through lets the proxy route reply with the
|
||||
// right headers.
|
||||
if (req.method === 'OPTIONS' && !hasValidWebviewCapability(req)) {
|
||||
if (req.method === 'OPTIONS' && !hasValidWebviewCapability(req, basePath)) {
|
||||
reply.code(204).send();
|
||||
done();
|
||||
return;
|
||||
|
||||
+243
-20
@@ -80,7 +80,7 @@ try {
|
||||
const prev = localStorage.getItem('codeman-crash-diag');
|
||||
if (prev) {
|
||||
console.log('[CRASH-DIAG] Previous session breadcrumbs:\n' + prev);
|
||||
navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: prev, id: _crashDiag._pageId + '-prev' }));
|
||||
navigator.sendBeacon(CodemanBase.url('/api/crash-diag'), JSON.stringify({ data: prev, id: _crashDiag._pageId + '-prev' }));
|
||||
}
|
||||
} catch {}
|
||||
_crashDiag.log('PAGE LOAD');
|
||||
@@ -89,7 +89,7 @@ _crashDiag.log('PAGE LOAD');
|
||||
function _crashDiagBeacon() {
|
||||
try {
|
||||
if (_crashDiag._entries.length > 0) {
|
||||
navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: _crashDiag._entries.join('\n'), id: _crashDiag._pageId }));
|
||||
navigator.sendBeacon(CodemanBase.url('/api/crash-diag'), JSON.stringify({ data: _crashDiag._entries.join('\n'), id: _crashDiag._pageId }));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -1268,7 +1268,11 @@ class CodemanApp {
|
||||
if (typeof window !== 'undefined' && typeof window.__CODEMAN_SOLO__ === 'string' && window.__CODEMAN_SOLO__) {
|
||||
return window.__CODEMAN_SOLO__;
|
||||
}
|
||||
const m = location.pathname.match(/^\/session\/([^/]+)\/?$/);
|
||||
// Strip the reverse-proxy base so the match works under a sub-path mount.
|
||||
const base = window.CodemanBase?.base || '';
|
||||
let path = location.pathname;
|
||||
if (base && path.startsWith(base)) path = path.slice(base.length) || '/';
|
||||
const m = path.match(/^\/session\/([^/]+)\/?$/);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
@@ -1293,7 +1297,7 @@ class CodemanApp {
|
||||
if (this.detachedSessions.has(id) && this._raiseDetached(id)) return;
|
||||
const features = 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no';
|
||||
let win = null;
|
||||
try { win = window.open('/session/' + encodeURIComponent(id), 'codeman-session-' + id, features); } catch {}
|
||||
try { win = window.open(CodemanBase.url('/session/' + encodeURIComponent(id)), 'codeman-session-' + id, features); } catch {}
|
||||
if (!win) {
|
||||
this.showToast?.('Pop-out blocked — allow popups for this site to detach a session', 'error');
|
||||
return;
|
||||
@@ -1328,7 +1332,11 @@ class CodemanApp {
|
||||
this._redock(id);
|
||||
}
|
||||
|
||||
/** Clear all dashboard-side detached state/timers for a session. */
|
||||
/** Clear all dashboard-side detached state/timers for a session, and take its
|
||||
* sizing back: the popup owned the pane while it was open, so the dashboard's
|
||||
* record of it is stale and the session it is showing needs re-measuring.
|
||||
* ⚠️ Not idempotent — each call re-asserts, so a path that redocks twice for
|
||||
* one close sends two SIGWINCHs. */
|
||||
_redock(id) {
|
||||
const t = this._detachWatchTimers.get(id);
|
||||
if (t) { clearInterval(t); this._detachWatchTimers.delete(id); }
|
||||
@@ -1336,6 +1344,21 @@ class CodemanApp {
|
||||
this._detachOrphanStrikes.delete(id);
|
||||
this.detachedWindows.delete(id);
|
||||
this._markDetached(id, false);
|
||||
// While the popup owned this session the dashboard sent no resizes, so
|
||||
// `_lastResizeDims` — one value for the whole window — no longer describes
|
||||
// the PTY, which the popup has been sizing. Clearing it makes the next
|
||||
// sendResize report truthfully, on every redock path rather than only the
|
||||
// active one: `selectSession` reads that answer to decide whether to wait
|
||||
// for the TUI's redraw, and a false "unchanged" makes it fetch the frame
|
||||
// before the redraw lands.
|
||||
this._lastResizeDims = null;
|
||||
// Sizing comes back with the session. `force` buys a guaranteed repaint for
|
||||
// the case where popup and dashboard happened to agree on a size; the server
|
||||
// already resizes on its own comparison against the real pane whenever the
|
||||
// two differ.
|
||||
if (this.sessions.has(id) && id === this.activeSessionId) {
|
||||
this.sendResize(id, { force: true })?.catch?.(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Defer a channel-driven redock briefly. A popup *reload* emits 'redocked'
|
||||
@@ -1545,7 +1568,7 @@ class CodemanApp {
|
||||
// regardless of filter (server side).
|
||||
const _sseParams = new URLSearchParams({ clientId: this._clientId });
|
||||
if (this.activeSessionId) _sseParams.set('sessions', this.activeSessionId);
|
||||
this.eventSource = new EventSource(`/api/events?${_sseParams.toString()}`);
|
||||
this.eventSource = new EventSource(CodemanBase.url(`/api/events?${_sseParams.toString()}`));
|
||||
|
||||
// Store all event listeners for cleanup on reconnect.
|
||||
//
|
||||
@@ -2126,6 +2149,16 @@ class CodemanApp {
|
||||
return;
|
||||
}
|
||||
|
||||
// A `localhost` URL in the agent's answer: from another device that can
|
||||
// only load through the server, so hand it to a proxied web tab
|
||||
// (webview-tabs.js). Every other link keeps its new-tab default.
|
||||
const urlLink = ev.target.closest('a[href]');
|
||||
if (urlLink && this.openLinkThroughWebTabIfLoopback?.(urlLink.href)) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// One-click copy: lift the raw source from the sibling <pre><code>.
|
||||
const copyBtn = ev.target.closest('.rv-copy-btn');
|
||||
if (copyBtn) {
|
||||
@@ -2183,15 +2216,26 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
/** Build one response-viewer message so the brief and full views share markup and CSS. */
|
||||
_buildResponseViewerMessage(text, role, agentLabel) {
|
||||
_buildResponseViewerMessage(text, role, agentLabel, meta) {
|
||||
const div = document.createElement('div');
|
||||
const isUser = role === 'user';
|
||||
div.className = 'rv-message ' + (isUser ? 'rv-msg-user' : 'rv-msg-assistant');
|
||||
// Consecutive messages from one speaker inside one turn are segments of a
|
||||
// single utterance: one badge, a hairline seam. Claude emits a median of 3
|
||||
// messages per turn (p90 11, max 51), so a badge per message would be the
|
||||
// card spam the old concatenation was introduced to avoid. `meta` is
|
||||
// optional so the brief view's 3-argument call keeps its exact shape.
|
||||
const continuation = !!(meta && meta.continuation);
|
||||
if (continuation) div.classList.add('rv-msg-cont');
|
||||
if (meta && meta.kind) div.dataset.kind = meta.kind;
|
||||
if (meta && meta.queued) div.dataset.queued = '1';
|
||||
|
||||
const roleBadge = document.createElement('div');
|
||||
roleBadge.className = 'rv-role ' + (isUser ? 'rv-role-user' : 'rv-role-assistant');
|
||||
roleBadge.textContent = isUser ? 'You' : agentLabel;
|
||||
div.appendChild(roleBadge);
|
||||
if (!continuation) {
|
||||
const roleBadge = document.createElement('div');
|
||||
roleBadge.className = 'rv-role ' + (isUser ? 'rv-role-user' : 'rv-role-assistant');
|
||||
roleBadge.textContent = isUser ? 'You' : agentLabel;
|
||||
div.appendChild(roleBadge);
|
||||
}
|
||||
|
||||
const renderedText = document.createElement('div');
|
||||
renderedText.className = 'rv-text';
|
||||
@@ -2291,10 +2335,19 @@ class CodemanApp {
|
||||
|
||||
if (!this.activeSessionId) return;
|
||||
try {
|
||||
// Source 1: Transcript JSONL (best quality — clean structured text from Claude)
|
||||
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response`);
|
||||
// Source 1: Transcript JSONL (best quality — clean structured text from Claude).
|
||||
// `context=turn` asks for the last ANSWERED turn as messages: a Claude
|
||||
// answer is a median of 3 model messages (p90 11), and `text` alone is
|
||||
// only the final one — usually a "Done." tail with the substance in the
|
||||
// rows before it. Readers that know no `turn` context (Codex, the pane
|
||||
// parser, an older server) answer with `text` only, and that path is
|
||||
// unchanged below.
|
||||
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response?context=turn`);
|
||||
const data = (await res.json())?.data ?? {};
|
||||
let lastResponse = data.text || '';
|
||||
const turnMessages = (Array.isArray(data.messages) ? data.messages : []).filter(
|
||||
(msg) => msg && msg.role === 'assistant' && typeof msg.text === 'string' && msg.text.trim()
|
||||
);
|
||||
|
||||
// Source 2: Terminal buffer fallback — strip ANSI, drop Claude CLI chrome.
|
||||
// Claude + shell only: _cleanTerminalBuffer knows Claude CLI's output, and
|
||||
@@ -2311,7 +2364,20 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
const body = document.getElementById('responseViewerBody');
|
||||
if (lastResponse) {
|
||||
if (turnMessages.length > 0) {
|
||||
// The whole last turn, rendered exactly as the full view renders that
|
||||
// turn: one badge, then badge-less continuation segments. The same
|
||||
// numeric-`turn` gate as loadFullContext, never same-role adjacency.
|
||||
const agentLabel = this._getResponseViewerAgentLabel();
|
||||
body.innerHTML = '';
|
||||
let previous = null;
|
||||
for (const msg of turnMessages) {
|
||||
const continuation = !!previous && typeof msg.turn === 'number' && previous.turn === msg.turn;
|
||||
body.appendChild(this._buildResponseViewerMessage(msg.text, 'assistant', agentLabel, { ...msg, continuation }));
|
||||
previous = msg;
|
||||
}
|
||||
this._bindResponseViewerInteractions(body);
|
||||
} else if (lastResponse) {
|
||||
// Keep the brief view inside the same message wrapper as the full
|
||||
// conversation view. The wrapper supplies the card, role badge and
|
||||
// descendant markdown styles that direct body children do not get.
|
||||
@@ -2332,7 +2398,12 @@ class CodemanApp {
|
||||
|
||||
viewer.classList.add('visible');
|
||||
backdrop.classList.add('visible');
|
||||
body.scrollTop = 0;
|
||||
// A multi-row turn opens at its NEWEST text, matching loadFullContext's
|
||||
// "scroll to bottom (latest message)". `scrollTop = 0` was right when the
|
||||
// brief view was a single card holding the last row; with the whole turn
|
||||
// rendered, the top is the turn's first narration line and the answer the
|
||||
// eye button exists to show can be several screens down.
|
||||
body.scrollTop = turnMessages.length > 1 ? body.scrollHeight : 0;
|
||||
} catch (err) {
|
||||
console.error('Failed to load response:', err);
|
||||
}
|
||||
@@ -2351,19 +2422,49 @@ class CodemanApp {
|
||||
if (!body) return;
|
||||
|
||||
if (messages.length === 0) {
|
||||
body.textContent = 'No conversation history available';
|
||||
// Never destroy what the eye button already rendered: the brief view has
|
||||
// a terminal-buffer fallback (see toggleResponseViewer) that this
|
||||
// endpoint does not, so an empty full-context result must not wipe a
|
||||
// real answer the user is reading.
|
||||
// ⚠️ Idempotent, because More deliberately stays live here: the branch
|
||||
// returns before the button is hidden so a transcript that appears a
|
||||
// moment later can still be loaded, and appending would then stack a
|
||||
// second identical notice on every retry.
|
||||
// `:scope >` keeps the lookup off model-rendered markdown inside .rv-text.
|
||||
let notice = body.querySelector(':scope > .rv-notice');
|
||||
if (!notice) {
|
||||
notice = document.createElement('div');
|
||||
notice.className = 'rv-notice';
|
||||
body.appendChild(notice);
|
||||
}
|
||||
const emptyText = 'No full conversation history available for this session';
|
||||
notice.textContent = window.codemanT?.(emptyText) || emptyText;
|
||||
return;
|
||||
}
|
||||
|
||||
// Render conversation thread
|
||||
const agentLabel = this._getResponseViewerAgentLabel();
|
||||
body.innerHTML = '';
|
||||
let previous = null;
|
||||
for (const msg of messages) {
|
||||
body.appendChild(this._buildResponseViewerMessage(msg.text, msg.role, agentLabel));
|
||||
// ⚠️ A numeric `turn` is REQUIRED, never same-role adjacency alone.
|
||||
// Only the Claude reader emits turns; Codex and the external-CLI pane
|
||||
// parser emit adjacent assistant/response blocks with no turn at all, and
|
||||
// an older server emits none either — all three must keep rendering one
|
||||
// badged card per message exactly as they do today.
|
||||
const continuation =
|
||||
!!previous && previous.role === msg.role && typeof msg.turn === 'number' && previous.turn === msg.turn;
|
||||
body.appendChild(this._buildResponseViewerMessage(msg.text, msg.role, agentLabel, { ...msg, continuation }));
|
||||
previous = msg;
|
||||
}
|
||||
this._bindResponseViewerInteractions(body);
|
||||
|
||||
if (title) title.textContent = `Conversation (${messages.length} messages)`;
|
||||
const turns = new Set(messages.filter((msg) => typeof msg.turn === 'number').map((msg) => msg.turn)).size;
|
||||
if (title) {
|
||||
title.textContent = turns
|
||||
? `Conversation (${messages.length} messages, ${turns} turns)`
|
||||
: `Conversation (${messages.length} messages)`;
|
||||
}
|
||||
if (moreBtn) moreBtn.style.display = 'none';
|
||||
// Scroll to bottom (latest message)
|
||||
body.scrollTop = body.scrollHeight;
|
||||
@@ -2753,7 +2854,7 @@ class CodemanApp {
|
||||
// up to the limit).
|
||||
const cid = this._clientId ? `${this._clientId}:${this._wsTabNonce}` : '';
|
||||
const cidQuery = cid ? `?cid=${encodeURIComponent(cid)}` : '';
|
||||
const url = `${proto}//${location.host}/ws/sessions/${sessionId}/terminal${cidQuery}`;
|
||||
const url = `${proto}//${location.host}${CodemanBase.base}/ws/sessions/${sessionId}/terminal${cidQuery}`;
|
||||
const ws = new WebSocket(url);
|
||||
this._ws = ws;
|
||||
this._wsSessionId = sessionId;
|
||||
@@ -3926,6 +4027,71 @@ class CodemanApp {
|
||||
return this.isSessionSidebarRich() || this.isTabRailRich();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the VERTICAL TAB RAIL orders its cards the way both home screens
|
||||
* do — blocked on you first, then running longest-first, then quiet
|
||||
* most-recently-quiet first (`CodemanSessionOrder`, constants.js) — instead of
|
||||
* leaving them in the user's tab order.
|
||||
*
|
||||
* Read off <html> like the other two rail gates, because the render loop asks
|
||||
* it once per pass and getSessionListLayout() re-parses localStorage.
|
||||
* `tabRailSort: 'manual'` is the opt-out, and it is what a user who reorders
|
||||
* by hand wants: a self-sorting list cannot also be drag-reorderable, so
|
||||
* setupTabDragHandlers() drops the drag affordance while this is on rather
|
||||
* than letting a card snap back to where the sort puts it.
|
||||
*
|
||||
* Deliberately NOT gated on `isTabRailRich()`: a simple rail lists the same
|
||||
* sessions and answers the same question, it just says less about each one.
|
||||
*/
|
||||
isTabRailSorted() {
|
||||
const root = document.documentElement;
|
||||
return root.getAttribute('data-tab-orientation') === 'vertical' && root.dataset.tabRailSort === 'activity';
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual position per session id for the sorted rail, or null when the rail is
|
||||
* not sorting.
|
||||
*
|
||||
* The sort is applied as the flex `order` property, NOT by reordering the DOM.
|
||||
* That is the whole design: `#sessionTabs` stays in `sessionOrder`, so
|
||||
* drag-and-drop, the Alt+N badges, the arrow-key walk, the sidebar filter and
|
||||
* `_scrollActiveTabIntoView()` all keep reading the list they have always
|
||||
* read, and a session changing state moves one inline style instead of
|
||||
* forcing the full rebuild that would restart every card's animation.
|
||||
*
|
||||
* Rows are classified by `_mobileOverviewState()` and compared by
|
||||
* `CodemanSessionOrder` — the same two helpers both home screens use, so the
|
||||
* rail cannot disagree with them about what "working" means or what sorts
|
||||
* first. `orderIndex` is the tab-strip position, which the comparator uses as
|
||||
* its deterministic final tiebreak.
|
||||
*
|
||||
* Guarded like every other cross-file consumer: a stale cached constants.js or
|
||||
* mobile-overview.js degrades to tab order rather than taking the strip down.
|
||||
*
|
||||
* @param {Array<string>} ids live session ids, in tab order
|
||||
* @returns {Map<string, number>|null}
|
||||
*/
|
||||
_tabRailSortOrder(ids) {
|
||||
if (!this.isTabRailSorted()) return null;
|
||||
if (!window.CodemanSessionOrder || typeof this._mobileOverviewState !== 'function') return null;
|
||||
const rows = [];
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const session = this.sessions.get(ids[i]);
|
||||
if (!session) continue;
|
||||
rows.push({
|
||||
id: ids[i],
|
||||
state: this._mobileOverviewState(session, this.pendingHooks?.get(ids[i])),
|
||||
lastActivityAt: Number(session.lastActivityAt) || 0,
|
||||
lastSubmitAt: Number(session.lastSubmitAt) || 0,
|
||||
orderIndex: i,
|
||||
});
|
||||
}
|
||||
const sorted = window.CodemanSessionOrder.sort(rows);
|
||||
const out = new Map();
|
||||
for (let i = 0; i < sorted.length; i++) out.set(sorted[i].id, i);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* True where the sidebar is a MODAL off-canvas drawer over the terminal
|
||||
* instead of a docked column.
|
||||
@@ -4547,11 +4713,21 @@ class CodemanApp {
|
||||
// Read once for the whole pass, like the full-rebuild path: this touches
|
||||
// the DOM and the loop below runs for every session on every SSE tick.
|
||||
const richRows = this.isRichTabRows();
|
||||
// Sorted vertical rail: a state change moves a card, and this is the path
|
||||
// that sees one — a session going working→idle never adds or removes a
|
||||
// tab, so the full rebuild below is not reached. Recomputed per pass for
|
||||
// the same reason the rich meta line is: the order IS the state.
|
||||
const railSortOrder = this._tabRailSortOrder(this.sessionOrder.filter((sid) => this.sessions.has(sid)));
|
||||
// Incremental update - only modify changed properties
|
||||
for (const [id, session] of this.sessions) {
|
||||
const tab = container.querySelector(`.session-tab[data-id="${id}"]`);
|
||||
if (!tab) continue;
|
||||
|
||||
// An empty string clears the property, which is also what un-sorts the
|
||||
// rail when the setting (or the layout) flips without a full rebuild.
|
||||
const railOrder = railSortOrder?.has(id) ? String(railSortOrder.get(id)) : '';
|
||||
if (tab.style.order !== railOrder) tab.style.order = railOrder;
|
||||
|
||||
// A web tab owns the active state while one is open. activeSessionId stays
|
||||
// set (the terminal keeps streaming underneath, and switching back is
|
||||
// instant): only the highlight moves. Without this the debounced render
|
||||
@@ -4858,10 +5034,17 @@ class CodemanApp {
|
||||
// Read once, not per session: isRichTabRows() touches the DOM and
|
||||
// this loop runs for every tab on every full rebuild.
|
||||
const richRows = this.isRichTabRows();
|
||||
// The sorted vertical rail (tabRailSort) moves cards with the flex `order`
|
||||
// property and leaves this loop iterating tab order, so the Alt+N badge
|
||||
// below still counts the strip, not the sorted list. Null in every other
|
||||
// layout, and the tabs then carry no inline order at all — the header
|
||||
// strip's markup is byte-identical to before.
|
||||
const railSortOrder = this._tabRailSortOrder(tabOrder.filter((id) => this.sessions.has(id)));
|
||||
let _tabIdx = 0;
|
||||
for (const id of tabOrder) {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) continue; // Skip if session was removed
|
||||
const railOrderStyle = railSortOrder?.has(id) ? ` style="order:${railSortOrder.get(id)}"` : '';
|
||||
|
||||
// See the note in the incremental path: a web tab owns the active highlight
|
||||
// while one is open, even though activeSessionId stays set.
|
||||
@@ -4915,7 +5098,7 @@ class CodemanApp {
|
||||
const inlineSessionActions = this.shouldInlineSessionActions();
|
||||
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">⚙</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">⋯</button></span>`;
|
||||
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
@@ -5000,6 +5183,18 @@ class CodemanApp {
|
||||
|
||||
// Rows hidden by the sidebar filter must not be steppable.
|
||||
const tabs = [...container.querySelectorAll('.session-tab:not(.tab-filtered-out)')];
|
||||
// ⚠️ A sorted rail paints its rows with the flex `order` property while the
|
||||
// DOM stays in `sessionOrder` (that is what keeps the Alt+N badge and the
|
||||
// drag model honest), so a DOM-order walk steps around the screen instead of
|
||||
// down it: ArrowDown from the top card lands wherever that session happens to
|
||||
// sit in the tab order. Walk what the eye sees. Read the COMPUTED order, not
|
||||
// the inline one, or web tabs (pinned past the cards by a CSS `order: 9999`
|
||||
// rather than an inline style) read as 0 and the walk starts on them. Array
|
||||
// sort is stable, so equal orders keep DOM order, which is the unsorted case.
|
||||
if (this.isTabRailSorted()) {
|
||||
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
|
||||
tabs.sort((a, b) => orderOf(a) - orderOf(b));
|
||||
}
|
||||
const currentIndex = tabs.indexOf(document.activeElement);
|
||||
|
||||
// Enter or Space activates the tab
|
||||
@@ -5126,6 +5321,17 @@ class CodemanApp {
|
||||
const container = this.$('sessionTabs');
|
||||
const tabs = container.querySelectorAll('.session-tab[data-id]');
|
||||
|
||||
// A self-sorting list cannot also be hand-ordered: the drop below rewrites
|
||||
// sessionOrder correctly, the sort then puts the card straight back where it
|
||||
// was, and the user is left dragging a row that refuses to move. Drop the
|
||||
// affordance instead of lying about it — `tabRailSort: 'manual'` is the way
|
||||
// back to drag-reordering, and Alt+N / Ctrl+Shift+{ } still walk the strip
|
||||
// order this list is no longer showing.
|
||||
if (this.isTabRailSorted()) {
|
||||
tabs.forEach((tab) => tab.setAttribute('draggable', 'false'));
|
||||
return;
|
||||
}
|
||||
|
||||
tabs.forEach(tab => {
|
||||
tab.setAttribute('draggable', 'true');
|
||||
|
||||
@@ -5861,6 +6067,23 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Hold for the terminal font before measuring anything. A cell measured
|
||||
// against a fallback font gives the wrong column and row count, and the
|
||||
// correction would land after the replay, leaving the CLI drawing against a
|
||||
// frame the terminal no longer shows. Resolves immediately once the font is
|
||||
// in, so this costs a tab switch nothing after the first load, and it is
|
||||
// bounded, so a font that never arrives cannot strand the session.
|
||||
// ⚠️ BEFORE `_beginBufferLoad` on purpose: inside it, every live SSE event
|
||||
// for this session queues instead of painting, so a slow font would hold
|
||||
// output back rather than merely mis-measuring the grid.
|
||||
if (this._terminalFontReady) {
|
||||
await this._terminalFontReady;
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Load terminal buffer for this session
|
||||
// Show cached content instantly while fetching fresh data in background.
|
||||
// Use tail mode for faster initial load (128KB is enough for recent visible content).
|
||||
|
||||
@@ -22,6 +22,63 @@
|
||||
|
||||
// Codeman — Shared constants and utility functions for frontend modules
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Reverse-proxy base path
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// When Codeman is served behind a reverse proxy under a sub-path (e.g. /codeman/),
|
||||
// the server injects `window.__CODEMAN_BASE__` (normalized: '' for root, or '/foo').
|
||||
// The `<base href>` tag in index.html already rewrites the RELATIVE asset refs, but
|
||||
// every URL the frontend builds at RUNTIME is root-absolute (`/api/...`, `/ws/...`)
|
||||
// and root-absolute URLs ignore `<base>` — so those must be prefixed here instead.
|
||||
// Rather than touch ~190 call sites, all runtime URL construction routes through this
|
||||
// ONE choke point: `CodemanBase.url()` is the route builder, and a thin wrapper over
|
||||
// `fetch` applies it transparently. The handful of EventSource/WebSocket sites call
|
||||
// `CodemanBase.url()` / `CodemanBase.base` explicitly. No-op when mounted at root.
|
||||
const CodemanBase = (function () {
|
||||
// `window` is absent in some unit-test vm contexts that load this module in
|
||||
// isolation; guard so the module still evaluates (base degrades to root).
|
||||
const _win = typeof window !== 'undefined' ? window : undefined;
|
||||
const base = String((_win && _win.__CODEMAN_BASE__) || '').replace(/\/+$/, '');
|
||||
/**
|
||||
* Prefix a root-absolute application path with the mount base. Leaves untouched:
|
||||
* relative paths and fragments/queries (resolved against `<base>`), protocol-relative
|
||||
* (`//host`) and absolute URLs, and paths already carrying the prefix.
|
||||
*/
|
||||
function url(path) {
|
||||
if (!base) return path;
|
||||
if (typeof path !== 'string' || path.length === 0) return path;
|
||||
if (path[0] !== '/') return path; // relative / fragment / query
|
||||
if (path[1] === '/') return path; // protocol-relative
|
||||
if (path === base || path.startsWith(base + '/') || path.startsWith(base + '?')) return path;
|
||||
return base + path;
|
||||
}
|
||||
return { base, url };
|
||||
})();
|
||||
if (typeof window !== 'undefined') window.CodemanBase = CodemanBase;
|
||||
|
||||
// Transparently prefix root-absolute app paths on every fetch, so the many
|
||||
// `/api/...` string literals across the frontend need no per-call edit.
|
||||
if (typeof window !== 'undefined' && CodemanBase.base && typeof window.fetch === 'function') {
|
||||
const _origFetch = window.fetch.bind(window);
|
||||
window.fetch = function (input, init) {
|
||||
if (typeof input === 'string') return _origFetch(CodemanBase.url(input), init);
|
||||
if (typeof Request !== 'undefined' && input instanceof Request) {
|
||||
try {
|
||||
const u = new URL(input.url);
|
||||
if (u.origin === location.origin) {
|
||||
const prefixed = CodemanBase.url(u.pathname);
|
||||
if (prefixed !== u.pathname) {
|
||||
return _origFetch(new Request(u.origin + prefixed + u.search + u.hash, input), init);
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
/* not a parseable URL — fall through */
|
||||
}
|
||||
}
|
||||
return _origFetch(input, init);
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Web Push Utilities
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -285,7 +342,13 @@ const LINEAGE_DIP_MAX_PX = 64;
|
||||
// apart bled into one thick band instead of reading as three separate lines.
|
||||
const LINEAGE_SIBLING_STEP_PX = 8;
|
||||
const LINEAGE_STRIP_TOLERANCE_PX = 4;
|
||||
const LINEAGE_VERTICAL_TRACK_INSET_PX = 6;
|
||||
// How far the vertical bracket sits in from the rail's left edge. It has to
|
||||
// clear the VIEWPORT edge, not just the tabs: the line carries an 11px outer
|
||||
// glow, so a track at 6px had half of that glow clipped away and the arc read
|
||||
// as a thin thread pinned to the window frame. The rail reserves the channel
|
||||
// itself (`--lineage-vertical-gutter` on the rail's .session-tabs), and
|
||||
// computeLineagePath still clamps the track to stay left of both tabs.
|
||||
const LINEAGE_VERTICAL_TRACK_INSET_PX = 10;
|
||||
const LINEAGE_VERTICAL_SIBLING_STEP_PX = 3;
|
||||
const LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX = 4;
|
||||
// Lineage palette, assigned per SPAWNING TAB in first-seen order and cycled
|
||||
@@ -602,6 +665,22 @@ function sortSessionsByActivity(rows) {
|
||||
// prompt icons (powerline segments, folder/git glyphs from p10k, starship,
|
||||
// oh-my-posh) render even though the text fonts carry no private-use-area
|
||||
// symbols — while all readable text keeps coming from the text fonts.
|
||||
/**
|
||||
* How long a terminal fit will wait for the terminal font, in ms.
|
||||
*
|
||||
* `FontFaceSet.ready` has no deadline of its own and the wait sits in front of
|
||||
* the buffer replay, so a font request that never settles would leave the
|
||||
* session unpainted. Past this we measure whatever is painted.
|
||||
*/
|
||||
const TERMINAL_FONT_WAIT_MS = 2000;
|
||||
|
||||
/**
|
||||
* Families in the stack that cannot move the measured cell, so nothing waits on
|
||||
* them: the generics match no `FontFace`, and the bundled symbols face carries
|
||||
* private-use-area glyphs only (xterm measures `W`) while weighing ~1.2MB.
|
||||
*/
|
||||
const TERMINAL_FONT_UNMEASURED = new Set(['monospace', 'serif', 'sans-serif', 'system-ui', 'symbols nerd font mono']);
|
||||
|
||||
const TERMINAL_FONT_DEFAULT_STACK =
|
||||
'"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, "Symbols Nerd Font Mono", monospace';
|
||||
|
||||
@@ -958,6 +1037,7 @@ const SSE_EVENTS = {
|
||||
HOOK_AGENT_WORKING: 'hook:agent_working',
|
||||
HOOK_TEAMMATE_IDLE: 'hook:teammate_idle',
|
||||
HOOK_TASK_COMPLETED: 'hook:task_completed',
|
||||
HOOK_PROMPT_SUBMITTED: 'hook:prompt_submitted',
|
||||
|
||||
// Approvals Inbox
|
||||
APPROVAL_PENDING: 'approval:pending',
|
||||
|
||||
@@ -25,7 +25,10 @@
|
||||
* 3. The terminal pane is ONE shared element, so its entrance is marked at
|
||||
* session creation but played at selection: a session created in the
|
||||
* background must not animate the pane the user is currently looking at. Its
|
||||
* styles are also restricted to transform/opacity/clip-path (see below).
|
||||
* styles are also restricted to transform/opacity/clip-path (see below), with
|
||||
* `blur` the one documented exception - a filter is the only thing that
|
||||
* actually blurs a live xterm; styles.css carries the measurement and the
|
||||
* three alternatives that do not work.
|
||||
* 4. Nothing may animate on page load or reconnect replay. Only ids that pass
|
||||
* through `markSessionTabEntering()` animate, and `_tabEnterSeen` makes that
|
||||
* once-per-id even though the POST response and the SSE event both call
|
||||
@@ -50,6 +53,7 @@ const TAB_ANIM_STYLES = [
|
||||
{ key: 'unroll', label: 'Unroll', blurb: 'The strip makes room and the tab widens in.', duration: 480 },
|
||||
{ key: 'boot', label: 'Boot', blurb: 'Flickers on under a green scan sweep.', duration: 720 },
|
||||
{ key: 'flip', label: 'Flip', blurb: 'Drops in as a card hinged on its top edge.', duration: 520 },
|
||||
{ key: 'blur', label: 'Blur', blurb: 'Focus-pulls in as the blur fades off it.', duration: 440 },
|
||||
{ key: 'off', label: 'Off', blurb: 'Tabs just appear.', duration: 0 },
|
||||
];
|
||||
|
||||
@@ -65,6 +69,7 @@ const WIN_ANIM_STYLES = [
|
||||
{ key: 'unfold', label: 'Unfold', blurb: 'Hinges down from its top edge in 3D.', duration: 560 },
|
||||
{ key: 'beam', label: 'Beam down', blurb: 'Waits for its line to reach it, then materializes.', duration: 620 },
|
||||
{ key: 'pop', label: 'Pop', blurb: 'Springs open from its centre.', duration: 460 },
|
||||
{ key: 'blur', label: 'Blur', blurb: 'Focus-pulls in as the blur fades off it.', duration: 560 },
|
||||
{ key: 'off', label: 'Off', blurb: 'Windows just appear.', duration: 0 },
|
||||
];
|
||||
|
||||
@@ -73,6 +78,7 @@ const LINE_ANIM_STYLES = [
|
||||
{ key: 'draw', label: 'Draw', blurb: 'Draws itself from the tab down to the window.', duration: 420 },
|
||||
{ key: 'packet', label: 'Packet', blurb: 'Line fades in, then a bright packet runs down it.', duration: 700 },
|
||||
{ key: 'fade', label: 'Fade', blurb: 'Simply fades in.', duration: 300 },
|
||||
{ key: 'blur', label: 'Blur', blurb: 'Focus-pulls in as the blur fades off it.', duration: 380 },
|
||||
{ key: 'off', label: 'Off', blurb: 'Lines just appear.', duration: 0 },
|
||||
];
|
||||
|
||||
@@ -92,6 +98,7 @@ const TERM_ANIM_STYLES = [
|
||||
{ key: 'wipe', label: 'Wipe', blurb: 'Reveals top-to-bottom behind a bright edge.', duration: 520 },
|
||||
{ key: 'slide', label: 'Slide up', blurb: 'Rises into place from below.', duration: 420 },
|
||||
{ key: 'fade', label: 'Fade', blurb: 'Quiet fade with a touch of scale.', duration: 340 },
|
||||
{ key: 'blur', label: 'Blur', blurb: 'Focus-pulls in as the blur lifts off the pane.', duration: 520 },
|
||||
{ key: 'off', label: 'Off', blurb: 'Current behaviour: the pane just appears.', duration: 0 },
|
||||
];
|
||||
|
||||
@@ -102,6 +109,7 @@ const BEAM_HOLD_MS = 360;
|
||||
const ANIM_THEMES = [
|
||||
{ key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt' },
|
||||
{ key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe' },
|
||||
{ key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur' },
|
||||
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade' },
|
||||
{ key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide' },
|
||||
{ key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off' },
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
'Instance count': '实例数量',
|
||||
'No response yet': '暂无回复',
|
||||
'No response yet — send a message in this session first.': '暂无回复,请先在此会话中发送一条消息。',
|
||||
'No full conversation history available for this session': '此会话没有可显示的完整对话历史',
|
||||
'Last Response': '最近一次回复',
|
||||
More: '更多',
|
||||
'Codeman version': '{name}版本',
|
||||
@@ -713,6 +714,19 @@
|
||||
'Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.':
|
||||
'在加固的隔离容器中运行此案例。首次使用时会自动构建基础镜像;必须安装 Docker/Podman。',
|
||||
'Run in an isolated Docker container': '在隔离的 Docker 容器中运行',
|
||||
'Attach to an existing container': '接入已在运行的容器',
|
||||
'On: Codeman only runs docker exec into a container you already built and run — it never creates, starts, stops or removes it. The CLIs must already be installed and logged in inside it.':
|
||||
'开启后,{name}只会 docker exec 进入你自己构建并运行的容器,绝不创建、启动、停止或删除它;容器内必须已安装并登录好相应 CLI。',
|
||||
'Container Name': '容器名称',
|
||||
'Pick from the running containers or type a name.': '从正在运行的容器中选择,或直接输入名称。',
|
||||
'Check container': '检查容器',
|
||||
'Container Workdir': '容器内工作目录',
|
||||
'A path that already exists inside the container. Adoption mounts nothing, so this need not match the host workspace path.':
|
||||
'容器内已存在的路径。接入不挂载任何目录,因此它不必与主机工作区路径相同。',
|
||||
'Already have a container running?': '已经有正在运行的容器?',
|
||||
'Attach to it instead': '改为接入该容器',
|
||||
'Codeman only runs docker exec into it and never touches its lifecycle.':
|
||||
'{name}只会 docker exec 进入它,绝不触碰其生命周期。',
|
||||
'Absolute HOST directory, bind-mounted into the container. Codeman scaffolds CLAUDE.md + hooks into it.':
|
||||
'绑定挂载到容器中的主机绝对目录;{name}会在其中生成 CLAUDE.md 和 hooks。',
|
||||
'A reusable docker host profile. Reuse the same ID across cases to share settings.':
|
||||
|
||||
@@ -60,9 +60,22 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.body.appendChild(trap);
|
||||
trap.focus();
|
||||
|
||||
// One Ctrl+V can deliver TWO paste events to this trap. The
|
||||
// execCommand('paste') below fires one wherever the browser honours that
|
||||
// command, and the key's own default action fires another, because xterm's
|
||||
// custom key handler returns false without cancelling the keydown. Handling
|
||||
// both sends the clipboard text to the PTY twice, which is the "Ctrl+V
|
||||
// pastes twice, right-click Paste does not" report: the context-menu paste
|
||||
// has no keydown, so it only ever produces one event. The trap therefore
|
||||
// accepts the first paste and drops every later one.
|
||||
var pasteConsumed = false;
|
||||
|
||||
// Listen for the paste event on our trap
|
||||
trap.addEventListener('paste', function(e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (pasteConsumed) return;
|
||||
pasteConsumed = true;
|
||||
|
||||
// Check for images in clipboard items
|
||||
var imageFiles = [];
|
||||
@@ -84,7 +97,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}, 0);
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
self._uploadAndInsertImages(imageFiles);
|
||||
} else {
|
||||
// No image -- route text through xterm's paste() so bracketed-paste
|
||||
@@ -94,7 +106,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
// indistinguishable from typed input, weakening the CLI's
|
||||
// prompt-injection defenses.
|
||||
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
||||
e.preventDefault();
|
||||
if (text && self.terminal) self.terminal.paste(text);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
app.js, NOT the handheld storage-key test `m`. Use a different predicate
|
||||
here and boot will contradict this value, animating the drawer open by
|
||||
itself on every load between 768 and 1023px. -->
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';}</script>
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';}</script>
|
||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||
<style>
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
|
||||
@@ -1889,6 +1889,7 @@
|
||||
<option value="legacy">Off (default)</option>
|
||||
<option value="terminal">Terminal (CRT)</option>
|
||||
<option value="beamdown">Beam down</option>
|
||||
<option value="softfocus">Soft focus (blur)</option>
|
||||
<option value="quiet">Quiet</option>
|
||||
<option value="playful">Playful</option>
|
||||
<option value="custom">Custom (set in the lab)</option>
|
||||
@@ -1943,6 +1944,16 @@
|
||||
<option value="simple">Simple (name only)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="tab rail sort order activity manual drag reorder">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Vertical Rail Order</span>
|
||||
<span class="set-row-desc">By activity uses the home screen's order: blocked on you first, then whatever has been running longest, then the most recently quiet. Manual keeps your tab order and is the only mode you can drag rows in. Alt+1..9 always follows the tab order either way.</span>
|
||||
</div>
|
||||
<select id="appSettingsTabRailSort" class="set-select">
|
||||
<option value="activity">By activity (home screen order)</option>
|
||||
<option value="manual">Manual (drag to reorder)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="tab rail width resize compact wide maximum">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Vertical Rail Width</span>
|
||||
@@ -2589,8 +2600,15 @@
|
||||
<div class="modal-content modal-lg set-shell">
|
||||
<div class="modal-header set-shell-head">
|
||||
<h3>Add Case</h3>
|
||||
<!-- mobile.css hides this modal's .set-foot, so on a phone the footer's
|
||||
Create/Link button is unreachable and the modal cannot be submitted
|
||||
at all. Mirrors the Settings modal's header Save: close first in the
|
||||
DOM so the focus trap still lands on it, row-reverse puts this to
|
||||
its left. Both buttons are driven together by switchCaseModalTab()
|
||||
and submitCaseModal(). -->
|
||||
<div class="set-head-actions">
|
||||
<button class="modal-close" onclick="app.closeCreateCaseModal()" aria-label="Close create case">×</button>
|
||||
<button class="set-head-save" id="caseModalSubmitMobile" onclick="app.submitCaseModal()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="set-body">
|
||||
@@ -2644,6 +2662,7 @@
|
||||
<div class="form-row docker-quick-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker"> 🐳 Run in an isolated Docker container</label>
|
||||
<span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.</span>
|
||||
<span class="form-hint">Already have a container running? <button type="button" class="btn-inline-check" id="dockerAdoptJumpBtn">Attach to it instead</button> Codeman only runs docker exec into it and never touches its lifecycle.</span>
|
||||
</div>
|
||||
<details class="advanced-options docker-quick-settings" id="dockerQuickSettings">
|
||||
<summary><svg class="set-adv-chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg><span>Container settings (optional, sensible defaults)</span></summary>
|
||||
@@ -2847,6 +2866,24 @@
|
||||
<h2>Docker</h2>
|
||||
</div>
|
||||
<p class="set-section-blurb">Run the case inside a container: one per case, shared by all its sessions.</p>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="dockerAdoptExisting"> Attach to an existing container</label>
|
||||
<span class="form-hint">On: Codeman only runs docker exec into a container you already built and run — it never creates, starts, stops or removes it. The CLIs must already be installed and logged in inside it.</span>
|
||||
</div>
|
||||
<div class="form-row docker-adopt-only">
|
||||
<label>Container Name</label>
|
||||
<input type="text" id="dockerContainerName" list="dockerContainerList" placeholder="my-dev-box" pattern="[a-zA-Z0-9][a-zA-Z0-9_.-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<datalist id="dockerContainerList"></datalist>
|
||||
<span class="form-hint">Pick from the running containers or type a name. <button type="button" class="btn-inline-check" id="dockerAdoptCheckBtn">Check container</button></span>
|
||||
</div>
|
||||
<div class="form-row docker-adopt-only">
|
||||
<label>Container Workdir</label>
|
||||
<div class="path-input-group">
|
||||
<input type="text" id="dockerAdoptWorkdir" placeholder="/workspace" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<button type="button" class="btn path-input-browse" onclick="app.openDockerWorkdirPicker()">Browse…</button>
|
||||
</div>
|
||||
<span class="form-hint">A path that already exists inside the container. Adoption mounts nothing, so this need not match the host workspace path.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Case Name</label>
|
||||
<input type="text" id="dockerCaseName" placeholder="sandbox" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
@@ -2854,7 +2891,10 @@
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Workspace Path</label>
|
||||
<input type="text" id="dockerWorkspacePath" placeholder="/home/user/projects/sandbox" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<div class="path-input-group">
|
||||
<input type="text" id="dockerWorkspacePath" placeholder="/home/user/projects/sandbox" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<button type="button" class="btn path-input-browse" onclick="app.openDockerWorkspacePathPicker()">Browse…</button>
|
||||
</div>
|
||||
<span class="form-hint">Absolute HOST directory, bind-mounted into the container. Codeman scaffolds CLAUDE.md + hooks into it.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
@@ -2862,12 +2902,12 @@
|
||||
<input type="text" id="dockerHostId" placeholder="local" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">A reusable docker host profile. Reuse the same ID across cases to share settings.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-row docker-create-only">
|
||||
<label>Image</label>
|
||||
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/codex/gemini/opencode/agy/pi/grok/dsh + tmux.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-row docker-create-only">
|
||||
<label>Network</label>
|
||||
<select id="dockerNetwork">
|
||||
<option value="bridge">bridge (internet on, default)</option>
|
||||
@@ -2875,7 +2915,7 @@
|
||||
<option value="custom">custom bridge</option>
|
||||
</select>
|
||||
</div>
|
||||
<details class="advanced-options">
|
||||
<details class="advanced-options docker-create-only">
|
||||
<summary><svg class="set-adv-chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg><span>Advanced container settings</span></summary>
|
||||
<div class="advanced-options-content">
|
||||
<div class="form-row">
|
||||
@@ -3429,6 +3469,8 @@
|
||||
<script defer src="notification-manager.js"></script>
|
||||
<script defer src="keyboard-accessory.js"></script>
|
||||
<script defer src="input-cjk.js"></script>
|
||||
<!-- Forwards committed input events that xterm drops on Android/GBoard soft keyboards. Must precede terminal-ui.js. -->
|
||||
<script defer src="terminal-keycode229-recovery.js"></script>
|
||||
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
|
||||
<script defer src="sanitize-html.js"></script>
|
||||
<script defer src="app.js"></script>
|
||||
|
||||
@@ -185,9 +185,13 @@ const PathPicker = {
|
||||
if (this._options.sessionId) params.set('sessionId', this._options.sessionId);
|
||||
if (this._showHidden) params.set('showHidden', 'true');
|
||||
try {
|
||||
const response = await fetch(`/api/filesystem/browse?${params.toString()}`);
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) throw new Error(result.error || 'Failed to browse this folder');
|
||||
// A caller may supply its own source (the container-workdir picker browses
|
||||
// INSIDE a container, which the host filesystem endpoint cannot answer).
|
||||
// It returns the same shape, so everything below is unchanged.
|
||||
const result = this._options.fetchListing
|
||||
? await this._options.fetchListing(path)
|
||||
: await (await fetch(`/api/filesystem/browse?${params.toString()}`)).json();
|
||||
if (!result?.success) throw new Error(result?.error || 'Failed to browse this folder');
|
||||
if (!this.overlay || loadSequence !== this._loadSequence) return;
|
||||
this.render(result.data);
|
||||
} catch (error) {
|
||||
@@ -304,7 +308,7 @@ const PathPicker = {
|
||||
// A hidden file is only reachable while the toggle is on, and the preview
|
||||
// endpoint re-resolves the path independently, so it needs the flag too.
|
||||
if (this._showHidden) params.set('showHidden', 'true');
|
||||
const previewUrl = `/api/filesystem/preview?${params.toString()}`;
|
||||
const previewUrl = (window.CodemanBase?.url || ((p) => p))(`/api/filesystem/preview?${params.toString()}`);
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'path-preview-overlay';
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"name": "Codeman",
|
||||
"short_name": "Codeman",
|
||||
"description": "Claude Code session manager",
|
||||
"start_url": "/",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0a0a0a",
|
||||
|
||||
@@ -148,6 +148,13 @@ const MobileDetection = {
|
||||
if (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) return;
|
||||
const vh = window.visualViewport?.height || window.innerHeight;
|
||||
document.documentElement.style.setProperty('--app-height', `${vh}px`);
|
||||
// How far the layout viewport (which anchors position: fixed) extends below
|
||||
// the visual viewport, i.e. behind the browser's bottom bar. 0 on iPhone
|
||||
// Safari, where fixed elements already stop above the bar; the overlap
|
||||
// where they do not. mobile.css lifts the toolbar by this rather than by
|
||||
// (100vh - --app-height), which on iPhone measures the collapsible chrome
|
||||
// instead and left an empty band between the toolbar and the bar.
|
||||
document.documentElement.style.setProperty('--chrome-overlap', `${Math.max(0, window.innerHeight - vh)}px`);
|
||||
},
|
||||
|
||||
/** Initialize mobile detection and set up resize listener */
|
||||
|
||||
@@ -228,6 +228,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
name: item.name || '',
|
||||
title: title || item.name || dir.split('/').pop() || item.sessionId.slice(0, 8),
|
||||
mode: item.mode || 'claude',
|
||||
// Only the scanner sets this, and only for a codex rollout. Dropping it here
|
||||
// is not cosmetic: resumeMobileOverviewSession() passes row.resumeId on to
|
||||
// resumeHistorySession(), so without it a tapped Codex row starts a FRESH
|
||||
// session on a thread that is already on disk.
|
||||
resumeId: item.resumeId || undefined,
|
||||
caseName: matched ? matched.name : '',
|
||||
dir: this._shortenHomePath ? this._shortenHomePath(dir) : dir,
|
||||
at: item.lastActivityAt || item.createdAt || 0,
|
||||
@@ -389,7 +394,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
async resumeMobileOverviewSession(sessionId) {
|
||||
const row = (this._mobileOverviewPastRows || []).find((r) => r.id === sessionId);
|
||||
if (!row || !row.workingDir) return;
|
||||
await this.resumeHistorySession(row.claudeSessionId || row.id, row.workingDir, row.name || undefined, row.mode);
|
||||
await this.resumeHistorySession(
|
||||
row.claudeSessionId || row.id,
|
||||
row.workingDir,
|
||||
row.name || undefined,
|
||||
row.mode,
|
||||
row.resumeId
|
||||
);
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
+24
-10
@@ -471,11 +471,11 @@ html.mobile-init .file-browser-panel {
|
||||
padding-bottom: calc(40px + var(--safe-area-bottom));
|
||||
}
|
||||
|
||||
/* iOS Safari: toolbar is pushed up by (100vh - --app-height) to clear the
|
||||
browser's bottom bar. Match that offset in main's padding so the terminal
|
||||
doesn't extend behind the toolbar. */
|
||||
/* iOS Safari: the toolbar is lifted by --chrome-overlap where the browser's
|
||||
bottom bar would otherwise cover it. Match that offset in main's padding so
|
||||
the terminal doesn't extend behind the toolbar. */
|
||||
.ios-device.safari-browser .main {
|
||||
padding-bottom: calc(40px + var(--safe-area-bottom) + (100vh - var(--app-height, 100vh)));
|
||||
padding-bottom: calc(40px + var(--safe-area-bottom) + var(--chrome-overlap, 0px));
|
||||
}
|
||||
|
||||
.header-right {
|
||||
@@ -780,11 +780,14 @@ html.mobile-init .file-browser-panel {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* iOS Safari with tab bar: position: fixed uses the layout viewport which
|
||||
extends behind the browser chrome. Offset the toolbar upward by the delta
|
||||
between 100vh (layout) and --app-height (visual). */
|
||||
/* iOS Safari: where position: fixed anchors to a layout viewport that
|
||||
extends behind the browser's bottom bar, lift the toolbar by that overlap.
|
||||
--chrome-overlap is innerHeight minus the visual viewport height, set in
|
||||
mobile-handlers.js. On iPhone Safari it is 0 because fixed elements already
|
||||
stop above the bar; the previous (100vh - --app-height) lift measured the
|
||||
collapsible chrome instead and left an empty band above the bar. */
|
||||
.ios-device.safari-browser .toolbar {
|
||||
bottom: calc(var(--safe-area-bottom) + (100vh - var(--app-height, 100vh)));
|
||||
bottom: calc(var(--safe-area-bottom) + var(--chrome-overlap, 0px));
|
||||
}
|
||||
|
||||
/* When keyboard is visible the JS translateY already accounts for the full
|
||||
@@ -3333,8 +3336,10 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
recessed tray and matching pill geometry instead of reading as a fat
|
||||
accent pill parked beside a stray × glyph. Tray colors come from skin
|
||||
tokens, never a hardcoded black alpha, or the light skins get a grey slab.
|
||||
`:has()` keeps the tray off the two sheets that carry a lone × (Session
|
||||
Options and Add Case save from inside their own forms). */
|
||||
`:has()` keeps the tray off Session Options, the one sheet left carrying a
|
||||
lone × because it saves from inside its own per-section forms. Add Case
|
||||
now has a header save of its own (its footer is hidden below 860px, so
|
||||
that button is the only way to submit it there), and picks up the tray. */
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-actions:has(.set-head-save) {
|
||||
padding: 3px;
|
||||
border: 1px solid var(--border);
|
||||
@@ -3353,6 +3358,15 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
/* Add Case's pending state has to show on the header button: below 860px it is
|
||||
the only submit control (the footer is hidden), and the #caseModalSubmit
|
||||
.loading rule in the phone block dims a button nobody can see. Measured at
|
||||
390px before this: header opacity 1 for the whole clone, hidden footer 0.6. */
|
||||
#createCaseModal .set-head-save.loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-actions .modal-close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
||||
@@ -390,7 +390,7 @@ class NotificationManager {
|
||||
const notif = new Notification(`${this.originalTitle}: ${localizedTitle}`, {
|
||||
body: localizedBody,
|
||||
tag, // Groups same-tag notifications
|
||||
icon: '/favicon.ico',
|
||||
icon: (window.CodemanBase?.url || ((p) => p))('/favicon.ico'),
|
||||
silent: true, // We handle audio ourselves
|
||||
});
|
||||
|
||||
|
||||
+46
-33
@@ -670,7 +670,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
} else if (record.workingDir) {
|
||||
// History rows are keyed by the Claude conversation UUID; resumed
|
||||
// sessions carry theirs separately as claudeSessionId.
|
||||
void this.resumeHistorySession(s.claudeSessionId || s.sessionId, record.workingDir, undefined, s.mode);
|
||||
void this.resumeHistorySession(
|
||||
s.claudeSessionId || s.sessionId,
|
||||
record.workingDir,
|
||||
undefined,
|
||||
s.mode,
|
||||
s.resumeId
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -3424,7 +3430,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
|
||||
|
||||
const downloadBtn = !isDir
|
||||
? `<a class="file-tree-download" href="${escapeHtml(`/api/sessions/${encodeURIComponent(owner)}/file-raw?path=${encodeURIComponent(node.path)}&download=true`)}" title="Download" onclick="event.stopPropagation()">⬇</a>`
|
||||
? `<a class="file-tree-download" href="${escapeHtml(CodemanBase.url(`/api/sessions/${encodeURIComponent(owner)}/file-raw?path=${encodeURIComponent(node.path)}&download=true`))}" title="Download" onclick="event.stopPropagation()">⬇</a>`
|
||||
: '';
|
||||
|
||||
html.push(`
|
||||
@@ -3599,7 +3605,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
: '';
|
||||
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
|
||||
const downloadBtn = !isDir
|
||||
? `<a class="file-tree-download" href="${escapeHtml(`/api/sessions/${ownerPath}/file-raw?path=${encodeURIComponent(match.path)}&download=true`)}" title="Download" onclick="event.stopPropagation()">⬇</a>`
|
||||
? `<a class="file-tree-download" href="${escapeHtml(CodemanBase.url(`/api/sessions/${ownerPath}/file-raw?path=${encodeURIComponent(match.path)}&download=true`))}" title="Download" onclick="event.stopPropagation()">⬇</a>`
|
||||
: '';
|
||||
return `
|
||||
<div class="file-tree-item" data-path="${escapeHtml(match.path)}" data-type="${escapeHtml(match.type)}" data-owner="${escapeHtml(ownerSessionId)}">
|
||||
@@ -3998,18 +4004,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
// (html/htm arrive as a download there by design — file-raw serves them
|
||||
// attachment-only so widening READ never widens RUN.)
|
||||
const officeDoc = ext === 'docx' || ext === 'pptx';
|
||||
this.filePreviewDetachUrl = attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/${officeDoc ? 'preview' : 'raw'}`
|
||||
: officeDoc
|
||||
? `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
|
||||
this.filePreviewDetachUrl = CodemanBase.url(
|
||||
attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/${officeDoc ? 'preview' : 'raw'}`
|
||||
: officeDoc
|
||||
? `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`
|
||||
);
|
||||
if (detachBtn) detachBtn.hidden = false;
|
||||
|
||||
// Registered attachment: render straight from its by-id routes — images and
|
||||
// PDFs inline, Office docs via the server-converted PDF preview, text fetched
|
||||
// raw. (Workspace-path previews fall through to the file-content endpoint.)
|
||||
if (attachmentId) {
|
||||
const base = `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`;
|
||||
const base = CodemanBase.url(`/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`);
|
||||
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
|
||||
// VIDEO/AUDIO mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS
|
||||
// (src/attachment-registry.ts, the single source); the frontend cannot import
|
||||
@@ -4067,13 +4075,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
// to file-content below, which would dump the binary bytes as mojibake.
|
||||
if (ext === 'docx' || ext === 'pptx') {
|
||||
footerEl.textContent = ext.toUpperCase();
|
||||
const previewSrc = `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`;
|
||||
const previewSrc = CodemanBase.url(`/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`);
|
||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(previewSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||
return;
|
||||
}
|
||||
if (ext === 'pdf') {
|
||||
footerEl.textContent = 'PDF';
|
||||
const rawSrc = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
|
||||
const rawSrc = CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
|
||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(rawSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||
return;
|
||||
}
|
||||
@@ -4107,19 +4115,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
const data = result.data;
|
||||
|
||||
if (data.type === 'image') {
|
||||
bodyEl.innerHTML = `<img src="${data.url}" alt="${escapeHtml(filePath)}">`;
|
||||
bodyEl.innerHTML = `<img src="${escapeHtml(CodemanBase.url(data.url))}" alt="${escapeHtml(filePath)}">`;
|
||||
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
||||
} else if (data.type === 'video') {
|
||||
// playsinline: iOS otherwise hijacks playback into its fullscreen
|
||||
// player, which leaves the overlay behind it and its own close button
|
||||
// as the only way back.
|
||||
bodyEl.innerHTML = `<video src="${escapeHtml(data.url)}" controls autoplay playsinline preload="metadata"></video>`;
|
||||
bodyEl.innerHTML = `<video src="${escapeHtml(CodemanBase.url(data.url))}" controls autoplay playsinline preload="metadata"></video>`;
|
||||
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
||||
} else if (data.type === 'audio') {
|
||||
bodyEl.innerHTML = `<audio src="${escapeHtml(data.url)}" controls autoplay preload="metadata"></audio>`;
|
||||
bodyEl.innerHTML = `<audio src="${escapeHtml(CodemanBase.url(data.url))}" controls autoplay preload="metadata"></audio>`;
|
||||
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
||||
} else if (data.type === 'binary') {
|
||||
const downloadHref = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`;
|
||||
const downloadHref = CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`);
|
||||
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview<br><a href="${escapeHtml(downloadHref)}" download>Download</a></div>`;
|
||||
footerEl.textContent = data.extension || 'binary';
|
||||
} else {
|
||||
@@ -4416,9 +4424,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
openAttachmentInNewTab(sessionId, filePath, attachmentId = null) {
|
||||
const url = attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
|
||||
const url = CodemanBase.url(
|
||||
attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`
|
||||
);
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
|
||||
@@ -4454,19 +4464,22 @@ Object.assign(CodemanApp.prototype, {
|
||||
const stack = this.ensureAttachmentCardStack();
|
||||
const session = this.sessions.get(sessionId);
|
||||
const sessionName = session?.name || sessionId.substring(0, 8);
|
||||
const attachmentRawUrl =
|
||||
const attachmentRawUrl = CodemanBase.url(
|
||||
rawUrl ||
|
||||
(attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
|
||||
const attachmentPreviewUrl =
|
||||
(attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`)
|
||||
);
|
||||
const attachmentPreviewUrl = CodemanBase.url(
|
||||
previewUrl ||
|
||||
(attachmentId ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/preview` : null);
|
||||
const attachmentThumbnailUrl =
|
||||
(attachmentId ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/preview` : null)
|
||||
);
|
||||
const attachmentThumbnailUrl = CodemanBase.url(
|
||||
thumbnailUrl ||
|
||||
(attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/thumbnail`
|
||||
: `/api/sessions/${sessionId}/file-thumbnail?path=${encodeURIComponent(filePath)}`);
|
||||
(attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/thumbnail`
|
||||
: `/api/sessions/${sessionId}/file-thumbnail?path=${encodeURIComponent(filePath)}`)
|
||||
);
|
||||
const downloadUrl = attachmentId ? `${attachmentRawUrl}?download=true` : `${attachmentRawUrl}&download=true`;
|
||||
const typeLabel = (extension || attachmentType || 'file').toUpperCase();
|
||||
|
||||
@@ -4730,7 +4743,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
.join(' • ');
|
||||
const thumb =
|
||||
item.thumbnailUrl && !item.missing
|
||||
? `<img class="attachment-history-thumb-img" src="${escapeHtml(item.thumbnailUrl)}" alt="">`
|
||||
? `<img class="attachment-history-thumb-img" src="${escapeHtml(CodemanBase.url(item.thumbnailUrl))}" alt="">`
|
||||
: '';
|
||||
const disabled = item.missing ? 'disabled aria-disabled="true"' : '';
|
||||
return `
|
||||
@@ -4770,7 +4783,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const item = this.getAttachmentHistoryItem(itemId);
|
||||
if (!item || item.missing) return;
|
||||
if (item.rawUrl || item.url) {
|
||||
window.open(item.rawUrl || item.url, '_blank');
|
||||
window.open(CodemanBase.url(item.rawUrl || item.url), '_blank');
|
||||
return;
|
||||
}
|
||||
this.openAttachmentInNewTab(item.sessionId, item.relativePath || item.fileName, item.attachmentId || null);
|
||||
@@ -4779,7 +4792,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
downloadAttachmentHistoryItem(itemId) {
|
||||
const item = this.getAttachmentHistoryItem(itemId);
|
||||
if (!item || item.missing || !item.downloadUrl) return;
|
||||
window.open(item.downloadUrl, '_blank');
|
||||
window.open(CodemanBase.url(item.downloadUrl), '_blank');
|
||||
},
|
||||
|
||||
reshowAttachmentCard(itemId) {
|
||||
@@ -4925,7 +4938,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Connect to SSE stream
|
||||
const eventSource = new EventSource(
|
||||
`/api/sessions/${sessionId}/tail-file?path=${encodeURIComponent(filePath)}&lines=50`
|
||||
CodemanBase.url(`/api/sessions/${sessionId}/tail-file?path=${encodeURIComponent(filePath)}&lines=50`)
|
||||
);
|
||||
|
||||
eventSource.onmessage = (e) => {
|
||||
@@ -5067,7 +5080,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Build image URL using the existing file-raw endpoint
|
||||
// Use relativePath (path from working dir) instead of fileName (basename) for subdirectory images
|
||||
const imageUrl = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(relativePath || fileName)}`;
|
||||
const imageUrl = CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(relativePath || fileName)}`);
|
||||
|
||||
// Create window element
|
||||
const win = document.createElement('div');
|
||||
|
||||
+430
-22
@@ -187,6 +187,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.closeCasePicker();
|
||||
this.updateDirDisplayForCase(select.value);
|
||||
this.updateMobileCaseLabel(select.value);
|
||||
// Warm the container's CLI list HERE rather than when the run menu opens.
|
||||
// The probe is a `docker exec` round trip, so gating it on the menu meant the
|
||||
// menu painted every mode first and only narrowed a moment later — which
|
||||
// reads as "it shows all of them" and lets a mode be picked that the
|
||||
// container does not have.
|
||||
const picked = (this.cases || []).find((c) => c.name === select.value);
|
||||
if (picked?.location === 'docker') void this._probeDockerCaseModes(picked, null);
|
||||
if (save) {
|
||||
this.saveLastUsedCase(select.value);
|
||||
}
|
||||
@@ -477,10 +484,40 @@ Object.assign(CodemanApp.prototype, {
|
||||
* run modes like the rest, and neither `agy` nor `pi` is likely to be installed.
|
||||
*/
|
||||
_refreshRunModeAvailability(menu) {
|
||||
// A DOCKER case runs its agents INSIDE the container, so host CLI
|
||||
// availability answers the wrong question: the host may have no claude at
|
||||
// all while the container ships one, and gating on the host hides a mode
|
||||
// that would have worked. Adoption records what the container really has
|
||||
// (`availableModes`); an owned container runs our base image, which ships
|
||||
// every CLI, so an absent list means "do not gate" rather than "nothing".
|
||||
// Same source every run* path reads the selected case from.
|
||||
const caseName = document.getElementById('quickStartCase')?.value;
|
||||
const activeCase = caseName ? (this.cases || []).find((c) => c.name === caseName) : null;
|
||||
const isDocker = activeCase?.location === 'docker';
|
||||
// Prefer a LIVE probe over the value stored at attach time: a container's
|
||||
// CLIs can be installed or removed long after the case was linked, and a
|
||||
// case linked before that field existed has none at all.
|
||||
const containerModes = isDocker
|
||||
? this._dockerCaseModes?.[caseName] || activeCase.docker?.availableModes || null
|
||||
: null;
|
||||
if (isDocker && !this._dockerCaseModes?.[caseName]) void this._probeDockerCaseModes(activeCase, menu);
|
||||
// An unreachable container hides every agent mode and explains why, instead
|
||||
// of silently offering modes that cannot start.
|
||||
//
|
||||
// ⚠️ ADOPTED cases only. For an OWNED case a missing container is the normal
|
||||
// state before the first session — the launch chain creates and starts it — so
|
||||
// reporting it as a fault hid every agent mode on a freshly linked Docker case
|
||||
// behind "start it yourself first", for a container Codeman was about to create.
|
||||
const probeError = isDocker ? this._dockerCaseProbeError?.[caseName] : null;
|
||||
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp']) {
|
||||
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
|
||||
if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none';
|
||||
if (!btn) continue;
|
||||
let available;
|
||||
if (isDocker) available = probeError ? false : containerModes ? containerModes.includes(mode) : true;
|
||||
else available = this.isCliAvailable(mode);
|
||||
btn.style.display = available ? 'flex' : 'none';
|
||||
}
|
||||
this._renderRunModeNotice(menu, probeError);
|
||||
// DeepSeek is the one mode whose availability has two halves: `dsh` can be
|
||||
// perfectly installed while no pane-capable profile exists, because DeepSeek
|
||||
// ships no terminal front door. In that state the honest offer is "add one",
|
||||
@@ -622,6 +659,81 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* One-line explanation at the top of the run menu. Only a container that could
|
||||
* not be read produces one; everything else removes it, so a stale reason can
|
||||
* never outlive the condition that caused it.
|
||||
*/
|
||||
_renderRunModeNotice(menu, message) {
|
||||
if (!menu) return;
|
||||
let el = menu.querySelector('.run-mode-notice');
|
||||
if (!message) {
|
||||
el?.remove();
|
||||
return;
|
||||
}
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.className = 'run-mode-notice';
|
||||
menu.prepend(el);
|
||||
}
|
||||
// Server-supplied text: set it, never parse it as markup.
|
||||
el.textContent = message;
|
||||
},
|
||||
|
||||
/**
|
||||
* Ask the container which CLIs it actually has, and re-gate the menu once the
|
||||
* answer lands. Cached per case for the page's lifetime: the menu re-opens
|
||||
* often and the probe is a `docker exec` round trip.
|
||||
*
|
||||
* Best-effort by design — an unreachable daemon or a stopped container leaves
|
||||
* the cache empty, which the caller reads as "unknown" and therefore does not
|
||||
* gate. Hiding every mode because a probe failed would be worse than showing
|
||||
* one that turns out to be missing, which the launch path already refuses with
|
||||
* a specific message.
|
||||
*/
|
||||
async _probeDockerCaseModes(activeCase, menu) {
|
||||
const name = activeCase?.name;
|
||||
const container = activeCase?.docker?.container;
|
||||
const hostId = activeCase?.docker?.hostId;
|
||||
if (!name || !container || !hostId) return;
|
||||
this._dockerCaseModes = this._dockerCaseModes || {};
|
||||
if (this._dockerModeProbeInFlight?.[name]) return;
|
||||
this._dockerModeProbeInFlight = this._dockerModeProbeInFlight || {};
|
||||
this._dockerModeProbeInFlight[name] = true;
|
||||
try {
|
||||
// ⚠️ _api serializes `body` and sets Content-Type itself. Passing an
|
||||
// already-stringified body double-encodes it and the server rejects a
|
||||
// JSON string where it expects an object (400 INVALID_INPUT).
|
||||
const probe = await this._apiJson('/api/docker-cases/adopt-preflight', {
|
||||
method: 'POST',
|
||||
body: { hostId, container },
|
||||
});
|
||||
if (probe?.ok && Array.isArray(probe.availableModes)) {
|
||||
this._dockerCaseModes[name] = probe.availableModes;
|
||||
delete this._dockerCaseProbeError?.[name];
|
||||
} else {
|
||||
// An ADOPTED container that cannot be probed — recreated, stopped, engine
|
||||
// down — must NOT fall through to "show everything". Offering claude on a
|
||||
// container that is not running is a click that can only fail, with the
|
||||
// reason visible nowhere. Record the reason and say it in the menu.
|
||||
//
|
||||
// ⚠️ An OWNED container gets no error: it does not exist until the first
|
||||
// session launches it, so "not found" is the expected answer for every
|
||||
// newly linked Docker case, and gating on it made those cases unusable.
|
||||
// Leaving the cache empty reads as "unknown", which does not gate.
|
||||
if (activeCase?.docker?.owned === false) {
|
||||
this._dockerCaseProbeError = this._dockerCaseProbeError || {};
|
||||
this._dockerCaseProbeError[name] = probe?.error || `Could not read container "${container}".`;
|
||||
}
|
||||
delete this._dockerCaseModes[name];
|
||||
}
|
||||
// Only repaint while the menu the user opened is still on screen.
|
||||
if (menu?.classList.contains('active')) this._refreshRunModeAvailability(menu);
|
||||
} finally {
|
||||
delete this._dockerModeProbeInFlight[name];
|
||||
}
|
||||
},
|
||||
|
||||
async _loadRunModeHistory() {
|
||||
const container = document.getElementById('runModeHistory');
|
||||
if (!container) return;
|
||||
@@ -692,7 +804,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
btn.append(...parts);
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.resumeHistorySession(s.sessionId, s.workingDir, s.name, s.mode);
|
||||
this.resumeHistorySession(s.sessionId, s.workingDir, s.name, s.mode, s.resumeId);
|
||||
});
|
||||
container.appendChild(btn);
|
||||
}
|
||||
@@ -2387,6 +2499,28 @@ Object.assign(CodemanApp.prototype, {
|
||||
modal.querySelectorAll('.set-rail-item').forEach(btn => {
|
||||
btn.onclick = () => this.switchCaseModalTab(btn.dataset.tab);
|
||||
});
|
||||
// Adopt-an-existing-container toggle + its read-only preflight. Assigned (not
|
||||
// addEventListener) so reopening the modal cannot stack duplicate handlers,
|
||||
// matching the rail wiring right above.
|
||||
const adoptToggle = document.getElementById('dockerAdoptExisting');
|
||||
if (adoptToggle) adoptToggle.onchange = () => this._syncDockerAdoptMode();
|
||||
const adoptCheck = document.getElementById('dockerAdoptCheckBtn');
|
||||
if (adoptCheck) adoptCheck.onclick = () => this._dockerAdoptPreflight();
|
||||
const adoptJump = document.getElementById('dockerAdoptJumpBtn');
|
||||
if (adoptJump) adoptJump.onclick = () => this.jumpToDockerAdopt();
|
||||
// Containers come from the host profile, so switching Host ID invalidates the
|
||||
// suggestions. Dropping the marker (rather than refetching here) keeps the
|
||||
// fetch lazy — it happens when adopt mode is actually on.
|
||||
const hostIdInput = document.getElementById('dockerHostId');
|
||||
if (hostIdInput) {
|
||||
hostIdInput.onchange = () => {
|
||||
delete document.getElementById('dockerContainerList')?.dataset.loadedFor;
|
||||
if (document.getElementById('dockerAdoptExisting')?.checked) void this._loadDockerContainerOptions();
|
||||
};
|
||||
}
|
||||
// A fresh open re-reads the engine: containers start and stop between visits.
|
||||
delete document.getElementById('dockerContainerList')?.dataset.loadedFor;
|
||||
this._syncDockerAdoptMode();
|
||||
// Scroll-into-view on focus for mobile keyboard visibility
|
||||
modal.querySelectorAll('input[type="text"]').forEach(input => {
|
||||
if (!input._mobileScrollWired) {
|
||||
@@ -2416,15 +2550,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
// A switched-to panel starts at its own top.
|
||||
const doc = document.getElementById('createCaseDoc');
|
||||
if (doc) doc.scrollTop = 0;
|
||||
// Update submit button (hide for manage tab)
|
||||
const submitBtn = document.getElementById('caseModalSubmit');
|
||||
// Update submit buttons (hide for manage tab). Two of them: mobile.css hides
|
||||
// this modal's .set-foot, so phones submit through the header button instead.
|
||||
const submitBtns = ['caseModalSubmit', 'caseModalSubmitMobile']
|
||||
.map((id) => document.getElementById(id))
|
||||
.filter(Boolean);
|
||||
if (tabName === 'case-manage') {
|
||||
submitBtn.style.display = 'none';
|
||||
submitBtns.forEach((btn) => {
|
||||
btn.style.display = 'none';
|
||||
});
|
||||
this.renderCaseManageList();
|
||||
this.refreshDockerExports();
|
||||
} else {
|
||||
submitBtn.style.display = '';
|
||||
submitBtn.textContent =
|
||||
const label =
|
||||
tabName === 'case-create'
|
||||
? 'Create'
|
||||
: tabName === 'case-clone'
|
||||
@@ -2434,6 +2572,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
: tabName === 'case-docker'
|
||||
? 'Link Docker'
|
||||
: 'Link';
|
||||
submitBtns.forEach((btn) => {
|
||||
btn.style.display = '';
|
||||
btn.textContent = label;
|
||||
});
|
||||
}
|
||||
// Focus appropriate input
|
||||
if (tabName === 'case-create') {
|
||||
@@ -2454,14 +2596,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
async submitCaseModal() {
|
||||
const btn = document.getElementById('caseModalSubmit');
|
||||
const originalText = btn.textContent;
|
||||
btn.classList.add('loading');
|
||||
btn.textContent =
|
||||
// Both submit buttons move together: whichever one the user pressed, the
|
||||
// other must show the same pending state and be equally unclickable.
|
||||
const btns = ['caseModalSubmit', 'caseModalSubmitMobile'].map((id) => document.getElementById(id)).filter(Boolean);
|
||||
const originalText = btns.map((btn) => btn.textContent);
|
||||
const pendingText =
|
||||
this.caseModalTab === 'case-create' ? 'Creating...' : this.caseModalTab === 'case-clone' ? 'Cloning...' : 'Linking...';
|
||||
// A clone holds this request open for minutes; without disabling the button a
|
||||
// second click fires a second clone (the loser then fails on ALREADY_EXISTS).
|
||||
btn.disabled = true;
|
||||
btns.forEach((btn) => {
|
||||
btn.classList.add('loading');
|
||||
btn.textContent = pendingText;
|
||||
btn.disabled = true;
|
||||
});
|
||||
try {
|
||||
if (this.caseModalTab === 'case-create') {
|
||||
await this.createCase();
|
||||
@@ -2475,9 +2622,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
await this.linkCase();
|
||||
}
|
||||
} finally {
|
||||
btn.classList.remove('loading');
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText;
|
||||
btns.forEach((btn, index) => {
|
||||
btn.classList.remove('loading');
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText[index];
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2862,6 +3011,61 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
},
|
||||
|
||||
/** HOST workspace directory — the same picker Link Existing uses. */
|
||||
openDockerWorkspacePathPicker() {
|
||||
const pathInput = document.getElementById('dockerWorkspacePath');
|
||||
PathPicker.open({
|
||||
title: 'Select Host Workspace Folder',
|
||||
initialPath: pathInput.value.trim(),
|
||||
directoriesOnly: true,
|
||||
onSelect: (path) => {
|
||||
pathInput.value = path;
|
||||
const nameInput = document.getElementById('dockerCaseName');
|
||||
if (nameInput && !nameInput.value.trim()) {
|
||||
const folder = path.split('/').filter(Boolean).pop() || '';
|
||||
if (/^[a-zA-Z0-9_-]+$/.test(folder)) nameInput.value = folder;
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Container workdir. Browses INSIDE the container, because for an adopted
|
||||
* container nothing is mounted at a matching host path — the host picker would
|
||||
* be listing a different filesystem, and typing this field blind is exactly
|
||||
* what makes the launch fail with an OCI chdir error.
|
||||
*/
|
||||
openDockerWorkdirPicker() {
|
||||
const pathInput = document.getElementById('dockerAdoptWorkdir');
|
||||
const container = document.getElementById('dockerContainerName')?.value.trim();
|
||||
const hostId = document.getElementById('dockerHostId')?.value.trim() || 'local';
|
||||
if (!container) {
|
||||
this.showToast('Enter the container name first', 'error');
|
||||
return;
|
||||
}
|
||||
PathPicker.open({
|
||||
title: `Select Folder Inside ${container}`,
|
||||
initialPath: pathInput.value.trim() || '/',
|
||||
directoriesOnly: true,
|
||||
fetchListing: async (path) => {
|
||||
const data = await this._apiJson('/api/docker-cases/browse', {
|
||||
method: 'POST',
|
||||
body: { hostId, container, path: path || '/' },
|
||||
});
|
||||
if (!data) return { success: false, error: `Could not read ${container}. Is it running?` };
|
||||
if (data.error) return { success: false, error: data.error };
|
||||
// Shape it like the host endpoint: one root, so Up/Location behave.
|
||||
return {
|
||||
success: true,
|
||||
data: { ...data, root: '/', roots: [{ label: container, path: '/' }], truncated: false },
|
||||
};
|
||||
},
|
||||
onSelect: (path) => {
|
||||
pathInput.value = path;
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async linkRemoteCase() {
|
||||
const name = document.getElementById('remoteCaseName').value.trim();
|
||||
const remotePath = document.getElementById('remoteCasePath').value.trim();
|
||||
@@ -2943,10 +3147,107 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reflect the "attach to an existing container" checkbox onto the modal so CSS
|
||||
* can swap which half of the Docker panel applies. An attribute rather than
|
||||
* per-row inline styles: the panel is rebuilt by nothing, but the create-time
|
||||
* rows are a SET (image, network, advanced block) and one attribute keeps them
|
||||
* in lockstep with the container-name row.
|
||||
*/
|
||||
_syncDockerAdoptMode() {
|
||||
const modal = document.getElementById('createCaseModal');
|
||||
if (!modal) return;
|
||||
const adopting = document.getElementById('dockerAdoptExisting')?.checked;
|
||||
if (adopting) modal.setAttribute('data-docker-adopt', '1');
|
||||
else modal.removeAttribute('data-docker-adopt');
|
||||
if (adopting) void this._loadDockerContainerOptions();
|
||||
},
|
||||
|
||||
/**
|
||||
* Fill the container-name `<datalist>`. A native datalist is deliberate: the
|
||||
* field must accept a free-typed name (the engine may be remote, or the
|
||||
* container may not exist yet when the form is filled), and datalist gives
|
||||
* type-to-filter over the suggestions without a custom dropdown.
|
||||
*
|
||||
* Best-effort by design — the endpoint returns [] for an unreachable daemon,
|
||||
* and an empty list simply leaves the field as plain text input.
|
||||
*/
|
||||
async _loadDockerContainerOptions() {
|
||||
const list = document.getElementById('dockerContainerList');
|
||||
if (!list) return;
|
||||
const hostId = document.getElementById('dockerHostId')?.value.trim() || 'local';
|
||||
if (list.dataset.loadedFor === hostId) return; // one fetch per host per open
|
||||
const data = await this._apiJson(`/api/docker-hosts/${encodeURIComponent(hostId)}/containers`);
|
||||
const containers = data?.containers || [];
|
||||
list.textContent = '';
|
||||
for (const c of containers) {
|
||||
const option = document.createElement('option');
|
||||
option.value = c.name;
|
||||
// Engine-supplied strings: set as text, never as markup.
|
||||
option.textContent = c.running ? `${c.image} · ${c.status}` : `${c.image} · ${c.status} (not running)`;
|
||||
list.appendChild(option);
|
||||
}
|
||||
list.dataset.loadedFor = hostId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Cross-link from the Create New tab's "Run in an isolated Docker container"
|
||||
* row. Adoption lives on the Docker tab, but the place users actually look for
|
||||
* anything container-shaped is that checkbox, so this jumps them there with the
|
||||
* toggle already on rather than leaving the feature undiscoverable.
|
||||
*/
|
||||
jumpToDockerAdopt() {
|
||||
this.switchCaseModalTab('case-docker');
|
||||
const toggle = document.getElementById('dockerAdoptExisting');
|
||||
if (toggle) toggle.checked = true;
|
||||
this._syncDockerAdoptMode();
|
||||
document.getElementById('dockerContainerName')?.focus();
|
||||
},
|
||||
|
||||
/**
|
||||
* Read-only preflight against an existing container. It links nothing, so the
|
||||
* user can find out "not running" / "no tmux" / "codex present, claude missing"
|
||||
* before committing to a case name — the same reason the server refuses at link
|
||||
* time rather than at session launch.
|
||||
*/
|
||||
async _dockerAdoptPreflight() {
|
||||
const statusEl = document.getElementById('dockerLinkStatus');
|
||||
const container = document.getElementById('dockerContainerName')?.value.trim();
|
||||
const containerWorkdir = document.getElementById('dockerAdoptWorkdir')?.value.trim();
|
||||
const hostId = document.getElementById('dockerHostId').value.trim() || 'local';
|
||||
if (!container) {
|
||||
if (statusEl) statusEl.textContent = 'Enter a container name first.';
|
||||
return;
|
||||
}
|
||||
if (statusEl) statusEl.textContent = 'Inspecting container...';
|
||||
// _apiJson folds every failure to null, and a preflight's whole value is the
|
||||
// reason it failed, so the envelope is unwrapped by hand here.
|
||||
const probe = await this._apiJson('/api/docker-cases/adopt-preflight', {
|
||||
method: 'POST',
|
||||
body: { hostId, container, ...(containerWorkdir ? { containerWorkdir } : {}) },
|
||||
});
|
||||
if (!statusEl) return;
|
||||
if (!probe) {
|
||||
statusEl.textContent = 'Could not reach the docker host profile. Save a Host ID first.';
|
||||
return;
|
||||
}
|
||||
if (!probe.ok) {
|
||||
statusEl.textContent = probe.error || 'Container is not adoptable.';
|
||||
return;
|
||||
}
|
||||
const modes = (probe.availableModes || []).filter((m) => m !== 'shell');
|
||||
statusEl.textContent = modes.length
|
||||
? `Running (${probe.image || 'unknown image'}). Available: ${modes.join(', ')}.`
|
||||
: `Running (${probe.image || 'unknown image'}), but no agent CLI found inside — only Shell will work.`;
|
||||
},
|
||||
|
||||
async linkDockerCase() {
|
||||
const name = document.getElementById('dockerCaseName').value.trim();
|
||||
const hostWorkspacePath = document.getElementById('dockerWorkspacePath').value.trim();
|
||||
const hostId = document.getElementById('dockerHostId').value.trim() || 'local';
|
||||
const adopting = !!document.getElementById('dockerAdoptExisting')?.checked;
|
||||
const container = document.getElementById('dockerContainerName')?.value.trim() || '';
|
||||
const adoptWorkdir = document.getElementById('dockerAdoptWorkdir')?.value.trim() || '';
|
||||
const image = document.getElementById('dockerImage').value.trim() || 'codeman/agent:base';
|
||||
const network = document.getElementById('dockerNetwork').value;
|
||||
const memory = document.getElementById('dockerMemory').value.trim();
|
||||
@@ -2967,9 +3268,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.showToast('Workspace path must be absolute', 'error');
|
||||
return;
|
||||
}
|
||||
if (adopting && !container) {
|
||||
this.showToast('Enter the name of the running container to attach to', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (statusEl) statusEl.textContent = 'Checking docker daemon + base image...';
|
||||
if (statusEl) {
|
||||
statusEl.textContent = adopting ? 'Inspecting the existing container...' : 'Checking docker daemon + base image...';
|
||||
}
|
||||
// omitted optionals sent as UNDEFINED (never null — Zod .optional() rejects null)
|
||||
const resources = {};
|
||||
if (memory) resources.memory = memory;
|
||||
@@ -3000,16 +3307,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
if (!hostData.success) throw new Error(hostData.error || 'Failed to save docker host');
|
||||
|
||||
const caseRes = await fetch('/api/cases/docker-link', {
|
||||
// Adoption reuses this whole flow and differs only in the final call: a
|
||||
// different endpoint (which never creates a container) plus the container
|
||||
// name. The host upsert above still applies — it is what resolves the
|
||||
// engine/context/daemon for the `docker exec`; its create-time fields are
|
||||
// simply never read for an adopted case.
|
||||
const caseRes = await fetch(adopting ? '/api/cases/docker-adopt' : '/api/cases/docker-link', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, hostId, hostWorkspacePath }),
|
||||
body: JSON.stringify(
|
||||
adopting
|
||||
? { name, hostId, hostWorkspacePath, container, ...(adoptWorkdir ? { containerWorkdir: adoptWorkdir } : {}) }
|
||||
: { name, hostId, hostWorkspacePath }
|
||||
),
|
||||
});
|
||||
const caseData = await caseRes.json();
|
||||
if (caseData.success) {
|
||||
this.closeCreateCaseModal();
|
||||
const caps = caseData.data?.capsEnforced === false ? ' (resource caps are advisory on this engine)' : '';
|
||||
this.showToast(`Docker case "${name}" linked${caps}`, 'success');
|
||||
const modes = (caseData.data?.availableModes || []).filter((m) => m !== 'shell');
|
||||
const found = adopting && modes.length ? ` — found ${modes.join(', ')}` : '';
|
||||
this.showToast(`Docker case "${name}" ${adopting ? 'attached' : 'linked'}${caps}${found}`, 'success');
|
||||
await this.loadQuickStartCases(name);
|
||||
await this.saveLastUsedCase(name);
|
||||
} else {
|
||||
@@ -3046,7 +3364,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
return `<div class="case-manage-item" style="display:flex; align-items:center; gap:8px; justify-content:space-between;">
|
||||
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${nm}">${nm} <span class="form-hint">(${mb} MB)</span></span>
|
||||
<span style="flex-shrink:0;">
|
||||
<a class="btn-toolbar" href="/api/docker-exports/${encodeURIComponent(e.name)}" download>Download</a>
|
||||
<a class="btn-toolbar" href="${CodemanBase.url(`/api/docker-exports/${encodeURIComponent(e.name)}`)}" download>Download</a>
|
||||
<button class="btn-toolbar" onclick="app.importDockerBundle('${nm.replace(/'/g, "\\'")}')">Import</button>
|
||||
<button class="btn-toolbar" onclick="app.deleteDockerExport('${nm.replace(/'/g, "\\'")}')">Delete</button>
|
||||
</span>
|
||||
@@ -3277,17 +3595,33 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
// Cases an agent worker created (server-side marker file, see agent-case-marker.ts).
|
||||
// A long orchestration leaves one scratch directory per worker behind, so they get
|
||||
// a badge and a bulk cleanup entry point rather than having to be recognised by name.
|
||||
const agentCases = cases.filter(c => c.agentCreated);
|
||||
let html = agentCases.length > 0
|
||||
? `<div class="case-manage-agent-bar">
|
||||
<span class="case-manage-agent-count">${agentCases.length} case${agentCases.length === 1 ? '' : 's'} created by agent workers</span>
|
||||
<button class="case-manage-btn case-manage-btn-cleanup" onclick="app.cleanupAgentCases()"
|
||||
title="Review and delete the scratch cases agent workers left behind">Clean up</button>
|
||||
</div>`
|
||||
: '';
|
||||
cases.forEach((c, idx) => {
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === cases.length - 1;
|
||||
// Was `/Users/<user>` only, the mirror image of the Run menu's bug: every
|
||||
// case path on a Linux host rendered in full, unabbreviated.
|
||||
const pathDisplay = c.path ? this._shortenHomePath(c.path) : '';
|
||||
const agentTitle = c.agentCreated
|
||||
? `Created by an agent worker${c.agentCreated.parentSessionName ? ` from ${c.agentCreated.parentSessionName}` : ''}` +
|
||||
` (${c.agentCreated.createdBy})${c.agentCreated.createdAt ? ` on ${new Date(c.agentCreated.createdAt).toLocaleString()}` : ''}`
|
||||
: '';
|
||||
html += `
|
||||
<div class="case-manage-item" data-case="${escapeHtml(c.name)}">
|
||||
<div class="case-manage-info">
|
||||
<span class="case-manage-name">${escapeHtml(c.name)}</span>
|
||||
<span class="case-manage-name">${escapeHtml(c.name)}${
|
||||
c.agentCreated ? `<span class="case-manage-tag-agent" title="${escapeHtml(agentTitle)}" data-i18n-skip>agent</span>` : ''
|
||||
}</span>
|
||||
<span class="case-manage-path">${escapeHtml(pathDisplay)}</span>
|
||||
</div>
|
||||
<div class="case-manage-actions">
|
||||
@@ -3365,6 +3699,80 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Review-then-delete the scratch cases agent workers left behind.
|
||||
*
|
||||
* ⚠️ Never silently bulk-deletes: the confirm names every directory, and a case a
|
||||
* LIVE session is still working in is excluded outright rather than confirmed away
|
||||
* (`inUse` from the server, which knows every session's working directory). Removal
|
||||
* reuses `DELETE /api/cases/:name` one name at a time, so there is no second
|
||||
* recursive-delete path to keep in step with the first.
|
||||
*/
|
||||
async cleanupAgentCases() {
|
||||
let agentCases;
|
||||
try {
|
||||
const res = await fetch('/api/cases/agent-created');
|
||||
const body = await res.json();
|
||||
if (!body.success) {
|
||||
this.showToast(body.error || 'Failed to list agent cases', 'error');
|
||||
return;
|
||||
}
|
||||
agentCases = body.data.cases || [];
|
||||
} catch (err) {
|
||||
this.showToast('Failed to list agent cases: ' + err.message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const busy = agentCases.filter(c => c.inUse);
|
||||
const removable = agentCases.filter(c => !c.inUse);
|
||||
if (removable.length === 0) {
|
||||
this.showToast(
|
||||
busy.length > 0
|
||||
? `All ${busy.length} agent case(s) are still in use by a running session`
|
||||
: 'No agent-created cases to clean up',
|
||||
'info'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const names = removable.map(c => ` ${c.name}`).join('\n');
|
||||
const busyNote = busy.length > 0 ? `\n\nSkipping ${busy.length} case(s) still in use by a running session.` : '';
|
||||
if (!confirm(`Permanently delete ${removable.length} agent-created case folder(s) and everything in them?\n\n${names}${busyNote}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let deleted = 0;
|
||||
const failed = [];
|
||||
for (const item of removable) {
|
||||
try {
|
||||
const res = await fetch(`/api/cases/${encodeURIComponent(item.name)}`, { method: 'DELETE' });
|
||||
const body = await res.json();
|
||||
if (body.success) deleted++;
|
||||
else failed.push(item.name);
|
||||
} catch {
|
||||
failed.push(item.name);
|
||||
}
|
||||
}
|
||||
|
||||
this.showToast(
|
||||
failed.length === 0
|
||||
? `Deleted ${deleted} agent case(s)`
|
||||
: `Deleted ${deleted}, failed: ${failed.join(', ')}`,
|
||||
failed.length === 0 ? 'success' : 'error'
|
||||
);
|
||||
|
||||
// Refresh the picker (its selected case may be one we just deleted) and the list.
|
||||
const select = document.getElementById('quickStartCase');
|
||||
const currentCase = select?.value;
|
||||
const currentDeleted = removable.some(c => c.name === currentCase);
|
||||
if (currentDeleted) select?.blur?.();
|
||||
await this.loadQuickStartCases(currentDeleted ? null : currentCase);
|
||||
if (currentDeleted) {
|
||||
await this.saveLastUsedCase(document.getElementById('quickStartCase')?.value || 'testcase');
|
||||
}
|
||||
this.renderCaseManageList();
|
||||
},
|
||||
|
||||
async saveCaseOrder(order) {
|
||||
try {
|
||||
await fetch('/api/cases/order', {
|
||||
|
||||
@@ -187,7 +187,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
registerServiceWorker() {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
navigator.serviceWorker.register('/sw.js').then((reg) => {
|
||||
// Behind a sub-path mount the worker is served at <base>/sw.js and controls
|
||||
// <base>/ (Service-Worker-Allowed is '/', so this narrower scope is permitted).
|
||||
const _swBase = window.CodemanBase?.base || '';
|
||||
navigator.serviceWorker.register(_swBase + '/sw.js', { scope: _swBase + '/' }).then((reg) => {
|
||||
this._swRegistration = reg;
|
||||
// Listen for messages from service worker (notification clicks)
|
||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||
@@ -428,6 +431,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.syncTabRailWidthSetting?.(tabRailWidth);
|
||||
document.getElementById('appSettingsTabRailDetail').value =
|
||||
settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich';
|
||||
document.getElementById('appSettingsTabRailSort').value =
|
||||
settings.tabRailSort ?? defaults.tabRailSort ?? 'activity';
|
||||
document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
|
||||
document.getElementById('appSettingsSessionListLayout').value =
|
||||
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
|
||||
@@ -1079,10 +1084,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
const verEl = this.$('updateCurrentVersion');
|
||||
if (verEl && data.currentVersion) verEl.textContent = `v${data.currentVersion}`;
|
||||
|
||||
if (data.installKind && data.installKind !== 'git') {
|
||||
this._setUpdateResult(
|
||||
`This install can't update itself (${escapeHtml(data.installKind)}). Update with <code>npm i -g aicodeman@latest</code>.`
|
||||
);
|
||||
// `docker-compose` self-updates in place like `git` does — the container
|
||||
// restarts itself. Anything else cannot.
|
||||
if (data.installKind && data.installKind !== 'git' && data.installKind !== 'docker-compose') {
|
||||
const hint =
|
||||
data.supervisor === 'docker-compose'
|
||||
? 'Update from the Docker host with <code>docker/Start-Codeman.sh</code>.'
|
||||
: 'Update with <code>npm i -g aicodeman@latest</code>.';
|
||||
this._setUpdateResult(`This install can't update itself (${escapeHtml(data.installKind)}). ${hint}`);
|
||||
return;
|
||||
}
|
||||
if (data.selfUpdateEnabled === false) {
|
||||
@@ -1093,6 +1102,30 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._setUpdateResult(escapeHtml(data.error));
|
||||
return;
|
||||
}
|
||||
// A container release that changes the ENVIRONMENT (Dockerfile, compose file
|
||||
// or new .env keys) cannot be applied by the container restarting itself, so
|
||||
// the update button is never offered — the host command is, instead. The
|
||||
// server re-checks this on POST, so hiding the button is UX, not the gate.
|
||||
const blockers = data.environment?.blockers || [];
|
||||
if (data.updateAvailable && blockers.length > 0) {
|
||||
const reasons = blockers
|
||||
.map((b) => {
|
||||
const details = b.details?.length ? `<br><code>${escapeHtml(b.details.join(' '))}</code>` : '';
|
||||
return `<li>${escapeHtml(b.message)}${details}</li>`;
|
||||
})
|
||||
.join('');
|
||||
this._setUpdateResult(
|
||||
`<strong>v${escapeHtml(data.latestVersion || '')}</strong> needs a rebuild on the Docker host` +
|
||||
` (current v${escapeHtml(data.currentVersion || '')}):<ul>${reasons}</ul>` +
|
||||
`Run <code>${escapeHtml(data.environment?.hostCommand || 'docker/Start-Codeman.sh')}</code> there to apply it.`
|
||||
);
|
||||
if (notes && data.notes) {
|
||||
notes.style.display = 'block';
|
||||
notes.textContent = data.notes;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.updateAvailable && data.latestVersion) {
|
||||
this._setUpdateResult(
|
||||
`Update available: <strong>v${escapeHtml(data.latestVersion)}</strong> (current v${escapeHtml(data.currentVersion || '')})`
|
||||
@@ -2066,6 +2099,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
tabOrientation: document.getElementById('appSettingsTabOrientation').value,
|
||||
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
|
||||
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
|
||||
tabRailSort: document.getElementById('appSettingsTabRailSort').value,
|
||||
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
|
||||
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
|
||||
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
|
||||
@@ -2469,6 +2503,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
tabOrientation: 'horizontal',
|
||||
tabRailWidth: 256,
|
||||
tabRailDetail: 'rich',
|
||||
tabRailSort: 'activity',
|
||||
sessionListLayout: 'header',
|
||||
sessionSidebarFontSize: 12,
|
||||
cjkInputEnabled: false,
|
||||
@@ -2735,6 +2770,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
const detail = (settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich') === 'simple' ? 'simple' : 'rich';
|
||||
root.dataset.tabRailDetail = detail;
|
||||
|
||||
// Row ORDER rides on a third attribute, for the same reason detail rides on
|
||||
// its own: a sort flip leaves orientation on 'vertical' both times, and the
|
||||
// order is applied as an inline `order` the render paths emit, not by CSS
|
||||
// that could just re-match. `isTabRailSorted()` (app.js) reads this.
|
||||
const previousSort = root.dataset.tabRailSort || 'activity';
|
||||
const sort = (settings.tabRailSort ?? defaults.tabRailSort ?? 'activity') === 'manual' ? 'manual' : 'activity';
|
||||
root.dataset.tabRailSort = sort;
|
||||
|
||||
const tabsEl = document.getElementById('sessionTabs');
|
||||
const rail = document.getElementById('tabRail');
|
||||
const headerHost = document.getElementById('sessionTabsHost');
|
||||
@@ -2758,7 +2801,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the row template, not toggled by CSS — same reasoning as the sidebar's
|
||||
// detail half in applySessionListLayout(). Taller rows also move every
|
||||
// connector anchored to a tab rect.
|
||||
const changed = orientationChanged || previousDetail !== detail;
|
||||
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort;
|
||||
if (orientationChanged) {
|
||||
this.updateTabOverflowMode?.();
|
||||
if (!settleRailWidth) this.fitAddon?.fit();
|
||||
@@ -3031,7 +3074,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||
'terminalFontFamily',
|
||||
'language',
|
||||
|
||||
+408
-5
@@ -905,6 +905,28 @@ html[data-tab-anim="flip"] .session-tab.tab-enter {
|
||||
100% { opacity: 1; transform: perspective(700px) rotateX(0deg); }
|
||||
}
|
||||
|
||||
/* Blur, iOS-style focus pull: the tab arrives out of focus and the blur fades
|
||||
OFF it as the opacity comes up, so it reads as resolving rather than moving.
|
||||
Opacity leads the blur (full opacity around 45%, blur still lifting) - that
|
||||
offset is what separates it from a plain cross-fade.
|
||||
|
||||
`filter` here, not on ::before: the tab's own box-shadow and border have to
|
||||
blur with it or the shape stays sharp inside a blurred fill, and unlike
|
||||
background/box-shadow (which .session-tab.active sets !important) nothing
|
||||
overrides filter. */
|
||||
html[data-tab-anim="blur"] .session-tab.tab-enter {
|
||||
animation-name: tab-enter-blur;
|
||||
animation-duration: calc(440ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
will-change: transform, opacity, filter;
|
||||
}
|
||||
|
||||
@keyframes tab-enter-blur {
|
||||
0% { opacity: 0; filter: blur(10px); transform: scale(0.94); }
|
||||
45% { opacity: 1; }
|
||||
100% { opacity: 1; filter: blur(0px); transform: none; }
|
||||
}
|
||||
|
||||
/* ── Entrance lab (?animlab=1) ───────────────────────────────────────────── */
|
||||
|
||||
.anim-lab {
|
||||
@@ -1186,6 +1208,25 @@ html[data-win-anim="pop"] .ultracode-window.win-enter {
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Blur, iOS-style focus pull. `materialize` is the noisy cousin: it glitches the
|
||||
opacity and rides a brightness boost. This one only defocuses, so it stays
|
||||
readable next to a terminal. The scale is deliberately small (0.96): the
|
||||
connection line is aimed at getBoundingClientRect(), which reports the
|
||||
TRANSFORMED box, so a big scale would swing the line's target while it draws.
|
||||
applyWindowEntrance() redraws the lines once the animation ends. */
|
||||
html[data-win-anim="blur"] .subagent-window.win-enter,
|
||||
html[data-win-anim="blur"] .ultracode-window.win-enter {
|
||||
animation-name: win-enter-blur;
|
||||
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
@keyframes win-enter-blur {
|
||||
0% { opacity: 0; filter: blur(18px); transform: scale(0.96); }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 1; filter: blur(0px); transform: none; }
|
||||
}
|
||||
|
||||
/* ── Main terminal pane entrance animations ────────────────────────────────
|
||||
⚠ transform / opacity / clip-path ONLY. xterm's FitAddon derives rows+cols
|
||||
from getComputedStyle(parent).width/height, the untransformed layout box -
|
||||
@@ -1327,6 +1368,47 @@ html[data-term-anim="fade"] .terminal-container.term-enter {
|
||||
100% { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* Blur, iOS-style focus pull.
|
||||
|
||||
⚠ THE ONE PLACE A `filter` GOES ON THE TERMINAL CONTAINER, and it is a
|
||||
deliberate exception to the rule above, not an oversight. Every other way of
|
||||
blurring this pane was tried against a real xterm and does not work:
|
||||
|
||||
- `backdrop-filter` on ::before blurs perfectly while it is STATIC, and
|
||||
Chrome silently drops the backdrop the moment ANY animation runs on that
|
||||
pseudo-element (measured: the veil computes `blur(15.3px)` and the text
|
||||
behind it stays razor sharp). Animating the container instead keeps the
|
||||
backdrop, so the veil would have to hold one fixed radius, which is a
|
||||
frosted pane that snaps off rather than a focus pull.
|
||||
- Driving the radius from rAF avoids the compositor promotion, at the cost
|
||||
of the same full-screen blur per frame plus main-thread work.
|
||||
|
||||
So the cost the rule exists to avoid is inherent to blurring a terminal at
|
||||
all, and this style buys it knowingly: it is opt-in, OFF by default, bounded
|
||||
to one ~520ms run when a session is opened (or switched to, with `Also on
|
||||
every tab switch`), and the class comes straight back off. `will-change` is
|
||||
still deliberately unset, per the base rule. Measured price on a headless
|
||||
SwiftShader rasterizer with no GPU at all, i.e. the worst case: frame deltas
|
||||
go 16.7ms -> 33.3ms for the length of the run, against 16.7ms flat for `fade`.
|
||||
|
||||
The blur must not change layout, or FitAddon would feed wrong dimensions into
|
||||
resize() and through to the PTY. `filter` is paint-only (measured live: 178x38
|
||||
before, during and after a run), and the property allowlist for every one of
|
||||
these keyframes is pinned by test/entrance-animations.test.ts. */
|
||||
html[data-term-anim="blur"] .terminal-container.term-enter {
|
||||
animation-name: term-enter-blur;
|
||||
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
/* Opacity leads the blur - full opacity around 45%, blur still lifting - which
|
||||
is what separates the effect from a plain cross-fade. */
|
||||
@keyframes term-enter-blur {
|
||||
0% { opacity: 0; filter: blur(14px); transform: scale(1.008); }
|
||||
45% { opacity: 1; }
|
||||
100% { opacity: 1; filter: blur(0px); transform: none; }
|
||||
}
|
||||
|
||||
/* ── Connection-line entrance animations ───────────────────────────────────
|
||||
`--line-len` is the measured path length, stamped inline by
|
||||
_applyLineEntrances(); `--line-enter-delay` is negative when an entrance is
|
||||
@@ -1393,6 +1475,26 @@ html[data-line-anim="packet"] .connection-line.line-enter {
|
||||
100% { stroke-dashoffset: calc(-1 * var(--line-len)); opacity: 0; }
|
||||
}
|
||||
|
||||
/* Blur, the line focuses in alongside a blurred tab and window. `filter` on an
|
||||
SVG path takes CSS filter functions, so the blur simply rides in front of the
|
||||
line's own glow (see --line-glow on .connection-line).
|
||||
|
||||
⚠ The 100% frame deliberately omits `opacity`, which makes the browser take
|
||||
the endpoint from the element's own computed value: a subagent line rests at
|
||||
0.9, a lineage line at 0.72, and a WORKING lineage line at 0.95. Pinning 0.9
|
||||
here - as `line-enter-fade` above still does - lands every lineage line on the
|
||||
wrong opacity and snaps it when the class comes off. */
|
||||
html[data-line-anim="blur"] .connection-line.line-enter {
|
||||
animation-name: line-enter-blur;
|
||||
animation-duration: calc(380ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
@keyframes line-enter-blur {
|
||||
0% { opacity: 0; filter: blur(5px) var(--line-glow); }
|
||||
100% { filter: blur(0px) var(--line-glow); }
|
||||
}
|
||||
|
||||
.anim-lab-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -5941,6 +6043,57 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Agent-created cases: the badge on a scratch case, and the bulk cleanup bar above
|
||||
the list. Tokens only (no hardcoded ink), so the light skins repaint with the rest. */
|
||||
.case-manage-tag-agent {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 0 5px;
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 3px;
|
||||
background: var(--control-bg);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.58rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
|
||||
.case-manage-agent-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 4px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 6px;
|
||||
/* Sticky, and therefore OPAQUE: it is the first child of the scrolling list
|
||||
(.case-manage-list is a 320px-tall flex scroller), so a translucent bar would
|
||||
have case rows sliding visibly under it, and a static one would put the cleanup
|
||||
button out of reach the moment a long case list is scrolled. */
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.case-manage-agent-count {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* The shared .case-manage-btn is a 26px icon square; this one carries a word. */
|
||||
.case-manage-btn-cleanup {
|
||||
width: auto;
|
||||
padding: 0 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar-input {
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: var(--bg-input);
|
||||
@@ -9782,10 +9935,17 @@ kbd {
|
||||
stroke-dasharray: 5 3;
|
||||
fill: none;
|
||||
opacity: 0.9;
|
||||
/* Dark outline for contrast, vibrant blue glow */
|
||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8))
|
||||
drop-shadow(0 0 4px rgba(59, 130, 246, 0.8))
|
||||
drop-shadow(0 0 8px rgba(59, 130, 246, 0.5));
|
||||
/* Dark outline for contrast, vibrant blue glow. Held in a variable because the
|
||||
`blur` line entrance animates `filter`: a keyframe listing only the blur would
|
||||
drop the glow for the length of the run and pop it back at the end, and the
|
||||
lineage lines below - whose glow is a different colour entirely, set per
|
||||
element - make that obvious. Both of its keyframes say
|
||||
`blur(N) var(--line-glow)`, so the function lists match and interpolate while
|
||||
each kind of line keeps its own glow. */
|
||||
--line-glow: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8))
|
||||
drop-shadow(0 0 4px rgba(59, 130, 246, 0.8))
|
||||
drop-shadow(0 0 8px rgba(59, 130, 246, 0.5));
|
||||
filter: var(--line-glow);
|
||||
transition: opacity 0.2s, stroke-width 0.2s, filter 0.2s;
|
||||
}
|
||||
|
||||
@@ -9879,8 +10039,10 @@ kbd {
|
||||
stroke-dasharray: 5 5;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.72;
|
||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7)) drop-shadow(0 0 5px var(--lineage-color, var(--session-blue, #2b8fd9)))
|
||||
--line-glow: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7))
|
||||
drop-shadow(0 0 5px var(--lineage-color, var(--session-blue, #2b8fd9)))
|
||||
drop-shadow(0 0 11px var(--lineage-color, var(--session-blue, #2b8fd9)));
|
||||
filter: var(--line-glow);
|
||||
}
|
||||
|
||||
/* ⚠ OUTSIDE the reduced-motion block below on purpose. A working child is the case
|
||||
@@ -12607,6 +12769,43 @@ kbd {
|
||||
background: color-mix(in srgb, var(--green) 12%, transparent);
|
||||
}
|
||||
|
||||
/* Consecutive messages from one speaker inside one turn are segments of a
|
||||
single utterance, not separate cards: no repeated badge, a hairline seam.
|
||||
The role accent survives because the colour rules above match on BOTH
|
||||
:has(.rv-role-*) and .rv-msg-* — a badge-less continuation still hits the
|
||||
class arm. Do not drop either arm. */
|
||||
.rv-message.rv-msg-cont {
|
||||
margin-top: -18px;
|
||||
border-top: 0;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.rv-message.rv-msg-cont > .rv-text {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.rv-message:has(+ .rv-msg-cont) {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* A prompt the user typed while the agent was working (absorbed mid-turn).
|
||||
A pseudo-element, not a text node, so the i18n MutationObserver cannot
|
||||
rewrite it. */
|
||||
.rv-message[data-queued='1'] .rv-role::after {
|
||||
content: ' ⏱';
|
||||
}
|
||||
|
||||
.rv-notice {
|
||||
opacity: 0.7;
|
||||
font-style: italic;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* Markdown rendered content inside response viewer.
|
||||
Prose uses a proportional font for readability; code keeps monospace. */
|
||||
.rv-text,
|
||||
@@ -16645,6 +16844,44 @@ html[data-tab-orientation='vertical'] .home-sessions {
|
||||
label as a row label, its `.form-hint` as a row description. Scoped to the
|
||||
document, so `.form-row` everywhere else is untouched.
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
/* Adopt-an-existing-container mode swaps which half of the Docker panel applies:
|
||||
the create-time fields (image, network, resources, credential mounts) describe
|
||||
a `docker create` that adoption never runs, and the container name is the one
|
||||
field only adoption needs. `.docker-adopt-only` is hidden by default so the
|
||||
panel stays exactly as it was until the checkbox is ticked. Rules carry
|
||||
`!important` because the adapter block above paints `.form-row` as a row card
|
||||
and `details.advanced-options` has its own display. */
|
||||
/* Run-menu notice: why a container case is offering no agent modes. Lives at the
|
||||
top of the menu so the reason is where the missing entries would have been. */
|
||||
.run-mode-notice {
|
||||
padding: 8px 12px;
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted, #9aa0a6);
|
||||
border-bottom: 1px solid var(--border, #333);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
#createCaseModal .docker-adopt-only {
|
||||
display: none !important;
|
||||
}
|
||||
#createCaseModal[data-docker-adopt='1'] .docker-adopt-only {
|
||||
display: block !important;
|
||||
}
|
||||
#createCaseModal[data-docker-adopt='1'] .docker-create-only {
|
||||
display: none !important;
|
||||
}
|
||||
#createCaseModal .btn-inline-check {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--accent, #4a9eff);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#createCaseModal .set-doc .form-row {
|
||||
margin: 0 0 3px;
|
||||
padding: 7px 10px;
|
||||
@@ -17517,6 +17754,172 @@ html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
/* --- Detailed rail rows wear the HOME SCREEN's card language -------------- */
|
||||
/* The desktop home rail (.home-sessions, home-sessions.js) and this rail list
|
||||
the same sessions with the same three lines — name, project, "created 3d ago
|
||||
· working 12m" + pill — so they now LOOK the same too: a bordered card per
|
||||
session instead of a flat strip row, a 9px state dot (pulsing and ringed
|
||||
while the session works), the name at full weight, and the stamps line
|
||||
wrapped onto its own full-width row with the pill parked at its right end.
|
||||
|
||||
⚠ RAIL ONLY, never the shared comma-grouped selectors above. The detailed
|
||||
SIDEBAR draws the same meta line and is deliberately left flat: it is a
|
||||
permanently-docked navigation column that sits next to the terminal all day,
|
||||
and 20 stacked cards there read as a wall. Widening one of those grouped
|
||||
rules instead of adding this block is what would silently restyle it.
|
||||
|
||||
⚠ The paint rules here are (0,5,1)-(0,6,1), so they outrank the plain
|
||||
`.session-tab .tab-status.idle` class rules (0,2,0) — which is the point —
|
||||
but they must NOT outrank the alert rules that turn a dot red or yellow when
|
||||
a session is blocked on a human. Those are (0,3,0), so every dot rule below
|
||||
excludes the two alert classes by hand rather than relying on the cascade.
|
||||
State and alert normally agree (_mobileOverviewState reads the same pending
|
||||
hooks the alert does), so this is a guard against them drifting, not a fix
|
||||
for a known disagreement. */
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tabs {
|
||||
gap: 0.35rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab {
|
||||
/* The stamps line rides a wrapped full-width row, exactly as on the home rail:
|
||||
that hands the whole width of the card to the session name, which is what
|
||||
stops a `w34-claudeman: mindreading` ellipsizing into `w34-claudeman: …`. */
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: rgba(34, 197, 94, 0.35);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* The name is what tells one card from another, so it carries the weight the
|
||||
home rail gives it rather than the strip's dim 0.75rem label. */
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-name {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
/* One step up from the shared vertical-list size, which is 12px because that
|
||||
is what the SIDEBAR has always been; a card row has the height for the
|
||||
~13px the home rail renders at. Still driven by the same setting (App
|
||||
Settings → Session Name Font Size), so it moves with it. */
|
||||
font-size: calc(var(--session-sidebar-name-font-size, 12px) + 1px);
|
||||
/* Three lines rather than two: the card is wrapped, so a long name costs
|
||||
height instead of pushing the pill or the stamps off the row. */
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-folder {
|
||||
font-size: 0.66rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
/* The state line gets the full width of the card (`flex: 0 0 100%` wraps it),
|
||||
so the stamps stop competing with the gear/close column for the sliver of
|
||||
room left beside the name. */
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta {
|
||||
flex: 0 0 100%;
|
||||
margin-top: 0;
|
||||
font-size: 0.64rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Bigger than the strip's 6px pip, because a card row has the room and the dot
|
||||
is the one thing on it readable at a glance. */
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-status {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
/* Idle is green, but a MUTED green — same mix the home rail uses, for the same
|
||||
reason: a glance down the rail must separate "running right now" from
|
||||
"sitting there" without reading a word. */
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact)
|
||||
.tab-rail
|
||||
.session-tab.tab-state-idle:not(.tab-alert-action):not(.tab-alert-idle)
|
||||
.tab-status {
|
||||
background: color-mix(in srgb, var(--green) 42%, var(--text-muted));
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact)
|
||||
.tab-rail
|
||||
.session-tab.tab-state-done:not(.tab-alert-action):not(.tab-alert-idle)
|
||||
.tab-status {
|
||||
background: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* The working halo. The orbiting ring is the shared `.tab-status.busy::after`
|
||||
already declared above — only its inset moves, to clear the wider dot. */
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact)
|
||||
.tab-rail
|
||||
.session-tab.tab-state-working:not(.tab-alert-action):not(.tab-alert-idle)
|
||||
.tab-status {
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 8px 2px color-mix(in srgb, var(--green) 55%, transparent);
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact)
|
||||
.tab-rail
|
||||
.session-tab
|
||||
.tab-status.busy::after {
|
||||
inset: -4px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
/* Card accents: the same three colours as every other session surface, and the
|
||||
same two blinks the home rail runs. `tab-alert-*` draws its own ::before ring
|
||||
on top of this for the sessions that are genuinely blocked on a human; these
|
||||
border accents are the calmer, always-on half. */
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab.tab-state-needs,
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab.tab-state-error {
|
||||
border-color: color-mix(in srgb, var(--red) 50%, transparent);
|
||||
animation: home-sessions-blink-red 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab.tab-state-waiting {
|
||||
border-color: color-mix(in srgb, var(--yellow) 50%, transparent);
|
||||
animation: home-sessions-blink-yellow 3.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab.tab-state-working {
|
||||
border-color: color-mix(in srgb, var(--green) 35%, transparent);
|
||||
}
|
||||
|
||||
/* No `.active` rule here on purpose: `.session-tab.active` (and its per-skin
|
||||
twin) already paints background, border and box-shadow with !important, so a
|
||||
card rule would be dead weight that reads as if it were doing something. The
|
||||
selected card keeps the accent its skin gives every selected tab, which is
|
||||
what makes it legible against the card background the state accents above
|
||||
paint on. */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Activity-sorted rail (tabRailSort) ---------------------------------- */
|
||||
/* Visual order only: the DOM stays in the user's tab order, so drag-reorder,
|
||||
Alt+N badges, the arrow-key walk and the sidebar filter all keep reading the
|
||||
list they always read. `order` is set inline per tab by the render paths
|
||||
(app.js `_tabRailSortOrder`).
|
||||
|
||||
Web tabs carry no session state to sort by and keep their place at the end of
|
||||
the list, so they are pinned past every session card rather than being given
|
||||
an inline order of their own — `renderWebviewTabs()` emits the same markup for
|
||||
every layout and must stay that way. */
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-sort='activity'] .tab-rail .session-tab[data-webview-id] {
|
||||
order: 9999;
|
||||
}
|
||||
|
||||
/* --- Collapsed rail ---------------------------------------------------- */
|
||||
/* Collapsed is a 44px icon rail, not "hidden": the ambient signal (status dot,
|
||||
task/subagent/ultracode badges) is the whole point of mission control and
|
||||
|
||||
+13
-6
@@ -20,6 +20,13 @@
|
||||
|
||||
const CACHE_NAME = 'codeman-v1';
|
||||
|
||||
// Reverse-proxy base path: the worker is served at `<base>/sw.js`, so its own
|
||||
// location tells us the mount prefix ('' at root, or '/codeman'). Every URL below
|
||||
// is prefixed through B() so the cached shell, icons and API calls resolve under
|
||||
// the mount instead of escaping to the origin root.
|
||||
const SW_BASE = self.location.pathname.replace(/\/sw\.js$/, '');
|
||||
const B = (p) => (p && p[0] === '/' ? SW_BASE + p : p);
|
||||
|
||||
// Core app shell -- cached on install for instant startup
|
||||
const APP_SHELL = [
|
||||
'/',
|
||||
@@ -45,7 +52,7 @@ const APP_SHELL = [
|
||||
'/icon-192.png',
|
||||
'/icon-512.png',
|
||||
'/manifest.json',
|
||||
];
|
||||
].map(B);
|
||||
|
||||
// --- Install: precache app shell ---
|
||||
|
||||
@@ -116,9 +123,9 @@ self.addEventListener('push', (event) => {
|
||||
const options = {
|
||||
body: body || '',
|
||||
tag: tag || 'codeman-default',
|
||||
icon: '/icon-192.png',
|
||||
badge: '/icon-192.png',
|
||||
data: { sessionId, approvalId, url: sessionId ? `/?session=${sessionId}` : '/' },
|
||||
icon: B('/icon-192.png'),
|
||||
badge: B('/icon-192.png'),
|
||||
data: { sessionId, approvalId, url: sessionId ? B(`/?session=${sessionId}`) : B('/') },
|
||||
renotify: true,
|
||||
requireInteraction: urgency === 'critical',
|
||||
};
|
||||
@@ -143,7 +150,7 @@ self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const { sessionId, approvalId, url } = event.notification.data || {};
|
||||
const targetUrl = url || '/';
|
||||
const targetUrl = url || B('/');
|
||||
const action = event.action || null;
|
||||
|
||||
// Approve/Deny action buttons answer the Approvals Inbox item directly from
|
||||
@@ -152,7 +159,7 @@ self.addEventListener('notificationclick', (event) => {
|
||||
// because a service worker fetch carries the worker's own (same) origin.
|
||||
if ((action === 'approve' || action === 'deny') && approvalId) {
|
||||
event.waitUntil(
|
||||
fetch(`/api/approvals/${encodeURIComponent(approvalId)}/answer`, {
|
||||
fetch(B(`/api/approvals/${encodeURIComponent(approvalId)}/answer`), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* @fileoverview Orphaned-input forwarder for xterm's helper textarea.
|
||||
*
|
||||
* xterm's `CoreBrowserTerminal._inputEvent` only forwards an `insertText`
|
||||
* input event while `(!ev.composed || !this._keyDownSeen)` holds. A soft
|
||||
* keyboard that delivers a `composed: true` input event after a keydown fails
|
||||
* that guard, so xterm returns without emitting and the committed character is
|
||||
* silently dropped.
|
||||
*
|
||||
* ⚠ The gap is NARROWER than "keyCode 229", and assuming otherwise produces a
|
||||
* controller that looks useful while doing nothing. For a keydown that really
|
||||
* does report `keyCode: 229`, xterm ALREADY self-rescues: `CompositionHelper
|
||||
* .keydown()` calls `_handleAnyTextareaChanges()`, which snapshots
|
||||
* `textarea.value` and diffs it on a 0 ms timer, emitting the difference
|
||||
* itself. Measured in headless chromium against a real terminal: for a 229
|
||||
* keydown xterm emits and this controller correctly stands down. What is left
|
||||
* unrescued is a refused `insertText` where NO 229 diff was scheduled — that is
|
||||
* the case this module exists for, and the case its browser test asserts by
|
||||
* checking WHO delivered the byte rather than merely that one arrived.
|
||||
*
|
||||
* The recovery never guesses the character: the `input` event already carries
|
||||
* the real committed text in `ev.data`, which is exactly what xterm itself
|
||||
* would have forwarded. We only decide WHETHER to forward it, by asking
|
||||
* whether xterm produced any canonical data since the keydown that started the
|
||||
* keystroke. That snapshot must be taken at KEYDOWN, not at the input event:
|
||||
* xterm's `_keyPress` emits and sets `_keyPressHandled` before `input` fires,
|
||||
* so a snapshot read at input time would already contain that emission and the
|
||||
* character would be delivered twice.
|
||||
*
|
||||
* Listener registration is load-bearing, in BOTH phase and order. xterm
|
||||
* registers its own `input` listener in `terminal.open()` with `capture:
|
||||
* true`, and ours is added afterwards, so at-target it runs second. It must
|
||||
* also be a CAPTURE listener; see the measured table at the addEventListener
|
||||
* call below.
|
||||
*
|
||||
* @dependency none (standalone IIFE; consumed by terminal-ui.js)
|
||||
* @loadorder 5.55 (before app.js/terminal-ui.js, which create the controller)
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
function create(options) {
|
||||
const textarea = options?.textarea;
|
||||
const emitRecovered = options?.emitRecovered;
|
||||
if (!textarea?.addEventListener || !textarea?.removeEventListener || typeof emitRecovered !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isScreenReaderMode = options.isScreenReaderMode;
|
||||
const setTimer = options.setTimer || global.setTimeout.bind(global);
|
||||
const clearTimer = options.clearTimer || global.clearTimeout.bind(global);
|
||||
|
||||
let destroyed = false;
|
||||
// Number of canonical data events xterm has emitted, bumped by the caller's
|
||||
// onData hook. Only its ORDER relative to a keydown matters.
|
||||
let canonicalCount = 0;
|
||||
// ⚠️ 0, never null. With `null` the `?? canonicalCount` fallback at the input
|
||||
// event reads a count xterm has ALREADY bumped: on a fresh page load with no
|
||||
// keydown yet (dictation, Android voice typing, any `insertText` with no key
|
||||
// held) xterm's own capture listener runs first, forwards the text itself and
|
||||
// bumps the counter, then this snapshot equals it, `count > snapshot` is false,
|
||||
// and the text is emitted a SECOND time. A baseline of 0 makes that comparison
|
||||
// true and stands the recovery down, which restores this file's invariant: a
|
||||
// missed recovery is acceptable, a duplicated keystroke is not.
|
||||
let keydownSnapshot = 0;
|
||||
let composing = false;
|
||||
const pending = [];
|
||||
|
||||
function cancelPending() {
|
||||
for (const candidate of pending.splice(0)) {
|
||||
candidate.active = false;
|
||||
if (candidate.timer !== null) {
|
||||
try {
|
||||
clearTimer(candidate.timer);
|
||||
} catch {
|
||||
// A broken timer host must not break input handling.
|
||||
}
|
||||
candidate.timer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCandidate(candidate) {
|
||||
const index = pending.indexOf(candidate);
|
||||
if (index !== -1) pending.splice(index, 1);
|
||||
candidate.timer = null;
|
||||
if (!candidate.active || destroyed) return;
|
||||
candidate.active = false;
|
||||
// xterm (or its keypress path) spoke for this keystroke — it is already
|
||||
// on its way to the PTY, so there is nothing to recover.
|
||||
if (canonicalCount > candidate.snapshot) return;
|
||||
try {
|
||||
emitRecovered(candidate.data);
|
||||
} catch {
|
||||
// Recovery is best effort; a failed delivery must never throw into the
|
||||
// browser's input handling.
|
||||
}
|
||||
}
|
||||
|
||||
/** Called from xterm's onData hook: xterm produced canonical data. */
|
||||
function notifyCanonicalData() {
|
||||
canonicalCount += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the canonical counter at every keydown. This deliberately reads
|
||||
* NOTHING else off the event — not `key`, not `keyCode`. Gating it on
|
||||
* keyCode 229 would make the recovery inert on exactly the devices it
|
||||
* exists for, whose keydowns report `key: 'Unidentified'`. It is a single
|
||||
* assignment, so running it for every keydown costs nothing.
|
||||
*/
|
||||
function handleKeyEvent(event) {
|
||||
if (destroyed || event?.type !== 'keydown') return;
|
||||
keydownSnapshot = canonicalCount;
|
||||
}
|
||||
|
||||
function onInput(event) {
|
||||
if (destroyed || composing || event?.isComposing) return;
|
||||
if (event.inputType !== 'insertText') return;
|
||||
const data = event.data;
|
||||
if (typeof data !== 'string' || data === '') return;
|
||||
try {
|
||||
if (isScreenReaderMode?.()) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = {
|
||||
data,
|
||||
snapshot: keydownSnapshot ?? canonicalCount,
|
||||
active: true,
|
||||
timer: null,
|
||||
};
|
||||
pending.push(candidate);
|
||||
try {
|
||||
candidate.timer = setTimer(() => resolveCandidate(candidate), 0);
|
||||
} catch {
|
||||
cancelPending();
|
||||
}
|
||||
}
|
||||
|
||||
function onCompositionStart() {
|
||||
if (destroyed) return;
|
||||
composing = true;
|
||||
cancelPending();
|
||||
}
|
||||
|
||||
function onCompositionEnd() {
|
||||
if (destroyed) return;
|
||||
composing = false;
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
cancelPending();
|
||||
try {
|
||||
textarea.removeEventListener('input', onInput, true);
|
||||
textarea.removeEventListener('compositionstart', onCompositionStart, true);
|
||||
textarea.removeEventListener('compositionend', onCompositionEnd, true);
|
||||
} catch {
|
||||
// Teardown is best effort; the terminal is being replaced anyway.
|
||||
}
|
||||
}
|
||||
|
||||
// capture: true, not bubble. The target (the textarea) is visited TWICE in
|
||||
// the event path, so a capture-phase listener on it calling
|
||||
// stopPropagation() still stops later BUBBLE-phase listeners on that same
|
||||
// target. xterm's `_inputEvent` calls `this.cancel(ev)` (preventDefault +
|
||||
// stopPropagation) exactly in the branch where it HANDLED the input, so on
|
||||
// bubble we would never see handled events — and whether we saw them at
|
||||
// all would hang off xterm's `options.cancelEvents`, which Codeman does not
|
||||
// set. Measured (jsdom and headless chromium agree):
|
||||
//
|
||||
// capture-then-BUBBLE, no stop: xterm -> ours
|
||||
// capture-then-BUBBLE, stopPropagation: xterm (ours never fires)
|
||||
// capture-then-CAPTURE, no stop: xterm -> ours
|
||||
// capture-then-CAPTURE, stopPropagation: xterm -> ours (still fires)
|
||||
//
|
||||
// On capture we therefore observe EVERY input event uniformly, and the
|
||||
// canonicalCount snapshot alone decides whether to forward.
|
||||
try {
|
||||
textarea.addEventListener('input', onInput, true);
|
||||
textarea.addEventListener('compositionstart', onCompositionStart, true);
|
||||
textarea.addEventListener('compositionend', onCompositionEnd, true);
|
||||
} catch {
|
||||
destroy();
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.freeze({ handleKeyEvent, notifyCanonicalData, destroy });
|
||||
}
|
||||
|
||||
global.CodemanKeyCode229Recovery = Object.freeze({ create });
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
+190
-15
@@ -232,12 +232,22 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Terminal Setup — xterm.js config and input handling
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
_destroyKeyCode229Recovery() {
|
||||
try {
|
||||
this._keyCode229Recovery?.destroy?.();
|
||||
} catch {
|
||||
// Recovery is optional; terminal replacement must continue.
|
||||
}
|
||||
this._keyCode229Recovery = null;
|
||||
},
|
||||
|
||||
initTerminal() {
|
||||
// Load scrollback setting from localStorage, treating DEFAULT_SCROLLBACK as a floor
|
||||
// so users who picked up the previous (smaller) default get the new minimum on upgrade.
|
||||
const stored = parseInt(localStorage.getItem('codeman-scrollback'));
|
||||
const scrollback = Number.isFinite(stored) && stored > 0 ? Math.max(stored, DEFAULT_SCROLLBACK) : DEFAULT_SCROLLBACK;
|
||||
|
||||
this._destroyKeyCode229Recovery();
|
||||
this.terminal = new Terminal({
|
||||
theme: { ...window.codemanCurrentXtermTheme() },
|
||||
fontFamily: window.CodemanTerminalFont.resolve(this.loadAppSettingsFromStorage?.().terminalFontFamily),
|
||||
@@ -292,6 +302,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
// punctuation; returning false here would stop xterm before it can diff
|
||||
// the helper textarea and emit the committed Unicode text.
|
||||
this.terminal.attachCustomKeyEventHandler((ev) => {
|
||||
try {
|
||||
// Deliberately runs for EVERY keydown, not just keyCode 229: the
|
||||
// controller snapshots a counter and reads nothing off the event, and
|
||||
// the devices this exists for report `key: 'Unidentified'` with no
|
||||
// reliable identity to gate on. Gating it would make recovery inert
|
||||
// exactly where it is needed. Cost is one assignment.
|
||||
this._keyCode229Recovery?.handleKeyEvent?.(ev);
|
||||
} catch {
|
||||
// The fallback must never interfere with xterm's canonical handler.
|
||||
}
|
||||
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
|
||||
|
||||
// Let the app's Alt/Option session-nav and Command Palette shortcuts reach the document keydown handler
|
||||
@@ -514,6 +534,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
} else {
|
||||
this.fitAddon.fit();
|
||||
}
|
||||
// Whenever that first fit runs — on this line, or a frame or two later on
|
||||
// the mobile-Safari branch above — it measures whatever font the browser has
|
||||
// painted with so far, which is not necessarily the terminal font. Start the
|
||||
// wait now so the buffer load can hold for it.
|
||||
this._terminalFontReady = this._awaitTerminalFont();
|
||||
|
||||
// Register link provider for clickable file paths in Bash tool output
|
||||
this.registerFilePathLinkProvider();
|
||||
@@ -935,7 +960,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// causes Ink to re-render at the new row count, garbling terminal output.
|
||||
// Local fit() still runs so xterm knows the viewport size for scrolling.
|
||||
const keyboardUp = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible;
|
||||
if (this.activeSessionId && !keyboardUp) {
|
||||
// Same yield as sendResize: never resize a PTY whose session is showing
|
||||
// in its own window. Dragging the dashboard's border must not reshape it.
|
||||
const detachedElsewhere = !this.isSoloWindow && this.detachedSessions?.has(this.activeSessionId);
|
||||
if (this.activeSessionId && !keyboardUp && !detachedElsewhere) {
|
||||
const dims = this.fitAddon.proposeDimensions();
|
||||
// Enforce minimum dimensions to prevent layout issues
|
||||
const cols = dims ? Math.max(dims.cols, MIN_COLS) : MIN_COLS;
|
||||
@@ -1026,7 +1054,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// mobile connections. The overlay + localStorage persistence ensure input
|
||||
// survives tab switches and reconnects.
|
||||
|
||||
this.terminal.onData((data) => {
|
||||
const handleTerminalData = (data) => {
|
||||
// Mouse SGR reports (tap-to-position) are NOT IME input — they must reach
|
||||
// the PTY even while the CJK input field owns focus. Without this exception
|
||||
// tapping to move the cursor silently does nothing whenever Chinese input
|
||||
@@ -1348,6 +1376,49 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Chrome on Android delivers a `composed: true` input event preceded by a
|
||||
// keydown, which is exactly the shape xterm's _inputEvent refuses to
|
||||
// forward, so the committed character is silently dropped. The controller
|
||||
// forwards the input event's own `data` when xterm produced nothing for
|
||||
// that keystroke. Created AFTER terminal.open() on purpose: for an event
|
||||
// targeting the textarea, at-target listeners run in registration order,
|
||||
// so xterm's listener (added in open()) still runs first. The controller
|
||||
// registers its own listener with `capture: true`; on bubble xterm's
|
||||
// `cancel()` (stopPropagation) would swallow exactly the handled events —
|
||||
// see the measured table in terminal-keycode229-recovery.js.
|
||||
try {
|
||||
this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({
|
||||
textarea: this.terminal.textarea,
|
||||
emitRecovered: (data) => handleTerminalData(data),
|
||||
isScreenReaderMode: () => this.terminal?.options?.screenReaderMode === true,
|
||||
});
|
||||
} catch {
|
||||
this._keyCode229Recovery = null;
|
||||
}
|
||||
this.terminal.onData((data) => {
|
||||
// Canonical xterm data. Telling the controller is what lets it know a
|
||||
// keystroke was already delivered and needs no recovery.
|
||||
//
|
||||
// ⚠️ onData ALSO fires for output xterm produces on its own initiative:
|
||||
// the DA/DSR/CPR/OSC replies it answers during Ink redraws, and the SGR
|
||||
// mouse and focus reports (see the two predicates above, used for exactly
|
||||
// this question at the send sites). Any one of those landing between the
|
||||
// keydown and the candidate's zero-delay resolution would be read as
|
||||
// "xterm spoke for this keystroke", standing the recovery down and
|
||||
// leaving the character dropped, worst on a busy agent pane, which is
|
||||
// the case this exists for. Narrowing the counter cannot cause a
|
||||
// duplicate: it only ever makes the controller less sure it can stand down.
|
||||
try {
|
||||
const input = window.CodemanTerminalInput;
|
||||
if (!input?.shouldSuppressTerminalQueryResponse(data) && !input?.isTerminalFocusOrMouseReport(data)) {
|
||||
this._keyCode229Recovery?.notifyCanonicalData?.();
|
||||
}
|
||||
} catch {
|
||||
// Bookkeeping must never block real input.
|
||||
}
|
||||
handleTerminalData(data);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1440,6 +1511,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
range: { start, end },
|
||||
decorations: { pointerCursor: true, underline: true },
|
||||
activate(_event, text) {
|
||||
// A `localhost` link tapped from another device can only work
|
||||
// through the server: route it into a proxied web tab
|
||||
// (webview-tabs.js). Anything else opens as before.
|
||||
if (self.openLinkThroughWebTabIfLoopback?.(text)) return;
|
||||
window.open(text, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
hover() {
|
||||
@@ -2193,7 +2268,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (isLive && this.sessions.has(s.sessionId)) {
|
||||
this.selectSession(s.sessionId);
|
||||
} else {
|
||||
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode);
|
||||
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode, s.resumeId);
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -2436,7 +2511,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
} else {
|
||||
// Resume by the Claude conversation UUID when present (resumed sessions
|
||||
// carry theirs separately from their Codeman id).
|
||||
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode);
|
||||
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode, s.resumeId);
|
||||
}
|
||||
this.closeSessionManager?.();
|
||||
closeMenu();
|
||||
@@ -2904,7 +2979,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
return `w${startNumber}-${dirName}`;
|
||||
},
|
||||
|
||||
async resumeHistorySession(sessionId, workingDir, existingName, mode) {
|
||||
async resumeHistorySession(sessionId, workingDir, existingName, mode, resumeId) {
|
||||
// Close the run mode menu if open
|
||||
document.getElementById('runModeMenu')?.classList.remove('active');
|
||||
// Close folder history modal if open
|
||||
@@ -2942,19 +3017,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
grok: 'grokConfig',
|
||||
omp: 'ompConfig',
|
||||
}[effectiveMode];
|
||||
// codex/gemini/antigravity have no wired continuation here yet (their
|
||||
// configs use an exact conversation id, not a "continue most recent"
|
||||
// flag, and the row's own `sessionId` is not verified to carry that
|
||||
// id for these three modes) — `continuesSomething` below is what keeps
|
||||
// their row from being retired for a resume that didn't actually
|
||||
// continue anything.
|
||||
// codex names a thread by an id of its own, not by Codeman's session id,
|
||||
// so it continues only when the row carried that id: `resumeId` is set by
|
||||
// the rollout scanner (codex-transcript.ts) and by nothing else, which is
|
||||
// what stops a LIVE codex row — whose sessionId is Codeman's uuid — from
|
||||
// asking codex for a thread that does not exist.
|
||||
//
|
||||
// gemini/antigravity still have no wired continuation here (same reason
|
||||
// codex used to have none: an exact conversation id nothing supplies) —
|
||||
// `continuesSomething` below is what keeps their row from being retired
|
||||
// for a resume that didn't actually continue anything.
|
||||
const codexResumeId = effectiveMode === 'codex' ? resumeId : undefined;
|
||||
const modeConfig =
|
||||
modeConfigKey
|
||||
? { [modeConfigKey]: { continueSession: true } }
|
||||
: effectiveMode === 'deepseek'
|
||||
? { deepSeekConfig: { resumeSession: true } }
|
||||
: {};
|
||||
const continuesSomething = Boolean(modeConfigKey) || effectiveMode === 'deepseek';
|
||||
: codexResumeId
|
||||
? { codexConfig: { resumeSessionId: codexResumeId } }
|
||||
: {};
|
||||
const continuesSomething =
|
||||
Boolean(modeConfigKey) || effectiveMode === 'deepseek' || Boolean(codexResumeId);
|
||||
const createRes = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -2982,11 +3065,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
// as a duplicate — click it 3 times, see the same name 3 times. Claude
|
||||
// rows are left alone: `sessionId` there is a claudeSessionId, which
|
||||
// usually has no live/persisted Codeman session of its own to delete.
|
||||
// Gated on `continuesSomething`: for codex/gemini/antigravity (no
|
||||
// continuation wired above), this is really a FRESH session with no
|
||||
// Gated on `continuesSomething`: for gemini/antigravity, and for a codex
|
||||
// row carrying no `resumeId`, this is really a FRESH session with no
|
||||
// relation to the old row's conversation, so retiring it would discard
|
||||
// the old conversation with no recovery — worse than the duplicate row
|
||||
// this guard exists to prevent for the modes that DO continue.
|
||||
//
|
||||
// A codex row that DOES continue passes this gate, but the DELETE is a
|
||||
// no-op for it: `sessionId` there is codex's thread id and no Codeman
|
||||
// session carries that id. Its duplicate is cleared from the other side
|
||||
// instead, by the alias fold in gatherUnifiedInputs()/Session.
|
||||
if (effectiveMode !== 'claude' && continuesSomething && sessionId !== newSessionId) {
|
||||
fetch(`/api/sessions/${sessionId}?killMux=true`, { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
@@ -3830,6 +3918,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
// The pane belongs to the popup showing it, so this window has nothing to
|
||||
// restore. Say so rather than reporting a size that was never sent — the
|
||||
// same button in that window does the job.
|
||||
if (!this.isSoloWindow && this.detachedSessions?.has(this.activeSessionId)) {
|
||||
this.showToast('This session is sized by its own window', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const dims = this.getTerminalDimensions();
|
||||
if (!dims) {
|
||||
this.showToast('Could not determine terminal size', 'error');
|
||||
@@ -4772,6 +4868,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
const resolved = window.CodemanTerminalFont.resolve(custom);
|
||||
if (!this.terminal || this.terminal.options.fontFamily === resolved) return;
|
||||
this.terminal.options.fontFamily = resolved;
|
||||
// Changing the family at runtime is the same race as the boot-time one: the
|
||||
// option write makes xterm re-measure immediately, against a family the
|
||||
// browser may not have loaded. Re-arm the wait for the new stack and fit
|
||||
// again once it settles, so the setting takes effect at the right size
|
||||
// without needing a tab switch. The fit below still runs, so the terminal
|
||||
// is never left unfitted if the wait is slow.
|
||||
this._terminalFontReady = this._awaitTerminalFont().then(() => {
|
||||
if (this.terminal?.options?.fontFamily === resolved) this.fitAddon?.fit();
|
||||
});
|
||||
this.fitAddon?.fit();
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
@@ -4788,6 +4893,65 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Wait for the terminal's own font, then make xterm re-measure against it.
|
||||
*
|
||||
* A character cell measured against a fallback font has a different width and
|
||||
* height from one measured against the terminal font, so a fit taken too early
|
||||
* produces the wrong column and row count. The correction then arrives after
|
||||
* the buffer has been replayed, and the CLI redraws a frame that no longer
|
||||
* matches what the terminal is showing.
|
||||
*
|
||||
* ⚠️ Waiting is not sufficient on its own, which is what the re-measure at the
|
||||
* end is for. `FitAddon.proposeDimensions()` divides the container by a CACHED
|
||||
* cell size, and xterm refreshes that cache only from `open()`, from a resize
|
||||
* that actually changed the grid, and on a device-pixel-ratio change — nothing
|
||||
* in it listens for font loading. So a fit that runs after the font arrives can
|
||||
* still divide by the fallback cell, propose the grid it already has, and
|
||||
* short-circuit before anything re-measures.
|
||||
*
|
||||
* `document.fonts.load` for each family is what actually REQUESTS the faces:
|
||||
* the WebGL renderer rasterises glyphs through a canvas texture atlas, and
|
||||
* canvas text never triggers a CSS font fetch, so `document.fonts.ready` can
|
||||
* resolve with a face never having been asked for at all.
|
||||
*
|
||||
* Every step is best-effort and the whole thing is bounded, because a font
|
||||
* request that never settles must not hold up the terminal: `FontFaceSet.ready`
|
||||
* has no deadline of its own, and the caller awaits this in front of the buffer
|
||||
* replay. Past the deadline we fit against whatever is painted, which is the
|
||||
* old behaviour rather than a new failure.
|
||||
*/
|
||||
async _awaitTerminalFont() {
|
||||
try {
|
||||
if (typeof document === 'undefined' || !document.fonts?.load) return;
|
||||
const size = this.terminal?.options?.fontSize || 14;
|
||||
const families = String(this.terminal?.options?.fontFamily || '')
|
||||
.split(',')
|
||||
.map((family) => family.trim().replace(/^["']|["']$/g, ''))
|
||||
.filter(Boolean)
|
||||
// Only the faces that can supply the measured glyph are worth waiting on.
|
||||
// The bundled symbols font is ~1.2MB and carries private-use-area glyphs
|
||||
// only — xterm measures `W`, which it does not contain — so awaiting it
|
||||
// puts a megabyte between the user and their first frame for nothing.
|
||||
// Generic families match no FontFace at all.
|
||||
.filter((family) => !TERMINAL_FONT_UNMEASURED.has(family.toLowerCase()));
|
||||
const loaded = Promise.all(
|
||||
families.map((family) => document.fonts.load(`${size}px "${family}"`).catch(() => {}))
|
||||
).then(() => document.fonts.ready);
|
||||
await Promise.race([loaded, new Promise((resolve) => setTimeout(resolve, TERMINAL_FONT_WAIT_MS))]);
|
||||
} catch {
|
||||
/* font loading is unavailable or failed — fit against whatever is painted */
|
||||
}
|
||||
// Force the cache refresh xterm will not do for us. Without this the wait
|
||||
// buys nothing on the common path (see the warning above). Private API, as
|
||||
// FitAddon itself is; guarded because a terminal can be disposed mid-wait.
|
||||
try {
|
||||
this.terminal?._core?._charSizeService?.measure();
|
||||
} catch {
|
||||
/* renderer not ready or internals moved — the next real resize re-measures */
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get terminal dimensions with minimum enforcement.
|
||||
* Prevents extremely narrow terminals that cause vertical text wrapping.
|
||||
@@ -4814,6 +4978,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Fit terminal to container before reading dimensions — ensures local
|
||||
// terminal size matches what we report to the server PTY.
|
||||
if (this.fitAddon) this.fitAddon.fit();
|
||||
// One PTY cannot hold two sizes. A detached session is owned by its own
|
||||
// window, and the dashboard's terminal is narrower than that window because
|
||||
// the session rail takes width the popup does not have — so both sizing it
|
||||
// makes the CLI draw frames that fit neither, which garbles the popup. The
|
||||
// dashboard yields; the solo window sizes what it alone displays.
|
||||
// (_maybeRefetchFullHistory already stands aside for the same reason.)
|
||||
// ⚠️ AFTER the fit, never before: the local reflow keeps the dashboard's own
|
||||
// xterm right, and only the SERVER write is the dashboard's to withhold —
|
||||
// the mobile-keyboard guard below draws exactly this line. tab-rail-resize
|
||||
// performs its one settle-time refit through this call and has no fallback.
|
||||
if (!this.isSoloWindow && this.detachedSessions?.has(sessionId)) return false;
|
||||
const dims = this.getTerminalDimensions();
|
||||
if (!dims) return false;
|
||||
// Did the dimensions actually change since the last resize we sent? Callers
|
||||
|
||||
@@ -322,7 +322,7 @@ const ClaudeVoiceProvider = {
|
||||
if (opts.keyterms?.length) params.set('keyterms', opts.keyterms.join(','));
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
try {
|
||||
this._ws = new WebSocket(`${proto}//${location.host}/ws/voice/stream?${params}`);
|
||||
this._ws = new WebSocket(`${proto}//${location.host}${window.CodemanBase?.base || ''}/ws/voice/stream?${params}`);
|
||||
} catch (err) {
|
||||
this._onError?.('Failed to open voice stream: ' + err.message);
|
||||
this._cleanup();
|
||||
|
||||
+202
-10
@@ -19,7 +19,170 @@
|
||||
* @loadorder 12.5 of 16, after session-ui.js (needs the tab strip), before api-client.js
|
||||
*/
|
||||
|
||||
// ── Loopback links ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// An agent prints `http://localhost:5173/` and the user taps it on a phone.
|
||||
// That link can only ever resolve on the Codeman box itself, so opening it in
|
||||
// the browser is a guaranteed connection error from anywhere else — while the
|
||||
// proxied web tab fetches from the server, where it works. Only loopback is
|
||||
// routed this way: a LAN or tailnet address may well be reachable from the
|
||||
// device (a VPN, the same Wi-Fi), and a direct open is the cheaper, richer path.
|
||||
|
||||
const LOOPBACK_HOSTNAMES = new Set(['localhost', '0.0.0.0', '::1', '[::1]', '::', '[::]']);
|
||||
|
||||
function normalizeHostname(hostname) {
|
||||
return String(hostname || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\.$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* `localhost`, 127.0.0.0/8, 0.0.0.0 and the IPv6 loopback forms: names that can
|
||||
* only ever mean this box.
|
||||
*
|
||||
* ⚠️ `*.localhost` is deliberately NOT here. The link source is agent-written
|
||||
* terminal output and response-viewer markdown, i.e. prompt-injectable, and
|
||||
* this set is the whole confinement on a tap that makes Codeman fetch a URL
|
||||
* server-side and persist it. Every other member is an address literal; a
|
||||
* `*.localhost` DNS name is not one: on a resolver that does not synthesise it
|
||||
* locally and has a search domain configured, `evil.localhost` NXDOMAINs as
|
||||
* absolute and is retried as `evil.localhost.<search domain>`, which an
|
||||
* attacker can control. A user who really runs `api.localhost` dev hosts can
|
||||
* still save that dashboard by hand, which is an explicit action.
|
||||
*/
|
||||
function isLoopbackHostname(hostname) {
|
||||
const host = normalizeHostname(hostname);
|
||||
if (!host) return false;
|
||||
if (LOOPBACK_HOSTNAMES.has(host)) return true;
|
||||
const ipv4 = /^(\d{1,3})\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.exec(host);
|
||||
return !!ipv4 && Number(ipv4[1]) === 127;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the PAGE is being viewed on the box itself. Broader than the
|
||||
* auto-route set on purpose, and safe in the opposite direction: a false
|
||||
* positive here only ever DECLINES to proxy, leaving the caller's direct open.
|
||||
*/
|
||||
function isOnBoxHostname(hostname) {
|
||||
const host = normalizeHostname(hostname);
|
||||
return isLoopbackHostname(host) || host.endsWith('.localhost');
|
||||
}
|
||||
|
||||
/**
|
||||
* One key per dev server, so `localhost:5173` and `127.0.0.1:5173` reuse a
|
||||
* single saved dashboard and a single tab instead of one per host spelling.
|
||||
*/
|
||||
function webTabOriginKey(url) {
|
||||
return isLoopbackHostname(url.hostname) ? `${url.protocol}//loopback:${url.port}` : url.origin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a link should open through a proxied web tab rather than directly:
|
||||
* an http(s) URL on a loopback host, viewed from a page that is NOT itself on
|
||||
* that host (on the box, the browser can reach localhost and the direct open
|
||||
* keeps devtools, extensions and the real origin).
|
||||
*/
|
||||
function linkNeedsWebTabProxy(rawUrl, pageHostname) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(String(rawUrl || ''));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
||||
if (!isLoopbackHostname(url.hostname)) return false;
|
||||
return !isOnBoxHostname(pageHostname);
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CodemanWebviewLinks = { isLoopbackHostname, isOnBoxHostname, webTabOriginKey, linkNeedsWebTabProxy };
|
||||
}
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
// ── Loopback links ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Take a link the device cannot reach and open it through a proxied web tab.
|
||||
* Returns true when it took the link; false leaves the caller's own opening
|
||||
* path (window.open, an anchor's default) untouched.
|
||||
*/
|
||||
openLinkThroughWebTabIfLoopback(rawUrl) {
|
||||
if (!linkNeedsWebTabProxy(rawUrl, window.location?.hostname)) return false;
|
||||
void this.openUrlInWebTab(rawUrl);
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Open an arbitrary URL as a proxied web tab, deep path included. A saved
|
||||
* proxied dashboard on the same origin is reused (one tab per dev server,
|
||||
* not one per link); otherwise one is saved under the host:port name so it
|
||||
* is there in the Run dropdown next time.
|
||||
*/
|
||||
async openUrlInWebTab(rawUrl) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(String(rawUrl || ''));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// ⚠️ "is `this.webviews` set" does NOT answer "is it loaded": initWebviews()
|
||||
// assigns a truthy EMPTY map synchronously and only then awaits the list, so
|
||||
// a tap during page load used to find nothing to reuse and POST a duplicate
|
||||
// record for an origin that already exists server-side. Join an in-flight
|
||||
// load; start one only when none has ever run.
|
||||
if (this._webviewsRefresh) await this._webviewsRefresh;
|
||||
else if (!this._webviewsLoaded) await this.refreshWebviews();
|
||||
if (!this.webviews) {
|
||||
this.showToast?.('Could not open URL', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const wantedKey = webTabOriginKey(url);
|
||||
let existing = null;
|
||||
for (const webview of this.webviews.values()) {
|
||||
// ⚠️ `trusted` is excluded alongside `managed` and direct-mode records: a
|
||||
// trusted frame runs with `allow-same-origin`, i.e. on Codeman's origin with
|
||||
// the user's cookie, and the link being followed came from agent output. An
|
||||
// agent that can write into the dev server's tree (it IS the workspace) could
|
||||
// otherwise print a path that one tap navigates that privileged frame to.
|
||||
// Opening such a dashboard from the Run dropdown is still an explicit action.
|
||||
if (webview.managed || webview.trusted || (webview.embedMode ?? 'proxy') !== 'proxy') continue;
|
||||
try {
|
||||
if (webTabOriginKey(new URL(webview.url)) === wantedKey) {
|
||||
existing = webview;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
/* a saved URL that no longer parses is not a match */
|
||||
}
|
||||
}
|
||||
|
||||
let id = existing?.id;
|
||||
if (!id) {
|
||||
const created = await this._apiJson('/api/webviews', {
|
||||
method: 'POST',
|
||||
body: { name: url.host.slice(0, 60), url: `${url.origin}/`, embedMode: 'proxy', trusted: false },
|
||||
});
|
||||
if (!created?.id) {
|
||||
this.showToast?.('Could not open URL', 'error');
|
||||
return;
|
||||
}
|
||||
await this.refreshWebviews();
|
||||
id = created.id;
|
||||
// The create is a persisted record: it writes webviews.json, broadcasts
|
||||
// over SSE, adds a Run-dropdown row on every device this owner is signed
|
||||
// in on and counts toward MAX_WEBVIEWS. Adding one by hand goes through a
|
||||
// modal; a tap should not do all that with a new tab as its only signal.
|
||||
this.showToast?.(`Saved ${url.host} as a web tab`, 'success');
|
||||
}
|
||||
// `/` is passed through rather than flattened to '': openWebview reads an
|
||||
// empty path as "no deep link" and leaves an already-open frame on whatever
|
||||
// page it was showing, so a link to the origin root did nothing visible.
|
||||
const path = `${url.pathname}${url.search}${url.hash}`;
|
||||
await this.openWebview(id, { path: path || '/' });
|
||||
},
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Load the saved list and restore which tabs were open. */
|
||||
@@ -45,11 +208,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
async refreshWebviews() {
|
||||
const data = await this._apiJson('/api/webviews');
|
||||
if (!data) return;
|
||||
this.webviews = new Map((data.webviews || []).map((w) => [w.id, w]));
|
||||
if (typeof data.maxLiveFrames === 'number') this._webviewMaxFrames = data.maxLiveFrames;
|
||||
this.renderWebviewMenuItems();
|
||||
const inFlight = this._apiJson('/api/webviews').then((data) => {
|
||||
if (!data) return;
|
||||
this.webviews = new Map((data.webviews || []).map((w) => [w.id, w]));
|
||||
if (typeof data.maxLiveFrames === 'number') this._webviewMaxFrames = data.maxLiveFrames;
|
||||
this._webviewsLoaded = true;
|
||||
this.renderWebviewMenuItems();
|
||||
});
|
||||
this._webviewsRefresh = inFlight;
|
||||
try {
|
||||
await inFlight;
|
||||
} finally {
|
||||
if (this._webviewsRefresh === inFlight) this._webviewsRefresh = null;
|
||||
}
|
||||
},
|
||||
|
||||
/** SSE: the saved list changed (possibly on another device). */
|
||||
@@ -133,7 +304,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
* memory-only and expire, so a tab reopened after a server restart must not reuse
|
||||
* the dead URL from the previous run.
|
||||
*/
|
||||
async openWebview(id) {
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {{path?: string}} [options] `path` (pathname+search+hash) opens a
|
||||
* deep link inside the dashboard: appended to the proxy prefix, or resolved
|
||||
* against the real URL in direct mode. A mounted frame is navigated there
|
||||
* rather than left on whatever page it was showing.
|
||||
*/
|
||||
async openWebview(id, options = {}) {
|
||||
const webview = this.webviews.get(id);
|
||||
if (!webview) return;
|
||||
|
||||
@@ -149,8 +327,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
if (data.webview) this.webviews.set(id, data.webview);
|
||||
|
||||
const src = data.embedUrl || data.webview?.url || webview.url;
|
||||
this._mountWebviewFrame(id, src, data.webview || webview);
|
||||
let src = data.embedUrl || data.webview?.url || webview.url;
|
||||
const path = typeof options.path === 'string' ? options.path : '';
|
||||
if (path) {
|
||||
// The proxy prefix is `/webview/<cap>/`; a wildcard rides after it. In
|
||||
// direct mode the deep link resolves against the dashboard's own origin.
|
||||
src = data.embedUrl ? `${data.embedUrl.replace(/\/?$/, '/')}${path.replace(/^\//, '')}` : new URL(path, src).href;
|
||||
}
|
||||
this._mountWebviewFrame(id, src, data.webview || webview, { navigate: !!path });
|
||||
this.activeWebviewId = id;
|
||||
this.hideWelcome?.();
|
||||
document.querySelector('.main')?.classList.add('webview-active');
|
||||
@@ -162,11 +346,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/** Create the frame if absent, then reveal it and hide its siblings. */
|
||||
_mountWebviewFrame(id, src, webview) {
|
||||
_mountWebviewFrame(id, src, webview, { navigate = false } = {}) {
|
||||
const layer = document.getElementById('webviewLayer');
|
||||
if (!layer) return;
|
||||
|
||||
let wrap = layer.querySelector(`.webview-frame[data-webview-id="${CSS.escape(id)}"]`);
|
||||
if (wrap && navigate) {
|
||||
// A deep link into an already-mounted dashboard: navigate the live frame
|
||||
// instead of tearing it down, so its login and state survive.
|
||||
const frame = wrap.querySelector('iframe');
|
||||
if (frame) frame.src = CodemanBase.url(src);
|
||||
}
|
||||
if (!wrap) {
|
||||
wrap = document.createElement('div');
|
||||
wrap.className = 'webview-frame';
|
||||
@@ -182,7 +372,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (webview.trusted) sandbox.push('allow-same-origin');
|
||||
frame.setAttribute('sandbox', sandbox.join(' '));
|
||||
frame.setAttribute('referrerpolicy', 'no-referrer-when-downgrade');
|
||||
frame.src = src;
|
||||
// Proxied dashboards carry a root-absolute `/webview/<cap>/` embedUrl that must
|
||||
// ride the mount prefix; external (trusted) URLs are absolute and pass through.
|
||||
frame.src = CodemanBase.url(src);
|
||||
|
||||
const failure = document.createElement('div');
|
||||
failure.className = 'webview-failure';
|
||||
|
||||
@@ -359,3 +359,30 @@ export function getLastTranscriptResponse(blocks: ResponseViewerTranscriptBlock[
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The messages of the most recent turn that has an answer: every assistant
|
||||
* message whose `turn` matches the highest turn any assistant message carries.
|
||||
*
|
||||
* This is what the viewer's brief ("Last Response") view renders for Claude.
|
||||
* The brief `text` is one row — the last assistant row — and a Claude turn is
|
||||
* a median of 3 rows (p90 11), so that row alone was usually the tail of the
|
||||
* answer ("Done.") with the substance in the rows before it. Reading the whole
|
||||
* turn gives the same cards the full view shows for it, and no more.
|
||||
*
|
||||
* Deliberately NOT "everything after the last user message": a prompt queued
|
||||
* while the agent works opens a new, still-unanswered turn, and the honest
|
||||
* brief view is then the previous, answered one — exactly the row `text`
|
||||
* already points at. Messages without a numeric `turn` (Codex, the pane
|
||||
* parser, an older reader) yield an empty list so callers fall back to `text`.
|
||||
*/
|
||||
export function selectLastAnsweredTurn<T extends { role: string; turn?: number }>(messages: T[]): T[] {
|
||||
let latest = -1;
|
||||
for (const message of messages) {
|
||||
if (message.role === 'assistant' && typeof message.turn === 'number' && message.turn > latest) {
|
||||
latest = message.turn;
|
||||
}
|
||||
}
|
||||
if (latest < 0) return [];
|
||||
return messages.filter((message) => message.role === 'assistant' && message.turn === latest);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { dataPath } from '../config/instance.js';
|
||||
import { getCasesDir } from '../config/cases-dir.js';
|
||||
import { isMultiUserMode, maxSessionsPerUser, userCasesDir } from '../config/multiuser.js';
|
||||
import { SYNTHETIC_ADMIN, findUser } from '../user-store.js';
|
||||
import { AGENT_ORIGIN_SPAWNED_BY_SESSION, normalizeAgentOrigin } from '../agent-case-marker.js';
|
||||
|
||||
// Shared path constants used across route modules. CASES_DIR (project folders)
|
||||
// stays shared across instances; SETTINGS_PATH is per-instance runtime state.
|
||||
@@ -361,6 +362,33 @@ export function resolveParentSessionId(
|
||||
return parent.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve "an agent asked for this", the signal that labels a case directory
|
||||
* Codeman is about to CREATE as an agent scratch workspace (see agent-case-marker.ts).
|
||||
*
|
||||
* Two signals, in order:
|
||||
* 1. an explicit `agentOrigin` body field, or the `X-Codeman-Agent-Origin` header the
|
||||
* packaged skill sets once on its shared curl invocation, so every spawn recipe
|
||||
* carries it without a per-recipe edit. The body wins, mirroring parentSessionId;
|
||||
* 2. failing that, an already-RESOLVED parent session id. A create request that names
|
||||
* the session that spawned it came from an agent by construction: nothing in the
|
||||
* browser UI sets lineage. This is what still labels workers spawned by a stale
|
||||
* skill copy or by hand-rolled curl that only carries the lineage header.
|
||||
*
|
||||
* ⚠️ Decoration, like parentSessionId: never an ownership or permission signal, and
|
||||
* never a reason to fail a spawn. An unrecognised origin token is dropped by
|
||||
* `normalizeAgentOrigin` rather than rejected.
|
||||
*/
|
||||
export function resolveAgentCaseOrigin(
|
||||
req: FastifyRequest,
|
||||
bodyValue: string | undefined,
|
||||
resolvedParentSessionId: string | undefined
|
||||
): string | undefined {
|
||||
const header = req.headers['x-codeman-agent-origin'];
|
||||
const raw = bodyValue ?? (Array.isArray(header) ? header[0] : header);
|
||||
return normalizeAgentOrigin(raw) ?? (resolvedParentSessionId ? AGENT_ORIGIN_SPAWNED_BY_SESSION : undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a request body against a Zod schema, or throw a structured 400 error.
|
||||
* Replaces the repeated pattern: `const r = Schema.safeParse(body); if (!r.success) return createErrorResponse(...)`.
|
||||
@@ -424,6 +452,11 @@ export function sanitizeHookData(data: Record<string, unknown> | null | undefine
|
||||
'stop_hook_active',
|
||||
'transcript_path',
|
||||
'message',
|
||||
// UserPromptSubmit identity fields. `prompt` is deliberately NOT here: the
|
||||
// prompt text would land in the SSE broadcast, and Read My Mind already
|
||||
// captures intent through transcript-watcher.
|
||||
'prompt_id',
|
||||
'source',
|
||||
];
|
||||
|
||||
for (const key of allowedKeys) {
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
UserStoreError,
|
||||
} from '../../user-store.js';
|
||||
import { getAuthUser, requireAdmin, revokeUserSessions } from '../route-helpers.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
import { appendAdminAudit } from '../admin-audit.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { AuthPort } from '../ports/auth-port.js';
|
||||
@@ -179,7 +180,10 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
|
||||
if (!gate(req, reply)) return;
|
||||
const { username } = req.params as { username: string };
|
||||
const revoked = revokeUserSessions(ctx.authSessions, username);
|
||||
audit(req, 'user.logout', username, { revoked });
|
||||
// Web-tab proxy capabilities are a second credential the cookie purge does not
|
||||
// touch; a forced logout that left them alive would not be a logout.
|
||||
const revokedWebviews = webviewCapabilities.revokeOwner(normalizeUsername(username));
|
||||
audit(req, 'user.logout', username, { revoked, revokedWebviews });
|
||||
return { success: true, data: { revoked } };
|
||||
});
|
||||
|
||||
@@ -201,6 +205,7 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
|
||||
await ctx.cleanupSession(id, true, 'admin_delete_user').catch(() => {});
|
||||
}
|
||||
revokeUserSessions(ctx.authSessions, username);
|
||||
webviewCapabilities.revokeOwner(normalizeUsername(username));
|
||||
if (deleteSpace) await deleteUserSpace(username);
|
||||
audit(req, 'user.delete', username, { deleteSpace, killedSessions: owned.length });
|
||||
ctx.broadcast(SseEvent.AdminUsersChanged, {});
|
||||
|
||||
@@ -13,7 +13,15 @@ import fs from 'node:fs/promises';
|
||||
import { join, resolve, basename } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
import type { ApiResponse, CaseInfo, DockerHost, RemoteSessionInfo, SessionDocker } from '../../types.js';
|
||||
import type {
|
||||
AgentCaseSummary,
|
||||
ApiResponse,
|
||||
CaseInfo,
|
||||
DockerHost,
|
||||
RemoteSessionInfo,
|
||||
SessionDocker,
|
||||
SessionMode,
|
||||
} from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import {
|
||||
CreateCaseSchema,
|
||||
@@ -24,6 +32,9 @@ import {
|
||||
RemoteCaseLinkSchema,
|
||||
RemoteHostSchema,
|
||||
DockerCaseLinkSchema,
|
||||
DockerCaseAdoptSchema,
|
||||
DockerAdoptPreflightSchema,
|
||||
DockerBrowseSchema,
|
||||
DockerHostSchema,
|
||||
DockerExportSchema,
|
||||
DockerImportSchema,
|
||||
@@ -39,6 +50,7 @@ import {
|
||||
} from '../../git-clone.js';
|
||||
import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { readAgentCaseMarker, type AgentCaseMarker } from '../../agent-case-marker.js';
|
||||
import { settingsWriteBlocker, writeHooksConfig } from '../../hooks-config.js';
|
||||
import {
|
||||
canAccessOwned,
|
||||
@@ -66,6 +78,10 @@ import {
|
||||
DEFAULT_AGENT_IMAGE,
|
||||
dockerContainerName,
|
||||
dockerDisplayPath,
|
||||
probeAdoptableContainer,
|
||||
listDockerContainers,
|
||||
browseInContainer,
|
||||
dockerAdoptProbeModes,
|
||||
readDockerCases,
|
||||
readDockerHosts,
|
||||
removeDockerContainer,
|
||||
@@ -73,6 +89,7 @@ import {
|
||||
writeDockerCases,
|
||||
writeDockerHosts,
|
||||
} from '../../docker-hosts.js';
|
||||
import type { AdoptedContainerProbe, DockerBrowseResult, DockerContainerInfo } from '../../docker-hosts.js';
|
||||
import { buildDockerRemoveCommand } from '../../tmux-manager.js';
|
||||
import {
|
||||
checkRemoteTmuxAvailable,
|
||||
@@ -136,6 +153,21 @@ function repoShipsClaudeSettings(casePath: string): boolean {
|
||||
return ['settings.json', 'settings.local.json'].some((file) => existsSync(join(casePath, '.claude', file)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a case's marker onto the wire shape `CaseInfo.agentCreated` carries.
|
||||
* `owner` stays server-side: the listings are already owner-scoped, and it is not
|
||||
* something the case list needs to publish.
|
||||
*/
|
||||
function agentCreatedInfo(marker: AgentCaseMarker): NonNullable<CaseInfo['agentCreated']> {
|
||||
return {
|
||||
createdAt: marker.createdAt,
|
||||
createdBy: marker.createdBy,
|
||||
...(marker.parentSessionId ? { parentSessionId: marker.parentSessionId } : {}),
|
||||
...(marker.parentSessionName ? { parentSessionName: marker.parentSessionName } : {}),
|
||||
...(marker.mode ? { mode: marker.mode } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
|
||||
async function readLinkedCases(): Promise<Record<string, string>> {
|
||||
return readJsonConfig<Record<string, string>>(LINKED_CASES_FILE, 'linked cases', {});
|
||||
@@ -214,11 +246,16 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
const entries = await fs.readdir(listBase, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (e.isDirectory() && SAFE_CASE_NAME.test(e.name)) {
|
||||
const casePath = join(listBase, e.name);
|
||||
// Only a directory Codeman scaffolded for an agent spawn carries a marker,
|
||||
// so this stays absent for every human-created, linked or cloned case.
|
||||
const marker = await readAgentCaseMarker(casePath);
|
||||
cases.push({
|
||||
name: e.name,
|
||||
path: join(listBase, e.name),
|
||||
hasClaudeMd: existsSync(join(listBase, e.name, 'CLAUDE.md')),
|
||||
path: casePath,
|
||||
hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')),
|
||||
location: 'local',
|
||||
...(marker ? { agentCreated: agentCreatedInfo(marker) } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -291,6 +328,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
image: host.image,
|
||||
path: dockerCase.hostWorkspacePath,
|
||||
network: host.network ?? 'bridge',
|
||||
...(dockerCase.availableModes ? { availableModes: dockerCase.availableModes } : {}),
|
||||
...(dockerCase.owned === false ? { owned: false } : {}),
|
||||
},
|
||||
};
|
||||
const existingIndex = cases.findIndex((item) => item.name === dockerCase.name);
|
||||
@@ -316,6 +355,61 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
return cases;
|
||||
});
|
||||
|
||||
// ========== Agent-created cases (cleanup listing) ==========
|
||||
|
||||
/**
|
||||
* The scratch workspaces agent workers left behind, newest first.
|
||||
*
|
||||
* A long orchestration creates one case directory per worker, and deleting the
|
||||
* sessions does not remove them, so without this the only way to tell an agent's
|
||||
* `alpha`/`beta` from a real project was to remember which was which. Reads the same
|
||||
* marker `GET /api/cases` exposes and adds the two facts a human needs before
|
||||
* deleting a directory: whether a live session is still working in it, and when it
|
||||
* was last touched.
|
||||
*
|
||||
* ⚠️ Read-only on purpose: removal goes through the existing `DELETE /api/cases/:name`,
|
||||
* one name at a time, so this file keeps exactly one recursive-delete path. Scoped by
|
||||
* construction — it only ever walks the caller's own case space.
|
||||
*/
|
||||
app.get('/api/cases/agent-created', async (req): Promise<ApiResponse<{ cases: AgentCaseSummary[] }>> => {
|
||||
const user = getAuthUser(req);
|
||||
const listBase = resolveCasesDir(user);
|
||||
const inUsePaths = new Set(
|
||||
Array.from(ctx.sessions.values())
|
||||
.filter((session) => canAccessOwned(user, session.owner))
|
||||
.map((session) => session.workingDir)
|
||||
);
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(listBase, { withFileTypes: true });
|
||||
} catch {
|
||||
return { success: true, data: { cases: [] } }; // case space not created yet
|
||||
}
|
||||
|
||||
const summaries: AgentCaseSummary[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || !SAFE_CASE_NAME.test(entry.name)) continue;
|
||||
const casePath = join(listBase, entry.name);
|
||||
const marker = await readAgentCaseMarker(casePath);
|
||||
if (!marker) continue;
|
||||
const modifiedAt = await fs
|
||||
.stat(casePath)
|
||||
.then((stat) => stat.mtime.toISOString())
|
||||
.catch(() => undefined);
|
||||
summaries.push({
|
||||
name: entry.name,
|
||||
path: casePath,
|
||||
...agentCreatedInfo(marker),
|
||||
inUse: inUsePaths.has(casePath),
|
||||
...(modifiedAt ? { modifiedAt } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
summaries.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
return { success: true, data: { cases: summaries } };
|
||||
});
|
||||
|
||||
app.post('/api/cases', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||
const { name, description } = parseBody(CreateCaseSchema, req.body);
|
||||
|
||||
@@ -771,6 +865,191 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* ADOPT an already-running container (`owned: false`). The mirror of the
|
||||
* remote-SSH attach path: Codeman execs into a container the user built and
|
||||
* runs, and never creates, starts, stops, restarts or removes it.
|
||||
*
|
||||
* Everything here is read-only toward the container. The preflight refuses at
|
||||
* LINK time — missing, stopped, or no tmux inside — because the alternative is
|
||||
* failing at session launch, where the only ways out would be a dead pane or
|
||||
* starting a container we do not own. There is no image gate and no
|
||||
* `ensureCaseImage`: adoption never runs `docker create`, so the container's
|
||||
* image is the user's business.
|
||||
*/
|
||||
app.post(
|
||||
'/api/cases/docker-adopt',
|
||||
async (req, reply): Promise<ApiResponse<{ case: unknown; image?: string; availableModes?: SessionMode[] }>> => {
|
||||
// ⚠️ Admin-only in multi-user mode, unlike `docker-link` right above. Linking
|
||||
// creates OUR container, whose only bind mount is a workspace `isWorkingDirAllowed`
|
||||
// has already confined. Adoption names a container someone else built, and its
|
||||
// mounts are whatever its owner gave it — a container mounting `/` hands the
|
||||
// adopter a shell over the whole host, which is exactly the workspace scoping this
|
||||
// mode exists to enforce. Same machine-level reasoning as the docker HOST routes.
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const dockerCase = {
|
||||
...parseBody(DockerCaseAdoptSchema, req.body),
|
||||
type: 'docker' as const,
|
||||
owner: ownerFor(req),
|
||||
owned: false as const,
|
||||
};
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
|
||||
const linkedCases = await readLinkedCases();
|
||||
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
if (
|
||||
dockerCases.some((item) => item.name === dockerCase.name) ||
|
||||
linkedCases[dockerCase.name] ||
|
||||
existsSync(join(resolveCasesDir(getAuthUser(req)), dockerCase.name))
|
||||
) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||
}
|
||||
// Two cases must never share one adopted container: session close kills the
|
||||
// in-container tmux by session id, but a shared adoption would let one case's
|
||||
// teardown and another's launch race over the same tmux server.
|
||||
const container = dockerCase.container;
|
||||
if (dockerCases.some((item) => (item.container ?? dockerContainerName(item.name)) === container)) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `Container "${container}" is already linked to a case`);
|
||||
}
|
||||
|
||||
if (!isWorkingDirAllowed(getAuthUser(req), dockerCase.hostWorkspacePath)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'hostWorkspacePath is outside your workspace');
|
||||
}
|
||||
// The workspace must ALREADY exist: it mirrors a path inside a container we
|
||||
// did not create, so silently mkdir-ing it would invent a host directory that
|
||||
// does not correspond to whatever is actually mounted there.
|
||||
if (!existsSync(dockerCase.hostWorkspacePath)) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'hostWorkspacePath does not exist. Adoption mirrors an existing container, so point this at the real host directory already mounted into it.'
|
||||
);
|
||||
}
|
||||
|
||||
const availability = await checkDockerAvailable(host.engine);
|
||||
if (!availability.ok) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
availability.error || 'docker daemon is not available'
|
||||
);
|
||||
}
|
||||
// The container workdir is validated INSIDE the container. It defaults to
|
||||
// hostWorkspacePath only because that is what an owned container's bind
|
||||
// mount guarantees; adoption mounts nothing, so the probe has to prove it.
|
||||
const adoptDocker = toSessionDocker(host, dockerCase);
|
||||
const probe = await probeAdoptableContainer(adoptDocker, dockerAdoptProbeModes(), adoptDocker.containerWorkdir);
|
||||
if (!probe.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, probe.error || 'container is not adoptable');
|
||||
}
|
||||
|
||||
// Persist what the container actually has: the run-mode picker gates on
|
||||
// HOST CLIs, which is the wrong question for a case whose agents run inside
|
||||
// a container the host knows nothing about.
|
||||
const adoptedCase = { ...dockerCase, availableModes: probe.availableModes };
|
||||
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, adoptedCase]);
|
||||
ctx.broadcast(SseEvent.CaseLinked, {
|
||||
name: adoptedCase.name,
|
||||
path: adoptedCase.hostWorkspacePath,
|
||||
type: 'docker',
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: { case: adoptedCase, image: probe.image, availableModes: probe.availableModes },
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Preflight an existing container WITHOUT linking anything, so the UI can tell
|
||||
* the user "not running" / "no tmux" / "codex present, claude missing" before
|
||||
* they commit to a case name. Read-only; never touches container lifecycle.
|
||||
*/
|
||||
/**
|
||||
* Containers on the host's engine, for the adoption picker. Read-only and
|
||||
* best-effort (mirror of the remote `:hostId/sessions` discovery route): an
|
||||
* unreachable daemon yields an empty list rather than an error, because the
|
||||
* container name is a free-text field the user can always type by hand.
|
||||
*/
|
||||
app.get(
|
||||
'/api/docker-hosts/:hostId/containers',
|
||||
async (req, reply): Promise<ApiResponse<{ containers: DockerContainerInfo[] }>> => {
|
||||
// Enumerating every container on the engine is machine-level information (names,
|
||||
// images, uptime), so it follows the docker-host policy rather than the case one.
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const { hostId } = req.params as { hostId: string };
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
const containers = await listDockerContainers({
|
||||
engine: host.engine ?? 'docker',
|
||||
context: host.context,
|
||||
daemonHost: host.daemonHost,
|
||||
});
|
||||
return { success: true, data: { containers } };
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Browse a directory INSIDE a container, for the adoption form's
|
||||
* container-workdir picker. The host picker cannot answer this: for an adopted
|
||||
* container nothing is mounted at a matching host path, so the field would
|
||||
* otherwise be typed blind. Read-only — one `ls` through `docker exec`.
|
||||
*/
|
||||
app.post('/api/docker-cases/browse', async (req, reply): Promise<ApiResponse<DockerBrowseResult>> => {
|
||||
// Reads a directory listing inside an ARBITRARY named container, so it is gated with
|
||||
// the adopt flow it serves rather than with the (owner-scoped) case file routes.
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const body = parseBody(DockerBrowseSchema, req.body);
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === body.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
const result = await browseInContainer(
|
||||
{
|
||||
engine: host.engine ?? 'docker',
|
||||
context: host.context,
|
||||
daemonHost: host.daemonHost,
|
||||
containerName: body.container,
|
||||
},
|
||||
body.path || '/'
|
||||
);
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
app.post('/api/docker-cases/adopt-preflight', async (req, reply): Promise<ApiResponse<AdoptedContainerProbe>> => {
|
||||
const body = parseBody(DockerAdoptPreflightSchema, req.body);
|
||||
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
// ⚠️ NOT plain `adminOnly`, unlike the two routes above: the run menu probes this for
|
||||
// every docker case to learn which CLIs the CONTAINER has, so an admin-only gate would
|
||||
// hide every agent mode from a non-admin's own docker case. A non-admin may therefore
|
||||
// probe a container ALREADY linked to a case they can access — never an arbitrary one,
|
||||
// which is the adopt-time question and stays admin-only with the rest of that flow.
|
||||
if (!isAdmin(req)) {
|
||||
const owns = dockerCases.some(
|
||||
(item) =>
|
||||
(item.container ?? dockerContainerName(item.name)) === body.container &&
|
||||
canAccessOwned(getAuthUser(req), item.owner)
|
||||
);
|
||||
if (!owns) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
|
||||
}
|
||||
}
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === body.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
const probe = await probeAdoptableContainer(
|
||||
{
|
||||
engine: host.engine ?? 'docker',
|
||||
context: host.context,
|
||||
daemonHost: host.daemonHost,
|
||||
containerName: body.container,
|
||||
},
|
||||
dockerAdoptProbeModes(),
|
||||
body.containerWorkdir
|
||||
);
|
||||
return { success: true, data: probe };
|
||||
});
|
||||
|
||||
// One-click "Run in Docker": create a NORMAL case (folder in CASES_DIR, scaffolded)
|
||||
// AND link it to a hardened container with default settings, auto-provisioning a
|
||||
// shared `default` docker host so the user never touches host/image/network fields.
|
||||
@@ -899,6 +1178,17 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
|
||||
const sessionDocker = toSessionDocker(host, dockerCase);
|
||||
// A full export `docker commit`s the container into an image. For an ADOPTED
|
||||
// container that means packaging someone else's container — with whatever
|
||||
// credentials its owner logged in with — into a bundle Codeman then hands out,
|
||||
// and it is the one export step that touches the container at all. The
|
||||
// workspace-only export is a plain host-directory tar and stays available.
|
||||
if (mode === 'full' && dockerCase.owned === false) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.FORBIDDEN,
|
||||
`Case "${name}" adopted an existing container. Codeman does not own it and will not commit it to an image — use a workspace-only export, or build the image yourself.`
|
||||
);
|
||||
}
|
||||
if (mode === 'full' && !sessionDocker.mountCredentials) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
@@ -1042,8 +1332,22 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
'/api/docker-cases/:name/recreate',
|
||||
async (req): Promise<ApiResponse<{ name: string; container: string }>> => {
|
||||
const { name } = req.params as { name: string };
|
||||
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name);
|
||||
// Ownership gate: recreate DESTROYS a container, so it must be scoped like
|
||||
// delete is (`canAccessOwned`). Without it any user could rebuild another
|
||||
// user's container by name.
|
||||
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find(
|
||||
(item) => item.name === name && canAccessOwned(getAuthUser(req), item.owner)
|
||||
);
|
||||
if (!dockerCase) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker case not found');
|
||||
// An ADOPTED container is the user's own: there is nothing to recreate it
|
||||
// from (no create-config, no image gate) and destroying it is exactly what
|
||||
// adoption promises never to do.
|
||||
if (dockerCase.owned === false) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.FORBIDDEN,
|
||||
`Case "${name}" adopted an existing container. Codeman does not own its lifecycle and will not recreate it — rebuild it yourself, or unlink the case.`
|
||||
);
|
||||
}
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
const sessionDocker = toSessionDocker(host, dockerCase);
|
||||
@@ -1154,7 +1458,13 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
);
|
||||
// Best-effort `docker rm -f` the per-case container (case-delete is the
|
||||
// explicit teardown that removes it; the bind-mounted workspace survives).
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
// An ADOPTED container is skipped entirely: unlinking the case must leave
|
||||
// the user's own container running and untouched. The seed file is skipped
|
||||
// with it — adoption never wrote one.
|
||||
const host =
|
||||
dockerCase.owned === false
|
||||
? undefined
|
||||
: (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
if (host) {
|
||||
const sessionDocker = toSessionDocker(host, dockerCase);
|
||||
try {
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
|
||||
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
|
||||
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
|
||||
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import { isMultiUserMode, userSpacePath } from '../../config/multiuser.js';
|
||||
import {
|
||||
CASES_DIR,
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
} from '../route-helpers.js';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js';
|
||||
import { downloadTooLargeMessage, exceedsDownloadLimit } from '../../config/buffer-limits.js';
|
||||
import { parseByteRange } from '../http-range.js';
|
||||
import { isSensitivePath } from '../sensitive-path.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
@@ -183,16 +184,8 @@ async function serveRawFile(
|
||||
rangeHeader?: string | string[]
|
||||
): Promise<void> {
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
const MAX_RAW_ATTACHMENT_SIZE = 50 * 1024 * 1024; // 50MB, matching file-raw / download
|
||||
if (stat.size > MAX_RAW_ATTACHMENT_SIZE) {
|
||||
reply
|
||||
.code(413)
|
||||
.send(
|
||||
createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_RAW_ATTACHMENT_SIZE / 1024 / 1024}MB limit)`
|
||||
)
|
||||
);
|
||||
if (exceedsDownloadLimit(stat.size)) {
|
||||
reply.code(413).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, downloadTooLargeMessage(stat.size)));
|
||||
return;
|
||||
}
|
||||
// Markup is download-only: served with a renderable type on our own origin it
|
||||
@@ -425,6 +418,40 @@ function getFilesystemPreviewKind(fileName: string): FilesystemPreviewKind | und
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocked trees, minus any tree that would swallow a configured picker root
|
||||
* whole.
|
||||
*
|
||||
* `/root` is a default blocked tree, and Codeman running as root (containers,
|
||||
* plenty of servers) makes `homedir()` exactly `/root` — so the picker's own
|
||||
* allowlisted Home root was blocked by the attachment guard, every other
|
||||
* candidate lives under it or does not exist, and the endpoint answered 403
|
||||
* "No filesystem browse roots are available" with no root the user could reach.
|
||||
*
|
||||
* Dropping the tree does NOT expose secrets: `isSensitivePath` independently
|
||||
* matches `.ssh/`, `.env`, `credentials*` and friends at any depth, and it is
|
||||
* what the directory probe below asks about. Trees with no configured root
|
||||
* beneath them (`/etc`) are untouched.
|
||||
*/
|
||||
function pickerBlockedTrees(blockedTrees: readonly string[], roots: readonly string[]): readonly string[] {
|
||||
if (roots.length === 0) return blockedTrees;
|
||||
return blockedTrees.filter((tree) => !roots.some((root) => isUnderTree(root, tree)));
|
||||
}
|
||||
|
||||
/** Resolve candidate roots to realpaths, dropping the ones that do not exist. */
|
||||
function resolveCandidateRootPaths(candidates: ReadonlyArray<{ path: string }>): string[] {
|
||||
const out: string[] = [];
|
||||
for (const candidate of candidates) {
|
||||
if (!isAbsolute(candidate.path)) continue;
|
||||
try {
|
||||
out.push(realpathSync(candidate.path));
|
||||
} catch {
|
||||
// Optional roots (for example /mnt/d on non-WSL hosts) are omitted.
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isBlockedPickerPath(path: string, blockedTrees: readonly string[], directory = false): boolean {
|
||||
if (isBlockedAttachmentPath(path, blockedTrees)) return true;
|
||||
// The shared sensitive-path matcher describes file locations such as
|
||||
@@ -491,13 +518,14 @@ async function resolveFilesystemPickerRoots(
|
||||
}
|
||||
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
const trees = pickerBlockedTrees(guard.blockedTrees, resolveCandidateRootPaths(candidates));
|
||||
const roots: FilesystemBrowseRoot[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const candidate of candidates) {
|
||||
if (!isAbsolute(candidate.path)) continue;
|
||||
try {
|
||||
const resolved = realpathSync(candidate.path);
|
||||
if (seen.has(resolved) || isBlockedPickerPath(resolved, guard.blockedTrees, true)) continue;
|
||||
if (seen.has(resolved) || isBlockedPickerPath(resolved, trees, true)) continue;
|
||||
const stat = await fs.stat(resolved);
|
||||
if (!stat.isDirectory()) continue;
|
||||
seen.add(resolved);
|
||||
@@ -536,8 +564,21 @@ async function resolveFilesystemPickerPath(
|
||||
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'No filesystem browse roots are available');
|
||||
}
|
||||
|
||||
// With no explicit path (the "Link Existing" case picker, which passes no
|
||||
// sessionId and an empty initialPath until the user has typed something),
|
||||
// land on the shared cases root rather than falling through to whichever
|
||||
// root happens to be first. `Codeman Cases` sits inside `Home` only on the
|
||||
// native default (~/codeman-cases); a Docker deployment binds them at
|
||||
// unrelated host paths (CODEMAN_APPDATA_PATH vs CODEMAN_CASES_PATH), so a
|
||||
// Home-first fallback opened the picker somewhere with no cases in sight —
|
||||
// and, worse, made an OLD case folder left behind by a since-changed
|
||||
// CODEMAN_CASES_PATH look like a normal thing to stumble across while
|
||||
// browsing for one to link.
|
||||
const fallbackRoot =
|
||||
roots.find((root) => root.label === 'Current Folder') ?? roots.find((root) => root.path === '/mnt/d') ?? roots[0];
|
||||
roots.find((root) => root.label === 'Current Folder') ??
|
||||
roots.find((root) => root.label === 'Codeman Cases') ??
|
||||
roots.find((root) => root.path === '/mnt/d') ??
|
||||
roots[0];
|
||||
const candidatePath = resolve(requestedPath ?? fallbackRoot.path);
|
||||
|
||||
let resolvedPath: string;
|
||||
@@ -556,7 +597,19 @@ async function resolveFilesystemPickerPath(
|
||||
}
|
||||
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
return { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees: guard.blockedTrees };
|
||||
// Navigation must use the SAME narrowed list the roots were selected with.
|
||||
// Handing the raw trees down here would admit a root and then refuse every
|
||||
// path inside it, which reads as a picker that opens and then does nothing.
|
||||
return {
|
||||
candidatePath,
|
||||
resolvedPath,
|
||||
roots,
|
||||
matchingRoot,
|
||||
blockedTrees: pickerBlockedTrees(
|
||||
guard.blockedTrees,
|
||||
roots.map((root) => root.path)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function appendDownloadFlag(url: string): string {
|
||||
@@ -1502,18 +1555,11 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
const { resolvedPath } = validated;
|
||||
|
||||
try {
|
||||
// Validate file size before reading (DoS protection - prevent memory exhaustion)
|
||||
const MAX_RAW_FILE_SIZE = 50 * 1024 * 1024; // 50MB for raw files
|
||||
// Sanity bound only: the body below is streamed and Range-aware, so size
|
||||
// does not translate into resident memory. Configurable, 0 = unlimited.
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
if (stat.size > MAX_RAW_FILE_SIZE) {
|
||||
reply
|
||||
.code(400)
|
||||
.send(
|
||||
createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_RAW_FILE_SIZE / 1024 / 1024}MB limit)`
|
||||
)
|
||||
);
|
||||
if (exceedsDownloadLimit(stat.size)) {
|
||||
reply.code(413).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, downloadTooLargeMessage(stat.size)));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1894,17 +1940,8 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
return;
|
||||
}
|
||||
|
||||
// 50MB size limit
|
||||
const MAX_DOWNLOAD_SIZE = 50 * 1024 * 1024;
|
||||
if (stat.size > MAX_DOWNLOAD_SIZE) {
|
||||
reply
|
||||
.code(400)
|
||||
.send(
|
||||
createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`File too large (${Math.round(stat.size / 1024 / 1024)}MB > 50MB limit)`
|
||||
)
|
||||
);
|
||||
if (exceedsDownloadLimit(stat.size)) {
|
||||
reply.code(413).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, downloadTooLargeMessage(stat.size)));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1928,15 +1965,13 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
};
|
||||
|
||||
const filename = pathBasename(resolvedPath);
|
||||
const content = await fs.readFile(resolvedPath);
|
||||
// Bypass Fastify compression — write directly to raw response
|
||||
reply.raw.writeHead(200, {
|
||||
...inheritedHeaders(reply),
|
||||
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Content-Length': content.length,
|
||||
});
|
||||
reply.raw.end(content);
|
||||
// Streamed rather than read into memory, and Range-aware, so a multi-GB
|
||||
// artifact costs one read stream and can be resumed. sendFileBody()
|
||||
// hijacks the reply, which also keeps Fastify's compression out of it.
|
||||
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
|
||||
reply.header('Content-Disposition', buildContentDisposition('attachment', filename));
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
sendFileBody(reply, resolvedPath, stat.size, req.headers.range);
|
||||
return;
|
||||
} catch (err) {
|
||||
reply
|
||||
|
||||
@@ -126,10 +126,34 @@ export function registerHookEventRoutes(
|
||||
// Sync Claude's current conversation id. Interactive PTY mode never emits
|
||||
// `session_id` on stdout, so hooks are the only reliable way to learn that
|
||||
// the user ran `/clear` (which spins up a new conversation jsonl).
|
||||
let conversationChanged = false;
|
||||
if (data && typeof data.session_id === 'string' && data.session_id) {
|
||||
const session = ctx.sessions.get(sessionId);
|
||||
const prevClaudeSessionId = session?.claudeSessionId;
|
||||
session?.adoptClaudeSessionId(data.session_id);
|
||||
const prevChainLength = session?.claudeSessionChain.length ?? 0;
|
||||
// FIRST-HAND: this payload came from the CLI process itself and reached us
|
||||
// because the pane's own $CODEMAN_SESSION_ID addressed it. No cwd, no
|
||||
// timestamp, nothing a sibling pane on the same folder could win — so the
|
||||
// response viewer can stop guessing entirely (see
|
||||
// resolveActiveClaudeSessionIdFromHistory).
|
||||
session?.adoptClaudeSessionId(data.session_id, { firstHand: true });
|
||||
if (event === 'prompt_submitted') {
|
||||
// Repairs `lastSubmitAt` for a pane driven straight from tmux: it was
|
||||
// bumped only by input that flowed through Codeman's own write path, so
|
||||
// it read 0 forever for those panes and every consumer of "when did this
|
||||
// pane last submit" silently degraded.
|
||||
session?.markPromptSubmitted();
|
||||
}
|
||||
// Persist when the conversation actually moved: `/clear` emits no
|
||||
// completion event, so without this the successor id is lost on restart
|
||||
// and recovery falls back to the launch conversation.
|
||||
if (
|
||||
session &&
|
||||
(session.claudeSessionId !== prevClaudeSessionId || session.claudeSessionChain.length !== prevChainLength)
|
||||
) {
|
||||
conversationChanged = true;
|
||||
ctx.persistSessionState(session);
|
||||
}
|
||||
// Docker sessions: keep the case's resume seed following the LIVE
|
||||
// conversation (post-/clear id switches), so a container stop/reboot
|
||||
// relaunch resumes the right transcript.
|
||||
@@ -207,9 +231,13 @@ export function registerHookEventRoutes(
|
||||
...(approvalId && session?.mode !== 'deepseek' && { approvalId }),
|
||||
});
|
||||
|
||||
// Track in run summary
|
||||
// Track in run summary. `prompt_submitted` fires on EVERY prompt of every
|
||||
// Claude pane; only the ones where the conversation actually moved (a /clear
|
||||
// successor) carry information, and recording the rest would push a row into
|
||||
// the Summary timeline and /api/search per turn and evict useful rows from
|
||||
// the 1000-event FIFO (#367 merge-time fix).
|
||||
const summaryTracker = ctx.runSummaryTrackers.get(sessionId);
|
||||
if (summaryTracker) {
|
||||
if (summaryTracker && (event !== 'prompt_submitted' || conversationChanged)) {
|
||||
summaryTracker.recordHookEvent(event, safeData);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '../../types.js';
|
||||
import { Session, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
import {
|
||||
CreateSessionSchema,
|
||||
SessionNameSchema,
|
||||
@@ -75,12 +76,14 @@ import {
|
||||
ownerFor,
|
||||
parseBody,
|
||||
persistAndBroadcastSession,
|
||||
resolveAgentCaseOrigin,
|
||||
resolveCasesDir,
|
||||
resolveParentSessionId,
|
||||
sessionCapacityMessage,
|
||||
SETTINGS_PATH,
|
||||
validatePathWithinBase,
|
||||
} from '../route-helpers.js';
|
||||
import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-marker.js';
|
||||
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
|
||||
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
|
||||
@@ -133,6 +136,7 @@ import {
|
||||
import {
|
||||
checkDockerAvailable,
|
||||
checkDockerConfigDrift,
|
||||
probeAdoptableContainer,
|
||||
checkDockerTmuxAvailable,
|
||||
ensureAgentBaseImage,
|
||||
DEFAULT_AGENT_IMAGE,
|
||||
@@ -144,10 +148,12 @@ import {
|
||||
import { LRUMap } from '../../utils/lru-map.js';
|
||||
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
|
||||
import { scanOmpSessionsHistory } from '../../omp-transcript.js';
|
||||
import { scanCodexSessionsHistory, codexThreadBySessionId } from '../../codex-transcript.js';
|
||||
import {
|
||||
getLastTranscriptResponse,
|
||||
isExternalCliTranscriptMode,
|
||||
parseExternalCliTranscript,
|
||||
selectLastAnsweredTurn,
|
||||
} from '../response-viewer-transcript.js';
|
||||
import { readDeepSeekLastResponse } from '../../deepseek-transcript.js';
|
||||
|
||||
@@ -821,6 +827,10 @@ export function registerSessionRoutes(
|
||||
if (sessionToken) {
|
||||
ctx.authSessions?.delete(sessionToken);
|
||||
}
|
||||
// The web-tab proxy authenticates on capabilities, not on this cookie, so a
|
||||
// logout has to retire them too or every dashboard URL opened during this
|
||||
// login keeps relaying without one (WebviewCapabilityStore.revokeOwner).
|
||||
webviewCapabilities.revokeOwner(ownerFor(req));
|
||||
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
|
||||
return {};
|
||||
});
|
||||
@@ -1834,8 +1844,17 @@ export function registerSessionRoutes(
|
||||
session: Session,
|
||||
projectsDir: string
|
||||
): Promise<string | null> {
|
||||
// A pane whose conversation id came from its OWN hook needs no correlation:
|
||||
// $CODEMAN_SESSION_ID (the pane's env) -> data.session_id (the CLI's own
|
||||
// stdin JSON) is a first-hand binding that never looks at cwd, so it cannot
|
||||
// be stolen by a sibling pane, a closed tab, or a bare `claude` in a
|
||||
// terminal. Guessing can only be worse than the fact. This is also what
|
||||
// closes the hole below for a pane driven straight from tmux: it never
|
||||
// reaches `if (!submitAt)`.
|
||||
if (session.claudeSessionIdIsFirstHand) return null;
|
||||
|
||||
const submitAt = session.lastSubmitAt;
|
||||
if (!submitAt) return null; // never typed through Codeman — nothing to credit
|
||||
if (!submitAt) return null; // no anchor at all — nothing to credit
|
||||
const cached = claudeHistoryPinCache.get(session.id);
|
||||
if (cached && cached.submitAt === submitAt) return cached.claudeSessionId;
|
||||
|
||||
@@ -1900,9 +1919,13 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
interface ClaudeResponseMessage {
|
||||
kind: 'prompt' | 'response';
|
||||
label: 'Prompt' | 'Response';
|
||||
role: 'user' | 'assistant';
|
||||
text: string;
|
||||
timestamp?: string;
|
||||
turn: number;
|
||||
queued?: boolean;
|
||||
}
|
||||
|
||||
interface ClaudeTranscriptEntry {
|
||||
@@ -1912,6 +1935,19 @@ export function registerSessionRoutes(
|
||||
isSidechain?: boolean;
|
||||
isCompactSummary?: boolean;
|
||||
message?: { content?: unknown };
|
||||
// A prompt typed while Claude is working is absorbed mid-turn and recorded
|
||||
// ONLY here — the CLI never re-emits it as a `user` row. Every field stays
|
||||
// optional and unvalidated: `queued_command` is not a documented CLI
|
||||
// contract, so a missing/renamed field must mean "skip", which is also what
|
||||
// the CLI's own non-human queue entries (commandMode 'task-notification',
|
||||
// no `origin` key) require. Shape observed on Claude Code 2.1.220-2.1.251.
|
||||
attachment?: {
|
||||
type?: string;
|
||||
prompt?: string;
|
||||
commandMode?: string;
|
||||
timestamp?: string;
|
||||
origin?: { kind?: string };
|
||||
};
|
||||
}
|
||||
|
||||
function extractClaudeText(content: unknown, separator: string): string {
|
||||
@@ -1937,10 +1973,17 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude writes one logical turn as many JSONL rows: text, thinking and tool
|
||||
* blocks share message ids, while tool results are represented as user rows.
|
||||
* Build viewer cards from real user boundaries instead of treating every row
|
||||
* as a separate chat message.
|
||||
* Claude writes an append-only event log: tool results arrive as user rows,
|
||||
* thinking/tool_use rows carry no text, and a prompt typed while the agent is
|
||||
* working is only ever recorded as an `attachment/queued_command` row. But one
|
||||
* assistant row IS one whole model message: measured across ~/.claude/projects
|
||||
* (CLI 2.1.220-2.1.251) no assistant row carries more than one content block
|
||||
* and no message id carries more than one text block, so there is nothing to
|
||||
* reassemble. Emit one card per row and group them with `turn` instead of
|
||||
* concatenating a human turn's replies into a single card (#169), which fused
|
||||
* up to 74 distinct model messages into one card. Splitting is safe for
|
||||
* markdown: no adjacent pair of assistant text rows in the corpus continues a
|
||||
* table, a list, or an open code fence.
|
||||
*/
|
||||
function parseClaudeResponseTranscript(
|
||||
content: string,
|
||||
@@ -1949,8 +1992,23 @@ export function registerSessionRoutes(
|
||||
let lastText = '';
|
||||
let lastTimestamp = '';
|
||||
const messages: ClaudeResponseMessage[] = [];
|
||||
let currentUserFragments = new Set<string>();
|
||||
let currentAssistantFragments = new Set<string>();
|
||||
// #169's replay guards, kept: they now SKIP a duplicated row instead of
|
||||
// concatenating it into the previous card.
|
||||
const currentUserFragments = new Set<string>();
|
||||
const currentAssistantFragments = new Set<string>();
|
||||
// Turn 0 is reserved for anything emitted before the first human prompt.
|
||||
let turn = 0;
|
||||
|
||||
const pushUserMessage = (text: string, timestamp: string | undefined, queued: boolean): void => {
|
||||
// A run of consecutive human inputs (a mid-turn queued burst) is ONE turn,
|
||||
// so the viewer renders it under one badge instead of one badge per line.
|
||||
if (messages.at(-1)?.role !== 'user') turn += 1;
|
||||
const message: ClaudeResponseMessage = { kind: 'prompt', label: 'Prompt', role: 'user', text, timestamp, turn };
|
||||
if (queued) message.queued = true;
|
||||
messages.push(message);
|
||||
currentUserFragments.add(text);
|
||||
currentAssistantFragments.clear();
|
||||
};
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
if (!line) continue;
|
||||
@@ -1964,25 +2022,37 @@ export function registerSessionRoutes(
|
||||
// rows include repeated image dimensions and other UI-generated context.
|
||||
if (entry.isSidechain) continue;
|
||||
|
||||
// A prompt typed while Claude is working is absorbed mid-turn and lives
|
||||
// ONLY in an attachment row, so it was lost outright. `origin.kind` and
|
||||
// `commandMode` separate the human's queue entries from the CLI's own:
|
||||
// measured over 57 real transcripts on 2026-09-01, 322 queued_command rows
|
||||
// split 163 `prompt`/`human` and 159 `task-notification`, and not one of
|
||||
// those 159 carries an `origin` key. The 163 human rows become 162 user
|
||||
// cards here — one is a verbatim repeat inside a still-unanswered user run
|
||||
// and is collapsed by the dedup guard below — out of 353 user cards total.
|
||||
if (entry.type === 'attachment') {
|
||||
if (!full) continue;
|
||||
const queued = entry.attachment;
|
||||
if (!queued || queued.type !== 'queued_command') continue;
|
||||
if (queued.origin?.kind !== 'human' || queued.commandMode !== 'prompt') continue;
|
||||
const text = typeof queued.prompt === 'string' ? queued.prompt.trim() : '';
|
||||
if (!text || isClaudeSyntheticUserMessage(entry, text)) continue;
|
||||
if (currentUserFragments.has(text)) continue;
|
||||
pushUserMessage(text, queued.timestamp || entry.timestamp, true);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type === 'user') {
|
||||
const text = extractClaudeText(entry.message?.content, '\n').trim();
|
||||
// A tool_result block has no text block and naturally drops out here.
|
||||
if (!text || isClaudeSyntheticUserMessage(entry, text)) continue;
|
||||
if (!full) continue;
|
||||
|
||||
const previous = messages.at(-1);
|
||||
if (previous?.role === 'user') {
|
||||
// Claude can replay the initial user row while restoring a transcript.
|
||||
// Only collapse duplicates within the same unanswered user turn; the
|
||||
// same prompt after an assistant response remains a legitimate turn.
|
||||
if (currentUserFragments.has(text)) continue;
|
||||
previous.text += `\n\n${text}`;
|
||||
currentUserFragments.add(text);
|
||||
} else {
|
||||
messages.push({ role: 'user', text, timestamp: entry.timestamp });
|
||||
currentUserFragments = new Set([text]);
|
||||
}
|
||||
currentAssistantFragments.clear();
|
||||
// Claude replays the initial user row while restoring a transcript, and
|
||||
// a CLI that also wrote an absorbed prompt as a user row would double it.
|
||||
// Both collapse here. The same prompt sent again AFTER a reply is a
|
||||
// legitimate second turn, because that reply cleared the set.
|
||||
if (currentUserFragments.has(text)) continue;
|
||||
pushUserMessage(text, entry.timestamp, false);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1993,18 +2063,10 @@ export function registerSessionRoutes(
|
||||
lastTimestamp = entry.timestamp || '';
|
||||
if (!full) continue;
|
||||
|
||||
const previous = messages.at(-1);
|
||||
if (previous?.role === 'assistant') {
|
||||
// Replayed snapshots sometimes repeat an identical text block. Distinct
|
||||
// progress/final blocks are kept, but remain inside one Claude card.
|
||||
if (currentAssistantFragments.has(text)) continue;
|
||||
previous.text += `\n\n${text}`;
|
||||
previous.timestamp = entry.timestamp || previous.timestamp;
|
||||
currentAssistantFragments.add(text);
|
||||
} else {
|
||||
messages.push({ role: 'assistant', text, timestamp: entry.timestamp });
|
||||
currentAssistantFragments = new Set([text]);
|
||||
}
|
||||
// Replayed snapshots repeat an identical text block inside one turn.
|
||||
if (currentAssistantFragments.has(text)) continue;
|
||||
messages.push({ kind: 'response', label: 'Response', role: 'assistant', text, timestamp: entry.timestamp, turn });
|
||||
currentAssistantFragments.add(text);
|
||||
currentUserFragments.clear();
|
||||
}
|
||||
|
||||
@@ -2153,10 +2215,14 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
const query = req.query as { context?: string };
|
||||
// `turn` is the brief view's context: the last ANSWERED turn's assistant
|
||||
// messages, so a multi-row answer is not reduced to its final row. `text`
|
||||
// stays the last assistant row in every mode (agent pollers hash it).
|
||||
const wantsMessages = query.context === 'full' || query.context === 'turn';
|
||||
const claudeSessionId = session.claudeSessionId || session.id;
|
||||
const transcript = await findClaudeTranscript(projectsDir, claudeSessionId, session.id);
|
||||
if (!transcript) {
|
||||
return query.context === 'full' ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
|
||||
return wantsMessages ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
|
||||
}
|
||||
|
||||
if (transcript.sessionId !== session.claudeSessionId && transcript.sessionId !== session.id) {
|
||||
@@ -2172,9 +2238,17 @@ export function registerSessionRoutes(
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(transcript.path, 'utf8');
|
||||
return parseClaudeResponseTranscript(content, query.context === 'full');
|
||||
const parsed = parseClaudeResponseTranscript(content, wantsMessages);
|
||||
if (query.context === 'turn') {
|
||||
return {
|
||||
text: parsed.text,
|
||||
timestamp: parsed.timestamp,
|
||||
messages: selectLastAnsweredTurn(parsed.messages ?? []),
|
||||
};
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return query.context === 'full' ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
|
||||
return wantsMessages ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2542,6 +2616,12 @@ export function registerSessionRoutes(
|
||||
? 'mux-full-history'
|
||||
: 'mux-visible'
|
||||
: 'history';
|
||||
// What the three row-preserving skips below must key on. `isFullReload` is
|
||||
// only what the CLIENT ASKED FOR: when the capture comes back null — ENOBUFS,
|
||||
// a timeout, a vanished pane, or a session with no mux at all — rawBuffer
|
||||
// falls back to the byte history, which is a stream of successive frames with
|
||||
// no row alignment to protect and every reason to be stripped.
|
||||
const isFullCapture = isFullReload && hasLiveMuxBuffer;
|
||||
let rawBuffer: string;
|
||||
if (liveMuxBuffer !== null && liveMuxBuffer.length > 0) {
|
||||
// Full-history capture is the RENDERED form of everything already in the
|
||||
@@ -2588,8 +2668,16 @@ export function registerSessionRoutes(
|
||||
// During long thinking phases, Ink rewrites the same rows thousands of times
|
||||
// (500KB+). Without stripping, tail mode returns only spinner frames and
|
||||
// the terminal appears empty when switching tabs.
|
||||
// A full reload's buffer IS the rendered pane, one line per screen row, and
|
||||
// it ends with an absolute cursor move back to the pane's own position.
|
||||
// Every transform below that can DELETE A LINE would shift the rows out from
|
||||
// under that position, leaving the caret a row off — on the composer's
|
||||
// border rather than its input line. Redraw-bloat stripping exists for a
|
||||
// byte stream of successive frames; a capture holds no successive frames.
|
||||
let strippedBuffer =
|
||||
getCli(session.mode)?.capabilities.stripInkBloat === false ? rawBuffer : stripInkRedrawBloat(rawBuffer);
|
||||
isFullCapture || getCli(session.mode)?.capabilities.stripInkBloat === false
|
||||
? rawBuffer
|
||||
: stripInkRedrawBloat(rawBuffer);
|
||||
|
||||
// Strip alt-screen toggles and scrollback-erase from Codex/Claude byte
|
||||
// streams. xterm.js obeys them by switching to its scrollback-less alt
|
||||
@@ -2627,7 +2715,10 @@ export function registerSessionRoutes(
|
||||
cleanBuffer = strippedBuffer;
|
||||
|
||||
// Find where Claude banner starts (has color codes before "Claude")
|
||||
const claudeMatch = cleanBuffer.match(CLAUDE_BANNER_PATTERN);
|
||||
// Skipped for a full reload: the banner sits at whatever row the pane has
|
||||
// it, and cutting to it would drop the blank rows above and move every
|
||||
// row up by that many.
|
||||
const claudeMatch = isFullCapture ? null : cleanBuffer.match(CLAUDE_BANNER_PATTERN);
|
||||
if (claudeMatch && claudeMatch.index !== undefined && claudeMatch.index > 0) {
|
||||
let lineStart = claudeMatch.index;
|
||||
while (lineStart > 0 && cleanBuffer[lineStart - 1] !== '\n') {
|
||||
@@ -2638,7 +2729,11 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
// Remove Ctrl+L and leading whitespace (cheap on tailed subset)
|
||||
cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '').replace(LEADING_WHITESPACE_PATTERN, '');
|
||||
// Leading whitespace goes too, except on a full reload where a leading
|
||||
// blank line is the pane's own first row and dropping it shifts every row
|
||||
// up by one.
|
||||
cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '');
|
||||
if (!isFullCapture) cleanBuffer = cleanBuffer.replace(LEADING_WHITESPACE_PATTERN, '');
|
||||
|
||||
const finishedAt = performance.now();
|
||||
reply.header(
|
||||
@@ -2915,8 +3010,13 @@ export function registerSessionRoutes(
|
||||
envOverrides,
|
||||
effort,
|
||||
parentSessionId,
|
||||
agentOrigin,
|
||||
} = parseBody(QuickStartSchema, req.body);
|
||||
|
||||
// Resolved ONCE here: the same value labels a case directory this request creates
|
||||
// (agent-case-marker.ts) and draws the tab lineage line on the session below.
|
||||
const qsParentSessionId = resolveParentSessionId(ctx, req, parentSessionId, owner);
|
||||
|
||||
// Multi-user: shell mode is arbitrary host-account execution, gated by the grant.
|
||||
// Resolve the owner's grant from the store so a GRANTED regular user is not wrongly denied.
|
||||
if (getCli(mode)?.capabilities.privilegedCommandGate && !(await canUsernameRunPrivilegedCommands(owner))) {
|
||||
@@ -3014,25 +3114,48 @@ export function registerSessionRoutes(
|
||||
);
|
||||
}
|
||||
const sessionDocker = toSessionDocker(host, dockerCase);
|
||||
// Ensure the base image exists, auto-building the default image on first use so
|
||||
// it is never a blocker. Dedup'd with any build kicked off at case-create, so
|
||||
// this awaits the SAME in-flight build rather than starting a second one.
|
||||
const ensured = await ensureAgentBaseImage(sessionDocker, sessionDocker.image, {
|
||||
onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }),
|
||||
});
|
||||
if (!ensured.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, ensured.error || 'base image not available');
|
||||
}
|
||||
if (ensured.built) {
|
||||
ctx.broadcast(SseEvent.DockerImageBuildComplete, { name: dockerCase.name, image: sessionDocker.image });
|
||||
}
|
||||
// tmux is a hard prerequisite (the in-container tmux makes reconnect durable).
|
||||
// Skip the extra container-run probe for our OWN default image (the baked
|
||||
// Dockerfile always contains tmux); still verify a custom image.
|
||||
if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) {
|
||||
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
|
||||
if (!tmuxCheck.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux');
|
||||
// An ADOPTED container skips every image-side gate: we never run `docker
|
||||
// create`, so the image is the user's business, and `ensureAgentBaseImage`
|
||||
// would build/require an image that has nothing to do with their container.
|
||||
// The prerequisite that DOES still hold is tmux inside it, so probe the live
|
||||
// container (not the image) and refuse before launch rather than dead-paning.
|
||||
if (sessionDocker.owned === false) {
|
||||
const probe = await probeAdoptableContainer(sessionDocker, [mode]);
|
||||
if (!probe.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, probe.error || 'container is not usable');
|
||||
}
|
||||
// The probe already exec'd into the container; carry its facts onto the
|
||||
// live session so the launch chain does not have to re-ask.
|
||||
sessionDocker.runsAsRoot = probe.runsAsRoot;
|
||||
// No `mode !== 'shell'` arm: a mode with no binary of its own is reported
|
||||
// available by the probe unconditionally, so this reads the same answer for it.
|
||||
if (!probe.availableModes?.includes(mode)) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
`"${mode}" is not installed in container "${sessionDocker.containerName}". Adoption never modifies the container — install it inside, or pick another mode.`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Ensure the base image exists, auto-building the default image on first use so
|
||||
// it is never a blocker. Dedup'd with any build kicked off at case-create, so
|
||||
// this awaits the SAME in-flight build rather than starting a second one.
|
||||
const ensured = await ensureAgentBaseImage(sessionDocker, sessionDocker.image, {
|
||||
onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }),
|
||||
});
|
||||
if (!ensured.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, ensured.error || 'base image not available');
|
||||
}
|
||||
if (ensured.built) {
|
||||
ctx.broadcast(SseEvent.DockerImageBuildComplete, { name: dockerCase.name, image: sessionDocker.image });
|
||||
}
|
||||
// tmux is a hard prerequisite (the in-container tmux makes reconnect durable).
|
||||
// Skip the extra container-run probe for our OWN default image (the baked
|
||||
// Dockerfile always contains tmux); still verify a custom image.
|
||||
if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) {
|
||||
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
|
||||
if (!tmuxCheck.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3139,6 +3262,26 @@ export function registerSessionRoutes(
|
||||
await writeHooksConfig(resolvedCasePath);
|
||||
}
|
||||
|
||||
// Label a directory an AGENT asked us to create, so the scratch workspaces a
|
||||
// long orchestration leaves behind can be told apart from the user's real
|
||||
// projects later (see agent-case-marker.ts). This is the only branch that may
|
||||
// write it: it is the only one that creates the directory, and a pre-existing
|
||||
// case must never be labelled. Best-effort — a failed marker must not fail the
|
||||
// spawn it decorates.
|
||||
const qsAgentOrigin = resolveAgentCaseOrigin(req, agentOrigin, qsParentSessionId);
|
||||
if (qsAgentOrigin) {
|
||||
await writeAgentCaseMarker(
|
||||
resolvedCasePath,
|
||||
buildAgentCaseMarker({
|
||||
createdBy: qsAgentOrigin,
|
||||
parentSessionId: qsParentSessionId,
|
||||
parentSessionName: qsParentSessionId ? ctx.sessions.get(qsParentSessionId)?.name : undefined,
|
||||
mode,
|
||||
owner,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: resolvedCasePath });
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
||||
@@ -3272,7 +3415,7 @@ export function registerSessionRoutes(
|
||||
docker,
|
||||
resumeSessionId: dockerResumeId,
|
||||
tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
|
||||
parentSessionId: resolveParentSessionId(ctx, req, parentSessionId, owner),
|
||||
parentSessionId: qsParentSessionId,
|
||||
});
|
||||
|
||||
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
||||
@@ -4056,6 +4199,13 @@ export function registerSessionRoutes(
|
||||
// Persisted sessions (state.json). resumeSessionId is the Claude
|
||||
// conversation UUID a resumed session continues — feed it to the merge's
|
||||
// alias map so its transcript row folds into this session.
|
||||
//
|
||||
// codex keeps its thread id in `codexConfig` instead, and state.json stores
|
||||
// that, so read it here as well. Without it a resumed codex session that has
|
||||
// been demoted to a persisted-only record loses its alias and duplicates: the
|
||||
// originator fallback below cannot rescue that one, because a RESUMED rollout
|
||||
// keeps the original session_meta (see findActiveCodexFile) and so still
|
||||
// names whichever pane first created the thread.
|
||||
const persisted: PersistedSessionInput[] = Object.values(ctx.store.getState().sessions).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
@@ -4064,7 +4214,7 @@ export function registerSessionRoutes(
|
||||
workingDir: p.workingDir,
|
||||
createdAt: p.createdAt,
|
||||
lastActivityAt: p.lastActivityAt,
|
||||
claudeSessionId: p.resumeSessionId,
|
||||
claudeSessionId: p.resumeSessionId || p.codexConfig?.resumeSessionId,
|
||||
pinned: p.pinned,
|
||||
pinnedAt: p.pinnedAt,
|
||||
}));
|
||||
@@ -4132,6 +4282,49 @@ export function registerSessionRoutes(
|
||||
// Best-effort, same as the claude scan above.
|
||||
}
|
||||
|
||||
// Codex's own rollout store (~/.codex/sessions) — the same treatment omp
|
||||
// gets above, and for the same reason: codex writes no Claude transcript, so
|
||||
// without this a codex conversation disappears from the list as soon as its
|
||||
// session record does. `resumeId` is the rollout's own thread id, which is
|
||||
// what `codex resume` takes; see codex-transcript.ts.
|
||||
try {
|
||||
const codexRows = await scanCodexSessionsHistory();
|
||||
for (const h of codexRows) {
|
||||
history.push({
|
||||
sessionId: h.sessionId,
|
||||
workingDir: h.workingDir,
|
||||
sizeBytes: h.sizeBytes,
|
||||
lastModified: h.lastModified,
|
||||
firstPrompt: h.firstPrompt,
|
||||
lastPrompt: h.lastPrompt,
|
||||
mode: 'codex',
|
||||
resumeId: h.sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
// Fold a FRESH codex pane into its own rollout row. A resumed one already
|
||||
// folds, because Session sets `claudeSessionId` from the resume id it was
|
||||
// given; a fresh one has no thread id until codex writes the rollout, so
|
||||
// the link has to come from the other side. Codeman spawns every codex pane
|
||||
// with CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codeman_<sessionId>, and codex
|
||||
// stamps that into session_meta.originator, so the rollout names the pane.
|
||||
//
|
||||
// Newest rollout wins: `/new` typed inside the TUI leaves several rollouts
|
||||
// carrying the same originator, and the pane is on the most recent one.
|
||||
// Rows arrive newest-first, so the first match is it.
|
||||
//
|
||||
// Never overwrites an id a session already knows — that one came from the
|
||||
// resume path and is authoritative.
|
||||
const codexThreads = codexThreadBySessionId(codexRows);
|
||||
for (const row of [...live, ...persisted]) {
|
||||
if (row.claudeSessionId && row.claudeSessionId !== row.id) continue;
|
||||
const threadId = codexThreads.get(row.id);
|
||||
if (threadId) row.claudeSessionId = threadId;
|
||||
}
|
||||
} catch {
|
||||
// Best-effort, same as the two scans above.
|
||||
}
|
||||
|
||||
// Mux process stats (best-effort; guard against mocks lacking the method).
|
||||
let mux: MuxStatInput[] = [];
|
||||
try {
|
||||
|
||||
@@ -390,6 +390,9 @@ export function registerSystemRoutes(
|
||||
'in-flight': { http: 409, api: ApiErrorCode.ALREADY_EXISTS },
|
||||
'up-to-date': { http: 409, api: ApiErrorCode.ALREADY_EXISTS },
|
||||
'not-git': { http: 400, api: ApiErrorCode.INVALID_INPUT },
|
||||
// A container release that changes the ENVIRONMENT: not a client error to
|
||||
// retry, it needs a host-side rebuild (docs/docker-self-update.md).
|
||||
'env-blocked': { http: 409, api: ApiErrorCode.INVALID_INPUT },
|
||||
disabled: { http: 403, api: ApiErrorCode.INVALID_INPUT },
|
||||
'bad-tag': { http: 400, api: ApiErrorCode.INVALID_INPUT },
|
||||
error: { http: 500, api: ApiErrorCode.INTERNAL_ERROR },
|
||||
|
||||
@@ -33,7 +33,8 @@ import { randomUUID } from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { WebSocket as WsClient } from 'ws';
|
||||
import type { WebSocket } from 'ws';
|
||||
import type { ClientOptions as WsClientOptions, WebSocket } from 'ws';
|
||||
import type { Response as UndiciResponse } from 'undici';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
import {
|
||||
MAX_LIVE_WEBVIEW_FRAMES,
|
||||
@@ -47,6 +48,8 @@ import {
|
||||
} from '../../config/webview-limits.js';
|
||||
import { readWebviews, writeWebviews } from '../../webview-store.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
import { egressBlockedReason, webviewEgressLookup, webviewFetch, type EgressLookup } from '../webview-egress.js';
|
||||
import { blockedWebviewHostReason } from '../webview-egress-policy.js';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import type { Webview, WebviewOpenData, WebviewProbe } from '../../types.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
@@ -99,14 +102,14 @@ function withWebviews<T>(fn: (list: Webview[]) => Promise<T> | T): Promise<T> {
|
||||
return next;
|
||||
}
|
||||
|
||||
export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort): void {
|
||||
registerCrudRoutes(app, ctx);
|
||||
registerProxyRoutes(app);
|
||||
export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort, basePath = ''): void {
|
||||
registerCrudRoutes(app, ctx, basePath);
|
||||
registerProxyRoutes(app, basePath);
|
||||
}
|
||||
|
||||
// ───────────────────────────── CRUD ─────────────────────────────
|
||||
|
||||
function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort): void {
|
||||
function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort, basePath: string): void {
|
||||
app.get('/api/webviews', async (req) => {
|
||||
const user = getAuthUser(req);
|
||||
const all = await readWebviews(configDir());
|
||||
@@ -276,7 +279,7 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort
|
||||
}
|
||||
|
||||
const capability = webviewCapabilities.mint(webview.id, webview.owner);
|
||||
const data: WebviewOpenData = { webview, embedUrl: proxyPrefixFor(capability) };
|
||||
const data: WebviewOpenData = { webview, embedUrl: proxyPrefixFor(capability, basePath) };
|
||||
return { success: true, data };
|
||||
});
|
||||
}
|
||||
@@ -293,7 +296,7 @@ async function probeUrl(url: string): Promise<WebviewProbe> {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(target.href, {
|
||||
const response = await webviewFetch(target, {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(WEBVIEW_PROBE_TIMEOUT_MS),
|
||||
@@ -326,6 +329,12 @@ async function probeUrl(url: string): Promise<WebviewProbe> {
|
||||
reason,
|
||||
};
|
||||
} catch (err) {
|
||||
const blocked = egressBlockedReason(err);
|
||||
if (blocked) {
|
||||
// Refused by policy, not unreachable: say so, or the user reads it as a
|
||||
// network problem and starts debugging their firewall.
|
||||
return { reachable: false, framable: false, recommendedMode: 'proxy', reason: blocked };
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
reachable: false,
|
||||
@@ -338,7 +347,7 @@ async function probeUrl(url: string): Promise<WebviewProbe> {
|
||||
|
||||
// ───────────────────────────── Proxy ─────────────────────────────
|
||||
|
||||
function registerProxyRoutes(app: FastifyInstance): void {
|
||||
function registerProxyRoutes(app: FastifyInstance, basePath: string): void {
|
||||
app.register(async (scope) => {
|
||||
// Encapsulated to this plugin only. The proxy must relay request bodies
|
||||
// BYTE-FOR-BYTE, so every parser is replaced with a pass-through that hands
|
||||
@@ -349,11 +358,11 @@ function registerProxyRoutes(app: FastifyInstance): void {
|
||||
|
||||
// A single GET route serving both roles: `handler` for normal requests,
|
||||
// `wsHandler` for upgrades. Registering them as two routes on one URL would
|
||||
// collide.
|
||||
// collide. (The WS leg produces no browser-facing URLs, so it needs no base.)
|
||||
scope.route<{ Params: ProxyParams }>({
|
||||
method: 'GET',
|
||||
url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`,
|
||||
handler: proxyHttp,
|
||||
handler: (req, reply) => proxyHttp(req, reply, basePath),
|
||||
wsHandler: proxyWebSocket,
|
||||
});
|
||||
|
||||
@@ -362,14 +371,14 @@ function registerProxyRoutes(app: FastifyInstance): void {
|
||||
scope.route<{ Params: ProxyParams }>({
|
||||
method: ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`,
|
||||
handler: proxyHttp,
|
||||
handler: (req, reply) => proxyHttp(req, reply, basePath),
|
||||
});
|
||||
|
||||
// `/webview/<cap>` with no trailing slash: redirect rather than serve, so the
|
||||
// browser's notion of the base path ends in `/` and relative URLs in the
|
||||
// dashboard's HTML resolve inside the prefix instead of one level above it.
|
||||
scope.get<{ Params: { cap: string } }>(`${WEBVIEW_PROXY_PREFIX}/:cap`, (req, reply) => {
|
||||
return reply.redirect(proxyPrefixFor(req.params.cap), 302);
|
||||
return reply.redirect(proxyPrefixFor(req.params.cap, basePath), 302);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -397,8 +406,12 @@ async function lookupCapability(capability: string): Promise<Webview | null> {
|
||||
* streamed asset comes back zero-length. Returning the reply is what tells Fastify
|
||||
* the response is already owned by this handler.
|
||||
*/
|
||||
function proxyHttp(req: FastifyRequest<{ Params: ProxyParams }>, reply: FastifyReply): Promise<FastifyReply> {
|
||||
return proxyRequest(req, reply, req.params.cap, req.params['*'] ?? '');
|
||||
function proxyHttp(
|
||||
req: FastifyRequest<{ Params: ProxyParams }>,
|
||||
reply: FastifyReply,
|
||||
basePath: string
|
||||
): Promise<FastifyReply> {
|
||||
return proxyRequest(req, reply, req.params.cap, req.params['*'] ?? '', basePath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -410,7 +423,8 @@ async function proxyRequest(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
cap: string,
|
||||
wildcard: string
|
||||
wildcard: string,
|
||||
basePath = ''
|
||||
): Promise<FastifyReply> {
|
||||
const webview = await lookupCapability(cap);
|
||||
if (!webview) {
|
||||
@@ -445,7 +459,8 @@ async function proxyRequest(
|
||||
const headers = buildUpstreamRequestHeaders(req.headers, upstream, {
|
||||
forwardCookies: webview.trusted,
|
||||
sessionCookieName: AUTH_COOKIE_NAME,
|
||||
refererPath: typeof req.headers.referer === 'string' ? stripProxyPrefix(req.headers.referer, cap) : undefined,
|
||||
refererPath:
|
||||
typeof req.headers.referer === 'string' ? stripProxyPrefix(req.headers.referer, cap, basePath) : undefined,
|
||||
});
|
||||
|
||||
// #237: the timeout bounds TIME-TO-HEADERS only. A plain AbortSignal.timeout on
|
||||
@@ -476,19 +491,19 @@ async function proxyRequest(
|
||||
// string (it can carry the dashboard's tokens).
|
||||
const logTarget = `${req.method} ${upstream.origin}${upstream.pathname}`;
|
||||
|
||||
let response: Response;
|
||||
let response: UndiciResponse;
|
||||
try {
|
||||
response = await fetch(upstream.href, {
|
||||
response = await webviewFetch(upstream, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: hasBody ? (req.body as Readable) : undefined,
|
||||
// Required by undici whenever the body is a stream.
|
||||
...(hasBody ? { duplex: 'half' } : {}),
|
||||
...(hasBody ? { duplex: 'half' as const } : {}),
|
||||
// Redirects are rewritten into the proxy prefix instead of followed, so the
|
||||
// browser's URL stays inside the frame and relative assets keep resolving.
|
||||
redirect: 'manual',
|
||||
signal: abort.signal,
|
||||
} as RequestInit);
|
||||
});
|
||||
} catch (err) {
|
||||
const elapsed = Date.now() - startedAt;
|
||||
if (clientGone) {
|
||||
@@ -496,6 +511,13 @@ async function proxyRequest(
|
||||
// failure, so no warn (it would read as the dashboard being broken).
|
||||
return reply;
|
||||
}
|
||||
const blocked = egressBlockedReason(err);
|
||||
if (blocked) {
|
||||
// Policy refusal, distinct from "unreachable": a record saved before the
|
||||
// egress rule existed, or a name that now resolves into a blocked range.
|
||||
console.warn(`[Webview] refused by egress policy: ${logTarget} (webview "${webview.name}"): ${blocked}`);
|
||||
return reply.code(403).type('text/plain').send(`Forbidden: ${blocked}`);
|
||||
}
|
||||
if (headerTimedOut) {
|
||||
console.warn(
|
||||
`[Webview] upstream sent no response headers within ${WEBVIEW_UPSTREAM_TIMEOUT_MS}ms: ` +
|
||||
@@ -530,7 +552,8 @@ async function proxyRequest(
|
||||
response.headers.getSetCookie(),
|
||||
cap,
|
||||
upstream,
|
||||
secureContext
|
||||
secureContext,
|
||||
basePath
|
||||
);
|
||||
|
||||
reply.code(response.status);
|
||||
@@ -559,7 +582,7 @@ async function proxyRequest(
|
||||
// Buffer only HTML, only under the cap: `<base>` injection needs the whole
|
||||
// document, and buffering an unbounded upstream body is a memory hazard.
|
||||
const html = await response.text();
|
||||
return reply.send(html.length <= MAX_WEBVIEW_HTML_REWRITE_BYTES ? rewriteHtml(html, cap) : html);
|
||||
return reply.send(html.length <= MAX_WEBVIEW_HTML_REWRITE_BYTES ? rewriteHtml(html, cap, basePath) : html);
|
||||
}
|
||||
|
||||
return reply.send(Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]));
|
||||
@@ -580,25 +603,34 @@ async function proxyRequest(
|
||||
*
|
||||
* @returns true when the request was handled (caller must not also reply).
|
||||
*/
|
||||
export async function tryWebviewRefererFallback(req: FastifyRequest, reply: FastifyReply): Promise<boolean> {
|
||||
export async function tryWebviewRefererFallback(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
basePath = ''
|
||||
): Promise<boolean> {
|
||||
// Safe methods only. A write arriving here has already lost its raw body to the
|
||||
// root instance's JSON parser, so it could not be relayed faithfully anyway.
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
|
||||
|
||||
const capability = capabilityFromReferer(typeof req.headers.referer === 'string' ? req.headers.referer : undefined);
|
||||
const capability = capabilityFromReferer(
|
||||
typeof req.headers.referer === 'string' ? req.headers.referer : undefined,
|
||||
basePath
|
||||
);
|
||||
if (!capability) return false;
|
||||
if (!webviewCapabilities.resolve(capability)) return false;
|
||||
|
||||
// req.url is already base-stripped by the server's rewriteUrl, so this is the
|
||||
// internal path the upstream resolver expects.
|
||||
const path = req.url.split('?')[0].replace(/^\//, '');
|
||||
await proxyRequest(req, reply, capability, path);
|
||||
await proxyRequest(req, reply, capability, path, basePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Turn a proxy-side Referer back into the upstream path it corresponds to. */
|
||||
function stripProxyPrefix(referer: string, capability: string): string | undefined {
|
||||
function stripProxyPrefix(referer: string, capability: string, basePath = ''): string | undefined {
|
||||
try {
|
||||
const url = new URL(referer);
|
||||
const prefix = proxyPrefixFor(capability);
|
||||
const prefix = proxyPrefixFor(capability, basePath);
|
||||
if (!url.pathname.startsWith(prefix)) return undefined;
|
||||
return `/${url.pathname.slice(prefix.length)}${url.search}`;
|
||||
} catch {
|
||||
@@ -644,6 +676,13 @@ function proxyWebSocket(socket: WebSocket, req: FastifyRequest<{ Params: ProxyPa
|
||||
return;
|
||||
}
|
||||
|
||||
// An IP literal never reaches the lookup hook (net.connect skips DNS for it),
|
||||
// so the literal form is judged here and the resolved form in the lookup.
|
||||
if (blockedWebviewHostReason(upstream.hostname)) {
|
||||
socket.close(4003, 'Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
socketCounts.set(webview.id, live + 1);
|
||||
let released = false;
|
||||
const release = () => {
|
||||
@@ -655,16 +694,21 @@ function proxyWebSocket(socket: WebSocket, req: FastifyRequest<{ Params: ProxyPa
|
||||
};
|
||||
|
||||
const protocols = req.headers['sec-websocket-protocol'];
|
||||
// `lookup` is absent from ws's ClientOptions typings but flows through
|
||||
// http.request to net.connect untouched, which is where the resolved
|
||||
// address is judged (see webview-egress.ts).
|
||||
const upstreamOptions: WsClientOptions & { lookup: EgressLookup } = {
|
||||
headers: {
|
||||
origin: upstream.origin,
|
||||
...(webview.trusted && req.headers.cookie ? { cookie: String(req.headers.cookie) } : {}),
|
||||
},
|
||||
handshakeTimeout: WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS,
|
||||
lookup: webviewEgressLookup,
|
||||
};
|
||||
const upstreamSocket = new WsClient(
|
||||
upstreamWebSocketUrl(upstream),
|
||||
protocols ? String(protocols).split(/,\s*/) : [],
|
||||
{
|
||||
headers: {
|
||||
origin: upstream.origin,
|
||||
...(webview.trusted && req.headers.cookie ? { cookie: String(req.headers.cookie) } : {}),
|
||||
},
|
||||
handshakeTimeout: WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS,
|
||||
}
|
||||
upstreamOptions
|
||||
);
|
||||
|
||||
// Buffer anything the browser sends before the upstream handshake completes,
|
||||
@@ -703,9 +747,14 @@ function proxyWebSocket(socket: WebSocket, req: FastifyRequest<{ Params: ProxyPa
|
||||
socket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString()));
|
||||
upstreamSocket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString()));
|
||||
socket.on('error', () => closeBoth());
|
||||
upstreamSocket.on('error', () => {
|
||||
upstreamSocket.on('error', (err: Error) => {
|
||||
release();
|
||||
if (socket.readyState === socket.OPEN) socket.close(1011, 'Upstream error');
|
||||
if (socket.readyState !== socket.OPEN) return;
|
||||
// A name that resolved into a blocked range fails inside the connect, so it
|
||||
// surfaces here rather than at the sync check above; report it as the same
|
||||
// policy refusal, not as the dashboard being broken.
|
||||
if (egressBlockedReason(err)) socket.close(4003, 'Forbidden');
|
||||
else socket.close(1011, 'Upstream error');
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { z } from 'zod';
|
||||
import { SAFE_PATH_PATTERN, isSafePushEndpoint } from '../utils/index.js';
|
||||
import { isValidWebviewUrl } from './webview-proxy.js';
|
||||
import { isBlockedWebviewUrl } from './webview-egress-policy.js';
|
||||
import {
|
||||
MAX_TERMINAL_BUFFER_BYTES,
|
||||
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||
@@ -867,6 +868,74 @@ export const DockerCaseLinkSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* ADOPT an already-running container the user built and runs themselves. The
|
||||
* container name is REQUIRED (there is nothing to derive it from — we are not
|
||||
* creating it), and `hostWorkspacePath` still points at real host bytes so the
|
||||
* file routes, watchers and transcript correlation keep working exactly as they
|
||||
* do for an owned case. Everything that only makes sense at container-create
|
||||
* time (image, network, resources, gpus, credential mounts) is deliberately
|
||||
* absent: adoption never runs `docker create`.
|
||||
*/
|
||||
export const DockerCaseAdoptSchema = z.object({
|
||||
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
|
||||
hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'),
|
||||
container: z
|
||||
.string()
|
||||
.min(2)
|
||||
.max(128)
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid container name'),
|
||||
hostWorkspacePath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.regex(/^\//, 'Workspace path must be absolute')
|
||||
.regex(/^[^,]*$/, 'Workspace path must not contain commas (docker --mount is comma-delimited)')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in workspace path'),
|
||||
containerWorkdir: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.regex(/^\//, 'Container workdir must be absolute')
|
||||
.regex(/^[^,]*$/, 'Container workdir must not contain commas (docker --mount is comma-delimited)')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in container workdir')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/** Read-only adoption preflight: report on an existing container, link nothing. */
|
||||
export const DockerAdoptPreflightSchema = z.object({
|
||||
hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'),
|
||||
container: z
|
||||
.string()
|
||||
.min(2)
|
||||
.max(128)
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid container name'),
|
||||
/** Optional: also verify this path exists INSIDE the container. */
|
||||
containerWorkdir: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.regex(/^\//, 'Container workdir must be absolute')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in container workdir')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/** Read-only directory listing inside a container (adoption workdir picker). */
|
||||
export const DockerBrowseSchema = z.object({
|
||||
hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'),
|
||||
container: z
|
||||
.string()
|
||||
.min(2)
|
||||
.max(128)
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid container name'),
|
||||
path: z
|
||||
.string()
|
||||
.max(2000)
|
||||
.regex(/^\//, 'Path must be absolute')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in path')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const DockerExportSchema = z.object({
|
||||
mode: z.enum(['full', 'workspace']).optional(),
|
||||
});
|
||||
@@ -956,6 +1025,16 @@ export const QuickStartSchema = z.object({
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort: effortLevelSchema,
|
||||
/**
|
||||
* Who is spawning this worker (`codeman-skill` from the packaged agent skill), or,
|
||||
* equivalently, the `X-Codeman-Agent-Origin` header; the body wins when both are
|
||||
* present. Used ONLY to label a case directory this request CREATES as an agent
|
||||
* scratch workspace, so it can be found and cleaned up later — see
|
||||
* `src/agent-case-marker.ts`. Never a permission signal, and an unrecognised token
|
||||
* is dropped rather than rejected. `POST /api/sessions` has no equivalent field
|
||||
* because it takes an existing `workingDir` and so never creates a directory to label.
|
||||
*/
|
||||
agentOrigin: z.string().max(64).optional(),
|
||||
});
|
||||
|
||||
// ========== Hook Events ==========
|
||||
@@ -974,6 +1053,9 @@ export const HookEventSchema = z.object({
|
||||
'stop',
|
||||
'teammate_idle',
|
||||
'task_completed',
|
||||
// Claude Code's UserPromptSubmit: a first-hand report of the pane's live
|
||||
// conversation id. Keep in step with HookEventType in types/api.ts.
|
||||
'prompt_submitted',
|
||||
// A turn STARTED. Unlike the others this one has no Claude Code hook behind
|
||||
// it: it is reported by the DeepSeek Harness status shim, and exists so a
|
||||
// dialog answered in the terminal resolves its Approvals Inbox item at once
|
||||
@@ -1173,6 +1255,15 @@ export const SettingsUpdateSchema = z
|
||||
tabOrientation: z.enum(['horizontal', 'vertical']).optional(),
|
||||
tabRailWidth: z.number().int().min(208).max(360).optional(),
|
||||
tabRailDetail: z.enum(['simple', 'rich']).optional(),
|
||||
/**
|
||||
* Vertical rail row order. Display key (per-device).
|
||||
* 'activity' = the home screens' order (CodemanSessionOrder): blocked on a
|
||||
* human first, then running longest-first, then quiet
|
||||
* most-recently-quiet first.
|
||||
* 'manual' = the user's tab order, and the only value that leaves the
|
||||
* rail drag-reorderable.
|
||||
*/
|
||||
tabRailSort: z.enum(['activity', 'manual']).optional(),
|
||||
/**
|
||||
* Session list layout. Display key (per-device).
|
||||
* 'header' = horizontal tab strip
|
||||
@@ -1762,6 +1853,13 @@ const webviewUrlSchema = z
|
||||
.max(2000, 'URL too long (max 2000 chars)')
|
||||
.refine(isValidWebviewUrl, {
|
||||
message: 'Invalid URL: must be http(s), with a hostname and no embedded credentials',
|
||||
})
|
||||
// Egress policy (`webview-egress-policy.ts`): no dashboard lives at a link-local
|
||||
// or cloud-metadata address, while an IAM credential does. Refused at save time
|
||||
// for the clear message; the proxy re-judges the RESOLVED address at connect time.
|
||||
.refine((url) => !isBlockedWebviewUrl(url), {
|
||||
message:
|
||||
'Blocked URL: link-local and cloud-metadata addresses (169.254.0.0/16, metadata.google.internal, ...) cannot be dashboards',
|
||||
});
|
||||
|
||||
const WebviewBaseSchema = z.object({
|
||||
|
||||
+327
-14
@@ -16,6 +16,15 @@
|
||||
* tested, and IO wrappers (`getInstallInfo`, `checkForUpdate`, `startUpdate`,
|
||||
* `reconcileUpdateOnBoot`) that touch git/network/fs.
|
||||
*
|
||||
* DOCKER COMPOSE installs update in place too, through the same script and the
|
||||
* same status file. The repo is a host bind mount, so the pull/build land on the
|
||||
* host filesystem and survive container recreation; the "restart" is the server
|
||||
* EXITING so the container's restart policy relaunches it on the new `dist/`.
|
||||
* That applies CODE only — a restart reuses the existing container's image and
|
||||
* config — so `evaluateEnvironmentGate()` refuses a release that changes
|
||||
* `server.Dockerfile`, `docker-compose.yaml` or `.env.example`, pointing at the
|
||||
* host command instead. See `docs/docker-self-update.md`.
|
||||
*
|
||||
* Related: `src/types/update.ts`, `scripts/self-update.sh`, routes in
|
||||
* `src/web/routes/system-routes.ts`.
|
||||
*
|
||||
@@ -26,13 +35,15 @@ import { spawn, execFileSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, writeFileSync, renameSync, copyFileSync, chmodSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { homedir, hostname, tmpdir } from 'node:os';
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { LAUNCHD_LABEL, SYSTEMD_UNIT } from '../config/service-names.js';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import type {
|
||||
EnvironmentBlocker,
|
||||
EnvironmentGate,
|
||||
InstallInfo,
|
||||
InstallKind,
|
||||
SupervisorKind,
|
||||
@@ -215,6 +226,139 @@ export function reconcileStatusDecision(
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PURE helpers — the container environment gate
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Host command that resolves every environment blocker. */
|
||||
export const DOCKER_HOST_UPDATE_COMMAND = 'docker/Start-Codeman.sh';
|
||||
|
||||
/**
|
||||
* Parse the SET keys out of a dotenv file. Commented-out lines are deliberately
|
||||
* NOT keys: `docker/.env.example` uses `# PUID=1000` to document an OPTIONAL
|
||||
* override, so treating those as required would block every update on settings
|
||||
* the user is meant to leave alone.
|
||||
*/
|
||||
export function parseEnvKeys(text: string): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const raw of text.split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const m = line.replace(/^export\s+/, '').match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=/);
|
||||
if (m && !keys.includes(m[1])) keys.push(m[1]);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys the TARGET release's `.env.example` sets that the user's `.env` does not.
|
||||
*
|
||||
* This is the check that makes a new required setting visible: Compose resolves
|
||||
* an unset `${VAR}` to the empty string and starts anyway, so a missing key is
|
||||
* otherwise silent until something misbehaves at runtime.
|
||||
*/
|
||||
export function diffRequiredEnvKeys(targetExample: string, userEnv: string): string[] {
|
||||
const have = new Set(parseEnvKeys(userEnv));
|
||||
return parseEnvKeys(targetExample).filter((k) => !have.has(k));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the container's restart policy relaunches it after the server exits.
|
||||
* `no` and an empty policy mean an in-place update would take Codeman DOWN
|
||||
* rather than restart it, so the update is refused instead.
|
||||
*/
|
||||
export function isAutoRestartPolicy(name: string | null | undefined): boolean {
|
||||
return name === 'always' || name === 'unless-stopped' || name === 'on-failure';
|
||||
}
|
||||
|
||||
/**
|
||||
* PURE: may the container updater restart the server by exiting? Yes when the
|
||||
* Compose file declared it (`CODEMAN_RESTART_BY_EXIT=1`, set only there, since
|
||||
* that file is what sets `restart: unless-stopped`) or when the daemon reports an
|
||||
* auto-restart policy. Otherwise the answer is NO, and the updater stages the
|
||||
* build and asks for a manual restart instead of exiting: an unknown policy is
|
||||
* fine to fail open in the GATE (refusing would block installs with no socket),
|
||||
* but the kill itself must not fail open, or a container the daemon would not
|
||||
* bring back goes down with no UI left to recover it from.
|
||||
*/
|
||||
export function shouldRestartByExit(declared: boolean, restartPolicy: string | null): boolean {
|
||||
return declared || isAutoRestartPolicy(restartPolicy);
|
||||
}
|
||||
|
||||
/** The Compose file's declaration that exiting relaunches this container. */
|
||||
export function restartByExitDeclared(): boolean {
|
||||
return process.env.CODEMAN_RESTART_BY_EXIT === '1';
|
||||
}
|
||||
|
||||
export interface EnvironmentGateInput {
|
||||
/** sha256 of `docker/server.Dockerfile` the running container was built from. */
|
||||
appliedDockerfileHash: string | null;
|
||||
/** sha256 of `docker/server.Dockerfile` at the target release tag. */
|
||||
targetDockerfileHash: string | null;
|
||||
/** sha256 of `docker/docker-compose.yaml` the running container was created from. */
|
||||
appliedComposeHash: string | null;
|
||||
/** sha256 of `docker/docker-compose.yaml` at the target release tag. */
|
||||
targetComposeHash: string | null;
|
||||
/** Keys from `diffRequiredEnvKeys()`. */
|
||||
missingEnvKeys: string[];
|
||||
/** Docker restart policy name of the running container, or null if unknown. */
|
||||
restartPolicy: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PURE gate decision. An in-place container update applies CODE only: the server
|
||||
* exits and the container's restart policy relaunches it on the new `dist/`. A
|
||||
* restart reuses the existing container's image and config, so anything that
|
||||
* changes the ENVIRONMENT cannot take effect that way and is refused here with
|
||||
* the host command that can apply it.
|
||||
*
|
||||
* ⚠️ An unknown hash (null) is NOT treated as "changed": a first update from a
|
||||
* container created before the fingerprint file existed has no baseline, and
|
||||
* failing closed there would block every such install from ever updating. The
|
||||
* baseline is written by `Start-Codeman.sh`, so it exists from the first
|
||||
* host-side start onward. An unknown restart policy is likewise not a blocker —
|
||||
* the shipped Compose file sets `unless-stopped`, and the probe needs the Docker
|
||||
* socket, which a user may not have mounted.
|
||||
*/
|
||||
export function computeEnvironmentBlockers(input: EnvironmentGateInput): EnvironmentBlocker[] {
|
||||
const blockers: EnvironmentBlocker[] = [];
|
||||
|
||||
if (
|
||||
input.appliedDockerfileHash &&
|
||||
input.targetDockerfileHash &&
|
||||
input.appliedDockerfileHash !== input.targetDockerfileHash
|
||||
) {
|
||||
blockers.push({
|
||||
kind: 'dockerfile-changed',
|
||||
message: 'This release changes docker/server.Dockerfile, so the image must be rebuilt.',
|
||||
});
|
||||
}
|
||||
|
||||
if (input.appliedComposeHash && input.targetComposeHash && input.appliedComposeHash !== input.targetComposeHash) {
|
||||
blockers.push({
|
||||
kind: 'compose-changed',
|
||||
message: 'This release changes docker/docker-compose.yaml, so the container must be recreated.',
|
||||
});
|
||||
}
|
||||
|
||||
if (input.missingEnvKeys.length > 0) {
|
||||
blockers.push({
|
||||
kind: 'env-keys-missing',
|
||||
message: `This release adds ${input.missingEnvKeys.length} setting(s) your docker/.env has no value for.`,
|
||||
details: input.missingEnvKeys,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.restartPolicy !== null && !isAutoRestartPolicy(input.restartPolicy)) {
|
||||
blockers.push({
|
||||
kind: 'no-auto-restart',
|
||||
message: `This container's restart policy is "${input.restartPolicy}", so it would not come back after the update.`,
|
||||
});
|
||||
}
|
||||
|
||||
return blockers;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Status file IO
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -273,19 +417,149 @@ export function resolveInstallDir(): string {
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this process runs inside a container. `/.dockerenv` is created by the
|
||||
* Docker daemon itself; the env var is set by our own Compose file so the check
|
||||
* also holds under runtimes that omit that file.
|
||||
*/
|
||||
export function isRunningInContainer(): boolean {
|
||||
return process.env.CODEMAN_IN_CONTAINER === '1' || existsSync('/.dockerenv');
|
||||
}
|
||||
|
||||
function detectInstallKind(dir: string): InstallKind {
|
||||
if (existsSync(join(dir, '.git'))) return 'git';
|
||||
// A container whose code is a bind-mounted checkout updates in place (the pull
|
||||
// and build land on the host filesystem and survive container recreation). A
|
||||
// container WITHOUT that mount runs a baked image copy — a pull there would go
|
||||
// to the writable layer and vanish on the next `up`, so it is not updatable.
|
||||
if (existsSync(join(dir, '.git'))) return isRunningInContainer() ? 'docker-compose' : 'git';
|
||||
// Global npm install ships only dist/ (no src/, no .git).
|
||||
if (!existsSync(join(dir, 'src'))) return 'npm';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/** Install kinds whose update is applied in place by `scripts/self-update.sh`. */
|
||||
export function canSelfUpdateInPlace(kind: InstallKind): boolean {
|
||||
return kind === 'git' || kind === 'docker-compose';
|
||||
}
|
||||
|
||||
/** Path of the fingerprint baseline written by `docker/Start-Codeman.sh`. */
|
||||
const DOCKER_ENV_APPLIED_FILE = dataPath('docker-env-applied.json');
|
||||
|
||||
/** Files whose content defines the container ENVIRONMENT (vs. the app's code). */
|
||||
const DOCKERFILE_REL = 'docker/server.Dockerfile';
|
||||
const COMPOSE_REL = 'docker/docker-compose.yaml';
|
||||
const ENV_EXAMPLE_REL = 'docker/.env.example';
|
||||
const ENV_REL = 'docker/.env';
|
||||
|
||||
function sha256(text: string): string {
|
||||
return createHash('sha256').update(text, 'utf-8').digest('hex');
|
||||
}
|
||||
|
||||
/** Read a file at a git TAG without checking it out (`git show tag:path`). */
|
||||
function gitShowAtTag(repo: string, tag: string, relPath: string): string | null {
|
||||
return tryExec('git', ['show', `${tag}:${relPath}`], repo);
|
||||
}
|
||||
|
||||
function readFileOrNull(path: string): string | null {
|
||||
try {
|
||||
return readFileSync(path, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The fingerprints the RUNNING container was created from, recorded on the host
|
||||
* by `Start-Codeman.sh` at each build/recreate. Returns nulls when absent (a
|
||||
* container started before this file existed) — `computeEnvironmentBlockers()`
|
||||
* deliberately treats an unknown baseline as "not a blocker".
|
||||
*/
|
||||
function readAppliedEnvironmentFingerprints(): { dockerfile: string | null; compose: string | null } {
|
||||
const raw = readFileOrNull(DOCKER_ENV_APPLIED_FILE);
|
||||
if (!raw) return { dockerfile: null, compose: null };
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { dockerfileSha256?: string; composeSha256?: string };
|
||||
return { dockerfile: parsed.dockerfileSha256 ?? null, compose: parsed.composeSha256 ?? null };
|
||||
} catch {
|
||||
return { dockerfile: null, compose: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart policy of the container we're running in, via the mounted Docker
|
||||
* socket. Returns null when the socket or CLI is unavailable — an unknown policy
|
||||
* is not a blocker (see `computeEnvironmentBlockers`).
|
||||
*/
|
||||
function detectOwnRestartPolicy(): string | null {
|
||||
// Docker sets HOSTNAME to the short container id; os.hostname() is the same
|
||||
// value when the env var is absent. A custom `hostname:` in the compose file
|
||||
// makes both unresolvable to the daemon, which fails open (unknown is not a
|
||||
// blocker) rather than refusing an update over a cosmetic setting.
|
||||
const id = process.env.HOSTNAME || hostname();
|
||||
if (!id) return null;
|
||||
const out = tryExec('docker', ['inspect', '--format', '{{.HostConfig.RestartPolicy.Name}}', id]);
|
||||
return out && out.length > 0 ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate the environment gate for a candidate release tag. Reads the TARGET
|
||||
* tag's files straight out of git (`git show`), so nothing is checked out and the
|
||||
* answer is available at CHECK time — the UI can refuse before the user commits
|
||||
* to an update.
|
||||
*/
|
||||
export function evaluateEnvironmentGate(installDir: string, tag: string): EnvironmentGate {
|
||||
// `git show <tag>:<path>` needs the tag's objects locally, and neither the
|
||||
// GitHub API nor `ls-remote` fetches anything — so a check that has never seen
|
||||
// this tag would read nothing and report a falsely clean gate. Fetch the one
|
||||
// ref first (cheap: it deltas against what the clone already has) and only
|
||||
// then read. The updater fetches the same ref again; both are idempotent.
|
||||
if (tryExec('git', ['rev-parse', '--verify', '--quiet', `${tag}^{commit}`], installDir) === null) {
|
||||
tryExec(
|
||||
'git',
|
||||
['fetch', '--tags', '--force', 'origin', `refs/tags/${tag}:refs/tags/${tag}`],
|
||||
installDir,
|
||||
CHECK_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
const targetDockerfile = gitShowAtTag(installDir, tag, DOCKERFILE_REL);
|
||||
const targetCompose = gitShowAtTag(installDir, tag, COMPOSE_REL);
|
||||
const targetExample = gitShowAtTag(installDir, tag, ENV_EXAMPLE_REL);
|
||||
|
||||
// No environment files at the target tag at all: we cannot judge, so say so
|
||||
// rather than reporting a clean gate the caller would trust.
|
||||
if (targetDockerfile === null && targetCompose === null && targetExample === null) {
|
||||
return { checked: false, blockers: [], hostCommand: DOCKER_HOST_UPDATE_COMMAND };
|
||||
}
|
||||
|
||||
const applied = readAppliedEnvironmentFingerprints();
|
||||
const userEnv = readFileOrNull(join(installDir, ENV_REL));
|
||||
|
||||
const blockers = computeEnvironmentBlockers({
|
||||
appliedDockerfileHash: applied.dockerfile,
|
||||
targetDockerfileHash: targetDockerfile === null ? null : sha256(targetDockerfile),
|
||||
appliedComposeHash: applied.compose,
|
||||
targetComposeHash: targetCompose === null ? null : sha256(targetCompose),
|
||||
// A missing/unreadable .env cannot be diffed — report no missing keys rather
|
||||
// than every key, which would block on an install using a non-standard path.
|
||||
missingEnvKeys: targetExample !== null && userEnv !== null ? diffRequiredEnvKeys(targetExample, userEnv) : [],
|
||||
restartPolicy: detectOwnRestartPolicy(),
|
||||
});
|
||||
|
||||
return { checked: true, blockers, hostCommand: DOCKER_HOST_UPDATE_COMMAND };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which init system supervises us. Detection happens HERE (in the running
|
||||
* server, which has a rich env) and the result is passed to the updater script —
|
||||
* the detached child must not re-probe with a stripped-down environment.
|
||||
*/
|
||||
export function detectSupervisor(): SupervisorKind {
|
||||
// Checked FIRST: a container has no init system of its own, and its "restart"
|
||||
// is the server exiting so the Docker restart policy relaunches it. Probing
|
||||
// systemd here would find nothing and report `none`, which stages the update
|
||||
// and then asks the user to restart by hand for no reason.
|
||||
if (isRunningInContainer()) return 'docker-compose';
|
||||
if (process.platform === 'darwin') {
|
||||
if (existsSync(join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`))) return 'launchd';
|
||||
// Headless Macs (no GUI login → no gui domain) run Codeman as a system-level
|
||||
@@ -389,17 +663,29 @@ export async function checkForUpdate(): Promise<UpdateCheckResult> {
|
||||
checkedAt,
|
||||
source: 'none',
|
||||
};
|
||||
if (info.installKind !== 'git') {
|
||||
return { ...base, error: 'Not a git install — self-update is unavailable.' };
|
||||
if (!canSelfUpdateInPlace(info.installKind)) {
|
||||
return {
|
||||
...base,
|
||||
error:
|
||||
info.installKind === 'unknown' && isRunningInContainer()
|
||||
? 'This container runs a baked image copy with no repository mounted — self-update is unavailable. See docs/docker-self-update.md.'
|
||||
: 'Not a git install — self-update is unavailable.',
|
||||
};
|
||||
}
|
||||
|
||||
/** Attach the container environment gate to a finished check result. */
|
||||
const withGate = (result: UpdateCheckResult): UpdateCheckResult => {
|
||||
if (info.installKind !== 'docker-compose' || !result.latestTag || !result.updateAvailable) return result;
|
||||
return { ...result, environment: evaluateEnvironmentGate(info.installDir, result.latestTag) };
|
||||
};
|
||||
|
||||
const remote = tryExec('git', ['remote', 'get-url', 'origin'], info.installDir);
|
||||
const gh = remote ? parseGitHubRepo(remote) : null;
|
||||
|
||||
if (gh) {
|
||||
const rel = await fetchLatestReleaseFromGitHub(gh.owner, gh.repo);
|
||||
if (rel) {
|
||||
return {
|
||||
return withGate({
|
||||
...base,
|
||||
latestVersion: rel.version,
|
||||
latestTag: rel.tag,
|
||||
@@ -407,20 +693,20 @@ export async function checkForUpdate(): Promise<UpdateCheckResult> {
|
||||
htmlUrl: rel.htmlUrl,
|
||||
updateAvailable: isNewerStableVersion(info.currentVersion, rel.version),
|
||||
source: 'github-api',
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: enumerate remote tags directly (works for non-GitHub remotes too).
|
||||
const viaGit = fetchLatestTagViaGit(info.installDir);
|
||||
if (viaGit) {
|
||||
return {
|
||||
return withGate({
|
||||
...base,
|
||||
latestVersion: viaGit.version,
|
||||
latestTag: viaGit.tag,
|
||||
updateAvailable: isNewerStableVersion(info.currentVersion, viaGit.version),
|
||||
source: 'git-ls-remote',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return { ...base, error: 'Could not reach the update server (GitHub API + git ls-remote both failed).' };
|
||||
@@ -432,7 +718,11 @@ export async function checkForUpdate(): Promise<UpdateCheckResult> {
|
||||
|
||||
export type StartUpdateResult =
|
||||
| { ok: true; updateId: string; toTag: string; toVersion: string | null }
|
||||
| { ok: false; code: 'disabled' | 'not-git' | 'in-flight' | 'up-to-date' | 'bad-tag' | 'error'; message: string };
|
||||
| {
|
||||
ok: false;
|
||||
code: 'disabled' | 'not-git' | 'in-flight' | 'up-to-date' | 'bad-tag' | 'env-blocked' | 'error';
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy the updater script OUT of the repo before running it. The script lives in
|
||||
@@ -497,11 +787,13 @@ export async function startUpdate(): Promise<StartUpdateResult> {
|
||||
if (!info.selfUpdateEnabled) {
|
||||
return { ok: false, code: 'disabled', message: 'Self-update is disabled (CODEMAN_DISABLE_SELF_UPDATE=1).' };
|
||||
}
|
||||
if (info.installKind !== 'git') {
|
||||
if (!canSelfUpdateInPlace(info.installKind)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'not-git',
|
||||
message: 'This is not a git install. Update with: npm i -g aicodeman@latest',
|
||||
message: isRunningInContainer()
|
||||
? 'This container has no repository mounted. Update from the host with docker/Start-Codeman.sh.'
|
||||
: 'This is not a git install. Update with: npm i -g aicodeman@latest',
|
||||
};
|
||||
}
|
||||
const existing = readUpdateStatus();
|
||||
@@ -517,6 +809,20 @@ export async function startUpdate(): Promise<StartUpdateResult> {
|
||||
return { ok: false, code: 'bad-tag', message: `Refusing to update to an unrecognized tag: ${check.latestTag}` };
|
||||
}
|
||||
|
||||
// Re-evaluate rather than trusting the check the browser saw: the UI hides the
|
||||
// button when the gate blocks, but the endpoint is reachable directly and the
|
||||
// release could have moved between the check and the click.
|
||||
if (info.installKind === 'docker-compose') {
|
||||
const gate = evaluateEnvironmentGate(info.installDir, check.latestTag);
|
||||
if (gate.blockers.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'env-blocked',
|
||||
message: `${gate.blockers.map((b) => b.message).join(' ')} Run ${gate.hostCommand} on the Docker host to apply this release.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const prevSha = tryExec('git', ['rev-parse', 'HEAD'], info.installDir);
|
||||
const runner = stageRunner(info.installDir);
|
||||
if (!runner) {
|
||||
@@ -558,11 +864,18 @@ export async function startUpdate(): Promise<StartUpdateResult> {
|
||||
process.execPath,
|
||||
'--log',
|
||||
logFile,
|
||||
// For the launchd-daemon restart path: the updater kills this PID and the
|
||||
// KeepAlive daemon respawns the server on the freshly built dist/.
|
||||
// For the launchd-daemon and docker-compose restart paths: the updater kills
|
||||
// this PID and the supervisor (KeepAlive daemon / Docker restart policy)
|
||||
// respawns the server on the freshly built dist/.
|
||||
'--server-pid',
|
||||
String(process.pid),
|
||||
];
|
||||
if (info.supervisor === 'docker-compose') {
|
||||
// Decided HERE, where the Docker socket and the Compose env are reachable;
|
||||
// the updater only reads the answer. Without a yes it never exits the server.
|
||||
const byExit = shouldRestartByExit(restartByExitDeclared(), detectOwnRestartPolicy());
|
||||
args.push('--restart-by-exit', byExit ? '1' : '0');
|
||||
}
|
||||
if (prevSha) args.push('--prev-sha', prevSha);
|
||||
if (info.dirty) args.push('--stash');
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user