diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 1d5433b4..7402d884 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "codeman", "source": "./plugins/codeman", "description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.", - "version": "1.29.1", + "version": "1.30.0", "author": { "name": "Ark0N", "url": "https://github.com/Ark0N" diff --git a/CHANGELOG.md b/CHANGELOG.md index c2740d9e..10fdcd21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # aicodeman +## 1.30.0 + +### Minor Changes + +- da933d7: Offer to rebuild the sessions a host reboot destroyed. A reboot takes the tmux server down with it, so every pane dies and the board comes up empty. Codeman now works out what was running, and the board offers to restore it behind a click. The conversations come back; the terminal scrollback does not, and the banner says so. + +### Patch Changes + +- a1c35da: Stop a phone keyboard losing the last character of every message it sends. Android soft keyboards commit the last typed character and send the Enter key in one InputConnection transaction, so the `input` event and the Enter keydown are both processed before any zero-delay timer runs. The orphaned-input recovery from #388 only resolved its candidate on such a timer, and lost it both ways: xterm emits `\r` synchronously from the Enter keydown, so the local-echo composer submitted the prompt before the recovered character existed, and that `\r` bumped the "did xterm speak for this keystroke" counter, so the candidate then stood itself down and dropped the character outright. Pending candidates are now drained synchronously at the next keydown, from xterm's custom key handler, which runs before xterm processes that key, so the counter still holds the value it had while the candidate's own keystroke was current, and the recovered byte reaches the composer ahead of the Enter. Typing on a physical keyboard is unaffected: there, the timer has already resolved the candidate before the next key arrives. +- 3f2928a: The installer's hint for a launcher-only CLI (DeepSeek today) now says why it is a docs link rather than a command you can run, and points at the thing that resolves it: the package installs a launcher that still needs a terminal profile, and Codeman's Run menu can add one in a click. Driven by a generated `CLI_LAUNCHER_ONLY` flag rather than an id check, so it covers any future entry of that shape. Also removes three dead lookup helpers and two never-read generated arrays from `install.sh`, skips a disabled entry's probe instead of filtering it afterwards, and corrects a comment that claimed the non-interactive default is always Claude Code (on a wget-only host its curl one-liner is filtered out first). +- 0e1191b: Maintainer fixes applied while landing the above. A session restored after a reboot keeps the name you gave it (the rebuild dropped the field that records who named a session, so a hand-renamed session came back looking auto-named and the next prompt overwrote it), and no longer types `continue` into itself on its own: a pending auto-resume stamp from before the reboot is dropped rather than re-armed, since the pane is new and one click could otherwise arm several unattended prompts at once. Auto-resume itself stays on and re-arms on the next real usage-limit message. The restore offer is also hidden in a detached single-session window, which has no tab strip to put restored sessions in, and a conversation that goes live while an earlier session in the same batch is starting is no longer restored a second time. +- 0e1191b: ### Thanks + - @irisitymichaelgrundberg for the reboot-restore banner (#442), and for the three real reboots behind it rather than a mocked one. + - @shenlvkang-collab for tracking down why Android keyboards lost the last character of every message (#441), including the half where the character was not late but gone. + - @opticon454 for going back and closing out the loose ends left as "worth knowing rather than fixing" after #380 (#429). + +- de864e7: Keep the terminal anchored where you are reading while an agent streams (#358). Scrolling up during a Codex response could still be dragged back to the live bottom by the next redraw: the flush captured the viewport before writing and restored it immediately after, but xterm parses asynchronously, so at that moment the buffer had not moved yet, the restore compared the anchor against itself and did nothing, and the redraw landed a tick later with nothing left to pull the view back. The restore now runs inside xterm's own write callback, which is the first point at which the redraw's effect exists, and it holds across consecutive and chunked redraws. It is dropped if you switch sessions or a history replay starts before the write parses, since the anchor indexes the buffer it was captured from. + ## 1.29.1 ### Patch Changes diff --git a/CLAUDE.md b/CLAUDE.md index 708594bd..c9e0b1cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -> Deep implementation detail lives in [`docs/architecture-invariants.md`](docs/architecture-invariants.md). This file holds the rules that prevent mistakes; that file holds the mechanisms, file inventories, and the history behind each rule. Pointers below are written as `→ architecture-invariants#anchor`. When the goal is raw throughput, [`docs/SPEEDRUN.md`](docs/SPEEDRUN.md) is the fast-execution protocol (it removes ceremony, never the safety rules here). +> Deep implementation detail lives in [`docs/architecture-invariants.md`](docs/architecture-invariants.md). This file holds the rules that prevent mistakes; that file holds the mechanisms, file inventories, and the history behind each rule. Pointers below are written as `→ architecture-invariants#anchor`. When the goal is raw throughput, [`docs/SPEEDRUN.md`](docs/SPEEDRUN.md) is the fast-execution protocol (it removes ceremony, never the safety rules here). The user-facing manual is `docs/wiki/` (mirrored to the GitHub wiki by CI; see the CI note under Additional Commands), and `AGENTS.md` deliberately just points here. > > **This file is in `.prettierignore` on purpose.** Prettier's markdown printer escapes underscores inside the glob-heavy paths used throughout (`agent-*.jsonl` became `agent-\_.jsonl`, collapsing backtick spans and corrupting a whole paragraph). Do not remove the ignore entry, and do not run `prettier --write` on it. > @@ -77,7 +77,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.29.1 (must match `package.json`) +**Version**: 1.30.0 (must match `package.json`) ## Project Overview @@ -120,11 +120,11 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | 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` | | 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). +**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 9 Playwright tests; globs live in `config/test-suites.ts`), followed by the **`packages/xterm-zerolag-input` package tests** (a bare `npx vitest run` in that directory; its vitest is hoisted by the root `npm ci`, so no separate install, and `npm test` at the root does NOT run them). `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). A third workflow, `wiki-sync.yml`, fires only on master pushes touching `docs/wiki/**` and mirrors that directory to the GitHub wiki (browser edits to the wiki are overwritten by the next sync, so fix pages via `docs/wiki/`). **Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`) — config lives in the **`"prettier"` key of `package.json`**, not a `.prettierrc` (keeps the repo root short; editors read it natively). `.prettierignore` stays at the root because Prettier resolves it relative to cwd. ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`. -**Prettier scope is deliberately narrow.** `npm run format` globs only `src/**/*.ts` and `src/web/public/**`, and `.prettierignore` then exempts most of `src/web/public/*.js` (app.js, styles.css, **mobile.css**, index.html, upload.html, and 15 hand-formatted modules) plus `CLAUDE.md`. Those files are hand-formatted by design; `npm run check:public-assets` and `check:frontend-syntax` are what guard them (NUL bytes + JS syntax), not Prettier. Do not "fix" a file by adding it back to Prettier's scope. +**Prettier scope is deliberately narrow.** `npm run format` globs only `src/**/*.ts` and `src/web/public/**` (`lint` only `src/**/*.ts`), and `.prettierignore` then exempts most of `src/web/public/*.js` (app.js, styles.css, **mobile.css**, index.html, upload.html, and 15 hand-formatted modules) plus `CLAUDE.md`. Those files are hand-formatted by design; `npm run check:public-assets` and `check:frontend-syntax` are what guard them (NUL bytes + JS syntax), not Prettier. Do not "fix" a file by adding it back to Prettier's scope. ## Common Gotchas @@ -170,15 +170,15 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | **Search** | `src/search-service.ts` | Pure in-memory core for `GET /api/search` | | **Attachments** | `src/attachment-registry.ts`, `attachment-magic`, `generated-artifact-attachments`, `session-attachment-history`, `document-preview-cache`, `document-thumbnailer`, `document-conversion-limiter`, `config/attachment-guard` | See Key Patterns | | **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`) | `templates/` holds the CLAUDE.md scaffold generated into new cases | -| **Web** | `src/web/server.ts` ★, `sse-events.ts`, `routes/*.ts` (25 modules + barrel; `session-routes.ts` ★), `route-helpers.ts`, `ports/*.ts`, `middleware/auth.ts`, `schemas.ts`, `self-update.ts`, `plan-usage-latest.ts`, `ws-connection-registry.ts`, `heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` | | -| **Frontend** | `src/web/public/app.js` (~6.7K lines, core) + 32 modules + `sw.js` | See Frontend section for the load order, which is authoritative | +| **Web** | `src/web/server.ts` ★, `sse-events.ts`, `routes/*.ts` (27 modules + barrel; `session-routes.ts` ★), `route-helpers.ts`, `ports/*.ts`, `middleware/auth.ts`, `schemas.ts`, `self-update.ts`, `plan-usage-latest.ts`, `ws-connection-registry.ts`, `heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` | | +| **Frontend** | `src/web/public/app.js` (~6.9K lines, core) + 33 modules + `sw.js` (+ `voice-pcm-worklet.js`, fetched from JS, not in the load order) | See Frontend section for the load order, which is authoritative | | **Types** | `src/types/index.ts` (barrel) → 22 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts | ★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`. **Local packages**: `packages/xterm-zerolag-input/` (local echo overlay, single-source, see Gotchas). `packages/gesture-control/` (`codeman-gesture-control`, hand-tracking overlay source, built via `npm run build:gesture`). -**Config**: `src/config/` — 21 files, no barrel (`index.ts`) exists; import from the specific file. +**Config**: `src/config/` — 23 files plus the `cli-registry/` subdir, no barrel (`index.ts`) exists; import from the specific file. ⚠️ There are TWO `config/` directories: the repo-root `config/` holds tooling only (ESLint, knip, the vitest configs, `test-suites.ts`), while runtime config lives in `src/config/`. Throughout this file a bare `config/.ts` in a code context means `src/config/.ts`. **Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap` (⚠ NOT in the barrel — import from `./utils/lru-map.js` directly), `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver`/`gemini-cli-resolver`/`antigravity-cli-resolver`/`pi-cli-resolver`/`grok-cli-resolver`/`deepseek-cli-resolver`/`omp-cli-resolver` (CLI path resolution, one per `SessionMode`, all nine sharing the lookup chain in `cli-executable-resolver`: server PATH, then that CLI's install dirs, then an interactive login shell LAST, since it is the only step that spawns anything and it is what finds nvm/Homebrew installs under a service manager's minimal PATH; ⚠ `pi-`, `grok-` and `deepseek-cli-resolver` additionally probe the binary's identity, since `pi` is a generic name, `grok` has npm squatters, and Debian ships an unrelated `dsh`), `file-query` (⚠ Files-panel search matcher, glob-by-two-pointer, never RegExp), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority), `shell-resolver` (⚠ resolves a real login shell for `mode: 'shell'`; the literal string `$SHELL` used to be expanded by the SERVER's shell, which is empty in a container), `event-loop-monitor` (a sync `execSync` freezes the port while the process stays alive, leaving no trace), `dependency-checker` + `dependency-report` (the `codeman doctor` probe engine, registry in `config/dependency-registry.ts`). @@ -245,6 +245,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **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. +**Reboot restore** (#411/#442, `src/reboot-restore.ts` pure + `web/reboot-restore-registry.ts` + `routes/reboot-restore-routes.ts` + `reboot-restore-ui.js`): a host reboot takes the tmux server with it, so every pane dies and the board comes up empty with no explanation. At boot Codeman works out which sessions that reboot destroyed, holds the plan IN MEMORY (no new state file, and a server restart simply drops the offer), and the banner asks. ⚠️ **The heuristic decides whether to ASK, never whether to act**: two signals have to agree (the socket holds no panes at all while state still lists sessions, AND the host booted after the newest persisted activity), and a wrong yes costs one dismissable line rather than N CLI processes nobody asked for. ⚠️ Rebuilding is TAKE-then-build: entries leave the plan synchronously before the first `await` and the route is single-flighted per owner, so a double-click or a second device cannot put two panes on one conversation. Anything that never became a pane goes BACK on offer, with one deliberate exception, `already-live`, which unlike a missing workspace or a withdrawn grant cannot stop being true. ⚠️ Three things are re-checked at click time rather than trusted from boot (the owner's privilege grant, the workspace still being on disk, and the conversation not already being live), and the already-live sets are read FRESH per iteration rather than snapshotted: the loop awaits a real `startInteractive()` per entry, so a snapshot taken before it is tens of seconds stale by the tenth entry and would miss a conversation the user resumed by hand in that window. The confinement re-check is keyed on the entry's OWNER, never the caller, or an admin spending another user's entry is waved through by `isWorkingDirAllowed`. ⚠️ A rebuilt session comes back **attached, idle and disarmed**: the pane is NEW, so terminal scrollback is gone (the banner says so) while the conversation continues, respawn controllers and Ralph loops are never re-armed, and `reapplyPersistedSessionState(..., { rearmAutoResumeSchedule: false })` keeps auto-resume ENABLED but drops the pre-reboot `autoResumeAt` stamp, or one click has every restored session type `continue` into itself a minute later, unattended. That option exists only for this path; a Codeman restart still re-arms, because the limit footer will not reprint on its own. ⚠️ The rebuild passes `nameSource` through, or the constructor re-infers it from the name and a hand-renamed session shaped like `w-` comes back as `placeholder` for auto-naming to overwrite. ⚠️ Claude-mode only (others carry their conversation id in their own config object), and remote/docker sessions are never offered (`remote-or-docker`), because both need another host or container to be up. ⚠️ A failed rebuild is undone with `discardPartiallyBuiltSession()`, deliberately NOT `cleanupSession()`: the delete path would count the session's tokens into the lifetime totals, demote a pinned record to `stopped` (which this pass reads as an intentional kill, making the session permanently unrestorable) and recursively remove the WORKSPACE's `.claude-images`. ⚠️ `os.uptime()` reports the HOST's uptime, which a container shares, and that cuts both ways: after a genuine host reboot a containerized Codeman does see a short uptime and the banner works, but a container-only restart is invisible to it, which is the case where this would help most. Tests: `test/reboot-restore.test.ts`, `test/routes/reboot-restore-routes.test.ts`, `test/routes/reboot-restore-rebuild-failure.test.ts`, `test/discard-partially-built-session.test.ts`. + **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`. **Read My Mind intent profiles** (phase 1 of `docs/readmymind-plan.md`; `readMyMindEnabled`, SYNCED, default OFF): per-CASE profiles (user-stated `goals` + the user's recent real prompts), keyed by owner + realpath(workingDir) so they survive `/clear`/respawns and multi-user scoping is structural. Capture rides the transcript (`transcript:user_prompt` from `transcript-watcher.ts`), NOT the input paths: `POST /input` sees only programmatic prompts and the WS channel is raw keystrokes. The listener lives inside `startTranscriptWatcher()`'s `if (!watcher)` block (outside it would duplicate per hook event) and is claude-only + gated on the setting per event. Store: `src/intent-store.ts` singleton, `intents.json` written 0600 tmp+rename (prompts can contain secrets; never fed to `/api/search`). Endpoints: GET/PUT/DELETE `/api/sessions/:id/intent` + POST `/api/sessions/:id/readmymind` (`readmymind-routes.ts`, ownership via `findSessionOrFail` WITH `req`; registrations stay the bare `app.('path')` shape, the endpoints.md drift scanner cannot see generics). **Phase 2 (predictor + 🧠 button)**: `readmymind-context.ts` is the PURE budgeted assembler (9 ranked sources, drop order siblings→away→workspace→tools, sections 1-4 truncate only); IO lives in `readmymind-collectors.ts` (transcript TAIL read — the live watcher keeps only a 500-char snippet — + git signals, skipped for remote-SSH cases) and the route; `readmymind-predictor.ts` reuses the AiCheckerBase spawn mechanics standalone (verdict-shaped base vs freeform JSON) as a mutable singleton routes call and tests stub. Claude-mode only (400), one in flight per session (409 CONFLICT), model = `readMyMindModel` setting defaulting to `AI_CHECK_MODEL` (opus, decided). Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`) until the setting is ON; phones hide it in mobile.css and get a keyboard-accessory 🧠 key instead (ships in BOTH bar templates, revealed by the `rmm-enabled` class on the BAR element — setMode() rebuilds button innerHTML, so per-key state would be wiped; synced at init + every `applyHeaderVisibilitySettings()`). Alternate suggestions render as tappable rows that swap into the editable field without losing edits; Rethink rejects the whole shown set and carries the optional steer note (`#readMyMindSteer`, sent as `steer`, shown in ready + empty-result phases, cleared on each open). Suggestions render via value/`textContent` ONLY and Send/Insert go through `POST /input` (server-side, so the sendEnterKey/local-echo trap does not apply) — nothing auto-sends, ever. User guide: `docs/readmymind.md`. @@ -300,7 +302,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ### Frontend -Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. +Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. **Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on ``. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY; `test/entrance-animations.test.ts` pins that property allowlist, plus the rule→keyframes→theme-option chain a style silently does nothing without. ⚠️ **`blur` is the ONE style that puts a `filter` on the terminal container**, against the standing rule, because every alternative was measured against a live xterm and does not work: a `backdrop-filter` veil on `::before` blurs perfectly while STATIC and Chrome silently drops the backdrop the moment ANY animation runs on that pseudo-element (the veil computes `blur(15.3px)` and the text behind it stays razor sharp), and driving the radius from rAF buys the same full-screen blur per frame plus main-thread work. The cost the rule exists to avoid is inherent to blurring a terminal, so the style buys it knowingly: opt-in, OFF by default, one ~520ms run per session open, class straight back off, `will-change` still unset. Worst-case price, headless SwiftShader with no GPU: frame deltas 16.7ms → 33.3ms for the run, against 16.7ms flat for `fade`. Do not generalise it — a second filtered terminal style needs its own measurement. ⚠️ The `blur` connection line animates `filter` too, so both kinds of line hold their glow in **`--line-glow`** and both of its keyframes say `blur(N) var(--line-glow)`: the function lists then match and interpolate, instead of the glow vanishing for the run and popping back (a lineage line's glow is a different colour entirely, set per element). Its 100% frame deliberately omits `opacity` so the endpoint comes from the element's own resting value — 0.9 subagent, 0.72 lineage, 0.95 working — which is what `line-enter-fade`'s hardcoded 0.9 gets wrong. ⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`. @@ -383,7 +385,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L ### API Routes -~228 handlers across 25 route files in `src/web/routes/`: system (56), sessions (34), cases (30), files (17), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), approvals (4), readmymind (4), me (2), teams (2), tab-layout (2), search (1), hooks (1), clipboard (1), status-telemetry (1), voice (1 + the `/ws/voice/stream` relay), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details. +~233 handlers across 27 route files in `src/web/routes/`: system (56), sessions (34), cases (34), files (17), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), approvals (4), readmymind (4), custom-model (5), reboot-restore (3), me (2), teams (2), tab-layout (2), search (1), hooks (1), clipboard (1), status-telemetry (1), voice (1 + the `/ws/voice/stream` relay), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details. **HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`). diff --git a/docs/api-reference.md b/docs/api-reference.md index 4ab301d2..f029de02 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -479,6 +479,48 @@ re-captured, or the item acknowledged), `approval:resolved` (`{ id, sessionId, k `resolution` one of `answered | resolved_in_terminal | superseded | session_ended | dismissed | expired`). +## Reboot restore + +A host reboot takes the tmux server down with it, so every pane dies and the +board comes up empty. At boot Codeman works out which sessions the reboot +destroyed and holds that plan in memory, and these endpoints let a client offer +it to the user. Nothing creates a pane until the user asks: the boot-time reboot +heuristic decides whether to ASK, never whether to act. + +Claude-mode sessions only (others carry their conversation id in their own +config object); remote and docker sessions are never offered, because both need +another host or container to be up. The plan is in-memory, so a server restart +drops it and the offer is gone; the conversations themselves are unaffected, +since they live in the CLI's own transcript store and stay reachable from the +Resume list. A plan nobody spends expires after 24 hours. + +- `GET /api/v1/reboot-restore` → `{ sessions: RestorableSession[], + scrollbackRestored: false }`, ownership-scoped in multi-user mode. + `RestorableSession`: `{ id, name?, workingDir, mode, owner? }`. The persisted + record itself is never sent. `scrollbackRestored` is always `false` and exists + so a client states it: a restored session is a NEW pane, so the conversation + continues and the terminal history does not. +- `POST /api/v1/reboot-restore/restore` with `{ sessionIds?: string[] }` (omit + to restore everything the caller can see) → `{ restored: RestorableSession[], + skipped: { sessionId, reason }[] }`. `reason` is one of `workspace-missing` + (the directory is gone), `workspace-forbidden` (in multi-user mode it is + outside the workspace of the user the session belongs to, re-checked against + that owner's current grant rather than the caller's), `already-live` (the conversation is already + open, typically resumed by hand from the Resume list), `capacity-reached` + (the global or per-user session cap), or `rebuild-failed` (the agent would not + start, most often a CLI binary missing from the server's PATH). + `409 CONFLICT` when that caller already has a restore running. Entries are + removed from the plan before any pane is built, so a double-click cannot put + two panes on one conversation; anything that never became a pane goes back on + offer, except `already-live`, which cannot stop being true. A restored session + comes back attached, idle and disarmed: respawn controllers and Ralph loops + are never re-armed automatically. +- `POST /api/v1/reboot-restore/dismiss` → `{ dismissed: n }`. Drops the offer + for everything the caller can see. + +Each rebuilt session also emits the ordinary `session:created` SSE event, so +clients other than the one that clicked pick it up without refetching. + ## Read My Mind intent profiles Per-case profiles of what the user is trying to accomplish: user/agent-stated diff --git a/install.sh b/install.sh index 5be359c2..29b6a13d 100755 --- a/install.sh +++ b/install.sh @@ -93,8 +93,7 @@ export PUPPETEER_SKIP_DOWNLOAD="${PUPPETEER_SKIP_DOWNLOAD:-1}" CLI_IDS=('claude' 'shell' 'opencode' 'codex' 'gemini' 'antigravity' 'pi' 'grok' 'deepseek' 'omp') CLI_LABELS=('Claude' 'Shell' 'OpenCode' 'Codex' 'Gemini' 'Antigravity' 'Pi' 'Grok' 'DeepSeek' 'OMP') CLI_ENABLED=(1 1 1 1 1 1 1 1 1 1) -CLI_KIND=('agent' 'shell' 'agent' 'agent' 'agent' 'agent' 'agent' 'agent' 'agent' 'agent') -CLI_NPM=('@anthropic-ai/claude-code' '' 'opencode-ai' '@openai/codex' '@google/gemini-cli' '' '@earendil-works/pi-coding-agent' '' '@deepseek-ai/dsh' '') +CLI_LAUNCHER_ONLY=(0 0 0 0 0 0 0 0 1 0) CLI_DOCS=('https://docs.claude.com/claude-code' '' 'https://opencode.ai/docs' 'https://developers.openai.com/codex/cli' 'https://github.com/google-gemini/gemini-cli' 'https://antigravity.google/cli' 'https://pi.dev' 'https://github.com/xai-org/grok-build' 'https://github.com/deepseek-ai/deepseek-harness' 'https://omp.sh') CLI_CMD_LINUX=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' '' 'curl -fsSL https://omp.sh/install | sh') CLI_CMD_DARWIN=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' '' 'brew install can1357/tap/omp') @@ -410,22 +409,6 @@ check_build_tools() { # test/install-sh-detection-parity.test.ts: the process PATH first (each declared # binary name in turn), then each known install path, dir-major. -# Index of "$1" in CLI_IDS -> CLI_IDX, returning 1 with CLI_IDX=-1 when unknown. -# A global rather than an echo because this runs inside loops, and a subshell per -# lookup is a fork per CLI per call site. -CLI_IDX=-1 -_cli_index() { - local want="$1" i - CLI_IDX=-1 - for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do - if [[ "${CLI_IDS[$i]}" == "$want" ]]; then - CLI_IDX=$i - return 0 - fi - done - return 1 -} - # `dsh` is the hardest name of the lot: Debian ships an unrelated `dsh` # (dancer's shell). The server-side resolver settles it by demanding the # harness's own help banner; detection here only feeds the "you have no AI CLI" @@ -465,7 +448,8 @@ _cli_candidate_ok() { # Resolve every CLI in ONE pass, memoized. # -# CLI_FOUND_PATH is parallel to CLI_IDS ('' when not found). CLI_FOUND_COUNT +# CLI_FOUND_PATH is parallel to CLI_IDS ('' when not found, and also '' for a +# DISABLED entry — it is never probed at all, see below). CLI_FOUND_COUNT # counts only ENABLED entries that have a binary to look for, which is what the # "no AI CLI found" gate asks about — `shell` has no binary and must never make # that gate think an agent is installed. @@ -485,6 +469,16 @@ detect_all_clis() { for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do found="" + # A disabled entry is never even probed: every consumer already filters + # on CLI_ENABLED before showing anything, so the command-v/stat calls + # below would be pure waste — and, unlike filtering downstream, skipping + # the probe here is what makes CLI_ENABLED mean "look for it" rather + # than just "offer it once found". + if [[ "${CLI_ENABLED[$i]}" != "1" ]]; then + CLI_FOUND_PATH[$i]="" + continue + fi + # 1. The process PATH, each declared binary name in turn. bin_end=$((${CLI_BIN_OFF[$i]} + ${CLI_BIN_LEN[$i]})) for ((j = ${CLI_BIN_OFF[$i]}; j < bin_end; j++)); do @@ -520,20 +514,6 @@ detect_all_clis() { return 0 } -# Is this CLI installed? Unknown id is "no", never an error. -check_cli() { - detect_all_clis - _cli_index "$1" || return 1 - [[ -n "${CLI_FOUND_PATH[$CLI_IDX]}" ]] -} - -# Where it was found, or nothing. -get_cli_path() { - detect_all_clis - _cli_index "$1" || return 1 - printf '%s\n' "${CLI_FOUND_PATH[$CLI_IDX]}" -} - # ---------------------------------------------------------------------------- # Catalogue helpers # ---------------------------------------------------------------------------- @@ -589,7 +569,12 @@ cli_catalog_names() { # the registry but an empty one here: installing the launcher alone leaves # nothing that can drive a pane, so the generator withholds the command for # any launcherProfile entry (see installCommandFor in generate-cli-catalog.mts) -# and this hint falls through to the docs URL instead. +# and this hint falls through to the docs URL instead — CLI_LAUNCHER_ONLY adds +# one line explaining WHY it is a docs link and not a command, so a user who +# follows that link straight to `npm install -g @deepseek-ai/dsh` (which the +# docs page itself documents) does not land back in the same "installed but +# cannot drive a pane" trap the menu exists to avoid. Data-driven, not an id +# check: any future launcherProfile entry gets the same caveat for free. cli_catalog_print_install_hints() { detect_all_clis local i @@ -601,6 +586,9 @@ cli_catalog_print_install_hints() { echo -e " ${CYAN}${CLI_INSTALL_CMD_TRUSTED[$i]}${NC} # ${CLI_LABELS[$i]}" elif [[ -n "${CLI_DOCS[$i]}" ]]; then echo -e " ${CLI_LABELS[$i]}: see ${CYAN}${CLI_DOCS[$i]}${NC}" + if [[ "${CLI_LAUNCHER_ONLY[$i]}" == "1" ]]; then + echo -e " (installs a launcher only: it still needs a terminal profile, and Codeman's Run menu can add one)" + fi fi done } @@ -679,9 +667,12 @@ offer_ai_cli_install() { local cli_choice="" if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then - # Explicit automation opt-in: default to the first offered entry, - # which is registry order, which is Claude Code (order 0) — the - # same default this prompt has always taken non-interactively. + # Explicit automation opt-in: default to the first OFFERED entry. + # That is registry order, which is Claude Code (order 0), UNLESS + # this is a wget-only host and Claude's curl one-liner was just + # filtered out of offer_idx above — there, the first survivor is + # whichever npm-based entry sorts earliest (Codex today), not + # Claude. Printed either way so the choice is never silent. cli_choice="1" info "CODEMAN_NONINTERACTIVE=1: defaulting to ${CLI_LABELS[${offer_idx[0]}]}" else diff --git a/package-lock.json b/package-lock.json index 22edc788..f9640246 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aicodeman", - "version": "1.29.1", + "version": "1.30.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aicodeman", - "version": "1.29.1", + "version": "1.30.0", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 287879ba..48a185f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "1.29.1", + "version": "1.30.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", diff --git a/plugins/codeman/.claude-plugin/plugin.json b/plugins/codeman/.claude-plugin/plugin.json index 8d16d73b..c2def406 100644 --- a/plugins/codeman/.claude-plugin/plugin.json +++ b/plugins/codeman/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "codeman", "description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.", - "version": "1.29.1", + "version": "1.30.0", "author": { "name": "Ark0N", "url": "https://github.com/Ark0N" diff --git a/scripts/generate-cli-catalog.mts b/scripts/generate-cli-catalog.mts index ce9cd7e4..de0d7934 100644 --- a/scripts/generate-cli-catalog.mts +++ b/scripts/generate-cli-catalog.mts @@ -135,8 +135,7 @@ export function renderInstallShBlock(entries: CliEntry[] = STOCK_CLIS): string { const ids: string[] = []; const labels: string[] = []; const enabled: string[] = []; - const kinds: string[] = []; - const npm: string[] = []; + const launcherOnly: string[] = []; const docs: string[] = []; const cmdLinux: string[] = []; const cmdDarwin: string[] = []; @@ -151,8 +150,11 @@ export function renderInstallShBlock(entries: CliEntry[] = STOCK_CLIS): string { ids.push(shQuote(entry.id as string)); labels.push(shQuote(entry.label)); enabled.push(entry.enabled ? '1' : '0'); - kinds.push(shQuote(entry.kind)); - npm.push(shQuote(entry.discovery.install.npmPackage ?? '')); + // Parallel to CLI_IDS: 1 when this entry's install command installs a launcher rather + // than something that can drive a pane on its own (see installCommandFor above). Purely + // derived from discovery.launcherProfile — install.sh's hint printer reads this to add a + // caveat instead of hardcoding which id it means. + launcherOnly.push(entry.discovery.launcherProfile ? '1' : '0'); docs.push(shQuote(entry.discovery.install.docsUrl ?? '')); cmdLinux.push(shQuote(installCommandFor(entry, 'linux'))); cmdDarwin.push(shQuote(installCommandFor(entry, 'darwin'))); @@ -195,8 +197,7 @@ export function renderInstallShBlock(entries: CliEntry[] = STOCK_CLIS): string { arr('CLI_IDS', ids), arr('CLI_LABELS', labels), arr('CLI_ENABLED', enabled), - arr('CLI_KIND', kinds), - arr('CLI_NPM', npm), + arr('CLI_LAUNCHER_ONLY', launcherOnly), arr('CLI_DOCS', docs), arr('CLI_CMD_LINUX', cmdLinux), arr('CLI_CMD_DARWIN', cmdDarwin), diff --git a/src/reboot-restore.ts b/src/reboot-restore.ts new file mode 100644 index 00000000..40b29b08 --- /dev/null +++ b/src/reboot-restore.ts @@ -0,0 +1,278 @@ +/** + * @fileoverview Decide which sessions a host reboot destroyed and may be rebuilt. + * + * A server restart and a host reboot both leave `reconcileSessions()` reporting + * dead sessions, and they need opposite handling. A server restart leaves the + * tmux panes running, so recovery ATTACHES to them. A host reboot takes the tmux + * server down with it, so there is nothing to attach to and the pane has to be + * created again. This module holds the decision half of that second case, kept + * free of tmux and disk access so it can be unit tested without either. Every + * observation it reads is gathered by the caller and passed in. + * + * "Eligible" here means a session the user did not end on purpose. The rule that + * an intentional kill or detach is never auto-revived is enforced at runtime by + * an in-memory guard in `TmuxManager`, and memory does not survive a reboot. The + * durable equivalent is the record `cleanupSession()` leaves behind. An unpinned + * kill deletes the record outright, so it is already absent here. A pinned kill + * goes through `demoteOrRemoveSession()` and lands as `status: 'stopped'`, which + * is the marker this module refuses. Pruning keeps a pinned record WITHOUT + * touching its status, so a pinned session a reboot killed still reads `idle` or + * `busy` and stays eligible. + * + * ⚠️ Ending the AGENT rather than the session is a shape this module CANNOT + * recognise today, and a reboot restores it. `/exit` ends the CLI inside the + * pane, `remain-on-exit` keeps the pane, and the PTY Codeman owns is the + * `tmux attach-session` process, which stays alive throughout — so no exit + * handler runs, no lifecycle `exit` is logged, and the record keeps both its pid + * and `status: 'idle'`. Nothing durable distinguishes it from a session that was + * simply idle when the power went. Ark0N/Codeman#446 covers making Codeman + * notice the dead pane; until a record can say the agent is gone, this pass will + * offer those sessions back, and the user dismisses or closes them. + * + * The `pid` check below is therefore NOT that rule. It refuses a record whose + * attach process was already gone, which is a session that never started or + * whose pane died outright. + * + * @dependencies types (SessionState), config/cli-registry + * @consumedby web/server (plan build at boot), web/routes/reboot-restore-routes + * + * @module reboot-restore + */ + +import type { SessionState } from './types.js'; +import { getCli } from './config/cli-registry/registry.js'; + +/** Session statuses a reboot restore may rebuild. `stopped` is the kill marker. */ +const RESTORABLE_STATUSES: ReadonlySet = new Set(['idle', 'busy', 'error']); + +/** Observations the reboot heuristic reads. Gathered by the caller, never here. */ +export interface RebootEvidence { + /** Sessions that still had a live pane during reconciliation. */ + livePaneCount: number; + /** Sessions reconciliation just marked dead. */ + deadSessionCount: number; + /** `os.uptime()`, in seconds. */ + uptimeSeconds: number; + /** Newest `lastActivityAt` across the persisted records, in ms since the epoch. */ + newestPersistedActivityAt: number; + /** `Date.now()` when the evidence was gathered, in ms. */ + now: number; +} + +/** + * Decide whether the machine plausibly rebooted rather than the server restarting. + * + * Two signals have to agree. The socket must hold no panes at all while state + * still lists sessions, which rules out an ordinary server restart. The host + * must also have booted after the newest persisted session activity, which is + * the corroboration `os.uptime()` provides cheaply. A wiped tmux socket on a + * long-uptime host fails the second test, so a user who killed the tmux server + * by hand does not get every session offered back to them. + * + * This heuristic decides whether to ASK, never whether to act. A wrong yes costs + * the user a banner they dismiss, because the restore itself waits for a click. + * + * ⚠️ `os.uptime()` reports the HOST's uptime, which a container shares, and that + * cuts BOTH ways rather than simply switching the feature off in Docker. After a + * genuine host reboot a containerized Codeman sees the host's short uptime, so the + * banner DOES appear and the feature works. What it cannot see is a container-only + * restart: the host uptime is long, the boot test fails, and no banner appears + * although every in-container pane is gone (`docker/server.Dockerfile` installs + * tmux inside the Codeman container, and the self-updater restarts the Compose + * deployment by exiting the container, so that is the case where this would help + * most). Failing quiet is the safe direction, and closing the gap needs a boot + * signal the container owns (PID 1's start time, gated on the existing + * `isRunningInContainer()`) rather than a wider heuristic. + */ +export function looksLikeHostReboot(evidence: RebootEvidence): boolean { + if (evidence.deadSessionCount === 0) return false; + if (evidence.livePaneCount > 0) return false; + if (evidence.newestPersistedActivityAt <= 0) return false; + const bootedAt = evidence.now - evidence.uptimeSeconds * 1000; + return bootedAt > evidence.newestPersistedActivityAt; +} + +/** + * Pick the conversation the rebuilt pane should resume. + * + * The chain's tail is the newest conversation the session was holding, which is + * what a compact or a clear leaves behind; `resumeSessionId` covers a session + * that was itself started as a resume, and the session id is the original + * conversation for everything else. + */ +export function resolveResumeConversationId(state: SessionState): string { + const chain = state.claudeSessionChain; + const chainTail = Array.isArray(chain) && chain.length > 0 ? chain[chain.length - 1] : undefined; + return chainTail || state.resumeSessionId || state.id; +} + +/** + * Why one session was passed over. Reported for logging and shown to the user. + * + * The first seven are decided before anything is built. `capacity-reached` and + * `rebuild-failed` can only happen once a click is spending the plan, and they + * are the two the banner must not confuse with a missing workspace: one means + * "try again after closing something", the other means the CLI would not start. + */ +export interface RebootRestoreRejection { + sessionId: string; + reason: + | 'no-persisted-record' + | 'intentionally-ended' + | 'not-running' + | 'respawn-blocked' + | 'remote-or-docker' + | 'unsupported-mode' + | 'no-working-dir' + | 'workspace-missing' + | 'workspace-forbidden' + | 'already-live' + | 'capacity-reached' + | 'rebuild-failed'; +} + +/** One restorable session, as the banner shows it and the rebuild replays it. */ +export interface RebootRestoreEntry { + sessionId: string; + name?: string; + workingDir: string; + owner?: string; + mode: string; + /** The conversation the rebuilt pane resumes. */ + resumeConversationId: string; + /** + * The persisted record, kept whole so the rebuild can replay what it held. + * Read at boot, before pruning deletes it, and held in memory until the click. + */ + state: SessionState; +} + +export interface RebootRestorePlan { + restore: RebootRestoreEntry[]; + skipped: RebootRestoreRejection[]; +} + +/** + * Split the sessions reconciliation just killed into the ones a reboot restore + * may offer and the ones it must leave alone. + * + * @param deadSessionIds Session ids `reconcileSessions()` reported as dead. + * @param persisted The `state.json` session records, which `cleanupStaleSessions()` + * has not pruned yet at the point this runs. + * @param workspaceExists Whether a working directory is still on disk. A tmux + * session can outlive its deleted repo, and rebuilding one there would scaffold + * an empty tree. The caller owns the disk access; the click re-checks, because + * a repo can be deleted between the boot and the click. + */ +export function planRebootRestore( + deadSessionIds: readonly string[], + persisted: Readonly>, + workspaceExists: (workingDir: string) => boolean +): RebootRestorePlan { + const restore: RebootRestoreEntry[] = []; + const skipped: RebootRestoreRejection[] = []; + + for (const sessionId of deadSessionIds) { + const state = persisted[sessionId]; + if (!state) { + // An unpinned kill already deleted the record, so absence IS the guard. + skipped.push({ sessionId, reason: 'no-persisted-record' }); + continue; + } + if (!RESTORABLE_STATUSES.has(state.status)) { + // A pinned kill was demoted to `stopped`. Reviving it would undo the kill. + skipped.push({ sessionId, reason: 'intentionally-ended' }); + continue; + } + if (state.pid === null || state.pid === undefined) { + // No attach process when the record was last written: the session never + // started, or its pane died outright rather than its agent exiting inside a + // surviving pane. Either way there was nothing running to bring back. + // + // ⚠️ This does NOT catch a session the user ended with `/exit`. See the + // module header: that leaves the pid in place, because the pid is the tmux + // attach process and `remain-on-exit` keeps it alive. + // + // Conservative on purpose. A session that somehow persisted no pid while + // genuinely running is not offered, and its conversation stays reachable + // from the Resume list, which is where every session would be without this + // feature. + skipped.push({ sessionId, reason: 'not-running' }); + continue; + } + if (state.respawnBlocked === true) { + // The crash-loop breaker tripped on this pane. Re-creating it restarts the loop. + skipped.push({ sessionId, reason: 'respawn-blocked' }); + continue; + } + if (state.remote || state.docker) { + // Both need another host or a container to be up, which a just-booted machine + // cannot promise. The remote reconnect watcher owns the remote case already. + skipped.push({ sessionId, reason: 'remote-or-docker' }); + continue; + } + // Capability, not a CLI id: this pass resumes by handing the CLI a conversation + // id through the top-level `resumeSessionId`, which only a CLI whose history the + // claude-jsonl reader understands can consume that way. Others carry their thread + // id in their own `Config`, which this pass does not thread through. + if (getCli(state.mode ?? 'claude')?.capabilities.transcript !== 'claude-jsonl') { + skipped.push({ sessionId, reason: 'unsupported-mode' }); + continue; + } + if (!state.workingDir) { + skipped.push({ sessionId, reason: 'no-working-dir' }); + continue; + } + if (!workspaceExists(state.workingDir)) { + skipped.push({ sessionId, reason: 'workspace-missing' }); + continue; + } + restore.push({ + sessionId, + name: state.name, + workingDir: state.workingDir, + owner: state.owner, + mode: state.mode ?? 'claude', + resumeConversationId: resolveResumeConversationId(state), + state, + }); + } + + return { restore, skipped }; +} + +/** + * Drop the entries whose conversation is already on screen. + * + * Hours can pass between the boot that built the plan and the click that spends + * it, and the Resume list can reach the same conversation in the meantime. Two + * panes running `claude --resume` on one conversation is the failure this + * prevents, so a match on either the session id or the conversation id is enough + * to skip the entry. + */ +export function rejectAlreadyLive( + entries: readonly RebootRestoreEntry[], + liveSessionIds: ReadonlySet, + liveConversationIds: ReadonlySet +): RebootRestorePlan { + const restore: RebootRestoreEntry[] = []; + const skipped: RebootRestoreRejection[] = []; + for (const entry of entries) { + if (liveSessionIds.has(entry.sessionId) || liveConversationIds.has(entry.resumeConversationId)) { + skipped.push({ sessionId: entry.sessionId, reason: 'already-live' }); + continue; + } + restore.push(entry); + } + return { restore, skipped }; +} + +/** Newest `lastActivityAt` across persisted records, or 0 when there are none. */ +export function newestPersistedActivity(persisted: Readonly>): number { + let newest = 0; + for (const state of Object.values(persisted)) { + const stamp = state.lastActivityAt ?? state.createdAt ?? 0; + if (stamp > newest) newest = stamp; + } + return newest; +} diff --git a/src/session-env-clamp.ts b/src/session-env-clamp.ts new file mode 100644 index 00000000..b9abf3a6 --- /dev/null +++ b/src/session-env-clamp.ts @@ -0,0 +1,97 @@ +/** + * @fileoverview The env-var half of the multi-user privilege clamp. + * + * A session's `envOverrides` can hand back privilege that the per-CLI config + * clamp removed, so a non-granted owner's overrides get the privileged keys + * stripped before the session is built. The create and resume routes are what + * this bites on: they clamp what a request asked for. + * + * The reboot-restore route calls it as defence in depth, and today it can strip + * nothing. `Session.getEnvOverridesForPersist()` keeps only `CLAUDE_CODE_*` and + * `CLAUDE_CONFIG_DIR` out of a session's overrides, claude's `privilegedEnvKeys` + * are the five `ANTHROPIC_*` names, and that pass admits claude alone — so a + * persisted record cannot carry a clamped key. The call is there for the day the + * persisted set widens. The grant re-resolution that does bite on that path is + * `resolveClaudeModeForUsername`, which recomputes the permission mode. + * + * This lives outside `web/routes` on purpose. The question it answers is about + * session privilege rather than about HTTP, and `cron/cron-service.ts` sets the + * precedent by importing `canUsernameRunPrivilegedCommands` from `user-store.ts` + * directly and re-resolving the owner's grant when a job fires. Every caller here + * re-resolves the grant at the moment it builds a session, for the same reason. + * + * @dependencies user-store (canUsernameRunPrivilegedCommands), config/cli-registry + * @consumedby web/routes/session-routes, web/routes/reboot-restore-routes + * + * @module session-env-clamp + */ + +import { canUsernameRunPrivilegedCommands } from './user-store.js'; +import { enabledClis } from './config/cli-registry/registry.js'; + +/** + * Env-var keys a non-granted owner must not be able to set, because each one + * hands back privilege `clampExternalCliBypassForOwner()` just removed, or redirects a + * credential-resolution endpoint. + * + * The DeepSeek three are reachable because `DSH_*` and `DEEPSEEK_*` are + * allowlisted `envOverrides` prefixes (schemas.ts) — which they have to be, since + * that is also how a user configures the harness's non-privileged knobs. + * + * - `DSH_PERMISSION_MODE` IS the harness's permission switch. Every other CLI's + * bypass is a command-line FLAG, reachable only through the per-CLI config the + * clamp already owns; this one is an env var, so the config clamp alone is + * half a gate. + * - `DSH_HOME` points the launcher at a profile tree, and a profile's plugin code + * executes at BOOT, before any approval row can apply. A user who can write a + * workspace can put a profile in it, so this is the wider of the two. + * - `DEEPSEEK_BASE_URL` aims the provider endpoint, and `_configureCliEnv()` + * forwards the SERVER's own `DEEPSEEK_API_KEY` into every dsh pane before + * `applyEnvOverrides()` runs — so a non-granted owner who could set the base + * URL would have the operator's API key sent as a bearer credential to a host + * of their choosing. (`DEEPSEEK_API_KEY` itself stays overridable: supplying + * your OWN key removes privilege rather than granting it.) + * - `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` are where omp resolves + * credentials from — the same shape as `DEEPSEEK_BASE_URL` above, reachable + * because `OMP_*` is an allowlisted prefix. Unlike DeepSeek, Codeman does not + * forward any operator-held key into an omp pane today (omp's provider + * credentials live in `~/.omp` config files, not env vars), so there is no + * known concrete exfiltration path yet — clamped defensively anyway, since a + * non-granted owner redirecting where a shared multi-tenant deployment + * resolves auth from is not something to allow silently (found in + * Ark0N/Codeman#353 review; omp's own knobs are otherwise mostly `PI_*`, + * already allowlisted for pi and not addressed here — see resolveOmpHome()). + */ +export function ownerClampedEnvKeys(): string[] { + return enabledClis().flatMap((entry) => entry.capabilities.privilegedEnvKeys); +} + +/** + * Env-var half of the multi-user bypass clamp. + * + * `clampExternalCliBypassForOwner()` in `web/routes/session-routes.ts` clamps the + * per-CLI CONFIG, and for every CLI + * but DeepSeek that is the whole story. Here it is not: `applyEnvOverrides()` runs + * AFTER `_configureCliEnv()` in tmux-manager, so an override sent on the SAME + * request lands last and wins, and a non-granted owner could restore + * `danger-full-access` on the very request the config clamp downgraded. + * + * Keys are DROPPED rather than rewritten: dropping falls through to what + * `_configureCliEnv()` exports, which is the clamped config and the server's own + * `DSH_HOME`, i.e. exactly the intended state. No-op in single-user mode and for a + * granted owner, like every other clamp here + * (`canUsernameRunPrivilegedCommands()` returns true when `!isMultiUserMode()`), + * and it returns the caller's own object untouched when there is nothing to strip. + */ +export async function clampEnvOverridesForOwner( + owner: string | undefined, + envOverrides: Record | undefined +): Promise | undefined> { + if (!envOverrides) return envOverrides; + const keys = ownerClampedEnvKeys(); + if (!keys.some((key) => key in envOverrides)) return envOverrides; + if (await canUsernameRunPrivilegedCommands(owner)) return envOverrides; + const clamped = { ...envOverrides }; + for (const key of keys) delete clamped[key]; + return clamped; +} diff --git a/src/session.ts b/src/session.ts index 21d5ac89..16720e59 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1499,6 +1499,19 @@ export class Session extends EventEmitter { this._pinnedAt = pinned ? Date.now() : null; } + /** + * Restore a pin from a persisted record, keeping the moment it was pinned. + * + * `setPinned()` stamps `pinnedAt` with now, which is right for a user pinning a + * session and wrong for a restore: the session-manager orders its pinned group + * by that stamp, so a restored session would jump to the front of a list it had + * been sitting further down. + */ + restorePin(pinned: boolean, pinnedAt?: number): void { + this._pinned = pinned; + this._pinnedAt = pinned ? (pinnedAt ?? Date.now()) : null; + } + get flickerFilterEnabled(): boolean { return this._flickerFilterEnabled; } diff --git a/src/web/ports/session-port.ts b/src/web/ports/session-port.ts index 61e02be3..78e1fbbd 100644 --- a/src/web/ports/session-port.ts +++ b/src/web/ports/session-port.ts @@ -4,6 +4,7 @@ */ import type { Session } from '../../session.js'; +import type { SessionState } from '../../types.js'; export interface SessionPort { readonly sessions: ReadonlyMap; @@ -12,5 +13,40 @@ export interface SessionPort { setupSessionListeners(session: Session): Promise; persistSessionState(session: Session): void; persistSessionStateNow(session: Session): void; + /** + * Re-apply the persisted state a freshly CONSTRUCTED session does not carry. + * + * A `Session` built from a record holds only what its constructor takes, so + * persisting it would otherwise REPLACE the fuller record with the reduced one. + * Two phases: `before-spawn` shapes the pane (the custom-model environment and + * the nice priority) and must precede `startInteractive()`; `after-spawn` is + * the session's own history (the pin, token and cost totals, auto-compact, + * auto-clear, auto-resume, colour, image watcher, flicker filter) and must NOT + * land on a session whose pane failed to start. + */ + reapplyPersistedSessionState( + session: Session, + saved: SessionState, + phase: 'before-spawn' | 'after-spawn', + options?: { + /** + * Re-arm a PENDING auto-resume schedule from the record's `autoResumeAt`. + * Default true, which is what a Codeman restart wants: the limit footer + * will not reprint on its own, so dropping the stamp there strands the + * pause. A reboot restore passes false: the stamp predates the reboot, + * the pane is new, and re-arming means every restored session types + * `continue` into itself about a minute after one click. Auto-resume + * stays ENABLED either way, so it re-arms on fresh evidence. + */ + rearmAutoResumeSchedule?: boolean; + } + ): Promise; + /** + * Undo a session that was registered but never got a working pane: the map + * entry, its tab-layout slot, and any pane the launch created before throwing. + * Unlike {@link cleanupSession} it leaves the persisted record, the lifetime + * token totals, the Ralph state and the workspace's own files untouched. + */ + discardPartiallyBuiltSession(sessionId: string): Promise; getSessionStateWithRespawn(session: Session): unknown; } diff --git a/src/web/public/app.js b/src/web/public/app.js index 44d24389..1d5aa72b 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -957,6 +957,10 @@ class CodemanApp { this.registerServiceWorker(); // Fetch tunnel status for header indicator (desktop only) this.loadTunnelStatus(); + // Ask whether a host reboot left sessions worth rebuilding (banner, never + // automatic). handleInit() re-reads it on every SSE init; this covers the + // path where that event never arrives. + this.initRebootRestoreBanner?.(); // Share a single settings fetch between both consumers const settingsPromise = fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.data ?? null).catch(() => null); this.loadQuickStartCases(null, settingsPromise); @@ -3778,6 +3782,12 @@ class CodemanApp { // a fresh load / reconnect (authoritative; wins over the localStorage restore). if (data.planUsage) this.updatePlanUsageChip(data.planUsage); + // A board left open across a host reboot reconnects HERE, to a server that came + // back with an empty session list. The reboot-restore offer is built at boot, + // before any client could be listening, so re-read it on every init rather than + // only on the page-load path. + this.refreshRebootRestoreBanner?.(); + // Update version displays (header and toolbar) if (data.version) { const versionEl = this.$('versionDisplay'); diff --git a/src/web/public/index.html b/src/web/public/index.html index 4e847eee..91197f3e 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -213,6 +213,24 @@ + + +