diff --git a/CHANGELOG.md b/CHANGELOG.md index f055d023..c9600c4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # aicodeman +## 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 `` 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 diff --git a/CLAUDE.md b/CLAUDE.md index a2fdb8de..6ba242f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,7 @@ When user says "COM": CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed. -**Version**: 1.24.7 (must match `package.json`) +**Version**: 1.25.0 (must match `package.json`) ## Project Overview @@ -99,6 +99,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | Dev with TLS | `npx tsx src/index.ts web --https` | | Override window title hostname | `npx tsx src/index.ts web --title-hostname ` (default: `os.hostname()` — `codeman:` is used for tab title, title-flash, and OS desktop notification prefix) | | Bind a non-loopback host | `npx tsx src/index.ts web --host 0.0.0.0` (or `-H`; env `CODEMAN_HOST`; default `127.0.0.1`). Without `CODEMAN_PASSWORD` it **starts but warns loudly** — see Common Gotchas + `docs/security-architecture.md` | +| Mount under a reverse-proxy sub-path | `npx tsx src/index.ts web --base-url /codeman` (env `CODEMAN_BASE_URL`; default `/`). Normalized in `src/config/base-path.ts` (`''` = root). See Reverse-proxy base path below + `docs/wiki/Remote-Access.md` | | Continuous typecheck | `tsc --noEmit --watch` | | Watch-mode test | `npm run test:watch -- test/.test.ts` (runs the CI gate's config; pass a file to narrow it) | | Test coverage | `npm run test:coverage` | @@ -114,6 +115,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | Detached server | `codeman web -d` (`--status`, `--stop`; pidfile+log at `dataPath('web.pid'/'web.log')`). ⚠ Refuses to start a 2nd server on one data dir — see Instance isolation | | Install/remove the service | `codeman service install` / `status` / `uninstall` (systemd user unit on Linux, LaunchAgent on macOS; names from `config/service-names.ts`) | | Dependency doctor | `codeman doctor` (alias `check-deps`; `--json`, `--category core\|office\|other`). Probes Node/Claude CLI/tmux/LibreOffice/MS Office against `config/dependency-registry.ts`; engine is pure given an injectable `ProbeHost` | +| PR review bot (maintainer) | `npm run pr-bot -- check` / `scan` / `review [--no-telegram]` / `run` / `install-service`. Reviews open PRs in Codeman sessions and reports over Telegram; `docs/pr-bot.md` | | Multi-user accounts | `codeman users add ` / `passwd ` / `list` / `rm ` (writes `~/.codeman/users.json`, mode 0600; see Multi-user mode) | **CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and 5 Playwright tests; globs live in `config/test-suites.ts`). `npm test` runs this same config, so local green == CI green. Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing). @@ -209,7 +211,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Remote sessions + remote SSH cases**: a case can point at a remote host. The agent runs inside a durable remote `tmux -L codeman-remote` (session name `codeman-ssh-`, deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so an instance on the target host never adopts it), fronted by a LOCAL tmux pane running `ssh`. Attached (`owned:false`) sessions **detach, never kill** on tab close; owned ones propagate `kill-session`. A bounded-backoff watcher auto-reconnects dropped sessions (`remoteAutoReconnect`, default ON). ⚠️ **It revives ONLY when the durable remote tmux session is verifiably still alive** (`remoteTmuxSessionAlive()`, a `has-session` probe over ssh, #355): a clean agent exit (Ctrl-C, Ctrl-D, `exit`) tears that session down, and `isPaneDead()` cannot tell it from a transport drop, so the watcher used to relaunch a FRESH agent after every clean exit (claude only looked fine because its `|| --resume` fallback masked it). An unreachable host answers `undefined`, which also means do not revive. ⚠️ `has-session` prints NOTHING on success, so the probe is classified by EXIT STATUS (`classifyRemoteAliveExit`: 0 alive, ssh's 255 or a timeout unknown, anything else gone); reading stdout classified every live session as gone and silently disabled transport-drop reconnects. The answer is cached per session and forgotten whenever the pane is seen alive again, or a stale `true` from one transport drop would revive the next clean exit. ⚠️ **Command-injection surface: every ssh command line must flow through `buildSshConnectionArgs()`**, which `shellescape`s every user field. Never hand-build an ssh line elsewhere. ⚠️ Run flows must route remote cases through `POST /api/quick-start`, not `POST /api/sessions` (which stat-validates `workingDir` locally and has no `caseName`). → [architecture-invariants#remote-sessions-over-ssh](docs/architecture-invariants.md#remote-sessions-over-ssh), [#remote-ssh-cases](docs/architecture-invariants.md#remote-ssh-cases), `docs/remote-sessions.md` -**Docker cases**: a case can point at a **container**, with any of the CLI run modes running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a `SessionMode` of its own**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design) +**Docker cases**: a case can point at a **container**, with any of the CLI run modes running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a `SessionMode` of its own**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ A case may instead **ADOPT** a container the user already runs (`DockerCase.owned === false`, mirror of remote-SSH's `owned:false`): Codeman only `exec`s into it and never creates, starts, stops, restarts or removes it, so a missing or stopped container FAILS CLOSED with an actionable message instead of being fixed. Absent = owned, so existing cases are byte-identical. The guarantee is enforced at four independent layers because it cannot be observed by using the feature: `buildDockerStopCommand`/`buildDockerRemoveCommand` throw during pure STRING CONSTRUCTION, `removeDockerContainer` refuses again, drift reports "none" (an adopted container carries no `codeman.confighash` label, so a real comparison would 409 the launch forever), and the boot reaper skips it. ⚠️ Two lifecycle touches the original design missed and that are easy to re-introduce: the full-image export `docker commit`s the container (refused for an adopted case) and the workspace export `docker pause`s it first (skipped — it freezes the owner's processes for the length of the tar). ⚠️ `owned` is applied AFTER `dockerConfigHash`, which takes an explicit field list, or every pre-existing case would trip the drift gate at once. ⚠️ Run modes for a container case come from the CONTAINER (`availableModes`, live-probed): gating the run menu on HOST CLIs (#201) is right for local sessions and wrong here, since a host with no `claude` may run a container that ships one. ⚠️ **A failed probe means opposite things per ownership** — for an ADOPTED case it is a fault worth reporting, for an OWNED one it is the NORMAL state before the first session (the launch chain creates the container), so treating it as a fault hid every agent mode on every freshly linked Docker case behind "start it yourself first". That is why `CaseInfo.docker.owned` is on the wire. ⚠️ Claude is launched WITHOUT `--dangerously-skip-permissions` when the container's exec user is root (Claude Code refuses the flag as root and the refusal is visible only inside the container); which flag to drop is a per-CLI fact, so it is the registry's `overlays.docker.rootCommand`, never a branch. ⚠️ Adoption is **admin-only in multi-user mode**, unlike `docker-link`: linking creates OUR container, whose one bind mount `isWorkingDirAllowed` has already confined, while an adopted container's mounts belong to its owner and one mounting `/` hands the adopter the host. The same reasoning admin-gates the container listing and the in-container directory browser; the preflight instead admits a non-admin for a container already linked to a case they own, because the run menu probes it for every docker case. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design) **Docker Compose deployment** (`docker/`, contributed): Codeman itself runs in a container and spawns Docker cases as **SIBLING** containers through the mounted host socket (Docker-outside-of-Docker), never nested. That inverts one assumption the bare-host path takes for granted: the daemon no longer shares Codeman's filesystem, so a bind source valid *inside* Codeman means nothing to it. `resolveDockerDaemonMountSource()` translates sources under HOME into the daemon's namespace via `CODEMAN_DOCKER_HOST_HOME`, and `CODEMAN_CASES_PATH` points the cases dir at a host-absolute bind mount so a workspace resolves to the SAME absolute path on both sides (which is what keeps the transcript projHash matching, per Docker cases above). ⚠️ **`CODEMAN_CASES_PATH` must move every consumer or none**: it is resolved once in `config/cases-dir.ts` because `src/cli.ts` resolves case paths too, and when only the server's `CASES_DIR` learned the override, `codeman skill install --case ` reported "Case not found" on exactly the deployment the override exists for. ⚠️ **`.dockerignore` patterns match the WHOLE context-relative path**, so a bare `.env` line excludes only the ROOT file: `docker/.env` (which holds `CODEMAN_PASSWORD` and any provider keys) rode `COPY . .` into the image until `**/.env` was added — verified in both directions with a real build context. ⚠️ A Compose LONG-form bind (`type: bind`) **creates a missing host source directory ROOT-OWNED** rather than refusing, so any bind source the runtime user must write to has to be pre-created and chowned. `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1` drops `--memory-swap` (and filters only that one kernel warning) for hosts without swap accounting; `--memory` still applies. ⚠️ The deployment ALSO self-updates in place (the repo bind mount at `/opt/codeman` + a restart-by-exiting supervisor) — see Self-update below and `docs/docker-self-update.md` before touching `server.Dockerfile`, the compose file or `.env.example`, since each is an input to the updater's environment gate. `docs/docker-compose.md` + `docker/README.md` (user guides) @@ -223,11 +225,13 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name the session that spawned it, as a `parentSessionId` body field on `POST /api/sessions` / `POST /api/quick-start` or the `X-Codeman-Parent-Session` header (the agent skill sets that once on its shared curl invocation, so every spawn recipe carries it). `resolveParentSessionId()` (route-helpers.ts) **resolves rather than trusts** it: exact id, else a UNIQUE ≥8-char prefix (ids reach agents truncated), it must be a live session the caller can see AND carry the same owner, and **anything unresolvable is DROPPED, never a 400** — a cosmetic field must not be able to fail a worker spawn. It rides `toState()` into `session_created`, so there is no new SSE event. ⚠️ Rendering is an ADDITIONAL LAYER on the existing SVG pass (`_appendLineageConnectionLines` called at the tail of `_updateConnectionLinesImmediate()`, exactly like ultracode), sharing one batched read→write reflow and the `tab:` rect cache; geometry is pure in `computeLineagePath()` (constants.js). ⚠️ **ONE shape, and the second one was the bug**: every pair (flat strip or wrapped) gets a U-bridge hanging below the strip, anchored on both tabs' BOTTOM edges. A wrapped strip used to get a parent-bottom → child-TOP bezier with a ~14px row gap to bend in, which drew a flat line hidden in the gap with siblings overprinting. ⚠️ The dip is a **mis-tuned-in-both-directions corridor** (44px cap = straight thread at strip-wide spans, #285; 104px cap + full row offset = ~106px over-bow into the terminal, 2026-08-15): it now hangs from the **STRIP's bottom edge** (fallback: lower tab bottom), capped at 64px, with NO per-row offsets stacked on top — the strip-bottom baseline is also what keeps a row-1 pair's arc from drawing through row 2's tab labels. ⚠️ **Colors are keyed on the SPAWNING tab, not per child**: every arc leaving one tab is the same color however many workers it spawns, so the strip reads as "these five came from w1, those two came from w2" — per-child coloring gave one tab's own children a different color each, which is the distinction the colors exist to make. A child that spawns in turn is a parent in its own right and gets its own color for the arcs below it, so a chain changes color at each generation while each generation's fan-out stays uniform. Assignment cycles `CodemanLineage.COLORS` in first-seen order per parent id (first entry empty = the skin-tuned `--session-blue`, so the first spawning tab keeps it; the rest vivid fixed hexes), memoized rather than derived from draw index (the SVG is wiped and rebuilt constantly, so an index-based color would flicker), and set inline as `--lineage-color` so styles.css keeps owning opacity/glow/dash. `test/session-lineage-lines.test.ts` drives the real `_appendLineageConnectionLines()` and asserts the painted property, since testing the color function alone would pass just as happily with the child id passed back in. ⚠️ **Desktop only**: the overlay is `z-index: 999` and the desktop header is 100 (arcs paint over it, which is what lets them touch tab bottoms), but under 1024px mobile.css makes the header `fixed; z-index: 1200` and would bury them. ⚠️ Paths carry `data-agent-id="lineage:"` because that is what `_applyLineEntrances()` queries — that one attribute is what gives them the entrance animation and its negative-`animation-delay` resume across `svg.innerHTML=''`. ⚠️ `.session-tabs` is `overflow-x: auto`, so a scrolled-out tab still HAS a rect (over the logo); edges with an endpoint outside the strip are skipped, and a passive `scroll` listener re-anchors the rest. +**PR bot** (`scripts/pr-bot/`, maintainer tooling, NOT part of the server; `docs/pr-bot.md`): a daemon (`codeman-pr-bot` user unit) that lists open PRs with `gh`, reviews each head commit once in a Codeman claude session named `prbot-` running in a private `git clone --shared` under `~/.codeman/pr-bot/worktrees/` (a clone, NOT a linked worktree: Claude Code reads a linked worktree's project settings from the MAIN checkout, so its `opus[1m]` pin silently overrode the bot's `modelOverride`, measured), and reports verdict + ranked findings + recommendation to Telegram with buttons. ⚠️ It reviews on its own but **never writes to GitHub on its own**: merge / close / post-comment / approve-CI happen only from a Telegram command or button from the configured chat, and merge/close/post take a second confirmation tap (`runConfirmed` in `bot.ts` is the one write site). ⚠️ The shared checkout is never checked out or reset by it (it only fetches into `refs/pr-bot/`, which also anchors the clone's objects against gc), a clone's `node_modules` is a SYMLINK into the main checkout unless the PR changes the lockfile (then the link is unlinked before `npm ci`), and `src/web/public/vendor` is copied per file, never linked, because postinstall regenerates it in place. Readiness/end-of-turn follow the codeman skill's rules (composer first, trust dialog read off the screen, `stop,blocked,exit` never `idle`). The Telegram token + chat id come from the existing notifier bot's `~/codeman-cases/telegram/.env`. Type-checked via `config/tsconfig.pr-bot.json` (part of `npm run typecheck`), linted/formatted with `src/`; tests `test/pr-bot-report.test.ts`, `test/pr-bot-state.test.ts`, `test/pr-bot-commands.test.ts` (the confirm-before-write flows against stubbed `gh`/Telegram). + **Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). Transcript rows fold into their owning session via a `claudeSessionId → Codeman id` alias map, so resumed and `/clear`-respawned sessions do not appear twice. No terminal buffers in the response, unlike `/api/sessions`. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (`PUT /api/session-order`; pure merge helpers in `src/session-order.ts`, pushing device wins and server-only ids are never dropped). → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager) **Owner tab layouts** (COD-359, `tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat tab strip, scoped per owner (`SINGLE_USER_LAYOUT_OWNER` = `@single` when multi-user is off), persisted under the `tabLayouts` key in state.json. A layout is `{version, groups[], ungrouped[], updatedAt}` whose refs point at either a session or a saved webview (`TabRefKind`), capped at 32 groups / 512 refs. ⚠️ **BACKEND ONLY as of 1.24.1**: nothing in `src/web/public/` calls these routes yet, so a UI built on top is new frontend work, not a rewiring job. ⚠️ **`TabLayoutService` is the single mutation boundary** and every lifecycle caller (session created/removed, webview created/deleted, a legacy order PUT) describes ONE completed server action and gets AT MOST ONE versioned write; writing layout state from a route or a manager directly is what the service exists to prevent. ⚠️ The layout does not replace `PUT /api/session-order`, it PROJECTS onto it: `tab-layout-legacy-order.ts` is the pure translation both ways (`putLegacyOrder()` recomposes a global order from the owner's groups), so changing one side without the other silently desyncs the tab strip from the stored layout. ⚠️ **Reconciliation is gated on a SUCCESSFUL restore** (`markRestorationComplete` / `markRestorationFailed` / `markRestorationSkipped`, plus `assertDeletionReady()`): pruning refs against a session list that failed to load would delete live tabs, so a failed restore must leave the layout untouched. `PUT` takes exactly `{baseVersion, layout}` (any other key shape is a validation error), answers a stale `baseVersion` with the current layout rather than clobbering, and is capped at 128 KiB. Broadcasts `tab:layoutChanged`, owner-routed via `deriveTabLayoutSseHint`. -**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`. ⚠️ **Every claude session INSTALLS the hooks block into its workspace** (`applyWorkspaceHooks` in hooks-config.ts → `ensureCodemanHooks`, an add-only merge that keeps a user's own handlers), from EVERY claude create path — both interactive routes, cron fires, legacy scheduled runs, the plan-orchestrator one-shots — and from `restoreMuxSessions()` for sessions recovered on server start (that boot sweep skips a workspace that no longer exists, so a deleted repo with a surviving tmux session is never resurrected as an empty dir). Before 2026-08-15 hooks were written ONLY when Codeman created the case DIRECTORY, so a linked case / cloned repo — where most sessions actually run — had no hooks at all and every hook-driven surface was silently dead there: an AskUserQuestion dialog blocked the pane while the tab and the phone overview both read a calm `idle`, with no Approvals Inbox item, no push, no definitive `stop`/`idle_prompt` for respawn and no `stop`/`blocked` for the wait endpoints. The escape hatch is the synced `workspaceHooksEnabled` setting (App Settings → Agents & CLIs → Claude, **default ON**); OFF restores the old behavior, where a Codeman block that is already there is still refreshed when stale (COD-91) but one is never added. ⚠️ Route the decision through `applyWorkspaceHooks` rather than calling `ensureCodemanHooks` at a new site, or the setting silently stops applying to that path. ⚠️ Claude Code RE-READS `settings.local.json`, so an already-running session starts firing hooks without a restart (measured 2026-08-15) — and the notification for a blocking dialog is delayed by Claude Code (~30s), so the alert trails the dialog. ⚠️ An AskUserQuestion / plan-selection dialog arrives as **`permission_prompt`**, not `elicitation_dialog` (that one is MCP elicitation), so it renders as the RED "needs you" alert, not the yellow idle one. +**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted` (UserPromptSubmit, #367: a Claude pane reports its live conversation id first-hand). See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`. ⚠️ **Every claude session INSTALLS the hooks block into its workspace** (`applyWorkspaceHooks` in hooks-config.ts → `ensureCodemanHooks`, an add-only merge that keeps a user's own handlers), from EVERY claude create path — both interactive routes, cron fires, legacy scheduled runs, the plan-orchestrator one-shots — and from `restoreMuxSessions()` for sessions recovered on server start (that boot sweep skips a workspace that no longer exists, so a deleted repo with a surviving tmux session is never resurrected as an empty dir). Before 2026-08-15 hooks were written ONLY when Codeman created the case DIRECTORY, so a linked case / cloned repo — where most sessions actually run — had no hooks at all and every hook-driven surface was silently dead there: an AskUserQuestion dialog blocked the pane while the tab and the phone overview both read a calm `idle`, with no Approvals Inbox item, no push, no definitive `stop`/`idle_prompt` for respawn and no `stop`/`blocked` for the wait endpoints. The escape hatch is the synced `workspaceHooksEnabled` setting (App Settings → Agents & CLIs → Claude, **default ON**); OFF restores the old behavior, where a Codeman block that is already there is still refreshed when stale (COD-91) but one is never added. ⚠️ Route the decision through `applyWorkspaceHooks` rather than calling `ensureCodemanHooks` at a new site, or the setting silently stops applying to that path. ⚠️ Claude Code RE-READS `settings.local.json`, so an already-running session starts firing hooks without a restart (measured 2026-08-15) — and the notification for a blocking dialog is delayed by Claude Code (~30s), so the alert trails the dialog. ⚠️ An AskUserQuestion / plan-selection dialog arrives as **`permission_prompt`**, not `elicitation_dialog` (that one is MCP elicitation), so it renders as the RED "needs you" alert, not the yellow idle one. **Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). ⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). ⚠️ Resolution on the heuristic `working` signal ALONE is restricted to `idle` items; a permission/question item gets the pane-VERIFIED variant on that same signal (`resolveIfDialogGone()` → `verifyStillAnswerable()`), so the heuristic only decides when to LOOK and the screen decides the outcome. That is what clears a dialog answered in the terminal mid-turn; the other definitive signals are `stop`, `elicitation_complete`/`elicitation_response`, exit/delete, answer, supersede and the 12h TTL. ⚠️ **Viewing a session ACKNOWLEDGES its idle item, it does not resolve it** (`POST /api/approvals/session/:sessionId/viewed` → `acknowledgedAt` → `approval:updated`): the item stays pending (still answerable, still Read My Mind context) and only stops arming the yellow tab alert. That flag is what makes the clear durable, since the view-clears-idle rule used to live in one browser's memory and `seedApprovals()` re-armed the alert on the next reload while other devices never heard about it at all; the local half is `markIdleAlertSeen()` (app.js), called from BOTH `selectSession` paths, including the already-active early return, where a click could otherwise never clear the alert. ⚠️ **Only a HUMAN opening a session acknowledges**: `selectSession(id, { auto: true })` marks the three selections the APP makes (boot restore, a solo window opening its target, the fallback after the active session is closed) and skips the acknowledgement, so a page load cannot silently spend an alert the user never saw. The flag defaults to user-initiated, so an untagged call site fails toward acknowledging rather than toward an alert nothing can clear; `test/session-select-ack-gate.test.ts` pins both the gate and the tagged call sites. Idle-only by construction (`acknowledge()` defaults to `['idle']`): looking at a permission/question dialog does not answer it. ⚠️ Same rule on the input path: `_ackDelivery` (app.js) spends the IDLE alert only, via that same `markIdleAlertSeen()`. It used to `clearPendingHooks(sessionId)` with no kind, so one keystroke wiped a RED alert on that device while the dialog was still up, the other devices stayed red, and a reload re-seeded it. ⚠️ Claude Code fires no "permission answered" hook (only `elicitation_complete`/`elicitation_response`, i.e. the question flavor), so an answered-in-the-terminal dialog would otherwise sit pending until `stop`: `GET /api/approvals` therefore runs a **staleness sweep** over the caller's own items via `verifyStillAnswerable()`, which is deliberately the conservative check the answer path uses (only an item whose ORIGINAL frame parsed options can be dropped, so an unreadable capture keeps the alert rather than losing a live one). ⚠️ **`applyCapture()` is therefore ADD-ONLY for `options`**: a re-capture that parses nothing must never erase a parse an earlier one found. Claude Code delays the Notification hook behind the dialog (measured 6s, documented ~30s), so the 600ms re-capture routinely lands on a frame the user has ALREADY answered; clearing the field there made the item permanently unsweepable, because `verifyStillAnswerable()` reads a MISSING `options` as "we never could read this dialog" and keeps such items answerable by design. The red "needs you" then survived every sweep AND every page reload, went away only on `stop` (2026-08-20: a confirmed question left a tab flowing red for ~8 minutes while the turn ran on), and the stale card still accepted an answer, typing a bare `1` into a composer with no dialog under it. Pinned by `test/approval-inbox.test.ts`. ⚠️ A frame that parses no options is CONCLUSIVE in exactly two cases, and the second one closes the late-hook hole: the item once parsed options (they cannot vanish while the dialog is up), or the frame shows Claude actively running a turn. A modal dialog BLOCKS the turn, so the two cannot coexist — measured on v2.1.237, a live-dialog frame carries neither the `… (13s` timer NOR the `esc to interrupt` footer, which the dialog replaces with `Enter to select · ↑/↓ to navigate · Esc to cancel`. Anything else stays answerable, so an unreadable capture still keeps the alert. That second signal is reached by a delayed staleness pass (`STALE_CHECK_DELAY_MS`, 3s) scheduled alongside the re-capture, because a prompt answered BEFORE the hook lands creates an item whose FIRST capture already has no dialog in it: nothing ever parsed, `stop` may have fired already, and the alert then outlived reloads until the 12h TTL. ⚠️ That pass must stay comfortably LATER than `RECAPTURE_DELAY_MS`, whose whole reason for existing is that the hook can beat Ink to the screen — resolving inside the paint window would clear the alert for a dialog that was about to appear. The frontend seeds from `GET /api/approvals` in `handleInit` **regardless of the setting**: the seed re-arms the tab-alert state machine (`setPendingHook`) unconditionally, and only populating `this.approvals` (the inbox surfaces) is gated — seeding used to be gated wholesale, which left a reloaded page with NO red tab while a permission dialog sat blocking a session (2026-08-15); `_onApprovalResolved` clears the pending-hook alert unconditionally for the same reason. ⚠️ The red/yellow tab alert itself is a STEADY border/background/dot with a pulse on top: the original keyframes swung to transparent at 0%/100%, so half of every cycle looked like a normal tab. Push Approve/Deny buttons stay gated on the setting (`sendPushNotifications` strips `actions`/`approvalId` when OFF) and are answered from `sw.js` directly so they work with no tab open. Surfaces (all gated on the setting): header bell (marker-hidden until count > 0, phones never show it) + drawer (`approvals-ui.js`), phone overview NEEDS YOU answer strips (`mobile-overview.js`). Design: `docs/approvals-inbox-plan.md`. @@ -250,6 +254,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Self-update** (App Settings → System → Updates): in-app updater for git-clone installs supervised by systemd/launchd (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none` → "restart manually"). The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` that outlives the restart and writes progress to `update-status.json`, which the browser polls across the connection drop. `src/web/self-update.ts` splits pure helpers (unit-tested) from IO wrappers. npm installs report as non-updatable. ⚠️ **The Compose deployment is the one supervisor that does NOT outlive the restart**: there the restart IS the container exiting (`restart: unless-stopped` relaunches it), which kills the script too — safe only because the terminal `restarting` marker is written BEFORE the kill, so nothing may be appended after it. Two config facts make it work at all and both are load-bearing: the repo is a HOST BIND MOUNT over `/opt/codeman` (a pull into the baked image copy would land in the writable layer and be silently discarded by the next `up`), and the runtime image keeps devDependencies + a build toolchain (`npm run build` is tsc+esbuild, and node-pty has no Linux prebuild), which is why `npm prune --omit=dev` is gone and the updater passes `--include=dev` against `NODE_ENV=production`. ⚠️ An in-place container update applies CODE ONLY — a restart reuses the existing image and config — so `evaluateEnvironmentGate()` REFUSES a release that changes `server.Dockerfile`/`docker-compose.yaml` (sha256 vs the baseline `Start-Codeman.sh` writes to `docker-env-applied.json` on every start) or adds `.env.example` keys the user's `.env` lacks, and refuses when the restart policy would not bring the container back. That third check exists because **Compose resolves an unset `${VAR}` to the EMPTY STRING and starts anyway**, so a new required setting otherwise arrives as a silently blank env var. Every unknown fails OPEN in the gate (no baseline, unreadable `.env`, no socket): failing closed would permanently block containers created before the fingerprint file existed. ⚠️ The KILL does not: the server exits only when `--restart-by-exit 1` was passed, i.e. the Compose file declared `CODEMAN_RESTART_BY_EXIT=1` (set ONLY there, since that file is what sets `restart: unless-stopped`; the image ENV deliberately does not) or the daemon reported an auto-restart policy; otherwise the build lands as `completed-needs-manual-restart`, because exiting blind takes a `docker run` container with no restart policy down with no UI left to recover it. The gate is re-evaluated on `POST /api/system/update`, so hiding the button is UX, not the control. ⚠️ The four global agent CLIs in `server.Dockerfile` are PINNED on purpose — unpinned, a user's CLI versions are a function of when their image was built rather than of any commit, which is the one environment change no diff-derived gate can see; pinning turns it into a Dockerfile change the gate already catches. `test/docker-compose-env-parity.test.ts` is the merge-side guard (every compose `${VAR}` ↔ an `.env.example` entry). → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update) +**Reverse-proxy base path** (`--base-url` / `CODEMAN_BASE_URL`, default `/`; `src/config/base-path.ts` is the pure single-source, normalized to `''` for root or `/foo`): lets Codeman be mounted under a sub-path behind a proxy that **forwards the prefix unchanged** (does NOT strip it). Deliberately few choke points, mirrored ingress/egress: **(server ingress)** `stripBasePath()` runs inside Fastify's `rewriteUrl` so ALL routes stay declared prefix-agnostic (`/api/...`, `/ws/...`) — and a request arriving WITHOUT the prefix (hooks, health checks, docker bridge, all hitting the raw port) is left untouched, so the server answers at both; **(server egress)** one `onSend` hook prepends the base to every root-absolute `Location` header, covering all redirects; **(HTML)** `renderIndexHtml` rewrites the shipped `` to the mount and injects `window.__CODEMAN_BASE__` — the template's asset refs are all RELATIVE so `` handles them for free; **(frontend runtime URLs)** root-absolute URLs ignore ``, so `CodemanBase.url()` (constants.js) is the route builder, applied transparently by a `fetch` wrapper and explicitly at the few EventSource/WebSocket/`window.open`/``-src sites; **(sw.js/manifest)** the worker derives its base from `self.location`, the manifest uses relative `start_url`/`scope`; **(web-tab proxy)** `proxyPrefixFor(cap, basePath)` is the single base-aware root that cascades to the injected ``, root-absolute HTML rewrites, the `runtimeUrlShim`, `Set-Cookie` Path and `Location` rebasing — while the INGRESS parsers (`capabilityFromProxyPath`, `resolveUpstreamUrl`) stay base-agnostic because `rewriteUrl` strips the prefix before routing, and `capabilityFromReferer(referer, basePath)` strips it from the browser-supplied Referer. ⚠️ `--base-url` rides the daemon relaunch via `buildWebArgs` and the service unit via `resolveServicePlan`. Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test.ts`; HTML injection in `test/render-index-html.test.ts`. + **Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments) **File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND the response viewer's `_linkifyFilePaths()`; a fresh instance per call, since `lastIndex` is per-object state. The chat linkifier walks TEXT NODES with DOM APIs (the source is model output; never rebuild sanitized markup as a string) and skips subtrees already inside an ``. ⚠️ **An out-of-workspace path is served through the ATTACHMENT routes, not the file routes** — `file-content`/`file-raw` are workspace-confined and 404 exactly the paths agents print most (a `/tmp` capture, Claude's scratchpad), so `openFilePreview()` registers such a path via `POST /api/sessions/:id/attachments` with **`notify: false`** (suppresses only the `attachment:detected` broadcast — same guard, same routes; without it every click also popped a card announcing the file already on screen) and renders by id. The click is an explicit action on the explicit, Origin-guarded route, which is what distinguishes it from the force-confined magic-link scanner. ⚠️ **Media extensions are single-sourced** (`VIDEO_ATTACHMENT_EXTENSIONS`/`AUDIO_ATTACHMENT_EXTENSIONS` in `attachment-registry.ts`, imported by `file-content`'s classification) so a clip plays the same in or out of the workspace; a player needs all THREE of allowlist + a real `MIME_TYPES` entry (octet-stream renders a dead player) + the range-aware body. ⚠️ **`TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS`** (never a second list): if the viewer would edit it inside the workspace, it can be read outside. Widening READ must never widen RUN, so `html`/`htm` joined `svg` in `serveRawFile`'s download-only branch, other text goes out as inert `text/plain`+`nosniff`, and `~/.codeman*/state.json` joined `isSensitivePath` (it persists `envOverrides`, which can hold `GEMINI_API_KEY`). ⚠️ The terminal sends an **out-of-workspace** path to the preview instead of the log viewer (that one spawns `tail -f` and reaches only workspace + `/var/log` + `~/logs`); in-workspace text keeps the tail viewer and `file-stream-manager`'s allowlist is untouched. The image-watcher keeps its own narrow detection list, so none of this cards every file an agent writes. → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) @@ -354,7 +360,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L ### SSE Event Registry -157 event constants in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). **Both must be kept in sync**, and `test/sse-registry-parity.test.ts` is the guard that pins it (currently exactly in sync, 157 = 157, no drift either direction). ⚠️ `hook:agent_working` is the one hook event with no Claude Code hook behind it — the DeepSeek status bridge reports it (see External CLI modes). The backend file's `@fileoverview` carries the per-category breakdown, including the two Web tab events. +158 event constants in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). **Both must be kept in sync**, and `test/sse-registry-parity.test.ts` is the guard that pins it (currently exactly in sync, 158 = 158, no drift either direction). ⚠️ `hook:agent_working` is the one hook event with no Claude Code hook behind it — the DeepSeek status bridge reports it (see External CLI modes). The backend file's `@fileoverview` carries the per-category breakdown, including the two Web tab events. ### API Routes diff --git a/config/knip.json b/config/knip.json index 922a0d96..aab5ffef 100644 --- a/config/knip.json +++ b/config/knip.json @@ -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", diff --git a/config/tsconfig.pr-bot.json b/config/tsconfig.pr-bot.json new file mode 100644 index 00000000..2f50001d --- /dev/null +++ b/config/tsconfig.pr-bot.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": false + }, + "include": ["../scripts/pr-bot/**/*.ts"] +} diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 82de9a49..e5aca7f3 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -56,7 +56,9 @@ Model is NOT a session field: it is a composition entry in the profile's config ### Docker cases -**Docker cases** (shipped 1.4.0; user guide `docs/docker-cases.md`, design `docs/docker-cases-plan.md`): a case can point at a **container** instead of a local/remote path, and any of the CLI run modes runs INSIDE it. Like remote-SSH, it is a **LOCATION OVERLAY on cases, never a `SessionMode` of its own** (`SessionMode` is unchanged). Storage `~/.codeman/docker-hosts.json` + `docker-cases.json` via `src/docker-hosts.ts` (direct mirror of `remote-hosts.ts`: `readDockerHosts`/`readDockerCases`, `toSessionDocker`, `dockerDisplayPath`, and the PURE builders `buildDockerBaseArgs`/`buildDockerCreateArgs`/`containerApiUrl`/`hostGatewayAlias`/`dockerConfigHash`). CRUD `/api/docker-hosts` + `/api/cases/docker-link`, plus **one-click** `/api/cases/docker-quickcreate` (Create New "Run in Docker" checkbox → case folder in `CASES_DIR` + auto-provisioned shared `default` host + auto-start a session inside; an expandable Template picker Small/Medium/Large/GPU or any override creates a per-case `q-` host), and export/import (`/api/docker-cases/:name/export`, `/api/docker-cases/import`, `GET/DELETE /api/docker-exports`) — all in `case-routes.ts`. Run flows route through `POST /api/quick-start` like remote (session-routes.ts docker branch, skips LOCAL CLI-availability gates). **Launch model**: exactly one long-lived container **per case** (`codeman-case-`, PID1 `sleep infinity` under `--init`); a LOCAL tmux pane runs `docker exec -it` into a **durable in-container tmux** on dedicated socket `-L codeman-docker`, session `codeman-dkr-` (deliberately fails `SAFE_MUX_NAME_PATTERN` so a Codeman running INSIDE the container never adopts it, exactly like remote's `codeman-ssh-`). Builders `buildDockerLaunchCommand`/`buildDockerKillCommand` in `tmux-manager.ts` (image-check → `docker inspect||create` → start → exec, all idempotent). The container is **shared by all sessions of the case**: `buildDockerKillCommand` kills ONLY that session's in-container tmux session, NEVER `docker stop` while siblings remain; `docker rm -f` happens only on case-delete (plus an instance-scoped boot reaper keyed on the `codeman.instance` label). **Two-layer durability/resume** (the central design point): (1) Codeman-PROCESS restart with the container still up → `tmux new-session -A` reattaches the SAME live agent (paneCommand ignored); (2) container stop/reboot/OOM → inner tmux is gone, so the re-run pane command resumes the conversation from the bind-mounted transcript: claude mode pins a DETERMINISTIC conversation id via `claudeDockerPaneCommand()` (`tmux-manager.ts`) — fresh launch `claude --session-id || claude --resume ` (a duplicate `--session-id` exits 1 "already in use", so the fallback RESUMES after a container stop; verified CLI behavior), explicit resume `--resume || --session-id ` so a stale id never dead-panes (leading `exec ` is stripped — an exec'd first branch could never fall back); codex `resume ` / gemini `--resume` keep `appendResumeFlag`. The resume id rides `resumeSessionId` through create/respawn options and persists on `DockerCase.lastClaudeSessionId` via `persistDockerCaseClaudeSessionId()` (written at quick-start launch, and again on hook/last-response conversation-id adoption so post-`/clear` switches track; seeded back when `resumeOnStart`, default true); `-A` makes the pane command self-selecting (inert on reattach, active only when tmux was re-created). **Config drift** (`dockerConfigHash` → `codeman.confighash` label): quick-start compares via `checkDockerConfigDrift()` and REFUSES a drifted launch with `CONFLICT`; the UI confirm calls `POST /api/docker-cases/:name/recreate` (refused while case sessions are live) which `docker rm -f`s so the next launch recreates with the new config — host config edits actually take effect. **Workspace** is a REAL host dir bind-mounted at the SAME absolute path (mirror, `dst==src`), so `Session.workingDir = hostWorkspacePath` keeps file-routes/attachments/watchers on real host bytes AND the in-container transcript projHash matches the host so subagent/workflow correlation (and thus resume-id capture) works; `resolveMuxAttachCwd` returns `/tmp` for docker (the local pane only runs `docker exec`). **Creds** arrive commit-safe and ISOLATED (1.4.1; replaced the whole-dir RW mounts that let in-container CLIs write refreshed tokens/state back to the host): shared RW across the boundary is ONLY what host-side reads/resume need (`~/.claude/projects` transcripts; codex `sessions/` + `history.jsonl` for response-viewer/`codex resume`); everything else is SEEDED (RO mount, copied into container HOME once at launch via `[ -e ] || cp`; the container refreshes its own copy and never writes back): `~/.claude.json` is merged through `buildSeamlessClaudeConfig()` (forces `hasCompletedOnboarding` + theme + workspace trust, so no login wizard/theme picker/trust prompt inside the container), plus `.claude/{.credentials.json,settings.json,stats-cache.json}`, plus whole-dir seeds for `~/.gemini`/`~/.config/{gcloud,opencode}` (`resolveDockerClaudeArtifacts`/`resolveDockerCredentialArtifacts` in `docker-hosts.ts`). Bind mounts are physically excluded from `docker commit`, so exports stay secret-free; API-key CLIs get exec-time NAME-ONLY `--env OPENAI_API_KEY` (no `=value`); the SEALED profile is `mountCredentials:false` + `network:none`. NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket. **Hardening** on every create: `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit`, `--memory`==`--memory-swap`, non-root via `--user :0` (Linux, GID 0 for writable HOME) / `--userns=keep-id` (podman rootless) / baked uid (Docker Desktop), `--pull=never`, `--init`. Base image `codeman/agent:base` is BUILT LOCALLY from `docker/agent.Dockerfile` (node22 + tmux + claude/codex/gemini/opencode, OpenShift arbitrary-uid HOME, `C.UTF-8` locale so tmux/Ink render real box-drawing glyphs; Codeman also sets `LANG`/`LC_ALL` at run time for containers built before that line) via `scripts/build-agent-image.mjs` OR **auto-built on first use** (1.4.1: `ensureAgentBaseImage()` in `docker-hosts.ts`; idempotent + concurrency-safe, only the DEFAULT image ref is ever auto-built, `--pull=never` stays absolute; build output streams over SSE `docker:imageBuildStarted`/`imageBuildProgress`/`imageBuildComplete`/`imageBuildFailed`, and quick-create returns `imageBuilding:true` while the first launch awaits the gate); tmux-in-image is a HARD gated prerequisite (`checkDockerTmuxAvailable`), never a silent bare-exec fallback. **Hooks + model**: the workspace-scaffolding block DOES run for docker (writes `.claude/settings.local.json` + the CLAUDE.md scaffold into the real host dir), so `modelOverride` works via `settings.local.json` — it is a `QuickStartSchema` field applied for local AND docker quick-starts (`updateCaseModel`), sent by the frontend docker run path (the one deliberate difference from remote, which rejects it); `effort`/`envOverrides`/`codexConfig`/`geminiConfig`/`openCodeConfig` stay rejected. In-container hook curls hit `containerApiUrl(process.env.CODEMAN_API_URL, engine)` (swaps ONLY the hostname to the gateway alias, preserving scheme+port so prod HTTPS still works); the host guard allowlists both `host.docker.internal`/`host.containers.internal` (`DOCKER_HOST_GATEWAY_ALIASES` in `network-auth-policy.ts`). ⚠️ On a **loopback-only** bind (the prod default) a container cannot reach 127.0.0.1, so in-container hooks fire ONLY when `CODEMAN_DOCKER_BRIDGE_HOOKS=1` — an opt-in SECOND listener on the docker bridge gateway (`_startDockerBridgeHooksListener` in `server.ts`; gateway auto-detected via `detectDockerBridgeGateway`, or set `CODEMAN_DOCKER_BRIDGE_HOST`) that serves ONLY the hook endpoints (403 for any other path) into the same secret-gated pipeline; otherwise idle detection falls back to output-based through the docker-exec PTY. Container-set `CLAUDE_CODE_TMPDIR` keeps claude launching regardless of workspace path. `SessionState.docker`/`MuxSession.docker` round-trip through recovery. Every docker IO path is `IS_TEST_MODE` (VITEST) no-op'd; the pure builders are unit-tested. **Export/import** (`src/docker-export.ts`): full-image (`docker commit` + `save | gzip` + workspace tar + manifest) or workspace-only → one portable `~/.codeman/docker-exports/-.codeman-container.tgz`; import validates per-member sha256, traversal-guards the workspace tar, `docker load`s + quarantine-retags the image (`codeman/imported-:`, never overwriting a local tag); a `saveImageToTar` stream `pipeline` avoids truncation. **GPU** passthrough (`gpus` → `--gpus`, needs the NVIDIA container toolkit) and **elastic disk** (no `--storage-opt` cap, so container storage grows with data). SSE `docker:exportComplete`/`exportFailed`/`importComplete` (both registries). **UI** in `session-ui.js`: Create Case **Docker** tab (collapsed/compact form since 1.4.1), the one-click checkbox + Template picker, short `(docker)` case-menu tags, and a Manage-tab Export button; docker AND remote sessions name their tabs `w-` via the shared `_nextCaseSessionStartNumber()` so all tabs follow one naming convention. Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/docker-export.test.ts`, `test/network-host-guard.test.ts`. +**Docker cases** (shipped 1.4.0; user guide `docs/docker-cases.md`, design `docs/docker-cases-plan.md`): a case can point at a **container** instead of a local/remote path, and any of the CLI run modes runs INSIDE it. Like remote-SSH, it is a **LOCATION OVERLAY on cases, never a `SessionMode` of its own** (`SessionMode` is unchanged). Storage `~/.codeman/docker-hosts.json` + `docker-cases.json` via `src/docker-hosts.ts` (direct mirror of `remote-hosts.ts`: `readDockerHosts`/`readDockerCases`, `toSessionDocker`, `dockerDisplayPath`, and the PURE builders `buildDockerBaseArgs`/`buildDockerCreateArgs`/`containerApiUrl`/`hostGatewayAlias`/`dockerConfigHash`). CRUD `/api/docker-hosts` + `/api/cases/docker-link`, plus **one-click** `/api/cases/docker-quickcreate` (Create New "Run in Docker" checkbox → case folder in `CASES_DIR` + auto-provisioned shared `default` host + auto-start a session inside; an expandable Template picker Small/Medium/Large/GPU or any override creates a per-case `q-` host), and export/import (`/api/docker-cases/:name/export`, `/api/docker-cases/import`, `GET/DELETE /api/docker-exports`) — all in `case-routes.ts`. Run flows route through `POST /api/quick-start` like remote (session-routes.ts docker branch, skips LOCAL CLI-availability gates). **Launch model**: exactly one long-lived container **per case** (`codeman-case-`, PID1 `sleep infinity` under `--init`); a LOCAL tmux pane runs `docker exec -it` into a **durable in-container tmux** on dedicated socket `-L codeman-docker`, session `codeman-dkr-` (deliberately fails `SAFE_MUX_NAME_PATTERN` so a Codeman running INSIDE the container never adopts it, exactly like remote's `codeman-ssh-`). Builders `buildDockerLaunchCommand`/`buildDockerKillCommand` in `tmux-manager.ts` (image-check → `docker inspect||create` → start → exec, all idempotent). The container is **shared by all sessions of the case**: `buildDockerKillCommand` kills ONLY that session's in-container tmux session, NEVER `docker stop` while siblings remain; `docker rm -f` happens only on case-delete (plus an instance-scoped boot reaper keyed on the `codeman.instance` label). **Two-layer durability/resume** (the central design point): (1) Codeman-PROCESS restart with the container still up → `tmux new-session -A` reattaches the SAME live agent (paneCommand ignored); (2) container stop/reboot/OOM → inner tmux is gone, so the re-run pane command resumes the conversation from the bind-mounted transcript: claude mode pins a DETERMINISTIC conversation id via `claudeDockerPaneCommand()` (`tmux-manager.ts`) — fresh launch `claude --session-id || claude --resume ` (a duplicate `--session-id` exits 1 "already in use", so the fallback RESUMES after a container stop; verified CLI behavior), explicit resume `--resume || --session-id ` so a stale id never dead-panes (leading `exec ` is stripped — an exec'd first branch could never fall back); codex `resume ` / gemini `--resume` keep `appendResumeFlag`. The resume id rides `resumeSessionId` through create/respawn options and persists on `DockerCase.lastClaudeSessionId` via `persistDockerCaseClaudeSessionId()` (written at quick-start launch, and again on hook/last-response conversation-id adoption so post-`/clear` switches track; seeded back when `resumeOnStart`, default true); `-A` makes the pane command self-selecting (inert on reattach, active only when tmux was re-created). **Config drift** (`dockerConfigHash` → `codeman.confighash` label): quick-start compares via `checkDockerConfigDrift()` and REFUSES a drifted launch with `CONFLICT`; the UI confirm calls `POST /api/docker-cases/:name/recreate` (refused while case sessions are live) which `docker rm -f`s so the next launch recreates with the new config — host config edits actually take effect. **Workspace** is a REAL host dir bind-mounted at the SAME absolute path (mirror, `dst==src`), so `Session.workingDir = hostWorkspacePath` keeps file-routes/attachments/watchers on real host bytes AND the in-container transcript projHash matches the host so subagent/workflow correlation (and thus resume-id capture) works; `resolveMuxAttachCwd` returns `/tmp` for docker (the local pane only runs `docker exec`). **Creds** arrive commit-safe and ISOLATED (1.4.1; replaced the whole-dir RW mounts that let in-container CLIs write refreshed tokens/state back to the host): shared RW across the boundary is ONLY what host-side reads/resume need (`~/.claude/projects` transcripts; codex `sessions/` + `history.jsonl` for response-viewer/`codex resume`); everything else is SEEDED (RO mount, copied into container HOME once at launch via `[ -e ] || cp`; the container refreshes its own copy and never writes back): `~/.claude.json` is merged through `buildSeamlessClaudeConfig()` (forces `hasCompletedOnboarding` + theme + workspace trust, so no login wizard/theme picker/trust prompt inside the container), plus `.claude/{.credentials.json,settings.json,stats-cache.json}`, plus whole-dir seeds for `~/.gemini`/`~/.config/{gcloud,opencode}` (`resolveDockerClaudeArtifacts`/`resolveDockerCredentialArtifacts` in `docker-hosts.ts`). Bind mounts are physically excluded from `docker commit`, so exports stay secret-free; API-key CLIs get exec-time NAME-ONLY `--env OPENAI_API_KEY` (no `=value`); the SEALED profile is `mountCredentials:false` + `network:none`. NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket. **Hardening** on every create: `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit`, `--memory`==`--memory-swap`, non-root via `--user :0` (Linux, GID 0 for writable HOME) / `--userns=keep-id` (podman rootless) / baked uid (Docker Desktop), `--pull=never`, `--init`. Base image `codeman/agent:base` is BUILT LOCALLY from `docker/agent.Dockerfile` (node22 + tmux + claude/codex/gemini/opencode, OpenShift arbitrary-uid HOME, `C.UTF-8` locale so tmux/Ink render real box-drawing glyphs; Codeman also sets `LANG`/`LC_ALL` at run time for containers built before that line) via `scripts/build-agent-image.mjs` OR **auto-built on first use** (1.4.1: `ensureAgentBaseImage()` in `docker-hosts.ts`; idempotent + concurrency-safe, only the DEFAULT image ref is ever auto-built, `--pull=never` stays absolute; build output streams over SSE `docker:imageBuildStarted`/`imageBuildProgress`/`imageBuildComplete`/`imageBuildFailed`, and quick-create returns `imageBuilding:true` while the first launch awaits the gate); tmux-in-image is a HARD gated prerequisite (`checkDockerTmuxAvailable`), never a silent bare-exec fallback. **Hooks + model**: the workspace-scaffolding block DOES run for docker (writes `.claude/settings.local.json` + the CLAUDE.md scaffold into the real host dir), so `modelOverride` works via `settings.local.json` — it is a `QuickStartSchema` field applied for local AND docker quick-starts (`updateCaseModel`), sent by the frontend docker run path (the one deliberate difference from remote, which rejects it); `effort`/`envOverrides`/`codexConfig`/`geminiConfig`/`openCodeConfig` stay rejected. In-container hook curls hit `containerApiUrl(process.env.CODEMAN_API_URL, engine)` (swaps ONLY the hostname to the gateway alias, preserving scheme+port so prod HTTPS still works); the host guard allowlists both `host.docker.internal`/`host.containers.internal` (`DOCKER_HOST_GATEWAY_ALIASES` in `network-auth-policy.ts`). ⚠️ On a **loopback-only** bind (the prod default) a container cannot reach 127.0.0.1, so in-container hooks fire ONLY when `CODEMAN_DOCKER_BRIDGE_HOOKS=1` — an opt-in SECOND listener on the docker bridge gateway (`_startDockerBridgeHooksListener` in `server.ts`; gateway auto-detected via `detectDockerBridgeGateway`, or set `CODEMAN_DOCKER_BRIDGE_HOST`) that serves ONLY the hook endpoints (403 for any other path) into the same secret-gated pipeline; otherwise idle detection falls back to output-based through the docker-exec PTY. Container-set `CLAUDE_CODE_TMPDIR` keeps claude launching regardless of workspace path. `SessionState.docker`/`MuxSession.docker` round-trip through recovery. Every docker IO path is `IS_TEST_MODE` (VITEST) no-op'd; the pure builders are unit-tested. **Export/import** (`src/docker-export.ts`): full-image (`docker commit` + `save | gzip` + workspace tar + manifest) or workspace-only → one portable `~/.codeman/docker-exports/-.codeman-container.tgz`; import validates per-member sha256, traversal-guards the workspace tar, `docker load`s + quarantine-retags the image (`codeman/imported-:`, never overwriting a local tag); a `saveImageToTar` stream `pipeline` avoids truncation. **GPU** passthrough (`gpus` → `--gpus`, needs the NVIDIA container toolkit) and **elastic disk** (no `--storage-opt` cap, so container storage grows with data). SSE `docker:exportComplete`/`exportFailed`/`importComplete` (both registries). **UI** in `session-ui.js`: Create Case **Docker** tab (collapsed/compact form since 1.4.1), the one-click checkbox + Template picker, short `(docker)` case-menu tags, and a Manage-tab Export button; docker AND remote sessions name their tabs `w-` via the shared `_nextCaseSessionStartNumber()` so all tabs follow one naming convention. **Adopting an already-running container** (`DockerCase.owned === false`, `POST /api/cases/docker-adopt` + the read-only `POST /api/docker-cases/adopt-preflight`, `GET /api/docker-hosts/:hostId/containers`, `POST /api/docker-cases/browse`): the mirror of remote-SSH's `owned:false` attach. For an adopted container the launch chain only LOOKS and then execs — no image gate (the image is theirs), no create, and above all no `start`, since starting a container we do not own is precisely the mutation adoption promises never to perform; a missing or stopped container fails closed with an actionable message. Credential seeding is skipped too (those copies read from create-time read-only mounts that do not exist here, and writing host credentials into someone's container is not ours to do), so its CLIs must already be authenticated inside it. Absent `owned` = owned, so every pre-existing case is byte-identical. ⚠️ The guarantee is NEGATIVE, so it cannot be observed by using the feature — only by asserting the mutating verbs are absent — and it is therefore enforced at four deliberately independent layers: `buildDockerStopCommand`/`buildDockerRemoveCommand` throw during pure STRING CONSTRUCTION (no shape of caller bug can produce a `docker stop`/`rm` for a container we do not own), `removeDockerContainer` refuses again at the lowest layer, `checkDockerConfigDrift` reports "none" (an adopted container carries no `codeman.confighash` label, so a real comparison would always report drift and the launch gate would 409 forever, offering a recreate we may not perform), and the orphan reaper skips it through a check independent of the two conditions that already cover it. ⚠️ **The export path is the one place that still touched the container** and both halves had to be closed: a full export `docker commit`s it (refused for an adopted case — it packages someone else's container, with their logins, into a bundle Codeman hands out) and even a workspace-only export `docker pause`d it first for snapshot consistency (skipped: the freeze stops the owner's processes for as long as the tar takes). ⚠️ `owned` is applied AFTER the config hash; `dockerConfigHash` takes an explicit field list, so ownership can never shift an existing case's hash and mass-trip the drift gate, whose only remedy is "recreate the container". ⚠️ The container workdir is verified INSIDE the container: it defaults to `hostWorkspacePath` for an OWNED case only because the create-time bind mount puts the host directory at that exact path, and adoption mounts nothing, so the two are independent facts — without the check `docker exec --workdir ` fails with an OCI chdir error the pane surfaces as a bare `execvp failed`. ⚠️ Run-mode availability comes from the CONTAINER (`availableModes`), live-probed rather than trusted from attach time: gating the dropdown on host CLI availability (#201) is right for local sessions and wrong here. The probe modes and the BINARY each mode looks for both come from the CLI registry (`enabledCliIds()` / `discovery.binaries[0]`), never a local table — a hand-written list silently froze once already, missing `omp` and hiding that mode on every docker case; `antigravity` ships as `agy` and `deepseek` as `dsh`, so a mode-name probe reports both missing on a container that has them, and a mode with no binary (`shell`) is reported available without a lookup. ⚠️ **A FAILED probe means opposite things per ownership.** For an adopted case it is a real fault (only the user can start that container). For an owned case it is the NORMAL state before the first session — the launch chain creates the container on demand — so recording it as an error hid every agent mode on every freshly linked Docker case behind "start it yourself first", for a container Codeman was about to create itself; `CaseInfo.docker.owned` exists on the wire so the frontend can tell the two apart. ⚠️ Claude launches WITHOUT `--dangerously-skip-permissions` when the container's exec user is root: Claude Code refuses the flag as root ("cannot be used with root/sudo privileges", still true in 2.1.261) and the refusal is visible only inside the container, so the pane just dies. Our 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 is `overlays.docker.rootCommand` in the registry rather than an id branch. ⚠️ **Admin-only in multi-user mode**, unlike `docker-link` right next to it: linking creates OUR container, whose sole bind mount `isWorkingDirAllowed` has already confined to the caller's space, while an adopted container's mounts are whatever its owner gave it — one mounting `/` hands the adopter a shell over the whole host, defeating exactly the workspace scoping that mode exists to enforce. The container listing and the in-container directory browser are gated with it (both are machine-level reads over containers belonging to anyone); the preflight is NOT, because the run menu probes it for every docker case, so it admits a non-admin only for a container already linked to a case they can access. Tests: `test/docker-adopted-container.test.ts`. + +Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/docker-export.test.ts`, `test/network-host-guard.test.ts`. ## Session data and lifecycle @@ -164,7 +166,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli ### Filesystem path picker -**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to `/mnt/d`), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command. +**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command. ⚠️ **This is a second file-serving surface, so it carries the same confinement burden as [Attachments](#attachments) and does not inherit it automatically.** Traversal is allowlisted to Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured via `CODEMAN_FILE_PICKER_ROOTS`; sensitive trees are blocked and symlink escapes are rejected after `realpath` resolution rather than before. Without the realpath step a symlink inside an allowed root would walk straight out of it. `preview` reuses the shared conversion cache and the **global** `document-conversion-limiter`, which is what stops N concurrent large-document previews from forking N multi-minute converter processes. Content types are pinned: images and PDF inline, DOCX/PPTX through the converters, and Markdown/TXT/JSON as inert `text/plain` (never `text/html`, which would be stored XSS on our own origin). Size caps are 2MB for text and 50MB for binary/document previews. @@ -342,9 +344,11 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se ⚠️ **A Claude pane's conversation is identified by the pane's own Enter, never by "newest entry for this cwd".** `~/.claude/history.jsonl` records every submitted prompt as `{project, sessionId, timestamp}`, and `/clear` moves the pane to a fresh `.jsonl` that nothing on the PTY announces — so the viewer has to re-derive the live conversation. Keying that off `project` alone was the bug: a cwd is shared with every other Codeman tab on it, with tabs long since closed, and with any plain `claude` the user runs in their own terminal, so the eye followed whichever of those conversations was typed into last and showed a stranger's transcript. `resolveActiveClaudeSessionIdFromHistory()` instead credits an entry to a pane only when it lands within `CLAUDE_SUBMIT_MATCH_MS` of that pane's `Session.lastSubmitAt` **and** no other pane on the same cwd submitted closer — the same last-submit correlation the Codex locator uses. With no correlated entry the pane keeps the id it has: a viewer one turn behind beats a viewer showing someone else's conversation. +⚠️ **A first-hand conversation id outranks every correlation, and the correlation must never run when one exists.** `UserPromptSubmit` and `Stop` hook payloads carry `session_id` — the pane's LIVE conversation, reported from inside the CLI process — and reach Codeman addressed by that pane's own `$CODEMAN_SESSION_ID`. That binding is a fact: it never consults `workingDir`, so it cannot be claimed by a sibling pane on the same folder, by a closed tab, or by a bare `claude` in the user's terminal. `Session.claudeSessionIdIsFirstHand` gates `resolveActiveClaudeSessionIdFromHistory()` at its first line, so the number of prompts eligible for cwd-based guessing goes DOWN, never up. There is no TTL: if hooks stop arriving the last hook-supplied id is kept forever rather than falling back to guessing, which is the same rule as the paragraph below. ⚠️ **This is also the only fix for a pane driven by attaching to its tmux session directly.** `lastSubmitAt` was bumped only by `Session.write()`/`writeViaMux()`, i.e. input that flows through Codeman, so such a pane's anchor stayed 0 and the resolver returned at `if (!submitAt)` for the pane's entire life. The hook stamps it too (`markPromptSubmitted()`), so it finally means "a prompt was submitted". ⚠️ **Only a first-hand adoption may extend `Session.claudeSessionChain`** — a correlated guess writing into the pane's permanent record is precisely the bug the paragraph above describes, made durable. The chain is persisted because `/clear` is otherwise unrecoverable: once the pane moves on, the predecessor id exists nowhere else. Its tail re-pins the conversation on a RESTORED mux attach, where the launch id is a lie (the CLI never stopped and may have `/clear`ed before the restart); a NEW pane has an empty chain and keeps the launch id unchanged. ⚠️ **The hook's stdout must stay discarded, using curl's own `-o /dev/null`** (`curlCmdSilent`): Claude Code injects a `UserPromptSubmit` hook's stdout into the model's context — the CLI's own hook reference says "Exit code 0 - stdout shown to Claude" — so an undiscarded curl pastes Codeman's `{"success":true,…}` envelope into the user's own prompt on every turn. ⚠️ **A trailing `>/dev/null` does NOT work and looks like it does**: `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. An `endsWith('>/dev/null')` assertion passes on exactly that broken form, so the test asserts the `-o` flag instead. The other events feed SSE, where their stdout is harmless — hence a separate builder rather than a change to `curlCmd`. Tests: `test/hooks-config.test.ts`, `test/routes/hook-event-routes.test.ts`, `test/routes/session-routes-claude-last-response.test.ts`. + ⚠️ **`Session.lastSubmitAt` is persisted state, not a runtime counter.** `start()` reassigns `_claudeSessionId = resumeSessionId || id` on every launch — including the re-attach path for a mux session that survived the restart — so a recovered pane always points the viewer at its *launch* conversation, even when the CLI moved on via `/clear` hours earlier. The submit anchor is the only thing that can correct that without user input, so it round-trips through `SessionState.lastSubmitAt` and is restored in `restoreMuxSessions()`. Drop it from `toState()` and recovered panes silently show the pre-`/clear` transcript until the user types again. Restoring a *stale* anchor is safe: the resolver's staleness guard rejects any candidate transcript older than the one the pane is currently on, which is exactly the shape of a respawn into a fresh conversation. -⚠️ **Claude transcripts are grouped at real human-turn boundaries, not per JSONL row.** A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots, and multi-block assistant output. Rendering a card per row was the bug: it produced duplicate and truncated cards that looked like the viewer had lost the response. The grouping walks to the next genuine user turn and dedups replayed assistant snapshots while preserving the tool/task/skill/compact/team metadata filtering. Related: a recovered `restored-` tmux placeholder carries a **stale cwd**, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: `test/routes/session-routes-claude-last-response.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices. +⚠️ **The Claude viewer emits one message per model message and groups them with `turn`; it never concatenates them.** A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots. Rendering a card per *row* was the original bug (#169) — but the fix overshot to one card per *human turn*, which fused up to 74 distinct model messages into a single card and reported it as one message. One assistant row IS one whole model message: measured across a real `~/.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 was never anything to reassemble, and no adjacent pair of assistant rows continues a table, a list, or an open code fence. Each row is therefore its own message carrying `{kind, label, role, text, timestamp, turn}`; the frontend renders a same-role run inside one `turn` as badge-less continuation segments (`.rv-msg-cont`), which is what keeps a p90 of 11 messages per turn from reading as card spam. ⚠️ **A prompt typed while Claude is working is recorded ONLY as an `attachment/queued_command` row** — the CLI never re-emits it as a `user` row — so reading only `user` rows lost 162 of 353 user cards on that corpus AND lost the turn boundary each one carries, which is what let an assistant run fuse in the first place. Take it only when `attachment.origin.kind === 'human'` and `commandMode === 'prompt'`; the CLI's own queue entries (`commandMode: 'task-notification'`) carry no `origin` key at all. The shape is not a documented CLI contract, so every field check must fail closed. ⚠️ **`data.text` (no `?context=full`) is frozen on the last assistant row and must never be derived from `messages.at(-1)`** — agent pollers hash it (`skills/codeman/preamble.sh`), and the last message can be the user's own queued prompt. Replayed assistant snapshots are still deduped, and the tool/task/skill/compact/team metadata filtering is unchanged. Related: a recovered `restored-` tmux placeholder carries a **stale cwd**, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: `test/routes/session-routes-claude-last-response.test.ts`, `test/response-viewer-turn-segments.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices. **File Viewer button** (header, 1.4.1) is **shown by default on desktop** since `211f3c0` (post-1.8.0): toggle under App Settings → Header & Panels → Header buttons → File Viewer (`showFileViewerButton`, in the per-device `displayKeys` set, fallback default `true`). Purely client-side like the response viewer: the template now ships the button VISIBLE (no `--hidden` class) and `applyHeaderVisibilitySettings()` toggles the `btn-file-viewer--hidden` marker class after settings load; phones still hide it via mobile.css. The button toggles the file-browser panel open/closed without opening the settings modal (`panels-ui.js`). The same commit set the **default desktop header** to WS/CPU/MEM + File Viewer + gear: the token-count chip (`showTokenCount`, no settings-UI toggle) and the lifecycle-log button (`showLifecycleLog`) both default **OFF** now (templates ship them hidden; stored prefs still honored). The plan-usage chip default is unchanged (opt-in, see Plan-usage chip). The **Cron toolbar button** joined the same opt-in pattern in 1.6.0: template ships `btn-cron--hidden`, `applyHeaderVisibilitySettings()` toggles it via the per-device `showCronButton` setting (default OFF, App Settings → Header & Panels → Scheduling); cron jobs themselves are unaffected. ### Session list layout (header strip vs. left sidebar) diff --git a/docs/docker-cases.md b/docs/docker-cases.md index 841b8710..738d5a3d 100644 --- a/docs/docker-cases.md +++ b/docs/docker-cases.md @@ -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). diff --git a/docs/pr-bot.md b/docs/pr-bot.md new file mode 100644 index 00000000..04117576 --- /dev/null +++ b/docs/pr-bot.md @@ -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/`) of the main repository + and checked out (detached) in a private clone under + `~/.codeman/pr-bot/worktrees/pr-`, 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-/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-` 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. `claude-fable-5-1`. | +| `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/` 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-`** 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-*` 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. diff --git a/docs/security-architecture.md b/docs/security-architecture.md index 5f2116ce..719caac3 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -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 | diff --git a/docs/wiki/Remote-Access.md b/docs/wiki/Remote-Access.md index e7e810c1..1a6a9e10 100644 --- a/docs/wiki/Remote-Access.md +++ b/docs/wiki/Remote-Access.md @@ -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 + `` 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 /` 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. | diff --git a/package-lock.json b/package-lock.json index 32ef8018..0ea08841 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aicodeman", - "version": "1.24.7", + "version": "1.25.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aicodeman", - "version": "1.24.7", + "version": "1.25.0", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index b26242e9..d06f29f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "1.24.7", + "version": "1.25.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, diff --git a/scripts/pr-bot/bot.ts b/scripts/pr-bot/bot.ts new file mode 100644 index 00000000..1526eb22 --- /dev/null +++ b/scripts/pr-bot/bot.ts @@ -0,0 +1,1083 @@ +/** + * @fileoverview The PR bot: polls the repository's open pull requests, reviews each + * one (once per head commit) in a Codeman claude session running in a private + * clone, and reports to the maintainer over Telegram with a verdict, the ranked + * findings, a recommendation and action buttons. + * + * Three rules shape everything here: + * + * 1. Reviews are automatic; GitHub WRITES are not. Merge, close, post-comment and + * approve-CI happen only from an explicit Telegram command or button press from + * the configured chat, and merge/close/post take a second confirmation tap. The + * bot never posts a review comment on its own: the draft is shown first and the + * maintainer decides. + * 2. One review session at a time (`reviewLoop` is serial); follow-up questions run + * beside it, at most two, never on a PR whose session is live (`busy`). + * 3. The bot deletes only sessions it created (`prbot-*`, tracked by id), and touches + * git only through worktree.ts, never the maintainer's checkout. + */ +import { randomBytes } from 'crypto'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { CodemanClient, stripAnsi, type TurnOutcome } from './codeman-client.js'; +import type { PrBotConfig } from './config.js'; +import { + approveWorkflowRun, + closePr, + commentPr, + getCiStatus, + getPrDetail, + gh, + listOpenPrs, + mergePr, + type CiStatus, + type PrSummary, +} from './github.js'; +import { + buildReportKeyboard, + confirmKeyboard, + escapeHtml, + extractJsonObject, + formatReviewFailure, + formatStatusList, + formatTelegramSummary, + orderBacklog, + parseReport, + splitTelegramMessage, + type ReviewReport, +} from './report.js'; +import { buildFollowupBrief, buildReviewBrief, followupKickoffLine, reviewKickoffLine } from './review-task.js'; +import { StateStore, type PendingConfirm, type PrRecord } from './state.js'; +import { + parseCallback, + parseCommand, + prNumberFromMessageText, + type TelegramCallbackQuery, + type TelegramClient, + type TelegramMessage, + type TelegramUpdate, +} from './telegram.js'; +import { preparePrWorktree, removePrWorktree } from './worktree.js'; + +/** What the bot needs from Telegram; `main.ts review --no-telegram` substitutes a console. */ +export type TelegramLike = Pick< + TelegramClient, + | 'isOurChat' + | 'sendMessage' + | 'sendPlain' + | 'editReplyMarkup' + | 'deleteMessage' + | 'answerCallback' + | 'sendDocument' + | 'getUpdates' + | 'setMyCommands' +>; + +export interface PrBotDeps { + telegram: TelegramLike; + codeman: CodemanClient; + log: (msg: string) => void; +} + +const CONFIRM_TTL_MS = 15 * 60_000; +/** A head that failed this many times is left alone until /review N or a new push. */ +const MAX_AUTO_RETRIES = 3; +const MAX_FOLLOWUPS = 2; +const REPORT_INLINE_MAX = 3000; + +const COMMANDS = [ + { command: 'status', description: 'Open PRs with verdicts' }, + { command: 'scan', description: 'Check GitHub now' }, + { command: 'review', description: '/review N: (re)review a PR now' }, + { command: 'report', description: '/report N: the full review' }, + { command: 'summary', description: '/summary N: the review message again' }, + { command: 'draft', description: '/draft N: the draft comment' }, + { command: 'post', description: '/post N: post the draft comment (asks first)' }, + { command: 'merge', description: '/merge N: merge (asks first)' }, + { command: 'close', description: '/close N reason: close with a comment (asks first)' }, + { command: 'approve', description: '/approve N: approve a waiting CI run' }, + { command: 'ask', description: '/ask N question: ask the reviewer' }, + { command: 'pause', description: 'Stop auto-reviewing' }, + { command: 'resume', description: 'Resume auto-reviewing' }, + { command: 'help', description: 'All commands' }, +]; + +const HELP = `Codeman PR bot +Every open PR is reviewed once per head commit in its own Codeman session; you get the verdict here and decide. + +/status · open PRs and verdicts +/scan · check GitHub now +/review N · (re)review now, jumps the queue +/report N · full review as a file +/summary N · the review message with its buttons again +/draft N · the comment drafted for the contributor +/post N · post that draft (you confirm first) +/merge N · merge with a merge commit (you confirm first) +/close N reason · close with that comment (you confirm first) +/approve N · approve a CI run waiting on you (first-time contributors) +/ask N question · ask the reviewer session anything; it resumes with its context +/pause · /resume · auto-review on and off + +Reply to any review message with plain text to ask about that PR.`; + +function isBotAuthor(login: string): boolean { + return login.endsWith('[bot]') || login.startsWith('app/'); +} + +function errText(err: unknown): string { + const e = err as { stderr?: string; message?: string }; + const stderr = typeof e.stderr === 'string' ? e.stderr.trim() : ''; + return (stderr || e.message || String(err)).slice(0, 1500); +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export class PrBot { + readonly store: StateStore; + private reviewQueue: number[] = []; + private readonly busy = new Set(); + private readonly createdSessions = new Set(); + /** PRs the bot itself merged or closed: the scan's close notice would repeat what runConfirmed already said. */ + private readonly selfClosed = new Set(); + private followupsRunning = 0; + private stopped = false; + private scanning = false; + private scanTimer?: NodeJS.Timeout; + private wakeQueue: (() => void) | null = null; + private reviewing: number | null = null; + + constructor( + private readonly cfg: PrBotConfig, + private readonly deps: PrBotDeps + ) { + mkdirSync(cfg.dataDir, { recursive: true }); + this.store = new StateStore(join(cfg.dataDir, 'state.json')); + } + + private get telegram(): TelegramLike { + return this.deps.telegram; + } + + private get codeman(): CodemanClient { + return this.deps.codeman; + } + + private log(msg: string): void { + this.deps.log(msg); + } + + // ---- lifecycle ----------------------------------------------------------- + + async start(): Promise { + await this.telegram.setMyCommands(COMMANDS).catch((err) => this.log(`setMyCommands: ${errText(err)}`)); + await this.sweepStaleSessions(); + const open = this.store.openPrs().length; + await this.telegram + .sendMessage( + `🤖 PR bot online · ${escapeHtml(this.cfg.githubRepo)} · auto-review ${ + this.cfg.autoReview && !this.store.state.paused ? 'on' : 'off' + } · polling every ${Math.round(this.cfg.pollIntervalMs / 60_000)} min${open ? ` · ${open} PRs known` : ''}` + ) + .catch((err) => this.log(`startup message: ${errText(err)}`)); + void this.reviewLoop(); + void this.telegramLoop(); + this.scheduleScan(2000); + } + + async stop(): Promise { + this.stopped = true; + if (this.scanTimer) clearTimeout(this.scanTimer); + this.wakeQueue?.(); + for (const id of this.createdSessions) { + await this.codeman.deleteSession(id).catch((err) => this.log(`delete ${id}: ${errText(err)}`)); + } + this.createdSessions.clear(); + for (const rec of Object.values(this.store.state.prs)) { + if (rec.status === 'reviewing') rec.status = rec.reviewedSha ? 'reviewed' : 'new'; + rec.activeSessionId = undefined; + } + this.store.save(); + } + + /** A crashed run can leave `prbot-*` sessions behind; they are ours by construction. */ + private async sweepStaleSessions(): Promise { + try { + const sessions = await this.codeman.listSessions(); + for (const s of sessions) { + if (!s.name?.startsWith('prbot-')) continue; + this.log(`sweeping stale session ${s.name} (${s.id.slice(0, 8)})`); + await this.codeman.deleteSession(s.id).catch((err) => this.log(`sweep ${s.id}: ${errText(err)}`)); + } + } catch (err) { + this.log(`session sweep skipped: ${errText(err)}`); + } + } + + private scheduleScan(delayMs: number): void { + if (this.stopped) return; + if (this.scanTimer) clearTimeout(this.scanTimer); + this.scanTimer = setTimeout(() => { + void this.scanOnce('timer') + .catch((err) => this.log(`scan failed: ${errText(err)}`)) + .finally(() => this.scheduleScan(this.cfg.pollIntervalMs)); + }, delayMs); + } + + // ---- scanning ------------------------------------------------------------ + + /** List open PRs, retire the ones that closed, queue what needs a (re)review. */ + async scanOnce(reason: string): Promise<{ queued: number[]; closed: number[] }> { + if (this.scanning) return { queued: [], closed: [] }; + this.scanning = true; + const queued: number[] = []; + const closed: number[] = []; + try { + const open = await listOpenPrs(this.cfg.githubRepo); + const openNumbers = new Set(open.map((p) => p.number)); + for (const rec of this.store.openPrs()) { + if (openNumbers.has(rec.number) || this.busy.has(rec.number)) continue; + await this.onPrClosed(rec); + closed.push(rec.number); + } + const candidates: PrSummary[] = []; + for (const pr of open) { + const rec = this.store.upsertPr(pr); + if (isBotAuthor(pr.author)) { + rec.status = 'skipped'; + continue; + } + if (pr.isDraft && !this.cfg.reviewDrafts) { + if (rec.status === 'new') rec.status = 'skipped'; + continue; + } + if (rec.status === 'skipped') rec.status = rec.reviewedSha ? 'reviewed' : 'new'; + const needsReview = rec.reviewedSha !== pr.headSha; + const gaveUp = (rec.failedAttempts ?? 0) >= MAX_AUTO_RETRIES && rec.failedSha === pr.headSha; + if (needsReview && !gaveUp && !this.busy.has(pr.number) && !this.reviewQueue.includes(pr.number)) + candidates.push(pr); + } + if (this.cfg.autoReview && !this.store.state.paused) { + for (const pr of orderBacklog(candidates)) { + this.enqueueReview(pr.number, { front: false }); + queued.push(pr.number); + } + } + this.store.save(); + this.log(`scan (${reason}): ${open.length} open, ${queued.length} queued, ${closed.length} closed`); + } finally { + this.scanning = false; + } + return { queued, closed }; + } + + private async onPrClosed(rec: PrRecord): Promise { + let merged = false; + try { + const out = await gh([ + 'pr', + 'view', + String(rec.number), + '--repo', + this.cfg.githubRepo, + '--json', + 'state', + '--jq', + '.state', + ]); + merged = out.trim() === 'MERGED'; + } catch (err) { + this.log(`state lookup for #${rec.number}: ${errText(err)}`); + } + rec.status = 'closed'; + rec.closedAs = merged ? 'merged' : 'closed'; + rec.activeSessionId = undefined; + this.reviewQueue = this.reviewQueue.filter((n) => n !== rec.number); + await removePrWorktree({ + mainCheckout: this.cfg.mainCheckout, + worktreesDir: this.cfg.worktreesDir, + prNumber: rec.number, + log: (m) => this.log(`[#${rec.number}] ${m}`), + }).catch((err) => this.log(`worktree cleanup #${rec.number}: ${errText(err)}`)); + if (this.selfClosed.delete(rec.number)) return; // announced by runConfirmed already + await this.telegram + .sendMessage( + `${merged ? '🎉 Merged' : '🔒 Closed'} #${rec.number} · ${escapeHtml(rec.title)} (${escapeHtml(rec.author)})` + ) + .catch((err) => this.log(`close notice: ${errText(err)}`)); + } + + // ---- review queue -------------------------------------------------------- + + enqueueReview(number: number, opts: { front: boolean }): 'queued' | 'moved' | 'busy' { + if (this.busy.has(number)) return 'busy'; + const rec = this.store.pr(number); + if (rec) rec.status = 'queued'; + const idx = this.reviewQueue.indexOf(number); + if (idx >= 0) { + if (!opts.front) return 'queued'; + this.reviewQueue.splice(idx, 1); + this.reviewQueue.unshift(number); + this.wakeQueue?.(); + return 'moved'; + } + if (opts.front) this.reviewQueue.unshift(number); + else this.reviewQueue.push(number); + this.wakeQueue?.(); + return 'queued'; + } + + private async reviewLoop(): Promise { + while (!this.stopped) { + const next = this.reviewQueue.shift(); + if (next === undefined) { + await new Promise((resolve) => { + this.wakeQueue = resolve; + }); + this.wakeQueue = null; + continue; + } + if (this.busy.has(next)) continue; + this.busy.add(next); + this.reviewing = next; + try { + await this.reviewPr(next); + } catch (err) { + this.log(`[#${next}] review crashed: ${errText(err)}`); + } finally { + this.busy.delete(next); + this.reviewing = null; + } + } + } + + /** One full review of a PR at its current head. Serial by construction (see reviewLoop). */ + async reviewPr(number: number): Promise { + const log = (m: string) => this.log(`[#${number}] ${m}`); + const started = Date.now(); + let rec = this.store.pr(number); + let sessionId: string | undefined; + let progressMsg: number | undefined; + try { + const detail = await getPrDetail(this.cfg.githubRepo, number); + rec = this.store.upsertPr(detail); + const ci = await getCiStatus(this.cfg.githubRepo, detail.headSha); + rec.ci = ci.state; + rec.status = 'reviewing'; + rec.lastError = undefined; + this.store.save(); + progressMsg = await this.telegram.sendMessage( + `🔍 Reviewing PR #${number} · ${escapeHtml(detail.title)} (${escapeHtml(detail.author)}, +${detail.additions}/−${detail.deletions}, ${detail.changedFiles} files) …` + ); + + const wt = await preparePrWorktree({ + mainCheckout: this.cfg.mainCheckout, + worktreesDir: this.cfg.worktreesDir, + prNumber: number, + reset: true, + log, + }); + rec.worktreeDir = wt.dir; + if (wt.headSha !== detail.headSha) log(`head moved during fetch: reviewing ${wt.headSha.slice(0, 8)}`); + detail.headSha = wt.headSha; + + const jobDir = join(this.cfg.dataDir, 'jobs', `pr-${number}`); + mkdirSync(jobDir, { recursive: true }); + const briefPath = join(jobDir, 'brief.md'); + const reportJsonPath = join(jobDir, 'report.json'); + const reportMdPath = join(jobDir, 'report.md'); + rmSync(reportJsonPath, { force: true }); + rmSync(reportMdPath, { force: true }); + writeFileSync( + briefPath, + buildReviewBrief({ + pr: detail, + ci, + mergeBase: wt.mergeBase, + worktreeDir: wt.dir, + mainCheckout: this.cfg.mainCheckout, + reportJsonPath, + reportMdPath, + }) + ); + Object.assign(rec, { briefPath, reportJsonPath, reportMdPath }); + + sessionId = await this.codeman.createInteractiveSession({ + workingDir: wt.dir, + name: `prbot-${number}`, + modelOverride: this.cfg.model, + effort: this.cfg.effort, + }); + this.createdSessions.add(sessionId); + rec.activeSessionId = sessionId; + this.store.save(); + log(`session ${sessionId.slice(0, 8)} spawned in ${wt.dir}`); + await this.codeman.ensureReady(sessionId, log); + + const isDone = () => existsSync(reportJsonPath) && existsSync(reportMdPath); + const outcome = await this.codeman.runTurn(sessionId, reviewKickoffLine(briefPath), { + deadlineMs: this.cfg.reviewTimeoutMs, + isDone, + log, + }); + log(`turn ended: ${outcome.kind}`); + if (outcome.kind === 'stop' && isDone()) { + // Let the session finish its closing line before we read and delete. + await this.codeman.waitSignal(sessionId, 'stop,exit', 15_000).catch(() => undefined); + } + + let report: ReviewReport | null = null; + let last = ''; + if (existsSync(reportJsonPath)) report = parseReport(extractJsonObject(readFileSync(reportJsonPath, 'utf8'))); + if (!report) { + last = await this.pollLastResponse(sessionId); + report = parseReport(extractJsonObject(last)); + if (report && !existsSync(reportMdPath)) writeFileSync(reportMdPath, last); + } + await this.recordClaudeSessionId(sessionId, rec); + if (!report) throw new Error(await this.describeFailure(sessionId, outcome, started, last)); + + const durationMin = Math.max(1, Math.round((Date.now() - started) / 60_000)); + Object.assign(rec, { + status: 'reviewed', + failedAttempts: 0, + failedSha: undefined, + reviewedSha: detail.headSha, + reviewedAt: new Date().toISOString(), + reviewDurationMin: durationMin, + verdict: report.verdict, + report, + }); + Object.assign(rec, { + additions: detail.additions, + deletions: detail.deletions, + changedFiles: detail.changedFiles, + }); + await this.sendSummary(rec); + log(`reviewed: ${report.verdict} (${durationMin} min)`); + } catch (err) { + const reason = errText(err); + log(`review failed: ${reason}`); + if (rec) { + rec.status = 'failed'; + rec.lastError = reason; + rec.failedAttempts = rec.failedSha === rec.headSha ? (rec.failedAttempts ?? 0) + 1 : 1; + rec.failedSha = rec.headSha; + const givingUp = rec.failedAttempts >= MAX_AUTO_RETRIES; + await this.telegram + .sendMessage( + formatReviewFailure(rec, reason) + + (givingUp + ? `\n\nThat was attempt ${rec.failedAttempts}; not retrying this head on my own.` + : ' (retrying on the next scan)') + ) + .catch((e) => log(`failure notice: ${errText(e)}`)); + } + } finally { + if (progressMsg !== undefined) await this.telegram.deleteMessage(progressMsg); + if (sessionId) await this.releaseSession(sessionId, log); + if (rec) rec.activeSessionId = undefined; + this.store.save(); + } + if (!rec) throw new Error(`PR #${number} not found`); + return rec; + } + + private async releaseSession(sessionId: string, log: (m: string) => void): Promise { + await this.codeman.deleteSession(sessionId).catch((err) => log(`delete session: ${errText(err)}`)); + this.createdSessions.delete(sessionId); + } + + private async recordClaudeSessionId(sessionId: string, rec: PrRecord): Promise { + try { + const s = await this.codeman.getSession(sessionId); + if (s.claudeSessionId) rec.claudeSessionId = s.claudeSessionId; + } catch { + // The session may already be gone; the follow-up path copes without an id. + } + } + + /** The transcript write lags the stop signal; poll briefly like the skill's `last_text`. */ + private async pollLastResponse(sessionId: string): Promise { + let text = ''; + for (let i = 0; i < 12; i++) { + text = await this.codeman.lastResponse(sessionId).catch(() => ''); + if (text.trim()) return text; + await sleep(1000); + } + return text; + } + + private async describeFailure( + sessionId: string, + outcome: TurnOutcome, + started: number, + lastText = '' + ): Promise { + const minutes = Math.round((Date.now() - started) / 60_000); + const said = lastText.trim() ? `\nIts last message:\n${lastText.trim().slice(-900)}` : ''; + switch (outcome.kind) { + case 'blocked': { + const screen = stripAnsi(await this.codeman.terminalText(sessionId).catch(() => '')); + const tail = screen.trim().split('\n').slice(-25).join('\n').slice(-1200); + return `the reviewer stopped on a question or permission prompt after ${minutes} min:\n${tail}`; + } + case 'exit': + return 'the session exited before writing a report'; + case 'timeout': + return `timed out after ${minutes} min without a report`; + default: + return `the session finished after ${minutes} min without writing report.json${said}`; + } + } + + // ---- follow-ups ---------------------------------------------------------- + + private startFollowup(number: number, instruction: string, replyTo?: number): void { + const rec = this.store.pr(number); + if (!rec || !rec.reviewedSha) { + void this.telegram.sendMessage(`No review of #${number} yet. /review ${number} first.`); + return; + } + if (this.busy.has(number)) { + void this.telegram.sendMessage(`#${number} has a session running right now; ask again in a few minutes.`); + return; + } + if (this.followupsRunning >= MAX_FOLLOWUPS) { + void this.telegram.sendMessage(`Two follow-ups are already running; try again shortly.`); + return; + } + this.busy.add(number); + this.followupsRunning++; + void this.followup(rec, instruction, replyTo).finally(() => { + this.busy.delete(number); + this.followupsRunning--; + }); + } + + private async followup(rec: PrRecord, instruction: string, replyTo?: number): Promise { + const number = rec.number; + const log = (m: string) => this.log(`[#${number} ask] ${m}`); + let sessionId: string | undefined; + try { + const wt = await preparePrWorktree({ + mainCheckout: this.cfg.mainCheckout, + worktreesDir: this.cfg.worktreesDir, + prNumber: number, + reset: false, + log, + }); + const jobDir = join(this.cfg.dataDir, 'jobs', `pr-${number}`); + mkdirSync(jobDir, { recursive: true }); + const followupPath = join(jobDir, `followup-${Date.now()}.md`); + writeFileSync( + followupPath, + buildFollowupBrief({ + prNumber: number, + title: rec.title, + instruction, + worktreeDir: wt.dir, + reportMdPath: rec.reportMdPath ?? join(jobDir, 'report.md'), + briefPath: rec.briefPath ?? join(jobDir, 'brief.md'), + }) + ); + const base = { + workingDir: wt.dir, + name: `prbot-${number}-ask`, + modelOverride: this.cfg.model, + effort: this.cfg.effort, + }; + if (rec.claudeSessionId) { + try { + sessionId = await this.codeman.createInteractiveSession({ ...base, resumeSessionId: rec.claudeSessionId }); + } catch (err) { + log(`resume of ${rec.claudeSessionId.slice(0, 8)} refused (${errText(err)}); starting fresh`); + } + } + if (!sessionId) sessionId = await this.codeman.createInteractiveSession(base); + this.createdSessions.add(sessionId); + rec.activeSessionId = sessionId; + this.store.save(); + await this.codeman.ensureReady(sessionId, log); + const outcome = await this.codeman.runTurn(sessionId, followupKickoffLine(followupPath), { + deadlineMs: this.cfg.followupTimeoutMs, + log, + }); + let answer = (await this.pollLastResponse(sessionId)).trim(); + if (!answer) answer = await this.describeFailure(sessionId, outcome, Date.now()); + await this.recordClaudeSessionId(sessionId, rec); + const moved = + wt.headSha !== rec.reviewedSha + ? `⚠️ #${number} has new commits since the review (use /review ${number}).\n\n` + : ''; + for (const chunk of splitTelegramMessage(`💬 #${number}\n${moved}${answer}`)) { + const id = await this.telegram.sendPlain(chunk, { replyToMessageId: replyTo }); + this.store.rememberMessage(id, number); + } + } catch (err) { + await this.telegram + .sendMessage(`⚠️ Follow-up on #${number} failed: ${escapeHtml(errText(err))}`) + .catch(() => undefined); + } finally { + if (sessionId) await this.releaseSession(sessionId, log); + rec.activeSessionId = undefined; + this.store.save(); + } + } + + // ---- telegram ------------------------------------------------------------ + + private async telegramLoop(): Promise { + let backoff = 5000; + while (!this.stopped) { + try { + const updates = await this.telegram.getUpdates(this.store.state.telegramOffset, 50); + backoff = 5000; + for (const update of updates) { + this.store.state.telegramOffset = update.update_id + 1; + this.store.save(); + try { + await this.handleUpdate(update); + } catch (err) { + this.log(`update ${update.update_id}: ${errText(err)}`); + } + } + } catch (err) { + if (this.stopped) return; + this.log(`telegram poll: ${errText(err)}; retrying in ${backoff / 1000}s`); + await sleep(backoff); + backoff = Math.min(backoff * 2, 120_000); + } + } + } + + async handleUpdate(update: TelegramUpdate): Promise { + if (update.callback_query) return this.handleCallback(update.callback_query); + if (update.message) return this.handleMessage(update.message); + } + + private async handleMessage(msg: TelegramMessage): Promise { + if (!this.telegram.isOurChat(msg.chat.id)) return; + const cmd = parseCommand(msg.text); + if (!cmd) { + const replyId = msg.reply_to_message?.message_id; + const text = msg.text?.trim(); + if (!text) return; + if (replyId !== undefined) { + const reasonPr = this.store.state.reasonPrompts[String(replyId)]; + if (reasonPr !== undefined) { + delete this.store.state.reasonPrompts[String(replyId)]; + this.store.save(); + return this.startConfirm('close', reasonPr, text); + } + const pr = this.store.prForMessage(replyId) ?? prNumberFromMessageText(msg.reply_to_message?.text) ?? undefined; + if (pr !== undefined) return this.startFollowup(pr, text, msg.message_id); + } + await this.telegram.sendMessage('Reply to a review message to ask about that PR, or see /help.'); + return; + } + const need = (): number | null => { + if (cmd.prNumber === undefined) { + void this.telegram.sendMessage(`Which PR? /${cmd.command} 123`); + return null; + } + return cmd.prNumber; + }; + switch (cmd.command) { + case 'start': + case 'help': + await this.telegram.sendMessage(HELP); + return; + case 'status': + await this.sendStatus(); + return; + case 'scan': { + const r = await this.scanOnce('command'); + await this.telegram.sendMessage( + `Scanned: ${r.queued.length ? `queued ${r.queued.map((n) => `#${n}`).join(', ')}` : 'nothing new'}${ + r.closed.length ? `; closed ${r.closed.map((n) => `#${n}`).join(', ')}` : '' + }.` + ); + return; + } + case 'review': + case 'rescan': { + const n = need(); + if (n === null) return; + await this.queueByCommand(n); + return; + } + case 'report': { + const n = need(); + if (n !== null) await this.sendReport(n); + return; + } + case 'summary': { + const n = need(); + if (n === null) return; + const rec = this.store.pr(n); + if (!rec?.report) await this.telegram.sendMessage(`No review of #${n} yet.`); + else await this.sendSummary(rec); + return; + } + case 'draft': { + const n = need(); + if (n !== null) await this.sendDraft(n); + return; + } + case 'post': { + const n = need(); + if (n !== null) await this.startConfirm('post', n); + return; + } + case 'merge': { + const n = need(); + if (n !== null) await this.startConfirm('merge', n); + return; + } + case 'close': { + const n = need(); + if (n === null) return; + if (cmd.rest) await this.startConfirm('close', n, cmd.rest); + else await this.askCloseReason(n); + return; + } + case 'approve': + case 'approveci': { + const n = need(); + if (n !== null) await this.approveCi(n); + return; + } + case 'ask': { + const n = need(); + if (n === null) return; + if (!cmd.rest) { + await this.telegram.sendMessage(`Ask what? /ask ${n} does this handle X?`); + return; + } + this.startFollowup(n, cmd.rest, msg.message_id); + return; + } + case 'pause': + this.store.state.paused = true; + this.store.save(); + await this.telegram.sendMessage('⏸ Auto-review paused. /review N still works; /resume to continue.'); + return; + case 'resume': { + this.store.state.paused = false; + this.store.save(); + const r = await this.scanOnce('resume'); + await this.telegram.sendMessage( + `▶️ Auto-review resumed${r.queued.length ? `; queued ${r.queued.map((n) => `#${n}`).join(', ')}` : ''}.` + ); + return; + } + default: + await this.telegram.sendMessage(`Unknown command /${escapeHtml(cmd.command)}. See /help.`); + } + } + + private async handleCallback(cb: TelegramCallbackQuery): Promise { + const ack = (text?: string) => this.telegram.answerCallback(cb.id, text).catch(() => undefined); + if (!cb.message || !this.telegram.isOurChat(cb.message.chat.id)) { + await ack(); + return; + } + const parsed = parseCallback(cb.data); + if (!parsed) { + await ack(); + return; + } + const n = parsed.prNumber; + switch (parsed.action) { + case 'report': + await ack('Sending the report…'); + await this.sendReport(n); + return; + case 'draft': + await ack(); + await this.sendDraft(n); + return; + case 'review': { + await ack(); + await this.queueByCommand(n); + return; + } + case 'merge': + await ack(); + await this.startConfirm('merge', n); + return; + case 'post': + await ack(); + await this.startConfirm('post', n); + return; + case 'close': + await ack(); + await this.askCloseReason(n); + return; + case 'approveci': + await ack(); + await this.approveCi(n); + return; + case 'confirm': + await ack(); + await this.runConfirmed(parsed.target ?? '', n, parsed.nonce ?? '', cb.message.message_id); + return; + case 'cancel': + delete this.store.state.pending[parsed.nonce ?? '']; + this.store.save(); + await this.telegram.editReplyMarkup(cb.message.message_id, { inline_keyboard: [] }); + await ack('Cancelled'); + return; + default: + await ack(); + } + } + + private async queueByCommand(n: number): Promise { + let rec = this.store.pr(n); + if (!rec) { + try { + rec = this.store.upsertPr(await getPrDetail(this.cfg.githubRepo, n)); + this.store.save(); + } catch (err) { + await this.telegram.sendMessage(`Could not load #${n}: ${escapeHtml(errText(err))}`); + return; + } + } + rec.failedAttempts = 0; + const result = this.enqueueReview(n, { front: true }); + if (result === 'busy') await this.telegram.sendMessage(`#${n} is being reviewed right now.`); + else { + const ahead = this.reviewing !== null ? ` after #${this.reviewing} finishes` : ''; + await this.telegram.sendMessage(`Queued #${n} for review${ahead}.`); + } + } + + private async sendStatus(): Promise { + const rows = this.store.openPrs().map((r) => ({ + number: r.number, + title: r.title, + author: r.author, + verdict: r.verdict, + status: r.status, + ci: r.ci, + mergeable: r.mergeable, + isDraft: r.isDraft, + })); + let text = formatStatusList(rows, this.store.state.paused); + const live = [ + this.reviewing !== null ? `reviewing #${this.reviewing}` : '', + this.reviewQueue.length ? `queue: ${this.reviewQueue.map((n) => `#${n}`).join(', ')}` : '', + ] + .filter(Boolean) + .join(' · '); + if (live) text += `\n\n${live}`; + for (const chunk of splitTelegramMessage(text)) await this.telegram.sendMessage(chunk); + } + + /** The report message with its buttons; also behind /summary N to bring the buttons back. */ + async sendSummary(rec: PrRecord): Promise { + if (!rec.report) return; + const summaryPr: PrSummary = { + number: rec.number, + title: rec.title, + author: rec.author, + headSha: rec.reviewedSha ?? rec.headSha, + baseRef: 'master', + headRef: '', + isDraft: rec.isDraft, + mergeable: rec.mergeable, + mergeState: '', + additions: rec.additions ?? 0, + deletions: rec.deletions ?? 0, + changedFiles: rec.changedFiles ?? 0, + updatedAt: rec.updatedAt, + url: rec.url, + isCrossRepository: true, + labels: [], + }; + const ci = rec.ci ?? 'none'; + const text = formatTelegramSummary(summaryPr, rec.report, { ci, durationMin: rec.reviewDurationMin }); + const keyboard = buildReportKeyboard(rec.number, { ci, hasDraft: Boolean(rec.report.draftComment) }); + const msgId = await this.telegram.sendMessage(text, { replyMarkup: { inline_keyboard: keyboard } }); + rec.telegramMessageId = msgId; + this.store.rememberMessage(msgId, rec.number); + this.store.save(); + } + + private async sendReport(n: number): Promise { + const rec = this.store.pr(n); + if (!rec?.reportMdPath || !existsSync(rec.reportMdPath)) { + await this.telegram.sendMessage(`No report for #${n} yet.`); + return; + } + const content = readFileSync(rec.reportMdPath, 'utf8'); + if (content.length <= REPORT_INLINE_MAX) { + const id = await this.telegram.sendPlain(`📄 Review of #${n}\n\n${content}`); + this.store.rememberMessage(id, n); + this.store.save(); + return; + } + await this.telegram.sendDocument(`pr-${n}-review.md`, content, `📄 Review of #${n} · ${rec.title}`.slice(0, 1000)); + } + + private async sendDraft(n: number): Promise { + const rec = this.store.pr(n); + const draft = rec?.report?.draftComment; + if (!draft) { + await this.telegram.sendMessage(`No draft comment for #${n}.`); + return; + } + for (const chunk of splitTelegramMessage( + `💬 Draft comment for #${n} (not posted; /post ${n} to post it):\n\n${draft}` + )) { + const id = await this.telegram.sendPlain(chunk); + this.store.rememberMessage(id, n); + } + this.store.save(); + } + + private async askCloseReason(n: number): Promise { + const id = await this.telegram.sendMessage( + `Reply to this message with the closing comment for #${n} (it is posted on the PR when you confirm), or use /close ${n} reason.` + ); + this.store.state.reasonPrompts[String(id)] = n; + this.store.save(); + } + + private async approveCi(n: number): Promise { + const rec = this.store.pr(n); + if (!rec) { + await this.telegram.sendMessage(`Unknown PR #${n}.`); + return; + } + try { + const ci = await getCiStatus(this.cfg.githubRepo, rec.headSha); + const waiting = ci.runs.filter((r) => r.conclusion === 'action_required'); + if (!waiting.length) { + await this.telegram.sendMessage(`Nothing to approve for #${n} (CI: ${ci.state}).`); + return; + } + for (const run of waiting) await approveWorkflowRun(this.cfg.githubRepo, run.id); + rec.ci = 'pending'; + this.store.save(); + await this.telegram.sendMessage( + `▶️ Approved ${waiting.length} workflow run${waiting.length === 1 ? '' : 's'} for #${n}; CI is starting.` + ); + } catch (err) { + await this.telegram.sendMessage(`⚠️ Approving CI for #${n} failed: ${escapeHtml(errText(err))}`); + } + } + + // ---- confirmations for GitHub writes -------------------------------------- + + private async startConfirm(action: PendingConfirm['action'], n: number, reason?: string): Promise { + const rec = this.store.pr(n); + if (!rec) { + await this.telegram.sendMessage(`Unknown PR #${n}.`); + return; + } + let text: string; + if (action === 'post') { + const draft = rec.report?.draftComment; + if (!draft) { + await this.telegram.sendMessage(`No draft comment for #${n}.`); + return; + } + for (const chunk of splitTelegramMessage(draft)) await this.telegram.sendPlain(chunk); + text = `📮 Post the comment above on #${n} · ${escapeHtml(rec.title)}? It goes out under your GitHub account.`; + } else if (action === 'close') { + if (!reason?.trim()) { + await this.askCloseReason(n); + return; + } + text = `🗑 Close #${n} · ${escapeHtml(rec.title)} (${escapeHtml(rec.author)}) with this comment?\n\n${escapeHtml(reason.trim())}`; + } else { + let fresh: PrSummary | undefined; + let ci: CiStatus | undefined; + try { + fresh = await getPrDetail(this.cfg.githubRepo, n); + ci = await getCiStatus(this.cfg.githubRepo, fresh.headSha); + } catch (err) { + await this.telegram.sendMessage(`Could not check #${n} before merging: ${escapeHtml(errText(err))}`); + return; + } + if (fresh.mergeable === 'CONFLICTING') { + await this.telegram.sendMessage(`#${n} conflicts with master; it needs a rebase before it can be merged.`); + return; + } + const notes: string[] = []; + if (ci.state === 'failed') notes.push('⚠️ CI is red'); + if (ci.state === 'awaiting-approval') notes.push('⚠️ CI never ran (waiting for your approval)'); + if (ci.state === 'pending') notes.push('⏳ CI still running'); + if (ci.state === 'none') notes.push('⚠️ no CI runs for this head'); + if (rec.reviewedSha && rec.reviewedSha !== fresh.headSha) notes.push('⚠️ new commits since the review'); + if (fresh.isDraft) notes.push('⚠️ still a draft'); + if (rec.verdict && rec.verdict !== 'merge' && rec.verdict !== 'merge-with-fixes') + notes.push(`⚠️ the review said ${rec.verdict.replace(/-/g, ' ')}`); + text = + `✅ Merge #${n} · ${escapeHtml(fresh.title)} (${escapeHtml(fresh.author)}) into ${escapeHtml(fresh.baseRef)} with a merge commit?` + + `\n${fresh.mergeable === 'MERGEABLE' ? 'mergeable' : 'mergeability unknown'} · CI ${ci.state} · head ${fresh.headSha.slice(0, 8)}` + + (notes.length ? `\n${notes.join('\n')}` : ''); + } + const nonce = randomBytes(4).toString('hex'); + const pending: PendingConfirm = { + action, + prNumber: n, + createdAt: new Date().toISOString(), + reason: reason?.trim(), + }; + const id = await this.telegram.sendMessage(text, { + replyMarkup: { inline_keyboard: confirmKeyboard(action, n, nonce) }, + }); + pending.messageId = id; + this.store.state.pending[nonce] = pending; + this.store.rememberMessage(id, n); + this.store.save(); + } + + private async runConfirmed(target: string, n: number, nonce: string, messageId: number): Promise { + const pending = this.store.state.pending[nonce]; + delete this.store.state.pending[nonce]; + this.store.save(); + const fresh = pending && pending.prNumber === n && pending.action === target; + const expired = !pending || Date.now() - Date.parse(pending.createdAt) > CONFIRM_TTL_MS; + await this.telegram.editReplyMarkup(messageId, { inline_keyboard: [] }); + if (!fresh || expired) { + await this.telegram.sendMessage(`That confirmation is no longer valid; run the command again.`); + return; + } + const rec = this.store.pr(n); + try { + switch (pending.action) { + case 'merge': { + await mergePr(this.cfg.githubRepo, n); + this.selfClosed.add(n); + const fixes = + rec?.verdict === 'merge-with-fixes' + ? ' The review listed fixes to apply at merge time; they are not applied by merging (see /report).' + : ''; + await this.telegram.sendMessage(`🎉 Merged #${n}${rec ? ` · ${escapeHtml(rec.title)}` : ''}.${fixes}`); + this.scheduleScan(5000); + return; + } + case 'close': { + await closePr(this.cfg.githubRepo, n, pending.reason ?? ''); + this.selfClosed.add(n); + await this.telegram.sendMessage(`🔒 Closed #${n}${rec ? ` · ${escapeHtml(rec.title)}` : ''}.`); + this.scheduleScan(5000); + return; + } + case 'post': { + const draft = rec?.report?.draftComment; + if (!draft) throw new Error('the draft comment is gone'); + await commentPr(this.cfg.githubRepo, n, draft); + await this.telegram.sendMessage(`📮 Posted the review comment on #${n}.`); + return; + } + } + } catch (err) { + await this.telegram.sendMessage(`⚠️ ${pending.action} on #${n} failed: ${escapeHtml(errText(err))}`); + } + } +} diff --git a/scripts/pr-bot/codeman-client.ts b/scripts/pr-bot/codeman-client.ts new file mode 100644 index 00000000..0efde9c5 --- /dev/null +++ b/scripts/pr-bot/codeman-client.ts @@ -0,0 +1,268 @@ +/** + * @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' }; + +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( + method: string, + path: string, + body?: unknown, + query?: Record, + timeoutMs = 60_000 + ): Promise { + 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 = { 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 = {}; + 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 { + const data = await this.request('GET', '/api/sessions'); + return Array.isArray(data) ? data : (data.sessions ?? []); + } + + async getSession(id: string): Promise { + return this.request('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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.request('POST', `/api/sessions/${id}/input`, { input, useMux: true, clientId, seq }); + } + + async lastResponse(id: string): Promise { + 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 { + 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 { + 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; + while (true) { + if (wait && !wait.timedOut) return toOutcome(wait); + if (opts.isDone?.()) return { kind: 'stop' }; + 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' }; +} diff --git a/scripts/pr-bot/config.ts b/scripts/pr-bot/config.ts new file mode 100644 index 00000000..93d75c0f --- /dev/null +++ b/scripts/pr-bot/config.ts @@ -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 { + const out: Record = {}; + 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, + 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 { + 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 = {}; + 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})`); + } +} diff --git a/scripts/pr-bot/github.ts b/scripts/pr-bot/github.ts new file mode 100644 index 00000000..69dce2d7 --- /dev/null +++ b/scripts/pr-bot/github.ts @@ -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 { + 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 { + 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 { + 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(); + 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 { + 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 { + 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 { + return gh(['pr', 'merge', String(number), '--repo', repo, '--merge'], { timeoutMs: 120_000 }); +} + +export async function closePr(repo: string, number: number, comment: string): Promise { + 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 { + return gh(['pr', 'comment', String(number), '--repo', repo, '--body-file', '-'], { input: body }); +} + +export async function ghAuthOk(): Promise { + try { + await gh(['auth', 'status']); + return true; + } catch { + return false; + } +} diff --git a/scripts/pr-bot/main.ts b/scripts/pr-bot/main.ts new file mode 100644 index 00000000..6445c737 --- /dev/null +++ b/scripts/pr-bot/main.ts @@ -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 { + console.log(`\n--- telegram (html) ---\n${text}\n---`); + return this.nextId++; + } + async sendPlain(text: string): Promise { + console.log(`\n--- telegram (plain) ---\n${text}\n---`); + return this.nextId++; + } + async editReplyMarkup(): Promise {} + async deleteMessage(): Promise {} + async answerCallback(): Promise {} + async sendDocument(filename: string, content: string): Promise { + console.log(`\n--- telegram document ${filename} (${content.length} chars) ---`); + } + async getUpdates(): Promise<[]> { + return []; + } + async setMyCommands(): Promise {} +} + +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(); + 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 { + 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) => { + 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 { + 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 { + 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 { + const number = parseInt(args.find((a) => /^\d+$/.test(a)) ?? '', 10); + if (!Number.isFinite(number)) throw new Error('usage: review [--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 { + const number = parseInt(args[0] ?? '', 10); + if (!Number.isFinite(number)) throw new Error('usage: notify '); + 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 { + 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 { + 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 [--no-telegram] | install-service | uninstall-service' + ); + process.exit(2); + } +} + +main().catch((err) => { + console.error((err as Error).stack ?? String(err)); + process.exit(1); +}); diff --git a/scripts/pr-bot/report.ts b/scripts/pr-bot/report.ts new file mode 100644 index 00000000..d4a45208 --- /dev/null +++ b/scripts/pr-bot/report.ts @@ -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 = { blocker: '🔴', major: '🟠', minor: '🟡', nit: '⚪' }; +const VERDICT_LABEL: Record = { + merge: '✅ MERGE', + 'merge-with-fixes': '🟢 MERGE WITH FIXES', + 'request-changes': '🟠 REQUEST CHANGES', + close: '❌ CLOSE', + 'needs-discussion': '💬 NEEDS DISCUSSION', +}; +const CI_LABEL: Record = { + 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, '>'); +} + +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; + 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; + 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; + 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 { + const out: Record = { 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 ? ` ${escapeHtml(f.file)}${f.line ? `:${f.line}` : ''}` : ''; + 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 `Checks: ${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 = + `🔍 PR #${pr.number} · ${escapeHtml(truncate(pr.title, 120))}\n` + + `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' : ''}\n` + + `${escapeHtml(pr.url)}\n`; + const verdict = `\n${VERDICT_LABEL[report.verdict]} (confidence ${report.confidence}${report.scope === 'mixed' ? ', mixed scope' : ''})\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 ? `\nFindings (${countStr}):\n` : '\nFindings: none\n'; + + const checks = checksLine(report.checks); + const recommendation = report.recommendation ? `\nRecommendation: ${escapeHtml(report.recommendation)}\n` : ''; + const footer = meta.durationMin !== undefined ? `\nreview took ${meta.durationMin} min` : ''; + + 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 ? `… ${hidden} more in the full report\n` : ''; + return fixed + lines.join('') + more + tail; +} + +export function formatReviewFailure( + pr: Pick, + reason: string +): string { + return ( + `⚠️ PR #${pr.number} · ${escapeHtml(truncate(pr.title, 120))}\n` + + `by ${escapeHtml(pr.author)}\n${escapeHtml(pr.url)}\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} #${r.number} ${escapeHtml(truncate(r.title, 60))} (${escapeHtml(r.author)}${flags ? `; ${flags}` : ''})`; + }); + return `${paused ? '⏸ auto-review paused\n' : ''}Open PRs (${rows.length})\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>( + 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]; +} diff --git a/scripts/pr-bot/review-task.ts b/scripts/pr-bot/review-task.ts new file mode 100644 index 00000000..b3192f8a --- /dev/null +++ b/scripts/pr-bot/review-task.ts @@ -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 --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 -- \`, ...). +- 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/.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 -- \`) + 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. +`; +} diff --git a/scripts/pr-bot/state.ts b/scripts/pr-bot/state.ts new file mode 100644 index 00000000..c87078c7 --- /dev/null +++ b/scripts/pr-bot/state.ts @@ -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; + pending: Record; + /** Telegram message id -> PR number, so a reply to any of the bot's messages finds its PR. */ + messages: Record; + /** Telegram message id -> PR number for "reply with the closing reason" prompts. */ + reasonPrompts: Record; +} + +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; + 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]; + } +} diff --git a/scripts/pr-bot/telegram.ts b/scripts/pr-bot/telegram.ts new file mode 100644 index 00000000..54ff9455 --- /dev/null +++ b/scripts/pr-bot/telegram.ts @@ -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(method: string, body?: Record, timeoutMs = 30_000): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.call('answerCallbackQuery', { callback_query_id: callbackId, text }); + } + + async sendDocument(filename: string, content: string, caption?: string): Promise { + 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 { + return this.call( + 'getUpdates', + { offset, timeout: timeoutSec, allowed_updates: ['message', 'callback_query'] }, + (timeoutSec + 15) * 1000 + ); + } + + async setMyCommands(commands: { command: string; description: string }[]): Promise { + 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; +} diff --git a/scripts/pr-bot/worktree.ts b/scripts/pr-bot/worktree.ts new file mode 100644 index 00000000..a8b23787 --- /dev/null +++ b/scripts/pr-bot/worktree.ts @@ -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/`) 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 `. + * + * 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]`). 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 { + 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 { + 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 { + 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 { + 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 { + 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. + } +} diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index a2ae7945..ac37a9fe 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -283,6 +283,7 @@ 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 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` | diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 41b3b27c..947253e1 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -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 diff --git a/src/cli.ts b/src/cli.ts index 874d0d22..2da7fac7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,6 +16,7 @@ import { isAbsolute, join } from 'node:path'; import { homedir } from 'node:os'; import { dataPath } from './config/instance.js'; import { 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 to bind to', process.env.CODEMAN_HOST || '127.0.0.1') .option('-p, --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 ', + '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 ', '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')); } diff --git a/src/config/base-path.ts b/src/config/base-path.ts new file mode 100644 index 00000000..0ac9d1ed --- /dev/null +++ b/src/config/base-path.ts @@ -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 + 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; +} diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index c63f9ee7..0add111f 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -322,7 +322,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(), ]); diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 1a814073..9f613cc1 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -210,7 +210,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. }, diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index deb2305b..dff70bdc 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -442,7 +442,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. * diff --git a/src/daemon-control.ts b/src/daemon-control.ts index 52a6ac00..cb09f3d7 100644 --- a/src/daemon-control.ts +++ b/src/daemon-control.ts @@ -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'); diff --git a/src/docker-export.ts b/src/docker-export.ts index e1e259e3..c812015c 100644 --- a/src/docker-export.ts +++ b/src/docker-export.ts @@ -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 { diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index 8d5c5c52..2cfa6502 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -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-` 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): 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 + docker: Pick ): Promise { 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 + docker: Pick ): Promise { + // 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 `). */ + 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 +): Promise { + 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, + modes: SessionMode[] = [], + containerWorkdir?: string +): Promise { + 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 ` 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, + path: string +): Promise { + 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 }); diff --git a/src/hooks-config.ts b/src/hooks-config.ts index 534993f0..687ee36d 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -366,19 +366,33 @@ export function generateHooksConfig(): { hooks: Record } { // 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 } { 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 `.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 // 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(); diff --git a/src/session.ts b/src/session.ts index ca5da833..a2ba1b1f 100644 --- a/src/session.ts +++ b/src/session.ts @@ -156,6 +156,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 +443,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 = ''; @@ -660,6 +676,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. */ @@ -723,6 +741,13 @@ export class Session extends EventEmitter { // response viewer re-derive the live conversation without waiting for the // user to type again. this._lastSubmitAt = config.lastSubmitAt ?? 0; + // Restored 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; @@ -921,6 +946,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; @@ -978,11 +1017,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; @@ -1416,6 +1482,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 @@ -1955,6 +2027,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 { @@ -1999,8 +2076,19 @@ export class Session extends EventEmitter { // 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 = - this._resumeSessionId || this._ompConfig?.resumeSessionId || this._codexConfig?.resumeSessionId || this.id; + 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 @@ -2104,8 +2192,16 @@ export class Session extends EventEmitter { // 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 = - this._resumeSessionId || this._ompConfig?.resumeSessionId || this._codexConfig?.resumeSessionId || this.id; + 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); @@ -3232,6 +3328,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 diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 8633bb51..9fdd2bda 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -951,14 +951,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 +1003,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 +1029,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 +1057,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 +1073,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)}`; } @@ -1725,7 +1772,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); } diff --git a/src/types/api.ts b/src/types/api.ts index b63268af..178bf9c3 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -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. @@ -167,6 +171,24 @@ 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; }; } diff --git a/src/types/session.ts b/src/types/session.ts index 3c34b49d..06aed46b 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -244,6 +244,33 @@ export interface DockerCase { containerWorkdir?: string; /** Container name (default codeman-case-). */ 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). diff --git a/src/web/middleware/auth.ts b/src/web/middleware/auth.ts index af0d0cfe..adbd9461 100644 --- a/src/web/middleware/auth.ts +++ b/src/web/middleware/auth.ts @@ -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, authFailures: StaleExpirationMap, hookSecretFailures: StaleExpirationMap, - userFailures: StaleExpirationMap + userFailures: StaleExpirationMap, + 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; diff --git a/src/web/public/app.js b/src/web/public/app.js index c80d526c..cd95f9da 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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; @@ -1545,7 +1549,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. // @@ -2183,15 +2187,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'; @@ -2351,19 +2366,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 +2798,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; diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 80308150..0302807f 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -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 `` 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 `` — 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 ``), 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 // ═══════════════════════════════════════════════════════════════ @@ -958,6 +1015,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', diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index fbdcc296..4c47e7fa 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -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.': diff --git a/src/web/public/index.html b/src/web/public/index.html index 95cf7149..3de02b40 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2589,8 +2589,15 @@