mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
145
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1928f300e | ||
|
|
ca731c67b3 | ||
|
|
a3fe0ae728 | ||
|
|
82825cbfb3 | ||
|
|
d1868516f7 | ||
|
|
3e1272a675 | ||
|
|
db6cd838b1 | ||
|
|
66a41f5aa9 | ||
|
|
a36c1f62db | ||
|
|
8b2c857c3f | ||
|
|
583678c950 | ||
|
|
5728b86a68 | ||
|
|
39ef17b6af | ||
|
|
814362b67b | ||
|
|
e9f9497259 | ||
|
|
8768ca4a5a | ||
|
|
df9214ba9a | ||
|
|
5f4c89b990 | ||
|
|
54615e2371 | ||
|
|
828b1664f7 | ||
|
|
6f4b2b8a17 | ||
|
|
a531f48e17 | ||
|
|
7d5ea0bd50 | ||
|
|
a9ae141eec | ||
|
|
7b79d4207c | ||
|
|
28744a2761 | ||
|
|
cca07e2b11 | ||
|
|
9806efdf0a | ||
|
|
b00e7cf17c | ||
|
|
efe2d8966a | ||
|
|
f55f035690 | ||
|
|
58fc5f874a | ||
|
|
1301b4b58c | ||
|
|
46493f374e | ||
|
|
b20c00702a | ||
|
|
d55ebcb644 | ||
|
|
e84a3834d0 | ||
|
|
f89bc420ba | ||
|
|
5a4e60dc8e | ||
|
|
460972a50e | ||
|
|
8a971c3935 | ||
|
|
83779cab4d | ||
|
|
a8e7669f5a | ||
|
|
5deb0d4a4c | ||
|
|
84ab4ff07b | ||
|
|
88f47754ad | ||
|
|
6e417d69dc | ||
|
|
f98d29b323 | ||
|
|
360d58ca4f | ||
|
|
6cac517fa6 | ||
|
|
2235f06ea5 | ||
|
|
65b609b6db | ||
|
|
c9f37f2628 | ||
|
|
309959be27 | ||
|
|
13c877f938 | ||
|
|
895edfedb0 | ||
|
|
3f23621f8d | ||
|
|
5cca965aa4 | ||
|
|
b74a904b41 | ||
|
|
05d366e405 | ||
|
|
9204e42812 | ||
|
|
a9749ead6a | ||
|
|
e510ab74ca | ||
|
|
7fa52cdcd6 | ||
|
|
5ea424565d | ||
|
|
0ad673794f | ||
|
|
4d3080aacc | ||
|
|
246f7b532d | ||
|
|
116db81002 | ||
|
|
bb1d16e230 | ||
|
|
978ca57343 | ||
|
|
f8aa93969b | ||
|
|
584910f645 | ||
|
|
b86b132af5 | ||
|
|
4ab89f9a4e | ||
|
|
20cb42d202 | ||
|
|
68fd6e8962 | ||
|
|
be4fecdad5 | ||
|
|
c7967d4b55 | ||
|
|
8be83cd585 | ||
|
|
09fd1e495f | ||
|
|
7efc6cd5a8 | ||
|
|
286cf0768d | ||
|
|
3c0e6286f6 | ||
|
|
8a133d083b | ||
|
|
a0e26db1dc | ||
|
|
596899e19b | ||
|
|
e8f5ac94f3 | ||
|
|
03192d9980 | ||
|
|
3d4444ad78 | ||
|
|
c45e456b0e | ||
|
|
ad25e234f4 | ||
|
|
48fd2da6ce | ||
|
|
e29721046c | ||
|
|
3a03792009 | ||
|
|
e83ff72b61 | ||
|
|
268a0bbdbd | ||
|
|
26e78daf58 | ||
|
|
568d93efb0 | ||
|
|
3bf991d730 | ||
|
|
7fb58648ba | ||
|
|
9535edc367 | ||
|
|
443b85c18e | ||
|
|
66eaaf0da3 | ||
|
|
ce4c5dd584 | ||
|
|
e77af21107 | ||
|
|
a842f2db4d | ||
|
|
bf36eb0db4 | ||
|
|
4dfdbcd100 | ||
|
|
ad71a92f29 | ||
|
|
1fa88cd187 | ||
|
|
613eb25302 | ||
|
|
8c0c94540c | ||
|
|
d9c2c6420d | ||
|
|
6082bceee6 | ||
|
|
40e26c5422 | ||
|
|
9feaa0d6e5 | ||
|
|
2d2f4e592b | ||
|
|
6ae86b53f6 | ||
|
|
abb6447f66 | ||
|
|
cc7c0e5dcb | ||
|
|
368fc20fc2 | ||
|
|
9cc310e843 | ||
|
|
aa991ece8f | ||
|
|
3b4106c349 | ||
|
|
c2867be77f | ||
|
|
a1b66f3510 | ||
|
|
98ba1fd49c | ||
|
|
9df310c30a | ||
|
|
50b8f1d9a0 | ||
|
|
11bacf67a0 | ||
|
|
509595b837 | ||
|
|
c95e94e4cb | ||
|
|
19139837e4 | ||
|
|
9afaccc85d | ||
|
|
95df96e06a | ||
|
|
1255e28f6f | ||
|
|
9d12fc7f94 | ||
|
|
5d406c9705 | ||
|
|
a8782b364f | ||
|
|
d8da1bd3ff | ||
|
|
06871eb7e3 | ||
|
|
5d59c1764d | ||
|
|
cfcd9d288b | ||
|
|
9c22114b5a |
@@ -52,8 +52,16 @@ Thumbs.db
|
||||
# Generated output
|
||||
out/
|
||||
screenshots-echo-diag/
|
||||
screenshots-readme/
|
||||
screenshots-readme-real/
|
||||
screenshots-real/
|
||||
scripts/remotion/out/
|
||||
|
||||
# Local UI/README capture scratch (screenshot runs, design mockups). Not build
|
||||
# output, but never meant for git — an unqualified `git add -A` during a COM has
|
||||
# swept dirs like these into a release before.
|
||||
design-explorations/
|
||||
|
||||
# Artifacts that should not be tracked
|
||||
test-results/
|
||||
tmp/
|
||||
|
||||
+218
@@ -1,5 +1,223 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- **Docker session mode** hardening + fixes, plus a File Viewer header button.
|
||||
|
||||
**What Docker session mode is** (recap): a case can run inside an isolated, hardened Docker container instead of on the host, and any of the CLI backends (Claude, Codex, Gemini, OpenCode, or a plain shell) runs inside it. It is a location overlay on cases — not a new session mode — and the container analog of remote-SSH cases: a local tmux pane `docker exec`s into a durable in-container tmux, with exactly one long-lived container per case that multiple sessions share. The workspace, credentials, and conversation transcripts are bind-mounted so the agent is authenticated and resumable; containers are hardened by default (`--cap-drop ALL`, `--security-opt no-new-privileges`, non-root, pids/memory caps, `--init`, never `--privileged` or the docker socket) and export-safe. Start one with the one-click "Run in Docker" checkbox on Create Case, or the Docker tab for full control.
|
||||
|
||||
This release fixes the rough edges found running it for real:
|
||||
|
||||
Docker cases:
|
||||
- **Seamless Claude auth in containers**: `~/.claude.json` is no longer bind-mounted as a single file (a mount point that broke Claude's atomic-rename config writes — forcing re-auth and, via failed in-place writes, corrupting the host `~/.claude.json`). It is now seeded as a writable, onboarding-complete copy, so a docker session boots straight to the prompt (no theme picker, login, or folder-trust prompt).
|
||||
- **Claude-state isolation**: containers no longer bind-mount the whole `~/.claude` directory (which wrote backups/tasks/teams/settings back into the host). Only `~/.claude/projects` transcripts are shared (host watchers + `--resume`); credentials, settings, and stats-cache are seeded as writable copies; everything else stays container-local.
|
||||
- **Codex/Gemini/gcloud/opencode isolation**: same treatment — codex shares `sessions/` + `history.jsonl` (response-viewer + resume) and seeds `auth.json`/`config.toml`; gemini/gcloud/opencode are whole seed-copies. Containers never write their credential state back into the host dirs.
|
||||
- **Base image auto-builds on first use**: a missing `codeman/agent:base` no longer blocks case creation or launch; it builds locally on first use (concurrency-safe, with SSE progress toasts).
|
||||
- **UTF-8 locale**: containers set `LANG`/`LC_ALL=C.UTF-8` so tmux renders Claude's box-drawing correctly (fixes `qqqq` line artifacts).
|
||||
- **Create Case UI**: larger, collapsed-by-default "Run in Docker" settings with a shorter hint; dockerized cases show a short `(docker)` tag (or the custom host id) in the case menus.
|
||||
- **Tab naming**: docker/remote (and codex/gemini/opencode) sessions now follow the `w<n>-<case>` convention instead of `codeman-<id>`.
|
||||
|
||||
Other:
|
||||
- **File Viewer header button** (opt-in via App Settings, Header Displays): toggle the file browser panel from the header.
|
||||
- Fixed a timezone-boundary flaky test in the away-digest route suite.
|
||||
|
||||
## 1.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add **Docker session mode**: a case can now run inside an isolated Docker container instead of on the host, with configurable network / resource / credential settings, multiple sessions sharing one per-case container, and one-click export to move a container (toolchain + workspace) to another machine.
|
||||
- Docker is a location overlay on cases (not a new session mode), mirroring the remote-SSH feature: a local tmux pane runs `docker exec -it` into a durable in-container tmux server. The container is scoped to the case (`codeman-case-<name>`), so multiple sessions share it; killing one session never stops the shared container.
|
||||
- New `/api/docker-hosts` CRUD, `/api/cases/docker-link`, and a `/api/quick-start` docker branch. Create Case gains a **Docker** tab. Base image is built locally via `scripts/build-agent-image.mjs` (node + claude/codex/gemini/opencode + tmux, secret-free, arbitrary-uid-writable HOME).
|
||||
- Hardened by default: `--cap-drop ALL`, `--security-opt no-new-privileges`, non-root, `--pids-limit`, `--memory`==`--memory-swap`, `--init`; never `--privileged` or the docker socket. Convenient credential default bind-mounts host `~/.claude` etc. read-write (never captured by `docker commit`); a sealed profile is opt-in.
|
||||
- Two-layer durability: reconnect after a Codeman restart reattaches the same live agent; a container stop/reboot resumes the conversation from the bind-mounted transcript via `--resume`.
|
||||
- Export / import: full-image (`docker commit` + `save` + workspace tar + manifest) or workspace-only, to one portable `.codeman-container.tgz`; import validates checksums, guards path traversal, and re-tags the loaded image into a quarantined namespace. Instance-scoped boot reaper cleans orphaned containers. New `docker:*` SSE events. Docs in `docs/docker-cases.md`.
|
||||
- Robustness: sets `CLAUDE_CODE_TMPDIR` in the container so claude launches regardless of workspace path. In-container hooks require the server to be reachable from the container (documented); on a loopback-only bind, idle detection falls back to output-based.
|
||||
|
||||
Also wire session, away-digest, and cron header-button visibility toggles in App Settings.
|
||||
|
||||
## 1.3.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a842f2d: fix(auth): slide the session cookie so active users aren't logged out
|
||||
|
||||
Re-issue the `codeman_session` cookie on every authenticated request so the
|
||||
browser cookie lifetime tracks the server-side sliding TTL (the session store
|
||||
already uses `refreshOnGet`). Previously the cookie was only set on the Basic
|
||||
Auth path with a fixed 24h lifetime from login, so the browser dropped it
|
||||
mid-use; the next request arrived cookie-less, fell through to Basic Auth and
|
||||
popped the native username/password dialog, perceived as a random logout while
|
||||
actively working.
|
||||
|
||||
## 1.3.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix "Run Shell" not switching the terminal to the newly created shell session. Clicking Run Shell created the shell tab but left the previous session's terminal on screen, so you had to manually click the new tab to actually enter it. Root cause: `runShell()` pre-set `activeSessionId` to the new session's id right before calling `selectSession()`, and `selectSession()` early-returns when the requested id already matches the active one, so it skipped the terminal buffer load, tab activation, and focus. Removed the premature assignment in both the local and remote-SSH shell branches so `selectSession()` runs to completion (matching `runClaude`/`runCodex`/`runGemini`/`runOpenCode`, which already avoid this). Verified end-to-end in a real browser with a negative/positive control.
|
||||
|
||||
## 1.3.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix terminal scroll-back in Claude sessions, especially on macOS trackpads (#154).
|
||||
- **Deterministic CLI version detection.** `cliVersion` was often `undefined` because it was scraped from the `Claude Code vX.Y.Z` startup banner, which newer Claude Code builds (2.1.187+) don't reliably print and resumed sessions never show. With the version unknown, wheel-forwarding to Claude's transcript was silently disabled — and since repaint-mode Claude keeps no local terminal scrollback, scrolling up reached nothing. A new `getClaudeCliVersion()` probe (`claude --version`, cached, local-only) seeds the version at session start so forwarding engages. Restored sessions pick it up on restart.
|
||||
- **Trackpad Shift+scroll.** The wheel handler now reads the dominant axis, so a macOS trackpad's Shift+two-finger scroll — which the browser reports as horizontal `deltaX` — reaches xterm's local scrollback instead of collapsing to a fixed one line per tick.
|
||||
- **Opt-out setting.** New per-device App Settings → Input → "Wheel Scrolls Local History" (default off) pins the plain wheel to local scrollback (the pre-#144 behavior) for shell and other non-repaint sessions.
|
||||
- **No more "queued bytes" flicker on scroll.** Wheel-scroll reports now use a fire-and-forget send path (seq-less input frame) instead of the durable exactly-once input queue, so they no longer appear in the pending-bytes connection indicator or churn localStorage. Keystrokes, taps, and clicks still use the durable queue.
|
||||
|
||||
## 1.3.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Make the Cron Jobs modal fully skin-aware and consistent with App Settings' design language.
|
||||
- **Fix white dropdowns:** `.form-select` had no `appearance` reset and the app set no `color-scheme`, so native `<select>` fields rendered as white OS widgets that ignored the active skin. Selects now use `appearance: none` with an opaque `var(--bg-input)` fill, a `var(--border)` outline, and a custom chevron, so they follow the skin (daylight `#202833`, OG `#1a1a1f`). This is on the shared `.form-select` class, so App Settings, Cron, and every other select match and are fixed together.
|
||||
- Set `color-scheme: dark` on `:root` so native select option popups, date/time pickers, and scrollbars render dark across all three (dark) skins instead of flashing white.
|
||||
- Themed the Cron date/time inputs with `var(--bg-input)` / `var(--border)` instead of hardcoded values.
|
||||
- Fixed the Cron toolbar: "+ New Job" / "Refresh" and the footer Save / Cancel now use the full `btn-toolbar` size (matching the App Settings footer), with a wider gap and a divider under the toolbar for better spacing.
|
||||
|
||||
## 1.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Redesign the Cron Jobs modal to match the App Settings styling, and fix a bug that left its create form fully expanded.
|
||||
- **Fix:** the cron modal's "New Cron Job" form and all of its conditional rows (Launch Command, Prompt File Path, and the once/interval/daily/weekly schedule fields) never actually collapsed — there is no global `.hidden` utility in the stylesheet and the cron modal never scoped its own, so the form opened fully expanded with every field visible at once. Added a scoped `#cronModal .hidden` rule; the form now stays collapsed until "+ New Job" and only shows the fields relevant to the selected agent type, prompt source, and schedule type.
|
||||
- Sectioned the create/edit form into Basics / Prompt / Schedule / Options with the same section-header dividers used in App Settings, and increased row spacing.
|
||||
- Styled the agent-type / prompt-source / input-mode / schedule-type dropdowns and the datetime-local / time inputs to share the bordered, rounded, focus-ringed field look.
|
||||
- Converted the "Auto-close previous run's session" and "Enabled" toggles into App-Settings-style cards (label + description on the left, compact switch on the right).
|
||||
- Replaced the raw weekday checkboxes with pill toggles that fill with the accent color when selected.
|
||||
- Restyled the job list rows as hover-highlighted cards with pill badges (agent type, schedule, disabled) and right-aligned actions, and gave the modal a divider-topped Cancel / Save footer.
|
||||
|
||||
## 1.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Community release: 16 contributor PRs reviewed (multi-agent adversarial review), fixed, and merged. Thanks to @aakhter, @TeigenZhang, @chatgptkrylor, @kvncrw, and @pirronewantlux529-coder!
|
||||
|
||||
**New features**
|
||||
- **Cron jobs** (#141, @chatgptkrylor): recurring scheduled jobs (once/interval/daily/weekly) that spawn a session and send a prompt when due — CRUD + run history (`/api/cron/*`), ⏰ modal UI, per-job concurrency policy and `autoClosePreviousSession` lifecycle, pure unit-tested next-run math. Distinct from the legacy `ScheduledRun`.
|
||||
- **Remote host SSH cases** (#145, @aakhter): link cases on remote hosts (`remote-hosts.json`/`remote-cases.json`), launch sessions over ssh into a durable remote tmux (dedicated `-L codeman-remote` socket; adoption-safe naming), per-host command overrides, injection-guarded schemas, remote tmux probe + ConnectTimeout, remote kill on delete, recovery-safe persistence.
|
||||
- **Command-K session palette + searchable case picker + shortcut registry** (#146, @aakhter): Ctrl/Cmd/Alt+K fuzzy session palette with "Browse all sessions" Session Manager; searchable quick-start case picker (remote-aware labels); rebindable shortcut registry with App Settings → Shortcuts tab and Ctrl+? overlay.
|
||||
- **Unified session list** (#139, @aakhter): `GET /api/sessions/unified` merges live/persisted/lifecycle/transcript sessions into one deduped list (resumed sessions fold via claudeSessionId alias map).
|
||||
- **Unified Session Manager UX** (#153, @aakhter): unified welcome list with mode/LIVE badges + per-row kebab menu, `projectKey` plumbing for "View all in this folder", SSE-driven live list refresh, desktop Session Manager header button.
|
||||
- **Full-scrollback replay** (#148, @aakhter): page reload replays the entire tmux scrollback (`?full=1`, bounded capture with proper maxBuffer) with CRLF normalization for shell panes.
|
||||
- **WebSocket resilience** (#149, @aakhter): reconnect with preserved exponential backoff, per-tab connection identity (multi-tab safe), ACK re-drive, and a truthful connection chip (WS/HTTP/reconnecting states).
|
||||
- **PTY-exit circuit breaker + TMUX scrub** (#147, @aakhter): rapid PTY crash-loops trip a breaker (SSE + critical push notification; explicit-restart-only reset); inherited TMUX vars are scrubbed so Codeman-in-tmux doesn't nest.
|
||||
- **Codex generated-artifact attachments** (#150, @aakhter): codex sessions surface `Saved to: file://…` outputs as attachment cards (realpath-anchored trust, codex-mode-gated, jpg/gif/webp thumbnails).
|
||||
- **Codex response viewer** (#152, @pirronewantlux529-coder): the eye button now works for Codex sessions via 4-layer rollout resolution (history pin → originator → resume-UUID → cwd) with dedup + injected-context filtering.
|
||||
- **HEIC paste conversion** (#151, @aakhter): iPhone HEIC pastes convert to JPEG server-side in a worker thread (concurrency-capped, 64MP decompression-bomb guard, magic-byte detection for mislabeled Android HEIFs). Deps: heic-decode + jpeg-js.
|
||||
- **WebGL renderer toggle** (#140, @kvncrw): per-device setting to switch xterm between WebGL and DOM renderers, cooperating with the GPU-stall auto-fallback marker.
|
||||
- **Raised terminal history defaults** (#138, @aakhter): tmux history-limit 50k→100k lines, PTY buffer 2MB/1.5MB→32MB/24MB (env-clamped so trim always stays below max).
|
||||
|
||||
**Mobile & input fixes**
|
||||
- CJK input loss fixes: IME state machine, focus routing, Android InputConnection recovery — with content-free diagnostics (#143, @TeigenZhang).
|
||||
- Tap/click/wheel restored when the server strips mouse DECSETs — version-gated wheel passthrough (claude ≥ 2.1.187), link-click double-fire fix, Shift+wheel documented (#144, @TeigenZhang).
|
||||
- Response-viewer readability on phones + iOS dvh viewport fix (#142, @TeigenZhang).
|
||||
|
||||
**Docs**: CLAUDE.md accuracy audit (18 verified fixes: security hook-bypass description, env-prefix allowlist, state-file inventory, watcher/function names, counts) + documentation for all new subsystems. README gains a user walkthrough (#141).
|
||||
|
||||
All PRs went through adversarial multi-agent review; ~60 verified findings (including 12 blockers) were fixed on the contributors' branches before merge. Full test suite green: 3,400+ tests.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- bf36eb0: Add a **WebGL Renderer** toggle to Settings → Appearance (desktop). WebGL stays on by default; turning it off forces the DOM renderer for users who hit GPU glitches, without needing the `?nowebgl` URL param. Turning it back on (or `?webgl=force`) clears any stale auto-fallback marker. The existing mobile skip and long-task auto-fallback safety net are unchanged. The skip decision is factored into a pure, unit-tested `shouldSkipWebGL()` helper.
|
||||
|
||||
## 1.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Centralize terminal history/scrollback/buffer retention limits into config (PR #137, COD-80).
|
||||
|
||||
New `src/config/terminal-history.ts` is now the single source of truth for the terminal scrollback lines, tmux `history-limit`, and server PTY buffer byte caps that were previously scattered as hardcoded literals across `buffer-limits.ts`, `tmux-manager.ts`, and `session.ts`. Each value is overridable (env var or the settings object) and bounds-clamped via a pure `resolveTerminalHistoryConfig()`.
|
||||
|
||||
This change is behavior-neutral: the defaults intentionally match the prior hardcoded values (tmux history-limit 50,000; terminal scrollback 50,000; PTY buffer max 2 MB; trim 1.5 MB) and the existing `CODEMAN_MAX_TERMINAL_BUFFER` / `CODEMAN_TRIM_TERMINAL_TO` env overrides are preserved, so runtime behavior is unchanged on its own. It is the mechanism half of a stacked change; a follow-up raises the defaults.
|
||||
- `buffer-limits.ts` sources `MAX_TERMINAL_BUFFER_SIZE` / `TRIM_TERMINAL_TO` from the resolver.
|
||||
- `tmux-manager.ts` uses `DEFAULT_TMUX_HISTORY_LIMIT` in place of the hardcoded `history-limit 50000`, gains `setHistoryLimit()` (mux-interface + impl) so a settings change applies to live sessions, and re-applies the limit on `respawnPane` so it survives a respawn.
|
||||
- `session.ts` threads a per-session `tmuxHistoryLimit` into the tmux spawn calls; `server.ts` exposes `getTerminalHistoryConfig()` on the route ctx and `system-routes.ts` applies a changed `tmuxHistoryLimit` to live sessions immediately.
|
||||
- `schemas.ts` adds four optional, bounds-clamped settings keys (`terminalScrollbackLines`, `tmuxHistoryLimit`, `terminalBufferMaxBytes`, `terminalBufferTrimBytes`) with a `trim <= max` cross-field check.
|
||||
- New tests: `test/terminal-history.test.ts` (resolver defaults / clamping / trim<=max / non-number fallback) and `test/terminal-history-schema.test.ts` (settings-schema validation).
|
||||
|
||||
## 1.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix local echo on iOS Safari when switching into a tab whose session already has output. The on-screen-keyboard "heal" (refit + scroll-to-bottom + overlay re-render + one-shot resize) only ran on a keyboard visibility transition, so switching into a tab while the keyboard was already up never triggered it — leaving the local-echo overlay rendering against stale, off-bottom terminal state. Typed characters were invisible (or mispositioned at the cursor row, far below the actual `❯` prompt) until the user manually hid and re-showed the keyboard. `selectSession` now replicates that heal when the keyboard is already visible, so local echo paints correctly on the first keystroke after a keyboard-up tab switch.
|
||||
|
||||
## 1.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Merge four feature PRs and harden them for release.
|
||||
|
||||
**Gemini run mode (PR #134, COD-36)** — a third external-CLI backend alongside Codex and OpenCode (`SessionMode` adds `'gemini'`). New `gemini-cli-resolver.ts`, `buildGeminiCommand()` (`--skip-trust`, `--approval-mode {default|auto_edit|yolo|plan}` defaulting to `yolo`, `--model`, `--resume`), `setGeminiEnvVars()` (socket-scoped `tmux setenv` of `GEMINI_*`/`GOOGLE_*` auth incl. Vertex AI), `GET /api/gemini/status` with an install hint (`npm install -g @google/gemini-cli`), run-mode dropdown + welcome "Run Gemini" button + "Run GM" label, `GeminiConfigSchema`, and `GEMINI_*`/`GOOGLE_*` added to the env-override allowlist. Requires tmux (no PTY fallback), like Codex.
|
||||
|
||||
**Cross-session search (PR #133, COD-113)** — `GET /api/search?q=&types=&limit=` federates an in-memory search across session metadata, run-summary events, and attachment-history file entries (substring match, hard caps, no FS reads); history-panel search box in the frontend.
|
||||
|
||||
**Away digest (PR #136, COD-41)** — `GET /api/away-digest` aggregates "what happened while you were away" (lifecycle log, run summaries, live sessions, daily token stats, recent subagents) into categorized sections behind a header-button modal (hidden on phones).
|
||||
|
||||
**Ralph todo-config (PR #135, COD-79)** — per-session `maxTodos` and `todoExpirationMinutes` via `POST /api/sessions/:id/ralph-config`; now persisted in `RalphTrackerState` and read back into the Session Options modal (mirrors `maxIterations` round-trip).
|
||||
|
||||
**Review fixes applied on merge:**
|
||||
- Gemini: fixed two `{success,data}` envelope bugs in `runGemini()` (status check and new-session selection) that made the Run-Gemini button non-functional; fixed `setGeminiEnvVars()` to use the socket-scoped tmux command so Google-auth env injection actually reaches the session.
|
||||
- Gemini parity: tab-mode badge, kill-dialog label, `codeman doctor` registry entry, `isGeminiAvailable` barrel export, `COLORTERM=truecolor`, and alt-screen/scrollback stripping (Ink TUI, like Codex/Claude).
|
||||
- Restored four envelope-shape test assertions weakened during the Gemini PR; added a `runGemini()` regression test covering the envelope path.
|
||||
- Ralph todo-config values now persist across restart and read back correctly instead of always reverting to defaults.
|
||||
|
||||
## 1.1.17
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix the connection indicator flashing "Sending 1B…" on every keystroke. The reliable input-delivery layer (1.1.16) marks each keystroke as briefly pending until its ACK arrives a few milliseconds later, which made the indicator flash on every character while typing on a healthy connection. The indicator is now hidden whenever the connection is healthy and only appears for an actual problem (reconnecting/offline), where it still shows the queued byte count so you know buffered input will be sent.
|
||||
|
||||
## 1.1.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Mobile image uploads, reliable input delivery, and gesture window dragging.
|
||||
|
||||
**Mobile image uploads (camera-roll picker / drag-drop / paste).** The "🖼 Image" button now handles real photo batches: up to 20 images per batch uploaded with bounded concurrency and a live "Uploading N/M…" progress toast (with a summary of successes, failures, and whether the 20-cap trimmed the selection). The per-file limit is raised from 10MB to 50MB (`MAX_PASTE_IMAGE_BYTES`, env-overridable via `CODEMAN_MAX_PASTE_IMAGE_BYTES`) so full-resolution phone photos and large screenshots are accepted. Very large images are downscaled to ≤4096px on the longest edge before upload, fixing iOS Safari's ~16.7M-px `<canvas>` limit that previously made huge photos fail to re-encode. Also fixes a latent concurrency bug the batch path exposed where the first parallel uploads to a session raced on creating `.claude-images/` and failed with EEXIST.
|
||||
|
||||
**Reliable, exactly-once input delivery.** A "sent" prompt could be silently lost on a flaky connection (e.g. a train): a half-open WebSocket accepts `ws.send()` without error while discarding the frame, and nothing was queued or resent. Input is now recorded durably (localStorage) with a stable clientId + monotonic per-session sequence before delivery, and only dropped once the server ACKs it — delivered over the WebSocket (acked via `{t:'ia',seq}`) or, when the socket is down, over POST in order. A 2s sweep force-reconnects a half-open socket; pending input survives reconnects and page reloads. The server applies each `(clientId, seq)` at most once (`Session.shouldApplyInput`), so an at-least-once resend can never type the prompt twice. Untagged input (curl/legacy) is unchanged. See `docs/reliable-input-delivery.md`.
|
||||
|
||||
**Gesture beta: drag agent windows.** With the camera hand-tracking overlay, you can now pinch and move the floating subagent and ultracode run/transcript windows. They keep their glowing connector line to the session tab while moving and can travel across a multi-monitor seam.
|
||||
|
||||
## 1.1.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Security: harden all frontend inline `onclick`/`ondblclick` handlers against a stored-XSS double-context bug.
|
||||
|
||||
Many inline handlers interpolated values as `'${escapeHtml(value)}'` — a JavaScript string literal sitting inside an HTML attribute. The browser HTML-decodes the attribute value _before_ parsing the handler source, so `escapeHtml`'s `'` reverts to a literal `'` and a quote-bearing id/name/path/URL breaks out of the JS string into executable code. `escapeHtml` alone is insufficient for this JS-string-within-HTML-attribute context.
|
||||
|
||||
All affected handlers now use `escapeHtml(JSON.stringify(value))`: `JSON.stringify` JS-encodes and quote-wraps the value, then `escapeHtml` handles the HTML-attribute layer, so the value round-trips as a single inert string argument.
|
||||
- ultracode run/agent cards and minimized-tab badges (`ultracode-panel.js`, `ultracode-windows.js`) — PR #132.
|
||||
- Session tabs (click/rename/gear/detach/close), notifications, subagent windows + dropdowns, the agents/tools/log-viewer/image-popup panels, mux-session monitor rows, and case-management buttons (`app.js`, `notification-manager.js`, `subagent-windows.js`, `panels-ui.js`, `session-ui.js`).
|
||||
- Two non-`escapeHtml` variants of the same class: a pre-escaped mux-session id in `panels-ui.js` (`selectSession`/`killMuxSession`) and a fully raw, unescaped `phase.id` in `orchestrator-panel.js` (`orchestratorSkipPhase`/`orchestratorRetryPhase`).
|
||||
|
||||
The most realistic exploitation vector was file paths in the project-insights log-viewer link, since filenames can legally contain a single quote. Purely numeric interpolations and developer-literal handler strings were left unchanged.
|
||||
|
||||
## 1.1.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Ultracode (Workflow-tool) floating windows — agent transcripts in-page, and minimize-to-tab.
|
||||
- **Agent transcripts open in-page, connected, instead of a detached browser popup.** Clicking an agent card (in a run window or the dock panel) now opens the agent's live transcript as its own draggable floating window, tied by a connector line to its parent run window (falling back to the run's session tab if that window has since closed) — the same line idiom the run windows use. Re-clicking a card focuses the existing window; closing it removes the window and its line. (Previously this spawned a separate `window.open` browser popup.)
|
||||
- **The window "−" button now minimizes into the originating session tab**, mirroring the subagent-window idiom. The window genie-animates into its tab and is tracked there; the tab shows an `ULTRA` badge whose hover/click dropdown lists each minimized item (🧬 run windows, 📄 agent transcripts). Click an item to restore its floating window, or dismiss it with ×. A run minimized while still active keeps tracking in the background and its badge auto-clears shortly after the run finishes. Both run windows and agent-transcript windows minimize into the same merged badge.
|
||||
- Removed the old collapse-to-header behavior that the "−" button previously triggered (now superseded by minimize-to-tab).
|
||||
|
||||
## 1.1.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Keep the `/compact` button in the extended (full) mobile keyboard accessory bar; only the simple bar drops it. (1.1.12 had removed it from both.)
|
||||
|
||||
## 1.1.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Remove the `/compact` button from the mobile keyboard accessory bar. It had been reintroduced in 1.1.10; this removes the button from both the simple and full accessory-bar layouts (the underlying command handler is left in place as inert plumbing).
|
||||
|
||||
## 1.1.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -45,6 +45,7 @@ codeman web
|
||||
<summary><strong>Run as a background service</strong></summary>
|
||||
|
||||
**Linux (systemd):**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cat > ~/.config/systemd/user/codeman-web.service << EOF
|
||||
@@ -67,6 +68,7 @@ loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
**macOS (launchd):**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/Library/LaunchAgents
|
||||
cat > ~/Library/LaunchAgents/com.codeman.web.plist << EOF
|
||||
@@ -94,6 +96,7 @@ cat > ~/Library/LaunchAgents/com.codeman.web.plist << EOF
|
||||
EOF
|
||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -104,10 +107,78 @@ wsl bash -c "curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/i
|
||||
```
|
||||
|
||||
Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), or [Codex](https://developers.openai.com/codex/cli)). After installing, `http://localhost:3000` is accessible from your Windows browser.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Using Codeman — A Human's Guide
|
||||
|
||||
A start-to-finish walkthrough for driving Codeman from the browser. If you just installed, this is where to begin.
|
||||
|
||||
### 1. Launch the server
|
||||
|
||||
```bash
|
||||
codeman web # localhost:3000 (loopback only — safe default)
|
||||
codeman web --port 8080 # custom port (or set CODEMAN_PORT)
|
||||
codeman web --https # self-signed TLS (only needed for remote access)
|
||||
codeman web -H 0.0.0.0 # bind LAN — REQUIRES CODEMAN_PASSWORD (see Security)
|
||||
```
|
||||
|
||||
Open the printed URL. The page is a single dashboard; everything below happens there.
|
||||
|
||||
### 2. Create your first session
|
||||
|
||||
Click **+ New Session** (or **Quick Start**). A session is one AI CLI running in its own tmux-backed terminal. You choose:
|
||||
|
||||
| Field | What it does |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. |
|
||||
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Gemini`, or `Terminal` (plain shell). |
|
||||
| **Model** | Per-session model (App Settings → Claude Model). A soft default — `/model` still works in-session. |
|
||||
| **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. |
|
||||
|
||||
Hit start — Codeman spawns the CLI via a real PTY and streams it to your browser over SSE.
|
||||
|
||||
### 3. Read the dashboard
|
||||
|
||||
- **Tabs (top)** — one per session. `Alt+1`-`9` to jump, `Ctrl+Tab` for next, drag to reorder.
|
||||
- **Terminal (center)** — a real `xterm.js` terminal; full TUIs render correctly. Type directly and press **Enter** to send. `Shift+Enter` inserts a newline.
|
||||
- **Side panels** — Respawn, Ralph, Orchestrator, Cron, Subagents, Settings (toggled from the toolbar).
|
||||
|
||||
### 4. Talk to the agent
|
||||
|
||||
- **Type prompts** straight into the terminal — input is delivered exactly-once even across reconnects (a dropped link never loses or double-sends a prompt).
|
||||
- **Paste or drag-and-drop images** directly into the session.
|
||||
- **Voice input** — `Ctrl+Shift+V` (Deepgram Nova-3, with auto-silence stop).
|
||||
- **Attachments** — register external files/docs and preview Office/PDF inline.
|
||||
|
||||
### 5. Make it autonomous
|
||||
|
||||
| Mode | Use it for | Where |
|
||||
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
|
||||
| **Respawn** | Long unattended runs — auto-restarts the CLI on idle/limit, with adaptive timing. Presets: `solo-work`, `overnight-autonomous`, … | Respawn tab |
|
||||
| **Ralph / Todo** | A self-driving loop that tracks a todo list and keeps working until done. | Ralph tab |
|
||||
| **Orchestrator** | Turn one goal into a phased plan and drive it to completion across agents. | Orchestrator panel |
|
||||
| **Cron** | Saved, named jobs on a schedule (`once`/`interval`/`daily`/`weekly`) that spawn a session and send a prompt when due. | ⏰ Cron button |
|
||||
| **Auto-resume** | Automatically continue after a subscription rate-limit resets. | Respawn tab (top) |
|
||||
|
||||
### 6. Reach it from anywhere
|
||||
|
||||
- **Phone/tablet** — the UI is fully touch-optimized; scan the desktop **QR code** to log in without typing a password.
|
||||
- **Outside your network** — `./scripts/tunnel.sh start` opens a Cloudflare tunnel (set `CODEMAN_PASSWORD` first).
|
||||
- **SSH** — the `sc` chooser attaches to any session from a terminal (`sc` interactive, `sc 2` quick-attach, `sc -l` list).
|
||||
|
||||
### 7. Operate & maintain
|
||||
|
||||
- **App Settings** — model, effort, theme/skin, notifications, display toggles, per-CLI options.
|
||||
- **Self-update** — git-clone installs update in place from **Settings → Updates**.
|
||||
- **Deploy your own changes** — see [Development](#development).
|
||||
|
||||
> ⚠️ **Safety:** if you're working _inside_ a Codeman-managed session (`echo $CODEMAN_MUX` → `1`), never run `tmux kill-session` / `pkill claude` directly — use the web UI or `./scripts/tmux-manager.sh`.
|
||||
|
||||
---
|
||||
|
||||
## Mobile-Optimized Web UI
|
||||
|
||||
The most responsive AI coding agent experience on any phone. Full xterm.js terminal with local echo, swipe navigation, and a touch-optimized interface designed for real remote work — not a desktop UI crammed onto a small screen.
|
||||
@@ -214,7 +285,7 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
|
||||
```
|
||||
|
||||
- **Multi-layer idle detection** — completion messages, AI-powered idle check, output silence, token stability
|
||||
- **Auto-resume on usage limit** *(opt-in, off by default)* — when Claude halts on a subscription limit ("You've hit your limit · resets 3pm"), Codeman parses the reset time, waits it out plus a 2-minute safety buffer, then dismisses the rate-limit dialog and sends `continue` — so an overnight run survives the 5-hour window instead of stalling until morning. Recognizes every Claude Code limit-message format, retries if still limited, survives Codeman restarts, and holds respawn cycles while paused so `/clear` can't wipe the waiting conversation. Enable per session at the top of the Respawn tab
|
||||
- **Auto-resume on usage limit** _(opt-in, off by default)_ — when Claude halts on a subscription limit ("You've hit your limit · resets 3pm"), Codeman parses the reset time, waits it out plus a 2-minute safety buffer, then dismisses the rate-limit dialog and sends `continue` — so an overnight run survives the 5-hour window instead of stalling until morning. Recognizes every Claude Code limit-message format, retries if still limited, survives Codeman restarts, and holds respawn cycles while paused so `/clear` can't wipe the waiting conversation. Enable per session at the top of the Respawn tab
|
||||
- **Circuit breaker** — prevents respawn thrashing when Claude is stuck (CLOSED -> HALF_OPEN -> OPEN states, tracks consecutive no-progress and repeated errors)
|
||||
- **Health scoring** — 0-100 health score with component scores for cycle success, circuit breaker state, iteration progress, and stuck recovery
|
||||
- **Built-in presets** — `solo-work` (3s idle, 60min), `subagent-workflow` (45s, 240min), `team-lead` (90s, 480min), `ralph-todo` (8s, 480min), `overnight-autonomous` (10s, 480min)
|
||||
@@ -260,10 +331,10 @@ The title is templated into the served HTML on first byte, so it's correct from
|
||||
|
||||
### Smart Token Management
|
||||
|
||||
| Threshold | Action | Result |
|
||||
|-----------|--------|--------|
|
||||
| Threshold | Action | Result |
|
||||
| --------------- | --------------- | ---------------------------------- |
|
||||
| **110k tokens** | Auto `/compact` | Context summarized, work continues |
|
||||
| **140k tokens** | Auto `/clear` | Fresh start with `/init` |
|
||||
| **140k tokens** | Auto `/clear` | Fresh start with `/init` |
|
||||
|
||||
### Notifications
|
||||
|
||||
@@ -295,16 +366,32 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
||||
|
||||
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
|
||||
- **Multi-CLI** — run **Claude Code**, **OpenCode**, or **Codex** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md)
|
||||
- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
|
||||
- **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too
|
||||
- **Voice input** — dictate prompts with Deepgram Nova-3 (Web Speech API fallback): toggle recording, auto-silence stop, live level meter (`Ctrl+Shift+V`)
|
||||
- **Image input** — paste or drag-and-drop images straight into a session
|
||||
- **Gesture control** *(opt-in)* — a MediaPipe hand-tracking overlay to grab/drag session windows and pinch buttons, hands-free. Enable with `CODEMAN_GESTURE=1` + App Settings → Display
|
||||
- **Multi-monitor span** *(macOS)* — one click opens a browser window maximized across all displays, so floating agent/gesture panels can cross the physical seam
|
||||
- **Gesture control** _(opt-in)_ — a MediaPipe hand-tracking overlay to grab/drag session windows and pinch buttons, hands-free. Enable with `CODEMAN_GESTURE=1` + App Settings → Display
|
||||
- **Multi-monitor span** _(macOS)_ — one click opens a browser window maximized across all displays, so floating agent/gesture panels can cross the physical seam
|
||||
- **CJK / IME input** — full composition support for Chinese / Japanese / Korean
|
||||
- **OS notifications & hostname-aware titles** — desktop alerts and tab titles are prefixed `codeman:<host>` so multi-host setups stay unambiguous
|
||||
|
||||
---
|
||||
|
||||
## Isolated Docker Sessions
|
||||
|
||||
Run a case inside its own hardened Docker container instead of directly on your host — for security isolation, reproducible toolchains, and one-click portability.
|
||||
|
||||
- **One click** — on **New Case → Create New**, tick **🐳 Run in an isolated Docker container**. Codeman creates the case folder, spins up a container with default settings, and starts the agent inside it. No host/image/network fields to fill in.
|
||||
- **Resource templates** — expand the checkbox for a **Small / Medium / Large / GPU** preset (memory, CPUs, GPU), or set your own. **Disk is elastic** — storage grows as data flows in, no fixed cap.
|
||||
- **Shared per-case container** — many sessions can `docker exec` into the same container; killing one session never tears the container out from under the others.
|
||||
- **Hardened by default** — non-root, `--cap-drop ALL`, `no-new-privileges`, PID/memory caps, never `--privileged` or the docker socket. Your existing `~/.claude` login is bind-mounted (credentials stay on the host, never captured in exports); a **sealed** profile (no host mounts, network off) is one toggle away.
|
||||
- **Move it to another machine** — export a container's whole environment (toolchain + workspace) to a portable `.tar.gz`, `docker load` it on the other side, and import it into a fresh case.
|
||||
- **Durable** — reconnect after a restart lands back in the same live agent; a container stop/reboot resumes the conversation from the bind-mounted transcript.
|
||||
|
||||
Prerequisite: Docker (or Podman) and the base image — build it once with `node scripts/build-agent-image.mjs`. Full guide: [`docs/docker-cases.md`](docs/docker-cases.md).
|
||||
|
||||
---
|
||||
|
||||
## Remote Access — Cloudflare Tunnel
|
||||
|
||||
Access Codeman from your phone or any device outside your local network using a free [Cloudflare quick tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/) — no port forwarding, no DNS, no static IP required.
|
||||
@@ -372,14 +459,14 @@ Every **60 seconds**, the server automatically rotates to a fresh token. The pre
|
||||
|
||||
The design is informed by ["Demystifying the (In)Security of QR Code-based Login"](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin) (USENIX Security 2025), which found 47 of the top-100 websites vulnerable to QR auth attacks due to 6 critical design flaws across 42 CVEs. Codeman addresses all six:
|
||||
|
||||
| USENIX Flaw | Mitigation |
|
||||
|-------------|------------|
|
||||
| **Flaw-1**: Missing single-use enforcement | Token atomically consumed on first scan — replays always fail |
|
||||
| **Flaw-2**: Long-lived tokens | 60s TTL with 90s grace, auto-rotation via timer |
|
||||
| **Flaw-3**: Predictable token generation | `crypto.randomBytes(32)` — 256-bit entropy. Short codes use rejection sampling to eliminate modulo bias |
|
||||
| **Flaw-4**: Client-side token generation | Server-side only — tokens never leave the server until embedded in the QR |
|
||||
| **Flaw-5**: Missing status notification | Desktop toast: *"Device [IP] authenticated via QR (Safari). Not you? [Revoke]"* — real-time QRLjacking detection |
|
||||
| **Flaw-6**: Inadequate session binding | IP + User-Agent stored for audit. Manual session revocation via API. HttpOnly + Secure + SameSite=lax cookies |
|
||||
| USENIX Flaw | Mitigation |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| **Flaw-1**: Missing single-use enforcement | Token atomically consumed on first scan — replays always fail |
|
||||
| **Flaw-2**: Long-lived tokens | 60s TTL with 90s grace, auto-rotation via timer |
|
||||
| **Flaw-3**: Predictable token generation | `crypto.randomBytes(32)` — 256-bit entropy. Short codes use rejection sampling to eliminate modulo bias |
|
||||
| **Flaw-4**: Client-side token generation | Server-side only — tokens never leave the server until embedded in the QR |
|
||||
| **Flaw-5**: Missing status notification | Desktop toast: _"Device [IP] authenticated via QR (Safari). Not you? [Revoke]"_ — real-time QRLjacking detection |
|
||||
| **Flaw-6**: Inadequate session binding | IP + User-Agent stored for audit. Manual session revocation via API. HttpOnly + Secure + SameSite=lax cookies |
|
||||
|
||||
#### Timing-Safe Lookup
|
||||
|
||||
@@ -404,23 +491,23 @@ When someone authenticates via QR, the desktop shows a notification toast with t
|
||||
|
||||
#### Threat Coverage
|
||||
|
||||
| Threat | Why it doesn't work |
|
||||
|--------|-------------------|
|
||||
| **QR screenshot shared** | Single-use: consumed on first scan. 60s TTL: expired before the attacker can act. Desktop notification alerts you immediately. |
|
||||
| **Replay attack** | Atomic single-use consumption + 60s TTL. Old URLs always return 401. |
|
||||
| **Cloudflare edge logs** | Short code is an opaque 6-char lookup key, not the real 256-bit token. Single-use means replaying from logs always fails. |
|
||||
| **Brute force** | 56.8 billion combinations, ~2 valid at any time, dual-layer rate limiting blocks well before statistical feasibility. |
|
||||
| **QRLjacking** | 60s rotation forces real-time relay. Desktop toast provides instant detection. Self-hosted single-user context makes phishing implausible. |
|
||||
| **Timing attack** | Hash-based Map lookup — no string comparison timing leak. |
|
||||
| **Session cookie theft** | HttpOnly + Secure + SameSite=lax + 24h TTL. Manual revocation at `POST /api/auth/revoke`. |
|
||||
| Threat | Why it doesn't work |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **QR screenshot shared** | Single-use: consumed on first scan. 60s TTL: expired before the attacker can act. Desktop notification alerts you immediately. |
|
||||
| **Replay attack** | Atomic single-use consumption + 60s TTL. Old URLs always return 401. |
|
||||
| **Cloudflare edge logs** | Short code is an opaque 6-char lookup key, not the real 256-bit token. Single-use means replaying from logs always fails. |
|
||||
| **Brute force** | 56.8 billion combinations, ~2 valid at any time, dual-layer rate limiting blocks well before statistical feasibility. |
|
||||
| **QRLjacking** | 60s rotation forces real-time relay. Desktop toast provides instant detection. Self-hosted single-user context makes phishing implausible. |
|
||||
| **Timing attack** | Hash-based Map lookup — no string comparison timing leak. |
|
||||
| **Session cookie theft** | HttpOnly + Secure + SameSite=lax + 24h TTL. Manual revocation at `POST /api/auth/revoke`. |
|
||||
|
||||
#### How It Compares
|
||||
|
||||
| Platform | Model | Comparison |
|
||||
|----------|-------|------------|
|
||||
| **Discord** | Long-lived token, no confirmation, [repeatedly exploited](https://owasp.org/www-community/attacks/Qrljacking) | Codeman: single-use + TTL + notification |
|
||||
| **WhatsApp Web** | Phone confirms "Link device?", ~60s rotation | Comparable rotation; WhatsApp adds explicit confirmation (acceptable tradeoff for single-user) |
|
||||
| **Signal** | Ephemeral public key, E2E encrypted channel | Stronger crypto, but [exploited by Russian state actors in 2025](https://cloud.google.com/blog/topics/threat-intelligence/russia-targeting-signal-messenger) via social engineering despite it |
|
||||
| Platform | Model | Comparison |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Discord** | Long-lived token, no confirmation, [repeatedly exploited](https://owasp.org/www-community/attacks/Qrljacking) | Codeman: single-use + TTL + notification |
|
||||
| **WhatsApp Web** | Phone confirms "Link device?", ~60s rotation | Comparable rotation; WhatsApp adds explicit confirmation (acceptable tradeoff for single-user) |
|
||||
| **Signal** | Ephemeral public key, E2E encrypted channel | Stronger crypto, but [exploited by Russian state actors in 2025](https://cloud.google.com/blog/topics/threat-intelligence/russia-targeting-signal-messenger) via social engineering despite it |
|
||||
|
||||
> Full design rationale, security analysis, and implementation details: [`docs/qr-auth-plan.md`](docs/qr-auth-plan.md)
|
||||
|
||||
@@ -428,20 +515,20 @@ When someone authenticates via QR, the desktop shows a notification toast with t
|
||||
|
||||
## Security
|
||||
|
||||
Codeman launches sessions with `--dangerously-skip-permissions`, so the web UI is by design a remote-code-execution surface for whoever can reach it — the whole security model exists to control *who* that is. Recent hardening (v0.9.0 + v0.9.5) closes the browser-driven attack paths that bite self-hosted dev tools. Full model: [`docs/security-architecture.md`](docs/security-architecture.md). **Found a vulnerability?** See [`SECURITY.md`](SECURITY.md) for private disclosure and the list of known limitations.
|
||||
Codeman launches sessions with `--dangerously-skip-permissions`, so the web UI is by design a remote-code-execution surface for whoever can reach it — the whole security model exists to control _who_ that is. Recent hardening (v0.9.0 + v0.9.5) closes the browser-driven attack paths that bite self-hosted dev tools. Full model: [`docs/security-architecture.md`](docs/security-architecture.md). **Found a vulnerability?** See [`SECURITY.md`](SECURITY.md) for private disclosure and the list of known limitations.
|
||||
|
||||
### Network & access
|
||||
|
||||
- **Loopback by default** — binds `127.0.0.1`, reachable only from the same machine, so the no-password default is safe out of the box. Binding a non-loopback host without `CODEMAN_PASSWORD` *starts but prints a loud warning* with three concrete fixes (set a password, loopback + an authenticated tunnel, or explicitly acknowledge with `--allow-unauthenticated-network`)
|
||||
- **Loopback by default** — binds `127.0.0.1`, reachable only from the same machine, so the no-password default is safe out of the box. Binding a non-loopback host without `CODEMAN_PASSWORD` _starts but prints a loud warning_ with three concrete fixes (set a password, loopback + an authenticated tunnel, or explicitly acknowledge with `--allow-unauthenticated-network`)
|
||||
- **Optional auth, real sessions** — HTTP Basic via `CODEMAN_USERNAME` (default `admin`) / `CODEMAN_PASSWORD`. Success issues an opaque 256-bit `codeman_session` cookie (`randomBytes(32)`) — validated server-side, not client-signed, so it can't be forged offline (24h TTL, auto-extend, device-context audit log)
|
||||
- **Per-IP rate limiting** — 10 failed attempts → `429` with `Retry-After` (15-min decay). A valid cookie or correct password recovers *immediately* even while an attacker hammers the same IP — important because all tunnel traffic shares one loopback IP. QR auth has its own separate limiter
|
||||
- **Per-IP rate limiting** — 10 failed attempts → `429` with `Retry-After` (15-min decay). A valid cookie or correct password recovers _immediately_ even while an attacker hammers the same IP — important because all tunnel traffic shares one loopback IP. QR auth has its own separate limiter
|
||||
|
||||
### Always-on browser hardening (v0.9.5)
|
||||
|
||||
These run for **every** request — before auth, even on the default no-password loopback install:
|
||||
|
||||
- **Host-header allowlist → blocks DNS rebinding.** A custom domain rebound to `127.0.0.1` is rejected with `403 host not allowed` before any handler runs. Allowed: `localhost`, any IP literal, the bind host, `.ts.net` / `.trycloudflare.com` / `.cfargotunnel.com`, the active managed tunnel, and `CODEMAN_ALLOWED_HOSTS` (add custom reverse-proxy domains here — comma-separated; exact host or leading-dot `.suffix` for subdomains)
|
||||
- **Cross-site Origin / CSRF guard.** On state-changing methods (`POST`/`PUT`/`PATCH`/`DELETE`) the `Origin` must pass the same allowlist, else `403 cross-site request blocked`. A *missing* Origin is allowed (so `curl`, the CLI, and Claude Code hooks keep working); only a present-but-foreign or opaque `null` origin is rejected
|
||||
- **Cross-site Origin / CSRF guard.** On state-changing methods (`POST`/`PUT`/`PATCH`/`DELETE`) the `Origin` must pass the same allowlist, else `403 cross-site request blocked`. A _missing_ Origin is allowed (so `curl`, the CLI, and Claude Code hooks keep working); only a present-but-foreign or opaque `null` origin is rejected
|
||||
- **Raw `text/plain` bodies.** The global parser no longer JSON-parses `text/plain`, closing the CORS "simple request" CSRF vector where a cross-site `fetch` could smuggle JSON into a write route with no preflight
|
||||
- **WebSocket origin validation.** The terminal WS upgrade runs the same Host + Origin check and closes with code `4003` on failure (anti-CSWSH)
|
||||
- **XSS-escaped agent output.** AI-derived strings (tool names, command arguments, subagent descriptions) are HTML-escaped at every injection site before rendering in the subagent / activity panels
|
||||
@@ -479,74 +566,177 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
|
||||
|
||||
> Ctrl bindings also accept Cmd on macOS.
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Ctrl/Cmd+W` | Kill active session |
|
||||
| `Ctrl/Cmd+Tab` | Next session |
|
||||
| `Alt/Option+[` / `Alt/Option+]` | Previous / next session |
|
||||
| `Alt/Option+1`-`Alt/Option+9` | Switch to tab N (physical keys, so macOS Option layouts work) |
|
||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right |
|
||||
| `Ctrl/Cmd+L` | Clear terminal |
|
||||
| `Ctrl+Shift+R` | Restore terminal size |
|
||||
| `Ctrl+Shift+V` | Toggle voice input |
|
||||
| `Ctrl/Cmd +` / `-` | Font size |
|
||||
| `Ctrl/Cmd+?` | Keyboard help |
|
||||
| `Shift+Enter` | Insert newline (sent to terminal) |
|
||||
| `Escape` | Close panels & modals |
|
||||
| Shortcut | Action |
|
||||
| ------------------------------- | ------------------------------------------------------------- |
|
||||
| `Ctrl/Cmd+W` | Kill active session |
|
||||
| `Ctrl/Cmd/Option+K` | Find open session or start a new one |
|
||||
| `Ctrl/Cmd+Tab` | Next session |
|
||||
| `Alt/Option+[` / `Alt/Option+]` | Previous / next session |
|
||||
| `Alt/Option+1`-`Alt/Option+9` | Switch to tab N (physical keys, so macOS Option layouts work) |
|
||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right |
|
||||
| `Ctrl/Cmd+L` | Clear terminal |
|
||||
| `Ctrl+Shift+R` | Restore terminal size |
|
||||
| `Ctrl+Shift+V` | Toggle voice input |
|
||||
| `Ctrl/Cmd +` / `-` | Font size |
|
||||
| `Ctrl/Cmd+?` | Keyboard help |
|
||||
| `Shift+Enter` | Insert newline (sent to terminal) |
|
||||
| `Escape` | Close panels & modals |
|
||||
|
||||
---
|
||||
|
||||
## Driving Codeman from an Agent — Programmatic Guide
|
||||
|
||||
For AI agents and automation that control Codeman without a browser: an agent that spins up worker sessions, a CI bot, or **Claude Code running _inside_ a Codeman session orchestrating other sessions**. Everything the UI does is HTTP + a CLI, so an agent can do it too.
|
||||
|
||||
### Detect that you're inside Codeman
|
||||
|
||||
When a CLI runs in a Codeman-managed session, these environment variables are set — read them instead of hardcoding anything:
|
||||
|
||||
| Variable | Meaning |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `CODEMAN_MUX=1` | You're in a managed tmux session. **Never** `tmux kill-session` / `pkill claude` / `pkill tmux` — you'll kill yourself or a sibling. |
|
||||
| `CODEMAN_API_URL` | Base URL of the API (e.g. `https://127.0.0.1:3000`). Use it for every call below. |
|
||||
| `CODEMAN_SESSION_ID` | _Your own_ session id. Use it to avoid acting on yourself. |
|
||||
| `CODEMAN_HOOK_SECRET_FILE` | Path to the hook secret (required on `/api/hook-event` while a managed tunnel is up). |
|
||||
|
||||
### Rules of the road (read before you POST)
|
||||
|
||||
1. **Single-line input only.** Programmatic input is sent as literal text **+ Enter** in one shot. Multi-line strings break the agent TUI (Ink) — send one line, or split into multiple calls.
|
||||
2. **Make input idempotent.** Include a stable `clientId` and a monotonic per-session `seq` on `POST …/input`. The server de-duplicates, so a retry after a dropped connection can't double-deliver a prompt.
|
||||
3. **Auth.** If `CODEMAN_PASSWORD` is set, send HTTP Basic auth (user `admin` or `CODEMAN_USERNAME`) or a `codeman_session` cookie. The default loopback install is passwordless. A missing `Origin` header is allowed, so plain `curl` works; cross-site browser origins are rejected (CSRF guard).
|
||||
4. **Response envelope.** Most endpoints return `{ "success": true, "data": … }` (errors: `{ "success": false, "error", "errorCode" }`). A few legacy GETs return bare bodies — **handle both** (`body.data ?? body`).
|
||||
5. **`/api/v1/*`** is a stable alias of `/api/*`.
|
||||
|
||||
### Recipes
|
||||
|
||||
```bash
|
||||
API="${CODEMAN_API_URL:-http://127.0.0.1:3000}"
|
||||
# (add -u admin:"$CODEMAN_PASSWORD" to each call if a password is set)
|
||||
|
||||
# 1. See what's running
|
||||
curl -s "$API/api/sessions" | jq '.data // .'
|
||||
|
||||
# 2. Spin up a worker session (a "case" = named working dir)
|
||||
curl -s -X POST "$API/api/quick-start" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"refactor-auth","mode":"claude","effort":"high"}' | jq
|
||||
|
||||
# 3. Send a prompt into a session (exactly-once: clientId + seq)
|
||||
curl -s -X POST "$API/api/sessions/$SID/input" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"input":"Run the test suite and summarize failures","useMux":true,"clientId":"agent-1","seq":1}'
|
||||
|
||||
# 4. Read the terminal back
|
||||
curl -s "$API/api/sessions/$SID/output" | jq -r '.data // .'
|
||||
|
||||
# 5. Stream live events (session output, agent activity, status)
|
||||
curl -sN "$API/api/events" # Server-Sent Events
|
||||
|
||||
# 6. Schedule recurring work (cron-style job)
|
||||
curl -s -X POST "$API/api/cron/jobs" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"nightly-deps","agentType":"claude","workingDir":"/home/me/proj",
|
||||
"promptMode":"inline_text","promptText":"Update dependencies and open a PR",
|
||||
"inputMode":"typed","scheduleType":"daily","dailyTime":"03:00",
|
||||
"enabled":true,"concurrencyPolicy":"warn_only"}' | jq
|
||||
|
||||
# 7. Inspect background sub-agents and their transcripts
|
||||
curl -s "$API/api/subagents" | jq '.data // .'
|
||||
curl -s "$API/api/subagents/$AID/transcript" | jq -r '.data // .'
|
||||
|
||||
# 8. Whole-system snapshot (sessions, settings, respawn, stats)
|
||||
curl -s "$API/api/status" | jq
|
||||
```
|
||||
|
||||
### Or use the bundled CLI
|
||||
|
||||
The same operations are available as commands (`codeman <cmd>`, aliases in parentheses) — handy from a shell tool inside a session:
|
||||
|
||||
```bash
|
||||
codeman session start -d /path/to/repo # (s) start a session
|
||||
codeman session list # list sessions
|
||||
codeman session logs <id> # tail output
|
||||
codeman task add "fix the failing test" # (t) queue a task
|
||||
codeman ralph start --min-hours 8 # (r) launch the autonomous loop
|
||||
codeman attach <path> # attach a Claude hook context
|
||||
```
|
||||
|
||||
### Hooks (events flowing _back_ to Codeman)
|
||||
|
||||
Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_prompt`, `idle_prompt`, `stop`, `task_completed`, …) so the dashboard reacts in real time. This endpoint is auth-exempt on loopback but, under a managed tunnel, requires the `X-Codeman-Hook-Secret` header (read it from `$CODEMAN_HOOK_SECRET_FILE`). You normally don't call this by hand — Codeman wires it up — but it's how the autonomy layers "see" what the agent is doing.
|
||||
|
||||
> Full endpoint list and request/response shapes follow.
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
REST over Fastify — **~140 handlers across 15 route modules**, plus an SSE stream and a WebSocket terminal channel. A representative subset:
|
||||
REST over Fastify — **~160 handlers across 18 route modules**, plus an SSE stream and a WebSocket terminal channel. All responses use the `ApiResponse<T>` envelope (`{success, data}` / `{success, error, errorCode}`); `/api/v1/*` is a stable alias. A representative subset:
|
||||
|
||||
### Sessions
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/sessions` | List all |
|
||||
| `POST` | `/api/quick-start` | Create case + start session |
|
||||
| `DELETE` | `/api/sessions/:id` | Delete session |
|
||||
| `POST` | `/api/sessions/:id/input` | Send input |
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | -------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `GET` | `/api/sessions` | List all |
|
||||
| `POST` | `/api/quick-start` | Create case + start session (`{caseName?, mode?, effort?, envOverrides?}`) |
|
||||
| `POST` | `/api/sessions/:id/input` | Send input (`{input, useMux?, clientId?, seq?}` — `clientId`+`seq` = exactly-once) |
|
||||
| `GET` | `/api/sessions/:id/output` | Read terminal output |
|
||||
| `DELETE` | `/api/sessions/:id` | Delete session |
|
||||
|
||||
### Respawn
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ---------------------------------- | -------------------------- |
|
||||
| `POST` | `/api/sessions/:id/respawn/enable` | Enable with config + timer |
|
||||
| `POST` | `/api/sessions/:id/respawn/stop` | Stop controller |
|
||||
| `PUT` | `/api/sessions/:id/respawn/config` | Update config |
|
||||
| `POST` | `/api/sessions/:id/respawn/stop` | Stop controller |
|
||||
| `PUT` | `/api/sessions/:id/respawn/config` | Update config |
|
||||
|
||||
### Ralph / Todo
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/sessions/:id/ralph-state` | Get loop state + todos |
|
||||
| `POST` | `/api/sessions/:id/ralph-config` | Configure tracking |
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | -------------------------------- | ---------------------- |
|
||||
| `GET` | `/api/sessions/:id/ralph-state` | Get loop state + todos |
|
||||
| `POST` | `/api/sessions/:id/ralph-config` | Configure tracking |
|
||||
|
||||
### Orchestrator
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/api/orchestrator/start` | Start orchestration from a goal |
|
||||
| `POST` | `/api/orchestrator/approve` | Approve the generated plan |
|
||||
| `GET` | `/api/orchestrator/status` | Current phase + progress |
|
||||
| `POST` | `/api/orchestrator/stop` | Stop and clean up |
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | --------------------------- | ------------------------------- |
|
||||
| `POST` | `/api/orchestrator/start` | Start orchestration from a goal |
|
||||
| `POST` | `/api/orchestrator/approve` | Approve the generated plan |
|
||||
| `GET` | `/api/orchestrator/status` | Current phase + progress |
|
||||
| `POST` | `/api/orchestrator/stop` | Stop and clean up |
|
||||
|
||||
### Cron (scheduled jobs)
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ---------------- | ---------------------------- | ----------------------- |
|
||||
| `GET` / `POST` | `/api/cron/jobs` | List / create cron jobs |
|
||||
| `PUT` / `DELETE` | `/api/cron/jobs/:id` | Update / delete a job |
|
||||
| `PUT` | `/api/cron/jobs/:id/enabled` | Enable / disable |
|
||||
| `POST` | `/api/cron/jobs/:id/run` | Run now |
|
||||
| `GET` | `/api/cron/jobs/:id/runs` | Run history |
|
||||
|
||||
### Subagents
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/subagents` | List all background agents |
|
||||
| `GET` | `/api/subagents/:id` | Agent info and status |
|
||||
| `GET` | `/api/subagents/:id/transcript` | Full activity transcript |
|
||||
| `DELETE` | `/api/subagents/:id` | Kill agent process |
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | ------------------------------- | -------------------------- |
|
||||
| `GET` | `/api/subagents` | List all background agents |
|
||||
| `GET` | `/api/subagents/:id` | Agent info and status |
|
||||
| `GET` | `/api/subagents/:id/transcript` | Full activity transcript |
|
||||
| `DELETE` | `/api/subagents/:id` | Kill agent process |
|
||||
|
||||
### System
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/events` | SSE stream |
|
||||
| `GET` | `/api/status` | Full app state |
|
||||
| `POST` | `/api/hook-event` | Hook callbacks |
|
||||
| `GET` | `/api/system/update/check` | Check for a new release |
|
||||
| `POST` | `/api/system/update` | Self-update (git-clone installs) |
|
||||
| `POST` | `/api/clipboard` | Push text to all connected browsers (`{text}`) |
|
||||
| `GET` | `/api/sessions/:id/run-summary` | Timeline + stats |
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ------------------------------- | ---------------------------------------------- |
|
||||
| `GET` | `/api/events` | SSE stream |
|
||||
| `GET` | `/api/status` | Full app state |
|
||||
| `POST` | `/api/hook-event` | Hook callbacks |
|
||||
| `GET` | `/api/system/update/check` | Check for a new release |
|
||||
| `POST` | `/api/system/update` | Self-update (git-clone installs) |
|
||||
| `POST` | `/api/clipboard` | Push text to all connected browsers (`{text}`) |
|
||||
| `GET` | `/api/sessions/:id/run-summary` | Timeline + stats |
|
||||
|
||||
---
|
||||
|
||||
@@ -624,14 +814,14 @@ See [CLAUDE.md](./CLAUDE.md) for full documentation.
|
||||
|
||||
The codebase went through a comprehensive 7-phase refactoring that eliminated god objects, centralized configuration, and established modular architecture:
|
||||
|
||||
| Phase | What changed | Impact |
|
||||
|-------|-------------|--------|
|
||||
| **Performance** | Cached endpoints, SSE adaptive batching, buffer chunking | Sub-16ms terminal latency |
|
||||
| **Route extraction** | `server.ts` split into 15 domain route modules + auth middleware + port interfaces | **−67%** server.ts LOC (6,736 → 2,254) |
|
||||
| **Domain splitting** | `types.ts` → 16 domain files, `ralph-tracker` → 7 files, `respawn-controller` → 5 files, `session` → 6 files | No more god files |
|
||||
| **Frontend modules** | `app.js` → 18 extracted modules across infra, domain & feature layers | app.js core down to **~3.4K LOC** |
|
||||
| **Config consolidation** | ~70 scattered magic numbers → 10 domain-focused config files | Zero cross-file duplicates |
|
||||
| **Test infrastructure** | Shared mock library, 12 route test files, consolidated MockSession | Testable route handlers via `app.inject()` |
|
||||
| Phase | What changed | Impact |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------ |
|
||||
| **Performance** | Cached endpoints, SSE adaptive batching, buffer chunking | Sub-16ms terminal latency |
|
||||
| **Route extraction** | `server.ts` split into 15 domain route modules + auth middleware + port interfaces | **−67%** server.ts LOC (6,736 → 2,254) |
|
||||
| **Domain splitting** | `types.ts` → 16 domain files, `ralph-tracker` → 7 files, `respawn-controller` → 5 files, `session` → 6 files | No more god files |
|
||||
| **Frontend modules** | `app.js` → 18 extracted modules across infra, domain & feature layers | app.js core down to **~3.4K LOC** |
|
||||
| **Config consolidation** | ~70 scattered magic numbers → 10 domain-focused config files | Zero cross-file duplicates |
|
||||
| **Test infrastructure** | Shared mock library, 12 route test files, consolidated MockSession | Testable route handlers via `app.inject()` |
|
||||
|
||||
Full details: [`docs/archive/code-structure-findings.md`](docs/archive/code-structure-findings.md)
|
||||
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
# SPEEDRUN.md — Fast-execution protocol for Claude
|
||||
|
||||
Read this when the goal is **throughput**: get correct, verified work done with
|
||||
minimum ceremony. This does **not** relax correctness or the safety rules in
|
||||
`CLAUDE.md` — those still win. It removes _waste_, not _rigor_.
|
||||
|
||||
> Precedence: `CLAUDE.md` > explicit user instructions > this file. If anything
|
||||
> here conflicts with `CLAUDE.md`, `CLAUDE.md` wins.
|
||||
|
||||
---
|
||||
|
||||
## The mindset
|
||||
|
||||
- **Act, don't announce.** No "I'm going to now…" preamble. Do the thing, report
|
||||
the result.
|
||||
- **Cheapest proof that the change works.** Pick the smallest check that actually
|
||||
demonstrates correctness — not the biggest.
|
||||
- **Batch aggressively.** Independent reads, greps, and edits go in **one**
|
||||
message with parallel tool calls. Never serialize work that has no dependency.
|
||||
- **Momentum over perfection.** Land a correct increment, verify it, move on.
|
||||
Don't gold-plate untouched code.
|
||||
|
||||
---
|
||||
|
||||
## Loop (repeat until done)
|
||||
|
||||
1. **Orient once** — one parallel burst of reads/greps to load the context you
|
||||
need. Don't re-read files the harness says are already current.
|
||||
2. **Change** — make the edit(s). Batch independent edits.
|
||||
3. **Verify cheaply** — the smallest check that proves _this_ change (see below).
|
||||
4. **Advance** — next item. Only re-verify what you touched.
|
||||
5. **Stop** at: list empty, a hard blocker, or a decision that's genuinely the
|
||||
user's to make.
|
||||
|
||||
---
|
||||
|
||||
## Verification ladder — climb only as high as the change needs
|
||||
|
||||
| Change kind | Cheapest sufficient check |
|
||||
|-------------|---------------------------|
|
||||
| Types / signatures / imports | `tsc --noEmit` (or `--watch` already running) |
|
||||
| One module's logic | `npm test -- test/<file>.test.ts` (the **one** relevant file) |
|
||||
| A named behavior | `npm test -- -t "pattern"` |
|
||||
| Route/handler | `app.inject()` route test, or one `curl` against the running dev server |
|
||||
| Frontend render | Playwright load + assert (`waitUntil: 'domcontentloaded'`, wait 3–4s) |
|
||||
| Broad / pre-merge | `npm run test:ci` (the CI-equivalent sweep) |
|
||||
|
||||
**Hard rules (never skip, even in a rush):**
|
||||
- ⚠️ **Never run bare `npm test`** — it pulls in browser/visual suites that hang
|
||||
or fail locally. Always pass a file or `-t`, or use `test:ci`.
|
||||
- ⚠️ **Never COM without verifying the change actually works** first (curl the
|
||||
endpoint / Playwright the UI). "Compiles" ≠ "works".
|
||||
- ⚠️ **Session safety** — check `$CODEMAN_MUX`; never `tmux kill-session` /
|
||||
`pkill claude` in a managed session.
|
||||
- ⚠️ **Single-line prompts** for any programmatic session input.
|
||||
|
||||
---
|
||||
|
||||
## Speed tactics that pay off here
|
||||
|
||||
- **Parallel exploration**: dispatch `Explore` subagents (or one parallel grep
|
||||
burst) instead of serial file-by-file reading when scope is uncertain.
|
||||
- **`tsc --noEmit --watch`** in the background — instant type feedback, no repeat
|
||||
cold starts.
|
||||
- **Target one test file** — `fileParallelism: false` means the suite is serial;
|
||||
running one file is dramatically faster than the sweep.
|
||||
- **`curl localhost:3000/api/...`** beats spinning up a browser for backend
|
||||
checks. Reserve Playwright for actual UI rendering.
|
||||
- **Trust the harness** — if it says a file you just edited is current, don't
|
||||
re-Read it to "confirm". The Edit already succeeded or it would have errored.
|
||||
|
||||
---
|
||||
|
||||
## Anti-patterns (these masquerade as speed, but cost time)
|
||||
|
||||
- Running the full test suite to check a one-file change.
|
||||
- Re-reading files you already have in context.
|
||||
- Narrating a plan you're about to execute anyway.
|
||||
- Serial tool calls that have no dependency between them.
|
||||
- Claiming "done / fixed / passing" **before** running the check that proves it.
|
||||
- Deploying (COM) on green typecheck alone, without exercising the real flow.
|
||||
|
||||
---
|
||||
|
||||
## Stop-conditions (don't rush past these)
|
||||
|
||||
Stop and surface, don't guess, when you hit:
|
||||
- A **destructive / hard-to-reverse** action (delete, overwrite, force-push).
|
||||
- An **outward-facing** action (publishing, sending, deploying) not already
|
||||
authorized.
|
||||
- A **genuine product decision** the code can't answer.
|
||||
- A **failing verification you can't explain** — debug it (see
|
||||
`superpowers:systematic-debugging`), don't paper over it.
|
||||
|
||||
---
|
||||
|
||||
## Definition of done
|
||||
|
||||
A task is done when **all** hold:
|
||||
- The change is made.
|
||||
- The cheapest sufficient check **ran** and **passed** — evidence, not assertion.
|
||||
- No new type errors / lint errors introduced (`tsc --noEmit`, `npm run lint`).
|
||||
- You state plainly what was done and what proved it. If a step was skipped or a
|
||||
test failed, say so — don't hedge, don't overclaim.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Codeman agent base image (built locally by scripts/build-agent-image.mjs).
|
||||
#
|
||||
# Contains the agent toolchain (node + the CLIs + git/tmux/ripgrep) but NO
|
||||
# secrets: credentials are delivered at RUNTIME via bind mounts (~/.claude etc.)
|
||||
# or name-only `docker exec --env`, never baked in, so `docker save` exports stay
|
||||
# secret-free. tmux is a HARD prerequisite (the in-container tmux is what makes a
|
||||
# reconnect durable), so it is installed here and probed before launch.
|
||||
#
|
||||
# HOME is made writable by an ARBITRARY host uid via the OpenShift "gid 0,
|
||||
# group-writable" convention: on Linux we run `--user <hostUid>:0`, so the agent
|
||||
# uid is the host uid (workspace files stay host-owned) while gid 0 keeps $HOME
|
||||
# writable even though the uid is not the baked 1000.
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
# Base toolchain. `curl` is needed for the hook callbacks (`curl -sk $CODEMAN_API_URL`),
|
||||
# `procps` for `ps`, `tmux` for the durable in-container session.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
tmux \
|
||||
ripgrep \
|
||||
curl \
|
||||
ca-certificates \
|
||||
less \
|
||||
procps \
|
||||
openssh-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# The agent CLIs (all four backends Codeman supports). Pinning is left to the
|
||||
# rebuild cadence (see docs/docker-cases-plan.md, user-decision 2).
|
||||
RUN npm install -g \
|
||||
@anthropic-ai/claude-code \
|
||||
@openai/codex \
|
||||
@google/gemini-cli \
|
||||
opencode-ai \
|
||||
&& npm cache clean --force
|
||||
|
||||
# `agent` user (gid 0) with an arbitrary-uid-writable HOME. The uid is
|
||||
# auto-assigned (node:22-slim already occupies uid 1000 with its `node` user); at
|
||||
# runtime Codeman overrides with `--user <hostUid>:0` on Linux, so the baked uid
|
||||
# only matters for a hand-run / Docker Desktop container. gid 0 + group-writable
|
||||
# HOME (OpenShift arbitrary-uid convention) keeps $HOME writable for any uid.
|
||||
# UTF-8 locale so tmux/Ink render Unicode box-drawing instead of VT100 ACS `q`
|
||||
# glyphs (C.UTF-8 is built into glibc; no locales package needed). Codeman also
|
||||
# sets these at run time so containers built before this line still get UTF-8.
|
||||
ENV LANG=C.UTF-8 LC_ALL=C.UTF-8
|
||||
ENV HOME=/home/agent
|
||||
# `.claude` (+ `.claude/projects` mount point) and `.codex` (+ `.codex/sessions`) are
|
||||
# pre-created gid-0 group-writable so the container owns its OWN credential config
|
||||
# dirs: tokens/settings/config are seeded in as writable copies and each CLI's runtime
|
||||
# state (backups, tasks, refreshed tokens) stays container-local, while ONLY the shared
|
||||
# transcript/rollout dirs (`.claude/projects`, `.codex/sessions`) are bind-mounted from
|
||||
# the host. (gemini/gcloud/opencode are whole seed-copies and need no pre-created dir.)
|
||||
RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \
|
||||
&& mkdir -p /home/agent/.npm /home/agent/.cache /home/agent/.config /home/agent/.codeman \
|
||||
/home/agent/.claude/projects /home/agent/.codex/sessions \
|
||||
&& chgrp -R 0 /home/agent \
|
||||
&& chmod -R g=u /home/agent
|
||||
|
||||
USER agent
|
||||
WORKDIR /home/agent
|
||||
|
||||
# Codeman overrides the command with `sleep infinity` at create time; this is the
|
||||
# fallback so a hand-run container also idles rather than exiting.
|
||||
CMD ["sleep", "infinity"]
|
||||
@@ -0,0 +1,588 @@
|
||||
# Claude Code Build Brief: Add Scheduling to Codeman
|
||||
|
||||
## 0. Purpose of This Brief
|
||||
|
||||
You are Claude Code working inside the Codeman repository.
|
||||
|
||||
Your task is to add a **small, reliable scheduling layer** to Codeman while preserving Codeman's existing architecture and session-management behavior.
|
||||
|
||||
This is not a greenfield rewrite. This is not a full product rebuild. This is a focused extension.
|
||||
|
||||
The target user wants Codeman-like tmux/web/session management, but with first-class scheduled jobs for Claude, Codex, OpenCode, Terminal, or any other configurable coding-agent harness.
|
||||
|
||||
---
|
||||
|
||||
## 1. Non-Negotiable Goal
|
||||
|
||||
Add scheduling to Codeman so a user can define a scheduled coding-agent job that:
|
||||
|
||||
1. Has a name.
|
||||
2. Uses an existing Codeman-supported agent/session type where possible.
|
||||
3. Has a working directory.
|
||||
4. Has a prompt or prompt file.
|
||||
5. Has a schedule.
|
||||
6. Can be enabled or disabled.
|
||||
7. Can be manually run now.
|
||||
8. When due, creates a Codeman/tmux session.
|
||||
9. Sends the configured prompt into that session.
|
||||
10. Records last run, next run, status, and run history.
|
||||
|
||||
The first working version should prioritize **scheduling correctness and reuse of Codeman's existing tmux/session system** over UI polish.
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Architectural Rule
|
||||
|
||||
Do **not** rebuild Codeman's session layer.
|
||||
|
||||
Reuse existing Codeman functionality for:
|
||||
|
||||
- Creating sessions.
|
||||
- Naming sessions.
|
||||
- Launching Claude/Codex/OpenCode/Terminal sessions.
|
||||
- Sending input into sessions.
|
||||
- Displaying sessions in the web UI.
|
||||
- Killing sessions.
|
||||
- Tracking session status if already supported.
|
||||
|
||||
If an internal API/service/function already exists, reuse it.
|
||||
|
||||
If no reusable function exists, create a thin wrapper around the existing implementation rather than duplicating logic.
|
||||
|
||||
---
|
||||
|
||||
## 3. Product Boundary
|
||||
|
||||
This build is **Codeman + Scheduler**.
|
||||
|
||||
It is not yet:
|
||||
|
||||
- A full quota engine.
|
||||
- A full lock manager.
|
||||
- A replacement for Codeman's terminal UI.
|
||||
- A new FastAPI application.
|
||||
- A multi-tenant SaaS platform.
|
||||
- A complex cron-management product.
|
||||
- A full agent autonomy framework.
|
||||
|
||||
Keep the build small and shippable.
|
||||
|
||||
---
|
||||
|
||||
## 4. Required Working Scope for v0.1
|
||||
|
||||
Implement the following minimum features.
|
||||
|
||||
### 4.1 Scheduled Jobs List
|
||||
|
||||
Create a UI page showing all scheduled jobs.
|
||||
|
||||
Each row/card should show:
|
||||
|
||||
- Job name.
|
||||
- Agent/session type.
|
||||
- Working directory.
|
||||
- Schedule type.
|
||||
- Enabled/disabled state.
|
||||
- Last run time.
|
||||
- Next run time.
|
||||
- Last run status.
|
||||
- Actions:
|
||||
- Run Now.
|
||||
- Enable/Disable.
|
||||
- Edit.
|
||||
- Delete.
|
||||
|
||||
### 4.2 Create/Edit Scheduled Job
|
||||
|
||||
Create a form for scheduled jobs with these fields:
|
||||
|
||||
- `name`
|
||||
- `agent_type`
|
||||
- Reuse Codeman's existing session/agent types where possible.
|
||||
- Include at least Terminal/custom command if supported.
|
||||
- `working_directory`
|
||||
- `launch_command` if needed by Codeman's model.
|
||||
- `prompt_mode`
|
||||
- `inline_text`
|
||||
- `prompt_file_path`
|
||||
- `prompt_text`
|
||||
- `prompt_file_path`
|
||||
- `input_mode`
|
||||
- `paste`
|
||||
- `typed`
|
||||
- `schedule_type`
|
||||
- `once`
|
||||
- `interval_minutes`
|
||||
- `daily_time`
|
||||
- `weekly_time`
|
||||
- `run_at` for one-time jobs.
|
||||
- `interval_minutes` for interval jobs.
|
||||
- `daily_time` for daily jobs.
|
||||
- `weekly_days` and `weekly_time` for weekly jobs.
|
||||
- `enabled`
|
||||
- `notes` optional.
|
||||
|
||||
Do not build a complex visual cron editor in v0.1.
|
||||
|
||||
### 4.3 Run Now
|
||||
|
||||
Every scheduled job must support a `Run Now` action.
|
||||
|
||||
Run Now should:
|
||||
|
||||
1. Create a new session through Codeman's existing session creation logic.
|
||||
2. Send the configured prompt into the session using Codeman's existing input mechanism.
|
||||
3. Create a run-history record.
|
||||
4. Update last-run fields.
|
||||
5. Redirect or link the user to the created Codeman session.
|
||||
|
||||
### 4.4 Background Scheduler Loop
|
||||
|
||||
Add a small background scheduler loop that runs inside the Codeman backend process.
|
||||
|
||||
The loop should:
|
||||
|
||||
1. Wake every 15-60 seconds.
|
||||
2. Load enabled schedules.
|
||||
3. Find schedules where `next_run_at <= now`.
|
||||
4. Create a scheduled run.
|
||||
5. Launch the session using existing Codeman session logic.
|
||||
6. Send the prompt.
|
||||
7. Record run history.
|
||||
8. Compute the next run time.
|
||||
9. Avoid duplicate launches if the loop overlaps or restarts.
|
||||
|
||||
Keep this simple and robust.
|
||||
|
||||
### 4.5 Run History
|
||||
|
||||
Every scheduled execution should create a run-history record.
|
||||
|
||||
Track:
|
||||
|
||||
- `id`
|
||||
- `scheduled_job_id`
|
||||
- `session_id` or Codeman session reference.
|
||||
- `session_name` if applicable.
|
||||
- `started_at`
|
||||
- `finished_at` optional.
|
||||
- `status`
|
||||
- `created`
|
||||
- `session_started`
|
||||
- `prompt_sent`
|
||||
- `failed`
|
||||
- `error_message` optional.
|
||||
- `trigger_type`
|
||||
- `scheduled`
|
||||
- `manual_run_now`
|
||||
- `created_session_url` or route reference if easy.
|
||||
|
||||
---
|
||||
|
||||
## 5. Scheduling Rules
|
||||
|
||||
### 5.1 Once
|
||||
|
||||
Run at a specific date/time.
|
||||
|
||||
After successful launch:
|
||||
|
||||
- Set `enabled = false`, or mark as completed.
|
||||
|
||||
### 5.2 Interval
|
||||
|
||||
Run every N minutes.
|
||||
|
||||
Example:
|
||||
|
||||
- Every 60 minutes.
|
||||
- Every 240 minutes.
|
||||
|
||||
After launch:
|
||||
|
||||
- `next_run_at = now + interval_minutes`.
|
||||
|
||||
### 5.3 Daily
|
||||
|
||||
Run every day at HH:MM.
|
||||
|
||||
After launch:
|
||||
|
||||
- Compute the next occurrence of HH:MM after now.
|
||||
|
||||
### 5.4 Weekly
|
||||
|
||||
Run on selected weekdays at HH:MM.
|
||||
|
||||
After launch:
|
||||
|
||||
- Compute the next selected weekday/time after now.
|
||||
|
||||
### 5.5 Timezone
|
||||
|
||||
Use the server's local timezone for v0.1 unless Codeman already has timezone handling.
|
||||
|
||||
Add a visible note in the UI:
|
||||
|
||||
> Times use the server's local timezone.
|
||||
|
||||
Do not overbuild timezone support in v0.1.
|
||||
|
||||
---
|
||||
|
||||
## 6. Data Storage Decision
|
||||
|
||||
First inspect Codeman's existing persistence model.
|
||||
|
||||
If Codeman already has a database or persistence layer:
|
||||
|
||||
- Reuse it.
|
||||
- Add scheduled job and scheduled run models/tables/records using the existing pattern.
|
||||
|
||||
If Codeman uses files or JSON state:
|
||||
|
||||
- Use the same style for v0.1.
|
||||
- Prefer simple persistence over introducing a heavy new dependency.
|
||||
|
||||
If there is no appropriate persistence layer:
|
||||
|
||||
- Add SQLite only if it fits the codebase cleanly.
|
||||
- Otherwise use a JSON file store for the first version.
|
||||
|
||||
Do not introduce Postgres, Redis, Celery, or a separate scheduler service.
|
||||
|
||||
---
|
||||
|
||||
## 7. Concurrency and Duplicate-Run Guard
|
||||
|
||||
Implement a basic duplicate-run guard.
|
||||
|
||||
A schedule should not launch twice for the same due time.
|
||||
|
||||
Minimum acceptable approach:
|
||||
|
||||
- Before launching, create/update a run record with a `created` or `launching` state.
|
||||
- Use a schedule-level `last_triggered_at` or `last_due_key` to avoid double launching.
|
||||
- If launch fails, record failure clearly.
|
||||
|
||||
Do not build distributed locks. Codeman is expected to be local/single-instance for v0.1.
|
||||
|
||||
---
|
||||
|
||||
## 8. Multi-Session Warning
|
||||
|
||||
When the user clicks `Run Now`, show a warning if there are already active sessions for the same agent type.
|
||||
|
||||
Minimum behavior:
|
||||
|
||||
- If active sessions exist, show a confirmation warning.
|
||||
- User can continue anyway.
|
||||
|
||||
For scheduled automatic runs:
|
||||
|
||||
- Add a setting on the scheduled job:
|
||||
- `warn_only`
|
||||
- `skip_if_same_agent_running`
|
||||
|
||||
Default:
|
||||
|
||||
- `warn_only` for manual runs.
|
||||
- `skip_if_same_agent_running = false` for automatic runs unless easy to implement.
|
||||
|
||||
Do not build a complete quota engine in v0.1.
|
||||
|
||||
---
|
||||
|
||||
## 9. Prompt Sending Rules
|
||||
|
||||
The scheduler must support sending the configured prompt into the created session.
|
||||
|
||||
Prompt source:
|
||||
|
||||
1. Inline prompt text.
|
||||
2. Prompt file path.
|
||||
|
||||
Input mode:
|
||||
|
||||
1. Paste mode.
|
||||
2. Typed mode.
|
||||
|
||||
If only one input mode is easy with Codeman's current internals, implement that first and structure the code so the other can be added later.
|
||||
|
||||
Important:
|
||||
|
||||
- Do not send prompts to a session if session creation failed.
|
||||
- Record prompt-send success/failure in run history.
|
||||
- Save enough metadata to understand what prompt was used.
|
||||
|
||||
---
|
||||
|
||||
## 10. UI Bifurcation
|
||||
|
||||
Keep UI changes cleanly separated.
|
||||
|
||||
Add scheduler UI under a clear navigation item:
|
||||
|
||||
- `Scheduled Jobs`
|
||||
|
||||
Do not clutter the existing session dashboard.
|
||||
|
||||
The existing session dashboard may show sessions created by scheduled jobs, but the scheduling controls should live in their own section.
|
||||
|
||||
Recommended pages/routes:
|
||||
|
||||
- `/schedules`
|
||||
- `/schedules/new`
|
||||
- `/schedules/:id`
|
||||
- `/schedules/:id/edit`
|
||||
- `/schedules/:id/run-now`
|
||||
- `/schedules/:id/enable`
|
||||
- `/schedules/:id/disable`
|
||||
- `/schedules/:id/delete`
|
||||
|
||||
Use Codeman's existing frontend conventions and routing style.
|
||||
|
||||
---
|
||||
|
||||
## 11. Backend Bifurcation
|
||||
|
||||
Keep scheduler code separate from existing session code.
|
||||
|
||||
Recommended logical modules, adapted to Codeman's actual structure:
|
||||
|
||||
- `scheduler/model` or equivalent.
|
||||
- `scheduler/store` or equivalent.
|
||||
- `scheduler/service` for schedule calculations and launch logic.
|
||||
- `scheduler/loop` for the background due-job checker.
|
||||
- `scheduler/routes` for API/UI endpoints.
|
||||
- `scheduler/time` for next-run calculations.
|
||||
|
||||
Do not mix scheduling logic directly into terminal rendering, xterm handling, or low-level tmux code.
|
||||
|
||||
The scheduler service should call session services; it should not own tmux directly unless Codeman has no session abstraction.
|
||||
|
||||
---
|
||||
|
||||
## 12. Required Discovery Phase Before Coding
|
||||
|
||||
Before implementing, inspect the Codeman repo and produce a short architecture note in the terminal or in a file called:
|
||||
|
||||
`docs/cron-discovery.md`
|
||||
|
||||
This note must identify:
|
||||
|
||||
1. Where session creation happens.
|
||||
2. Where agent/session types are defined.
|
||||
3. Where input is sent into a session.
|
||||
4. Where active sessions are listed.
|
||||
5. Where session kill/delete is handled.
|
||||
6. How session state is stored.
|
||||
7. Whether there is existing persistence.
|
||||
8. Where backend routes live.
|
||||
9. Where frontend pages/components live.
|
||||
10. The smallest integration points for scheduling.
|
||||
|
||||
Do not start coding until this discovery is complete.
|
||||
|
||||
---
|
||||
|
||||
## 13. Implementation Phases
|
||||
|
||||
### Phase 1: Discovery
|
||||
|
||||
Deliverable:
|
||||
|
||||
- `docs/cron-discovery.md`
|
||||
|
||||
Must answer the 10 discovery questions above.
|
||||
|
||||
### Phase 2: Data Model / Persistence
|
||||
|
||||
Deliverable:
|
||||
|
||||
- Scheduled job persistence.
|
||||
- Scheduled run history persistence.
|
||||
- Basic create/read/update/delete operations.
|
||||
|
||||
### Phase 3: Scheduler Calculation Logic
|
||||
|
||||
Deliverable:
|
||||
|
||||
- Functions to compute `next_run_at` for:
|
||||
- once
|
||||
- interval
|
||||
- daily
|
||||
- weekly
|
||||
|
||||
Add tests if the repo has an existing test setup.
|
||||
|
||||
### Phase 4: Manual Run Now
|
||||
|
||||
Deliverable:
|
||||
|
||||
- Create scheduled job.
|
||||
- Click Run Now.
|
||||
- Codeman session is created.
|
||||
- Prompt is sent.
|
||||
- Run history is recorded.
|
||||
- UI links to the session.
|
||||
|
||||
This is the most important milestone.
|
||||
|
||||
### Phase 5: Background Scheduler Loop
|
||||
|
||||
Deliverable:
|
||||
|
||||
- Enabled schedules launch automatically when due.
|
||||
- Run history is recorded.
|
||||
- `last_run_at` and `next_run_at` update.
|
||||
- Duplicate launch guard exists.
|
||||
|
||||
### Phase 6: UI Polish Only After Functionality
|
||||
|
||||
Deliverable:
|
||||
|
||||
- Scheduled jobs list is readable.
|
||||
- Create/edit form is usable.
|
||||
- Status labels are clear.
|
||||
- Errors are visible.
|
||||
|
||||
Do not polish before Phase 4 works.
|
||||
|
||||
---
|
||||
|
||||
## 14. Acceptance Criteria
|
||||
|
||||
The build is acceptable when all these pass.
|
||||
|
||||
### Manual Run
|
||||
|
||||
1. Create a schedule/job with inline prompt.
|
||||
2. Click Run Now.
|
||||
3. A new Codeman/tmux session starts.
|
||||
4. Prompt is sent into that session.
|
||||
5. The created session is visible in Codeman's normal session UI.
|
||||
6. Run history shows success or failure.
|
||||
|
||||
### One-Time Schedule
|
||||
|
||||
1. Create a one-time schedule 2 minutes in the future.
|
||||
2. Wait for it to become due.
|
||||
3. Scheduler launches a session.
|
||||
4. Prompt is sent.
|
||||
5. Schedule does not repeatedly launch forever.
|
||||
|
||||
### Interval Schedule
|
||||
|
||||
1. Create interval schedule every 2 minutes.
|
||||
2. It launches once when due.
|
||||
3. It computes the next due time.
|
||||
4. It does not launch duplicates for the same due time.
|
||||
|
||||
### Daily Schedule
|
||||
|
||||
1. Create daily schedule at a time a few minutes ahead.
|
||||
2. It launches when due.
|
||||
3. Next run becomes tomorrow at the same time.
|
||||
|
||||
### Disable Schedule
|
||||
|
||||
1. Disable a schedule.
|
||||
2. It does not launch even when due.
|
||||
|
||||
### Error Handling
|
||||
|
||||
1. Invalid working directory produces visible error.
|
||||
2. Invalid prompt file produces visible error.
|
||||
3. Failed session launch creates failed run-history entry.
|
||||
|
||||
---
|
||||
|
||||
## 15. Explicitly Out of Scope for v0.1
|
||||
|
||||
Do not implement these unless all required scope is already working:
|
||||
|
||||
- Full quota engine.
|
||||
- Advanced lock manager.
|
||||
- Post-run git inspection reports.
|
||||
- Complex recurring calendar UI.
|
||||
- User accounts / RBAC.
|
||||
- External distributed workers.
|
||||
- Redis.
|
||||
- Postgres.
|
||||
- Celery.
|
||||
- Kubernetes.
|
||||
- A separate Python service.
|
||||
- Full visual cron editor.
|
||||
- AI-generated follow-up prompts.
|
||||
- Automatic continuation after idle.
|
||||
- Any attempt to bypass agent quotas or platform limits.
|
||||
|
||||
---
|
||||
|
||||
## 16. Quality Rules
|
||||
|
||||
Follow these rules while coding:
|
||||
|
||||
1. Reuse existing Codeman services and conventions.
|
||||
2. Keep scheduler code isolated.
|
||||
3. Prefer boring, readable code over clever abstractions.
|
||||
4. Add error messages that a human can understand.
|
||||
5. Do not break existing Codeman sessions.
|
||||
6. Do not rename existing core concepts unnecessarily.
|
||||
7. Do not introduce large dependencies without strong reason.
|
||||
8. Keep v0.1 local-first and single-instance.
|
||||
9. Commit in logical chunks if git is available.
|
||||
10. After coding, provide a final implementation summary.
|
||||
|
||||
---
|
||||
|
||||
## 17. Final Response Required from Claude Code
|
||||
|
||||
At the end, report:
|
||||
|
||||
1. Files changed.
|
||||
2. New routes/pages added.
|
||||
3. New data structures added.
|
||||
4. How the scheduler loop works.
|
||||
5. How to run the app.
|
||||
6. How to test manual Run Now.
|
||||
7. How to test scheduled execution.
|
||||
8. Known limitations.
|
||||
9. Suggested v0.2 improvements.
|
||||
|
||||
---
|
||||
|
||||
## 18. v0.2 Ideas, Not for Current Build
|
||||
|
||||
Keep these in mind but do not build unless v0.1 is complete:
|
||||
|
||||
- Quota-aware scheduling.
|
||||
- Manual takeover locks.
|
||||
- Post-idle inspection.
|
||||
- Git diff reports.
|
||||
- Schedule groups.
|
||||
- Prompt templates.
|
||||
- Agent-specific concurrency rules.
|
||||
- Better timezone support.
|
||||
- Audit events.
|
||||
- More advanced cron expressions.
|
||||
|
||||
---
|
||||
|
||||
## 19. Final Reminder
|
||||
|
||||
The goal is to add **scheduling** to Codeman quickly and cleanly.
|
||||
|
||||
Do not drift into building a new platform.
|
||||
|
||||
The highest-priority path is:
|
||||
|
||||
1. Discover existing Codeman integration points.
|
||||
2. Add scheduled job persistence.
|
||||
3. Add Run Now.
|
||||
4. Add background due-job loop.
|
||||
5. Add minimal UI.
|
||||
6. Verify that scheduled jobs create real Codeman/tmux sessions and send prompts.
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# CRON_DISCOVERY.md
|
||||
|
||||
Phase 1 deliverable for the "Add Scheduling to Codeman" build brief.
|
||||
This documents the existing Codeman architecture and the smallest integration
|
||||
points for a cron. **No session/tmux logic will be rebuilt** —
|
||||
the new code is purely a trigger + persistence + history layer on top of the
|
||||
existing primitives.
|
||||
|
||||
Stack: `aicodeman` v1.2.1 — Fastify 5 backend, `node-pty` + tmux sessions,
|
||||
vanilla-JS SPA frontend served as static assets, JSON file state store, zod
|
||||
validation, ports-based dependency injection.
|
||||
|
||||
---
|
||||
|
||||
## 0. Critical finding: an existing `ScheduledRun` is NOT a cron
|
||||
|
||||
Codeman already has a `ScheduledRun` concept (`/api/scheduled`,
|
||||
`src/web/ports/infra-port.ts:14-26`, `src/web/server.ts:1480-1605`). It is a
|
||||
**run-now, duration-bounded autonomous loop**: given `{prompt, workingDir,
|
||||
durationMinutes}` it immediately spawns/kills throwaway sessions in a loop until
|
||||
the duration elapses. It has **no** time-based triggering, recurrence
|
||||
(once/interval/daily/weekly), enable/disable, next-run calculation, run history,
|
||||
or persistence across restarts.
|
||||
|
||||
Therefore the brief's core (the calendar/cron trigger layer) does **not** exist
|
||||
and must be built. The execution primitives it sits on top of **do** exist and
|
||||
will be reused. To honor brief §16 ("do not rename existing core concepts"), the
|
||||
new feature is named **`CronJob`** (with **`CronJobRun`** history
|
||||
records), kept distinct from the existing `ScheduledRun`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Where session creation happens
|
||||
|
||||
- Canonical create flow: `POST /api/sessions`,
|
||||
`src/web/routes/session-routes.ts:262-438`.
|
||||
- `new Session({ workingDir, mode, ... })` (`src/session.ts:421-570`)
|
||||
- `ctx.addSession(session)` → `ctx.setupSessionListeners(session)` →
|
||||
`ctx.persistSessionState(session)` (all via `SessionPort`).
|
||||
- `SessionPort` interface: `src/web/ports/session-port.ts:8-16`.
|
||||
- **Integration point:** the cron service will mirror this exact sequence
|
||||
(create → addSession → setupSessionListeners → start) via `SessionPort`,
|
||||
not reimplement it.
|
||||
|
||||
## 2. Where agent/session types are defined
|
||||
|
||||
- `type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini'`
|
||||
(`src/types/session.ts:43-44`). `shell` covers the brief's "Terminal/custom".
|
||||
- CLI availability resolvers in `src/utils/{claude,codex,gemini,opencode}-cli-resolver.ts`.
|
||||
- **Integration point:** the job's `agentType` reuses `SessionMode` verbatim.
|
||||
|
||||
## 3. Where input is sent into a session
|
||||
|
||||
- Raw / paste: `session.write(data)` (`src/session.ts:2243-2247`) — direct PTY write.
|
||||
- Typed (recommended): `session.writeViaMux(data)` (`src/session.ts:2301-2311`)
|
||||
— tmux `send-keys`, falls back to PTY. Submit requires trailing `\r`.
|
||||
- **Integration point:** prompt delivery uses `writeViaMux` (typed) by default,
|
||||
`write` (paste) as the alternate `input_mode`.
|
||||
|
||||
## 4. Where active sessions are listed
|
||||
|
||||
- `ctx.sessions: ReadonlyMap<string, Session>` (`SessionPort`).
|
||||
- Filters: `Array.from(ctx.sessions.values()).filter(s => s.mode === X)` and
|
||||
`.isBusy()` / `.isIdle()` (`src/session-manager.ts:220-247`).
|
||||
- **Integration point:** the §8 multi-session warning queries this map.
|
||||
|
||||
## 5. Where session kill/delete is handled
|
||||
|
||||
- `ctx.cleanupSession(sessionId, killMux?, reason?)`
|
||||
(`SessionPort`; impl `src/web/server.ts:997-1152`). Underlying
|
||||
`session.stop(killMux)` at `src/session.ts:2498-2585`.
|
||||
- The cron does **not** kill sessions it launches (the brief wants them
|
||||
visible in the normal session UI); cleanup stays user-driven.
|
||||
_Superseded post-review:_ recurring jobs now default to
|
||||
`autoClosePreviousSession: true` — the previous run's still-open session is
|
||||
closed via `cleanupSession` when the next run fires (see
|
||||
`docs/cron-guide.md` §8); opt out per job for fully user-driven cleanup.
|
||||
|
||||
## 6. How session state is stored / 7. Existing persistence
|
||||
|
||||
- JSON file store: `~/.codeman/state.json` (+ `state-inner.json` for Ralph).
|
||||
`StateStore` class `src/state-store.ts:71`; `AppState` interface
|
||||
`src/types/app-state.ts:99-114`.
|
||||
- Pattern: declare a field on `AppState`, add typed get/set methods on
|
||||
`StateStore` that mutate in-memory state and call the debounced `save()`
|
||||
(500ms debounce, atomic temp-file+rename, `.bak` backup, circuit breaker).
|
||||
- **Integration point:** add `cronJobs?: Record<string, CronJob>` and
|
||||
`cronJobRuns?: Record<string, CronJobRun>` to `AppState`, with
|
||||
matching `StateStore` accessors. No new DB (brief §6 forbids Postgres/Redis).
|
||||
|
||||
## 8. Where backend routes live
|
||||
|
||||
- Route modules: `src/web/routes/*.ts`; barrel `src/web/routes/index.ts`;
|
||||
registered in `WebServer.setupRoutes()` `src/web/server.ts:858-876` with a
|
||||
single `ctx` object from `createRouteContext()` (`src/web/server.ts:553-613`)
|
||||
that satisfies all port interfaces.
|
||||
- Validation: zod schemas in `src/web/schemas.ts`, applied via
|
||||
`parseBody(Schema, req.body)` (`src/web/route-helpers.ts:101-111`).
|
||||
- Errors: `createErrorResponse(ApiErrorCode.X, msg)` / `ApiResponse`
|
||||
(`src/types/api.ts`), auto-mapped to HTTP status by a `preSerialization` hook
|
||||
(`src/web/server.ts:644-659`).
|
||||
- SSE: `ctx.broadcast(SseEvent.X, data)` (`EventPort`,
|
||||
`src/web/sse-events.ts`); frontend mirror in `src/web/public/constants.js`.
|
||||
- **Integration point:** new `cron-routes.ts` registered alongside the
|
||||
others; new zod schema; new `SseEvent` constants for job list/run changes.
|
||||
|
||||
## 9. Where frontend pages/components live
|
||||
|
||||
- Vanilla-JS SPA: single `src/web/public/index.html` + feature mixin files
|
||||
(`Object.assign(CodemanApp.prototype, {...})`). API via `api-client.js`
|
||||
(`_apiJson/_apiPost/_apiDelete`). Build = esbuild minify + content-hash, no
|
||||
bundler (`scripts/build.mjs`).
|
||||
- UI is panels/modals toggled by JS classes; forms use `.form-row` / `.modal`
|
||||
conventions (`styles.css`). SSE handler map in `app.js`.
|
||||
- **Integration point:** add a new `cron-ui.js` mixin + a panel/modal in
|
||||
`index.html` + nav entry, following the orchestrator/respawn panel pattern.
|
||||
|
||||
## 10. Background-loop pattern (for the due-checker)
|
||||
|
||||
- Established pattern: `this.cleanup.setInterval(fn, intervalMs, {description})`
|
||||
in `WebServer.start()` (`src/web/server.ts:~1942-1966`), auto-disposed in
|
||||
`WebServer.stop()` via `this.cleanup.dispose()` (`src/web/server.ts:2336`).
|
||||
RalphLoop (`src/ralph-loop.ts:268-286`) shows the self-rescheduling guard idiom.
|
||||
- **Integration point:** register a 30s cron tick via `cleanup.setInterval`;
|
||||
no manual shutdown wiring needed.
|
||||
|
||||
---
|
||||
|
||||
## Smallest integration points (summary)
|
||||
|
||||
| New piece | Reuses | Location |
|
||||
| --- | --- | --- |
|
||||
| `CronJob` / `CronJobRun` types | — (new) | `src/types/cron.ts` |
|
||||
| Persistence | `StateStore` / `AppState` | `src/types/app-state.ts`, `src/state-store.ts` |
|
||||
| Next-run time math | — (new, pure, unit-tested) | `src/cron/cron-time.ts` |
|
||||
| Launch + send prompt | `SessionPort` (`addSession`/listeners/`writeViaMux`) | `src/cron/cron-service.ts` |
|
||||
| Background due loop | `cleanup.setInterval` pattern | `src/cron/cron-loop.ts` |
|
||||
| Routes + schema | route/ports/zod/SSE patterns | `src/web/routes/cron-routes.ts`, `src/web/schemas.ts`, `src/web/sse-events.ts` |
|
||||
| UI | panel/modal/mixin conventions | `src/web/public/cron-ui.js`, `index.html` |
|
||||
|
||||
Nothing in the session, tmux, persistence, routing, or SSE subsystems is
|
||||
rewritten — the cron is additive and calls existing services.
|
||||
@@ -0,0 +1,426 @@
|
||||
# Cron Jobs — User & Operator Guide
|
||||
|
||||
Codeman's **Cron** feature lets you save named, recurring jobs that automatically
|
||||
spin up a Claude (or shell / OpenCode / Codex / Gemini) session on a schedule and
|
||||
feed it a prompt. Think "cron for agent sessions": _"every weekday at 3am, open a
|
||||
Claude session in `~/proj` and tell it to update dependencies and open a PR."_
|
||||
|
||||
- **UI**: the **⏰ Cron** button in the header → the Cron Jobs modal (`#cronModal`).
|
||||
- **API**: `/api/cron/jobs*` and `/api/cron/runs`.
|
||||
- **Code**: `src/cron/cron-service.ts`, `src/cron/cron-time.ts`, `src/cron/cron-input.ts`,
|
||||
types in `src/types/cron.ts`, routes in `src/web/routes/cron-routes.ts`,
|
||||
frontend in `src/web/public/cron-ui.js`.
|
||||
|
||||
> **Not to be confused with `ScheduledRun` (`/api/scheduled`).** That older,
|
||||
> deliberately-separate concept is a _run-now, duration-bounded autonomous loop_
|
||||
> (`{prompt, workingDir, durationMinutes}` → spawn/kill throwaway sessions until
|
||||
> the duration elapses). It has no recurrence, no saved jobs, and no next-run
|
||||
> calculation. The two systems never interact. This guide is only about **Cron
|
||||
> jobs** (`Cron*`). See `docs/cron-discovery.md` §0.
|
||||
|
||||
---
|
||||
|
||||
## 1. Quick start
|
||||
|
||||
### In the browser
|
||||
|
||||
1. Click **⏰ Cron** in the header.
|
||||
2. Click **+ New Job**.
|
||||
3. Fill in a **name**, pick an **agent type** and **working directory**, choose a
|
||||
**prompt** (inline text or a file path), pick a **schedule**, and leave
|
||||
**Enabled** on.
|
||||
4. **Save**. The job appears in the list with its computed **next run**.
|
||||
5. Use **Run Now** to fire it immediately without waiting for the schedule.
|
||||
|
||||
### With curl
|
||||
|
||||
```bash
|
||||
API=http://localhost:3000
|
||||
|
||||
# Create a daily job (03:00 server-local time)
|
||||
curl -s -X POST "$API/api/cron/jobs" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "nightly-deps",
|
||||
"agentType": "claude",
|
||||
"workingDir": "/home/me/proj",
|
||||
"promptMode": "inline_text",
|
||||
"promptText": "Update dependencies and open a PR",
|
||||
"inputMode": "typed",
|
||||
"scheduleType": "daily",
|
||||
"dailyTime": "03:00",
|
||||
"enabled": true,
|
||||
"concurrencyPolicy": "warn_only"
|
||||
}' | jq
|
||||
|
||||
# List jobs
|
||||
curl -s "$API/api/cron/jobs" | jq
|
||||
|
||||
# Run one immediately
|
||||
curl -s -X POST "$API/api/cron/jobs/<jobId>/run" | jq
|
||||
|
||||
# See a job's run history
|
||||
curl -s "$API/api/cron/jobs/<jobId>/runs" | jq
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Concepts
|
||||
|
||||
| Term | Meaning |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| **Cron job** (`CronJob`) | A saved, named definition: what agent to launch, where, with what prompt, on what schedule. |
|
||||
| **Run** (`CronJobRun`) | One execution of a job — a history record with a status and a link to the session it created. |
|
||||
| **Schedule type** | How fire times are computed: `once`, `interval`, `daily`, or `weekly`. |
|
||||
| **Next run** (`nextRunAt`) | Server-computed epoch-ms of the next fire. `null` when the job is disabled or has no future run. |
|
||||
| **Due tick** | A background loop (every 30s) that launches any enabled job whose `nextRunAt` has passed. |
|
||||
|
||||
A job is essentially a **trigger + persistence + history layer on top of the
|
||||
existing session primitives**. When a job fires, the cron service does exactly
|
||||
what the "quick start" route does — `new Session(...)` → `addSession` →
|
||||
`setupSessionListeners` → `startInteractive()`/`startShell()` → deliver the
|
||||
prompt. It does **not** reimplement any tmux/PTY logic.
|
||||
|
||||
---
|
||||
|
||||
## 3. The job form — every field
|
||||
|
||||
These map 1:1 to `CronJobSchema` (`src/web/schemas.ts`) and the `CronJob` type
|
||||
(`src/types/cron.ts`).
|
||||
|
||||
| Field | Required | Values / limits | Notes |
|
||||
| -------------------------- | ----------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `name` | ✅ | 1–200 chars | Display name; also used as the created session's name. |
|
||||
| `agentType` | ✅ | `claude` \| `shell` \| `opencode` \| `codex` \| `gemini` | Reuses Codeman's `SessionMode`. `shell` = a plain terminal. |
|
||||
| `workingDir` | ✅ | valid path (allowlist-validated) | Validated at **create/update** (must exist, be a directory, and not resolve into a blocked tree — `/etc`, `/root`, `/proc`, `/sys`, `/dev`, or `/` itself) and again **at fire time**. |
|
||||
| `launchCommand` | — | ≤ 2000 chars, single line | `shell` mode only: sent as the **first input line** once the shell is up, before the prompt. Ignored for other agent types. |
|
||||
| `promptMode` | ✅ | `inline_text` \| `prompt_file_path` | See §5. |
|
||||
| `promptText` | conditional | ≤ 100000 chars, **single line** | Required when `promptMode = inline_text`. Newlines are rejected (see §6). |
|
||||
| `promptFilePath` | conditional | valid path | Required when `promptMode = prompt_file_path`. Confined to `workingDir` (see §5). |
|
||||
| `inputMode` | ✅ | `paste` \| `typed` | How the prompt is delivered. See §6. |
|
||||
| `scheduleType` | ✅ | `once` \| `interval` \| `daily` \| `weekly` | See §4. |
|
||||
| `runAt` | conditional | epoch-ms (positive int) | Required for `once`. |
|
||||
| `intervalMinutes` | conditional | 1–525600 (≤ 1 year) | Required for `interval`. |
|
||||
| `dailyTime` | conditional | `HH:MM` (24h) | Required for `daily`. Server-local time. |
|
||||
| `weeklyDays` | conditional | array of 1–7 ints, each 0–6 (0 = Sunday) | Required for `weekly`. |
|
||||
| `weeklyTime` | conditional | `HH:MM` (24h) | Required for `weekly`. Server-local time. |
|
||||
| `enabled` | ✅ | boolean | Disabled jobs never auto-fire (but **Run Now** still works). |
|
||||
| `notes` | — | ≤ 2000 chars | Free-form. |
|
||||
| `concurrencyPolicy` | ✅ | `warn_only` \| `skip_if_same_agent_running` | Applies to **automatic** runs only. See §7. |
|
||||
| `autoClosePreviousSession` | — | boolean (default **true**) | Recurring schedules only (ignored for `once`): when the next run fires, the still-open session created by this job's **previous** run is closed first via the normal cleanup path. See §8. |
|
||||
|
||||
**Cross-field validation** (`refineCronJob` in `schemas.ts`): the conditional
|
||||
fields above are enforced by a Zod `superRefine` on create. A missing dependent
|
||||
field (e.g. `scheduleType: "once"` with no `runAt`) is rejected with
|
||||
`INVALID_INPUT` and a field-specific message.
|
||||
|
||||
> ⚠️ **Update caveat.** `PUT /api/cron/jobs/:id` uses a `.partial()` schema that
|
||||
> does **not** re-run the cross-field `superRefine`. To keep partial edits safe,
|
||||
> `updateJob()` re-validates the **merged** job against the full `CronJobSchema`
|
||||
> and throws `400` if the result is inconsistent (e.g. switching to `once`
|
||||
> without a `runAt`). So the store is never left with a half-valid job.
|
||||
|
||||
---
|
||||
|
||||
## 4. Schedule types
|
||||
|
||||
Next-run math lives in `src/cron/cron-time.ts` (pure, unit-tested in
|
||||
`test/cron-time.test.ts`). **All wall-clock times use the server's local
|
||||
timezone** (v0.1 decision).
|
||||
|
||||
### `once`
|
||||
|
||||
- Fires a single time at the absolute `runAt` epoch-ms.
|
||||
- A **missed** one-time job (server was down at `runAt`) **still fires once** on
|
||||
the next tick — `computeNextRunAt` returns `runAt` even if it's in the past,
|
||||
until the job has fired.
|
||||
- After firing, the job **self-disables**: `completedOnce = true`, `enabled =
|
||||
false`, `nextRunAt = null`.
|
||||
|
||||
### `interval`
|
||||
|
||||
- Fires every `intervalMinutes`, computed as `fireTime + intervalMinutes`.
|
||||
- ⚠️ **Drift**: the next run re-anchors to the actual fire time, not to an ideal
|
||||
cadence — a slow tick or restart shifts subsequent runs slightly later. This is
|
||||
an accepted limitation.
|
||||
|
||||
### `daily`
|
||||
|
||||
- Fires at `dailyTime` (`HH:MM`) every day, server-local.
|
||||
- If today's time has already passed, the next run is tomorrow at that time.
|
||||
|
||||
### `weekly`
|
||||
|
||||
- Fires at `weeklyTime` on each weekday in `weeklyDays` (0 = Sunday … 6 =
|
||||
Saturday), server-local.
|
||||
- The next run is the soonest upcoming matching weekday/time within the next 7
|
||||
days.
|
||||
|
||||
---
|
||||
|
||||
## 5. Prompt source (`promptMode`)
|
||||
|
||||
### `inline_text`
|
||||
|
||||
The prompt is the literal `promptText`. Simplest option.
|
||||
|
||||
### `prompt_file_path`
|
||||
|
||||
The prompt is read from a file at fire time. **This path is security-hardened**
|
||||
because a job config is attacker-controllable and the file's contents are
|
||||
injected into an agent session (an exfiltration sink over SSE/terminal).
|
||||
`resolveSafePromptPath()` enforces, in order:
|
||||
|
||||
1. **`realpath` resolution** — symlinks are resolved to their true target, for
|
||||
the prompt file **and for `workingDir` itself**.
|
||||
2. **`workingDir` is not a trust boundary** — because it is user-supplied, the
|
||||
resolved `workingDir` is itself rejected if it is `/` or resolves into a
|
||||
blocked tree (`/etc`, `/root`, operator extras) or a pseudo-filesystem
|
||||
(`/proc`, `/sys`, `/dev`). This closes the `workingDir: '/proc'` +
|
||||
`promptFilePath: '/proc/self/environ'` env-exfil trick. The same rule is
|
||||
enforced earlier, at job create/update.
|
||||
3. **Blocklist** (defense-in-depth) — sensitive trees (`/etc`, `/root`,
|
||||
`/proc`, `/sys`, `/dev`, known secret locations) are rejected for the
|
||||
resolved prompt file.
|
||||
4. **Allowlist (primary gate)** — the resolved path **must live inside the job's
|
||||
(resolved) `workingDir`** (`validateSessionFilePath`). A symlink escaping the
|
||||
workspace fails here.
|
||||
5. **Regular-file check** — directories, FIFOs, and `/dev/*` character devices
|
||||
are rejected (they would hang or OOM an unbounded read).
|
||||
6. **Size cap** — files larger than **1 MiB** (`MAX_PROMPT_FILE_BYTES`) are
|
||||
rejected.
|
||||
7. **Single-line check** — after trailing newlines are stripped, the file
|
||||
content must be a single line (see §6).
|
||||
|
||||
If any check fails, the run is recorded as **`failed`** with the reason; no
|
||||
session is created.
|
||||
|
||||
---
|
||||
|
||||
## 6. Prompt delivery (`inputMode`)
|
||||
|
||||
Once the CLI is ready (see §8), the prompt is written to the session with a
|
||||
trailing carriage return:
|
||||
|
||||
| Mode | Mechanism | Use when |
|
||||
| ------- | --------------------------------------------------------------- | ------------------------------------------------ |
|
||||
| `typed` | `session.writeViaMux()` — tmux `send-keys -l` (literal) + Enter | Default; behaves like a human typing the prompt. |
|
||||
| `paste` | `session.write()` — writes directly to the PTY/mux | Bulk paste-style delivery. |
|
||||
|
||||
> ⚠️ **Single-line only — enforced.** Like all programmatic input in Codeman,
|
||||
> multi-line delivery would be silently corrupted (Ink-based TUIs treat a
|
||||
> newline as submit; typed mode fuses lines). So newlines are **rejected**: the
|
||||
> schema and the form refuse a multi-line `promptText`, and at fire time a
|
||||
> prompt file whose content is multi-line (after stripping trailing newlines)
|
||||
> fails the run with a clear `errorMessage`. Put multi-line instructions in a
|
||||
> file the agent is told to read itself (e.g. "read TASKS.md and do it").
|
||||
|
||||
---
|
||||
|
||||
## 7. Concurrency policy (automatic runs)
|
||||
|
||||
`concurrencyPolicy` governs what happens when a **scheduled** run is due and
|
||||
sessions of the same `agentType` already exist:
|
||||
|
||||
| Policy | Behavior |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `warn_only` | Always launch. (The count is surfaced but not blocking.) |
|
||||
| `skip_if_same_agent_running` | If ≥ 1 **other, live** session of that mode is active, **skip** this fire — record a `skipped` run and (for recurring schedules) advance the schedule without launching. |
|
||||
|
||||
Notes on `skip_if_same_agent_running`:
|
||||
|
||||
- Only **live** sessions block: a tab whose CLI already exited (status
|
||||
`stopped`/`error`) does not count.
|
||||
- Sessions created by **this job's own previous runs never block it** —
|
||||
otherwise a recurring job would deadlock on the session it created last time
|
||||
and fire exactly once.
|
||||
- A skipped **`once`** job is **not consumed**: it stays armed and retries on
|
||||
the next tick until the blocking session goes away, then fires its single run.
|
||||
- A skip is **not** a run: it sets `lastStatus = 'skipped'` but does **not**
|
||||
advance `lastRunAt`.
|
||||
- Consecutive skips are **coalesced** — a perpetually-skipped interval job writes
|
||||
**one** skip record per streak, not one every tick, so it can't bloat
|
||||
`state.json`.
|
||||
|
||||
**Run Now ignores this policy on the server.** The browser shows a `confirm()`
|
||||
warning if same-type sessions are active, but if you proceed (or call the API
|
||||
directly), the job launches unconditionally.
|
||||
|
||||
---
|
||||
|
||||
## 8. What happens when a job fires
|
||||
|
||||
Sequence in `CronService.launch()`:
|
||||
|
||||
1. A `CronJobRun` is created with status **`created`** and broadcast
|
||||
(`cron:runCreated`).
|
||||
2. The prompt is resolved (inline or file, single-line enforced). Failure →
|
||||
**`failed`**.
|
||||
3. `workingDir` is checked (`statSync().isDirectory()`). Missing/not-a-dir →
|
||||
**`failed`**.
|
||||
4. **Auto-close previous session** (recurring schedules, unless
|
||||
`autoClosePreviousSession: false`): any still-open session created by this
|
||||
job's previous runs is closed via the normal session-cleanup path.
|
||||
5. The global session cap is checked (`MAX_CONCURRENT_SESSIONS = 50`). At cap →
|
||||
**`failed`**.
|
||||
6. A `Session` is created **with `useMux: true`** (so it runs inside tmux),
|
||||
registered, listeners attached, and started via `startInteractive()`
|
||||
(`startShell()` for `shell` mode). Model/claudeMode come from global config.
|
||||
Run status → **`session_started`**.
|
||||
7. **Readiness wait** (async, non-blocking): for non-shell agents the service
|
||||
polls the terminal buffer up to **60 × 500ms** for a `❯` prompt or the string
|
||||
`tokens`, then settles **2000ms** (`CRON_READY_SETTLE_MS`). Shell mode waits
|
||||
1000ms, then sends the optional `launchCommand` as the first input line
|
||||
(+1000ms settle).
|
||||
8. The prompt is delivered (`typed`/`paste`, trailing `\r`). Run status →
|
||||
**`prompt_sent`**; `finishedAt` stamped. Delivery failure (e.g. the mux
|
||||
session is gone) → **`failed`**.
|
||||
|
||||
The created session is a **normal, persistent interactive session** — it appears
|
||||
as its own tab and keeps running after the prompt is sent. The run's
|
||||
`createdSessionUrl` is a deep link (`/?session=<id>`); the UI focuses it
|
||||
automatically after **Run Now**.
|
||||
|
||||
> ⚠️ **Session-cap math if you disable auto-close.** With
|
||||
> `autoClosePreviousSession: false`, nothing ever closes the sessions a
|
||||
> recurring job creates — an interval job every 30 min creates 48 tabs/day and
|
||||
> hits the global 50-session cap in ~25 hours (sooner with existing tabs), after
|
||||
> which **every** fire of **every** job fails with "Maximum concurrent sessions
|
||||
> reached" until you delete tabs by hand. Leave auto-close on for unattended
|
||||
> recurring jobs, or clean up sessions yourself.
|
||||
|
||||
### The background tick
|
||||
|
||||
`tickDueJobs()` runs every **30s** (`CRON_TICK_INTERVAL`, registered in
|
||||
`server.ts`). For each enabled job whose `nextRunAt ≤ now`:
|
||||
|
||||
- **Duplicate-launch guard**: `lastDueKey = jobId:fireTime`. If this due time was
|
||||
already consumed (overlap/restart), the job is just advanced, not relaunched.
|
||||
- The schedule is **advanced _before_ launching** so a slow launch can't be
|
||||
re-triggered by the next tick.
|
||||
- On boot, `init()` recomputes `nextRunAt` for loaded jobs (dead `once` jobs stay
|
||||
dead).
|
||||
|
||||
---
|
||||
|
||||
## 9. Run history & statuses
|
||||
|
||||
Each job keeps a history of `CronJobRun` records. Statuses (`CronJobRunStatus`):
|
||||
|
||||
| Status | Meaning |
|
||||
| ----------------- | ------------------------------------------------------------- |
|
||||
| `created` | Run record created; prompt/session not yet started. |
|
||||
| `session_started` | Session launched successfully. |
|
||||
| `prompt_sent` | Prompt delivered — the happy-path terminal state. |
|
||||
| `failed` | Something went wrong (see `errorMessage`). |
|
||||
| `skipped` | A scheduled fire was skipped by `skip_if_same_agent_running`. |
|
||||
|
||||
Each run also records `triggerType` (`scheduled` or `manual_run_now`),
|
||||
`sessionId`/`sessionName`, timestamps, and `createdSessionUrl`.
|
||||
|
||||
**History is capped globally** at **500 records** (`MAX_CRON_RUN_HISTORY`); the
|
||||
oldest are pruned first. Deleting a job also deletes its run records.
|
||||
|
||||
---
|
||||
|
||||
## 10. API reference
|
||||
|
||||
All responses use the standard `ApiResponse<T>` envelope (`{success, data}` /
|
||||
`{success, error, errorCode}`). `/api/v1/*` is a stable alias.
|
||||
|
||||
| Method | Endpoint | Body | Returns |
|
||||
| -------- | ---------------------------- | ---------------------- | --------------------------------- |
|
||||
| `GET` | `/api/cron/jobs` | — | `CronJob[]` |
|
||||
| `POST` | `/api/cron/jobs` | `CronJobSchema` | `{ job }` |
|
||||
| `GET` | `/api/cron/jobs/:id` | — | `CronJob` (404 if missing) |
|
||||
| `PUT` | `/api/cron/jobs/:id` | partial `CronJob` | `{ job }` (400 if merge invalid) |
|
||||
| `DELETE` | `/api/cron/jobs/:id` | — | `{}` |
|
||||
| `PUT` | `/api/cron/jobs/:id/enabled` | `{ enabled: boolean }` | `{ job }` |
|
||||
| `POST` | `/api/cron/jobs/:id/run` | — | `{ run, activeAgents }` |
|
||||
| `GET` | `/api/cron/jobs/:id/runs` | — | `CronJobRun[]` (newest first) |
|
||||
| `GET` | `/api/cron/runs` | — | all `CronJobRun[]` (newest first) |
|
||||
|
||||
---
|
||||
|
||||
## 11. SSE events
|
||||
|
||||
Emitted on `/api/events`, mirrored in `SSE_EVENTS` (`constants.js`):
|
||||
|
||||
| Event | Payload | When |
|
||||
| ------------------ | ------------ | -------------------------------------------------------------------- |
|
||||
| `cron:jobsChanged` | `{ jobs }` | Any job created / updated / enabled / status change. |
|
||||
| `cron:jobDeleted` | `{ id }` | A job was deleted. |
|
||||
| `cron:runCreated` | `CronJobRun` | A run (incl. skips) started. |
|
||||
| `cron:runUpdated` | `CronJobRun` | A run advanced state (`session_started` / `prompt_sent` / `failed`). |
|
||||
|
||||
---
|
||||
|
||||
## 12. State & persistence
|
||||
|
||||
Persisted in `~/.codeman/state.json` via `StateStore`:
|
||||
|
||||
- `AppState.cronJobs` — map of `id → CronJob`.
|
||||
- `AppState.cronJobRuns` — map of `id → CronJobRun`.
|
||||
|
||||
Jobs and their schedules survive restarts; `init()` recomputes `nextRunAt` on
|
||||
boot. Sessions the jobs create persist through the normal session-recovery path.
|
||||
|
||||
---
|
||||
|
||||
## 13. Limits & constants
|
||||
|
||||
| Constant | Value | Source |
|
||||
| ------------------------ | --------------------- | ------------------------------------------------ |
|
||||
| Due-tick interval | 30s | `CRON_TICK_INTERVAL` (`config/server-timing.ts`) |
|
||||
| Readiness poll | 60 × 500ms | `CRON_READY_MAX_ATTEMPTS` |
|
||||
| Readiness settle | 2000ms | `CRON_READY_SETTLE_MS` |
|
||||
| Run-history cap (global) | 500 | `MAX_CRON_RUN_HISTORY` (`config/map-limits.ts`) |
|
||||
| Saved-jobs cap | 100 | `MAX_CRON_JOBS` (`config/map-limits.ts`) |
|
||||
| Concurrent-session cap | 50 | `MAX_CONCURRENT_SESSIONS` |
|
||||
| Prompt-file size cap | 1 MiB | `MAX_PROMPT_FILE_BYTES` (`cron-service.ts`) |
|
||||
| `name` length | 1–200 | `CronJobSchema` |
|
||||
| `promptText` length | ≤ 100000 | `CronJobSchema` |
|
||||
| `intervalMinutes` | 1–525600 | `CronJobSchema` |
|
||||
| `weeklyDays` | 1–7 entries, each 0–6 | `CronJobSchema` |
|
||||
|
||||
---
|
||||
|
||||
## 14. Known limitations
|
||||
|
||||
- **Server-local timezone only** — `daily`/`weekly` times are interpreted in the
|
||||
host's local time; there is no per-job timezone.
|
||||
- **Interval drift** — `interval` re-anchors to the actual fire time; long-running
|
||||
intervals slowly shift.
|
||||
- **Single-line prompts** — multi-line prompts are rejected (schema, form, and
|
||||
at fire time for prompt files); tell the agent to read a file itself for
|
||||
multi-line instructions.
|
||||
- **`runNow` / tick race** — a manual Run Now firing at the same instant as a
|
||||
scheduled tick is theoretically possible; benign (you may get two sessions).
|
||||
- **`{enabled:true}` on a dead `once` job** — re-enabling a fired one-time job
|
||||
without changing its schedule leaves it enabled-but-dead (won't fire); change
|
||||
the schedule to re-arm.
|
||||
|
||||
---
|
||||
|
||||
## 15. Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| Job never fires | Disabled, or `nextRunAt: null` | Check **Enabled**; verify the schedule fields are complete. |
|
||||
| Run shows `failed` immediately | Bad `workingDir`, prompt-file rejected, or session cap hit | Read `errorMessage` on the run; confirm the dir exists and the prompt file is inside it and < 1 MiB. |
|
||||
| Run shows `skipped` | `skip_if_same_agent_running` + another live same-type session (this job's own sessions and dead tabs don't count) | Switch to `warn_only`, or wait for the other session to end. |
|
||||
| Run fails with "single line" | Multi-line prompt text / prompt file | Keep the prompt to one line; point the agent at a file to read for long instructions. |
|
||||
| Sessions pile up between runs | `autoClosePreviousSession: false` | Re-enable auto-close, or delete old tabs before the 50-session cap bites (see §8). |
|
||||
| Wrong fire time | Timezone assumption | Times are **server-local** — check the host clock/TZ. |
|
||||
| One-time job won't re-fire | `completedOnce` set | Edit the schedule (any real schedule change re-arms it). |
|
||||
|
||||
---
|
||||
|
||||
## 16. Related docs
|
||||
|
||||
- `docs/cron-discovery.md` — architecture / integration-point analysis (why the
|
||||
feature reuses the session layer and stays distinct from `ScheduledRun`).
|
||||
- `docs/cron-build-brief.md` — the original build brief / requirements.
|
||||
- `CLAUDE.md` → **Key Patterns → Cron** — the one-paragraph engineering summary.
|
||||
- Tests: `test/cron-time.test.ts` (schedule math), `test/cron-service.test.ts`
|
||||
(CRUD, tick, concurrency, security).
|
||||
@@ -0,0 +1,433 @@
|
||||
<!-- Design doc generated via ultracode multi-agent workflow (wf_e3a7498b-26f): 3 architecture proposals -> judge panel -> synthesis -> completeness critic. -->
|
||||
|
||||
# Docker Session Mode, Implementation Plan
|
||||
|
||||
## Decisions (locked 2026-07-19, by repo owner)
|
||||
|
||||
1. **Isolation posture**: CONVENIENT default (bind-mount host `~/.claude` etc. read-write so the existing login just works; network on; still hardened non-root + cap-drop + resource caps). SEALED profile (`mountCredentials:false` + `network:none`) is a per-case opt-in.
|
||||
2. **Export**: offer BOTH full-image (`commit`+`save`+workspace tar) AND workspace-only, side by side, no default (ask each time).
|
||||
3. **Base image**: BUILD LOCALLY on first use via `scripts/build-agent-image.mjs` from a repo `docker/agent.Dockerfile`. No registry required. (GHCR pull can be added later.)
|
||||
4. **Hooks**: WIRE HOOKS NOW. Codeman scaffolds `.claude/settings.local.json` + CLAUDE.md into the linked host workspace dir (same as local cases), enabling in-container permission prompts, hook-idle detection, and the Claude Model picker.
|
||||
|
||||
Adopted defaults for the remaining open items (Section 10): resume-on-restart ON; container is per-CASE and shared by multiple sessions (killing one session only kills its in-container tmux session, never `docker stop` while siblings remain; stop/remove only on explicit teardown or case-delete); rootless caps = ship-with-warning (`capsEnforced` surfaced); remote docker daemon = local-first; podman = docker-first best-effort.
|
||||
|
||||
## Implementation status (branch `feat/docker-session-mode`)
|
||||
|
||||
DONE and END-TO-END VERIFIED against a real docker daemon (create host, link case, quick-start shell in a real container, workspace bind-mount round-trip, hook scaffolding, session-delete keeps the shared container up, case-delete `docker rm`s it):
|
||||
|
||||
- Phase 0-1: types (`DockerHost`/`DockerCase`/`SessionDocker`), `src/docker-hosts.ts` (storage, pure `buildDockerBaseArgs`/`buildDockerCreateArgs`, `containerApiUrl`, `hostGatewayAlias`, config-hash, credential-mount resolution, daemon probes), `DockerHostSchema`/`DockerCaseLinkSchema`. 26 unit tests.
|
||||
- Phase 2: `tmux-manager` `buildDockerLaunchCommand` (image-check -> ensure -> start -> exec, resume-aware), `buildDockerKillCommand` (in-container tmux only, multi-session safe), stop/remove; wired into `createSession`/`respawnPane`/`killSession`. 14 unit tests.
|
||||
- Phase 3: `Session` threading (`_docker`, toState, option builders, in-container cliVersion probe, `resolveMuxAttachCwd`), `server.ts` recovery round-trip.
|
||||
- Phase 4: `case-routes` `/api/docker-hosts` CRUD + `/api/cases/docker-link` + listing + docker-unlink; `session-routes` `/api/quick-start` docker branch (rejects per-session config, probes availability + tmux, scaffolds hooks, seeds resume id).
|
||||
- Phase 5 (partial): `docker/agent.Dockerfile` + `scripts/build-agent-image.mjs` (built + verified: node 22, tmux, claude/codex/gemini/opencode, arbitrary-uid HOME). Host-guard allowlists `host.docker.internal`/`host.containers.internal` for in-container hooks.
|
||||
- Full CI green (3445 tests).
|
||||
|
||||
REMAINING:
|
||||
|
||||
- Phase 6: export / import (`docker commit` + `save | gzip` + workspace tar + manifest; `load` + quarantined re-tag), GC / boot reaper, disk-safety prechecks, drift-recreate route, SSE `docker:*` events. THE "move to a new machine" feature.
|
||||
- Phase 7: frontend Create Case "Docker" tab + `linkDockerCase` + run wiring + case-picker labels + export/import UI.
|
||||
- Phase 8: CLAUDE.md "Docker cases" Key Pattern + `docs/docker-cases.md` + COM.
|
||||
- Deferred refinements: in-container model-picker via `settings.local.json`; live mid-run resume-id capture into `DockerCase.lastClaudeSessionId`; rootless/Desktop uid probe (currently a platform heuristic).
|
||||
|
||||
## 1. Goal & user stories
|
||||
|
||||
Add "Docker cases" to Codeman: a case can point at a container instead of a local or remote-SSH path, and any of the five CLI backends (`claude` / `shell` / `opencode` / `codex` / `gemini`) runs inside that container. It is modeled as a LOCATION OVERLAY on cases, exactly like the remote-SSH feature (COD-94/#145), never as a sixth `SessionMode`.
|
||||
|
||||
User stories:
|
||||
|
||||
- As the repo owner, I link a case to a per-project container so an autonomous Claude/Ralph run executes in a hardened sandbox (cap-drop, non-root, resource caps) instead of directly on my host, while keeping my existing OAuth login and transcript history working with zero extra setup.
|
||||
- I set default, per-case-changeable container settings (image, network mode, memory/cpu/pids caps) at link time and edit them later, and edits actually take effect through a recreate-on-drift path (see Section 4).
|
||||
- I reconnect after a Codeman restart and land back in the SAME running agent with the conversation intact. When the CONTAINER itself was stopped/rebooted/OOM-killed (which destroys the in-container tmux), the next launch RESUMES the last conversation from the bind-mounted transcript rather than starting fresh (durability model in Section 2, Key decision 1).
|
||||
- I export a finished run's whole environment (toolchain plus workspace) to a portable, secret-free `.tar.gz`, move it to another machine, and import it back into a fresh case in one click.
|
||||
- The container never accumulates: killing the session stops it, deleting the case removes it, and an instance-scoped boot reaper reaps containers whose case is gone.
|
||||
|
||||
Non-goals for the MVP: multi-tenant untrusted-code isolation guarantees (Codeman is loopback-default and single-operator, and the agent already runs `--dangerously-skip-permissions` on the host today), Kubernetes/compose orchestration, and per-command ephemeral containers.
|
||||
|
||||
## 2. Chosen architecture and why
|
||||
|
||||
The design grafts the strongest idea from each of the three proposals:
|
||||
|
||||
- Overlay-not-a-mode + faithful remote-SSH mirror (from "Docker Cases as a Location Overlay"): lowest churn, rides the existing quick-start / mux-sessions / state / recovery plumbing.
|
||||
- Convenient-but-hardened default with an opt-in sealed profile, plus exec-time name-only secret env (from "Sealed Sandbox"): a strict security improvement over today's on-host execution without the UX tax of forcing an in-container re-login.
|
||||
- One-artifact export + in-app import route (from "Container-as-Cargo"): the genuinely new, high-value capability Codeman lacks.
|
||||
|
||||
### Key decision 1: persistent per-CASE container, durable in-container tmux, AND resume-on-restart (the two-layer durability model)
|
||||
|
||||
Exactly one long-lived container per Docker case, named as a pure slug function `codeman-case-<slug>` (Docker charset `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`; Codeman already slugs case names for tmux), so create-if-missing and boot recovery are idempotent. PID1 is `sleep infinity` under `--init` (tini reaps zombies and forwards `docker stop`'s SIGTERM); the CLI is NOT the container command. The CLI runs inside a DURABLE in-container tmux on a dedicated socket `-L codeman-docker`, session `codeman-dkr-<id8>`, the direct analog of remote's `-L codeman-remote` / `codeman-ssh-<id8>`.
|
||||
|
||||
Two DIFFERENT failure surfaces need two DIFFERENT recovery layers, and conflating them is the central flaw the critic caught:
|
||||
|
||||
1. Codeman-PROCESS restart while the container stays up: the in-container tmux is still alive, so `tmux new-session -A` (attach-or-create) reattaches the SAME live agent and the paneCommand is ignored. This is the remote-SSH durability idiom and it works unchanged.
|
||||
2. CONTAINER stop / daemon restart / host reboot / OOM-kill: the in-container tmux is GONE (fresh PID1). `new-session -A` will now CREATE a fresh session and run the paneCommand, which would start a brand-new conversation. This is the case the raw plan silently lost. Because the transcript directory is bind-mounted from the host (Key decision 3), the fix is to launch with RESUME: the paneCommand becomes `exec claude --dangerously-skip-permissions --resume <claudeSessionId>` (codex uses `resume <id>`, gemini `--resume <id>`) whenever a captured `claudeSessionId` exists. The `-A` semantics make this self-selecting: the resume flag only ever executes when tmux is actually re-created, which is exactly when the live session was lost. When tmux is still alive (case 1), attach wins and the flag is inert.
|
||||
|
||||
Capturing / persisting / reusing the resume id (the missing mechanism the critic flagged): Codeman already learns `Session.claudeSessionId` from transcript correlation (which works here because projHash matches, Key decision 3) and persists it in `SessionState`. We thread that value into `createSessionOptions` / `respawnPaneOptions` for docker so `buildDockerLaunchCommand` can inject the resume flag on any relaunch. To make a NEW Codeman session (new `id8`) re-launched against the same case resume its predecessor's conversation, we ALSO persist `lastClaudeSessionId` on the `DockerCase` record; the quick-start docker branch seeds the new `Session` with it when the `dockerResumeOnStart` setting is on. First-ever launch has no id, so it starts fresh. This is user-decision 7 (default resume behavior).
|
||||
|
||||
Reconciling with stop-on-kill and with the `--restart` policy (the internal inconsistency the critic found): the container is created with `--restart no` uniformly (Codeman's idempotent create-if-missing plus boot recovery is the single recovery mechanism; a restart policy would not preserve the conversation anyway because a restarted container gets a fresh PID1/tmux). Boot recovery re-runs `buildDockerLaunchCommand` from the restored `MuxSession.docker` (`docker inspect || docker create; docker start`, then exec with resume), so a host reboot or daemon restart recreates+starts the container and resumes the conversation instead of the session vanishing. `reconcileSessions` (tmux-manager.ts ~1800-1815) must NOT hard-delete a docker session merely because no LOCAL pane exists after the local `-L codeman` server died; docker (like remote) sessions are restored from `mux-sessions.json` and relaunched. This relaunch path is explicitly part of Phase 4/Phase 3 recovery work, not assumed.
|
||||
|
||||
Why this over the alternatives: `docker exec` gets SIGHUP and dies when its client TTY closes, so a bare `docker exec claude` restarts the CLI on every reconnect/respawn. The inner tmux plus resume is what makes reconnect idempotent across BOTH failure surfaces. Because this durability is the single most important design point, tmux-in-image is a HARD gated prerequisite (`checkDockerTmuxAvailable`), never a silent fallback to bare exec. Rejected alternatives: ephemeral-per-run or bare-exec containers (no reattach durability); a literal `'docker'` `SessionMode` (touches dozens of switch/enum sites and diverges from the remote overlay precedent, since Docker is a LOCATION orthogonal to the 5 CLI backends).
|
||||
|
||||
### Key decision 2: CLI + auth delivery
|
||||
|
||||
One prebuilt base image (built once, contains NO secrets): `node:22-bookworm-slim` + `git tmux ripgrep ca-certificates`, `npm i -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli opencode-ai`, an `agent` user, HOME dirs made writable by an arbitrary host uid via the OpenShift "gid 0, group-writable" convention (Key decision 6). Because the toolchain is baked, export is reproducible and needs no network at import time. The image name/namespace/registry and its refresh cadence are user-decision 2 (the `codeman/agent:base` placeholder implies a Docker Hub org the project may not own).
|
||||
|
||||
Credentials are delivered ONLY at runtime, two commit-safe channels, default convenient:
|
||||
|
||||
- OAuth/config-file CLIs (Claude Max/Pro, gcloud, opencode): bind-mount the host credential dirs read-write (`~/.claude`, `~/.codex`, `~/.gemini` + `~/.config/gcloud`, `~/.config/opencode`) so the common user "just works" with no in-container login. Because these are bind mounts, `docker commit` (which captures only the container's own writable layer, never bind mounts) physically cannot capture them, so exports stay secret-free.
|
||||
- API-key CLIs (codex/gemini): exec-time NAME-ONLY `docker exec --env OPENAI_API_KEY --env GEMINI_API_KEY ...` (no `=value`), sourced from Codeman's own process env. Only the key NAME appears in argv (no `ps` leak), and per-exec env is never captured by `docker commit`. This is the technique Codeman already uses via `tmux setenv` for the local Codex/Gemini panes, so it composes with existing machinery.
|
||||
|
||||
Per-host `DockerHost.mountCredentials` defaults `true` (convenient); setting it `false` yields a SEALED profile (no host cred mounts, in-container login only) for genuinely untrusted work. CRITICAL sealed-mode export rule (the leak the critic caught): in sealed mode the in-container login writes tokens into the container's OWN writable layer, which `docker commit` DOES capture, so a full-image export of a sealed container would ship credentials. Therefore full-image export is REFUSED for `mountCredentials:false` containers by default; the user may either take a workspace-only export (always safe) or opt into a pre-commit scrub that `docker exec`s `rm -rf ~/.claude ~/.codex ~/.gemini ~/.config/gcloud ~/.config/opencode` inside the container before commit (destructive to the in-container login, which is the point). This is enforced in the export route, not left to a manifest assertion.
|
||||
|
||||
Per-session `envOverrides` / `effort` / `codexConfig` / `geminiConfig` / `openCodeConfig` are REJECTED at quick-start exactly like the remote branch (session-routes.ts ~1698-1710). `modelOverride` is the one deliberate difference from remote: because the docker workspace is a REAL bind-mounted host dir that Codeman scaffolds (Key decision 5 and Section 6), `updateCaseModel()` can write the `model` key into `<workspace>/.claude/settings.local.json` and the in-container `claude` reads it, so the App Settings Claude Model picker works for docker cases. `effort` is a `--effort` CLI arg applied only by the local-spawn path we bypass, so it stays rejected (surfaced honestly in the UI, not silently inert). Per-mode command customization goes through `DockerHost.commands.<mode>` (`defaultDockerCommandForMode`, mirror of `defaultRemoteCommandForMode` at remote-hosts.ts:60). NEVER bake secrets into an image layer and NEVER pass a secret via create-time `-e` (both are committed).
|
||||
|
||||
Rejected alternative: sealed-by-default. For a single-operator loopback tool where the agent already runs skip-permissions on the host, forcing an in-container OAuth re-login is a UX regression with little real gain. We keep sealed as an opt-in. Rejected alternative: baking a login into the image, which leaks the instant you `docker save`.
|
||||
|
||||
### Key decision 3: workspace mount, container CWD, and transcript correlation
|
||||
|
||||
Bind-mount the host workspace dir into the container at the SAME absolute path (`dst == src`, mirror the host path), and set both `Session.workingDir` and the container workdir to that host path.
|
||||
|
||||
Two problems this solves that the raw proposals got wrong:
|
||||
|
||||
- File features: `DockerCase.hostWorkspacePath` is a REAL host directory, so `Session.workingDir = hostWorkspacePath` keeps file-routes, attachments, image-watcher, and previews working on real host bytes (unlike remote, where the path is remote-only and those features no-op). All three proposals wired `casePath = <container path>`; we deliberately diverge and use the host path.
|
||||
- Transcript correlation: Claude writes transcripts under `~/.claude/projects/<hash-of-CWD>/`. By mirroring the host path as the container CWD, the projHash computed inside the container equals the host-side hash Codeman's transcript/subagent/workflow watchers expect, so correlation keeps working (and, in turn, feeds the resume-id capture in Key decision 1). A `/workspace`-style fixed dst would break it. Mirror-vs-fixed is user-decision 3.
|
||||
|
||||
`resolveMuxAttachCwd` still returns `/tmp` for docker sessions (the LOCAL bash pane only runs `docker exec`; it never needs the workspace as its cwd), mirroring remote.
|
||||
|
||||
### Key decision 4: network default and the engine-specific host gateway
|
||||
|
||||
Default `bridge` (own netns, NAT egress, no inbound), per-case changeable to `none` (offline shell sandbox; warned because it breaks the API CLIs) or `custom` (a user-defined bridge `codeman-net-<slug>`, the chokepoint for a future egress allowlist). `host` networking and any `-p` inbound publish are structurally unrepresentable in the flag builder and schema. Rationale: every API-backed CLI (Claude, Codex, Gemini) plus npm/git needs egress, so `bridge` is the only sane functional default; `none` is reserved for `shell`.
|
||||
|
||||
The host-callback gateway alias is ENGINE-SPECIFIC (the critic's podman finding): Docker uses `host.docker.internal`, Podman uses `host.containers.internal` (Docker's alias only exists on recent podman). A helper `hostGatewayAlias(engine)` returns the right name; Section 2.5, the create args, the `CODEMAN_API_URL` rewrite, and the host-guard allowlist all consume it, and BOTH aliases are added to the allowlist so a mixed fleet keeps working.
|
||||
|
||||
### Key decision 5: hooks actually reach the host AND are actually installed
|
||||
|
||||
Two independent things must both be true for a hook to fire, and the raw plan wired only the first:
|
||||
|
||||
1. Network reachability. Claude Code hooks POST to `$CODEMAN_API_URL` (`curl -sk`). Inside a bridge container `localhost` is the container and prod binds `127.0.0.1`, so we set `--add-host <gatewayAlias>:host-gateway` on create (skipped on Docker Desktop, where the alias is native), add the gateway alias to the host guard, and provide `CODEMAN_API_URL` and the hook secret (below).
|
||||
2. Hook INSTALLATION. Hooks live in `<workspace>/.claude/settings.local.json`, written by the quick-start scaffolding block (around session-routes.ts ~1776) that calls `writeHooksConfig()` / `updateCaseModel()`. The raw plan extended the `!remote` guard to `!remote && !docker`, which would SKIP that block and silently disable ALL hooks regardless of networking. For docker the workspace is a REAL bind-mounted host dir, so the scaffolding block MUST run. Precise fix: extend to `!remote && !docker` ONLY the LOCAL-CLI-availability and local-spawn guards (the ones that stat the local binary or build the local spawn command); leave the workspace-scaffolding guard at `!remote` so it runs for docker. This same decision is what makes `modelOverride` work (Key decision 2). Consequence, surfaced as user-decision 4: linking a docker case now WRITES `.claude/settings.local.json` (and the CLAUDE.md scaffold, matching local-case behavior) into the user's real host directory, a behavioral shift from "link a dir" to "link and scaffold a dir."
|
||||
|
||||
`CODEMAN_API_URL` derivation (the wrong-scheme bug the critic caught): prod is HTTPS-only on 3000, and `server.ts` (~2000) auto-sets `process.env.CODEMAN_API_URL = ${protocol}://${apiHost}:${port}`. Hardcoding `http://host.docker.internal:3000` fails every hook. Instead a pure helper `containerApiUrl(process.env.CODEMAN_API_URL, engine)` parses the running URL and substitutes ONLY the hostname with `hostGatewayAlias(engine)`, preserving scheme and port (`https://host.docker.internal:3000`). Unit-tested against http, https, non-default ports, and both engines. Passed as create-time `--env CODEMAN_API_URL=<derived>` (case-stable, non-secret).
|
||||
|
||||
Hook secret and session attribution:
|
||||
- `~/.codeman/hook-secret` is bind-mounted read-only to a container path; `--env CODEMAN_HOOK_SECRET_FILE=<that path>` is create-time (a path is non-secret; the bytes ride the bind mount and are never committed).
|
||||
- `CODEMAN_SESSION_ID` (which the generated hooks reference at hooks-config.ts:78-80 to attribute events) plus `CODEMAN_MUX=1` are SESSION-scoped, so they are passed at EXEC time via `docker exec --env CODEMAN_SESSION_ID=<id> --env CODEMAN_MUX=1` (non-secret, value inline is fine, and exec env is not committed). Because a `tmux` session started fresh only inherits the invoking env when it starts the SERVER, the launch chain ALSO runs `tmux -L codeman-docker setenv -g CODEMAN_SESSION_ID <id>` (and `CODEMAN_MUX`) so reattaches and newly created panes see the same values. This mirrors how Codeman already injects per-session env into tmux for the external CLIs.
|
||||
|
||||
Hooks-in-MVP-vs-deferred stays user-decision 4; if deferred, docker ships as explicitly hook-degraded and we lean on output-based idle detection through the docker-exec PTY.
|
||||
|
||||
### Key decision 6: uid / HOME / rootless enforcement / macOS Docker Desktop
|
||||
|
||||
The raw plan showed `--user 1000:1000` in one place and `--user "$(id -u):$(id -g)"` in another and never resolved HOME writability; this section fixes all of it.
|
||||
|
||||
- Linux native (docker rootful or rootless): run `--user <hostUid>:0` (host uid, GID 0). The image follows the OpenShift arbitrary-uid convention: `HOME=/home/agent`, and `/home/agent` plus the tool cache dirs (`~/.npm`, `~/.cache`, `~/.config`) are owned `root:0` and group-writable (`chmod -R g+w`, `g+s` on dirs) so a process with GID 0 can write HOME even though its UID is not 1000. This keeps workspace files host-owned (the agent's UID is the host UID) AND keeps HOME writable, so the CLIs actually start.
|
||||
- Podman rootless: use `--userns=keep-id` (maps the host uid to the image's `agent` uid inside the container) instead of `--user`, so `/home/agent` is owned by the running user and workspace files are host-owned. This is a real per-engine branch in `buildDockerCreateArgs`.
|
||||
- macOS Docker Desktop: `--user <macUid>` (e.g. 501) does not own the image's `/home/agent`, so non-bind HOME writes fail EACCES and the CLIs may not start; Desktop also does its own bind-mount uid translation, provides `host.docker.internal` natively (no `--add-host`), and its VM memory ceiling can cap `--memory`. Detect Desktop via `docker info` (Server OS `linuxkit` / `OperatingString` contains "Docker Desktop") and take a dedicated path: do NOT pass `--user` (run as the image's baked `agent` uid and rely on Desktop's translation for workspace access), skip `--add-host`, and note in the UI that memory caps are subject to the VM ceiling.
|
||||
|
||||
Rootless resource-cap enforcement (the silently-inert risk): rootless Docker without cgroup-v2 systemd delegation (`Delegate=yes`) silently IGNORES `--memory`/`--cpus`/`--pids-limit`. The probe checks `docker info` for `CgroupVersion=2` plus rootless plus delegation; if caps cannot be enforced, `checkDockerAvailable` returns `capsEnforced:false` and the link/probe surfaces "resource caps are advisory on this engine." Whether to REQUIRE delegation or ship-with-warning is user-decision 6.
|
||||
|
||||
## 3. Data model
|
||||
|
||||
New TypeScript types in `src/types/session.ts`, added right after the remote types (lines 46-99). SessionMode (line 44) is UNCHANGED.
|
||||
|
||||
```ts
|
||||
export type DockerCommandMode = Extract<SessionMode, 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini'>;
|
||||
export type DockerEngine = 'docker' | 'podman';
|
||||
export type DockerNetworkMode = 'bridge' | 'none' | 'custom'; // never 'host'
|
||||
|
||||
export interface DockerResourceLimits {
|
||||
memory?: string; // '4g' -> --memory 4g --memory-swap 4g (swap==memory: real OOM cap)
|
||||
cpus?: string; // '2'
|
||||
pidsLimit?: number; // 512 (fork-bomb guard)
|
||||
nofile?: string; // '4096:8192'
|
||||
shmSize?: string; // optional; only when a tool needs /dev/shm
|
||||
}
|
||||
|
||||
export interface DockerHost {
|
||||
id: string;
|
||||
label: string;
|
||||
engine?: DockerEngine; // default resolved by probe (docker, else podman)
|
||||
image: string; // default resolved image ref (see user-decision 2)
|
||||
daemonHost?: string; // advanced: -H ssh://user@host / DOCKER_HOST
|
||||
context?: string; // advanced: --context <ctx>
|
||||
network?: DockerNetworkMode; // default 'bridge'
|
||||
networkName?: string; // when network === 'custom'
|
||||
resources?: DockerResourceLimits;
|
||||
mountCredentials?: boolean; // default true (false = sealed; blocks full-image export)
|
||||
hooksEnabled?: boolean; // default true (host-gateway callback wiring)
|
||||
resumeOnStart?: boolean; // default true (see Key decision 1 / user-decision 7)
|
||||
commands?: Partial<Record<DockerCommandMode, string>>;
|
||||
extraCreateArgs?: string[]; // validated like extraSshOptions
|
||||
extraExecArgs?: string[];
|
||||
}
|
||||
|
||||
export interface DockerCase {
|
||||
name: string;
|
||||
type: 'docker';
|
||||
hostId: string;
|
||||
hostWorkspacePath: string; // absolute HOST dir: bind src + Session.workingDir
|
||||
containerWorkdir?: string; // container path; default = hostWorkspacePath (mirror -> projHash match)
|
||||
container?: string; // default codeman-case-<slug>
|
||||
lastClaudeSessionId?: string; // captured resume id (Key decision 1)
|
||||
}
|
||||
|
||||
export interface SessionDocker { // flattened, round-trips through mux/state (mirror SessionRemote at 91)
|
||||
hostId: string;
|
||||
label: string;
|
||||
engine: DockerEngine;
|
||||
image: string;
|
||||
containerName: string;
|
||||
hostWorkspacePath: string;
|
||||
containerWorkdir: string;
|
||||
network: DockerNetworkMode;
|
||||
networkName?: string;
|
||||
resources?: DockerResourceLimits;
|
||||
mountCredentials: boolean;
|
||||
hooksEnabled: boolean;
|
||||
resumeOnStart: boolean;
|
||||
daemonHost?: string;
|
||||
context?: string;
|
||||
commands?: Partial<Record<DockerCommandMode, string>>;
|
||||
extraCreateArgs?: string[];
|
||||
extraExecArgs?: string[];
|
||||
configHash?: string; // drift detection (Key decision, Section 4)
|
||||
}
|
||||
```
|
||||
|
||||
- `SessionState` gains `docker?: SessionDocker` immediately after `remote?` (line 219). It persists automatically because `SessionState` is structural and `state-store.ts` stores `toState()` verbatim.
|
||||
- `src/mux-interface.ts`: add `docker?: SessionDocker` to `MuxSession` (after line 38), `CreateSessionOptions` (after 81), `RespawnPaneOptions` (after 105). `MuxSession.docker` round-trips through `mux-sessions.json` automatically.
|
||||
- `src/types/api.ts` `CaseInfo`: add `'docker'` to the `location` union and a `docker?: { hostId; container; image?; path; network }` display block.
|
||||
- `src/services/unified-session-service.ts`: add a boolean `docker?` flag on `UnifiedSessionItem` and source rows, set from `MuxSession.docker` presence (mirror the `remote` flag at ~line 200 and the harvest at session-routes.ts:2313).
|
||||
|
||||
New state files (all via `dataPath()`, mirroring `remote-hosts.json` / `remote-cases.json`):
|
||||
|
||||
- `~/.codeman/docker-hosts.json` (reusable engine/image/network/resource profiles).
|
||||
- `~/.codeman/docker-cases.json` (`name -> DockerCase`, including `lastClaudeSessionId`).
|
||||
- `~/.codeman/docker-exports/` (dedicated dir for `.image.tar.gz` + `.workspace.tar.gz` + `manifest.json`; never inline in state.json; retention/pruning per Section 5).
|
||||
|
||||
No new `state.json` / `mux-sessions.json` files: `SessionState.docker` and `MuxSession.docker` ride the existing serialization.
|
||||
|
||||
## 4. Container lifecycle (exact command shapes)
|
||||
|
||||
All builders are PURE string functions (directly unit-testable). Host values interpolated into the outer `bash -c "..."` layer (container name, image, workdir, host paths) are `shellescape()`'d and, for user-supplied fields, schema-rejected for `$`/backtick via `NO_SHELL_META`. The escaping chain here is DEEPER than remote's single `ssh '<tmux ...>'`: the whole `docker inspect || docker create <dozens of --mount/--env/shellescaped host paths>` is interpolated into `bash -c "..."` then `JSON.stringify`'d into respawn-pane. This is a known place to get stuck, so it is covered by concrete escaping tests (Section 9), including host workspace paths containing spaces, not just a "we call shellescape" claim.
|
||||
|
||||
New in `src/tmux-manager.ts`:
|
||||
|
||||
```ts
|
||||
const DOCKER_TMUX_SOCKET = 'codeman-docker';
|
||||
// 'dkr' letters deliberately FAIL SAFE_MUX_NAME_PATTERN (^codeman-[a-f0-9-]+$),
|
||||
// so a Codeman running INSIDE the container never adopts/resizes/respawns our session.
|
||||
export function dockerTmuxSessionName(id: string): string { return `codeman-dkr-${id.slice(0, 8)}`; }
|
||||
```
|
||||
|
||||
`buildDockerBaseArgs(docker)` (pure, in `docker-hosts.ts`, mirror of `buildSshConnectionArgs`) emits the engine prefix tokens: `docker` (or `podman`) + optional `--context <ctx>` or `-H <daemonHost>`. `buildDockerCreateArgs(docker, sessionId)` emits the `docker create` flag array (with the per-engine uid/userns branch from Key decision 6).
|
||||
|
||||
IMAGE PRESENCE (before any create, the auto-pull footgun the critic caught): the launch chain runs `docker image inspect <image> >/dev/null 2>&1` first; on miss it exits with a distinct message ("base image <ref> not present: build with scripts/build-agent-image.mjs or pull it") rather than triggering a blocking multi-GB auto-pull inside the tmux pane. `docker create` carries `--pull=never`. The tmux-availability probe likewise uses `docker run --rm --pull=never <image> sh -lc 'command -v tmux'` and reports the same build/pull hint if the image is absent, so the 15s-bounded probe never hangs on a pull.
|
||||
|
||||
CREATE (the ensure step, embedded in the launch string):
|
||||
|
||||
```
|
||||
docker create \
|
||||
--name codeman-case-myproj --hostname myproj \
|
||||
--label codeman.managed=1 --label codeman.instance=<CODEMAN_INSTANCE> \
|
||||
--label codeman.case=myproj --label codeman.session=<id8> \
|
||||
--label codeman.confighash=<hash> \
|
||||
--pull=never --init --restart no \
|
||||
--user 1000:0 \
|
||||
--workdir '/home/arkon/cases/myproj' \
|
||||
--mount type=bind,src='/home/arkon/cases/myproj',dst='/home/arkon/cases/myproj' \
|
||||
--mount type=bind,src='/home/arkon/.claude',dst='/home/agent/.claude' \
|
||||
--mount type=bind,src='/home/arkon/.codeman/hook-secret',dst='/home/agent/.codeman/hook-secret',readonly \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--memory 4g --memory-swap 4g --cpus 2 --pids-limit 512 --ulimit nofile=4096:8192 \
|
||||
--cap-drop ALL --security-opt no-new-privileges \
|
||||
--network bridge \
|
||||
--env HOME=/home/agent --env TERM=xterm-256color --env COLORTERM=truecolor \
|
||||
--env CODEMAN_API_URL=https://host.docker.internal:3000 \
|
||||
--env CODEMAN_HOOK_SECRET_FILE=/home/agent/.codeman/hook-secret \
|
||||
codeman/agent:base \
|
||||
sleep infinity
|
||||
```
|
||||
|
||||
- `--user 1000:0` shown is the Linux-native form with GID 0 (Key decision 6); it is actually `--user <hostUid>:0`, or `--userns=keep-id` for podman rootless, or omitted on Docker Desktop. The literal is illustrative only.
|
||||
- Create-time `--env` carries only NON-SESSION, non-secret, case-stable values (safe to be committed): the DERIVED `CODEMAN_API_URL` (https-preserving, Key decision 5) and the hook-secret FILE PATH. `CODEMAN_SESSION_ID`/`CODEMAN_MUX` and the codex/gemini key NAMES are exec-time only.
|
||||
- `codeman.instance=<CODEMAN_INSTANCE>` is REQUIRED on the label set so the boot reaper is instance-scoped (a beta/second instance must never reap prod's containers).
|
||||
- `codeman.confighash` is a stable hash of the drift-relevant create args (image, resources, network, mounts, non-session env). Drift detection (user story 2, the config-never-takes-effect gap): on launch the ensure block compares the desired hash to the existing container's label; on mismatch the launch does NOT silently reuse the stale container. Instead the docker route returns a "container config changed, recreate?" action (SSE + UI confirm), and on confirm Codeman `docker rm`'s and recreates. rm destroys in-image (non-bind) state, but the workspace and transcripts survive on their bind mounts and the conversation is restored via `--resume`, so the recreate is safe. Auto-recreate-vs-prompt is a UI choice; the MVP prompts.
|
||||
- `--restart no` (resolved consistently with Key decision 1; recovery is Codeman's idempotent create-if-missing, not an engine restart policy, which also matters for Podman which has no daemon).
|
||||
|
||||
EXEC (`buildDockerLaunchCommand`, the docker analog of `buildRemoteLaunchCommand`, TTY-correct, resume-aware). The whole thing is ONE `bash -c` string that image-checks, ensures, starts, primes tmux env, then execs:
|
||||
|
||||
```
|
||||
docker image inspect codeman/agent:base >/dev/null 2>&1 || { echo 'Codeman: base image codeman/agent:base not present (build or pull it)'; exit 1; } ; \
|
||||
docker inspect codeman-case-myproj >/dev/null 2>&1 || docker create <all create args above> ; \
|
||||
docker start codeman-case-myproj >/dev/null 2>&1 || { echo 'Codeman: container codeman-case-myproj failed to start (daemon down?)'; exit 1; } ; \
|
||||
exec docker exec -it \
|
||||
--workdir '/home/arkon/cases/myproj' \
|
||||
--env TERM=xterm-256color --env COLORTERM=truecolor \
|
||||
--env CODEMAN_SESSION_ID=1a2b3c4d --env CODEMAN_MUX=1 \
|
||||
--env OPENAI_API_KEY --env GEMINI_API_KEY \
|
||||
codeman-case-myproj \
|
||||
sh -lc 'tmux -L codeman-docker setenv -g CODEMAN_SESSION_ID 1a2b3c4d \; setenv -g CODEMAN_MUX 1 \; new-session -A -s codeman-dkr-1a2b3c4d -c '\''/home/arkon/cases/myproj'\'' '\''cd /home/arkon/cases/myproj && exec claude --dangerously-skip-permissions --resume <claudeSessionId>'\'' \; set -t codeman-dkr-1a2b3c4d status off \; set -t codeman-dkr-1a2b3c4d mouse off \; set -t codeman-dkr-1a2b3c4d prefix C-q \; set -s escape-time 0'
|
||||
```
|
||||
|
||||
- `docker exec -it`: `-t` allocates a PTY and forwards SIGWINCH into the container so the Ink TUI re-lays-out on pane resize; `TERM`/`COLORTERM` prevent degraded rendering. `--env OPENAI_API_KEY` (name only) is present only for codex/gemini and is exec-time (never committed). `CODEMAN_SESSION_ID`/`CODEMAN_MUX` are exec-time values plus a `tmux setenv -g` prime so reattaches and new panes inherit them (Key decision 5).
|
||||
- `--resume <claudeSessionId>` (codex `resume <id>`, gemini `--resume <id>`) is appended to `modeCommand` ONLY when a captured id exists; on first launch it is omitted. `new-session -A` makes the flag inert on a live-tmux reattach and effective only when tmux is re-created (Key decision 1).
|
||||
- `modeCommand = docker.commands?.[mode] || defaultDockerCommandForMode(mode)` (`exec claude --dangerously-skip-permissions`, `exec bash -l`, etc.), with the resume suffix injected by the builder.
|
||||
- Escaping survives every layer identically to remote in shape but deeper in nesting: `paneCommand` (`cd ... && exec ...`) is one shellescaped tmux arg, the whole `tmuxInvocation` is one shellescaped `sh -lc` arg, and the outer string is `JSON.stringify()`'d into `bash -c` by respawn-pane (tmux-manager.ts:1329).
|
||||
|
||||
Wire-up (extend the two existing seams to 3-way):
|
||||
|
||||
- createSession (tmux-manager.ts:1276): `const fullCmd = docker ? buildDockerLaunchCommand({ mode, docker, sessionId, resumeSessionId }) : remote ? buildRemoteLaunchCommand({ mode, remote, sessionId }) : localFullCmd;`
|
||||
- launchCmd cd-skip (tmux-manager.ts:1327): `const launchCmd = (remote || docker) ? fullCmd : \`cd ${JSON.stringify(workingDir)} && ${fullCmd}\`;`
|
||||
- respawnPane: same two edits at lines 1524 and 1542.
|
||||
|
||||
START / reattach-after-reboot: the ensure block (image-check, `docker inspect || docker create`, `docker start`) is fully idempotent, so boot recovery just re-runs `buildDockerLaunchCommand` from the restored `MuxSession.docker` with the persisted resume id. A rebooted host recreates the container and resumes the conversation.
|
||||
|
||||
DOCKER-DOWN surfacing (the PTY-exit-breaker false-trip risk): if `docker start` or `docker exec` cannot attach (daemon down, container missing), the launch prints a docker-specific message and exits, which alone would still count toward `session-pty-exit-breaker` and show a generic "respawn breaker tripped" push. To avoid masking the cause, the docker reattach path runs a fast `checkDockerAvailable` pre-flight: if the daemon/container is unreachable, Codeman broadcasts a docker-specific error (SSE + push, "container <name> is not running / daemon down") and SKIPS the auto-reattach that would trip the breaker, rather than fast-looping `docker exec`.
|
||||
|
||||
STOP / KILL (`killSession` Strategy 3c, right after remote's Strategy 3b at tmux-manager.ts:1719, guarded by `IS_TEST_MODE`):
|
||||
|
||||
```ts
|
||||
if (session.docker) {
|
||||
// best-effort, fire-and-forget, timeout-bounded so it never blocks the local kill
|
||||
execAsync(buildDockerKillCommand({ docker: session.docker, sessionId }), { timeout: EXEC_TIMEOUT_MS }).catch(() => {});
|
||||
}
|
||||
```
|
||||
|
||||
`buildDockerKillCommand` emits: `docker exec codeman-case-<slug> tmux -L codeman-docker kill-session -t codeman-dkr-<id8> ; docker stop -t 10 codeman-case-<slug>`. Stopping frees CPU/RAM and, per Key decision 1, is safe for conversation continuity because the NEXT launch resumes from the bind-mounted transcript via `--resume`. Whether to stop at all (RAM vs instant live-agent reattach) is user-decision 6/1 (reframed honestly). The bind-mounted workspace and transcripts always survive on the host.
|
||||
|
||||
REMOVE: only on explicit case delete (`docker rm -f codeman-case-<slug>`), gated behind an "export first?" UI prompt because rm destroys any in-image (non-bind) state. Instance-scoped boot reaper (fixing the racy/cross-instance reaper): after `docker-cases.json` is loaded AND after `restoreMuxSessions` has run, enumerate `docker ps -a --filter label=codeman.managed=1 --filter label=codeman.instance=<CODEMAN_INSTANCE> --format '{{.Names}}\t{{index .Labels "codeman.case"}}'` and `docker rm -f` only containers whose case is gone from THIS instance's `docker-cases.json`. The instance filter is what stops a beta reaping prod's containers (the exact cross-instance hazard the project memory warns about).
|
||||
|
||||
AVAILABILITY PROBE (`docker-hosts.ts`, timeout-bounded like `checkRemoteTmuxAvailable`'s 15s, `IS_TEST_MODE` no-op):
|
||||
|
||||
```
|
||||
docker info --format '{{json .}}' # server up, CgroupVersion, rootless, OS (Desktop detect), cap-delegation
|
||||
docker image inspect <image> --format '{{.Id}}' # image PRESENT (no auto-pull)
|
||||
docker run --rm --pull=never <image> sh -lc 'command -v tmux' # tmux-in-image gate (hard prerequisite), only if image present
|
||||
```
|
||||
|
||||
`checkDockerAvailable()` returns `{ ok, engine, rootless, isDesktop, cgroupV2, capsEnforced }` (parse `SecurityOptions` for `name=rootless`, `CgroupVersion`, delegation, and Server OS for Desktop). `checkDockerTmuxAvailable(host)` returns a structured result with a user-facing error and correct install hint (NOT `npm install -g`; the hint is "build/pull the base image" for a missing image and "install docker or podman" for a missing engine).
|
||||
|
||||
IN-CONTAINER CLI VERSION (fixing the #154 wheel-forwarding regression): the raw plan skipped the LOCAL `cliVersion` probe for docker (correct, since it reports the HOST claude) but left `cliVersion` undefined, which disables trackpad wheel-forwarding. Instead, for docker sessions Codeman runs an IN-CONTAINER probe `docker exec <container> claude --version` (bounded, `IS_TEST_MODE` no-op) and feeds THAT into `cliVersion`. This also means a stale baked CLI is visible; combined with the rebuild-cadence in user-decision 2, agents are not silently pinned to an old claude.
|
||||
|
||||
## 5. Export / Import
|
||||
|
||||
EXPORT is a concurrency-bounded job (reuse `runWithConversionLimit` from `document-conversion-limiter.ts` so N simultaneous exports cannot fork-bomb the host). Route `POST /api/docker-cases/:name/export`.
|
||||
|
||||
Preconditions (the consistency and leak risks the critic caught):
|
||||
- Sealed guard: if `mountCredentials:false`, full-image export is REFUSED unless the caller explicitly opts into the pre-commit scrub (Key decision 2). Workspace-only export is always allowed.
|
||||
- Quiesce + free-space: require the session idle, then `docker pause` the container spanning BOTH the workspace tar AND the commit so the two artifacts are mutually consistent (the raw plan paused only the commit, leaving the bind-mount tar to run against a mid-write agent). Before any heavy step, precheck free space in the exports dir and in `/var/lib/docker`; if below `DOCKER_EXPORT_MIN_FREE_BYTES`, refuse with a clear error (a full `/var/lib/docker` wedges the daemon and breaks EVERY session on the host).
|
||||
|
||||
Steps (all cleanup in try/finally so a mid-way failure never orphans an intermediate image or leaves the container paused):
|
||||
|
||||
1. `docker commit -c 'LABEL codeman.exported=1' codeman-case-<slug> codeman/export-<slug>:<ts>` (unique tag per export defeats the stale-image trap). Optional pre-commit scrub in sealed mode as above; also blank instance-specific committed env (`-c 'ENV CODEMAN_API_URL='` etc.) so the image carries no stale host references.
|
||||
2. `docker save codeman/export-<slug>:<ts> | gzip` streamed in fixed 8192-byte chunks to `~/.codeman/docker-exports/<slug>-<ts>.image.tar.gz`. Uses `docker save` (layers + repo:tag + CMD), never `docker export` (flat rootfs), so restore is a trivial `docker load`.
|
||||
3. `tar --numeric-owner -C <hostWorkspacePath> -czf <slug>-<ts>.workspace.tar.gz .` while paused (the bind-mounted workspace is NOT in the image, so it travels separately and consistently).
|
||||
4. Write `manifest.json`: schema version, caseName, image tag, engine, containerWorkdir, resource/network config, codeman version, base-image digest, createdAt, per-member sha256, `mountCredentials`, and `secretFree` (true only for convenient-mode or scrubbed-sealed exports).
|
||||
5. `docker rmi codeman/export-<slug>:<ts>` in the `finally` (delete the intermediate committed image regardless of success), then `docker unpause`.
|
||||
|
||||
The three files are wrapped in one bundle `<slug>-<ts>.codeman-container.tgz` and offered as a downloadable artifact through the existing file-routes streaming + attachment-registry handoff.
|
||||
|
||||
Retention / disk budget (user-decision 3): `docker-exports/` is capped at `DOCKER_EXPORT_KEEP` most-recent bundles with an auto-prune on each new export, plus the free-space precheck above. Workspace scrub: the WORKSPACE tar gets a scan/warn pass for agent-created `.env` / `.git/credentials` (a distinct leak channel from container creds). A lighter "workspace-only" export (just the workspace tar, no commit/save) is the fast default for 24h+ runs; full-image is the explicit heavier option (user-decision 7 in the original list, now decision on the default button below).
|
||||
|
||||
What travels: the baked toolchain image plus any in-image writes, and the workspace tar. What does NOT travel: bind-mounted credentials (physically excluded from commit) and anything that lived only in a bind mount. Secret-free by construction in convenient mode, and enforced (refuse-or-scrub) in sealed mode.
|
||||
|
||||
IMPORT `POST /api/docker-cases/import` (untrusted-bundle containment, the traversal/overwrite risk): stream the uploaded bundle, validate every manifest checksum BEFORE any extraction or load. Extract the workspace tar with `tar --no-absolute-names -C <fresh dir>` PLUS per-entry validation rejecting any member whose normalized path escapes the destination (leading `/` or `..` components). `gunzip | docker load` the image, then RE-TAG the loaded image id into a quarantined namespace `codeman/imported-<slug>:<ts>` and NEVER allow the load to overwrite `codeman/agent:base` or any pre-existing tag (capture the loaded id, ignore the bundle's repo:tag). Create a NEW `DockerCase` pointing at the quarantined image with THIS host's mounts/creds and the manifest's resource/network config, and recreate the container hardened (cap-drop ALL, no-new-privileges, non-root, `--pull=never`, CMD overridden to `sleep infinity`). The destination supplies its own login, so credentials never cross machines. Plus `GET /api/docker-exports` (list) and `DELETE /api/docker-exports/:filename`, all behind Codeman's existing auth / loopback-default / host-guard / Origin-CSRF stack.
|
||||
|
||||
## 6. Codeman integration (file-by-file, mirroring the remote-SSH feature)
|
||||
|
||||
- `src/types/session.ts`: add `DockerCommandMode`, `DockerEngine`, `DockerNetworkMode`, `DockerResourceLimits`, `DockerHost`, `DockerCase`, `SessionDocker` (Section 3). Add `docker?: SessionDocker` to `SessionState` after line 219. SessionMode (line 44) UNCHANGED.
|
||||
- `src/mux-interface.ts`: add `docker?: SessionDocker` to `MuxSession` (38), `CreateSessionOptions` (81), `RespawnPaneOptions` (105).
|
||||
- `src/docker-hosts.ts` (NEW, direct mirror of `src/remote-hosts.ts`): `readDockerHosts`/`writeDockerHosts`/`readDockerCases`/`writeDockerCases` (via `dataPath`, including `lastClaudeSessionId` read/write), `defaultDockerCommandForMode` (mirror line 60), `dockerDisplayPath` (`container:/path`, mirror `remoteDisplayPath` at 205), `toSessionDocker(host, case)` (mirror `toSessionRemote` at 212), `buildDockerBaseArgs`/`buildDockerCreateArgs` (per-engine uid/userns branch), `hostGatewayAlias(engine)`, `containerApiUrl(processApiUrl, engine)` (scheme+port-preserving, unit-tested), `checkDockerAvailable`/`checkDockerTmuxAvailable`/`probeDockerCliVersion` (15s-bounded, `IS_TEST_MODE` no-op), a config-hash helper for drift, its own POSIX `shellescape` copy (mirror line 83). `const IS_TEST_MODE = !!process.env.VITEST;` gates every real `docker` invocation.
|
||||
- `src/tmux-manager.ts`: add `DOCKER_TMUX_SOCKET`, `dockerTmuxSessionName`, `buildDockerLaunchCommand` (resume-aware, image-check, env-prime), `buildDockerKillCommand` (Section 4). Extend the two `fullCmd` ternaries (1276, 1524) and the two `launchCmd` cd-skips (1327, 1542). Add `killSession` Strategy 3c after 1719. Ensure `reconcileSessions` (~1800-1815) does NOT hard-delete docker sessions on local-tmux death (recovery relaunch path).
|
||||
- `src/session.ts`: add `_docker?: SessionDocker` field (mirror `_remote` at 403), constructor arg (477), assignment (550). Thread `docker: this._docker` and `resumeSessionId: this._claudeSessionId` into BOTH `createSessionOptions` and `respawnPaneOptions` in `startInteractive` (1352/1370) and the second path (1740/1750). Emit `docker: this._docker` in `toState()` (1010). Replace the LOCAL cliVersion probe at 1320 for docker with the IN-CONTAINER `probeDockerCliVersion` (do not merely skip it). Extend `resolveMuxAttachCwd(workingDir, remote, docker)` (215) to return `/tmp` when `docker` is set. On claudeSessionId capture, persist it to the owning `DockerCase.lastClaudeSessionId`.
|
||||
- `src/web/server.ts`: in `restoreMuxSessions` (2160), add `docker: muxSession.docker ?? savedState?.docker` to the `new Session({...})` call (2195-2216), and skip docker in the same `isExternalCliMode`/Ralph recovery guards as remote. Register the instance-scoped boot reaper to run AFTER docker-cases load and AFTER `restoreMuxSessions`. Ensure `CODEMAN_API_URL` derivation reads the SAME `process.env.CODEMAN_API_URL` the server sets at ~2000.
|
||||
- `src/web/schemas.ts`: add `DockerHostSchema` and `DockerCaseLinkSchema` (below). The three mode enums (177/373/705) and `QuickStartSchema` (368) UNCHANGED (docker resolves by `caseName` lookup like remote).
|
||||
- `src/web/routes/session-routes.ts`: import the docker helpers from `../../docker-hosts.js`. Add a docker branch in `/api/quick-start` parallel to the remote branch (1686-1720): `readDockerCases` -> find by `caseName` -> `readDockerHosts` -> find by `hostId`; reject `envOverrides`/`effort`/`codexConfig`/`geminiConfig`/`openCodeConfig` (but ACCEPT `modelOverride`, which flows via scaffolded `settings.local.json`); run `checkDockerAvailable` + `checkDockerTmuxAvailable` (image-present, engine, caps-enforced); surface `capsEnforced:false` and Desktop notes; set `casePath = dockerCase.hostWorkspacePath` (REAL host dir), `docker = toSessionDocker(host, dockerCase)`, and seed `resumeSessionId` from `dockerCase.lastClaudeSessionId` when `resumeOnStart`. Extend the LOCAL-availability and local-spawn guards (around 1796/1810) to `!remote && !docker`, but DO NOT extend the workspace-scaffolding guard (~1776, `writeHooksConfig`/`updateCaseModel`), which MUST run for docker. Pass `docker` into `new Session` (1847); `autoConfigureRalph` (1853) gated on `!docker`. Add `docker: m.docker !== undefined ? true : undefined` to the unified harvest (2313).
|
||||
- `src/web/routes/case-routes.ts`: import the docker read/write/check helpers + schemas. Add a docker listing loop in `GET /api/cases` (mirror 94-119, `location: 'docker'`, `docker: {...}` via `dockerDisplayPath`). Add `/api/docker-hosts` GET/POST/PUT/DELETE (mirror 168-204) and `POST /api/cases/docker-link` (mirror 206-232; run `checkDockerAvailable`/`checkDockerTmuxAvailable` at link time; broadcast `CaseLinked` with `type: 'docker'`). Add a docker-unlink branch to `DELETE /api/cases/:name` (mirror 288-296; `docker rm -f`; broadcast `CaseDeleted` `type: 'docker-unlinked'`). Add the docker branch to single-case `GET` (mirror 358-368). Add `POST /api/docker-cases/:name/export`, `/import`, `GET/DELETE /api/docker-exports`, and a `POST /api/docker-cases/:name/recreate` (drift confirm) per Sections 4 and 5.
|
||||
- `src/web/sse-events.ts` + `src/web/public/constants.js`: reuse `CaseLinked`/`CaseDeleted` for CRUD. Add `docker:exportProgress`, `docker:exportComplete`, `docker:importComplete`, `docker:configDrift`, and `docker:containerError` to BOTH registries (kept in sync per CLAUDE.md).
|
||||
- Frontend `src/web/public/index.html` (~1831): add a Docker `modal-tab-btn` next to Remote; add a `#case-docker` panel mirroring `#case-remote` with `dockerCaseName`, `dockerHostWorkspacePath`, `dockerContainer`, `dockerImage`, `dockerHostId`, and an Advanced `<details>` for network mode, resource caps, `mountCredentials`, `resumeOnStart`, and remote daemon. Surface a "scaffolds .claude into this host dir" note (user-decision 4) and a "resource caps advisory on this engine" warning when `capsEnforced:false`.
|
||||
- Frontend `src/web/public/session-ui.js`: `formatCasePickerLabel` (48) + `buildCasePickerOptions` (71-73) handle `location === 'docker'` (`name @ container`, add container/image to the search haystack); `resetCaseModalFields` (~1514) add a `dockerFields` array; `switchCaseModalTab` (1573/1580/1597) handle `'case-docker'`; `submitCaseModal` add the docker branch; new `linkDockerCase()` (mirror `linkRemoteCase` at 1689) POSTing `/api/docker-hosts` then `/api/cases/docker-link`, sending omitted optionals as `undefined` (spread `...(x ? {x} : {})`, never `null`, per the Zod `.optional()`-rejects-null gotcha); `runClaude` (520) / `runShell` (702) extend the `location === 'remote'` routing to also match `'docker'`; `runOpenCode`/`runCodex`/`runGemini` (792/846/900) make the `isRemote` checks `isRemoteOrDocker` so local status probes are skipped. In the session-options Summary tab, note that `effort` is inert for docker (rejected) while `model` IS honored via `settings.local.json`.
|
||||
- Frontend `src/web/public/panels-ui.js` (425-426): add `caseItem?.docker?.path`/`container` to the case-search fields.
|
||||
|
||||
Schemas (`src/web/schemas.ts`), mirroring `RemoteHostSchema` (299) / `RemoteCaseLinkSchema` (351):
|
||||
|
||||
```ts
|
||||
export const DockerHostSchema = z.object({
|
||||
id: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'),
|
||||
label: z.string().min(1).max(100),
|
||||
engine: z.enum(['docker', 'podman']).optional(),
|
||||
image: z.string().min(1).max(512).regex(/^[a-zA-Z0-9][\w./:@-]*$/, 'Invalid image ref').regex(NO_SHELL_META),
|
||||
daemonHost: z.string().max(512).regex(NO_SHELL_META, 'Invalid daemon host').optional(),
|
||||
context: z.string().max(128).regex(/^[a-zA-Z0-9._-]+$/, 'Invalid context').optional(),
|
||||
network: z.enum(['bridge', 'none', 'custom']).optional(),
|
||||
networkName: z.string().max(128).regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/).optional(),
|
||||
resources: z.object({
|
||||
memory: z.string().regex(/^\d+[bkmg]?$/i).optional(),
|
||||
cpus: z.string().regex(/^\d+(\.\d+)?$/).optional(),
|
||||
pidsLimit: z.number().int().positive().max(100000).optional(),
|
||||
nofile: z.string().regex(/^\d+:\d+$/).optional(),
|
||||
shmSize: z.string().regex(/^\d+[bkmg]?$/i).optional(),
|
||||
}).strict().optional(),
|
||||
mountCredentials: z.boolean().optional(),
|
||||
hooksEnabled: z.boolean().optional(),
|
||||
resumeOnStart: z.boolean().optional(),
|
||||
commands: RemoteCommandOverridesSchema, // reuse the shared shape
|
||||
extraCreateArgs: z.array(z.string().min(1).max(1024).regex(NO_SHELL_INJECTION).refine(noCommandSubstitution)).max(32).optional(),
|
||||
extraExecArgs: z.array(z.string().min(1).max(1024).regex(NO_SHELL_INJECTION).refine(noCommandSubstitution)).max(32).optional(),
|
||||
});
|
||||
|
||||
export const DockerCaseLinkSchema = z.object({
|
||||
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
|
||||
hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'),
|
||||
hostWorkspacePath: z.string().min(1).max(2000).regex(/^\//, 'Path must be absolute').regex(NO_SHELL_META, 'Invalid characters in workspace path'),
|
||||
containerWorkdir: z.string().min(1).max(2000).regex(/^\//).regex(NO_SHELL_META).optional(),
|
||||
container: z.string().min(2).max(128).regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid container name').optional(),
|
||||
});
|
||||
```
|
||||
|
||||
`NO_SHELL_META` (rejects `$`/backtick, schemas.ts:297) is REQUIRED on `image`, `hostWorkspacePath`, `containerWorkdir`, and `container`, because all four reach the outer `bash -c "..."` double-quote layer where `$(...)`/backtick re-expose, exactly the reason `remotePath`/`identityFile` use it. `--privileged` and any `-v /var/run/docker.sock` are structurally unrepresentable (never emitted by the builder, never accepted by the schema).
|
||||
|
||||
## 7. Security model
|
||||
|
||||
- Hardening flags on every create: `--cap-drop ALL`, `--security-opt no-new-privileges` (NOT auto-set by rootless Docker or Podman, so always explicit), the uid/userns branch of Key decision 6 (never container-root; workspace files stay host-owned and HOME stays writable via GID 0), `--pids-limit` (fork-bomb guard), `--memory` with `--memory-swap == --memory` (real OOM cap), `--ulimit nofile`, `--init`, `--pull=never`. NEVER `--privileged`, NEVER mount the docker socket into the agent container. `--storage-opt size=` is emitted ONLY after the probe confirms overlay2-on-xfs-pquota or btrfs (the AICE-class silently-ignored trap); otherwise it is omitted and the UI does not advertise a size cap. Resource caps are advertised as ENFORCED only when the probe reports `capsEnforced:true`; under non-delegated rootless they are labeled advisory (user-decision 6).
|
||||
- Engine: prefer whichever the probe finds, Podman-rootless first for security (a container-root breakout lands as an unprivileged host user). Rootless bind-mount ownership uses `--userns=keep-id` (Podman) vs `--user <hostUid>:0` (Docker), so real per-engine branching lives in `buildDockerCreateArgs`. Docker Desktop takes its own uid path (Key decision 6).
|
||||
- Blast radius (the combined-posture the critic asked to surface, user-decision 5): the default convenient profile mounts an arbitrary host workspace dir RW (host-owned, mirrored path) AND host `~/.claude`/`~/.codex`/`~/.gemini`/`~/.config/gcloud`/`~/.config/opencode` RW into a NETWORK-ENABLED container. Container-run agent code can therefore read/modify those host trees and reach the network simultaneously. This is still a strict improvement over today's on-host skip-permissions execution, but the user must accept the combined posture explicitly; the sealed profile plus `network:none` is the mitigation for genuinely untrusted work.
|
||||
- Secret handling: creds arrive ONLY as bind-mounted files (default) or exec-time NAME-ONLY `--env` (codex/gemini keys), NEVER as create-time `-e` and NEVER as an image layer. Sealed-mode export is refuse-or-scrub (Section 5), closing the sealed-leak inversion.
|
||||
- CLAUDE.md "Multi-CLI prefix discipline": the exec-time name-only env is restricted to the CLI-specific keys per mode (Claude: none with OAuth mount; Codex: `OPENAI_API_KEY`/`CODEX_API_KEY`; Gemini: `GEMINI_API_KEY`/`GOOGLE_*`), never a blanket forward. `envOverrides` is rejected for docker, so the `ALLOWED_ENV_PREFIXES` allowlist is not widened.
|
||||
- hook-secret: bind-mounted read-only, referenced via `CODEMAN_HOOK_SECRET_FILE` (a path, non-secret); the secret bytes never enter env or the image. Both `host.docker.internal` and `host.containers.internal` are added to the host-guard allowlist so the in-container hook curl's Host header passes on either engine.
|
||||
- Host guard / instance isolation: the in-container tmux socket (`codeman-docker`) and name (`codeman-dkr-<id8>`) deliberately FAIL a container-internal Codeman's `SAFE_MUX_NAME_PATTERN`, so a nested Codeman never adopts our session (unit-asserted). The boot reaper is instance-scoped by the `codeman.instance` label so a beta never reaps prod. Any remote-daemon (`-H`/`--context`) mode is host-root-equivalent and stays strictly behind the existing auth/loopback/host-guard/Origin-CSRF stack.
|
||||
- Import containment: untrusted bundles are checksum-validated, extracted with traversal guards, and loaded into a quarantined image namespace (never overwriting the base image), then run with the same hardening.
|
||||
|
||||
## 8. Phased implementation (branch: `feat/docker-session-mode`)
|
||||
|
||||
Each phase is independently testable; per CLAUDE.md, end-to-end test in the real env before COM. All new docker IO paths carry `const IS_TEST_MODE = !!process.env.VITEST;` and no-op under it; the pure command builders are tested directly.
|
||||
|
||||
- Phase 0: base image + engine probe. Author `docker/agent.Dockerfile` (OpenShift arbitrary-uid HOME) and `scripts/build-agent-image.mjs` (build or pull the base image; digest recorded). Add `checkDockerAvailable`/`checkDockerTmuxAvailable`/`containerApiUrl`/`hostGatewayAlias` (IS_TEST_MODE no-op) and `GET /api/docker/status`. Test: probe stub returns available/caps/Desktop flags under VITEST; `containerApiUrl` preserves scheme+port and swaps host per engine; status route returns the envelope.
|
||||
- Phase 1: types + storage + schemas. Add all types (Section 3), `src/docker-hosts.ts`, `DockerHostSchema`/`DockerCaseLinkSchema`. Test: `docker-hosts.test.ts` (round-trip incl. `lastClaudeSessionId`, display path, config-hash stability); `docker-exec-options.test.ts` (schema rejects `$`/backtick in image/workdir/container).
|
||||
- Phase 2: tmux-manager builders. Add `DOCKER_TMUX_SOCKET`, `dockerTmuxSessionName`, `buildDockerLaunchCommand` (resume-aware, image-check, env-prime), `buildDockerKillCommand`; wire the two ternaries + two cd-skips + Strategy 3c; harden `reconcileSessions` against docker hard-delete. Test (pure strings): adopt-proof name fails `SAFE_MUX_NAME_PATTERN`; image-check precedes create; `new-session -A` idempotent; resume flag present only when a resume id is passed; `--pull=never` present; instance label present; escaping survives `bash -c` -> `docker exec` -> `sh -lc` -> tmux WITH a host workspace path containing spaces.
|
||||
- Phase 3: session.ts + mux + recovery. Add `_docker` + `resumeSessionId` threading, in-container cliVersion probe, `resolveMuxAttachCwd`, mux-interface fields, `restoreMuxSessions` passthrough, instance-scoped reaper wiring, claudeSessionId -> `DockerCase.lastClaudeSessionId` persistence, unified flag. Test: `toState()` emits docker; a persisted docker session round-trips through mux/state; a relaunch injects the persisted resume id (mock mux); reaper only targets this instance's orphaned containers.
|
||||
- Phase 4: routes + first real e2e. case-routes CRUD + listing + drift-recreate; session-routes quick-start branch (scaffolding RUNS, local-availability guards skip, model accepted, effort/config rejected). Manual e2e on a real docker host: docker-host create -> docker-link -> quick-start; confirm the pane runs `claude` in the container, files land host-owned, a Codeman restart reattaches the SAME live agent, and a `docker stop` followed by relaunch RESUMES the conversation.
|
||||
- Phase 5: hooks connectivity + installation. host-gateway (per engine), derived `CODEMAN_API_URL`, hook-secret mount, `CODEMAN_SESSION_ID`/`CODEMAN_MUX` exec-env + tmux setenv, host-guard allowlist, and the scaffolding write into the real workspace. Manual e2e: trigger a permission prompt from inside the container and confirm it surfaces; verify hook payloads carry the right session id. If deferred, ship docker as explicitly hook-degraded and verify output-based idle detection through the docker-exec PTY.
|
||||
- Phase 6: export/import + GC + disk safety. quiesce+pause span, free-space precheck, commit+save+gzip + workspace tar + manifest + streaming download; sealed-mode refuse-or-scrub; retention/auto-prune; import with checksum validation + traversal guard + quarantined re-tag; drift-recreate; boot reaper; `runWithConversionLimit` cap; `docker rmi` in finally. Manual e2e: export, `docker load` on a second machine (or fresh case), import, confirm toolchain + workspace restored and NO creds present; attempt a sealed full-image export and confirm it is refused-or-scrubbed; attempt a `../` bundle and confirm it is rejected.
|
||||
- Phase 7: frontend. Docker tab, `linkDockerCase`, run wiring, case-picker labels, panels search, caps-advisory + scaffold-warning + effort-inert notes. Verify with Playwright (`waitUntil: 'domcontentloaded'`, 3-4s settle) that the Docker tab renders and a linked docker case appears in the picker.
|
||||
- Phase 8: docs + COM. Update CLAUDE.md (a "Docker cases" Key Pattern paragraph mirroring remote-SSH, plus the new state files, routes counts, and the resume/durability model), `docs/docker-cases.md`, then COM per the standard flow.
|
||||
|
||||
## 9. Test plan
|
||||
|
||||
- Unit (pure, CI-safe, mirror `test/remote-hosts.test.ts` / `test/remote-ssh-options.test.ts`):
|
||||
- `test/docker-hosts.test.ts`: storage round-trip (incl. `lastClaudeSessionId`), `dockerDisplayPath`, `defaultDockerCommandForMode`, `toSessionDocker`, `containerApiUrl` (http/https, custom port, docker vs podman gateway), config-hash stability/drift, `buildDockerCreateArgs` flag ordering (cap-drop/no-new-privileges/memory==memory-swap/instance-label/`--pull=never` present; host/privileged/socket absent; per-engine uid vs `--userns=keep-id`).
|
||||
- `test/docker-exec-options.test.ts`: `buildDockerLaunchCommand`/`buildDockerKillCommand` string shape and escaping through `bash -c` -> `docker exec` -> `sh -lc` -> tmux, including a workspace path with spaces; resume flag present only with a resume id; image-presence check precedes create; `dockerTmuxSessionName` fails `SAFE_MUX_NAME_PATTERN`; schema rejects `$`/backtick in image/workdir/container/name; `linkDockerCase`-shaped bodies with omitted optionals validate (no `null` on the wire).
|
||||
- Probe no-op: `checkDockerAvailable`/`checkDockerTmuxAvailable`/`probeDockerCliVersion` return canned values under VITEST and never spawn.
|
||||
- Integration (route tests via `app.inject()`, docker no-op'd): `/api/docker-hosts` CRUD; `/api/cases/docker-link` dup-check + broadcast; `GET /api/cases` includes the docker case with `location: 'docker'`; `/api/quick-start` docker branch rejects `envOverrides`/`effort`/config but ACCEPTS `modelOverride`, runs the workspace-scaffolding path, and constructs a session with `docker` set + seeded resume id; `DELETE /api/cases/:name` docker-unlink; export refuse-or-scrub for sealed; import traversal rejection; reaper instance-scoping (label filter). Pick a unique port only if a live-server test is added (search `const PORT =`; 3150+).
|
||||
- Manual end-to-end (real docker daemon, the mandatory "always end-to-end test" gate): build the base image; link a docker case; quick-start `claude`; verify OAuth via the mounted `~/.claude`, transcript correlation (subagent/workflow watchers show the session), host-owned files, and a working permission-prompt hook; reattach after a Codeman PROCESS restart (SAME live agent); `docker stop` then relaunch and confirm conversation RESUME; reboot-equivalent (daemon restart) and confirm boot recovery recreates+resumes; change the host's memory/image and confirm the drift-recreate prompt fires; export (convenient) and confirm the tar `docker load`s with no creds; attempt a sealed full-image export and confirm refuse-or-scrub; import into a fresh case; delete the case and confirm `docker rm -f` plus instance-scoped reaper GC; confirm a docker-down state surfaces a docker-specific error and does NOT trip the generic PTY-exit breaker.
|
||||
|
||||
## 10. Open decisions for the user
|
||||
|
||||
1. Credential + blast-radius posture (combined). Convenient default bind-mounts host `~/.claude` etc. RW AND an arbitrary host workspace RW into a network-enabled container, so container-run agent code can read/modify those host trees and reach the network at the same time. Recommended: convenient default plus a per-host SEALED opt-in (`mountCredentials:false` + `network:none`) for untrusted work. Please confirm you accept the combined arbitrary-workspace-plus-egress-plus-host-creds posture for the default profile (it is still a net improvement over today's on-host skip-permissions execution).
|
||||
2. Base image ownership, registry, and freshness. The `codeman/agent:base` placeholder implies a Docker Hub org the project may not own. Pick the real registry/namespace (GHCR under the repo is the natural fit), decide digest pinning, and set a REBUILD CADENCE so agents are not stuck on a stale baked `claude` (the in-container version probe surfaces staleness, but something must trigger rebuilds). Choose: pull a pinned published image, build locally on first use via `scripts/build-agent-image.mjs`, or both.
|
||||
3. Container CWD strategy. Mirror the host workspace path inside the container (recommended: makes transcript projHash correlate, file features and resume capture work) vs a fixed `/workspace` (simpler mount, breaks watcher correlation). Please confirm the mirror approach.
|
||||
4. Hooks in the MVP AND workspace scaffolding. Making docker hooks fire requires WRITING `.claude/settings.local.json` (and the CLAUDE.md scaffold) into the user's REAL linked host directory, a behavioral shift from "link a dir" to "link and scaffold a dir." Choose: wire hooks + scaffolding now (Phase 5, recommended, and it also enables the model picker), or ship docker as explicitly hook-degraded (no permission prompts / hook-idle) for v1 and add later. Confirm you are OK with Codeman mutating the linked host workspace.
|
||||
5. Session-kill teardown and RESUME (reframed honestly). `docker stop` on session kill is not merely "free RAM vs instant reattach": it destroys the in-container live agent, and the conversation survives ONLY because the next launch runs `--resume` from the bind-mounted transcript. Choose: keep the container running (costs RAM, preserves the exact live in-flight agent) vs stop and rely on `--resume` (frees RAM, may lose uncommitted in-flight tool state). Case-delete always `docker rm -f`.
|
||||
6. Rootless enforcement posture. Under rootless without cgroup-v2 systemd delegation, `--memory`/`--cpus`/`--pids-limit` are SILENTLY ignored. Choose: REQUIRE delegation (refuse to link a host that cannot enforce caps) or ship-with-warning ("resource caps are advisory on your engine"). The probe reports `capsEnforced` either way.
|
||||
7. Default resume behavior. Should a re-linked or re-run docker case default to resuming its last conversation (`resumeOnStart:true`, using `DockerCase.lastClaudeSessionId`) rather than starting clean? This is the crux of making the durability story real and is the recommended default, but it changes user-visible behavior (a new session in an existing case continues the prior conversation).
|
||||
8. Export defaults and disk budget. Default export button: workspace-only (fast, small, files-only, recommended for 24h+ runs) vs full-image (reproducible env, multi-GB). Also set the retention cap (max retained exports), the auto-prune policy, and the free-space threshold below which export is refused (a full `/var/lib/docker` breaks EVERY session on the host, not just docker ones).
|
||||
9. Remote docker daemon (`-H ssh://...` / `--context`). Support in the MVP (composes with remote hosts, adds host-root trust surface) or local-daemon-only first.
|
||||
10. Podman parity depth. Full `--userns=keep-id` plus Quadlet boot-persistence, or Docker-first with Podman as best-effort and boot-persistence via Codeman's idempotent create-if-missing only. Note the podman host alias is `host.containers.internal`, already handled per engine.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Docker cases
|
||||
|
||||
Run a case inside an **isolated Docker container** instead of directly on the host. Any number of Codeman sessions can share one container (it is scoped to the case, not the session), so a whole project lives in a sandbox with its own network, resource caps, and filesystem, and you can **export the container to move it to another machine**.
|
||||
|
||||
Docker mode is a **location overlay on cases**, the direct analog of [remote SSH cases](./remote-hosts.md): where a remote case runs a local tmux pane doing `ssh host` into a durable remote tmux server, a docker case runs a local tmux pane doing `docker exec -it` into a durable **in-container** tmux server. It is not a separate `SessionMode`, so `claude` / `shell` / `opencode` / `codex` / `gemini` all work inside the container.
|
||||
|
||||
## One-time setup: build the base image
|
||||
|
||||
The container needs a base image with the agent toolchain (node, the CLIs, git, tmux). Build it locally once:
|
||||
|
||||
```bash
|
||||
node scripts/build-agent-image.mjs # builds codeman/agent:base
|
||||
# options: --engine docker|podman --image <ref> --no-cache
|
||||
```
|
||||
|
||||
The image is **secret-free**: credentials are delivered at runtime (bind mounts or `docker exec --env`), never baked in, so exports never leak them.
|
||||
|
||||
## Quickest path: one-click "Run in Docker"
|
||||
|
||||
On the **New case → Create New** tab there's a **🐳 Run in an isolated Docker container** checkbox. Checking it alone is enough: Codeman creates the case folder in `~/codeman-cases/<name>`, spins up a hardened container with sensible defaults (auto-provisioning a shared `default` host), and starts the session inside it. No host/image/network fields to fill in.
|
||||
|
||||
Click the checkbox's **Container settings** to optionally tweak the predefined defaults, including a **Template** picker:
|
||||
|
||||
| Template | Memory | CPUs | GPUs |
|
||||
|----------|--------|------|------|
|
||||
| Small | 2 GB | 1 | none |
|
||||
| Medium (default) | 4 GB | 2 | none |
|
||||
| Large | 8 GB | 4 | none |
|
||||
| GPU | 8 GB | 4 | all (needs the NVIDIA container toolkit) |
|
||||
|
||||
**Disk is elastic** — the container's storage grows automatically as data flows in; there is no fixed cap (bounded only by host disk). Any tweaked setting creates a dedicated per-case host so it never changes the shared `default`.
|
||||
|
||||
## Create a docker case (full control)
|
||||
|
||||
App → **New case → Docker** tab:
|
||||
|
||||
- **Case Name** / **Workspace Path**: the workspace is a real HOST directory bind-mounted into the container at the same path. Codeman scaffolds `CLAUDE.md` + `.claude/settings.local.json` (hooks) into it, and file previews / attachments work on the real bytes.
|
||||
- **Host ID**: a reusable docker host profile (image, network, resources). Reuse the same ID across cases to share settings.
|
||||
- **Network**: `bridge` (internet on, default), `none` (fully isolated), or a `custom` bridge.
|
||||
- **Advanced**: memory / CPU caps, **Mount host credentials** (on = your existing `~/.claude` login just works; off = a sealed sandbox you log into inside the container), **Resume last conversation on relaunch**.
|
||||
|
||||
Then run it like any case (Run Claude / Run Shell / …). The first launch creates the container (`codeman-case-<name>`); subsequent sessions attach to the same one.
|
||||
|
||||
Equivalent API:
|
||||
|
||||
```bash
|
||||
curl -X POST localhost:3000/api/docker-hosts -d '{"id":"local","label":"Local","image":"codeman/agent:base"}'
|
||||
curl -X POST localhost:3000/api/cases/docker-link -d '{"name":"sandbox","hostId":"local","hostWorkspacePath":"/home/you/projects/sandbox"}'
|
||||
curl -X POST localhost:3000/api/quick-start -d '{"caseName":"sandbox","mode":"claude"}'
|
||||
```
|
||||
|
||||
## Lifecycle
|
||||
|
||||
- **Reconnect after a Codeman restart** lands back in the same live agent (the in-container tmux survives).
|
||||
- **Container stop / host reboot** recreates the container and, when a resume id was captured, **resumes** the last conversation from the bind-mounted transcript.
|
||||
- **Killing one session** only kills that session's in-container tmux session; the shared container stays up for sibling sessions.
|
||||
- **Deleting the case** `docker rm -f`s the container (the bind-mounted workspace on the host survives). An instance-scoped boot reaper removes containers whose case is gone.
|
||||
|
||||
## Isolation & security
|
||||
|
||||
Every container runs hardened: `--cap-drop ALL`, `--security-opt no-new-privileges`, non-root (`--user <hostUid>:0` so workspace files stay host-owned), `--pids-limit`, `--memory` == `--memory-swap`, `--init`. Never `--privileged`, never the docker socket. The default **convenient** profile bind-mounts host credential dirs read-write so the common login just works (creds stay on the host, never captured by `docker commit`); the **sealed** profile (`mountCredentials:false` + `network:none`) is the opt-in for genuinely untrusted work.
|
||||
|
||||
Rootless engines without cgroup-v2 systemd delegation cannot enforce resource caps; linking such a host warns that caps are advisory.
|
||||
|
||||
## Export / Import (move to another machine)
|
||||
|
||||
**Export** (from the Docker tab, or `POST /api/docker-cases/:name/export`): choose
|
||||
|
||||
- **Full image + workspace**: `docker commit` the container to an image, `docker save` it, tar the workspace, and a manifest, all into one portable `<case>-<ts>.codeman-container.tgz` (the whole toolchain, installed packages, and files). Runs in the background; you are notified when the bundle is ready.
|
||||
- **Workspace only**: just the project files (fast, small).
|
||||
|
||||
The container is paused across the capture so the image and workspace are consistent; a full `/var/lib/docker` is guarded against with a free-space precheck; the intermediate image is always cleaned up.
|
||||
|
||||
**Import** (`POST /api/docker-cases/import`, or the Manage tab): copy the `.tgz` onto the new machine's `~/.codeman/docker-exports/`, then import it into a new case. The manifest and per-member SHA-256 checksums are validated, the workspace tar is extracted with a path-traversal guard, and the image is `docker load`ed and **re-tagged into a quarantined namespace** (`codeman/imported-<case>:<ts>`) so it never overwrites a local tag. The destination supplies its own credentials, so nothing secret crosses machines.
|
||||
|
||||
`GET /api/docker-exports` lists bundles; `GET /api/docker-exports/:filename` downloads one; `DELETE` removes one.
|
||||
|
||||
## Hooks require the server to be reachable from the container
|
||||
|
||||
In-container hooks (permission events, hook-based idle/stop/task notifications) POST to `CODEMAN_API_URL`, which is derived as `https://host.docker.internal:<port>` (`host.docker.internal` → the docker bridge gateway, e.g. `172.17.0.1`, via `--add-host …:host-gateway`). For that callback to succeed, the Codeman server must be **listening on an interface the container can reach**.
|
||||
|
||||
- If Codeman binds **loopback-only** (`127.0.0.1`, the default and the production systemd config), a container reaching `172.17.0.1:<port>` cannot connect, so by default **in-container hooks do not fire**. The session still works fully: idle/stop detection falls back to **output-based** detection through the `docker exec` PTY (which always works), and claude runs with `--dangerously-skip-permissions` so there are no permission prompts to forward anyway.
|
||||
- **To enable in-container hooks on a loopback-only server, set `CODEMAN_DOCKER_BRIDGE_HOOKS=1`** (env). Codeman then starts a SECOND listener bound to the docker bridge gateway (`172.17.0.1`, auto-detected; override with `CODEMAN_DOCKER_BRIDGE_HOST`) that serves **only the hook endpoints** (`/api/hook-event`, `/api/status-telemetry`) and delegates them into the same secret-gated pipeline. The bridge is host-internal (containers + host, not the LAN), and every other path returns `403`, so this does not widen your network exposure. Add `Environment=CODEMAN_DOCKER_BRIDGE_HOOKS=1` to the systemd unit and restart.
|
||||
- Alternatively, bind `0.0.0.0` **with `CODEMAN_PASSWORD` set** (exposes on the LAN too).
|
||||
|
||||
The host-gateway mapping, `CODEMAN_API_URL` derivation, host-guard allowlist, and hook-secret mount are all wired correctly; `CODEMAN_DOCKER_BRIDGE_HOOKS` closes the last gap for loopback-only servers.
|
||||
|
||||
## Notes & limits
|
||||
|
||||
- Requires Docker (or Podman) with a reachable daemon; tmux must be present in the base image (a hard prerequisite, probed at link time).
|
||||
- Per-session `envOverrides` / `effort` / per-CLI config are rejected for docker cases (they do not cross into the container); configure the container via the docker host's per-mode command override instead.
|
||||
- macOS Docker Desktop takes a dedicated uid path (the baked image uid; memory caps are subject to the VM ceiling).
|
||||
|
||||
Design + rationale: [`docker-cases-plan.md`](./docker-cases-plan.md).
|
||||
@@ -0,0 +1,72 @@
|
||||
# Reliable input delivery (exactly-once, durable)
|
||||
|
||||
## The bug this fixes
|
||||
|
||||
With local echo on, pressing Enter cleared the overlay and then sent the prompt
|
||||
over the WebSocket **fire-and-forget** (`ws.send({t:'i',d})`). On a flaky link
|
||||
(e.g. a moving train) the socket is frequently *half-open*: `readyState === OPEN`
|
||||
so `ws.send()` does **not** throw, but the underlying TCP is dead, so the frame is
|
||||
silently discarded. Nothing was enqueued (the send "succeeded"), the on-screen
|
||||
prompt was already wiped, and `navigator.onLine` stays `true` — so a long typed
|
||||
prompt vanished with no trace and no resend.
|
||||
|
||||
## The guarantee
|
||||
|
||||
Every byte of user input is **recorded durably before delivery** and **only
|
||||
dropped once the server ACKs it** — so a half-open socket, a reconnect, or a page
|
||||
reload can never lose input. Redelivery is **exactly-once**: the server applies
|
||||
each `(clientId, seq)` at most once, so a resend can't type the prompt twice.
|
||||
|
||||
## How it works
|
||||
|
||||
### Client (`app.js`)
|
||||
|
||||
- A stable **`clientId`** (`localStorage['codeman:clientId']`) identifies this
|
||||
browser to the server's dedup across reconnects and reloads.
|
||||
- Each input frame gets a **monotonic per-session `seq`**. Frame records
|
||||
(`{seq,data,useMux,ts,tries,sentAt}`) live in `_pendingDeliveries`
|
||||
(`Map<sessionId, record[]>`), persisted (debounced, + flushed on `pagehide`/
|
||||
`visibilitychange`) to `localStorage['codeman:pendingInput']`. The seq counters
|
||||
persist too, so seqs stay monotonic across reloads (never reset — a reset would
|
||||
let the server treat fresh input as an already-applied duplicate).
|
||||
- **Delivery** (`_drainSession`):
|
||||
- **WS path** — when the socket is `OPEN` for the session, send each not-yet-sent
|
||||
record (`sentAt === 0`) in seq order over the single ordered stream. Records
|
||||
stay pending until the server's `{t:'ia',seq}` ACK removes them.
|
||||
- **POST path** — when no WS, POST records in order, awaiting each (the HTTP 2xx
|
||||
*is* the ACK). A 404/410 (session gone) drops the record rather than retry
|
||||
forever.
|
||||
- **Half-open recovery** (`_redeliverSweep`, every 2s): if the active WS session's
|
||||
oldest record is unacked past `_reliableAckTimeoutMs` (4s), the socket is assumed
|
||||
dead — `ws.close()` forces a fast reconnect; `onopen` (`_onWsReady`) resets
|
||||
`sentAt = 0` and re-sends everything pending. Also re-drains background sessions
|
||||
over POST, and fires on SSE-reconnect / `online`.
|
||||
- The connection indicator shows pending count/bytes (`_pendingBytes`).
|
||||
|
||||
### Server
|
||||
|
||||
- **`Session.shouldApplyInput(clientId, seq)`** — returns `true` exactly once per
|
||||
`(clientId, seq)`: the first time a seq strictly greater than that client's
|
||||
last-applied is seen. A replayed/lower seq returns `false`. Bounded MRU map
|
||||
(`MAX_INPUT_DEDUP_CLIENTS = 256`).
|
||||
- **WS route** (`ws-routes.ts`) — parses optional `cid`/`seq` on `{t:'i'}`; applies
|
||||
via `shouldApplyInput` (skips a duplicate, still ACKs with `{t:'ia',seq}` so the
|
||||
client drops it). Untagged frames apply unconditionally (no behavior change).
|
||||
- **POST route** (`/api/sessions/:id/input`) — optional `seq`/`clientId` in
|
||||
`SessionInputWithLimitSchema`; a deduped duplicate returns 200 without writing
|
||||
(the 200 is the client's ACK). `curl`/legacy callers omit the fields and always
|
||||
apply.
|
||||
|
||||
## Known limitation
|
||||
|
||||
Dedup state is in-memory on the server. A **server restart** between a write and
|
||||
the client's redelivery of that same seq could re-apply it (a rare duplicate).
|
||||
This is a deliberate trade-off: favor *never losing input* over a rare duplicate
|
||||
across the narrow restart window.
|
||||
|
||||
## Tests
|
||||
|
||||
- `test/reliable-input-dedup.test.ts` — `Session.shouldApplyInput` exactly-once
|
||||
semantics (monotonic, per-client, gap-tolerant, eviction-safe).
|
||||
- `test/routes/session-routes.test.ts` — POST `/input` applies a tagged
|
||||
`(clientId, seq)` once on redelivery; untagged input always applies.
|
||||
@@ -30,7 +30,8 @@ an explicit, guided opt‑in.
|
||||
7. [Supply‑chain & build‑asset hardening](#7-supplychain--buildasset-hardening-cod28)
|
||||
8. [Multi‑instance isolation](#8-multiinstance-isolation)
|
||||
9. [Transport security headers](#9-transport-security-headers)
|
||||
10. [Quick reference](#10-quick-reference)
|
||||
10. [Docker container isolation](#10-docker-container-isolation)
|
||||
11. [Quick reference](#11-quick-reference)
|
||||
|
||||
---
|
||||
|
||||
@@ -471,7 +472,22 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
|
||||
|
||||
---
|
||||
|
||||
## 10. Quick reference
|
||||
## 10. Docker container isolation
|
||||
|
||||
Docker cases (1.4.0) run a session inside a per‑case container instead of on the host. The security posture:
|
||||
|
||||
- **Hardened create flags, always** — `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit` (fork‑bomb guard), `--memory` == `--memory-swap` (a real OOM cap), `--init`, and non‑root: `--user <hostUid>:0` on Linux (host uid → workspace files stay host‑owned; GID 0 keeps `$HOME` writable), `--userns=keep-id` on rootless Podman. **Never** `--privileged`, and **never** the docker socket — the pure builder in `docker-hosts.ts` cannot emit them and the schema cannot represent them.
|
||||
- **Credentials never enter an image** — the convenient default bind‑mounts host cred dirs (`~/.claude`, `~/.codex`, `~/.gemini`, `~/.config/{gcloud,opencode}`) read‑write. Bind mounts are physically excluded from `docker commit`, so exported images are secret‑free. API‑key CLIs get their key as an exec‑time NAME‑ONLY `--env OPENAI_API_KEY` (no `=value`, no `ps` leak, never committed); a create‑time `-e` for a secret is never used. The **sealed** profile (`mountCredentials:false` + `network:none`) drops the host mounts; full‑image export is then refused (an in‑container login would ride the committed layer) unless a pre‑commit scrub is opted into.
|
||||
- **Blast radius — accept it explicitly** — the convenient profile mounts an arbitrary host workspace RW plus the host credential dirs RW into a network‑enabled container, so container‑run agent code can read/modify those host trees and reach the network at once. Still a net improvement over today's on‑host `--dangerously-skip-permissions` execution; use the sealed profile for genuinely untrusted work.
|
||||
- **Import is untrusted‑bundle‑safe** — `/api/docker-cases/import` validates the manifest + per‑member SHA‑256 before extraction, rejects absolute / `..` tar members (traversal guard), and re‑tags the loaded image into a quarantined namespace so it can never overwrite `codeman/agent:base` or a pre‑existing tag.
|
||||
- **Host guard & the bridge‑hooks listener** — in‑container hook callbacks carry `Host: host.docker.internal` / `host.containers.internal`; both are on the always‑on host‑header allowlist (`DOCKER_HOST_GATEWAY_ALIASES`) and resolve to the host only from inside a container netns, so they are not a browser DNS‑rebinding surface. On a loopback‑only server, in‑container hooks are opt‑in via `CODEMAN_DOCKER_BRIDGE_HOOKS=1`, which binds a SECOND listener on the docker bridge gateway serving **only** the hook endpoints (every other path → `403`) into the same hook‑secret‑gated pipeline. The bridge is host‑internal (containers + host), not the LAN, so it does not widen network exposure; the hook secret is bind‑mounted read‑only and referenced by path.
|
||||
- **Instance isolation** — every managed container is labeled `codeman.instance=<CODEMAN_INSTANCE>`; the boot reaper reaps orphans of its OWN instance only, so a beta never removes a prod container. The in‑container tmux socket (`-L codeman-docker`) + session name (`codeman-dkr-*`) deliberately fail a nested Codeman's discovery pattern.
|
||||
|
||||
Full feature guide: [`docker-cases.md`](docker-cases.md).
|
||||
|
||||
---
|
||||
|
||||
## 11. Quick reference
|
||||
|
||||
| Env / flag | Effect |
|
||||
|------------|--------|
|
||||
@@ -482,6 +498,8 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
|
||||
| `--https` | Enable TLS (adds HSTS) |
|
||||
| `CODEMAN_INSTANCE` | Scope tmux socket + data dir for isolation |
|
||||
| `CODEMAN_GESTURE=1` | Make the gesture overlay available (widens CSP) |
|
||||
| `CODEMAN_DOCKER_BRIDGE_HOOKS=1` | Serve the hook endpoints on the docker bridge gateway (host‑internal, hooks‑only, `403` elsewhere) so in‑container hooks reach a loopback‑bound server — see §10 |
|
||||
| `CODEMAN_DOCKER_BRIDGE_HOST` | Override the bridge gateway IP the hooks listener binds (default: auto‑detect) |
|
||||
|
||||
**Audit log:** session lifecycle and server start are recorded in
|
||||
`~/.codeman/session-lifecycle.jsonl`.
|
||||
|
||||
Generated
+31
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.1.11",
|
||||
"version": "1.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.1.11",
|
||||
"version": "1.4.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -28,6 +28,8 @@
|
||||
"chokidar": "^3.6.0",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^5.8.5",
|
||||
"heic-decode": "^2.1.0",
|
||||
"jpeg-js": "^0.4.4",
|
||||
"node-pty": "^1.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^14.0.0",
|
||||
@@ -7023,6 +7025,18 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/heic-decode": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz",
|
||||
"integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"libheif-js": "^1.19.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
|
||||
@@ -7481,6 +7495,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/jpeg-js": {
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
|
||||
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||
@@ -7664,6 +7684,15 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libheif-js": {
|
||||
"version": "1.19.8",
|
||||
"resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.19.8.tgz",
|
||||
"integrity": "sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ==",
|
||||
"license": "LGPL-3.0",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/light-my-request": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.1.11",
|
||||
"version": "1.4.1",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -69,6 +69,8 @@
|
||||
"chokidar": "^3.6.0",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^5.8.5",
|
||||
"heic-decode": "^2.1.0",
|
||||
"jpeg-js": "^0.4.4",
|
||||
"node-pty": "^1.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^14.0.0",
|
||||
|
||||
@@ -17,6 +17,13 @@
|
||||
// • Panel "re-grab" — pinch an existing floating panel and move it anywhere;
|
||||
// release over the tab strip to re-dock it (panel goes away, the tab stays).
|
||||
// This is the capability the old OS-window detach lost.
|
||||
// • Agent-window "grab-to-move" — pinch any floating *subagent* or *ultracode*
|
||||
// run/transcript window (the dashboard's own `.subagent-window` /
|
||||
// `.ultracode-window` floats) and move it anywhere. These windows stay owned
|
||||
// by app.js — we only nudge their `style.left/top` and ask app.js to redraw
|
||||
// the glowing connector line back to their session tab (its redraw reads live
|
||||
// rects, so the line tracks without us touching app.js internals). This is the
|
||||
// multi-monitor verb that lets these windows cross the physical monitor seam.
|
||||
// • Button "tap" — pinch over a toolbar button (Run / Run Shell) and release
|
||||
// in place → fires the button's real click handler. Drift too far first and
|
||||
// it's treated as a stray move, not a tap.
|
||||
@@ -36,12 +43,29 @@ import type { HandState } from '../gesture/types.ts';
|
||||
declare global {
|
||||
interface Window {
|
||||
__codemanGesture?: GestureBridge;
|
||||
/** The Codeman dashboard singleton (app.js, `window.app`). The gesture layer
|
||||
* reaches into it to redraw the floating-window connector lines and bump a
|
||||
* grabbed window's z-order while moving the subagent / ultracode windows.
|
||||
* Loosely typed — only the few members we touch. */
|
||||
app?: {
|
||||
updateConnectionLines?: () => void;
|
||||
saveSubagentWindowStates?: () => void;
|
||||
subagentWindowZIndex?: number;
|
||||
ultracodeWindowZIndex?: number;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const TAB_SELECTOR = '.session-tab';
|
||||
/** An in-page floating session panel this layer spawned — re-grabbable to move. */
|
||||
const PANEL_SELECTOR = '.cg-float';
|
||||
/** The dashboard's own floating agent windows (subagent runs + ultracode run and
|
||||
* transcript windows). All three carry one of these classes, position via
|
||||
* `style.left/top`, and redraw their connector line from
|
||||
* `window.app.updateConnectionLines()` — so the hand can pick one up and move it
|
||||
* without app.js knowing. (`.ultracode-agent-window` also carries
|
||||
* `.ultracode-window`, so this matches it too.) */
|
||||
const WINDOW_SELECTOR = '.subagent-window, .ultracode-window';
|
||||
/** The session-tab strip; dropping a moved panel over it re-docks the session. */
|
||||
const DOCK_SELECTOR = '.session-tabs';
|
||||
/** Toolbar buttons a pinch can "tap": Run (#runBtn → app.run()) and Run Shell
|
||||
@@ -93,6 +117,17 @@ type Grab =
|
||||
dy: number;
|
||||
/** Cursor currently over the tab strip → releasing re-docks. */
|
||||
overDock: boolean;
|
||||
}
|
||||
| {
|
||||
/** A dashboard-owned floating agent window (subagent / ultracode) being
|
||||
* moved. We never remove or re-parent it — just reposition + redraw its
|
||||
* connector. The element ref can go stale mid-grab (SSE reconnect tears
|
||||
* ultracode windows down), so every move guards on `el.isConnected`. */
|
||||
kind: 'window';
|
||||
el: HTMLElement;
|
||||
/** Cursor→window-top-left offset at grab, so it doesn't snap. */
|
||||
dx: number;
|
||||
dy: number;
|
||||
};
|
||||
|
||||
/** Live state for one hand pinching a toolbar button (Run / Run Shell). */
|
||||
@@ -122,6 +157,8 @@ class GestureBridge {
|
||||
private taps = new Map<string, Tap>();
|
||||
/** Live floating panels, keyed by session id (idempotent per id). */
|
||||
private floats = new Map<string, FloatingPanel>();
|
||||
/** rAF coalescing for connector-line redraws while dragging an agent window. */
|
||||
private connectorRedrawScheduled = false;
|
||||
|
||||
constructor() {
|
||||
injectStyles();
|
||||
@@ -187,7 +224,7 @@ class GestureBridge {
|
||||
await this.gc.start();
|
||||
this.running = true;
|
||||
this.button.classList.add('on');
|
||||
this.status.textContent = 'on — pinch a tab or button';
|
||||
this.status.textContent = 'on — pinch a tab, window, or button';
|
||||
} catch (err) {
|
||||
// Surface the *real* cause: MediaPipe/Emscripten can throw a non-Error
|
||||
// (number/string), so `(err as Error).message` was logging "undefined".
|
||||
@@ -242,6 +279,22 @@ class GestureBridge {
|
||||
}
|
||||
}
|
||||
|
||||
// A dashboard-owned floating agent window (subagent / ultracode run or
|
||||
// transcript) → pick it up and move it. Priority below cg-float panels
|
||||
// (which sit far above), above tabs/buttons. We grab anywhere on the window
|
||||
// (not just its titlebar) since the hand is choosing the whole window.
|
||||
const win = this.hitClosest(x, y, WINDOW_SELECTOR);
|
||||
if (win) {
|
||||
const rect = win.getBoundingClientRect();
|
||||
// Match app.js's own drag: drop any bottom-anchor so left/top take effect.
|
||||
win.style.bottom = 'auto';
|
||||
win.classList.add('cg-win-grabbed');
|
||||
this.bringWindowToFront(win);
|
||||
this.grabs.set(hand, { kind: 'window', el: win, dx: x - rect.left, dy: y - rect.top });
|
||||
this.status.textContent = 'moving window';
|
||||
return;
|
||||
}
|
||||
|
||||
// A session tab → grab-and-pull-out into a floating panel (ghost follows).
|
||||
const tab = this.hitClosest(x, y, TAB_SELECTOR);
|
||||
const id = tab?.dataset.id;
|
||||
@@ -292,12 +345,16 @@ class GestureBridge {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (grab?.kind === 'window') {
|
||||
this.moveWindow(grab.el, x - grab.dx, y - grab.dy);
|
||||
return;
|
||||
}
|
||||
// A button pinch that drifts too far is a stray move, not a tap — cancel it.
|
||||
const tap = this.taps.get(hand);
|
||||
if (tap && Math.hypot(x - tap.ox, y - tap.oy) > TAP_CANCEL_PX) {
|
||||
tap.el.classList.remove('cg-tap-armed');
|
||||
this.taps.delete(hand);
|
||||
this.status.textContent = 'on — pinch a tab or button';
|
||||
this.status.textContent = 'on — pinch a tab, window, or button';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,6 +376,23 @@ class GestureBridge {
|
||||
else this.flash('placed');
|
||||
return;
|
||||
}
|
||||
if (grab?.kind === 'window') {
|
||||
this.grabs.delete(hand);
|
||||
grab.el.classList.remove('cg-win-grabbed');
|
||||
// Clear the coalescer so the final placement always redraws, even if a
|
||||
// mid-drag rAF was throttled (tab briefly backgrounded) and left it latched.
|
||||
this.connectorRedrawScheduled = false;
|
||||
this.redrawWindowConnectors();
|
||||
// Persist subagent-window positions like app.js's own drag end does
|
||||
// (a no-op for ultracode windows, which aren't position-persisted).
|
||||
try {
|
||||
window.app?.saveSubagentWindowStates?.();
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
this.flash('placed window');
|
||||
return;
|
||||
}
|
||||
// Release over the same button → fire its real click handler.
|
||||
const tap = this.taps.get(hand);
|
||||
if (tap) {
|
||||
@@ -373,6 +447,59 @@ class GestureBridge {
|
||||
float.el.style.top = `${t}px`;
|
||||
}
|
||||
|
||||
/** Move a dashboard-owned agent window by its top-left, clamped on-screen, then
|
||||
* redraw its connector line. The window self-positions via `style.left/top` and
|
||||
* app.js's connector redraw reads live rects, so this tracks without touching
|
||||
* app.js internals. Guards on `isConnected`: ultracode windows can be torn down
|
||||
* (SSE reconnect / auto-close) while still held. Clamps to `innerWidth/Height`,
|
||||
* which equals the *spanned* viewport in a multi-monitor window — so the window
|
||||
* can still travel across the physical monitor seam, just not off-screen. */
|
||||
private moveWindow(el: HTMLElement, left: number, top: number): void {
|
||||
if (!el.isConnected) return;
|
||||
const w = el.offsetWidth || 380;
|
||||
const h = el.offsetHeight || 320;
|
||||
const l = Math.min(Math.max(4, left), Math.max(4, window.innerWidth - w - 4));
|
||||
const t = Math.min(Math.max(4, top), Math.max(4, window.innerHeight - h - 4));
|
||||
el.style.left = `${l}px`;
|
||||
el.style.top = `${t}px`;
|
||||
this.redrawWindowConnectors();
|
||||
}
|
||||
|
||||
/** Ask app.js to redraw all connector lines (subagent + ultracode), coalesced to
|
||||
* one per frame so per-frame drags don't thrash. `updateConnectionLines()` is
|
||||
* itself debounced in app.js, but we rAF-gate too in case an older dashboard
|
||||
* build isn't, and to no-op cleanly when app.js isn't present (standalone). */
|
||||
private redrawWindowConnectors(): void {
|
||||
if (this.connectorRedrawScheduled) return;
|
||||
this.connectorRedrawScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.connectorRedrawScheduled = false;
|
||||
try {
|
||||
window.app?.updateConnectionLines?.();
|
||||
} catch {
|
||||
/* app.js may not expose it (standalone playground) */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Pop a grabbed window above its siblings using app.js's own z-counter, so a
|
||||
* picked-up window comes to the front like a real focus. Cosmetic + best-effort. */
|
||||
private bringWindowToFront(el: HTMLElement): void {
|
||||
const app = window.app;
|
||||
if (!app) return;
|
||||
try {
|
||||
if (el.classList.contains('ultracode-window')) {
|
||||
app.ultracodeWindowZIndex = (app.ultracodeWindowZIndex ?? 1000) + 1;
|
||||
el.style.zIndex = String(app.ultracodeWindowZIndex);
|
||||
} else {
|
||||
app.subagentWindowZIndex = (app.subagentWindowZIndex ?? 1000) + 1;
|
||||
el.style.zIndex = String(app.subagentWindowZIndex);
|
||||
}
|
||||
} catch {
|
||||
/* cosmetic only */
|
||||
}
|
||||
}
|
||||
|
||||
private positionGhost(ghost: HTMLElement, x: number, y: number): void {
|
||||
ghost.style.left = `${x}px`;
|
||||
ghost.style.top = `${y}px`;
|
||||
@@ -385,17 +512,19 @@ class GestureBridge {
|
||||
if (grab.kind === 'tab') {
|
||||
grab.ghost.remove();
|
||||
grab.tab.classList.remove('cg-grabbed');
|
||||
} else {
|
||||
} else if (grab.kind === 'panel') {
|
||||
grab.panel.el.style.pointerEvents = '';
|
||||
grab.panel.el.classList.remove('cg-float-grabbed', 'cg-redock');
|
||||
} else {
|
||||
grab.el.classList.remove('cg-win-grabbed');
|
||||
}
|
||||
}
|
||||
this.grabs.clear();
|
||||
for (const tap of this.taps.values()) tap.el.classList.remove('cg-tap-armed');
|
||||
this.taps.clear();
|
||||
document
|
||||
.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed`)
|
||||
.forEach((t) => t.classList.remove('cg-grabbed', 'cg-tap-armed'));
|
||||
.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed, .cg-win-grabbed`)
|
||||
.forEach((t) => t.classList.remove('cg-grabbed', 'cg-tap-armed', 'cg-win-grabbed'));
|
||||
}
|
||||
|
||||
private onStatus(fps: number, hands: HandState[]): void {
|
||||
@@ -491,6 +620,10 @@ function injectStyles(): void {
|
||||
.cg-status { color: #9aa0a6; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.session-tab.cg-grabbed { opacity: .35; outline: 2px dashed #4ade80; outline-offset: -2px; }
|
||||
.cg-tap-armed { outline: 2px solid #4ade80 !important; outline-offset: 2px; box-shadow: 0 0 0 4px rgba(74,222,128,.25) !important; }
|
||||
.subagent-window.cg-win-grabbed, .ultracode-window.cg-win-grabbed {
|
||||
outline: 2px solid #4ade80 !important; outline-offset: -2px;
|
||||
box-shadow: 0 12px 48px rgba(74,222,128,.5) !important;
|
||||
}
|
||||
.cg-float {
|
||||
position: fixed; left: 0; top: 0; width: ${FLOAT_W}px; height: ${FLOAT_H}px;
|
||||
z-index: ${Z}; display: flex; flex-direction: column; overflow: hidden;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build the Codeman agent base image locally (decision: "build locally on first
|
||||
* use", see docs/docker-cases-plan.md). No registry account required.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/build-agent-image.mjs [--engine docker|podman] [--image <ref>] [--no-cache]
|
||||
*
|
||||
* Defaults: engine=docker (falls back to podman if docker is absent),
|
||||
* image=codeman/agent:base
|
||||
*/
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = join(__dirname, '..');
|
||||
const DOCKERFILE = join(REPO_ROOT, 'docker', 'agent.Dockerfile');
|
||||
const DEFAULT_IMAGE = 'codeman/agent:base';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { image: DEFAULT_IMAGE, engine: undefined, noCache: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--image') args.image = argv[++i];
|
||||
else if (a === '--engine') args.engine = argv[++i];
|
||||
else if (a === '--no-cache') args.noCache = true;
|
||||
else if (a === '-h' || a === '--help') args.help = true;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function engineAvailable(engine) {
|
||||
const r = spawnSync(engine, ['--version'], { stdio: 'ignore' });
|
||||
return r.status === 0;
|
||||
}
|
||||
|
||||
function resolveEngine(preferred) {
|
||||
if (preferred) {
|
||||
if (!engineAvailable(preferred)) {
|
||||
console.error(`[build-agent-image] engine "${preferred}" not found on PATH`);
|
||||
process.exit(1);
|
||||
}
|
||||
return preferred;
|
||||
}
|
||||
if (engineAvailable('docker')) return 'docker';
|
||||
if (engineAvailable('podman')) return 'podman';
|
||||
console.error('[build-agent-image] neither docker nor podman found on PATH. Install one and retry.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
console.log('Usage: node scripts/build-agent-image.mjs [--engine docker|podman] [--image <ref>] [--no-cache]');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const engine = resolveEngine(args.engine);
|
||||
const buildArgs = ['build', '-f', DOCKERFILE, '-t', args.image];
|
||||
if (args.noCache) buildArgs.push('--no-cache');
|
||||
buildArgs.push(REPO_ROOT);
|
||||
|
||||
console.log(`[build-agent-image] ${engine} ${buildArgs.join(' ')}`);
|
||||
const child = spawn(engine, buildArgs, { stdio: 'inherit' });
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
console.log(`\n[build-agent-image] built ${args.image}. Docker cases can now launch.`);
|
||||
} else {
|
||||
console.error(`\n[build-agent-image] build failed (exit ${code}).`);
|
||||
}
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
@@ -0,0 +1,482 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* capture-readme-gifs.mjs
|
||||
*
|
||||
* Deterministic README GIFs — no real server, Claude CLI, or tmux. Reuses the
|
||||
* mock-injection pipeline from capture-readme-screenshots.mjs (static file
|
||||
* server + page.route mocks), drives a scripted timeline in the page, records
|
||||
* it with Playwright video, and converts to GIF via ffmpeg palette encoding.
|
||||
*
|
||||
* Scenes:
|
||||
* 1. subagent-demo.gif — terminal spawns 3 parallel agents; floating agent
|
||||
* windows open one by one and stream tool-call activity live (driven
|
||||
* through the real _onSubagentDiscovered/_onSubagentToolCall handlers).
|
||||
* 2. zerolag-demo.gif — side-by-side typing: instant local echo (zerolag)
|
||||
* vs bursty ~350 ms server echo, rendered with the vendored xterm.
|
||||
*
|
||||
* Usage: node scripts/capture-readme-gifs.mjs
|
||||
* SCREENSHOT_OUT_DIR=/path/to/review node scripts/capture-readme-gifs.mjs
|
||||
* Output: docs/images/ (or flat into SCREENSHOT_OUT_DIR)
|
||||
* Requires: ffmpeg
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { execSync } from 'child_process';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
PORT,
|
||||
SESSION_IDS,
|
||||
STANDARD_SESSIONS,
|
||||
buildInitPayload,
|
||||
startStaticServer,
|
||||
setupRoutes,
|
||||
injectState,
|
||||
outPath,
|
||||
RST, GRN, YEL, MAG, CYN, GRY, BOLD,
|
||||
} from './capture-readme-screenshots.mjs';
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const GIF_COLORS = 192;
|
||||
|
||||
// ─── ffmpeg conversion (palette recipe from capture-subagent-gif.mjs) ────────
|
||||
|
||||
function webmToGif(videoPath, gifPath, { ss, duration, width, fps }) {
|
||||
// One GLOBAL palette (default stats_mode=full) + ordered dither: per-frame
|
||||
// palettes (stats_mode=single:new=1) make dirty rectangles visibly mismatch
|
||||
// on flat dark UI, and error-diffusion dither shimmers between frames.
|
||||
const filters = `fps=${fps},scale=${width}:-1:flags=lanczos`;
|
||||
execSync(
|
||||
`ffmpeg -y -loglevel error -ss ${ss.toFixed(2)} -t ${duration} -i "${videoPath}" ` +
|
||||
`-vf "${filters},split[s0][s1];[s0]palettegen=max_colors=${GIF_COLORS}:reserve_transparent=0[p];` +
|
||||
`[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" "${gifPath}"`,
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Scene 1: subagent demo ──────────────────────────────────────────────────
|
||||
|
||||
const SUBAGENT_VIEWPORT = { width: 1440, height: 810 };
|
||||
|
||||
// Terminal content visible before the agents spawn
|
||||
const TERMINAL_PRESPAWN = [
|
||||
'',
|
||||
`${GRN}●${RST} Working on ${CYN}/home/arkon/codeman-cases/testcase${RST} - I'll use the ${BOLD}Task tool${RST} to spawn parallel agents.`,
|
||||
'',
|
||||
`${GRN}●${RST} ${BOLD}Read${RST}(/home/arkon/codeman-cases/testcase/CLAUDE.md)`,
|
||||
` ${GRY}░${RST} Read ${BOLD}127${RST} lines ${GRY}│${RST} ${CYN}1.2KB${RST}`,
|
||||
'',
|
||||
`${GRN}●${RST} ${BOLD}Bash${RST}(find . -name "*.ts" -not -path "*/node_modules/*" | head -20)`,
|
||||
` ${GRY}░${RST} ./src/index.ts`,
|
||||
` ${GRY}░${RST} ./src/session.ts`,
|
||||
` ${GRY}░${RST} ./src/web/server.ts`,
|
||||
` ${GRY}░${RST} ${GRY}... (17 more)${RST}`,
|
||||
'',
|
||||
`${GRN}●${RST} I'll spawn 3 parallel research agents to analyze different parts of the codebase simultaneously.`,
|
||||
'',
|
||||
].join('\r\n');
|
||||
|
||||
function makeAgent(agentId, description, startedOffsetMs) {
|
||||
return {
|
||||
agentId,
|
||||
sessionId: 'claude-sess-w1-0001',
|
||||
projectHash: 'abc123',
|
||||
filePath: `/tmp/${agentId}.jsonl`,
|
||||
startedAt: new Date(Date.now() - startedOffsetMs).toISOString(),
|
||||
lastActivityAt: Date.now(),
|
||||
status: 'active',
|
||||
toolCallCount: 0,
|
||||
entryCount: 0,
|
||||
fileSize: 4000,
|
||||
description,
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
modelShort: 'haiku',
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
parentSessionId: SESSION_IDS.w1,
|
||||
};
|
||||
}
|
||||
|
||||
// Timeline events: t (ms from scene start) + kind
|
||||
// term — write raw data to the session terminal
|
||||
// discover — register subagent + open + position its floating window
|
||||
// tool — stream a tool call into an agent window
|
||||
// msg — stream an assistant message into an agent window
|
||||
// complete — flip an agent to completed
|
||||
function buildSubagentTimeline() {
|
||||
const T = (lines) => lines.join('\r\n') + '\r\n';
|
||||
const tool = (t, agentId, name, input) => ({ t, kind: 'tool', agentId, tool: name, input });
|
||||
const msg = (t, agentId, text) => ({ t, kind: 'msg', agentId, text });
|
||||
|
||||
return [
|
||||
{
|
||||
t: 600,
|
||||
kind: 'term',
|
||||
data: T([
|
||||
`${GRN}●${RST} ${BOLD}Task${RST}(Find and document all API endpoints in src/)`,
|
||||
` ${GRY}░${RST} Spawned ${CYN}agent-001${RST} ${GRY}(haiku)${RST}`,
|
||||
'',
|
||||
]),
|
||||
},
|
||||
{
|
||||
t: 1000,
|
||||
kind: 'discover',
|
||||
agent: makeAgent('agent-001', 'Find and document all API endpoints in src/', 2000),
|
||||
x: 440, y: 45,
|
||||
},
|
||||
tool(1500, 'agent-001', 'Glob', { pattern: 'src/**/*.ts' }),
|
||||
{
|
||||
t: 2000,
|
||||
kind: 'term',
|
||||
data: T([
|
||||
`${GRN}●${RST} ${BOLD}Task${RST}(Explore and understand test structure in test/)`,
|
||||
` ${GRY}░${RST} Spawned ${CYN}agent-002${RST} ${GRY}(haiku)${RST}`,
|
||||
'',
|
||||
]),
|
||||
},
|
||||
tool(2200, 'agent-001', 'Read', { file_path: '/home/arkon/codeman/src/web/server.ts' }),
|
||||
{
|
||||
t: 2500,
|
||||
kind: 'discover',
|
||||
agent: makeAgent('agent-002', 'Explore and understand test structure in test/', 1200),
|
||||
x: 880, y: 45,
|
||||
},
|
||||
tool(3000, 'agent-002', 'Glob', { pattern: 'test/**/*.test.ts' }),
|
||||
{
|
||||
t: 3300,
|
||||
kind: 'term',
|
||||
data: T([
|
||||
`${GRN}●${RST} ${BOLD}Task${RST}(Analyze TypeScript type definitions in src/types.ts)`,
|
||||
` ${GRY}░${RST} Spawned ${CYN}agent-003${RST} ${GRY}(haiku)${RST}`,
|
||||
'',
|
||||
]),
|
||||
},
|
||||
tool(3500, 'agent-001', 'Grep', { pattern: 'app\\.get|app\\.post|app\\.delete', path: 'src/' }),
|
||||
{
|
||||
t: 3800,
|
||||
kind: 'discover',
|
||||
agent: makeAgent('agent-003', 'Analyze TypeScript type definitions in src/types.ts', 400),
|
||||
x: 660, y: 400,
|
||||
},
|
||||
tool(4100, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/test/respawn-test-utils.ts' }),
|
||||
{
|
||||
t: 4500,
|
||||
kind: 'term',
|
||||
data: T([
|
||||
`${MAG}✻${RST} ${YEL}Waiting for agents...${RST} ${GRY}(${BOLD}esc${RST}${GRY} to interrupt · 32s · ↓ 1.7k tokens · thinking)${RST}`,
|
||||
'',
|
||||
]),
|
||||
},
|
||||
tool(4700, 'agent-003', 'Read', { file_path: '/home/arkon/codeman/src/types.ts' }),
|
||||
tool(5200, 'agent-001', 'Read', { file_path: '/home/arkon/codeman/src/web/schemas.ts' }),
|
||||
tool(5600, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/config/vitest.config.ts' }),
|
||||
tool(6100, 'agent-003', 'Grep', { pattern: 'export (interface|type)', path: 'src/types/' }),
|
||||
msg(6700, 'agent-001', 'Found 47 API endpoints across server.ts. Documenting REST paths...'),
|
||||
tool(7100, 'agent-002', 'Grep', { pattern: 'const PORT =', path: 'test/' }),
|
||||
msg(7700, 'agent-002', 'Analyzing test patterns: MockSession, unique ports, fileParallelism: false...'),
|
||||
tool(8100, 'agent-003', 'Read', { file_path: '/home/arkon/codeman/src/types/index.ts' }),
|
||||
msg(8700, 'agent-003', 'Mapped 38 exported interfaces across 15 domain files. Building summary...'),
|
||||
{
|
||||
t: 9300,
|
||||
kind: 'term',
|
||||
data: T([
|
||||
`${GRN}●${RST} ${CYN}agent-001${RST}: ${GRY}12 tool calls — Glob, Read(server.ts), Grep(endpoints)...${RST}`,
|
||||
`${GRN}●${RST} ${CYN}agent-002${RST}: ${GRY}8 tool calls — Glob, Read(test-utils), Read(vitest.config)...${RST}`,
|
||||
`${GRN}●${RST} ${CYN}agent-003${RST}: ${GRY}7 tool calls — Read(types.ts), Grep(interface)...${RST}`,
|
||||
'',
|
||||
]),
|
||||
},
|
||||
tool(10100, 'agent-001', 'Glob', { pattern: 'src/web/routes/*.ts' }),
|
||||
tool(10600, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/test/setup.ts' }),
|
||||
tool(11100, 'agent-003', 'Grep', { pattern: 'assertNever', path: 'src/' }),
|
||||
{
|
||||
t: 11600,
|
||||
kind: 'term',
|
||||
data: T([`${GRN}●${RST} ${GRY}171.8k, 13s${RST} ${GRY}│${RST} ${GRY}1.7k tokens${RST} ${GRY}│${RST} ${GRY}thinking${RST}`, '']),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const SUBAGENT_TAIL_HOLD = 2500; // hold the final frame
|
||||
|
||||
async function recordSubagentScene(browser, videoDir) {
|
||||
console.log('\n1/2 Recording subagent-demo...');
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: SUBAGENT_VIEWPORT,
|
||||
deviceScaleFactor: 1,
|
||||
recordVideo: { dir: videoDir, size: SUBAGENT_VIEWPORT },
|
||||
});
|
||||
const recStart = Date.now();
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
|
||||
// Start with NO subagents — they appear during the recording
|
||||
const initPayload = buildInitPayload(STANDARD_SESSIONS);
|
||||
await setupRoutes(page, initPayload, TERMINAL_PRESPAWN);
|
||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await injectState(page, initPayload, TERMINAL_PRESPAWN, SESSION_IDS.w1);
|
||||
|
||||
await page.evaluate(() => {
|
||||
try { window.app?.fitAddon?.fit(); } catch {}
|
||||
window.app?.terminal?.scrollToBottom();
|
||||
});
|
||||
await sleep(500);
|
||||
|
||||
const timeline = buildSubagentTimeline();
|
||||
const totalMs = Math.max(...timeline.map((e) => e.t)) + SUBAGENT_TAIL_HOLD;
|
||||
const sceneStart = Date.now();
|
||||
|
||||
// Run the whole timeline inside the page so events interleave naturally
|
||||
await page.evaluate((events) => {
|
||||
const app = window.app;
|
||||
for (const ev of events) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (ev.kind === 'term') {
|
||||
app.terminal.write(ev.data);
|
||||
app.terminal.scrollToBottom();
|
||||
} else if (ev.kind === 'discover') {
|
||||
app._onSubagentDiscovered(ev.agent);
|
||||
app.openSubagentWindow(ev.agent.agentId);
|
||||
// The spawn animation (400ms) lands on the auto-grid; glide to our tile after it
|
||||
setTimeout(() => {
|
||||
const win = app.subagentWindows.get(ev.agent.agentId);
|
||||
if (win?.element) {
|
||||
win.element.style.transition = 'left 0.25s ease, top 0.25s ease';
|
||||
win.element.style.left = `${ev.x}px`;
|
||||
win.element.style.top = `${ev.y}px`;
|
||||
}
|
||||
}, 520);
|
||||
setTimeout(() => {
|
||||
const win = app.subagentWindows.get(ev.agent.agentId);
|
||||
if (win?.element) win.element.style.transition = '';
|
||||
app.updateConnectionLines();
|
||||
}, 850);
|
||||
} else if (ev.kind === 'tool') {
|
||||
app._onSubagentToolCall({
|
||||
agentId: ev.agentId,
|
||||
tool: ev.tool,
|
||||
input: ev.input,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} else if (ev.kind === 'msg') {
|
||||
app._onSubagentMessage({
|
||||
agentId: ev.agentId,
|
||||
role: 'assistant',
|
||||
text: ev.text,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} else if (ev.kind === 'complete') {
|
||||
app._onSubagentCompleted({ agentId: ev.agentId, timestamp: new Date().toISOString() });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('timeline event failed', ev, err);
|
||||
}
|
||||
}, ev.t);
|
||||
}
|
||||
}, timeline);
|
||||
|
||||
await sleep(totalMs + 500);
|
||||
|
||||
await page.close();
|
||||
const videoPath = await page.video().path();
|
||||
await context.close();
|
||||
|
||||
return {
|
||||
videoPath,
|
||||
ss: (sceneStart - recStart) / 1000 - 0.4,
|
||||
duration: (totalMs + 400) / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Scene 2: zerolag typing comparison ──────────────────────────────────────
|
||||
|
||||
const ZEROLAG_VIEWPORT = { width: 1280, height: 470 };
|
||||
const TYPED_TEXT = 'echo "zero lag typing from anywhere"';
|
||||
const TYPE_INTERVAL_MS = 110;
|
||||
const REMOTE_FLUSH_MS = 350; // server-echo pane flushes queued chars in bursts
|
||||
const ZEROLAG_TAIL_HOLD = 1800;
|
||||
|
||||
const ZEROLAG_HTML = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="http://localhost:${PORT}/vendor/xterm.css">
|
||||
<script src="http://localhost:${PORT}/vendor/xterm.min.js"></script>
|
||||
<style>
|
||||
* { margin: 0; box-sizing: border-box; }
|
||||
body {
|
||||
width: 1280px; height: 470px; background: #0a0a0c;
|
||||
display: flex; align-items: center; justify-content: center; gap: 48px;
|
||||
font-family: -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
.pane { width: 560px; }
|
||||
.card {
|
||||
background: #131316; border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 10px; overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.45);
|
||||
}
|
||||
.card-head {
|
||||
display: flex; align-items: baseline; gap: 10px;
|
||||
padding: 12px 16px; border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.dot { width: 9px; height: 9px; border-radius: 50%; align-self: center; }
|
||||
.title { font-size: 15px; font-weight: 600; color: #e8e8ea; }
|
||||
.sub { font-size: 12.5px; color: #8b8b92; }
|
||||
.term { padding: 16px 8px 12px 16px; height: 165px; }
|
||||
.good .dot { background: #22c55e; box-shadow: 0 0 8px rgba(34,197,94,0.7); }
|
||||
.bad .dot { background: #ef4444; box-shadow: 0 0 8px rgba(239,68,68,0.7); }
|
||||
.tag {
|
||||
margin-top: 14px; text-align: center; font-size: 14.5px; color: #7e7e86;
|
||||
}
|
||||
.tag b { color: #22c55e; font-weight: 600; }
|
||||
.bad-tag b { color: #ef4444; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="pane">
|
||||
<div class="card good">
|
||||
<div class="card-head">
|
||||
<span class="dot"></span>
|
||||
<span class="title">With zerolag-input</span>
|
||||
<span class="sub">instant local echo</span>
|
||||
</div>
|
||||
<div class="term" id="termLeft"></div>
|
||||
</div>
|
||||
<div class="tag">keystrokes echo in <b>0 ms</b></div>
|
||||
</div>
|
||||
<div class="pane">
|
||||
<div class="card bad">
|
||||
<div class="card-head">
|
||||
<span class="dot"></span>
|
||||
<span class="title">Without</span>
|
||||
<span class="sub">server round-trip echo</span>
|
||||
</div>
|
||||
<div class="term" id="termRight"></div>
|
||||
</div>
|
||||
<div class="tag bad-tag">keystrokes echo after <b>~350 ms</b></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
async function recordZerolagScene(browser, videoDir) {
|
||||
console.log('\n2/2 Recording zerolag-demo...');
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: ZEROLAG_VIEWPORT,
|
||||
deviceScaleFactor: 1,
|
||||
recordVideo: { dir: videoDir, size: ZEROLAG_VIEWPORT },
|
||||
});
|
||||
const recStart = Date.now();
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
|
||||
await page.setContent(ZEROLAG_HTML, { waitUntil: 'load' });
|
||||
await page.waitForFunction(() => typeof Terminal !== 'undefined');
|
||||
|
||||
await page.evaluate(() => {
|
||||
const theme = {
|
||||
background: '#131316',
|
||||
foreground: '#e8e8ea',
|
||||
cursor: '#22c55e',
|
||||
cursorAccent: '#131316',
|
||||
};
|
||||
const mk = (id) => {
|
||||
const term = new Terminal({
|
||||
cols: 44,
|
||||
rows: 5,
|
||||
fontSize: 20,
|
||||
fontFamily: "'SF Mono', 'Cascadia Code', Menlo, monospace",
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
theme,
|
||||
});
|
||||
term.open(document.getElementById(id));
|
||||
term.write('\x1b[32m❯\x1b[0m ');
|
||||
return term;
|
||||
};
|
||||
window.termLeft = mk('termLeft');
|
||||
window.termRight = mk('termRight');
|
||||
});
|
||||
await sleep(600);
|
||||
|
||||
const sceneStart = Date.now();
|
||||
const typingMs = TYPED_TEXT.length * TYPE_INTERVAL_MS;
|
||||
const totalMs = typingMs + REMOTE_FLUSH_MS + ZEROLAG_TAIL_HOLD;
|
||||
|
||||
await page.evaluate(
|
||||
({ text, interval, flushEvery }) => {
|
||||
let i = 0;
|
||||
const remoteQueue = [];
|
||||
const typer = setInterval(() => {
|
||||
if (i >= text.length) { clearInterval(typer); return; }
|
||||
const ch = text[i++];
|
||||
window.termLeft.write(ch); // local echo: instant
|
||||
remoteQueue.push(ch); // server echo: waits for the round-trip
|
||||
}, interval);
|
||||
const flusher = setInterval(() => {
|
||||
if (remoteQueue.length) window.termRight.write(remoteQueue.splice(0).join(''));
|
||||
if (i >= text.length && remoteQueue.length === 0) clearInterval(flusher);
|
||||
}, flushEvery);
|
||||
},
|
||||
{ text: TYPED_TEXT, interval: TYPE_INTERVAL_MS, flushEvery: REMOTE_FLUSH_MS }
|
||||
);
|
||||
|
||||
await sleep(totalMs + 400);
|
||||
|
||||
await page.close();
|
||||
const videoPath = await page.video().path();
|
||||
await context.close();
|
||||
|
||||
return {
|
||||
videoPath,
|
||||
ss: (sceneStart - recStart) / 1000 - 0.6, // small lead-in with idle cursors
|
||||
duration: (totalMs + 600) / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
console.log('='.repeat(60));
|
||||
console.log('Codeman README GIF Capture');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const server = await startStaticServer();
|
||||
const videoDir = mkdtempSync(join(tmpdir(), 'codeman-gifs-'));
|
||||
let browser;
|
||||
|
||||
try {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
|
||||
});
|
||||
|
||||
const sub = await recordSubagentScene(browser, videoDir);
|
||||
const subGif = outPath('images', 'subagent-demo.gif');
|
||||
webmToGif(sub.videoPath, subGif, { ss: Math.max(0, sub.ss), duration: sub.duration, width: 960, fps: 8 });
|
||||
console.log(` Saved: ${subGif}`);
|
||||
|
||||
const zl = await recordZerolagScene(browser, videoDir);
|
||||
const zlGif = outPath('images', 'zerolag-demo.gif');
|
||||
webmToGif(zl.videoPath, zlGif, { ss: Math.max(0, zl.ss), duration: zl.duration, width: 900, fps: 10 });
|
||||
console.log(` Saved: ${zlGif}`);
|
||||
|
||||
console.log('\nDone.');
|
||||
} catch (err) {
|
||||
console.error('\nFatal error:', err.message);
|
||||
console.error(err.stack);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (browser) await browser.close().catch(() => {});
|
||||
server.close();
|
||||
rmSync(videoDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => process.exit(1));
|
||||
|
||||
main();
|
||||
@@ -51,6 +51,9 @@ async function newCtx(browser) {
|
||||
localStorage.setItem('codeman:skin', skin);
|
||||
localStorage.setItem('codeman-font-size', String(font));
|
||||
const blob = { skin, showFileBrowser: false, showProjectInsights: false };
|
||||
// Don't auto-hide subagent windows that belong to a non-active tab — the
|
||||
// subagent scene re-homes agents and needs both windows visible at once.
|
||||
blob.subagentActiveTabOnly = false;
|
||||
if (planUsage) blob.showPlanUsageLimits = true;
|
||||
localStorage.setItem('codeman-app-settings', JSON.stringify(blob));
|
||||
} catch {
|
||||
@@ -136,9 +139,9 @@ async function sceneSubagent(browser) {
|
||||
const sessions = await listSessions(page);
|
||||
const targetId = process.env.SUBAGENT_SID || (sessions.find((s) => s.mode === 'claude') || sessions[0])?.id;
|
||||
if (targetId) await page.evaluate((id) => window.app.selectSession(id), targetId);
|
||||
// Wait (up to ~25s) for live subagents to arrive via SSE into app.subagents.
|
||||
// Wait (up to ~45s) for live subagents to arrive via SSE into app.subagents.
|
||||
let agents = [];
|
||||
for (let i = 0; i < 25; i++) {
|
||||
for (let i = 0; i < 45; i++) {
|
||||
agents = await page.evaluate(() =>
|
||||
Array.from(window.app.subagents?.entries?.() || []).map(([id, a]) => ({ id, name: a.name ?? a.agentType ?? '' }))
|
||||
);
|
||||
@@ -151,6 +154,44 @@ async function sceneSubagent(browser) {
|
||||
await context.close();
|
||||
return;
|
||||
}
|
||||
// The window body renders from app.subagentActivity, which fills ONLY from live
|
||||
// SSE tool-call/progress events — a fresh client never gets past activity replayed.
|
||||
// So sit connected and wait for live activity to accumulate, then open the two
|
||||
// agents that actually have content (otherwise the windows read "No activity yet").
|
||||
let active = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
active = await page.evaluate(() =>
|
||||
Array.from(window.app.subagentActivity?.entries?.() || [])
|
||||
.filter(([, arr]) => Array.isArray(arr) && arr.length >= 1)
|
||||
.map(([id, arr]) => ({ id, n: arr.length }))
|
||||
.sort((a, b) => b.n - a.n)
|
||||
);
|
||||
if (active.length >= 2) break;
|
||||
// xhigh-effort agents churn in bursts between long thinking pauses, so be
|
||||
// patient (~150s); accept a single populated window after ~45s if that's all.
|
||||
if (i >= 30 && active.length >= 1) break;
|
||||
await sleep(1500);
|
||||
}
|
||||
console.log(' agents with live activity:', JSON.stringify(active));
|
||||
const openIds = (active.length ? active : agents).map((a) => a.id);
|
||||
// Capture-only DOM nudge: on fresh dev sessions, a tab's claudeSessionId stays the
|
||||
// Codeman id and never becomes the real Claude conversation UUID, so the window
|
||||
// open-gate (claudeSessionId === agent.sessionId) + the activeTabOnly hide rule both
|
||||
// fail. Re-home the chosen agents onto the active tab and align its claudeSessionId
|
||||
// to the agents' (shared) sessionId so the windows open AND show their live activity.
|
||||
await page.evaluate(
|
||||
(ids) => {
|
||||
const activeId = window.app.activeSessionId;
|
||||
const tab = window.app.sessions.get(activeId);
|
||||
ids.slice(0, 2).forEach((id) => {
|
||||
const a = window.app.subagents.get(id);
|
||||
if (!a) return;
|
||||
a.parentSessionId = activeId;
|
||||
if (tab && a.sessionId) tab.claudeSessionId = a.sessionId;
|
||||
});
|
||||
},
|
||||
openIds
|
||||
);
|
||||
await page.evaluate(
|
||||
(ids) => {
|
||||
ids.slice(0, 2).forEach((id) => {
|
||||
@@ -159,22 +200,33 @@ async function sceneSubagent(browser) {
|
||||
} catch {}
|
||||
});
|
||||
},
|
||||
agents.map((a) => a.id)
|
||||
openIds
|
||||
);
|
||||
await sleep(2000);
|
||||
await page.evaluate(() => {
|
||||
// Viewport-relative tiling: center two subagent windows over the terminal so
|
||||
// the layout adapts to whatever VW/VH the capture uses (e.g. the HQ 1100×650
|
||||
// recipe) instead of overflowing at narrower widths.
|
||||
const wins = Array.from(window.app.subagentWindows.values());
|
||||
const place = [
|
||||
{ left: 360, top: 60, w: 430, h: 330 },
|
||||
{ left: 810, top: 60, w: 430, h: 330 },
|
||||
];
|
||||
const W = window.innerWidth;
|
||||
const H = window.innerHeight;
|
||||
const winW = Math.min(440, Math.floor((W - 60) / 2 - 10));
|
||||
const winH = Math.min(360, Math.floor(H * 0.56));
|
||||
const top = Math.floor(H * 0.16);
|
||||
const gap = 16;
|
||||
const totalW = winW * 2 + gap;
|
||||
const startLeft = Math.max(16, Math.floor((W - totalW) / 2));
|
||||
wins.slice(0, 2).forEach((win, i) => {
|
||||
const el = win.element;
|
||||
const p = place[i];
|
||||
el.style.left = p.left + 'px';
|
||||
el.style.top = p.top + 'px';
|
||||
el.style.width = p.w + 'px';
|
||||
el.style.height = p.h + 'px';
|
||||
// Force visible: a freshly opened window may be hidden by the activeTabOnly
|
||||
// rule before we override it (we also seed subagentActiveTabOnly:false).
|
||||
win.hidden = false;
|
||||
win.minimized = false;
|
||||
el.style.display = 'flex';
|
||||
el.style.left = startLeft + i * (winW + gap) + 'px';
|
||||
el.style.top = top + 'px';
|
||||
el.style.width = winW + 'px';
|
||||
el.style.height = winH + 'px';
|
||||
});
|
||||
});
|
||||
await sleep(1500);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,11 +3,52 @@
|
||||
*/
|
||||
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { isSupportedAttachmentExtension } from './attachment-registry.js';
|
||||
import { stripAnsi } from './utils/index.js';
|
||||
|
||||
const MAGIC_LINK_RE = /codeman:\/\/attach\?([^\s<>"']+)/g;
|
||||
const CODEX_SAVED_FILE_RE = /\bSaved to:\s*(file:\/\/[^\s<>"']+)/gi;
|
||||
|
||||
export interface TerminalAttachmentRequest {
|
||||
path: string;
|
||||
source: 'external' | 'codex-generated';
|
||||
}
|
||||
|
||||
export interface ParseTerminalAttachmentOptions {
|
||||
/**
|
||||
* Enable the Codex `Saved to: file://...` scanner. Only codex-mode sessions
|
||||
* may set this — the relaxed codex-generated trust policy must never be
|
||||
* reachable from other modes' (prompt-injectable) terminal output.
|
||||
*/
|
||||
codexArtifacts?: boolean;
|
||||
}
|
||||
|
||||
export function parseAttachmentMagicLinks(data: string): string[] {
|
||||
return parseMagicAttachmentRequests(data).map((request) => request.path);
|
||||
}
|
||||
|
||||
export function parseTerminalAttachmentRequests(
|
||||
data: string,
|
||||
options: ParseTerminalAttachmentOptions = {}
|
||||
): TerminalAttachmentRequest[] {
|
||||
const results: TerminalAttachmentRequest[] = [];
|
||||
const seen = new Set<string>();
|
||||
const requests = options.codexArtifacts
|
||||
? [...parseMagicAttachmentRequests(data), ...parseCodexGeneratedArtifactRequests(data)]
|
||||
: parseMagicAttachmentRequests(data);
|
||||
|
||||
for (const request of requests) {
|
||||
const key = `${request.source}:${request.path}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
results.push(request);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function parseMagicAttachmentRequests(data: string): TerminalAttachmentRequest[] {
|
||||
const results: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
@@ -27,6 +68,31 @@ export function parseAttachmentMagicLinks(data: string): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
return results.map((path) => ({ path, source: 'external' }));
|
||||
}
|
||||
|
||||
function parseCodexGeneratedArtifactRequests(data: string): TerminalAttachmentRequest[] {
|
||||
const results: TerminalAttachmentRequest[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Codex styles its TUI output — strip ANSI first so a trailing SGR reset
|
||||
// (e.g. `...mockup.png\x1b[0m`) doesn't ride into the captured URL and break
|
||||
// the extension allowlist check.
|
||||
for (const match of stripAnsi(data).matchAll(CODEX_SAVED_FILE_RE)) {
|
||||
const rawUrl = trimTrailingPunctuation(match[1] || '');
|
||||
try {
|
||||
const filePath = fileURLToPath(rawUrl);
|
||||
if (!isAbsolute(filePath)) continue;
|
||||
const extension = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
if (!isSupportedAttachmentExtension(extension)) continue;
|
||||
if (seen.has(filePath)) continue;
|
||||
seen.add(filePath);
|
||||
results.push({ path: filePath, source: 'codex-generated' });
|
||||
} catch {
|
||||
// Ignore malformed terminal text. Generated-artifact links are advisory.
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,18 @@ import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from './config/att
|
||||
import { validateSessionFilePath } from './web/route-helpers.js';
|
||||
import type { AttachmentDetectedEvent, AttachmentDetectedType } from './types.js';
|
||||
|
||||
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set(['png', 'pdf', 'docx', 'pptx', 'md', 'txt']);
|
||||
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'webp',
|
||||
'pdf',
|
||||
'docx',
|
||||
'pptx',
|
||||
'md',
|
||||
'txt',
|
||||
]);
|
||||
|
||||
export type AttachmentSource = 'detected' | 'external';
|
||||
|
||||
@@ -96,7 +107,7 @@ export function isSupportedAttachmentExtension(extension: string): boolean {
|
||||
|
||||
export function getAttachmentType(extension: string): AttachmentDetectedType {
|
||||
const normalized = extension.toLowerCase().replace(/^\./, '');
|
||||
if (normalized === 'png') return 'image';
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(normalized)) return 'image';
|
||||
if (normalized === 'pdf') return 'pdf';
|
||||
if (normalized === 'pptx') return 'presentation';
|
||||
if (normalized === 'md') return 'markdown';
|
||||
|
||||
@@ -6,14 +6,15 @@
|
||||
* it easy to tune memory usage.
|
||||
*
|
||||
* Memory Budget Rationale (for 20 concurrent sessions):
|
||||
* - Terminal buffer: 2MB max × 20 = 40MB worst case
|
||||
* - Terminal buffer: 32MB max × 20 = 640MB worst case
|
||||
* - Text output: 1MB max × 20 = 20MB worst case
|
||||
* - Messages: ~1KB each × 1000 × 20 = 20MB worst case
|
||||
* - Total buffer overhead: ~80MB (acceptable for long-running server)
|
||||
*
|
||||
* @module config/buffer-limits
|
||||
*/
|
||||
|
||||
import { DEFAULT_TERMINAL_BUFFER_MAX_BYTES, DEFAULT_TERMINAL_BUFFER_TRIM_BYTES } from './terminal-history.js';
|
||||
|
||||
// ============================================================================
|
||||
// Terminal Buffer Limits
|
||||
// ============================================================================
|
||||
@@ -21,17 +22,17 @@
|
||||
/**
|
||||
* Maximum terminal buffer size in characters.
|
||||
* Contains raw terminal output with ANSI escape sequences.
|
||||
* Reduced from 5MB to 2MB for better render performance.
|
||||
* Sourced from terminal-history config (env/settings overridable).
|
||||
* Override: CODEMAN_MAX_TERMINAL_BUFFER (bytes)
|
||||
*/
|
||||
export const MAX_TERMINAL_BUFFER_SIZE = parseInt(process.env.CODEMAN_MAX_TERMINAL_BUFFER || '') || 2 * 1024 * 1024;
|
||||
export const MAX_TERMINAL_BUFFER_SIZE = DEFAULT_TERMINAL_BUFFER_MAX_BYTES;
|
||||
|
||||
/**
|
||||
* Size to trim terminal buffer to when max is exceeded.
|
||||
* Keeps the most recent portion to preserve context.
|
||||
* Override: CODEMAN_TRIM_TERMINAL_TO (bytes)
|
||||
*/
|
||||
export const TRIM_TERMINAL_TO = parseInt(process.env.CODEMAN_TRIM_TERMINAL_TO || '') || 1.5 * 1024 * 1024;
|
||||
export const TRIM_TERMINAL_TO = DEFAULT_TERMINAL_BUFFER_TRIM_BYTES;
|
||||
|
||||
// ============================================================================
|
||||
// Text Output Buffer Limits
|
||||
@@ -96,3 +97,18 @@ export const TRIM_RESPAWN_BUFFER_TO = 512 * 1024; // 512KB
|
||||
* which is enough to extract metadata from the first few JSONL lines.
|
||||
*/
|
||||
export const FILE_PEEK_BYTES = 8 * 1024 - 1; // 8KB (inclusive end offset)
|
||||
|
||||
// ============================================================================
|
||||
// Paste-Image Upload Limits
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Maximum size (bytes) of a single image uploaded via POST
|
||||
* /api/sessions/:id/paste-image. The mobile picker / drag-drop / paste paths
|
||||
* send one file per request (the client uploads up to MAX_PASTE_IMAGES of them
|
||||
* per batch), so this caps each individual file, not the batch. Generous enough
|
||||
* for full-resolution phone photos and large screenshots; the client downscales
|
||||
* very large images before upload, so legitimate uploads land well under this.
|
||||
* Override: CODEMAN_MAX_PASTE_IMAGE_BYTES (bytes)
|
||||
*/
|
||||
export const MAX_PASTE_IMAGE_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_IMAGE_BYTES || '') || 50 * 1024 * 1024; // 50MB
|
||||
|
||||
@@ -90,6 +90,14 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [
|
||||
usedBy: ['Codex sessions'],
|
||||
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['codex'], versionArg: '--version' } }],
|
||||
},
|
||||
{
|
||||
id: 'gemini',
|
||||
label: 'Gemini CLI',
|
||||
category: 'core',
|
||||
required: false,
|
||||
usedBy: ['Gemini sessions'],
|
||||
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['gemini'], versionArg: '--version' } }],
|
||||
},
|
||||
{
|
||||
id: 'libreoffice',
|
||||
label: 'LibreOffice',
|
||||
|
||||
@@ -43,6 +43,19 @@ export const MAX_SSE_CLIENTS = 100;
|
||||
*/
|
||||
export const MAX_TODOS_PER_SESSION = 500;
|
||||
|
||||
/**
|
||||
* Maximum cron-job run-history records retained across all jobs. Oldest runs
|
||||
* (by startedAt) are pruned when exceeded — bounds state.json growth from
|
||||
* frequently-firing or perpetually-skipped jobs.
|
||||
*/
|
||||
export const MAX_CRON_RUN_HISTORY = 500;
|
||||
|
||||
/**
|
||||
* Maximum saved cron jobs. Jobs persist to state.json, so an unbounded count
|
||||
* would grow it without limit; creation past the cap is rejected with 400.
|
||||
*/
|
||||
export const MAX_CRON_JOBS = 100;
|
||||
|
||||
// ============================================================================
|
||||
// Pending Tool Calls Limits
|
||||
// ============================================================================
|
||||
|
||||
@@ -51,6 +51,19 @@ export const SCHEDULED_CLEANUP_INTERVAL = 5 * 60 * 1000;
|
||||
/** Completed scheduled run max age before cleanup (ms) */
|
||||
export const SCHEDULED_RUN_MAX_AGE = 60 * 60 * 1000;
|
||||
|
||||
// ============================================================================
|
||||
// Cron Jobs
|
||||
// ============================================================================
|
||||
|
||||
/** How often the cron loop wakes to check for due jobs (ms). */
|
||||
export const CRON_TICK_INTERVAL = 30 * 1000;
|
||||
|
||||
/** Max attempts (× 500ms) to poll a launched session for CLI readiness before sending the prompt. */
|
||||
export const CRON_READY_MAX_ATTEMPTS = 60;
|
||||
|
||||
/** Extra settle delay after CLI readiness is detected, before sending the prompt (ms). */
|
||||
export const CRON_READY_SETTLE_MS = 2000;
|
||||
|
||||
/** Session limit retry wait before retrying (ms) */
|
||||
export const SESSION_LIMIT_WAIT_MS = 5000;
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Defaults, bounds, and resolution for terminal history retention.
|
||||
*
|
||||
* Raised defaults (the ones actually wired):
|
||||
* - tmux history-limit: 50,000 -> 100,000 lines (applied at session spawn)
|
||||
* - server PTY buffer cap: 2MB max / 1.5MB trim -> 32MB / 24MB (via buffer-limits.ts)
|
||||
* Browser xterm scrollback is a separate hardcoded DEFAULT_SCROLLBACK (50,000) in
|
||||
* src/web/public/constants.js and deliberately stays at 50k — 100k xterm lines per tab
|
||||
* is a mobile-memory hazard — so DEFAULT_TERMINAL_SCROLLBACK_LINES stays 50,000 to match.
|
||||
* The terminalScrollbackLines/terminalBufferMaxBytes/terminalBufferTrimBytes settings keys
|
||||
* remain schema-validated but inert (a follow-up wires them); only tmuxHistoryLimit is live.
|
||||
* All values remain env- and settings-overridable and bounds-clamped via
|
||||
* resolveTerminalHistoryConfig().
|
||||
*/
|
||||
|
||||
export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 50_000;
|
||||
export const DEFAULT_TMUX_HISTORY_LIMIT = 100_000;
|
||||
export const DEFAULT_TERMINAL_BUFFER_MAX_BYTES =
|
||||
parseInt(process.env.CODEMAN_MAX_TERMINAL_BUFFER || '', 10) || 32 * 1024 * 1024;
|
||||
// Trim must stay below the max: BufferAccumulator.trim() keeps the last trimSize chars, so a
|
||||
// trim >= max never shrinks the buffer — every append then re-joins the whole string (O(n²))
|
||||
// and memory overshoots the operator's cap (e.g. CODEMAN_MAX_TERMINAL_BUFFER=2097152 with no
|
||||
// trim env would leave the 24MB trim default in force). Clamp to 75% of the resolved max,
|
||||
// preserving the 24MB/32MB default ratio as trim hysteresis.
|
||||
export const DEFAULT_TERMINAL_BUFFER_TRIM_BYTES = Math.min(
|
||||
parseInt(process.env.CODEMAN_TRIM_TERMINAL_TO || '', 10) || 24 * 1024 * 1024,
|
||||
Math.floor(DEFAULT_TERMINAL_BUFFER_MAX_BYTES * 0.75)
|
||||
);
|
||||
|
||||
export const MIN_TERMINAL_SCROLLBACK_LINES = 1_000;
|
||||
export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000;
|
||||
export const MIN_TERMINAL_BUFFER_BYTES = 1024 * 1024;
|
||||
export const MAX_TERMINAL_BUFFER_BYTES = 128 * 1024 * 1024;
|
||||
|
||||
export interface TerminalHistoryConfig {
|
||||
terminalScrollbackLines: number;
|
||||
tmuxHistoryLimit: number;
|
||||
terminalBufferMaxBytes: number;
|
||||
terminalBufferTrimBytes: number;
|
||||
}
|
||||
|
||||
function boundedInt(value: unknown, fallback: number, min: number, max: number): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
|
||||
return Math.max(min, Math.min(max, Math.trunc(value)));
|
||||
}
|
||||
|
||||
export function resolveTerminalHistoryConfig(settings: Record<string, unknown> = {}): TerminalHistoryConfig {
|
||||
const terminalBufferMaxBytes = boundedInt(
|
||||
settings.terminalBufferMaxBytes,
|
||||
DEFAULT_TERMINAL_BUFFER_MAX_BYTES,
|
||||
MIN_TERMINAL_BUFFER_BYTES,
|
||||
MAX_TERMINAL_BUFFER_BYTES
|
||||
);
|
||||
const terminalBufferTrimBytes = boundedInt(
|
||||
settings.terminalBufferTrimBytes,
|
||||
Math.min(DEFAULT_TERMINAL_BUFFER_TRIM_BYTES, terminalBufferMaxBytes),
|
||||
MIN_TERMINAL_BUFFER_BYTES,
|
||||
terminalBufferMaxBytes
|
||||
);
|
||||
|
||||
return {
|
||||
terminalScrollbackLines: boundedInt(
|
||||
settings.terminalScrollbackLines,
|
||||
DEFAULT_TERMINAL_SCROLLBACK_LINES,
|
||||
MIN_TERMINAL_SCROLLBACK_LINES,
|
||||
MAX_TERMINAL_SCROLLBACK_LINES
|
||||
),
|
||||
tmuxHistoryLimit: boundedInt(
|
||||
settings.tmuxHistoryLimit,
|
||||
DEFAULT_TMUX_HISTORY_LIMIT,
|
||||
MIN_TERMINAL_SCROLLBACK_LINES,
|
||||
MAX_TERMINAL_SCROLLBACK_LINES
|
||||
),
|
||||
terminalBufferMaxBytes,
|
||||
terminalBufferTrimBytes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @fileoverview Input shape for creating/updating a cron job. This is the
|
||||
* user-settable subset of `CronJob` (server-maintained bookkeeping fields
|
||||
* such as nextRunAt / lastStatus are excluded). Produced by the zod schema.
|
||||
*/
|
||||
|
||||
import type { ConcurrencyPolicy, InputMode, PromptMode, ScheduleType } from '../types/cron.js';
|
||||
import type { SessionMode } from '../types/session.js';
|
||||
|
||||
export type { CronJob, CronJobRun, CronJobRunStatus, TriggerType } from '../types/cron.js';
|
||||
|
||||
export interface CronJobInput {
|
||||
name: string;
|
||||
agentType: SessionMode;
|
||||
workingDir: string;
|
||||
launchCommand?: string;
|
||||
promptMode: PromptMode;
|
||||
promptText?: string;
|
||||
promptFilePath?: string;
|
||||
inputMode: InputMode;
|
||||
scheduleType: ScheduleType;
|
||||
runAt?: number;
|
||||
intervalMinutes?: number;
|
||||
dailyTime?: string;
|
||||
weeklyDays?: number[];
|
||||
weeklyTime?: string;
|
||||
enabled: boolean;
|
||||
notes?: string;
|
||||
concurrencyPolicy: ConcurrencyPolicy;
|
||||
/** Default true. Ignored for 'once' schedules. */
|
||||
autoClosePreviousSession?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
/**
|
||||
* @fileoverview Cron service: CRUD for cron jobs, manual Run Now,
|
||||
* the background due-job tick, and run-history recording.
|
||||
*
|
||||
* It does NOT own session/tmux logic — it reuses Codeman's existing session
|
||||
* layer (create → addSession → setupSessionListeners → startInteractive/Shell →
|
||||
* send prompt via writeViaMux/write), mirroring the "quick start" route flow.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { statSync, realpathSync } from 'node:fs';
|
||||
import { Session } from '../session.js';
|
||||
import { SseEvent } from '../web/sse-events.js';
|
||||
import { CronJobSchema } from '../web/schemas.js';
|
||||
import { getErrorMessage, createErrorResponse, ApiErrorCode } from '../types/api.js';
|
||||
import { MAX_CONCURRENT_SESSIONS, MAX_CRON_JOBS, MAX_CRON_RUN_HISTORY } from '../config/map-limits.js';
|
||||
import { CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js';
|
||||
import {
|
||||
DEFAULT_BLOCKED_TREES,
|
||||
isBlockedAttachmentPath,
|
||||
loadAttachmentGuardConfig,
|
||||
} from '../config/attachment-guard.js';
|
||||
import { validateSessionFilePath } from '../web/route-helpers.js';
|
||||
import { computeNextRunAt, dueKeyFor } from './cron-time.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../web/ports/index.js';
|
||||
import type { CronJob, CronJobRun, CronJobRunStatus, TriggerType } from '../types/cron.js';
|
||||
import type { CronJobInput } from './cron-input.js';
|
||||
|
||||
/** The subset of the route context the cron depends on. */
|
||||
export type CronDeps = SessionPort & EventPort & ConfigPort & InfraPort;
|
||||
|
||||
const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** Hard ceiling on a prompt-file read (defends against unbounded-read DoS). */
|
||||
const MAX_PROMPT_FILE_BYTES = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Pseudo-filesystem trees a cron job may never touch, ON TOP of the shared
|
||||
* attachment blocklist. `/proc` in particular defeats the workingDir
|
||||
* confinement trick (`workingDir: '/proc'` + `promptFilePath:
|
||||
* '/proc/self/environ'` would read the SERVER's own environment).
|
||||
*/
|
||||
const CRON_PSEUDO_FS_TREES: readonly string[] = ['/proc', '/sys', '/dev'];
|
||||
|
||||
/** Sync blocklist for the create/update workingDir gate (no settings extras). */
|
||||
const CRON_WORKING_DIR_BLOCKED_TREES: readonly string[] = [...DEFAULT_BLOCKED_TREES, ...CRON_PSEUDO_FS_TREES];
|
||||
|
||||
/** Prompt delivery is single-line only (writeViaMux/Ink constraint). */
|
||||
const HAS_NEWLINE = /[\r\n]/;
|
||||
|
||||
/** Order-insensitive equality for the weekly-days arrays. */
|
||||
function sameDays(a: number[] | undefined, b: number[] | undefined): boolean {
|
||||
const x = [...(a ?? [])].sort((p, q) => p - q);
|
||||
const y = [...(b ?? [])].sort((p, q) => p - q);
|
||||
return x.length === y.length && x.every((v, i) => v === y[i]);
|
||||
}
|
||||
|
||||
export class CronService {
|
||||
constructor(private readonly deps: CronDeps) {}
|
||||
|
||||
private get store() {
|
||||
return this.deps.store;
|
||||
}
|
||||
|
||||
// ───────────────────────────── Reads ─────────────────────────────
|
||||
|
||||
listJobs(): CronJob[] {
|
||||
return Object.values(this.store.getCronJobs());
|
||||
}
|
||||
|
||||
getJob(id: string): CronJob | null {
|
||||
return this.store.getCronJob(id);
|
||||
}
|
||||
|
||||
listRuns(jobId?: string): CronJobRun[] {
|
||||
const all = Object.values(this.store.getCronJobRuns());
|
||||
const filtered = jobId ? all.filter((r) => r.cronJobId === jobId) : all;
|
||||
return filtered.sort((a, b) => b.startedAt - a.startedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of LIVE sessions of a given agent type (for the multi-session
|
||||
* warning and the skip_if_same_agent_running policy). Sessions whose CLI has
|
||||
* exited (`stopped`/`error` — the tab is still open but nothing is running)
|
||||
* don't count. When `excludeJobId` is given, sessions created by that job's
|
||||
* own runs are also excluded — otherwise a recurring job with the skip
|
||||
* policy would deadlock on its own previous (never-closed) session and fire
|
||||
* exactly once, forever skipping after that.
|
||||
*/
|
||||
countActiveAgents(agentType: string, excludeJobId?: string): number {
|
||||
const ownSessionIds = excludeJobId
|
||||
? new Set(
|
||||
this.listRuns(excludeJobId)
|
||||
.map((r) => r.sessionId)
|
||||
.filter((id): id is string => id !== null)
|
||||
)
|
||||
: null;
|
||||
let n = 0;
|
||||
for (const [id, s] of this.deps.sessions.entries()) {
|
||||
if (s.mode !== agentType) continue;
|
||||
if (s.status === 'stopped' || s.status === 'error') continue;
|
||||
if (ownSessionIds?.has(id)) continue;
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// ──────────────────────────── Mutations ───────────────────────────
|
||||
|
||||
createJob(input: CronJobInput): CronJob {
|
||||
if (Object.keys(this.store.getCronJobs()).length >= MAX_CRON_JOBS) {
|
||||
throw this.badRequest(`Maximum number of cron jobs (${MAX_CRON_JOBS}) reached`);
|
||||
}
|
||||
this.assertValidWorkingDir(input.workingDir);
|
||||
const now = Date.now();
|
||||
const job: CronJob = {
|
||||
id: uuidv4(),
|
||||
name: input.name,
|
||||
agentType: input.agentType,
|
||||
workingDir: input.workingDir,
|
||||
launchCommand: input.launchCommand,
|
||||
promptMode: input.promptMode,
|
||||
promptText: input.promptText,
|
||||
promptFilePath: input.promptFilePath,
|
||||
inputMode: input.inputMode,
|
||||
scheduleType: input.scheduleType,
|
||||
runAt: input.runAt,
|
||||
intervalMinutes: input.intervalMinutes,
|
||||
dailyTime: input.dailyTime,
|
||||
weeklyDays: input.weeklyDays,
|
||||
weeklyTime: input.weeklyTime,
|
||||
enabled: input.enabled,
|
||||
notes: input.notes,
|
||||
concurrencyPolicy: input.concurrencyPolicy,
|
||||
autoClosePreviousSession: input.autoClosePreviousSession ?? true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastRunAt: null,
|
||||
nextRunAt: null,
|
||||
lastStatus: null,
|
||||
lastDueKey: null,
|
||||
};
|
||||
job.nextRunAt = job.enabled ? computeNextRunAt(job, now) : null;
|
||||
this.store.setCronJob(job.id, job);
|
||||
this.broadcastListChanged();
|
||||
return job;
|
||||
}
|
||||
|
||||
updateJob(id: string, patch: Partial<CronJobInput>): CronJob | null {
|
||||
const existing = this.getJob(id);
|
||||
if (!existing) return null;
|
||||
const now = Date.now();
|
||||
|
||||
// A completed one-time job is only re-armed when the SCHEDULE actually
|
||||
// CHANGES — otherwise a cosmetic edit would silently resurrect a job that
|
||||
// already fired. We compare VALUES, not field-presence: the edit form
|
||||
// round-trips the full job (incl. unchanged scheduleType/runAt) on every
|
||||
// save, so a presence check would always re-arm. Only a real schedule
|
||||
// change re-arms.
|
||||
const changed = <T>(next: T | undefined, prev: T): boolean => next !== undefined && next !== prev;
|
||||
const scheduleChanged =
|
||||
changed(patch.scheduleType, existing.scheduleType) ||
|
||||
changed(patch.runAt, existing.runAt) ||
|
||||
changed(patch.intervalMinutes, existing.intervalMinutes) ||
|
||||
changed(patch.dailyTime, existing.dailyTime) ||
|
||||
changed(patch.weeklyTime, existing.weeklyTime) ||
|
||||
(patch.weeklyDays !== undefined && !sameDays(patch.weeklyDays, existing.weeklyDays));
|
||||
const reArm = existing.scheduleType !== 'once' || !existing.completedOnce || scheduleChanged;
|
||||
|
||||
const updated: CronJob = {
|
||||
...existing,
|
||||
...patch,
|
||||
id: existing.id,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: now,
|
||||
completedOnce: reArm ? false : existing.completedOnce,
|
||||
lastDueKey: null,
|
||||
};
|
||||
|
||||
// The PUT schema is `.partial()`, so its cross-field rules don't run on a
|
||||
// partial body. Re-validate the MERGED job against the full schema so a
|
||||
// partial edit can't leave an enabled job with an inconsistent schedule
|
||||
// (e.g. switching to `once` without a `runAt` → a dead `nextRunAt:null`).
|
||||
const check = CronJobSchema.safeParse(updated);
|
||||
if (!check.success) {
|
||||
throw this.badRequest(check.error.issues[0]?.message ?? 'Invalid cron job update');
|
||||
}
|
||||
if (patch.workingDir !== undefined) this.assertValidWorkingDir(patch.workingDir);
|
||||
|
||||
updated.nextRunAt = updated.enabled ? computeNextRunAt(updated, now) : null;
|
||||
this.store.setCronJob(updated.id, updated);
|
||||
this.broadcastListChanged();
|
||||
return updated;
|
||||
}
|
||||
|
||||
setEnabled(id: string, enabled: boolean): CronJob | null {
|
||||
const existing = this.getJob(id);
|
||||
if (!existing) return null;
|
||||
const now = Date.now();
|
||||
existing.enabled = enabled;
|
||||
existing.updatedAt = now;
|
||||
existing.nextRunAt = enabled ? computeNextRunAt(existing, now) : null;
|
||||
this.store.setCronJob(existing.id, existing);
|
||||
this.broadcastListChanged();
|
||||
return existing;
|
||||
}
|
||||
|
||||
deleteJob(id: string): boolean {
|
||||
if (!this.getJob(id)) return false;
|
||||
this.store.removeCronJob(id);
|
||||
for (const run of this.listRuns(id)) this.store.removeCronJobRun(run.id);
|
||||
this.deps.broadcast(SseEvent.CronJobDeleted, { id });
|
||||
this.broadcastListChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ──────────────────────────── Execution ───────────────────────────
|
||||
|
||||
/** Manual Run Now — always launches regardless of schedule/enabled state. */
|
||||
async runNow(id: string): Promise<CronJobRun | null> {
|
||||
const job = this.getJob(id);
|
||||
if (!job) return null;
|
||||
return this.launch(job, 'manual_run_now');
|
||||
}
|
||||
|
||||
/**
|
||||
* Background tick: launch every enabled job whose next run is due. Advances
|
||||
* each job's schedule and guards against double-launching the same due time.
|
||||
*/
|
||||
async tickDueJobs(now: number = Date.now()): Promise<void> {
|
||||
for (const job of this.listJobs()) {
|
||||
if (!job.enabled || job.nextRunAt == null || job.nextRunAt > now) continue;
|
||||
|
||||
const key = dueKeyFor(job.id, job.nextRunAt);
|
||||
if (job.lastDueKey === key) {
|
||||
// This due time was already consumed (overlap/restart) — just advance.
|
||||
this.advanceAfterFire(job, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Optional concurrency policy for AUTOMATIC runs. Only LIVE sessions
|
||||
// block, and this job's own previous sessions never do (see
|
||||
// countActiveAgents) — otherwise a recurring job would deadlock on the
|
||||
// session it created last time.
|
||||
if (job.concurrencyPolicy === 'skip_if_same_agent_running' && this.countActiveAgents(job.agentType, job.id) > 0) {
|
||||
// Record the skip so the job's run history isn't silently empty when it
|
||||
// keeps getting skipped (otherwise it looks like the job never ran).
|
||||
this.recordSkippedRun(job);
|
||||
if (job.scheduleType === 'once') {
|
||||
// A skipped one-time job is NOT consumed: leave nextRunAt armed (and
|
||||
// the due key unconsumed) so the next tick retries once the blocking
|
||||
// session goes away.
|
||||
continue;
|
||||
}
|
||||
job.lastDueKey = key;
|
||||
this.advanceAfterFire(job, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
job.lastDueKey = key;
|
||||
// Advance the schedule BEFORE launching so a slow launch can't be
|
||||
// re-triggered by the next tick.
|
||||
this.advanceAfterFire(job, now);
|
||||
this.launch(job, 'scheduled').catch((err) =>
|
||||
console.error(`[cron] launch failed for job ${job.id}:`, getErrorMessage(err))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Recompute nextRunAt for loaded jobs on boot (e.g. after a restart). */
|
||||
init(): void {
|
||||
const now = Date.now();
|
||||
for (const job of this.listJobs()) {
|
||||
const isDeadOnce = job.scheduleType === 'once' && job.completedOnce;
|
||||
if (job.enabled && job.nextRunAt == null && !isDeadOnce) {
|
||||
job.nextRunAt = computeNextRunAt(job, now);
|
||||
this.store.setCronJob(job.id, job);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────── Internals ───────────────────────────
|
||||
|
||||
private advanceAfterFire(job: CronJob, now: number): void {
|
||||
if (job.scheduleType === 'once') {
|
||||
job.completedOnce = true;
|
||||
job.enabled = false;
|
||||
job.nextRunAt = null;
|
||||
} else {
|
||||
job.nextRunAt = computeNextRunAt(job, now);
|
||||
}
|
||||
job.updatedAt = now;
|
||||
this.store.setCronJob(job.id, job);
|
||||
this.broadcastListChanged();
|
||||
}
|
||||
|
||||
private async launch(job: CronJob, trigger: TriggerType): Promise<CronJobRun> {
|
||||
const run: CronJobRun = {
|
||||
id: uuidv4(),
|
||||
cronJobId: job.id,
|
||||
sessionId: null,
|
||||
sessionName: null,
|
||||
startedAt: Date.now(),
|
||||
finishedAt: null,
|
||||
status: 'created',
|
||||
triggerType: trigger,
|
||||
createdSessionUrl: null,
|
||||
};
|
||||
this.store.setCronJobRun(run.id, run);
|
||||
this.pruneRunHistory();
|
||||
this.deps.broadcast(SseEvent.CronRunCreated, run);
|
||||
|
||||
// Resolve the prompt.
|
||||
let prompt: string;
|
||||
try {
|
||||
prompt = await this.resolvePrompt(job);
|
||||
} catch (err) {
|
||||
return this.failRun(job, run, `Prompt error: ${getErrorMessage(err)}`);
|
||||
}
|
||||
|
||||
// Validate working directory.
|
||||
try {
|
||||
if (!statSync(job.workingDir).isDirectory()) {
|
||||
return this.failRun(job, run, 'workingDir is not a directory');
|
||||
}
|
||||
} catch {
|
||||
return this.failRun(job, run, 'workingDir does not exist');
|
||||
}
|
||||
|
||||
// Recurring jobs: close the still-open session created by this job's
|
||||
// previous run before launching the next (default ON, opt-out via
|
||||
// autoClosePreviousSession:false) — otherwise an unattended interval/daily
|
||||
// job accumulates a new tab per fire until the global session cap.
|
||||
if (job.scheduleType !== 'once' && job.autoClosePreviousSession !== false) {
|
||||
await this.closePreviousRunSessions(job, run.id);
|
||||
}
|
||||
|
||||
// Respect the global session cap.
|
||||
if (this.deps.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
||||
return this.failRun(job, run, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`);
|
||||
}
|
||||
|
||||
// Create + start the session (mirrors the quick-start route flow).
|
||||
let session: Session;
|
||||
try {
|
||||
const mode = job.agentType;
|
||||
const globalNice = await this.deps.getGlobalNiceConfig();
|
||||
const modelConfig = await this.deps.getModelConfig();
|
||||
const claudeModeConfig = await this.deps.getClaudeModeConfig();
|
||||
const model = mode !== 'shell' ? modelConfig?.defaultModel || undefined : undefined;
|
||||
session = new Session({
|
||||
workingDir: job.workingDir,
|
||||
mode,
|
||||
name: job.name,
|
||||
mux: this.deps.mux,
|
||||
useMux: true,
|
||||
niceConfig: globalNice,
|
||||
model,
|
||||
claudeMode: claudeModeConfig.claudeMode,
|
||||
allowedTools: claudeModeConfig.allowedTools,
|
||||
});
|
||||
this.deps.addSession(session);
|
||||
this.store.incrementSessionsCreated();
|
||||
this.deps.persistSessionState(session);
|
||||
await this.deps.setupSessionListeners(session);
|
||||
this.deps.broadcast(SseEvent.SessionCreated, this.deps.getSessionStateWithRespawn(session));
|
||||
if (mode === 'shell') {
|
||||
await session.startShell();
|
||||
} else {
|
||||
await session.startInteractive();
|
||||
}
|
||||
this.deps.broadcast(SseEvent.SessionInteractive, { id: session.id, mode });
|
||||
} catch (err) {
|
||||
return this.failRun(job, run, `Session launch failed: ${getErrorMessage(err)}`);
|
||||
}
|
||||
|
||||
run.sessionId = session.id;
|
||||
run.sessionName = session.name;
|
||||
run.createdSessionUrl = `/?session=${session.id}`;
|
||||
run.status = 'session_started';
|
||||
this.store.setCronJobRun(run.id, run);
|
||||
this.deps.broadcast(SseEvent.CronRunUpdated, run);
|
||||
this.updateJobLastStatus(job.id, 'session_started');
|
||||
|
||||
// Send the prompt once the CLI is ready (async; does not block the caller).
|
||||
this.sendPromptWhenReady(session.id, prompt, job, run);
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the prompt text and enforces the single-line constraint: prompt
|
||||
* delivery rides writeViaMux/PTY writes where a newline is Enter, so a
|
||||
* multi-line prompt would be silently corrupted (typed mode fuses lines,
|
||||
* paste mode submits the first line and dribbles the rest in as separate
|
||||
* messages). Rather than mangle an unattended agent's instructions, fail the
|
||||
* run with a clear error. A prompt FILE may end with trailing newline(s)
|
||||
* (every editor writes one) — those are stripped before the check.
|
||||
*/
|
||||
private async resolvePrompt(job: CronJob): Promise<string> {
|
||||
if (job.promptMode === 'prompt_file_path') {
|
||||
if (!job.promptFilePath) throw new Error('prompt file path is empty');
|
||||
const safePath = await this.resolveSafePromptPath(job.promptFilePath, job.workingDir);
|
||||
const content = (await readFile(safePath, 'utf-8')).replace(/[\r\n]+$/, '');
|
||||
if (HAS_NEWLINE.test(content)) {
|
||||
throw new Error('prompt file must contain a single line — multi-line prompts are not supported');
|
||||
}
|
||||
return content;
|
||||
}
|
||||
const text = job.promptText ?? '';
|
||||
if (HAS_NEWLINE.test(text)) {
|
||||
// Schema-rejected since this check was added; guards legacy persisted jobs.
|
||||
throw new Error('promptText must be a single line — multi-line prompts are not supported');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards a prompt-file path before it is read. The path is user-supplied via
|
||||
* the API and its contents are injected into an agent session (an exfil sink
|
||||
* over SSE/terminal), so an unconfined read would let a hostile job config
|
||||
* pull arbitrary host files — including the SERVER PROCESS'S OWN secrets via
|
||||
* `/proc/self/environ` — into the session.
|
||||
*
|
||||
* A denylist is the wrong posture for an exfil sink (it kept missing `/proc`,
|
||||
* `/dev`, other users' `~/.ssh`, modern cloud creds…). So the PRIMARY gate is
|
||||
* an allowlist: the prompt file must resolve INSIDE the job's working
|
||||
* directory. A symlink escaping the workspace fails this because we check the
|
||||
* realpath-resolved target. We additionally require a regular file (rejects
|
||||
* directories, FIFOs, and `/dev/*` character devices that would hang or OOM
|
||||
* the unbounded read) within a sane size cap, and keep the shared blocklist as
|
||||
* cheap defense-in-depth. Returns the symlink-resolved path to read.
|
||||
*/
|
||||
private async resolveSafePromptPath(rawPath: string, workingDir: string): Promise<string> {
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = realpathSync(rawPath);
|
||||
} catch {
|
||||
throw new Error('prompt file path could not be resolved');
|
||||
}
|
||||
|
||||
// workingDir is USER-CONTROLLED, so it is not a trust boundary by itself:
|
||||
// realpath-resolve it (a symlinked workspace must not defeat containment)
|
||||
// and reject blocked/pseudo-fs trees — otherwise workingDir '/proc' would
|
||||
// make '/proc/self/environ' pass the containment check below.
|
||||
let realWorkingDir: string;
|
||||
try {
|
||||
realWorkingDir = realpathSync(workingDir);
|
||||
} catch {
|
||||
throw new Error('job working directory could not be resolved');
|
||||
}
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
const blockedTrees = [...guard.blockedTrees, ...CRON_PSEUDO_FS_TREES];
|
||||
if (realWorkingDir === '/' || isBlockedAttachmentPath(realWorkingDir, blockedTrees)) {
|
||||
throw new Error('job working directory is blocked');
|
||||
}
|
||||
|
||||
// Defense-in-depth blocklist (secret locations, /etc, /root, pseudo-fs).
|
||||
if (isBlockedAttachmentPath(resolved, blockedTrees)) {
|
||||
throw new Error('prompt file path is blocked');
|
||||
}
|
||||
|
||||
// Primary gate: the prompt file must live inside the job's workspace.
|
||||
if (!validateSessionFilePath(realWorkingDir, resolved)) {
|
||||
throw new Error('prompt file path must be inside the job working directory');
|
||||
}
|
||||
|
||||
// Reject non-regular files and oversized files (DoS via unbounded read).
|
||||
let info;
|
||||
try {
|
||||
info = statSync(resolved);
|
||||
} catch {
|
||||
throw new Error('prompt file path could not be resolved');
|
||||
}
|
||||
if (!info.isFile()) throw new Error('prompt file path is not a regular file');
|
||||
if (info.size > MAX_PROMPT_FILE_BYTES) throw new Error('prompt file is too large');
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private sendPromptWhenReady(sessionId: string, prompt: string, job: CronJob, run: CronJobRun): void {
|
||||
setImmediate(() => {
|
||||
const poll = async (): Promise<void> => {
|
||||
if (job.agentType !== 'shell') {
|
||||
for (let attempt = 0; attempt < CRON_READY_MAX_ATTEMPTS; attempt++) {
|
||||
await delay(500);
|
||||
const s = this.deps.sessions.get(sessionId);
|
||||
if (!s) return; // session was removed
|
||||
const buf = s.getTerminalBuffer().slice(-2048);
|
||||
if (buf.includes('❯') || buf.includes('tokens')) break;
|
||||
}
|
||||
await delay(CRON_READY_SETTLE_MS);
|
||||
} else {
|
||||
await delay(1000);
|
||||
// Shell mode: deliver the optional custom launch command as the
|
||||
// first input line (single-line, schema-enforced), then give it a
|
||||
// moment to start before the prompt follows.
|
||||
if (job.launchCommand) {
|
||||
const shell = this.deps.sessions.get(sessionId);
|
||||
if (!shell) return;
|
||||
const sent = await shell.writeViaMux(`${job.launchCommand}\r`);
|
||||
if (!sent) {
|
||||
this.failRun(job, run, 'Failed to send launch command: mux write failed');
|
||||
return;
|
||||
}
|
||||
await delay(1000);
|
||||
}
|
||||
}
|
||||
const s = this.deps.sessions.get(sessionId);
|
||||
if (!s) return;
|
||||
try {
|
||||
const payload = prompt.endsWith('\r') ? prompt : `${prompt}\r`;
|
||||
let delivered = true;
|
||||
if (job.inputMode === 'paste') {
|
||||
s.write(payload);
|
||||
} else {
|
||||
delivered = await s.writeViaMux(payload);
|
||||
}
|
||||
if (!delivered) {
|
||||
this.failRun(job, run, 'Failed to send prompt: mux write failed');
|
||||
return;
|
||||
}
|
||||
run.status = 'prompt_sent';
|
||||
run.finishedAt = Date.now();
|
||||
this.store.setCronJobRun(run.id, run);
|
||||
this.deps.broadcast(SseEvent.CronRunUpdated, run);
|
||||
this.updateJobLastStatus(job.id, 'prompt_sent');
|
||||
} catch (err) {
|
||||
this.failRun(job, run, `Failed to send prompt: ${getErrorMessage(err)}`);
|
||||
}
|
||||
};
|
||||
poll().catch((err) => console.error('[cron] sendPromptWhenReady error:', getErrorMessage(err)));
|
||||
});
|
||||
}
|
||||
|
||||
/** 400-shaped error for route handlers (mirrors parseBody's error contract). */
|
||||
private badRequest(msg: string): Error {
|
||||
return Object.assign(new Error(msg), {
|
||||
statusCode: 400,
|
||||
body: createErrorResponse(ApiErrorCode.INVALID_INPUT, msg),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create/update gate for a job's workingDir: must exist, be a directory, and
|
||||
* not resolve into a blocked or pseudo-filesystem tree (nor the fs root).
|
||||
* The user-supplied workingDir doubles as the prompt-file confinement root,
|
||||
* so an unrestricted value would defeat that boundary (e.g. '/proc').
|
||||
*/
|
||||
private assertValidWorkingDir(workingDir: string): void {
|
||||
let real: string;
|
||||
try {
|
||||
real = realpathSync(workingDir);
|
||||
} catch {
|
||||
throw this.badRequest('workingDir does not exist');
|
||||
}
|
||||
if (!statSync(real).isDirectory()) throw this.badRequest('workingDir is not a directory');
|
||||
if (real === '/' || isBlockedAttachmentPath(real, CRON_WORKING_DIR_BLOCKED_TREES)) {
|
||||
throw this.badRequest('workingDir is not allowed (blocked or pseudo-filesystem tree)');
|
||||
}
|
||||
}
|
||||
|
||||
/** Close still-open sessions created by this job's previous runs (normal cleanup path). */
|
||||
private async closePreviousRunSessions(job: CronJob, currentRunId: string): Promise<void> {
|
||||
for (const prev of this.listRuns(job.id)) {
|
||||
if (prev.id === currentRunId || !prev.sessionId) continue;
|
||||
if (!this.deps.sessions.has(prev.sessionId)) continue;
|
||||
try {
|
||||
await this.deps.cleanupSession(prev.sessionId, true, 'cron: superseded by the next run of this job');
|
||||
} catch (err) {
|
||||
console.error(`[cron] failed to auto-close previous session ${prev.sessionId}:`, getErrorMessage(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private failRun(job: CronJob, run: CronJobRun, message: string): CronJobRun {
|
||||
run.status = 'failed';
|
||||
run.errorMessage = message;
|
||||
run.finishedAt = Date.now();
|
||||
this.store.setCronJobRun(run.id, run);
|
||||
this.deps.broadcast(SseEvent.CronRunUpdated, run);
|
||||
this.updateJobLastStatus(job.id, 'failed');
|
||||
return run;
|
||||
}
|
||||
|
||||
private recordSkippedRun(job: CronJob): void {
|
||||
// Coalesce consecutive skips: if the job is already in a skip streak, don't
|
||||
// record again — a perpetually-skipped interval job would otherwise write a
|
||||
// run every tick forever and bloat state.json.
|
||||
if (this.listRuns(job.id)[0]?.status === 'skipped') return;
|
||||
|
||||
const now = Date.now();
|
||||
const run: CronJobRun = {
|
||||
id: uuidv4(),
|
||||
cronJobId: job.id,
|
||||
sessionId: null,
|
||||
sessionName: null,
|
||||
startedAt: now,
|
||||
finishedAt: now,
|
||||
status: 'skipped',
|
||||
errorMessage: `Skipped: a ${job.agentType} agent is already running (concurrency policy)`,
|
||||
triggerType: 'scheduled',
|
||||
createdSessionUrl: null,
|
||||
};
|
||||
this.store.setCronJobRun(run.id, run);
|
||||
this.pruneRunHistory();
|
||||
this.deps.broadcast(SseEvent.CronRunCreated, run);
|
||||
// A skip is NOT a run: surface it as the lastStatus, but do NOT advance
|
||||
// lastRunAt (no session was created).
|
||||
this.updateJobLastStatus(job.id, 'skipped', { touchLastRun: false });
|
||||
}
|
||||
|
||||
/** Prune the oldest run records (by startedAt) once the global cap is exceeded. */
|
||||
private pruneRunHistory(): void {
|
||||
const runs = Object.values(this.store.getCronJobRuns());
|
||||
if (runs.length <= MAX_CRON_RUN_HISTORY) return;
|
||||
runs.sort((a, b) => a.startedAt - b.startedAt);
|
||||
for (const run of runs.slice(0, runs.length - MAX_CRON_RUN_HISTORY)) {
|
||||
this.store.removeCronJobRun(run.id);
|
||||
}
|
||||
}
|
||||
|
||||
private updateJobLastStatus(jobId: string, status: CronJobRunStatus, opts: { touchLastRun?: boolean } = {}): void {
|
||||
const fresh = this.store.getCronJob(jobId);
|
||||
if (!fresh) return;
|
||||
const now = Date.now();
|
||||
fresh.lastStatus = status;
|
||||
if (opts.touchLastRun !== false) fresh.lastRunAt = now;
|
||||
fresh.updatedAt = now;
|
||||
this.store.setCronJob(fresh.id, fresh);
|
||||
this.broadcastListChanged();
|
||||
}
|
||||
|
||||
private broadcastListChanged(): void {
|
||||
this.deps.broadcast(SseEvent.CronJobsChanged, { jobs: this.listJobs() });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @fileoverview Pure next-run-time calculations for the cron.
|
||||
*
|
||||
* All functions are pure and take an explicit `after` timestamp (epoch ms) so
|
||||
* they are deterministic and unit-testable. Times use the SERVER'S LOCAL
|
||||
* timezone for v0.1 (per the build brief) — daily/weekly wall-clock times are
|
||||
* interpreted via the host's local time.
|
||||
*/
|
||||
|
||||
import type { CronJob } from '../types/cron.js';
|
||||
|
||||
/** Parse an 'HH:MM' (24-hour) string into hours/minutes, or null if invalid. */
|
||||
export function parseHHMM(value: string | undefined): { hours: number; minutes: number } | null {
|
||||
if (!value) return null;
|
||||
const m = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
|
||||
if (!m) return null;
|
||||
const hours = Number(m[1]);
|
||||
const minutes = Number(m[2]);
|
||||
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;
|
||||
return { hours, minutes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the epoch-ms timestamp for `hours:minutes` (local time) on the day of
|
||||
* `base`, shifted by `dayOffset` days.
|
||||
*/
|
||||
function atLocalTime(base: number, hours: number, minutes: number, dayOffset: number): number {
|
||||
const d = new Date(base);
|
||||
d.setHours(hours, minutes, 0, 0);
|
||||
d.setDate(d.getDate() + dayOffset);
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the next fire time strictly relevant to `after`, or null if the job
|
||||
* has no future run (e.g. a completed one-time job, or invalid config).
|
||||
*
|
||||
* For `once`, returns the absolute `runAt` (even if already in the past, so a
|
||||
* missed one-time job still fires once) until it has `completedOnce`.
|
||||
*/
|
||||
export function computeNextRunAt(job: CronJob, after: number): number | null {
|
||||
switch (job.scheduleType) {
|
||||
case 'once': {
|
||||
if (job.completedOnce) return null;
|
||||
return typeof job.runAt === 'number' ? job.runAt : null;
|
||||
}
|
||||
case 'interval': {
|
||||
const minutes = job.intervalMinutes;
|
||||
if (!minutes || minutes <= 0) return null;
|
||||
return after + minutes * 60_000;
|
||||
}
|
||||
case 'daily': {
|
||||
const t = parseHHMM(job.dailyTime);
|
||||
if (!t) return null;
|
||||
let next = atLocalTime(after, t.hours, t.minutes, 0);
|
||||
if (next <= after) next = atLocalTime(after, t.hours, t.minutes, 1);
|
||||
return next;
|
||||
}
|
||||
case 'weekly': {
|
||||
const t = parseHHMM(job.weeklyTime);
|
||||
if (!t) return null;
|
||||
const days = (job.weeklyDays ?? []).filter((d) => d >= 0 && d <= 6);
|
||||
if (days.length === 0) return null;
|
||||
for (let offset = 0; offset <= 7; offset++) {
|
||||
const cand = atLocalTime(after, t.hours, t.minutes, offset);
|
||||
if (cand > after && days.includes(new Date(cand).getDay())) return cand;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate-launch guard key: identifies a specific due time for a job. The
|
||||
* cron records the key it last consumed so an overlapping or restarted
|
||||
* loop will not launch the same due time twice.
|
||||
*/
|
||||
export function dueKeyFor(jobId: string, fireTime: number): string {
|
||||
return `${jobId}:${fireTime}`;
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* @fileoverview Docker case export / import: move a container (toolchain + any
|
||||
* in-image changes) PLUS its workspace to another machine as one portable
|
||||
* `.codeman-container.tgz`, and restore it.
|
||||
*
|
||||
* A full-image export = `docker commit` the running container to an image ->
|
||||
* `docker save` that image -> tar the bind-mounted workspace -> a manifest, all
|
||||
* bundled into one gzip tarball. A workspace-only export skips the image (fast,
|
||||
* files-only). Import validates the manifest + per-member checksums, extracts the
|
||||
* workspace with a path-traversal guard, `docker load`s the image and RE-TAGS it
|
||||
* into a quarantined namespace (never overwriting a local tag), and hands the
|
||||
* caller enough to recreate a hardened case on the destination.
|
||||
*
|
||||
* Safety (all from the design critic): pause the container spanning the workspace
|
||||
* tar AND the commit so the two artifacts are mutually consistent; a free-space
|
||||
* precheck (a full docker graph wedges EVERY session on the host); `docker rmi`
|
||||
* the intermediate image in a finally; sealed containers refuse a full-image
|
||||
* export (an in-container login would ride the committed layer); import rejects
|
||||
* absolute / `..` tar members and checksum mismatches. Bounded by
|
||||
* runWithConversionLimit so N exports cannot fork-bomb the host.
|
||||
*
|
||||
* @module docker-export
|
||||
*/
|
||||
|
||||
import { createReadStream, createWriteStream, existsSync, mkdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join, basename } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
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';
|
||||
|
||||
const IS_TEST_MODE = !!process.env.VITEST;
|
||||
|
||||
/** Refuse to export when the target filesystem has less than this free (a full graph wedges the daemon). */
|
||||
export const DOCKER_EXPORT_MIN_FREE_BYTES = 2 * 1024 * 1024 * 1024; // 2 GiB
|
||||
|
||||
/** Manifest schema version (bump on any breaking field change). */
|
||||
export const DOCKER_EXPORT_SCHEMA = 1;
|
||||
|
||||
export type DockerExportMode = 'full' | 'workspace';
|
||||
|
||||
export interface DockerExportManifest {
|
||||
schemaVersion: number;
|
||||
caseName: string;
|
||||
mode: DockerExportMode;
|
||||
engine: DockerEngine;
|
||||
image: string;
|
||||
containerWorkdir: string;
|
||||
network: string;
|
||||
createdAt: number;
|
||||
codemanVersion: string;
|
||||
mountCredentials: boolean;
|
||||
/** True when the bundle provably carries no credentials (convenient-mode workspace, or a full image whose creds were bind-mounted and thus never committed). */
|
||||
secretFree: boolean;
|
||||
/** sha256 of each bundle member that is present. */
|
||||
checksums: { image?: string; workspace?: string };
|
||||
}
|
||||
|
||||
// ========== Pure helpers (unit-tested) ==========
|
||||
|
||||
/** Raw argv prefix for the engine (NO shell escaping — used with spawn). */
|
||||
export function dockerArgv(docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>): string[] {
|
||||
const argv: string[] = [docker.engine === 'podman' ? 'podman' : 'docker'];
|
||||
if (docker.context) argv.push('--context', docker.context);
|
||||
if (docker.daemonHost) argv.push('-H', docker.daemonHost);
|
||||
return argv;
|
||||
}
|
||||
|
||||
/** Portable bundle filename for a case export. */
|
||||
export function exportBundleName(caseName: string, timestamp: number, mode: DockerExportMode): string {
|
||||
const suffix = mode === 'workspace' ? 'workspace' : 'container';
|
||||
return `${caseName}-${timestamp}.codeman-${suffix}.tgz`;
|
||||
}
|
||||
|
||||
/** Quarantined image tag for an imported bundle (never overwrites a local tag). */
|
||||
export function importedImageTag(caseName: string, timestamp: number): string {
|
||||
return `codeman/imported-${caseName}:${timestamp}`;
|
||||
}
|
||||
|
||||
/** Intermediate commit tag for a full-image export (unique per export, rmi'd in finally). */
|
||||
export function exportImageTag(caseName: string, timestamp: number): string {
|
||||
return `codeman/export-${caseName}:${timestamp}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a tar member path that would escape the extraction root (absolute path
|
||||
* or a `..` component). The import-side traversal guard.
|
||||
*/
|
||||
export function isSafeTarMember(member: string): boolean {
|
||||
const trimmed = member.trim();
|
||||
if (!trimmed || trimmed === './') return true;
|
||||
if (trimmed.startsWith('/')) return false;
|
||||
// Normalize separators and check each component.
|
||||
return !trimmed.split('/').some((part) => part === '..');
|
||||
}
|
||||
|
||||
/** Parse the image id/ref from `docker load` output ("Loaded image: x" / "Loaded image ID: sha256:..."). */
|
||||
export function parseLoadedImageRef(loadOutput: string): string | null {
|
||||
const idMatch = loadOutput.match(/Loaded image ID:\s*(sha256:[0-9a-f]+)/i);
|
||||
if (idMatch) return idMatch[1];
|
||||
const refMatch = loadOutput.match(/Loaded image:\s*(\S+)/i);
|
||||
if (refMatch) return refMatch[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========== IO helpers ==========
|
||||
|
||||
function run(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: { timeout?: number } = {}
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
if (opts.timeout) {
|
||||
timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
reject(new Error(`${cmd} timed out after ${opts.timeout}ms`));
|
||||
}, opts.timeout);
|
||||
}
|
||||
child.stdout.on('data', (d) => (stdout += d));
|
||||
child.stderr.on('data', (d) => (stderr += d));
|
||||
child.on('error', (err) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (code === 0) resolve({ stdout, stderr });
|
||||
else reject(new Error(`${cmd} ${args.join(' ')} exited ${code}: ${stderr.trim()}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream `docker save <tag>` stdout to a raw tar file (no shell, no double-gzip).
|
||||
* Uses stream `pipeline` so completion means the write stream is FULLY flushed to
|
||||
* disk (a naive child 'close' resolves before the last chunks land, truncating the
|
||||
* file — a real bug caught in end-to-end testing), AND waits for a clean exit code.
|
||||
*/
|
||||
async function saveImageToTar(argv: string[], tag: string, outPath: string): Promise<void> {
|
||||
const child = spawn(argv[0], [...argv.slice(1), 'save', tag], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stderr = '';
|
||||
child.stderr.on('data', (d) => (stderr += d));
|
||||
const exited = new Promise<void>((resolve, reject) => {
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) =>
|
||||
code === 0 ? resolve() : reject(new Error(`docker save exited ${code}: ${stderr.trim()}`))
|
||||
);
|
||||
});
|
||||
// pipeline resolves only after the destination has fully flushed.
|
||||
await Promise.all([pipeline(child.stdout, createWriteStream(outPath)), exited]);
|
||||
}
|
||||
|
||||
async function sha256File(path: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = createHash('sha256');
|
||||
const stream = createReadStream(path);
|
||||
stream.on('data', (d) => hash.update(d));
|
||||
stream.on('error', reject);
|
||||
stream.on('end', () => resolve(hash.digest('hex')));
|
||||
});
|
||||
}
|
||||
|
||||
async function freeBytes(path: string): Promise<number> {
|
||||
try {
|
||||
const stat = await fs.statfs(path);
|
||||
return Number(stat.bavail) * Number(stat.bsize);
|
||||
} catch {
|
||||
return Number.POSITIVE_INFINITY; // statfs unsupported — don't block
|
||||
}
|
||||
}
|
||||
|
||||
async function isContainerRunning(argv: string[], container: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await run(argv[0], [...argv.slice(1), 'inspect', '-f', '{{.State.Running}}', container], {
|
||||
timeout: 15_000,
|
||||
});
|
||||
return stdout.trim() === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExportResult {
|
||||
bundlePath: string;
|
||||
manifest: DockerExportManifest;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a docker case to a portable bundle. Bounded by runWithConversionLimit.
|
||||
* `full` mode commits + saves the image AND tars the workspace; `workspace` mode
|
||||
* tars just the workspace. The container is paused across the artifact capture so
|
||||
* image and workspace are mutually consistent.
|
||||
*/
|
||||
export async function exportDockerCase(params: {
|
||||
docker: SessionDocker;
|
||||
caseName: string;
|
||||
timestamp: number;
|
||||
exportsDir: string;
|
||||
mode: DockerExportMode;
|
||||
codemanVersion: string;
|
||||
}): Promise<ExportResult> {
|
||||
const { docker, caseName, timestamp, exportsDir, mode, codemanVersion } = params;
|
||||
|
||||
if (mode === 'full' && !docker.mountCredentials) {
|
||||
throw new Error(
|
||||
'full-image export is refused for a sealed (mountCredentials:false) container: an in-container login would ride the committed image layer. Use a workspace-only export.'
|
||||
);
|
||||
}
|
||||
|
||||
if (IS_TEST_MODE) {
|
||||
// No real docker/tar under vitest — return a deterministic stub.
|
||||
const manifest: DockerExportManifest = {
|
||||
schemaVersion: DOCKER_EXPORT_SCHEMA,
|
||||
caseName,
|
||||
mode,
|
||||
engine: docker.engine,
|
||||
image: docker.image,
|
||||
containerWorkdir: docker.containerWorkdir,
|
||||
network: docker.network,
|
||||
createdAt: timestamp,
|
||||
codemanVersion,
|
||||
mountCredentials: docker.mountCredentials,
|
||||
secretFree: true,
|
||||
checksums: {},
|
||||
};
|
||||
return { bundlePath: join(exportsDir, exportBundleName(caseName, timestamp, mode)), manifest, sizeBytes: 0 };
|
||||
}
|
||||
|
||||
return runWithConversionLimit(async () => {
|
||||
if (!existsSync(exportsDir)) mkdirSync(exportsDir, { recursive: true });
|
||||
|
||||
const free = await freeBytes(exportsDir);
|
||||
if (free < DOCKER_EXPORT_MIN_FREE_BYTES) {
|
||||
throw new Error(
|
||||
`not enough free space to export (need >= ${Math.round(DOCKER_EXPORT_MIN_FREE_BYTES / 1e9)}GB, have ${Math.round(free / 1e9)}GB). A full docker graph wedges every session on the host.`
|
||||
);
|
||||
}
|
||||
|
||||
const argv = dockerArgv(docker);
|
||||
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);
|
||||
let commitTag: string | undefined;
|
||||
|
||||
try {
|
||||
if (wasRunning) {
|
||||
await run(argv[0], [...argv.slice(1), 'pause', docker.containerName], { timeout: 30_000 }).catch(() => {});
|
||||
}
|
||||
|
||||
const checksums: DockerExportManifest['checksums'] = {};
|
||||
|
||||
if (mode === 'full') {
|
||||
commitTag = exportImageTag(caseName, timestamp);
|
||||
// Blank instance-specific committed env so the image carries no stale host refs.
|
||||
await run(
|
||||
argv[0],
|
||||
[
|
||||
...argv.slice(1),
|
||||
'commit',
|
||||
'-c',
|
||||
'ENV CODEMAN_API_URL=',
|
||||
'-c',
|
||||
'ENV CODEMAN_HOOK_SECRET_FILE=',
|
||||
docker.containerName,
|
||||
commitTag,
|
||||
],
|
||||
{ timeout: 300_000 }
|
||||
);
|
||||
const imageTar = join(stageDir, 'image.tar');
|
||||
await saveImageToTar(argv, commitTag, imageTar);
|
||||
checksums.image = await sha256File(imageTar);
|
||||
}
|
||||
|
||||
const workspaceTar = join(stageDir, 'workspace.tar');
|
||||
await run('tar', ['-cf', workspaceTar, '-C', docker.hostWorkspacePath, '.'], { timeout: 300_000 });
|
||||
checksums.workspace = await sha256File(workspaceTar);
|
||||
|
||||
const manifest: DockerExportManifest = {
|
||||
schemaVersion: DOCKER_EXPORT_SCHEMA,
|
||||
caseName,
|
||||
mode,
|
||||
engine: docker.engine,
|
||||
image: docker.image,
|
||||
containerWorkdir: docker.containerWorkdir,
|
||||
network: docker.network,
|
||||
createdAt: timestamp,
|
||||
codemanVersion,
|
||||
mountCredentials: docker.mountCredentials,
|
||||
// Convenient mode keeps creds on bind mounts (never committed), so the bundle is secret-free.
|
||||
secretFree: docker.mountCredentials,
|
||||
checksums,
|
||||
};
|
||||
await fs.writeFile(join(stageDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||||
|
||||
const members =
|
||||
mode === 'full' ? ['manifest.json', 'image.tar', 'workspace.tar'] : ['manifest.json', 'workspace.tar'];
|
||||
await run('tar', ['-czf', bundlePath, '-C', stageDir, ...members], { timeout: 300_000 });
|
||||
|
||||
const stat = await fs.stat(bundlePath);
|
||||
return { bundlePath, manifest, sizeBytes: stat.size };
|
||||
} finally {
|
||||
// Always remove the intermediate image + stage dir, and unpause.
|
||||
if (commitTag) {
|
||||
await run(argv[0], [...argv.slice(1), 'rmi', commitTag], { timeout: 60_000 }).catch(() => {});
|
||||
}
|
||||
await fs.rm(stageDir, { recursive: true, force: true }).catch(() => {});
|
||||
if (wasRunning) {
|
||||
await run(argv[0], [...argv.slice(1), 'unpause', docker.containerName], { timeout: 30_000 }).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
manifest: DockerExportManifest;
|
||||
/** Quarantined image ref the destination case should use (full mode only). */
|
||||
importedImage?: string;
|
||||
/** Directory the workspace was extracted into. */
|
||||
workspacePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a bundle produced by exportDockerCase: validate the manifest + per-member
|
||||
* checksums, extract the workspace (traversal-guarded) into destWorkspace, and, in
|
||||
* full mode, `docker load` the image and re-tag it into a quarantined namespace.
|
||||
*/
|
||||
export async function importDockerBundle(params: {
|
||||
bundlePath: string;
|
||||
destWorkspace: string;
|
||||
engine: DockerEngine;
|
||||
timestamp: number;
|
||||
}): Promise<ImportResult> {
|
||||
const { bundlePath, destWorkspace, engine, timestamp } = params;
|
||||
const argv: string[] = [engine === 'podman' ? 'podman' : 'docker'];
|
||||
|
||||
if (IS_TEST_MODE) {
|
||||
const raw = await fs.readFile(bundlePath, 'utf-8').catch(() => '{}');
|
||||
return { manifest: JSON.parse(raw) as DockerExportManifest, workspacePath: destWorkspace };
|
||||
}
|
||||
|
||||
const stageDir = `${destWorkspace}.import-stage-${timestamp}`;
|
||||
mkdirSync(stageDir, { recursive: true });
|
||||
try {
|
||||
await run('tar', ['-xzf', bundlePath, '-C', stageDir], { timeout: 300_000 });
|
||||
|
||||
const manifestRaw = await fs.readFile(join(stageDir, 'manifest.json'), 'utf-8');
|
||||
const manifest = JSON.parse(manifestRaw) as DockerExportManifest;
|
||||
if (manifest.schemaVersion !== DOCKER_EXPORT_SCHEMA) {
|
||||
throw new Error(`unsupported export schema version ${manifest.schemaVersion} (expected ${DOCKER_EXPORT_SCHEMA})`);
|
||||
}
|
||||
|
||||
// Integrity: verify checksums before trusting any member.
|
||||
const workspaceTar = join(stageDir, 'workspace.tar');
|
||||
if (manifest.checksums.workspace) {
|
||||
const actual = await sha256File(workspaceTar);
|
||||
if (actual !== manifest.checksums.workspace)
|
||||
throw new Error('workspace checksum mismatch (corrupt or tampered bundle)');
|
||||
}
|
||||
|
||||
// Traversal guard: reject absolute / `..` members before extraction.
|
||||
const { stdout: memberList } = await run('tar', ['-tf', workspaceTar], { timeout: 60_000 });
|
||||
for (const member of memberList.split('\n').filter(Boolean)) {
|
||||
if (!isSafeTarMember(member)) throw new Error(`unsafe path in workspace archive: ${member}`);
|
||||
}
|
||||
mkdirSync(destWorkspace, { recursive: true });
|
||||
await run('tar', ['--no-same-owner', '-xf', workspaceTar, '-C', destWorkspace], { timeout: 300_000 });
|
||||
|
||||
let importedImage: string | undefined;
|
||||
if (manifest.mode === 'full') {
|
||||
const imageTar = join(stageDir, 'image.tar');
|
||||
if (manifest.checksums.image) {
|
||||
const actual = await sha256File(imageTar);
|
||||
if (actual !== manifest.checksums.image)
|
||||
throw new Error('image checksum mismatch (corrupt or tampered bundle)');
|
||||
}
|
||||
const { stdout } = await run(argv[0], [...argv.slice(1), 'load', '-i', imageTar], { timeout: 300_000 });
|
||||
const loadedRef = parseLoadedImageRef(stdout);
|
||||
if (!loadedRef) throw new Error('could not determine loaded image ref');
|
||||
// Quarantine: re-tag by the loaded ref/id, never trusting the bundle's original tag.
|
||||
importedImage = importedImageTag(manifest.caseName, timestamp);
|
||||
await run(argv[0], [...argv.slice(1), 'tag', loadedRef, importedImage], { timeout: 60_000 });
|
||||
}
|
||||
|
||||
return { manifest, importedImage, workspacePath: destWorkspace };
|
||||
} finally {
|
||||
await fs.rm(stageDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** List export bundles in the exports dir (newest first), with size + mtime. */
|
||||
export async function listDockerExports(
|
||||
exportsDir: string
|
||||
): Promise<Array<{ name: string; sizeBytes: number; mtimeMs: number }>> {
|
||||
if (!existsSync(exportsDir)) return [];
|
||||
const entries = await fs.readdir(exportsDir).catch(() => [] as string[]);
|
||||
const out: Array<{ name: string; sizeBytes: number; mtimeMs: number }> = [];
|
||||
for (const name of entries) {
|
||||
if (!name.endsWith('.tgz')) continue;
|
||||
try {
|
||||
const stat = await fs.stat(join(exportsDir, name));
|
||||
out.push({ name: basename(name), sizeBytes: stat.size, mtimeMs: stat.mtimeMs });
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
}
|
||||
@@ -0,0 +1,956 @@
|
||||
/**
|
||||
* @fileoverview Docker cases: storage, pure command-arg builders, and daemon probes.
|
||||
*
|
||||
* Docker mode is a LOCATION OVERLAY on cases (not a 6th SessionMode), the direct
|
||||
* analog of the remote-SSH feature in `remote-hosts.ts`. Instead of a local tmux
|
||||
* pane running `ssh host` into a durable remote tmux server, a local tmux pane
|
||||
* runs `docker exec -it` into a durable IN-CONTAINER tmux server. The container is
|
||||
* scoped to the CASE (`codeman-case-<name>`), so multiple sessions can `docker
|
||||
* exec` into the same long-lived container.
|
||||
*
|
||||
* This module mirrors `remote-hosts.ts`:
|
||||
* - JSON storage for hosts (`docker-hosts.json`) and cases (`docker-cases.json`)
|
||||
* - `toSessionDocker()` (mirror of `toSessionRemote`)
|
||||
* - `buildDockerBaseArgs()` / `buildDockerCreateArgs()` (mirror of `buildSshConnectionArgs`)
|
||||
* - `checkDockerAvailable()` / `checkDockerTmuxAvailable()` (mirror of `checkRemoteTmuxAvailable`)
|
||||
*
|
||||
* The launch/kill command orchestration (`buildDockerLaunchCommand`,
|
||||
* `buildDockerKillCommand`, `dockerTmuxSessionName`) lives in `tmux-manager.ts`,
|
||||
* mirroring where `buildRemoteLaunchCommand` lives.
|
||||
*
|
||||
* @module docker-hosts
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import type {
|
||||
DockerCase,
|
||||
DockerCommandMode,
|
||||
DockerEngine,
|
||||
DockerHost,
|
||||
DockerNetworkMode,
|
||||
DockerResourceLimits,
|
||||
SessionDocker,
|
||||
SessionMode,
|
||||
} from './types.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Under vitest, all real `docker` invocations no-op (mirror of tmux-manager's IS_TEST_MODE). */
|
||||
const IS_TEST_MODE = !!process.env.VITEST;
|
||||
|
||||
const DOCKER_HOSTS_FILE = 'docker-hosts.json';
|
||||
const DOCKER_CASES_FILE = 'docker-cases.json';
|
||||
|
||||
/** Locally-built base image (see scripts/build-agent-image.mjs). */
|
||||
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';
|
||||
|
||||
/** Per-case container name prefix. The `case` letters deliberately do NOT matter to
|
||||
* tmux; this is a DOCKER name (`^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`), and case names are
|
||||
* already validated `^[a-zA-Z0-9_-]+$`, so `codeman-case-<name>` is always valid. */
|
||||
const CONTAINER_NAME_PREFIX = 'codeman-case-';
|
||||
|
||||
/** Sensible resource defaults (all overridable per host). */
|
||||
export const DEFAULT_DOCKER_RESOURCES: DockerResourceLimits = {
|
||||
memory: '4g',
|
||||
cpus: '2',
|
||||
pidsLimit: 512,
|
||||
nofile: '4096:8192',
|
||||
};
|
||||
|
||||
// ========== Storage (mirror of remote-hosts.ts) ==========
|
||||
|
||||
export function dockerHostsPath(configDir: string): string {
|
||||
return join(configDir, DOCKER_HOSTS_FILE);
|
||||
}
|
||||
|
||||
export function dockerCasesPath(configDir: string): string {
|
||||
return join(configDir, DOCKER_CASES_FILE);
|
||||
}
|
||||
|
||||
async function readJsonArray<T>(path: string): Promise<T[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(path, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as T[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJsonArray<T>(configDir: string, path: string, value: T[]): Promise<void> {
|
||||
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
|
||||
await fs.writeFile(path, JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
export async function readDockerHosts(configDir: string): Promise<DockerHost[]> {
|
||||
return readJsonArray<DockerHost>(dockerHostsPath(configDir));
|
||||
}
|
||||
|
||||
export async function writeDockerHosts(configDir: string, hosts: DockerHost[]): Promise<void> {
|
||||
await writeJsonArray(configDir, dockerHostsPath(configDir), hosts);
|
||||
}
|
||||
|
||||
export async function readDockerCases(configDir: string): Promise<DockerCase[]> {
|
||||
return readJsonArray<DockerCase>(dockerCasesPath(configDir));
|
||||
}
|
||||
|
||||
export async function writeDockerCases(configDir: string, cases: DockerCase[]): Promise<void> {
|
||||
await writeJsonArray(configDir, dockerCasesPath(configDir), cases);
|
||||
}
|
||||
|
||||
// ========== Naming / display / defaults ==========
|
||||
|
||||
/** Per-case container name. Mirrors how remote derives a stable name from the case. */
|
||||
export function dockerContainerName(caseName: string): string {
|
||||
return `${CONTAINER_NAME_PREFIX}${caseName}`;
|
||||
}
|
||||
|
||||
/** Default pane command per CLI mode (mirror of defaultRemoteCommandForMode). */
|
||||
export function defaultDockerCommandForMode(mode: SessionMode): string {
|
||||
const commands: Record<DockerCommandMode, string> = {
|
||||
shell: 'exec bash -l',
|
||||
// Mirror the LOCAL claude default so the in-container agent runs non-interactively.
|
||||
claude: 'exec claude --dangerously-skip-permissions',
|
||||
opencode: 'exec opencode',
|
||||
codex: 'exec codex',
|
||||
gemini: 'exec gemini',
|
||||
};
|
||||
return commands[mode as DockerCommandMode] || commands.shell;
|
||||
}
|
||||
|
||||
/** `container:/workdir` display string (mirror of remoteDisplayPath's `user@host:path`). */
|
||||
export function dockerDisplayPath(
|
||||
docker: Pick<SessionDocker, 'containerName' | 'containerWorkdir'> | { container: string; path: string }
|
||||
): string {
|
||||
if ('containerName' in docker) return `${docker.containerName}:${docker.containerWorkdir}`;
|
||||
return `${docker.container}:${docker.path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The host-callback gateway alias is ENGINE-SPECIFIC: Docker exposes the host as
|
||||
* `host.docker.internal`, Podman as `host.containers.internal`. Both are added to
|
||||
* the host-guard allowlist so a mixed fleet keeps working.
|
||||
*/
|
||||
export function hostGatewayAlias(engine: DockerEngine): string {
|
||||
return engine === 'podman' ? 'host.containers.internal' : 'host.docker.internal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the server's own `CODEMAN_API_URL` to a container-reachable one by
|
||||
* swapping ONLY the hostname for the engine's host-gateway alias, preserving
|
||||
* scheme AND port (prod is HTTPS on 3000, so hardcoding http://…:3000 breaks
|
||||
* every hook). Falls back to `https://<alias>:3000` when the input is absent or
|
||||
* unparseable.
|
||||
*/
|
||||
export function containerApiUrl(processApiUrl: string | undefined, engine: DockerEngine): string {
|
||||
const alias = hostGatewayAlias(engine);
|
||||
if (!processApiUrl) return `https://${alias}:3000`;
|
||||
try {
|
||||
const url = new URL(processApiUrl);
|
||||
url.hostname = alias;
|
||||
// origin drops any trailing path/slash and keeps scheme + (non-default) port
|
||||
return url.origin;
|
||||
} catch {
|
||||
return `https://${alias}:3000`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable hash of the drift-relevant `docker create` inputs, stored on the
|
||||
* container as the `codeman.confighash` label. On launch, a mismatch between the
|
||||
* desired hash and the running container's label triggers the recreate-on-drift
|
||||
* prompt (host config edits actually take effect).
|
||||
*/
|
||||
export function dockerConfigHash(
|
||||
docker: Pick<
|
||||
SessionDocker,
|
||||
| 'engine'
|
||||
| 'image'
|
||||
| 'containerWorkdir'
|
||||
| 'network'
|
||||
| 'networkName'
|
||||
| 'resources'
|
||||
| 'gpus'
|
||||
| 'mountCredentials'
|
||||
| 'extraCreateArgs'
|
||||
>
|
||||
): string {
|
||||
const normalized = JSON.stringify({
|
||||
engine: docker.engine,
|
||||
image: docker.image,
|
||||
containerWorkdir: docker.containerWorkdir,
|
||||
network: docker.network,
|
||||
networkName: docker.networkName ?? null,
|
||||
resources: docker.resources ?? null,
|
||||
gpus: docker.gpus ?? null,
|
||||
mountCredentials: docker.mountCredentials,
|
||||
extraCreateArgs: docker.extraCreateArgs ?? null,
|
||||
});
|
||||
return createHash('sha256').update(normalized).digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the flattened per-session Docker metadata from a host profile + a case,
|
||||
* resolving every default (mirror of toSessionRemote). The `configHash` is
|
||||
* computed last over the resolved values.
|
||||
*/
|
||||
export function toSessionDocker(host: DockerHost, dockerCase: DockerCase): SessionDocker {
|
||||
const engine: DockerEngine = host.engine ?? 'docker';
|
||||
const containerWorkdir = dockerCase.containerWorkdir ?? dockerCase.hostWorkspacePath;
|
||||
const base: Omit<SessionDocker, 'configHash'> = {
|
||||
hostId: host.id,
|
||||
label: host.label,
|
||||
engine,
|
||||
image: host.image || DEFAULT_AGENT_IMAGE,
|
||||
containerName: dockerCase.container ?? dockerContainerName(dockerCase.name),
|
||||
hostWorkspacePath: dockerCase.hostWorkspacePath,
|
||||
containerWorkdir,
|
||||
network: host.network ?? 'bridge',
|
||||
networkName: host.networkName,
|
||||
resources: host.resources ?? DEFAULT_DOCKER_RESOURCES,
|
||||
gpus: host.gpus,
|
||||
mountCredentials: host.mountCredentials ?? true,
|
||||
hooksEnabled: host.hooksEnabled ?? true,
|
||||
resumeOnStart: host.resumeOnStart ?? true,
|
||||
daemonHost: host.daemonHost,
|
||||
context: host.context,
|
||||
commands: host.commands,
|
||||
extraCreateArgs: host.extraCreateArgs,
|
||||
extraExecArgs: host.extraExecArgs,
|
||||
};
|
||||
return { ...base, configHash: dockerConfigHash(base) };
|
||||
}
|
||||
|
||||
// ========== Shell escaping ==========
|
||||
|
||||
/**
|
||||
* POSIX single-quote shell-escaping (end-quote, escaped-quote, restart-quote).
|
||||
* Mirror of the helper in remote-hosts.ts / tmux-manager.ts. Every dynamic value
|
||||
* interpolated into the outer `bash -c "..."` launch layer is escaped through
|
||||
* this so a path with spaces stays a single shell token. Operator-entered fields
|
||||
* are ALSO schema-rejected for `$`/backtick (NO_SHELL_META) as defense in depth.
|
||||
*/
|
||||
export function shellescape(str: string): string {
|
||||
return "'" + str.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
// ========== Pure command-arg builders ==========
|
||||
|
||||
/** A resolved bind mount (source existence already checked by the caller). */
|
||||
export interface DockerMount {
|
||||
src: string;
|
||||
dst: string;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved, IO-free context for buildDockerCreateArgs. The caller (tmux-manager)
|
||||
* resolves the environment-dependent bits (host uid, existing cred mounts, the
|
||||
* derived api url, Desktop detection) so this builder stays pure and unit-testable.
|
||||
*/
|
||||
export interface DockerCreateContext {
|
||||
docker: SessionDocker;
|
||||
/** Codeman session id (only the first 8 chars are used, for the codeman.session label). */
|
||||
sessionId: string;
|
||||
/** CODEMAN_INSTANCE ('' for prod) — scopes the boot reaper so a beta never reaps prod. */
|
||||
instance: string;
|
||||
/** Pre-resolved uid/userns tokens: ['--user','1000:0'] | ['--userns','keep-id'] | []. */
|
||||
userArgs: string[];
|
||||
/** Existing host credential bind mounts (convenient mode). Empty in sealed mode. */
|
||||
credentialMounts: DockerMount[];
|
||||
/** Extra bind mounts (e.g. the read-only hook-secret file). */
|
||||
extraMounts: DockerMount[];
|
||||
/** Create-time env (NON-secret, committed-safe): HOME, TERM, COLORTERM, CODEMAN_API_URL, CODEMAN_HOOK_SECRET_FILE. */
|
||||
envCreate: Record<string, string>;
|
||||
/** Whether to add `--add-host <alias>:host-gateway` (skipped on Docker Desktop, where the alias is native). */
|
||||
addHostGateway: boolean;
|
||||
/** Engine host-gateway alias (host.docker.internal / host.containers.internal). */
|
||||
gatewayAlias: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Engine prefix tokens shared by every docker invocation (mirror of
|
||||
* buildSshConnectionArgs). Returns e.g. ['docker'] or ['podman','--context','ctx'].
|
||||
*/
|
||||
export function buildDockerBaseArgs(docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>): string[] {
|
||||
const parts: string[] = [docker.engine === 'podman' ? 'podman' : 'docker'];
|
||||
if (docker.context) parts.push('--context', shellescape(docker.context));
|
||||
if (docker.daemonHost) parts.push('-H', shellescape(docker.daemonHost));
|
||||
return parts;
|
||||
}
|
||||
|
||||
function mountSpec(m: DockerMount): string {
|
||||
return `type=bind,src=${m.src},dst=${m.dst}${m.readonly ? ',readonly' : ''}`;
|
||||
}
|
||||
|
||||
function resourceFlags(resources?: DockerResourceLimits): string[] {
|
||||
if (!resources) return [];
|
||||
const flags: string[] = [];
|
||||
if (resources.memory) {
|
||||
// memory-swap == memory disables swap, making --memory a REAL OOM cap.
|
||||
flags.push('--memory', resources.memory, '--memory-swap', resources.memory);
|
||||
}
|
||||
if (resources.cpus) flags.push('--cpus', resources.cpus);
|
||||
if (resources.pidsLimit) flags.push('--pids-limit', String(resources.pidsLimit));
|
||||
if (resources.nofile) flags.push('--ulimit', `nofile=${resources.nofile}`);
|
||||
if (resources.shmSize) flags.push('--shm-size', resources.shmSize);
|
||||
return flags;
|
||||
}
|
||||
|
||||
function networkArg(network: DockerNetworkMode, networkName?: string): string {
|
||||
if (network === 'custom' && networkName) return networkName;
|
||||
return network; // 'bridge' | 'none'
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `docker create` token list (from `create` through the `sleep
|
||||
* infinity` CMD) for a long-lived, hardened, per-case container. PURE: every
|
||||
* dynamic value is shellescaped; the caller joins with spaces into the launch
|
||||
* string. Security invariants baked in: --cap-drop ALL, --security-opt
|
||||
* no-new-privileges, --pids-limit, --memory==--memory-swap, --init,
|
||||
* --pull=never, --restart no, NEVER --privileged, NEVER the docker socket.
|
||||
*/
|
||||
export function buildDockerCreateArgs(ctx: DockerCreateContext): string[] {
|
||||
const {
|
||||
docker,
|
||||
sessionId,
|
||||
instance,
|
||||
userArgs,
|
||||
credentialMounts,
|
||||
extraMounts,
|
||||
envCreate,
|
||||
addHostGateway,
|
||||
gatewayAlias,
|
||||
} = ctx;
|
||||
|
||||
const args: string[] = [
|
||||
'create',
|
||||
'--name',
|
||||
shellescape(docker.containerName),
|
||||
'--label',
|
||||
'codeman.managed=1',
|
||||
'--label',
|
||||
shellescape(`codeman.instance=${instance}`),
|
||||
'--label',
|
||||
shellescape(`codeman.session=${sessionId.slice(0, 8)}`),
|
||||
'--label',
|
||||
shellescape(`codeman.confighash=${docker.configHash ?? dockerConfigHash(docker)}`),
|
||||
'--pull=never',
|
||||
'--init',
|
||||
'--restart',
|
||||
'no',
|
||||
...userArgs,
|
||||
'--workdir',
|
||||
shellescape(docker.containerWorkdir),
|
||||
// Workspace bind: mirror the host path inside the container so the transcript
|
||||
// projHash correlates and file features read real host bytes.
|
||||
'--mount',
|
||||
shellescape(mountSpec({ src: docker.hostWorkspacePath, dst: docker.containerWorkdir })),
|
||||
...credentialMounts.flatMap((m) => ['--mount', shellescape(mountSpec(m))]),
|
||||
...extraMounts.flatMap((m) => ['--mount', shellescape(mountSpec(m))]),
|
||||
];
|
||||
|
||||
if (addHostGateway) args.push('--add-host', `${gatewayAlias}:host-gateway`);
|
||||
|
||||
args.push(
|
||||
...resourceFlags(docker.resources),
|
||||
// GPU passthrough (needs the NVIDIA container toolkit on the host). No storage
|
||||
// cap is set, so the container's writable layer + volumes grow elastically as
|
||||
// data flows in (bounded only by host disk).
|
||||
...(docker.gpus ? ['--gpus', shellescape(docker.gpus)] : []),
|
||||
'--cap-drop',
|
||||
'ALL',
|
||||
'--security-opt',
|
||||
'no-new-privileges',
|
||||
'--network',
|
||||
networkArg(docker.network, docker.networkName)
|
||||
);
|
||||
|
||||
for (const [key, value] of Object.entries(envCreate)) {
|
||||
args.push('--env', shellescape(`${key}=${value}`));
|
||||
}
|
||||
|
||||
// Operator escape-hatch args (schema-validated NO_SHELL_INJECTION), escaped again here.
|
||||
for (const extra of docker.extraCreateArgs ?? []) {
|
||||
args.push(shellescape(extra));
|
||||
}
|
||||
|
||||
args.push(shellescape(docker.image), 'sleep', 'infinity');
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* PURE argv for building the agent base image locally (the programmatic mirror of
|
||||
* scripts/build-agent-image.mjs): `build -f <dockerfile> -t <image> [--no-cache]
|
||||
* <contextDir>`. Kept pure + unit-testable; the caller prepends the engine binary.
|
||||
*/
|
||||
export function agentImageBuildArgs(dockerfile: string, image: string, contextDir: string, noCache = false): string[] {
|
||||
return ['build', '-f', dockerfile, '-t', image, ...(noCache ? ['--no-cache'] : []), contextDir];
|
||||
}
|
||||
|
||||
// ========== Credential mount resolution (IO) ==========
|
||||
|
||||
/** Container Claude config dir (created gid-0 writable in the image). */
|
||||
export const CONTAINER_CLAUDE_DIR = `${CONTAINER_HOME}/.claude`;
|
||||
/** In-container path of the seeded (writable) `~/.claude.json`. */
|
||||
export const CLAUDE_JSON_HOME = `${CONTAINER_HOME}/.claude.json`;
|
||||
/** In-container path of the read-only host-seeded `~/.claude.json` (copied into HOME at launch). */
|
||||
export const CLAUDE_JSON_SEED = `${CONTAINER_HOME}/.codeman/claude.seed.json`;
|
||||
/** Read-only seed paths for the files copied into the container's `.claude`. */
|
||||
const CLAUDE_CREDS_SEED = `${CONTAINER_HOME}/.codeman/claude-creds.seed.json`;
|
||||
const CLAUDE_SETTINGS_SEED = `${CONTAINER_HOME}/.codeman/claude-settings.seed.json`;
|
||||
const CLAUDE_STATS_SEED = `${CONTAINER_HOME}/.codeman/claude-stats.seed.json`;
|
||||
/** Staging root for read-only host-cred seed mounts (codex/gemini/gcloud/opencode). */
|
||||
const CRED_SEED_DIR = `${CONTAINER_HOME}/.codeman/cred-seeds`;
|
||||
|
||||
/**
|
||||
* PURE: merge the host `~/.claude.json` into a config that makes an
|
||||
* already-authenticated Claude skip its INTERACTIVE onboarding inside the container
|
||||
* (the host file itself lacks these flags — the host install is grandfathered, so a
|
||||
* verbatim copy still triggers the theme picker + login wizard + folder-trust
|
||||
* prompt). Forces `hasCompletedOnboarding`, a `theme` (so the theme picker is
|
||||
* skipped), and marks the workspace project trusted + onboarded. Auth still comes
|
||||
* from the copied `oauthAccount` + the dir-mounted `~/.claude/.credentials.json`.
|
||||
*/
|
||||
export function buildSeamlessClaudeConfig(
|
||||
hostConfig: Record<string, unknown>,
|
||||
workspacePath: string,
|
||||
theme = 'dark'
|
||||
): Record<string, unknown> {
|
||||
const merged: Record<string, unknown> = { ...hostConfig };
|
||||
merged.hasCompletedOnboarding = true;
|
||||
if (typeof merged.theme !== 'string') merged.theme = theme;
|
||||
const projects = { ...((merged.projects as Record<string, Record<string, unknown>> | undefined) ?? {}) };
|
||||
const existing = (projects[workspacePath] as Record<string, unknown> | undefined) ?? {};
|
||||
const seenCount = existing.projectOnboardingSeenCount;
|
||||
projects[workspacePath] = {
|
||||
...existing,
|
||||
hasTrustDialogAccepted: true,
|
||||
hasCompletedProjectOnboarding: true,
|
||||
projectOnboardingSeenCount: typeof seenCount === 'number' && seenCount > 0 ? seenCount : 1,
|
||||
};
|
||||
merged.projects = projects;
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Best-effort read of the host `~/.claude/settings.json` theme (drives the seed's theme). */
|
||||
function readHostClaudeTheme(home: string): string | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(join(home, '.claude', 'settings.json'), 'utf-8')) as { theme?: unknown };
|
||||
return typeof parsed.theme === 'string' ? parsed.theme : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the read-only seed mount for `~/.claude.json`. Reads the host file, merges
|
||||
* in the seamless-onboarding flags + workspace trust (buildSeamlessClaudeConfig),
|
||||
* writes the result to a per-container seed file under `~/.codeman/docker-seeds/`,
|
||||
* and returns its mount. The launch chain copies it to `~/.claude.json` inside HOME
|
||||
* once — giving Claude a NORMAL writable, already-onboarded config (no atomic-rename
|
||||
* EBUSY, no re-auth, no theme/trust prompts). Falls back to the RAW host file when
|
||||
* parse/write fails (auth still works; the wizard may show). Returns null when the
|
||||
* host has no `~/.claude.json`. IO; under VITEST returns the raw mount (no write).
|
||||
*/
|
||||
export function resolveClaudeJsonSeedMount(
|
||||
home: string = homedir(),
|
||||
containerName?: string,
|
||||
workspacePath?: string
|
||||
): DockerMount | null {
|
||||
const src = join(home, '.claude.json');
|
||||
if (!existsSync(src)) return null;
|
||||
const rawMount: DockerMount = { src, dst: CLAUDE_JSON_SEED, readonly: true };
|
||||
if (IS_TEST_MODE || !containerName || !workspacePath) return rawMount;
|
||||
try {
|
||||
const hostConfig = JSON.parse(readFileSync(src, 'utf-8')) as Record<string, unknown>;
|
||||
const merged = buildSeamlessClaudeConfig(hostConfig, workspacePath, readHostClaudeTheme(home) ?? 'dark');
|
||||
const seedsDir = dataPath('docker-seeds');
|
||||
if (!existsSync(seedsDir)) mkdirSync(seedsDir, { recursive: true });
|
||||
const seedFile = join(seedsDir, `${containerName}.json`);
|
||||
writeFileSync(seedFile, JSON.stringify(merged), { mode: 0o600 });
|
||||
return { src: seedFile, dst: CLAUDE_JSON_SEED, readonly: true };
|
||||
} catch {
|
||||
return rawMount; // partial host write / unreadable — auth still carries, wizard may show
|
||||
}
|
||||
}
|
||||
|
||||
/** A file (or dir, when `recursive`) copied into the container HOME once at launch
|
||||
* (`[ -e to ] || cp [-a] from to`). */
|
||||
export interface DockerSeedCopy {
|
||||
from: string;
|
||||
to: string;
|
||||
/** `cp -a` for whole-directory credential seeds (gemini/gcloud/opencode). */
|
||||
recursive?: boolean;
|
||||
}
|
||||
|
||||
export interface DockerClaudeArtifacts {
|
||||
/** Bind mounts to add: the shared `projects/` transcripts (RW) + read-only seed files. */
|
||||
mounts: DockerMount[];
|
||||
/** Files copied into the container's writable HOME/.claude (+ HOME/.claude.json) at launch. */
|
||||
seedCopies: DockerSeedCopy[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the ISOLATED Claude artifacts for a docker session (replaces the old
|
||||
* whole-`~/.claude` RW mount that polluted the host). Shares ONLY what must cross
|
||||
* the boundary and seeds the rest as writable copies:
|
||||
* - `~/.claude/projects` → RW dir mount (transcripts: host watchers + `--resume`).
|
||||
* - `~/.claude.json` → merged onboarding seed, copied to HOME (no re-auth/wizard).
|
||||
* - `~/.claude/.credentials.json` + `~/.claude/settings.json` → read-only seeds
|
||||
* copied into the container's own `~/.claude` (token + global prefs carry in;
|
||||
* the container refreshes its own copy and never writes back to the host).
|
||||
* Everything else Claude writes (backups, tasks, teams, session-env, history) stays
|
||||
* container-local. IO (reads host files, writes the merged `.claude.json` seed).
|
||||
*/
|
||||
export function resolveDockerClaudeArtifacts(
|
||||
home: string,
|
||||
containerName: string,
|
||||
workspacePath: string
|
||||
): DockerClaudeArtifacts {
|
||||
const mounts: DockerMount[] = [];
|
||||
const seedCopies: DockerSeedCopy[] = [];
|
||||
|
||||
// The ONE genuinely-shared part: conversation transcripts (dir mount → renames work).
|
||||
const projectsSrc = join(home, '.claude', 'projects');
|
||||
if (existsSync(projectsSrc)) {
|
||||
mounts.push({ src: projectsSrc, dst: `${CONTAINER_CLAUDE_DIR}/projects` });
|
||||
}
|
||||
|
||||
// ~/.claude.json → merged, onboarding-complete seed at HOME root.
|
||||
const jsonSeed = resolveClaudeJsonSeedMount(home, containerName, workspacePath);
|
||||
if (jsonSeed) {
|
||||
mounts.push(jsonSeed);
|
||||
seedCopies.push({ from: CLAUDE_JSON_SEED, to: CLAUDE_JSON_HOME });
|
||||
}
|
||||
|
||||
// credentials (token) + settings (theme/model/effort/permissions) + stats-cache
|
||||
// (drives the model/effort status indicator) → writable copies inside the
|
||||
// container's own ~/.claude (never a wholesale mount → no host pollution).
|
||||
const files: Array<[rel: string, seed: string, dest: string]> = [
|
||||
['.credentials.json', CLAUDE_CREDS_SEED, `${CONTAINER_CLAUDE_DIR}/.credentials.json`],
|
||||
['settings.json', CLAUDE_SETTINGS_SEED, `${CONTAINER_CLAUDE_DIR}/settings.json`],
|
||||
['stats-cache.json', CLAUDE_STATS_SEED, `${CONTAINER_CLAUDE_DIR}/stats-cache.json`],
|
||||
];
|
||||
for (const [rel, seed, dest] of files) {
|
||||
const src = join(home, '.claude', rel);
|
||||
if (existsSync(src)) {
|
||||
mounts.push({ src, dst: seed, readonly: true });
|
||||
seedCopies.push({ from: seed, to: dest });
|
||||
}
|
||||
}
|
||||
|
||||
return { mounts, seedCopies };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-CLI credential-store isolation policy (the codex/gemini/gcloud/opencode analog
|
||||
* of resolveDockerClaudeArtifacts). Codex is the direct Claude-analog: its
|
||||
* `sessions/` rollouts + `history.jsonl` are read HOST-SIDE (response-viewer +
|
||||
* `codex resume`), so they are SHARED (RW), while `auth.json`/`config.toml` are
|
||||
* seeded. The other three have no host-read/resume dependency and are fully
|
||||
* seed-copied (writable copy in the container, no write-back to the host).
|
||||
*/
|
||||
interface CredStorePolicy {
|
||||
/** Path relative to HOME (host + container), e.g. '.codex' or '.config/gcloud'. */
|
||||
rel: string;
|
||||
/** Subdirs bind-mounted RW (shared: resume + host reads). */
|
||||
shareDirs?: string[];
|
||||
/** Files bind-mounted RW (append-only, e.g. codex history.jsonl — never renamed). */
|
||||
shareFiles?: string[];
|
||||
/** Files seeded (RO mount → cp) into the container's own copy. */
|
||||
seedFiles?: string[];
|
||||
/** Seed the WHOLE dir (RO mount → cp -a) — for stores with no shared/host-read state. */
|
||||
seedWhole?: boolean;
|
||||
}
|
||||
|
||||
const CRED_STORES: CredStorePolicy[] = [
|
||||
{ rel: '.codex', shareDirs: ['sessions'], shareFiles: ['history.jsonl'], seedFiles: ['auth.json', 'config.toml'] },
|
||||
{ rel: '.gemini', seedWhole: true },
|
||||
{ rel: '.config/gcloud', seedWhole: true },
|
||||
{ rel: '.config/opencode', seedWhole: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolve the ISOLATED codex/gemini/gcloud/opencode artifacts (replaces the old
|
||||
* whole-dir RW mounts that let each in-container CLI write its refreshed tokens +
|
||||
* session state back into the host). Every path is existsSync-gated (on most hosts
|
||||
* only a subset exists). Pure-ish IO (no writes; just existence checks + mount specs).
|
||||
*/
|
||||
export function resolveDockerCredentialArtifacts(home: string = homedir()): DockerClaudeArtifacts {
|
||||
const mounts: DockerMount[] = [];
|
||||
const seedCopies: DockerSeedCopy[] = [];
|
||||
for (const store of CRED_STORES) {
|
||||
const hostBase = join(home, store.rel);
|
||||
if (!existsSync(hostBase)) continue;
|
||||
const containerBase = `${CONTAINER_HOME}/${store.rel}`;
|
||||
const seedName = store.rel.replace(/\//g, '-'); // '.config/gcloud' → '.config-gcloud'
|
||||
if (store.seedWhole) {
|
||||
const seed = `${CRED_SEED_DIR}/${seedName}`;
|
||||
mounts.push({ src: hostBase, dst: seed, readonly: true });
|
||||
seedCopies.push({ from: seed, to: containerBase, recursive: true });
|
||||
continue;
|
||||
}
|
||||
for (const sub of store.shareDirs ?? []) {
|
||||
const src = join(hostBase, sub);
|
||||
if (existsSync(src)) mounts.push({ src, dst: `${containerBase}/${sub}` });
|
||||
}
|
||||
for (const file of store.shareFiles ?? []) {
|
||||
const src = join(hostBase, file);
|
||||
if (existsSync(src)) mounts.push({ src, dst: `${containerBase}/${file}` });
|
||||
}
|
||||
for (const file of store.seedFiles ?? []) {
|
||||
const src = join(hostBase, file);
|
||||
if (existsSync(src)) {
|
||||
const seed = `${CRED_SEED_DIR}/${seedName}-${file}`;
|
||||
mounts.push({ src, dst: seed, readonly: true });
|
||||
seedCopies.push({ from: seed, to: `${containerBase}/${file}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { mounts, seedCopies };
|
||||
}
|
||||
|
||||
// ========== Daemon probes (IO; no-op under VITEST) ==========
|
||||
|
||||
export interface DockerAvailability {
|
||||
ok: boolean;
|
||||
engine: DockerEngine;
|
||||
rootless: boolean;
|
||||
isDesktop: boolean;
|
||||
cgroupV2: boolean;
|
||||
/** Best-effort: are --memory/--cpus/--pids-limit actually enforced on this engine? */
|
||||
capsEnforced: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const DOCKER_PROBE_TIMEOUT_MS = 15_000;
|
||||
|
||||
interface DockerInfoJson {
|
||||
ServerVersion?: string;
|
||||
CgroupVersion?: string;
|
||||
SecurityOptions?: string[];
|
||||
OperatingSystem?: string;
|
||||
OSType?: string;
|
||||
Name?: string;
|
||||
}
|
||||
|
||||
async function runDockerInfo(engine: DockerEngine): Promise<DockerInfoJson | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(engine, ['info', '--format', '{{json .}}'], {
|
||||
timeout: DOCKER_PROBE_TIMEOUT_MS,
|
||||
});
|
||||
return JSON.parse(stdout) as DockerInfoJson;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function classifyDockerInfo(engine: DockerEngine, info: DockerInfoJson): DockerAvailability {
|
||||
const security = info.SecurityOptions ?? [];
|
||||
const rootless = security.some((opt) => opt.includes('rootless'));
|
||||
const cgroupV2 = info.CgroupVersion === '2';
|
||||
const os = `${info.OperatingSystem ?? ''}`.toLowerCase();
|
||||
const isDesktop = os.includes('docker desktop') || os.includes('desktop');
|
||||
// Under rootless, resource caps are only reliably enforced with cgroup v2 +
|
||||
// systemd delegation. We can't detect delegation from `docker info`, so we
|
||||
// treat rootless+cgroupv2 as "likely enforced" and rootless+cgroupv1 as not.
|
||||
const capsEnforced = !rootless || cgroupV2;
|
||||
return { ok: true, engine, rootless, isDesktop, cgroupV2, capsEnforced };
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the container engine: server up, cgroup version, rootless, Desktop, and
|
||||
* whether resource caps are enforceable. Auto-detects docker then podman when no
|
||||
* engine is given. No-op canned value under VITEST.
|
||||
*/
|
||||
export async function checkDockerAvailable(engine?: DockerEngine): Promise<DockerAvailability> {
|
||||
if (IS_TEST_MODE) {
|
||||
return {
|
||||
ok: true,
|
||||
engine: engine ?? 'docker',
|
||||
rootless: false,
|
||||
isDesktop: false,
|
||||
cgroupV2: true,
|
||||
capsEnforced: true,
|
||||
};
|
||||
}
|
||||
const candidates: DockerEngine[] = engine ? [engine] : ['docker', 'podman'];
|
||||
for (const candidate of candidates) {
|
||||
const info = await runDockerInfo(candidate);
|
||||
if (info) return classifyDockerInfo(candidate, info);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
engine: engine ?? 'docker',
|
||||
rootless: false,
|
||||
isDesktop: false,
|
||||
cgroupV2: false,
|
||||
capsEnforced: false,
|
||||
error: 'Docker/Podman not available. Install docker (or podman) and ensure the daemon is running.',
|
||||
};
|
||||
}
|
||||
|
||||
/** Is the base image present locally? (never triggers an auto-pull). */
|
||||
export async function checkDockerImagePresent(engine: DockerEngine, image: string): Promise<boolean> {
|
||||
if (IS_TEST_MODE) return true;
|
||||
try {
|
||||
await execFileAsync(engine, ['image', 'inspect', '--format', '{{.Id}}', image], {
|
||||
timeout: DOCKER_PROBE_TIMEOUT_MS,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface EnsureImageResult {
|
||||
ok: boolean;
|
||||
/** true when this call actually ran a build (vs. the image already existing). */
|
||||
built: boolean;
|
||||
alreadyPresent: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** In-flight builds keyed by `engine:image`, so concurrent callers share ONE build. */
|
||||
const inFlightImageBuilds = new Map<string, Promise<EnsureImageResult>>();
|
||||
|
||||
/**
|
||||
* Resolve the repo's Dockerfile + build context. Works from BOTH src (dev/tsx) and
|
||||
* dist/index.js (esbuild prod: dist sits at repo root), since both are one level
|
||||
* under the repo root. Returns null when the Dockerfile is absent (npm-global
|
||||
* installs don't ship docker/ — Docker cases are a git-clone feature).
|
||||
*/
|
||||
function resolveAgentDockerfile(): { dockerfile: string; contextDir: string } | null {
|
||||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const dockerfile = join(repoRoot, 'docker', 'agent.Dockerfile');
|
||||
return existsSync(dockerfile) ? { dockerfile, contextDir: repoRoot } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the agent base image exists, BUILDING it locally on first use so a missing
|
||||
* image is never a hard blocker (decision: "build locally on first use",
|
||||
* docs/docker-cases-plan.md). Idempotent, concurrency-safe (one build per
|
||||
* engine:image shared by concurrent callers), and a no-op under VITEST. Only the
|
||||
* DEFAULT image is auto-built — we can never build a user's custom ref, and the
|
||||
* `--pull=never` invariant forbids pulling. `onProgress` receives build output
|
||||
* lines for SSE surfacing.
|
||||
*/
|
||||
export async function ensureAgentBaseImage(
|
||||
engine: DockerEngine,
|
||||
image: string,
|
||||
opts: { onProgress?: (line: string) => void; noCache?: boolean } = {}
|
||||
): Promise<EnsureImageResult> {
|
||||
if (IS_TEST_MODE) return { ok: true, built: false, alreadyPresent: true };
|
||||
if (await checkDockerImagePresent(engine, image)) {
|
||||
return { ok: true, built: false, alreadyPresent: true };
|
||||
}
|
||||
if (image !== DEFAULT_AGENT_IMAGE) {
|
||||
return {
|
||||
ok: false,
|
||||
built: false,
|
||||
alreadyPresent: false,
|
||||
error: `image ${image} is not present and only ${DEFAULT_AGENT_IMAGE} is auto-built. Build or pull ${image} yourself.`,
|
||||
};
|
||||
}
|
||||
const key = `${engine}:${image}`;
|
||||
const existing = inFlightImageBuilds.get(key);
|
||||
if (existing) return existing;
|
||||
const build = buildAgentImage(engine, image, opts).finally(() => inFlightImageBuilds.delete(key));
|
||||
inFlightImageBuilds.set(key, build);
|
||||
return build;
|
||||
}
|
||||
|
||||
function buildAgentImage(
|
||||
engine: DockerEngine,
|
||||
image: string,
|
||||
opts: { onProgress?: (line: string) => void; noCache?: boolean }
|
||||
): Promise<EnsureImageResult> {
|
||||
const resolved = resolveAgentDockerfile();
|
||||
if (!resolved) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
built: false,
|
||||
alreadyPresent: false,
|
||||
error: `docker/agent.Dockerfile not found in this install; clone the repo or build ${image} manually`,
|
||||
});
|
||||
}
|
||||
const args = agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache);
|
||||
return new Promise<EnsureImageResult>((resolve) => {
|
||||
// async spawn (NEVER spawnSync) so a multi-minute build never wedges the event loop.
|
||||
const child = spawn(engine, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const forward = (buf: Buffer) => {
|
||||
for (const line of buf.toString('utf-8').split('\n')) {
|
||||
const trimmed = line.trimEnd();
|
||||
if (trimmed) opts.onProgress?.(trimmed);
|
||||
}
|
||||
};
|
||||
child.stdout?.on('data', forward);
|
||||
child.stderr?.on('data', forward);
|
||||
child.on('error', (err) => {
|
||||
resolve({
|
||||
ok: false,
|
||||
built: false,
|
||||
alreadyPresent: false,
|
||||
error: `could not spawn ${engine} build: ${err.message}`,
|
||||
});
|
||||
});
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) resolve({ ok: true, built: true, alreadyPresent: false });
|
||||
else resolve({ ok: false, built: false, alreadyPresent: false, error: `${engine} build failed (exit ${code})` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface DockerTmuxCheckResult {
|
||||
ok: boolean;
|
||||
tmuxPath?: string;
|
||||
/** Distinguishes "image missing" (build it) from "tmux missing in image" (rebuild it). */
|
||||
imageMissing?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the base image is present AND contains tmux (a HARD prerequisite: the
|
||||
* in-container tmux is what makes reconnect durable). Never triggers a pull
|
||||
* (`--pull=never`). No-op under VITEST. Mirror of checkRemoteTmuxAvailable.
|
||||
*/
|
||||
export async function checkDockerTmuxAvailable(
|
||||
docker: Pick<SessionDocker, 'engine' | 'image'>
|
||||
): Promise<DockerTmuxCheckResult> {
|
||||
if (IS_TEST_MODE) return { ok: true, tmuxPath: '/usr/bin/tmux' };
|
||||
const engine = docker.engine;
|
||||
if (!(await checkDockerImagePresent(engine, docker.image))) {
|
||||
return {
|
||||
ok: false,
|
||||
imageMissing: true,
|
||||
error: `image ${docker.image} not present (the default image is auto-built on first use; a custom image must be built or pulled first)`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
engine,
|
||||
['run', '--rm', '--pull=never', docker.image, 'sh', '-lc', 'command -v tmux'],
|
||||
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
|
||||
);
|
||||
const tmuxPath = stdout.trim();
|
||||
if (!tmuxPath) {
|
||||
return { ok: false, error: `base image ${docker.image} is missing tmux (required for durable sessions)` };
|
||||
}
|
||||
return { ok: true, tmuxPath };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, error: `could not verify tmux in ${docker.image}: ${msg}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* there and in-container hooks can call back. Defaults to the conventional
|
||||
* 172.17.0.1 when the inspect fails but docker is up; null when docker is absent.
|
||||
* No-op canned value under VITEST.
|
||||
*/
|
||||
export async function detectDockerBridgeGateway(engine: DockerEngine = 'docker'): Promise<string | null> {
|
||||
if (IS_TEST_MODE) return '172.17.0.1';
|
||||
const bin = engine === 'podman' ? 'podman' : 'docker';
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
bin,
|
||||
['network', 'inspect', 'bridge', '--format', '{{(index .IPAM.Config 0).Gateway}}'],
|
||||
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
|
||||
);
|
||||
const ip = stdout.trim();
|
||||
return /^\d{1,3}(\.\d{1,3}){3}$/.test(ip) ? ip : '172.17.0.1';
|
||||
} catch {
|
||||
return null; // docker not available — nothing to bind
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance-scoped boot reaper: `docker rm -f` any MANAGED container that belongs
|
||||
* to THIS instance (by the `codeman.instance` label) but whose case is no longer
|
||||
* in `docker-cases.json`. The instance scoping is what stops a beta from reaping
|
||||
* prod's containers (the cross-instance hazard). No-op under VITEST. Best-effort.
|
||||
*/
|
||||
export async function reapOrphanedDockerContainers(
|
||||
configDir: string,
|
||||
instance: string,
|
||||
engine: DockerEngine = 'docker'
|
||||
): Promise<string[]> {
|
||||
if (IS_TEST_MODE) return [];
|
||||
const bin = engine === 'podman' ? 'podman' : 'docker';
|
||||
let rows: Array<{ name: string; inst: string }> = [];
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
bin,
|
||||
[
|
||||
'ps',
|
||||
'-a',
|
||||
'--filter',
|
||||
'label=codeman.managed=1',
|
||||
'--format',
|
||||
'{{.Names}}\t{{index .Labels "codeman.instance"}}',
|
||||
],
|
||||
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
|
||||
);
|
||||
rows = stdout
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [name, inst = ''] = line.split('\t');
|
||||
return { name, inst };
|
||||
});
|
||||
} catch {
|
||||
return []; // daemon down / engine absent — nothing to reap
|
||||
}
|
||||
const cases = await readDockerCases(configDir);
|
||||
const expected = new Set(cases.map((c) => c.container ?? dockerContainerName(c.name)));
|
||||
const reaped: string[] = [];
|
||||
for (const { name, inst } of rows) {
|
||||
if (inst !== instance) continue; // only THIS instance's containers
|
||||
if (expected.has(name)) continue; // still referenced by a live case
|
||||
try {
|
||||
await execFileAsync(bin, ['rm', '-f', name], { timeout: DOCKER_PROBE_TIMEOUT_MS });
|
||||
reaped.push(name);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
return reaped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the IN-CONTAINER Claude CLI version (`docker exec <container> claude
|
||||
* --version`). Feeds Session.cliVersion for docker sessions (the LOCAL claude
|
||||
* would report the wrong version and disable trackpad wheel-forwarding, #154).
|
||||
* Returns undefined on any failure. No-op under VITEST.
|
||||
*/
|
||||
export async function probeDockerCliVersion(
|
||||
docker: Pick<SessionDocker, 'engine' | 'containerName'>,
|
||||
mode: SessionMode
|
||||
): Promise<string | undefined> {
|
||||
if (IS_TEST_MODE) return undefined;
|
||||
const bin = mode === 'shell' ? null : mode;
|
||||
if (!bin) return undefined;
|
||||
try {
|
||||
const { stdout } = await execFileAsync(docker.engine, ['exec', docker.containerName, bin, '--version'], {
|
||||
timeout: DOCKER_PROBE_TIMEOUT_MS,
|
||||
});
|
||||
const match = stdout.trim().match(/\d+\.\d+\.\d+/);
|
||||
return match ? match[0] : stdout.trim() || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,18 @@ import { runWithConversionLimit } from './document-conversion-limiter.js';
|
||||
const execFileAsync = promisify(execFile);
|
||||
const THUMBNAIL_CONVERSION_TIMEOUT_MS = 5 * 60_000;
|
||||
|
||||
/** Browser-renderable image formats served as-is (no conversion). */
|
||||
const IMAGE_PASSTHROUGH_CONTENT_TYPES: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
};
|
||||
|
||||
export interface ThumbnailResult {
|
||||
content: Buffer;
|
||||
contentType: 'image/png';
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
export async function generateFirstPageThumbnail(filePath: string, extension: string): Promise<ThumbnailResult | null> {
|
||||
@@ -24,8 +33,9 @@ export async function generateFirstPageThumbnail(filePath: string, extension: st
|
||||
try {
|
||||
await fs.stat(filePath);
|
||||
|
||||
if (ext === 'png') {
|
||||
return { content: await fs.readFile(filePath), contentType: 'image/png' };
|
||||
const passthroughContentType = IMAGE_PASSTHROUGH_CONTENT_TYPES[ext];
|
||||
if (passthroughContentType) {
|
||||
return { content: await fs.readFile(filePath), contentType: passthroughContentType };
|
||||
}
|
||||
|
||||
if (ext === 'pdf') {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @fileoverview Codex generated-artifact attachment registration.
|
||||
*
|
||||
* Codex image generation prints paths such as `Saved to: file://...`. These
|
||||
* paths are registered directly when they fall within allowed locations (the
|
||||
* session workspace or the well-known Codex generated-artifact directories
|
||||
* anchored at the user's home). The trust decision is made on the
|
||||
* realpath-RESOLVED path so a symlink staged at an allowed location cannot
|
||||
* smuggle an arbitrary host file past workspace confinement.
|
||||
*/
|
||||
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, normalize, sep } from 'node:path';
|
||||
import { registerExternalAttachment, type AttachmentRegistrationResult } from './attachment-registry.js';
|
||||
|
||||
export interface GeneratedArtifactRegistrationOptions {
|
||||
sessionId: string;
|
||||
filePath: string;
|
||||
sessionWorkingDir: string;
|
||||
}
|
||||
|
||||
export async function registerGeneratedArtifactAttachment(
|
||||
options: GeneratedArtifactRegistrationOptions
|
||||
): Promise<AttachmentRegistrationResult> {
|
||||
// Decide trust on the symlink-resolved path. If it can't be resolved, fall
|
||||
// back to the strict force-confined policy (registration will 404 a missing
|
||||
// file anyway).
|
||||
let forceWorkspaceConfinement = true;
|
||||
try {
|
||||
const resolvedPath = realpathSync(options.filePath);
|
||||
forceWorkspaceConfinement = !isAllowedGeneratedArtifactPath(resolvedPath, options.sessionWorkingDir);
|
||||
} catch {
|
||||
// Keep force confinement.
|
||||
}
|
||||
return registerExternalAttachment(options.sessionId, options.filePath, {
|
||||
sessionWorkingDir: options.sessionWorkingDir,
|
||||
forceWorkspaceConfinement,
|
||||
});
|
||||
}
|
||||
|
||||
/** Well-known Codex generated-artifact directories, anchored at the user's home. */
|
||||
function codexGeneratedDirs(): string[] {
|
||||
const home = homedir();
|
||||
return [
|
||||
join(home, '.codex-personal', 'generated_images'),
|
||||
join(home, '.codex', 'generated_images'),
|
||||
join(home, '.codex-personal', 'generated_artifacts'),
|
||||
join(home, '.codex', 'generated_artifacts'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `filePath` (absolute; callers should pass the realpath-resolved
|
||||
* path) is inside the session workspace or one of the well-known Codex
|
||||
* generated-artifact directories under the current user's home. The marker
|
||||
* directories are prefix-anchored to `os.homedir()` — a `.codex/...` subtree
|
||||
* elsewhere on the filesystem does NOT qualify.
|
||||
*/
|
||||
export function isAllowedGeneratedArtifactPath(filePath: string, workingDir: string): boolean {
|
||||
const normalizedPath = normalize(filePath);
|
||||
if (isPathInside(normalizedPath, workingDir)) return true;
|
||||
return codexGeneratedDirs().some((dir) => isPathInside(normalizedPath, dir));
|
||||
}
|
||||
|
||||
function isPathInside(filePath: string, rootPath: string): boolean {
|
||||
const normalizedRoot = normalize(rootPath);
|
||||
if (filePath === normalizedRoot) return true;
|
||||
return filePath.startsWith(normalizedRoot.endsWith(sep) ? normalizedRoot : normalizedRoot + sep);
|
||||
}
|
||||
@@ -14,6 +14,18 @@ import { program } from './cli.js';
|
||||
// In web mode, we should NOT exit on transient errors — log and continue
|
||||
const isWebMode = process.argv.includes('web');
|
||||
|
||||
// COD-115: Codeman IS a tmux controller; it must never present as a tmux *client*.
|
||||
// If the web server is launched from inside a tmux pane it inherits TMUX/TMUX_PANE,
|
||||
// and tmux's nesting guard then kills every new attach-bridge PTY (exit 1 → respawn
|
||||
// loop, crash-looping any new tmux-backed session). Scrub at the root so every
|
||||
// downstream `{...process.env}` spread (attach, send-keys, create) is clean regardless
|
||||
// of launch context. `delete` (not `= undefined`, which node-pty serializes as the
|
||||
// literal string "undefined" and fails to clear).
|
||||
if (isWebMode) {
|
||||
delete process.env.TMUX;
|
||||
delete process.env.TMUX_PANE;
|
||||
}
|
||||
|
||||
import { MAX_CONSECUTIVE_ERRORS, ERROR_RESET_MS } from './config/server-timing.js';
|
||||
|
||||
// Track consecutive unhandled errors in web mode — restart after too many
|
||||
|
||||
+49
-4
@@ -16,6 +16,9 @@ import type {
|
||||
OpenCodeConfig,
|
||||
CodexConfig,
|
||||
EffortLevel,
|
||||
GeminiConfig,
|
||||
SessionRemote,
|
||||
SessionDocker,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
@@ -32,6 +35,10 @@ export interface MuxSession {
|
||||
createdAt: number;
|
||||
/** Working directory */
|
||||
workingDir: string;
|
||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
|
||||
docker?: SessionDocker;
|
||||
/** Session mode */
|
||||
mode: SessionMode;
|
||||
/** Whether webserver is attached to this session */
|
||||
@@ -64,12 +71,19 @@ export interface CreateSessionOptions {
|
||||
allowedTools?: string;
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
codexConfig?: CodexConfig;
|
||||
geminiConfig?: GeminiConfig;
|
||||
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
|
||||
envOverrides?: Record<string, string>;
|
||||
/** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */
|
||||
effort?: EffortLevel;
|
||||
/** tmux history-limit (scrollback lines) to set for this session. */
|
||||
historyLimit?: number;
|
||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
|
||||
docker?: SessionDocker;
|
||||
}
|
||||
|
||||
/** Options for respawning a dead pane. */
|
||||
@@ -83,12 +97,33 @@ export interface RespawnPaneOptions {
|
||||
allowedTools?: string;
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
codexConfig?: CodexConfig;
|
||||
geminiConfig?: GeminiConfig;
|
||||
/** Resume a previous Claude conversation when respawning */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported before launching the CLI (preserved across respawns). */
|
||||
envOverrides?: Record<string, string>;
|
||||
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
|
||||
effort?: EffortLevel;
|
||||
/** tmux history-limit (scrollback lines) to set for this session after respawn. */
|
||||
historyLimit?: number;
|
||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
|
||||
docker?: SessionDocker;
|
||||
}
|
||||
|
||||
/** Options for pane buffer capture (COD-47 full-history mode). */
|
||||
export interface PaneCaptureOptions {
|
||||
/** Capture the entire tmux scrollback instead of just the visible frame. */
|
||||
fullHistory?: boolean;
|
||||
/** Bound the full-history capture to this many scrollback lines (`-S -<N>`). */
|
||||
historyLimitLines?: number;
|
||||
/**
|
||||
* Byte cap the consumer will keep from the capture. Sizes the child-process
|
||||
* stdout buffer (with slack) so multi-MB scrollback dumps aren't killed by
|
||||
* the 1MB execSync default (ENOBUFS).
|
||||
*/
|
||||
maxCaptureBytes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,6 +202,9 @@ export interface TerminalMultiplexer extends EventEmitter {
|
||||
/** Update Ralph enabled state for a session */
|
||||
updateRalphEnabled(sessionId: string, enabled: boolean): void;
|
||||
|
||||
/** Apply a tmux history-limit to all tracked sessions. */
|
||||
setHistoryLimit(limit: number): Promise<void>;
|
||||
|
||||
// ========== Discovery ==========
|
||||
|
||||
/**
|
||||
@@ -215,9 +253,16 @@ export interface TerminalMultiplexer extends EventEmitter {
|
||||
/** Respawn a dead pane with a fresh command. Returns the new PID or null on failure. */
|
||||
respawnPane(options: RespawnPaneOptions): Promise<number | null>;
|
||||
|
||||
/** Capture a pane's current tmux buffer with ANSI escape codes preserved. */
|
||||
capturePaneBuffer?(muxName: string, paneTarget: string): string | null;
|
||||
/**
|
||||
* Capture a pane's current tmux buffer with ANSI escape codes preserved.
|
||||
* Pass `{ fullHistory: true }` to capture the entire scrollback as linear
|
||||
* text instead of just the visible single-screen frame (COD-47).
|
||||
*/
|
||||
capturePaneBuffer?(muxName: string, paneTarget?: string, opts?: PaneCaptureOptions): string | null;
|
||||
|
||||
/** Capture the active pane's current tmux buffer with ANSI escape codes preserved. */
|
||||
captureActivePaneBuffer?(muxName: string): string | null;
|
||||
/**
|
||||
* Capture the active pane's current tmux buffer with ANSI escape codes preserved.
|
||||
* Pass `{ fullHistory: true }` to capture the entire scrollback (COD-47).
|
||||
*/
|
||||
captureActivePaneBuffer?(muxName: string, opts?: PaneCaptureOptions): string | null;
|
||||
}
|
||||
|
||||
+47
-3
@@ -467,6 +467,12 @@ export class RalphTracker extends EventEmitter {
|
||||
/** Timestamp of last cleanup check for throttling */
|
||||
private _lastCleanupTime: number = 0;
|
||||
|
||||
/** Maximum number of todos retained for this session (defaults to global cap) */
|
||||
private _maxTodos: number = MAX_TODOS_PER_SESSION;
|
||||
|
||||
/** Todo auto-expiry duration in milliseconds (defaults to global constant) */
|
||||
private _todoExpiryMs: number = TODO_EXPIRY_MS;
|
||||
|
||||
/** Debouncer for todoUpdate events */
|
||||
private _todoDeb = new Debouncer(EVENT_DEBOUNCE_MS);
|
||||
|
||||
@@ -1053,6 +1059,10 @@ export class RalphTracker extends EventEmitter {
|
||||
planVersion: this.planTracker.planVersion,
|
||||
planHistoryLength: this.planTracker.getPlanHistory().length,
|
||||
completionConfidence: this._lastCompletionConfidence,
|
||||
// Surface the live todo-config so it persists (toState) and reads back into
|
||||
// the Session Options modal (broadcast) — mirrors maxIterations round-trip.
|
||||
maxTodos: this._maxTodos,
|
||||
todoExpirationMinutes: this.todoExpirationMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1840,7 +1850,7 @@ export class RalphTracker extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
while (this._todos.size >= MAX_TODOS_PER_SESSION) {
|
||||
while (this._todos.size >= this._maxTodos) {
|
||||
const oldest = this.findOldestTodo();
|
||||
if (oldest) {
|
||||
this._todos.delete(oldest.id);
|
||||
@@ -2164,14 +2174,14 @@ export class RalphTracker extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove todo items older than TODO_EXPIRY_MS.
|
||||
* Remove todo items older than the configured expiry duration.
|
||||
*/
|
||||
private cleanupExpiredTodos(): void {
|
||||
const now = Date.now();
|
||||
const toDelete: string[] = [];
|
||||
|
||||
for (const [id, todo] of this._todos) {
|
||||
if (now - todo.detectedAt > TODO_EXPIRY_MS) {
|
||||
if (now - todo.detectedAt > this._todoExpiryMs) {
|
||||
toDelete.push(id);
|
||||
}
|
||||
}
|
||||
@@ -2211,6 +2221,34 @@ export class RalphTracker extends EventEmitter {
|
||||
this.emit('loopUpdate', this.loopState);
|
||||
}
|
||||
|
||||
/** Maximum number of todos retained for this session. */
|
||||
get maxTodos(): number {
|
||||
return this._maxTodos;
|
||||
}
|
||||
|
||||
/** Todo auto-expiry duration in minutes for this session. */
|
||||
get todoExpirationMinutes(): number {
|
||||
return Math.round(this._todoExpiryMs / 60000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the maximum number of retained todos (external API).
|
||||
* Ignores non-positive values.
|
||||
*/
|
||||
setMaxTodos(maxTodos: number): void {
|
||||
if (!Number.isFinite(maxTodos) || maxTodos <= 0) return;
|
||||
this._maxTodos = Math.floor(maxTodos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the todo auto-expiry duration (external API), specified in minutes.
|
||||
* Converts to milliseconds internally. Ignores non-positive values.
|
||||
*/
|
||||
setTodoExpirationMinutes(minutes: number): void {
|
||||
if (!Number.isFinite(minutes) || minutes <= 0) return;
|
||||
this._todoExpiryMs = Math.floor(minutes) * 60000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the tracker from external state.
|
||||
*/
|
||||
@@ -2311,6 +2349,12 @@ export class RalphTracker extends EventEmitter {
|
||||
...loopState,
|
||||
enabled: loopState.enabled ?? false,
|
||||
};
|
||||
// Restore the per-session todo-config into the live fields used by the hot
|
||||
// paths (eviction cap + expiry). Setters ignore non-positive values.
|
||||
if (typeof loopState.maxTodos === 'number') this.setMaxTodos(loopState.maxTodos);
|
||||
if (typeof loopState.todoExpirationMinutes === 'number') {
|
||||
this.setTodoExpirationMinutes(loopState.todoExpirationMinutes);
|
||||
}
|
||||
this._todos.clear();
|
||||
for (const todo of todos) {
|
||||
this._todos.set(todo.id, {
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import type {
|
||||
RemoteCase,
|
||||
RemoteCommandMode,
|
||||
RemoteHost,
|
||||
RemoteSshOptions,
|
||||
SessionMode,
|
||||
SessionRemote,
|
||||
} from './types.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const REMOTE_HOSTS_FILE = 'remote-hosts.json';
|
||||
const REMOTE_CASES_FILE = 'remote-cases.json';
|
||||
|
||||
export function remoteHostsPath(configDir: string): string {
|
||||
return join(configDir, REMOTE_HOSTS_FILE);
|
||||
}
|
||||
|
||||
export function remoteCasesPath(configDir: string): string {
|
||||
return join(configDir, REMOTE_CASES_FILE);
|
||||
}
|
||||
|
||||
async function readJsonArray<T>(path: string): Promise<T[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(path, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as T[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJsonArray<T>(configDir: string, path: string, value: T[]): Promise<void> {
|
||||
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
|
||||
await fs.writeFile(path, JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
export async function readRemoteHosts(configDir: string): Promise<RemoteHost[]> {
|
||||
return readJsonArray<RemoteHost>(remoteHostsPath(configDir));
|
||||
}
|
||||
|
||||
export async function writeRemoteHosts(configDir: string, hosts: RemoteHost[]): Promise<void> {
|
||||
await writeJsonArray(configDir, remoteHostsPath(configDir), hosts);
|
||||
}
|
||||
|
||||
export async function readRemoteCases(configDir: string): Promise<RemoteCase[]> {
|
||||
return readJsonArray<RemoteCase>(remoteCasesPath(configDir));
|
||||
}
|
||||
|
||||
export async function writeRemoteCases(configDir: string, cases: RemoteCase[]): Promise<void> {
|
||||
await writeJsonArray(configDir, remoteCasesPath(configDir), cases);
|
||||
}
|
||||
|
||||
export function defaultRemoteCommandForMode(mode: SessionMode): string {
|
||||
const commands: Record<RemoteCommandMode, string> = {
|
||||
shell: 'exec bash -l',
|
||||
// Mirror the LOCAL claude default so the remote agent runs non-interactively
|
||||
// (no trust-folder/permission prompt that nothing on the remote answers). The
|
||||
// per-host `commands.claude` override stays the escape hatch.
|
||||
claude: 'exec claude --dangerously-skip-permissions',
|
||||
opencode: 'exec opencode',
|
||||
codex: 'exec codex',
|
||||
gemini: 'exec gemini',
|
||||
};
|
||||
return commands[mode as RemoteCommandMode] || commands.shell;
|
||||
}
|
||||
|
||||
export function remoteSshTarget(host: Pick<RemoteHost, 'username' | 'host'>): string {
|
||||
return `${host.username}@${host.host}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* POSIX single-quote shell-escaping (end-quote, escaped-quote, restart-quote).
|
||||
* Mirrors the helper in tmux-manager.ts so a value with spaces/metachars stays a
|
||||
* single shell token. Used here for identity paths and `-o KEY=VALUE` options.
|
||||
*/
|
||||
function shellescape(str: string): string {
|
||||
return "'" + str.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a leading `~` or `$HOME` in an identity path to an absolute path.
|
||||
*
|
||||
* ssh does NOT expand `~` inside `-i` (the shell would, but we shellescape the
|
||||
* value into a single quoted token so the shell never sees it). So we expand at
|
||||
* build time, before escaping. Non-`~`/`$HOME` paths are returned unchanged.
|
||||
*/
|
||||
function expandIdentityPath(identityFile: string): string {
|
||||
if (identityFile === '~') return homedir();
|
||||
if (identityFile.startsWith('~/')) return join(homedir(), identityFile.slice(2));
|
||||
if (identityFile === '$HOME') return homedir();
|
||||
if (identityFile.startsWith('$HOME/')) return join(homedir(), identityFile.slice('$HOME/'.length));
|
||||
return identityFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-107 — build the ordered, shell-safe ssh CONNECTION tokens shared by both
|
||||
* the durable-launch command (`buildRemoteLaunchCommand`) and the tmux
|
||||
* prerequisite probe (`buildRemoteTmuxCheckCommand`), so the prereq check and
|
||||
* the real launch connect with IDENTICAL options (they can't drift).
|
||||
*
|
||||
* Returns the leading tokens of an ssh command line (NOT including `-t`, the
|
||||
* target, or any remote command). Order:
|
||||
* ssh -o BatchMode=yes
|
||||
* [-o ConnectTimeout=10] (default; suppressed if extraSshOptions sets it)
|
||||
* [-p <port>]
|
||||
* [-i <abs-identity>] (~/$HOME expanded, then shellescaped)
|
||||
* [-J <jumpHost>] (shellescaped, single token)
|
||||
* [-o ProxyCommand=nc -X 5 -x <socks> %h %p] (ONE shellescaped -o token)
|
||||
* [-o <KEY=VALUE>] … (each extra option, shellescaped)
|
||||
*
|
||||
* Escaping notes (the risky part):
|
||||
* - The ProxyCommand is emitted as a single shellescaped `-o KEY=VALUE`, so the
|
||||
* whole value (spaces + `%h`/`%p`) reaches ssh as one argument and `%h %p`
|
||||
* survive verbatim — ssh expands them to the real host/port, not the shell.
|
||||
* - A default `-o ConnectTimeout=10` bounds the wait on an unreachable/blackholed
|
||||
* host (else the pane hangs on the OS TCP timeout). It is omitted when the
|
||||
* operator already set ConnectTimeout via extraSshOptions, so their value wins.
|
||||
*/
|
||||
export function buildSshConnectionArgs(remote: RemoteSshOptions & Pick<RemoteHost, 'port'>): string[] {
|
||||
const parts: string[] = ['ssh', '-o BatchMode=yes'];
|
||||
const hasConnectTimeout = (remote.extraSshOptions ?? []).some((opt) => /^ConnectTimeout=/i.test(opt));
|
||||
if (!hasConnectTimeout) parts.push('-o ConnectTimeout=10');
|
||||
if (remote.port) parts.push(`-p ${remote.port}`);
|
||||
if (remote.identityFile) parts.push(`-i ${shellescape(expandIdentityPath(remote.identityFile))}`);
|
||||
if (remote.jumpHost) parts.push(`-J ${shellescape(remote.jumpHost)}`);
|
||||
if (remote.socksProxy) {
|
||||
parts.push(`-o ${shellescape(`ProxyCommand=nc -X 5 -x ${remote.socksProxy} %h %p`)}`);
|
||||
}
|
||||
for (const opt of remote.extraSshOptions ?? []) {
|
||||
parts.push(`-o ${shellescape(opt)}`);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-104 — build the SSH command that checks the remote host has tmux.
|
||||
*
|
||||
* Durable remote sessions run the agent inside a tmux server ON the remote host
|
||||
* (`tmux -L codeman new-session -A …`), so tmux is now a hard prerequisite there.
|
||||
* `command -v tmux` exits 0 (and prints the path) when tmux is installed.
|
||||
*
|
||||
* COD-107 — connects with the SAME options as the real launch
|
||||
* (`buildSshConnectionArgs`) so a proxied/custom-port/identity host that the
|
||||
* launch can reach also passes the prereq probe (and vice-versa).
|
||||
*/
|
||||
export function buildRemoteTmuxCheckCommand(
|
||||
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
|
||||
): string {
|
||||
// ConnectTimeout is now a default of buildSshConnectionArgs (shared with the launch).
|
||||
return [...buildSshConnectionArgs(host), remoteSshTarget(host), "'command -v tmux'"].join(' ');
|
||||
}
|
||||
|
||||
export interface RemoteTmuxCheckResult {
|
||||
ok: boolean;
|
||||
/** Resolved tmux path on the remote (when ok). */
|
||||
tmuxPath?: string;
|
||||
/** Human-readable failure reason (when !ok). */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-104 — verify the remote host has tmux installed (required for durable
|
||||
* remote sessions). Returns a structured result with a clear, user-facing error
|
||||
* when tmux is missing or the host is unreachable. Never throws.
|
||||
*/
|
||||
export async function checkRemoteTmuxAvailable(
|
||||
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
|
||||
): Promise<RemoteTmuxCheckResult> {
|
||||
const command = buildRemoteTmuxCheckCommand(host);
|
||||
try {
|
||||
const { stdout } = await execAsync(command, { timeout: 15_000 });
|
||||
const tmuxPath = stdout.trim();
|
||||
if (!tmuxPath) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `remote host ${host.host} needs tmux installed for durable remote sessions`,
|
||||
};
|
||||
}
|
||||
return { ok: true, tmuxPath };
|
||||
} catch (err) {
|
||||
const stderr =
|
||||
err && typeof err === 'object' && 'stderr' in err ? String((err as { stderr?: unknown }).stderr ?? '') : '';
|
||||
// `command -v tmux` exits non-zero when tmux is absent (no stderr); a real
|
||||
// connection failure surfaces ssh diagnostics on stderr.
|
||||
if (stderr.trim()) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `could not verify tmux on remote host ${host.host}: ${stderr.trim()}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: `remote host ${host.host} needs tmux installed for durable remote sessions`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function remoteDisplayPath(
|
||||
remote: Pick<SessionRemote, 'username' | 'host' | 'remotePath'> | { username: string; host: string; path: string }
|
||||
): string {
|
||||
const path = 'remotePath' in remote ? remote.remotePath : remote.path;
|
||||
return `${remote.username}@${remote.host}:${path}`;
|
||||
}
|
||||
|
||||
export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): SessionRemote {
|
||||
return {
|
||||
hostId: host.id,
|
||||
label: host.label,
|
||||
host: host.host,
|
||||
username: host.username,
|
||||
port: host.port,
|
||||
remotePath: remoteCase.remotePath,
|
||||
commands: host.commands,
|
||||
// COD-107 — carry the advanced SSH options from host config into the session
|
||||
// so the launch/prereq commands connect the same way the operator configured.
|
||||
identityFile: host.identityFile,
|
||||
socksProxy: host.socksProxy,
|
||||
jumpHost: host.jumpHost,
|
||||
extraSshOptions: host.extraSshOptions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* @fileoverview Pure cross-session federated search core (COD-9).
|
||||
*
|
||||
* `searchSources()` is the testable heart of `GET /api/search`: it takes a
|
||||
* normalized query plus already-collected, in-memory source data and returns
|
||||
* grouped, ranked, and capped results. It performs NO I/O — the route wrapper
|
||||
* (`src/web/routes/search-routes.ts`) is responsible for harvesting the source
|
||||
* arrays from the live server stores (sessions, run-summary trackers, attachment
|
||||
* histories) in a bounded way before calling this.
|
||||
*
|
||||
* v1 scope (do not expand here): three sources — sessions/cases, run-summary
|
||||
* events, file paths. Terminal-buffer scanning and any persisted index are
|
||||
* explicitly deferred.
|
||||
*
|
||||
* Ranking: results are grouped by source type in the fixed order
|
||||
* sessions → events → files. Within each group, exact (case-insensitive)
|
||||
* name/path matches come first, then recency (newest timestamp first) as the
|
||||
* tiebreak. There is no relevance-scoring pass in v1.
|
||||
*
|
||||
* Safety: file results only ever expose a workspace-relative path — server-
|
||||
* private absolute paths are never placed in a result. Per-group and total caps
|
||||
* bound the output so a broad query cannot return an unbounded payload.
|
||||
*
|
||||
* Key exports:
|
||||
* - searchSources() — the pure core.
|
||||
* - SEARCH_TOTAL_CAP / SEARCH_PER_GROUP_CAP — the output bounds.
|
||||
* - SearchSources and the *Input row types — the source-data contract.
|
||||
*/
|
||||
|
||||
import type { SearchResult, SearchResultGroup, SearchResponseData, SearchSourceType } from './types/search.js';
|
||||
|
||||
/** Maximum results returned across all groups combined. */
|
||||
export const SEARCH_TOTAL_CAP = 60;
|
||||
/** Maximum results returned within any single source group. */
|
||||
export const SEARCH_PER_GROUP_CAP = 25;
|
||||
/** Maximum characters in a result snippet. */
|
||||
export const SEARCH_SNIPPET_MAX = 200;
|
||||
|
||||
/** A live-session row harvested for the session/case source. */
|
||||
export interface SessionSearchInput {
|
||||
sessionId: string;
|
||||
sessionName: string;
|
||||
workingDir: string;
|
||||
/** Recency timestamp (e.g. lastActivityAt or createdAt). */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** A run-summary timeline event harvested for the event source. */
|
||||
export interface EventSearchInput {
|
||||
sessionId: string;
|
||||
sessionName: string;
|
||||
eventId: string;
|
||||
title: string;
|
||||
details: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** A per-session attachment harvested for the file source. */
|
||||
export interface FileSearchInput {
|
||||
sessionId: string;
|
||||
sessionName: string;
|
||||
fileName: string;
|
||||
/** Workspace-relative path, if known. Absolute/external paths are never passed in. */
|
||||
relativePath: string | undefined;
|
||||
timestamp: number;
|
||||
/** Attachment history item id, used as the jump-to target. */
|
||||
itemId: string;
|
||||
}
|
||||
|
||||
/** The full set of in-memory source data the pure core searches over. */
|
||||
export interface SearchSources {
|
||||
sessions: SessionSearchInput[];
|
||||
events: EventSearchInput[];
|
||||
files: FileSearchInput[];
|
||||
}
|
||||
|
||||
/** Fixed group/render order. */
|
||||
const GROUP_ORDER: SearchSourceType[] = ['session', 'event', 'file'];
|
||||
|
||||
function truncate(text: string, max = SEARCH_SNIPPET_MAX): string {
|
||||
const trimmed = text.trim().replace(/\s+/g, ' ');
|
||||
return trimmed.length > max ? trimmed.slice(0, max - 1) + '…' : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort a group's results: exact matches first, then newest timestamp first.
|
||||
* Stable for equal keys.
|
||||
*/
|
||||
function sortGroup(rows: SearchResult[]): SearchResult[] {
|
||||
return rows
|
||||
.map((result, index) => ({ result, index }))
|
||||
.sort((a, b) => {
|
||||
if (a.result.exactMatch !== b.result.exactMatch) {
|
||||
return a.result.exactMatch ? -1 : 1;
|
||||
}
|
||||
if (a.result.timestamp !== b.result.timestamp) {
|
||||
return b.result.timestamp - a.result.timestamp;
|
||||
}
|
||||
return a.index - b.index;
|
||||
})
|
||||
.map((r) => r.result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the provided in-memory sources for `query`.
|
||||
*
|
||||
* @param query Raw query string (already length-validated by the route). Blank
|
||||
* queries return an empty result set.
|
||||
* @param sources Harvested, bounded source arrays.
|
||||
*/
|
||||
export function searchSources(query: string, sources: SearchSources): SearchResponseData {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (needle.length === 0) {
|
||||
return { query: query.trim(), groups: [], totalResults: 0, truncated: false };
|
||||
}
|
||||
|
||||
const contains = (s: string | undefined): boolean => !!s && s.toLowerCase().includes(needle);
|
||||
const isExact = (s: string | undefined): boolean => !!s && s.toLowerCase() === needle;
|
||||
|
||||
// -- Source: sessions/cases --
|
||||
const sessionRows: SearchResult[] = [];
|
||||
for (const s of sources.sessions) {
|
||||
if (contains(s.sessionName) || contains(s.workingDir) || contains(s.sessionId)) {
|
||||
sessionRows.push({
|
||||
type: 'session',
|
||||
sessionId: s.sessionId,
|
||||
sessionName: s.sessionName,
|
||||
timestamp: s.timestamp,
|
||||
snippet: truncate(s.workingDir ? `${s.sessionName} — ${s.workingDir}` : s.sessionName),
|
||||
exactMatch: isExact(s.sessionName),
|
||||
jumpTo: { kind: 'session', sessionId: s.sessionId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -- Source: run-summary events --
|
||||
const eventRows: SearchResult[] = [];
|
||||
for (const e of sources.events) {
|
||||
if (contains(e.title) || contains(e.details)) {
|
||||
const snippetBase = e.details && contains(e.details) ? `${e.title}: ${e.details}` : e.title;
|
||||
eventRows.push({
|
||||
type: 'event',
|
||||
sessionId: e.sessionId,
|
||||
sessionName: e.sessionName,
|
||||
timestamp: e.timestamp,
|
||||
snippet: truncate(snippetBase),
|
||||
exactMatch: isExact(e.title),
|
||||
jumpTo: { kind: 'run-summary', sessionId: e.sessionId, targetId: e.eventId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -- Source: file paths --
|
||||
const fileRows: SearchResult[] = [];
|
||||
for (const f of sources.files) {
|
||||
if (contains(f.fileName) || contains(f.relativePath)) {
|
||||
fileRows.push({
|
||||
type: 'file',
|
||||
sessionId: f.sessionId,
|
||||
sessionName: f.sessionName,
|
||||
timestamp: f.timestamp,
|
||||
snippet: truncate(f.relativePath ?? f.fileName),
|
||||
// Exact match keys off the safe path (or filename) — never an absolute path.
|
||||
exactMatch: isExact(f.relativePath) || isExact(f.fileName),
|
||||
jumpTo: {
|
||||
kind: 'file-preview',
|
||||
sessionId: f.sessionId,
|
||||
targetId: f.itemId,
|
||||
// Only ever expose a relative path; absolute/external paths are not passed in.
|
||||
relativePath: f.relativePath,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const byType: Record<SearchSourceType, SearchResult[]> = {
|
||||
session: sortGroup(sessionRows),
|
||||
event: sortGroup(eventRows),
|
||||
file: sortGroup(fileRows),
|
||||
};
|
||||
|
||||
const groups: SearchResultGroup[] = [];
|
||||
let total = 0;
|
||||
let truncated = false;
|
||||
|
||||
for (const type of GROUP_ORDER) {
|
||||
const all = byType[type];
|
||||
if (all.length === 0) continue;
|
||||
|
||||
// Per-group cap.
|
||||
let capped = all.slice(0, SEARCH_PER_GROUP_CAP);
|
||||
if (all.length > capped.length) truncated = true;
|
||||
|
||||
// Total cap (never exceed the global budget).
|
||||
const remaining = SEARCH_TOTAL_CAP - total;
|
||||
if (capped.length > remaining) {
|
||||
capped = capped.slice(0, Math.max(0, remaining));
|
||||
truncated = true;
|
||||
}
|
||||
if (capped.length === 0) continue;
|
||||
|
||||
groups.push({ type, results: capped });
|
||||
total += capped.length;
|
||||
}
|
||||
|
||||
return { query: query.trim(), groups, totalResults: total, truncated };
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* @fileoverview Pure merge/filter logic for the unified session list (COD-121).
|
||||
*
|
||||
* Combines four read-only views of a session — live (in-memory `Session`),
|
||||
* persisted (`state.json`), transcript history (`~/.claude/projects`), and the
|
||||
* lifecycle audit log — plus mux process stats, into one de-duplicated list
|
||||
* keyed by sessionId. Transcript-history rows are keyed by the Claude
|
||||
* conversation UUID (the `.jsonl` filename stem), which diverges from the
|
||||
* Codeman id for resumed sessions — an alias map (claudeSessionId → Codeman id,
|
||||
* built from the live/persisted views) folds them into the owning session item.
|
||||
* Higher-precedence sources overwrite scalar fields when present
|
||||
* (history < lifecycle < persisted < live), while the `sources` array
|
||||
* always accumulates every contributing view. A "meaningfulness floor" drops
|
||||
* noise (bare lifecycle/mux-only rows with no name and no first prompt).
|
||||
*
|
||||
* PURE: no fs/IO and no node imports. All IO happens in the route that feeds
|
||||
* this module its inputs, which keeps the merge/sort/filter behavior unit-testable.
|
||||
*/
|
||||
|
||||
export type UnifiedSessionItem = {
|
||||
sessionId: string;
|
||||
name?: string;
|
||||
mode?: string;
|
||||
status?: string;
|
||||
isWorking?: boolean;
|
||||
workingDir?: string;
|
||||
createdAt?: number;
|
||||
lastActivityAt?: number;
|
||||
claudeSessionId?: string;
|
||||
firstPrompt?: string;
|
||||
sizeBytes?: number;
|
||||
projectKey?: string;
|
||||
remote?: boolean;
|
||||
sources: string[];
|
||||
stats?: { memoryMB: number; cpuPercent: number };
|
||||
};
|
||||
|
||||
/** Live in-memory session view (subset of `Session.toState()`). */
|
||||
export type LiveSessionInput = {
|
||||
id: string;
|
||||
name?: string;
|
||||
mode?: string;
|
||||
status?: string;
|
||||
isWorking?: boolean;
|
||||
workingDir?: string;
|
||||
createdAt?: number;
|
||||
lastActivityAt?: number;
|
||||
claudeSessionId?: string;
|
||||
};
|
||||
|
||||
/** Persisted session view (subset of `SessionState`). */
|
||||
export type PersistedSessionInput = {
|
||||
id: string;
|
||||
name?: string;
|
||||
mode?: string;
|
||||
status?: string;
|
||||
workingDir?: string;
|
||||
createdAt?: number;
|
||||
lastActivityAt?: number;
|
||||
/** Claude conversation ID this session resumes (`SessionState.resumeSessionId`). */
|
||||
claudeSessionId?: string;
|
||||
};
|
||||
|
||||
/** Lifecycle audit-log view. Entries are expected NEWEST-first (the order `SessionLifecycleLog.query()` returns). */
|
||||
export type LifecycleInput = {
|
||||
sessionId: string;
|
||||
name?: string;
|
||||
mode?: string;
|
||||
ts: number;
|
||||
event?: string;
|
||||
};
|
||||
|
||||
/** Transcript-history view (one `.jsonl` per session). */
|
||||
export type HistoryInput = {
|
||||
sessionId: string;
|
||||
workingDir: string;
|
||||
sizeBytes: number;
|
||||
lastModified: string;
|
||||
firstPrompt?: string;
|
||||
projectKey?: string;
|
||||
};
|
||||
|
||||
/** Mux process-stat view. */
|
||||
export type MuxStatInput = {
|
||||
sessionId: string;
|
||||
muxName?: string;
|
||||
mode?: string;
|
||||
stats?: { memoryMB: number; cpuPercent: number };
|
||||
remote?: boolean;
|
||||
};
|
||||
|
||||
export type UnifiedSources = {
|
||||
live?: LiveSessionInput[];
|
||||
persisted?: PersistedSessionInput[];
|
||||
lifecycle?: LifecycleInput[];
|
||||
history?: HistoryInput[];
|
||||
mux?: MuxStatInput[];
|
||||
};
|
||||
|
||||
/** Push a source tag onto an item exactly once. */
|
||||
function addSource(item: UnifiedSessionItem, source: string): void {
|
||||
if (!item.sources.includes(source)) item.sources.push(source);
|
||||
}
|
||||
|
||||
/** Get-or-create the accumulator item for a sessionId. */
|
||||
function ensureItem(map: Map<string, UnifiedSessionItem>, sessionId: string): UnifiedSessionItem {
|
||||
let item = map.get(sessionId);
|
||||
if (!item) {
|
||||
item = { sessionId, sources: [] };
|
||||
map.set(sessionId, item);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Overwrite a scalar field only when the incoming value is defined. */
|
||||
function overwrite<K extends keyof UnifiedSessionItem>(
|
||||
item: UnifiedSessionItem,
|
||||
key: K,
|
||||
value: UnifiedSessionItem[K] | undefined
|
||||
): void {
|
||||
if (value !== undefined) item[key] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge all source views into one list, applying precedence
|
||||
* (history → lifecycle → persisted → live) and the meaningfulness floor.
|
||||
*/
|
||||
export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionItem[] {
|
||||
const map = new Map<string, UnifiedSessionItem>();
|
||||
|
||||
// Alias map: Claude conversation UUID → owning Codeman session id. Resumed
|
||||
// (claudeSessionId = resumeSessionId != id) and /clear-respawned sessions
|
||||
// would otherwise surface twice — once as a live/persisted row and once as a
|
||||
// separate history-only row keyed by the conversation UUID. Live wins over
|
||||
// persisted on conflicting entries (registered last).
|
||||
const aliasToOwner = new Map<string, string>();
|
||||
for (const p of sources.persisted ?? []) {
|
||||
if (p.claudeSessionId !== undefined && p.claudeSessionId !== p.id) aliasToOwner.set(p.claudeSessionId, p.id);
|
||||
}
|
||||
for (const v of sources.live ?? []) {
|
||||
if (v.claudeSessionId !== undefined && v.claudeSessionId !== v.id) aliasToOwner.set(v.claudeSessionId, v.id);
|
||||
}
|
||||
const resolveId = (sessionId: string): string => aliasToOwner.get(sessionId) ?? sessionId;
|
||||
|
||||
// 1) history (lowest precedence; keys resolve through the alias map)
|
||||
for (const h of sources.history ?? []) {
|
||||
const item = ensureItem(map, resolveId(h.sessionId));
|
||||
addSource(item, 'history');
|
||||
overwrite(item, 'workingDir', h.workingDir);
|
||||
overwrite(item, 'sizeBytes', h.sizeBytes);
|
||||
overwrite(item, 'firstPrompt', h.firstPrompt);
|
||||
overwrite(item, 'projectKey', h.projectKey);
|
||||
const ms = Date.parse(h.lastModified);
|
||||
if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms;
|
||||
}
|
||||
|
||||
// 2) lifecycle — entries arrive NEWEST-first, so first-seen wins for
|
||||
// name/mode (mirrors the lastActivityAt guard); unconditional overwrites
|
||||
// would leave the OLDEST entry in the window (stale name/mode) standing.
|
||||
for (const l of sources.lifecycle ?? []) {
|
||||
const item = ensureItem(map, resolveId(l.sessionId));
|
||||
addSource(item, 'lifecycle');
|
||||
if (item.name === undefined) overwrite(item, 'name', l.name);
|
||||
if (item.mode === undefined) overwrite(item, 'mode', l.mode);
|
||||
if (item.lastActivityAt === undefined && typeof l.ts === 'number') item.lastActivityAt = l.ts;
|
||||
}
|
||||
|
||||
// 3) persisted
|
||||
for (const p of sources.persisted ?? []) {
|
||||
const item = ensureItem(map, p.id);
|
||||
addSource(item, 'persisted');
|
||||
overwrite(item, 'name', p.name);
|
||||
overwrite(item, 'mode', p.mode);
|
||||
overwrite(item, 'status', p.status);
|
||||
overwrite(item, 'workingDir', p.workingDir);
|
||||
overwrite(item, 'createdAt', p.createdAt);
|
||||
overwrite(item, 'lastActivityAt', p.lastActivityAt);
|
||||
}
|
||||
|
||||
// 4) live (highest precedence)
|
||||
for (const v of sources.live ?? []) {
|
||||
const item = ensureItem(map, v.id);
|
||||
addSource(item, 'live');
|
||||
overwrite(item, 'name', v.name);
|
||||
overwrite(item, 'mode', v.mode);
|
||||
overwrite(item, 'status', v.status);
|
||||
overwrite(item, 'isWorking', v.isWorking);
|
||||
overwrite(item, 'workingDir', v.workingDir);
|
||||
overwrite(item, 'createdAt', v.createdAt);
|
||||
overwrite(item, 'lastActivityAt', v.lastActivityAt);
|
||||
overwrite(item, 'claudeSessionId', v.claudeSessionId);
|
||||
}
|
||||
|
||||
// 5) mux stats + remote flag (create item if mux-only)
|
||||
for (const m of sources.mux ?? []) {
|
||||
const item = ensureItem(map, m.sessionId);
|
||||
addSource(item, 'mux');
|
||||
overwrite(item, 'mode', m.mode);
|
||||
if (m.stats) item.stats = m.stats;
|
||||
if (m.remote !== undefined) item.remote = m.remote;
|
||||
}
|
||||
|
||||
// Meaningfulness floor: keep real rows, drop bare lifecycle/mux-only noise.
|
||||
const kept: UnifiedSessionItem[] = [];
|
||||
for (const item of map.values()) {
|
||||
const isReal =
|
||||
item.sources.includes('live') ||
|
||||
item.sources.includes('persisted') ||
|
||||
item.sources.includes('history') ||
|
||||
(item.firstPrompt !== undefined && item.firstPrompt !== '');
|
||||
if (isReal) kept.push(item);
|
||||
}
|
||||
|
||||
// Stable sort: lastActivityAt desc (undefined last), createdAt desc, sessionId asc.
|
||||
kept.sort((a, b) => {
|
||||
const la = a.lastActivityAt;
|
||||
const lb = b.lastActivityAt;
|
||||
if (la !== lb) {
|
||||
if (la === undefined) return 1;
|
||||
if (lb === undefined) return -1;
|
||||
return lb - la;
|
||||
}
|
||||
const ca = a.createdAt;
|
||||
const cb = b.createdAt;
|
||||
if (ca !== cb) {
|
||||
if (ca === undefined) return 1;
|
||||
if (cb === undefined) return -1;
|
||||
return cb - ca;
|
||||
}
|
||||
return a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0;
|
||||
});
|
||||
|
||||
return kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive substring filter (name + firstPrompt + workingDir + sessionId)
|
||||
* with offset/limit paging. `total` is the filtered count BEFORE paging.
|
||||
*/
|
||||
export function filterAndPaginate(
|
||||
items: UnifiedSessionItem[],
|
||||
opts: { q?: string; offset?: number; limit?: number }
|
||||
): { sessions: UnifiedSessionItem[]; total: number } {
|
||||
const q = (opts.q ?? '').trim().toLowerCase();
|
||||
const filtered = q
|
||||
? items.filter((it) => {
|
||||
const hay = [it.name, it.firstPrompt, it.workingDir, it.sessionId]
|
||||
.filter((v): v is string => typeof v === 'string')
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return hay.includes(q);
|
||||
})
|
||||
: items;
|
||||
|
||||
const total = filtered.length;
|
||||
const offset = Math.max(0, Math.floor(opts.offset ?? 0));
|
||||
const limit = Math.min(500, Math.max(1, Math.floor(opts.limit ?? 100)));
|
||||
const sessions = filtered.slice(offset, offset + limit);
|
||||
return { sessions, total };
|
||||
}
|
||||
@@ -102,14 +102,12 @@ export function buildPromptArgs(prompt: string, model?: string): string[] {
|
||||
* @returns Environment variables object for pty.spawn
|
||||
*/
|
||||
export function buildClaudeEnv(sessionId: string): Record<string, string | undefined> {
|
||||
return {
|
||||
const env: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
LANG: 'en_US.UTF-8',
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
PATH: getAugmentedPath(),
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: undefined,
|
||||
CLAUDECODE: undefined,
|
||||
// Inform Claude it's running within Codeman (helps prevent self-termination)
|
||||
CODEMAN_MUX: '1',
|
||||
CODEMAN_SESSION_ID: sessionId,
|
||||
@@ -117,6 +115,11 @@ export function buildClaudeEnv(sessionId: string): Record<string, string | undef
|
||||
// Path only (not the secret value) — hook curls cat it at execution time (COD-54)
|
||||
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
|
||||
};
|
||||
// COD-115: `delete`, not `= undefined` — node-pty serializes a present-with-undefined
|
||||
// key as the literal string "KEY=undefined" (see buildMuxAttachEnv below).
|
||||
delete env.COLORTERM;
|
||||
delete env.CLAUDECODE;
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,17 +127,32 @@ export function buildClaudeEnv(sessionId: string): Record<string, string | undef
|
||||
* Lighter than buildClaudeEnv — no PATH augmentation or Codeman vars needed
|
||||
* since the mux session already has those set.
|
||||
*
|
||||
* @param truecolorEnabled - When true, set COLORTERM=truecolor (COD-75 opt-in);
|
||||
* otherwise leave COLORTERM unset. Mirrors buildEnvExports() so both paths agree.
|
||||
* @returns Environment variables object for pty.spawn
|
||||
*/
|
||||
export function buildMuxAttachEnv(): Record<string, string | undefined> {
|
||||
return {
|
||||
export function buildMuxAttachEnv(truecolorEnabled?: boolean): Record<string, string | undefined> {
|
||||
const env: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
LANG: 'en_US.UTF-8',
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: undefined,
|
||||
CLAUDECODE: undefined,
|
||||
};
|
||||
// COD-115: keys to UNSET must be `delete`d, NOT set to `undefined`. On a
|
||||
// `{...process.env}` spread the key stays present with value undefined, and node-pty
|
||||
// serializes it as the literal string "TMUX=undefined" — a non-empty value that still
|
||||
// trips tmux's nesting guard, killing the attach-bridge PTY (exit 1 → respawn loop).
|
||||
// The server can be launched from inside tmux; attach clients must never inherit that
|
||||
// parent tmux context. (Same fix the working create path uses in tmux-manager.ts.)
|
||||
delete env.TMUX;
|
||||
delete env.TMUX_PANE;
|
||||
delete env.CLAUDECODE;
|
||||
if (truecolorEnabled) {
|
||||
env.COLORTERM = 'truecolor';
|
||||
} else {
|
||||
delete env.COLORTERM; // COD-75: unset for non-truecolor (was `: undefined`, same node-pty quirk)
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @fileoverview Circuit breaker bounding repeated non-zero interactive-PTY exits (COD-118).
|
||||
*
|
||||
* Defense-in-depth after COD-115: if the interactive PTY exits non-zero repeatedly,
|
||||
* external recovery/reconnect paths recreate it indefinitely (COD-115 observed 114
|
||||
* `exited with code: 1` events + orphan sessions). This breaker tracks recent
|
||||
* non-zero exits within a sliding window and "trips" once they exceed a threshold,
|
||||
* so the Session can refuse to respawn and surface an error state instead of looping.
|
||||
*
|
||||
* Design notes:
|
||||
* - PURE + dependency-free. Time is INJECTED (`nowMs` passed to `recordExit`); the
|
||||
* breaker never calls `Date.now()` itself, so trip/window logic is deterministically
|
||||
* unit-testable with no real timers.
|
||||
* - A clean (exit code 0) exit resets the counter — a session that exited normally is
|
||||
* not on a crash-loop. (It does NOT clear an already-tripped breaker; only an explicit
|
||||
* `reset()` — e.g. a user-initiated restart — does that.)
|
||||
* - Once tripped, stays tripped until `reset()`.
|
||||
*
|
||||
* @consumedby session (instantiates one per session; records exits in the interactive
|
||||
* PTY `onExit` handler; gates `startInteractive()` when tripped; `reset()` on restart)
|
||||
* @module session-pty-exit-breaker
|
||||
*/
|
||||
|
||||
/** Non-zero interactive-PTY exits within the window required to trip the breaker. */
|
||||
export const DEFAULT_BREAKER_THRESHOLD = 5;
|
||||
|
||||
/** Sliding window (ms) over which non-zero exits accumulate toward the threshold. */
|
||||
export const DEFAULT_BREAKER_WINDOW_MS = 10_000;
|
||||
|
||||
export interface InteractivePtyExitBreakerOptions {
|
||||
/** Trip after this many non-zero exits within `windowMs` (default 5). */
|
||||
threshold?: number;
|
||||
/** Sliding window length in ms (default 10_000). */
|
||||
windowMs?: number;
|
||||
}
|
||||
|
||||
export interface RecordExitResult {
|
||||
/** True once the breaker has tripped (stays true until `reset()`). */
|
||||
tripped: boolean;
|
||||
/** Number of non-zero exits currently inside the window. */
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sliding-window counter that trips on rapid repeated non-zero exits.
|
||||
*
|
||||
* 5 within 10s safely clears normal usage (a single exit, an intentional restart)
|
||||
* while tripping fast on a real loop — COD-115 saw 114 exits, far above 5.
|
||||
*/
|
||||
export class InteractivePtyExitBreaker {
|
||||
private readonly _threshold: number;
|
||||
private readonly _windowMs: number;
|
||||
|
||||
/** Timestamps (ms, injected) of recent non-zero exits, oldest first. */
|
||||
private _exitTimes: number[] = [];
|
||||
|
||||
private _tripped = false;
|
||||
|
||||
constructor(opts: InteractivePtyExitBreakerOptions = {}) {
|
||||
this._threshold = opts.threshold ?? DEFAULT_BREAKER_THRESHOLD;
|
||||
this._windowMs = opts.windowMs ?? DEFAULT_BREAKER_WINDOW_MS;
|
||||
}
|
||||
|
||||
/** Whether the breaker has tripped (respawn should be blocked). */
|
||||
get tripped(): boolean {
|
||||
return this._tripped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a PTY exit. A zero (clean) exit resets the non-zero counter; a non-zero
|
||||
* exit is added to the window, stale entries are evicted, and the breaker trips
|
||||
* once the in-window count reaches the threshold.
|
||||
*
|
||||
* @param exitCode the PTY exit code (0 = clean)
|
||||
* @param nowMs injected current time in ms (never read from a real clock)
|
||||
*/
|
||||
recordExit(exitCode: number, nowMs: number): RecordExitResult {
|
||||
if (exitCode === 0) {
|
||||
// Clean exit: a normal stop, not a crash-loop. Clear accumulated non-zero
|
||||
// exits. Does NOT un-trip an already-tripped breaker (only reset() does).
|
||||
this._exitTimes = [];
|
||||
return { tripped: this._tripped, count: 0 };
|
||||
}
|
||||
|
||||
// Evict exits strictly older than the window, then record this one.
|
||||
const cutoff = nowMs - this._windowMs;
|
||||
this._exitTimes = this._exitTimes.filter((t) => t > cutoff);
|
||||
this._exitTimes.push(nowMs);
|
||||
|
||||
if (this._exitTimes.length >= this._threshold) {
|
||||
this._tripped = true;
|
||||
}
|
||||
|
||||
return { tripped: this._tripped, count: this._exitTimes.length };
|
||||
}
|
||||
|
||||
/** Clear the tripped state and the non-zero counter (e.g. on intentional restart). */
|
||||
reset(): void {
|
||||
this._exitTimes = [];
|
||||
this._tripped = false;
|
||||
}
|
||||
}
|
||||
+268
-19
@@ -48,7 +48,11 @@ import {
|
||||
type OpenCodeConfig,
|
||||
type CodexConfig,
|
||||
type EffortLevel,
|
||||
type GeminiConfig,
|
||||
type SessionRemote,
|
||||
type SessionDocker,
|
||||
} from './types.js';
|
||||
import { probeDockerCliVersion } from './docker-hosts.js';
|
||||
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
||||
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
||||
import { RalphTracker } from './ralph-tracker.js';
|
||||
@@ -60,6 +64,7 @@ import {
|
||||
SPINNER_PATTERN,
|
||||
MAX_SESSION_TOKENS,
|
||||
execPattern,
|
||||
getClaudeCliVersion,
|
||||
} from './utils/index.js';
|
||||
import {
|
||||
MAX_TERMINAL_BUFFER_SIZE,
|
||||
@@ -69,6 +74,7 @@ import {
|
||||
MAX_MESSAGES,
|
||||
MAX_LINE_BUFFER_SIZE,
|
||||
} from './config/buffer-limits.js';
|
||||
import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js';
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import {
|
||||
buildInteractiveArgs,
|
||||
@@ -80,7 +86,8 @@ import {
|
||||
import { SessionAutoOps } from './session-auto-ops.js';
|
||||
import { detectUsageLimitPause } from './usage-limit-patterns.js';
|
||||
import { SessionTaskCache } from './session-task-cache.js';
|
||||
import { parseAttachmentMagicLinks } from './attachment-magic.js';
|
||||
import { InteractivePtyExitBreaker } from './session-pty-exit-breaker.js';
|
||||
import { parseTerminalAttachmentRequests } from './attachment-magic.js';
|
||||
import {
|
||||
sanitizeAttachmentHistory,
|
||||
upsertAttachmentHistory as upsertAttachmentHistoryList,
|
||||
@@ -133,7 +140,22 @@ const NEWLINE_SPLIT_PATTERN = /\r?\n/;
|
||||
|
||||
/** True for external-CLI run modes (non-Claude) that use their own TUI and output format. */
|
||||
export function isExternalCliMode(mode: SessionMode): boolean {
|
||||
return mode === 'opencode' || mode === 'codex';
|
||||
return mode === 'opencode' || mode === 'codex' || mode === 'gemini';
|
||||
}
|
||||
|
||||
function getModeLabel(mode: SessionMode): string {
|
||||
switch (mode) {
|
||||
case 'opencode':
|
||||
return 'OpenCode';
|
||||
case 'codex':
|
||||
return 'Codex';
|
||||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'shell':
|
||||
return 'Shell';
|
||||
case 'claude':
|
||||
return 'Claude';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,14 +163,15 @@ export function isExternalCliMode(mode: SessionMode): boolean {
|
||||
* that we strip so the browser keeps everything in the main buffer with scrollback
|
||||
* reachable (the strip runs on both the live stream and the buffer replay).
|
||||
*
|
||||
* Codex and Claude Code are known, controlled TUIs that repaint via cursor
|
||||
* positioning, so dropping the alt-screen switch is safe — content stays in the
|
||||
* normal buffer. Excluded: `shell` (arbitrary programs like vim/less/htop
|
||||
* legitimately need the alt screen) and `opencode` (renders its own TUI that
|
||||
* may rely on it). Keep parity with the replay-side strip in session-routes.ts.
|
||||
* Codex, Claude Code, and Gemini are known, controlled (Ink/React) TUIs that
|
||||
* repaint via cursor positioning, so dropping the alt-screen switch is safe —
|
||||
* content stays in the normal buffer. Excluded: `shell` (arbitrary programs like
|
||||
* vim/less/htop legitimately need the alt screen) and `opencode` (renders its own
|
||||
* TUI that may rely on it). Keep parity with the replay-side strip in
|
||||
* session-routes.ts.
|
||||
*/
|
||||
export function isAltScreenStripMode(mode: SessionMode): boolean {
|
||||
return mode === 'codex' || mode === 'claude';
|
||||
return mode === 'codex' || mode === 'claude' || mode === 'gemini';
|
||||
}
|
||||
|
||||
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
|
||||
@@ -157,6 +180,8 @@ export function isAltScreenStripMode(mode: SessionMode): boolean {
|
||||
const DEFAULT_PTY_COLS = 120;
|
||||
const DEFAULT_PTY_ROWS = 40;
|
||||
const TMUX_DISPLAY_TIMEOUT_MS = 2000;
|
||||
/** Delay before the in-container Claude CLI version probe (lets the container start). */
|
||||
const DOCKER_CLI_VERSION_PROBE_DELAY_MS = 3000;
|
||||
|
||||
/**
|
||||
* Ask tmux for the current window geometry of `muxName` so a re-attaching PTY
|
||||
@@ -191,6 +216,12 @@ export function queryTmuxWindowSize(muxName: string, socket: string): { cols: nu
|
||||
return { cols: DEFAULT_PTY_COLS, rows: DEFAULT_PTY_ROWS };
|
||||
}
|
||||
|
||||
export function resolveMuxAttachCwd(workingDir: string, remote?: SessionRemote, docker?: SessionDocker): string {
|
||||
// Remote and docker sessions run the CLI elsewhere (ssh / docker exec); the LOCAL
|
||||
// wrapper pane never needs the workspace as its cwd, so launch it in /tmp.
|
||||
return remote || docker ? '/tmp' : workingDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a JSON message from Claude CLI's stream-json output format.
|
||||
* Messages are newline-delimited JSON objects parsed from PTY output.
|
||||
@@ -270,6 +301,13 @@ export class Session extends EventEmitter {
|
||||
private _pid: number | null = null;
|
||||
private _status: SessionStatus = 'idle';
|
||||
private _currentTaskId: string | null = null;
|
||||
|
||||
// COD-118: bound repeated non-zero interactive-PTY exits. Recorded in the
|
||||
// interactive PTY onExit handler; when it trips, the session flips to 'error'
|
||||
// and startInteractive() refuses to respawn until an explicit user restart
|
||||
// calls resetRespawnBreaker(). Defense-in-depth over the COD-115 crash-loop.
|
||||
private readonly _ptyExitBreaker = new InteractivePtyExitBreaker();
|
||||
private _respawnBlocked = false;
|
||||
// Use BufferAccumulator for hot-path buffers to reduce GC pressure
|
||||
private _terminalBuffer = new BufferAccumulator(MAX_TERMINAL_BUFFER_SIZE, TERMINAL_BUFFER_TRIM_SIZE);
|
||||
private _textOutput = new BufferAccumulator(MAX_TEXT_OUTPUT_SIZE, TEXT_OUTPUT_TRIM_SIZE);
|
||||
@@ -351,6 +389,8 @@ export class Session extends EventEmitter {
|
||||
private _openCodeConfig: OpenCodeConfig | undefined;
|
||||
// Codex configuration (only for mode === 'codex')
|
||||
private _codexConfig: CodexConfig | undefined;
|
||||
// Gemini configuration (only for mode === 'gemini')
|
||||
private _geminiConfig: GeminiConfig | undefined;
|
||||
private _resumeSessionId: string | undefined;
|
||||
|
||||
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
|
||||
@@ -362,6 +402,16 @@ export class Session extends EventEmitter {
|
||||
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
|
||||
private _effort: EffortLevel | undefined;
|
||||
|
||||
// tmux history-limit (scrollback lines) applied to this session's pane.
|
||||
private readonly _tmuxHistoryLimit: number;
|
||||
|
||||
// Remote execution metadata, present when this session runs over SSH through local tmux.
|
||||
private readonly _remote?: SessionRemote;
|
||||
|
||||
// Docker execution metadata, present when this session runs inside a container via
|
||||
// local tmux + `docker exec`. The container is per-CASE (shared by sibling sessions).
|
||||
private readonly _docker?: SessionDocker;
|
||||
|
||||
// Session color for visual differentiation
|
||||
private _color: import('./types.js').SessionColor = 'default';
|
||||
|
||||
@@ -421,14 +471,22 @@ export class Session extends EventEmitter {
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
/** Codex configuration (only for mode === 'codex') */
|
||||
codexConfig?: CodexConfig;
|
||||
/** Gemini configuration (only for mode === 'gemini') */
|
||||
geminiConfig?: GeminiConfig;
|
||||
/** Resume a previous Claude conversation (used after server reboot) */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
|
||||
envOverrides?: Record<string, string>;
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort?: EffortLevel;
|
||||
/** tmux history-limit (scrollback lines) for this session's pane. */
|
||||
tmuxHistoryLimit?: number;
|
||||
/** Restored per-session attachment history. May include server-private external paths. */
|
||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||
/** Remote execution metadata for sessions launched through SSH inside local tmux. */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata for sessions launched inside a container via local tmux. */
|
||||
docker?: SessionDocker;
|
||||
}
|
||||
) {
|
||||
super();
|
||||
@@ -481,6 +539,11 @@ export class Session extends EventEmitter {
|
||||
this._codexConfig = config.codexConfig;
|
||||
}
|
||||
|
||||
// Apply Gemini configuration
|
||||
if (config.geminiConfig) {
|
||||
this._geminiConfig = config.geminiConfig;
|
||||
}
|
||||
|
||||
// Apply env overrides (exported at spawn, not persisted to disk).
|
||||
// Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var,
|
||||
// which hard-locks /effort switching. Extract it into _effort (--settings soft default)
|
||||
@@ -495,6 +558,9 @@ export class Session extends EventEmitter {
|
||||
if (config.effort && isEffortLevel(config.effort)) {
|
||||
this._effort = config.effort;
|
||||
}
|
||||
this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT;
|
||||
this._remote = config.remote;
|
||||
this._docker = config.docker;
|
||||
if (config.attachmentHistory && config.attachmentHistory.length > 0) {
|
||||
this.restoreAttachmentHistory(config.attachmentHistory);
|
||||
}
|
||||
@@ -596,6 +662,11 @@ export class Session extends EventEmitter {
|
||||
return this._claudeSessionId;
|
||||
}
|
||||
|
||||
/** Docker execution metadata when this session runs inside a container, else undefined. */
|
||||
get docker(): SessionDocker | undefined {
|
||||
return this._docker;
|
||||
}
|
||||
|
||||
// Adopt a Claude conversation ID observed from an external source (e.g. hook
|
||||
// payload). In interactive PTY mode Claude CLI emits no JSON to stdout, so
|
||||
// `_handleJsonMessage` never sees `session_id`; hooks are the only signal
|
||||
@@ -954,6 +1025,8 @@ export class Session extends EventEmitter {
|
||||
pid: this.pid,
|
||||
status: this._status,
|
||||
workingDir: this.workingDir,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
currentTaskId: this._currentTaskId,
|
||||
createdAt: this.createdAt,
|
||||
lastActivityAt: this._lastActivityAt,
|
||||
@@ -985,8 +1058,14 @@ export class Session extends EventEmitter {
|
||||
cliLatestVersion: this._cliLatestVersion || undefined,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
codexConfig: this._codexConfig,
|
||||
geminiConfig: this._geminiConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
effort: this._effort,
|
||||
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
||||
// intent before restarting a crash-looped session. Deliberately NOT restored
|
||||
// by the constructor: a Codeman restart starts with a fresh breaker so boot
|
||||
// recovery can re-attach.
|
||||
respawnBlocked: this._respawnBlocked || undefined,
|
||||
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
|
||||
// envOverrides intentionally NOT on the public SessionState type — they must not
|
||||
// leak into SSE / GET /api/sessions broadcasts (schema allows OPENCODE_*, which
|
||||
@@ -1137,8 +1216,10 @@ export class Session extends EventEmitter {
|
||||
name: 'xterm-256color',
|
||||
cols: ptyCols,
|
||||
rows: ptyRows,
|
||||
cwd: this.workingDir,
|
||||
env: buildMuxAttachEnv(),
|
||||
cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker),
|
||||
// COD-75: codex/gemini get COLORTERM=truecolor — mirrors buildEnvExports()
|
||||
// in tmux-manager.ts so the attach client and the tmux session agree.
|
||||
env: buildMuxAttachEnv(this.mode === 'codex' || this.mode === 'gemini'),
|
||||
});
|
||||
} catch (spawnErr) {
|
||||
console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr);
|
||||
@@ -1196,18 +1277,26 @@ export class Session extends EventEmitter {
|
||||
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, '');
|
||||
}
|
||||
|
||||
// Scan terminal output for `codeman://attach?path=...` magic links and emit
|
||||
// an attachmentRequested event for each newly-seen absolute path. The web
|
||||
// server turns these into registered attachment cards.
|
||||
const attachmentPaths = parseAttachmentMagicLinks(data);
|
||||
for (const attachmentPath of attachmentPaths) {
|
||||
if (this._attachmentMagicSeen.has(attachmentPath)) continue;
|
||||
this._attachmentMagicSeen.add(attachmentPath);
|
||||
// Scan terminal output for attachment requests. `codeman://attach?...` is an
|
||||
// explicit magic link (all modes); Codex generated images report
|
||||
// `Saved to: file://...` — that scanner (and its relaxed trust policy) is
|
||||
// only enabled for codex-mode sessions. The web server applies the trust
|
||||
// boundary for each request source.
|
||||
const attachmentRequests = parseTerminalAttachmentRequests(data, { codexArtifacts: this.mode === 'codex' });
|
||||
for (const request of attachmentRequests) {
|
||||
const seenKey = `${request.source}:${request.path}`;
|
||||
if (this._attachmentMagicSeen.has(seenKey)) continue;
|
||||
this._attachmentMagicSeen.add(seenKey);
|
||||
if (this._attachmentMagicSeen.size > 200) {
|
||||
const oldest = this._attachmentMagicSeen.values().next().value;
|
||||
if (oldest) this._attachmentMagicSeen.delete(oldest);
|
||||
}
|
||||
this.emit('attachmentRequested', { sessionId: this.id, path: attachmentPath, timestamp: Date.now() });
|
||||
this.emit('attachmentRequested', {
|
||||
sessionId: this.id,
|
||||
path: request.path,
|
||||
source: request.source,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// BufferAccumulator handles auto-trimming when max size exceeded
|
||||
@@ -1222,13 +1311,69 @@ export class Session extends EventEmitter {
|
||||
throw new Error('Session already has a running process');
|
||||
}
|
||||
|
||||
// COD-118: if the PTY exit breaker has tripped (repeated non-zero exits in a
|
||||
// short window), refuse to respawn. This is the uniform choke point that stops
|
||||
// automatic recovery/reconnect callers from re-creating a crash-looping PTY.
|
||||
// An explicit user restart clears it via resetRespawnBreaker().
|
||||
if (this._respawnBlocked) {
|
||||
throw new Error(
|
||||
'Respawn blocked: interactive PTY exited non-zero too many times in a short window (circuit breaker tripped). Restart the session to clear it.'
|
||||
);
|
||||
}
|
||||
|
||||
this._resetBuffers();
|
||||
|
||||
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : this.mode === 'codex' ? 'Codex' : 'Claude';
|
||||
const modeLabel = getModeLabel(this.mode);
|
||||
console.log(
|
||||
`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '')
|
||||
);
|
||||
|
||||
// Seed the CLI version deterministically for LOCAL Claude sessions. The
|
||||
// banner scrape in parseClaudeCodeInfo() is unreliable — newer Claude Code
|
||||
// builds don't print "Claude Code vX.Y.Z" at startup and resumed sessions
|
||||
// never show it — which left cliVersion undefined and silently disabled
|
||||
// wheel-forwarding to Claude's own transcript (the only route to history in
|
||||
// repaint/alt-screen mode; issue #154). Remote sessions run claude on
|
||||
// another host, so a local probe wouldn't reflect their version — skip them
|
||||
// and let the banner scrape handle those. Cached process-wide, best-effort.
|
||||
if (this.mode === 'claude' && !this._remote && !this._docker && !this._cliVersion) {
|
||||
const probedVersion = getClaudeCliVersion();
|
||||
if (probedVersion) {
|
||||
this._cliVersion = probedVersion;
|
||||
this.emit('cliInfoUpdated', {
|
||||
version: this._cliVersion,
|
||||
model: this._cliModel,
|
||||
accountType: this._cliAccountType,
|
||||
latestVersion: this._cliLatestVersion,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Docker sessions run claude INSIDE the container, so the local probe above
|
||||
// reports the HOST claude (wrong version, and leaving cliVersion undefined
|
||||
// silently disables wheel-forwarding, #154). Probe the IN-CONTAINER version
|
||||
// instead — deferred so the container is up after the mux attach below.
|
||||
if (this.mode === 'claude' && this._docker && !this._cliVersion) {
|
||||
const dockerMeta = this._docker;
|
||||
setTimeout(() => {
|
||||
if (this._isStopped || this._cliVersion) return;
|
||||
void probeDockerCliVersion(dockerMeta, this.mode)
|
||||
.then((version) => {
|
||||
if (!version || this._isStopped || this._cliVersion) return;
|
||||
this._cliVersion = version;
|
||||
this.emit('cliInfoUpdated', {
|
||||
version: this._cliVersion,
|
||||
model: this._cliModel,
|
||||
accountType: this._cliAccountType,
|
||||
latestVersion: this._cliLatestVersion,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
/* best-effort */
|
||||
});
|
||||
}, DOCKER_CLI_VERSION_PROBE_DELAY_MS);
|
||||
}
|
||||
|
||||
// If mux wrapping is enabled, create or attach to a mux session
|
||||
if (this._useMux && this._mux) {
|
||||
try {
|
||||
@@ -1243,9 +1388,13 @@ export class Session extends EventEmitter {
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
codexConfig: this._codexConfig,
|
||||
geminiConfig: this._geminiConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
},
|
||||
createSessionOptions: {
|
||||
sessionId: this.id,
|
||||
@@ -1258,9 +1407,13 @@ export class Session extends EventEmitter {
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
codexConfig: this._codexConfig,
|
||||
geminiConfig: this._geminiConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
},
|
||||
spawnErrLabel: 'mux attachment',
|
||||
});
|
||||
@@ -1331,6 +1484,10 @@ export class Session extends EventEmitter {
|
||||
if (this.mode === 'codex') {
|
||||
throw new Error('Codex sessions require tmux. Direct PTY fallback is not supported.');
|
||||
}
|
||||
// Gemini sessions require tmux for Gemini/Google auth env injection via setenv
|
||||
if (this.mode === 'gemini') {
|
||||
throw new Error('Gemini sessions require tmux. Direct PTY fallback is not supported.');
|
||||
}
|
||||
try {
|
||||
// Pass --session-id to use the SAME ID as the Codeman session
|
||||
// This ensures subagents can be directly matched to the correct tab
|
||||
@@ -1454,6 +1611,9 @@ export class Session extends EventEmitter {
|
||||
|
||||
this.ptyProcess.onExit(({ exitCode }) => {
|
||||
console.log('[Session] Interactive PTY exited with code:', exitCode);
|
||||
// COD-118: record the exit in the circuit breaker BEFORE status bookkeeping.
|
||||
// A clean (0) exit resets the counter; rapid non-zero repeats trip it.
|
||||
const breakerResult = this._ptyExitBreaker.recordExit(exitCode, Date.now());
|
||||
this.ptyProcess = null;
|
||||
this._pid = null;
|
||||
this._status = 'idle';
|
||||
@@ -1481,10 +1641,38 @@ export class Session extends EventEmitter {
|
||||
if (this._muxSession && this._mux) {
|
||||
this._mux.setAttached(this.id, false);
|
||||
}
|
||||
// COD-118: if the breaker tripped, surface an error state and block the NEXT
|
||||
// respawn so recovery/reconnect callers stop looping. Still emit 'exit' below
|
||||
// for normal cleanup. Cleared by an explicit user restart (resetRespawnBreaker()).
|
||||
if (breakerResult.tripped && !this._respawnBlocked) {
|
||||
this._respawnBlocked = true;
|
||||
this._status = 'error';
|
||||
console.error(
|
||||
`[Session] PTY exit circuit breaker tripped for ${this.id} (${breakerResult.count} non-zero exits within window); blocking respawn.`
|
||||
);
|
||||
this.emit('respawnBreakerTripped', { count: breakerResult.count });
|
||||
}
|
||||
this.emit('exit', exitCode);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the interactive-PTY exit circuit breaker (COD-118).
|
||||
*
|
||||
* Called on an EXPLICIT, user-initiated (re)start so an intentional restart is
|
||||
* never blocked by a prior crash-loop trip. Automatic recovery/reconnect paths
|
||||
* must NOT call this — that's the whole point of the breaker.
|
||||
*/
|
||||
resetRespawnBreaker(): void {
|
||||
this._ptyExitBreaker.reset();
|
||||
this._respawnBlocked = false;
|
||||
}
|
||||
|
||||
/** Whether the interactive-PTY exit circuit breaker is currently tripped (COD-118). */
|
||||
get respawnBlocked(): boolean {
|
||||
return this._respawnBlocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process expensive parsers (ANSI strip, Ralph, bash tool, token, CLI info, task descriptions).
|
||||
* Called on a throttled schedule (every EXPENSIVE_PROCESS_INTERVAL_MS) instead of on every
|
||||
@@ -1594,6 +1782,9 @@ export class Session extends EventEmitter {
|
||||
mode: 'shell',
|
||||
niceConfig: this._niceConfig,
|
||||
envOverrides: this._envOverrides,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
},
|
||||
createSessionOptions: {
|
||||
sessionId: this.id,
|
||||
@@ -1602,6 +1793,9 @@ export class Session extends EventEmitter {
|
||||
name: this._name,
|
||||
niceConfig: this._niceConfig,
|
||||
envOverrides: this._envOverrides,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
},
|
||||
spawnErrLabel: 'shell mux attachment',
|
||||
});
|
||||
@@ -2208,11 +2402,65 @@ export class Session extends EventEmitter {
|
||||
* ```
|
||||
*/
|
||||
write(data: string): void {
|
||||
this._trackCodexSubmit(data);
|
||||
if (this.ptyProcess) {
|
||||
this.ptyProcess.write(data);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Codex thread tracking ─────────────────────────────────────────────
|
||||
// When a codex pane last submitted a message (Enter). The response-viewer
|
||||
// correlates this against ~/.codex/history.jsonl entry timestamps to find
|
||||
// the thread the pane is ACTUALLY on — the only signal that survives
|
||||
// /resume, /new and /fork typed inside the codex TUI itself.
|
||||
private _codexLastSubmitAt = 0;
|
||||
|
||||
get codexLastSubmitAt(): number {
|
||||
return this._codexLastSubmitAt;
|
||||
}
|
||||
|
||||
private _trackCodexSubmit(data: string): void {
|
||||
if (this.mode === 'codex' && (data.includes('\r') || data.includes('\n'))) {
|
||||
this._codexLastSubmitAt = 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
|
||||
* long-lived session can't grow it without limit (insertion order = MRU, so
|
||||
* eviction drops the least-recently-active client).
|
||||
*/
|
||||
private _appliedInputSeq = new Map<string, number>();
|
||||
private static readonly MAX_INPUT_DEDUP_CLIENTS = 256;
|
||||
|
||||
/**
|
||||
* Decide whether an input frame should be applied to the PTY or skipped as a
|
||||
* duplicate redelivery. Returns true exactly once per (clientId, seq): the
|
||||
* first time a seq strictly greater than the client's last-applied is seen.
|
||||
* A redelivery of an already-applied seq (the client never got our ACK and
|
||||
* resent) returns false. Callers should ACK regardless — a duplicate is, from
|
||||
* the client's view, "delivered" — and only `write()` the PTY when this is
|
||||
* true. Relies on the client delivering one client's frames in seq order over
|
||||
* a single ordered stream, so `seq <= last` ⇒ already applied.
|
||||
*
|
||||
* Without this, the client's at-least-once redelivery (needed because a
|
||||
* half-open socket silently drops frames with no error) would type a prompt
|
||||
* twice whenever an ACK is lost after the write landed.
|
||||
*/
|
||||
shouldApplyInput(clientId: string, seq: number): boolean {
|
||||
const last = this._appliedInputSeq.get(clientId);
|
||||
if (last !== undefined && seq <= last) return false;
|
||||
// Re-insert to move this client to the MRU end for fair eviction.
|
||||
if (last !== undefined) this._appliedInputSeq.delete(clientId);
|
||||
this._appliedInputSeq.set(clientId, seq);
|
||||
if (this._appliedInputSeq.size > Session.MAX_INPUT_DEDUP_CLIENTS) {
|
||||
const oldest = this._appliedInputSeq.keys().next().value;
|
||||
if (oldest !== undefined) this._appliedInputSeq.delete(oldest);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends input via the terminal multiplexer's direct input mechanism.
|
||||
*
|
||||
@@ -2230,6 +2478,7 @@ export class Session extends EventEmitter {
|
||||
* ```
|
||||
*/
|
||||
async writeViaMux(data: string): Promise<boolean> {
|
||||
this._trackCodexSubmit(data);
|
||||
if (this._mux && this._muxSession) {
|
||||
return this._mux.sendInput(this.id, data);
|
||||
}
|
||||
|
||||
@@ -272,6 +272,12 @@ export class StateStore {
|
||||
if (this.state.tokenStats) {
|
||||
parts.push(`"tokenStats":${JSON.stringify(this.state.tokenStats)}`);
|
||||
}
|
||||
if (this.state.cronJobs) {
|
||||
parts.push(`"cronJobs":${JSON.stringify(this.state.cronJobs)}`);
|
||||
}
|
||||
if (this.state.cronJobRuns) {
|
||||
parts.push(`"cronJobRuns":${JSON.stringify(this.state.cronJobRuns)}`);
|
||||
}
|
||||
|
||||
return `{${parts.join(',')}}`;
|
||||
}
|
||||
@@ -568,6 +574,51 @@ export class StateStore {
|
||||
this.save();
|
||||
}
|
||||
|
||||
// ========== Cron Job Methods ==========
|
||||
|
||||
/** Returns all scheduled jobs keyed by job ID. */
|
||||
getCronJobs(): Record<string, import('./types/cron.js').CronJob> {
|
||||
if (!this.state.cronJobs) this.state.cronJobs = {};
|
||||
return this.state.cronJobs;
|
||||
}
|
||||
|
||||
/** Returns a scheduled job by ID, or null if not found. */
|
||||
getCronJob(id: string): import('./types/cron.js').CronJob | null {
|
||||
return this.state.cronJobs?.[id] ?? null;
|
||||
}
|
||||
|
||||
/** Sets a scheduled job and triggers a debounced save. */
|
||||
setCronJob(id: string, job: import('./types/cron.js').CronJob): void {
|
||||
if (!this.state.cronJobs) this.state.cronJobs = {};
|
||||
this.state.cronJobs[id] = job;
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Removes a scheduled job and triggers a debounced save. */
|
||||
removeCronJob(id: string): void {
|
||||
if (this.state.cronJobs) delete this.state.cronJobs[id];
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Returns all scheduled job runs keyed by run ID. */
|
||||
getCronJobRuns(): Record<string, import('./types/cron.js').CronJobRun> {
|
||||
if (!this.state.cronJobRuns) this.state.cronJobRuns = {};
|
||||
return this.state.cronJobRuns;
|
||||
}
|
||||
|
||||
/** Sets a scheduled job run (history record) and triggers a debounced save. */
|
||||
setCronJobRun(id: string, run: import('./types/cron.js').CronJobRun): void {
|
||||
if (!this.state.cronJobRuns) this.state.cronJobRuns = {};
|
||||
this.state.cronJobRuns[id] = run;
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Removes a scheduled job run and triggers a debounced save. */
|
||||
removeCronJobRun(id: string): void {
|
||||
if (this.state.cronJobRuns) delete this.state.cronJobRuns[id];
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Returns the application configuration. */
|
||||
getConfig() {
|
||||
return this.state.config;
|
||||
|
||||
+682
-28
@@ -29,7 +29,8 @@ const execAsync = promisify(exec);
|
||||
import { existsSync, readFileSync, mkdirSync } from 'node:fs';
|
||||
import { writeFile, rename } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { dataPath, DEFAULT_TMUX_SOCKET } from './config/instance.js';
|
||||
import { homedir } from 'node:os';
|
||||
import { dataPath, DEFAULT_TMUX_SOCKET, CODEMAN_INSTANCE } from './config/instance.js';
|
||||
import {
|
||||
ProcessStats,
|
||||
PersistedRespawnConfig,
|
||||
@@ -41,15 +42,41 @@ import {
|
||||
type OpenCodeConfig,
|
||||
type CodexConfig,
|
||||
type EffortLevel,
|
||||
type GeminiConfig,
|
||||
type SessionRemote,
|
||||
type SessionDocker,
|
||||
type DockerCommandMode,
|
||||
} from './types.js';
|
||||
import { buildEffortCliArgs } from './session-cli-builder.js';
|
||||
import { wrapWithNice, SAFE_PATH_PATTERN, findClaudeDir, resolveOpenCodeDir, resolveCodexDir } from './utils/index.js';
|
||||
import { buildSshConnectionArgs, defaultRemoteCommandForMode, remoteSshTarget } from './remote-hosts.js';
|
||||
import {
|
||||
buildDockerBaseArgs,
|
||||
buildDockerCreateArgs,
|
||||
containerApiUrl,
|
||||
CONTAINER_HOME,
|
||||
defaultDockerCommandForMode,
|
||||
hostGatewayAlias,
|
||||
resolveDockerClaudeArtifacts,
|
||||
resolveDockerCredentialArtifacts,
|
||||
type DockerCreateContext,
|
||||
type DockerMount,
|
||||
type DockerSeedCopy,
|
||||
} from './docker-hosts.js';
|
||||
import {
|
||||
wrapWithNice,
|
||||
SAFE_PATH_PATTERN,
|
||||
findClaudeDir,
|
||||
resolveOpenCodeDir,
|
||||
resolveCodexDir,
|
||||
resolveGeminiDir,
|
||||
} from './utils/index.js';
|
||||
import type {
|
||||
TerminalMultiplexer,
|
||||
MuxSession,
|
||||
MuxSessionWithStats,
|
||||
CreateSessionOptions,
|
||||
RespawnPaneOptions,
|
||||
PaneCaptureOptions,
|
||||
} from './mux-interface.js';
|
||||
|
||||
// ============================================================================
|
||||
@@ -57,6 +84,15 @@ import type {
|
||||
// ============================================================================
|
||||
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import { DEFAULT_TMUX_HISTORY_LIMIT, DEFAULT_TERMINAL_BUFFER_MAX_BYTES } from './config/terminal-history.js';
|
||||
|
||||
/**
|
||||
* Extra stdout headroom for the full-history `capture-pane` child process on
|
||||
* top of the consumer's byte cap: raw scrollback carries per-line SGR/ANSI
|
||||
* overhead that the route pipeline strips before applying its cap, so the
|
||||
* capture must be allowed to exceed the final payload size.
|
||||
*/
|
||||
const FULL_HISTORY_CAPTURE_SLACK_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/** Delay after tmux session creation — enough for detached tmux to be queryable */
|
||||
const TMUX_CREATION_WAIT_MS = 100;
|
||||
@@ -421,6 +457,26 @@ function truncatePaneLineByVisibleColumns(line: string, maxColumns: number): str
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize scrollback line endings to `\r\n` so a fresh xterm replays each line
|
||||
* at column 0 (COD-138).
|
||||
*
|
||||
* `capture-pane -p -e -S -` (full-history capture) joins scrollback rows with a
|
||||
* BARE `\n`. The browser xterm is created with the default `convertEol: false`
|
||||
* (correct for the live PTY stream, which already carries real `\r\n`), so a bare
|
||||
* `\n` drops a row without returning the cursor to column 0. Replaying that raw
|
||||
* buffer on a full page reload makes every line start one column further right —
|
||||
* the diagonal "staircase". The visible/tab-switch path avoids this by repainting
|
||||
* each row with an absolute cursor CSI (`formatPaneSnapshot`); the full-history
|
||||
* path returns raw scrollback, so it must be CRLF-normalized here.
|
||||
*
|
||||
* `\r?\n → \r\n` is idempotent on already-CRLF input and leaves a lone `\r` (an
|
||||
* intentional in-line column reset / overwrite) untouched.
|
||||
*/
|
||||
export function normalizeScrollbackEol(buffer: string): string {
|
||||
return buffer.replace(/\r?\n/g, '\r\n');
|
||||
}
|
||||
|
||||
export function formatPaneSnapshot(
|
||||
lines: string[],
|
||||
geometry: { cols: number; rows: number; cursorX: number; cursorY: number }
|
||||
@@ -571,6 +627,35 @@ export function buildCodexCommand(config?: CodexConfig): string {
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Gemini CLI command with appropriate flags.
|
||||
*
|
||||
* `--skip-trust` avoids a first-run workspace trust prompt inside Codeman.
|
||||
* Approval mode defaults to `yolo` for parity with Codeman's Claude default
|
||||
* of `--dangerously-skip-permissions`; users can override it later through
|
||||
* Gemini config once Codeman exposes richer Gemini settings.
|
||||
*/
|
||||
function buildGeminiCommand(config?: GeminiConfig): string {
|
||||
const parts = ['gemini', '--skip-trust'];
|
||||
|
||||
const approvalMode = config?.approvalMode || 'yolo';
|
||||
if (['default', 'auto_edit', 'yolo', 'plan'].includes(approvalMode)) {
|
||||
parts.push('--approval-mode', approvalMode);
|
||||
}
|
||||
|
||||
if (config?.model) {
|
||||
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
|
||||
if (safeModel) parts.push('--model', safeModel);
|
||||
}
|
||||
|
||||
if (config?.resumeSession) {
|
||||
const safeId = /^[a-zA-Z0-9._-]+$/.test(config.resumeSession) ? config.resumeSession : undefined;
|
||||
if (safeId) parts.push('--resume', safeId);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the spawn command for any session mode.
|
||||
* Shared by createSession() and respawnPane() to avoid duplication.
|
||||
@@ -597,6 +682,7 @@ function buildSpawnCommand(options: {
|
||||
allowedTools?: string;
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
codexConfig?: CodexConfig;
|
||||
geminiConfig?: GeminiConfig;
|
||||
resumeSessionId?: string;
|
||||
effort?: EffortLevel;
|
||||
}): string {
|
||||
@@ -624,9 +710,385 @@ function buildSpawnCommand(options: {
|
||||
if (options.mode === 'codex') {
|
||||
return buildCodexCommand(options.codexConfig);
|
||||
}
|
||||
if (options.mode === 'gemini') {
|
||||
return buildGeminiCommand(options.geminiConfig);
|
||||
}
|
||||
return '$SHELL';
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated socket for Codeman-launched REMOTE tmux servers, distinct from the
|
||||
* canonical local `-L codeman` socket. A remote host that runs its OWN Codeman
|
||||
* would otherwise share the `-L codeman` socket AND the `codeman-<hex>` discovery
|
||||
* name, so its `reconcileSessions()` would ADOPT our session (attach a PTY,
|
||||
* resize, respawn-pane it locally) — the cross-machine form of the "2nd instance
|
||||
* attaches live sessions" hazard. A private socket keeps our remote sessions off
|
||||
* that instance's radar entirely.
|
||||
*/
|
||||
const REMOTE_TMUX_SOCKET = 'codeman-remote';
|
||||
|
||||
/**
|
||||
* Deterministic, reattach-stable remote tmux session name for a Codeman session.
|
||||
*
|
||||
* Derived from the same stable field the LOCAL muxName uses (the first 8 chars of
|
||||
* the sessionId), so reconnecting (which re-issues the exact same
|
||||
* `ssh … new-session -A`) lands back in the SAME remote session. Must NOT be
|
||||
* random/time-based — it has to be stable across reconnects.
|
||||
*
|
||||
* The `codeman-ssh-` prefix is deliberately chosen to FAIL a remote Codeman's
|
||||
* `SAFE_MUX_NAME_PATTERN` (`^codeman-[a-f0-9-]+$`) — the `s`/`h` letters mean a
|
||||
* remote instance's discovery never treats this as one of its own sessions (belt
|
||||
* to the dedicated-socket suspenders above).
|
||||
*/
|
||||
export function remoteTmuxSessionName(sessionId: string): string {
|
||||
return `codeman-ssh-${sessionId.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-104 — build the SSH command that launches (or reattaches) a remote
|
||||
* session INSIDE a tmux server on the remote host, so the remote agent survives
|
||||
* an SSH drop.
|
||||
*
|
||||
* Emits:
|
||||
* ssh -o BatchMode=yes -t [<COD-107 connection opts>] user@host \
|
||||
* 'tmux -L codeman-remote new-session -A -s codeman-ssh-<id> -c <path> "cd <path> && exec <cli>" \
|
||||
* \; set -t codeman-ssh-<id> status off \; set -t codeman-ssh-<id> mouse off \
|
||||
* \; set -t codeman-ssh-<id> prefix C-q \; set -s escape-time 0'
|
||||
*
|
||||
* COD-107 — the connection options (`-p`, `-i`, `-J`, SOCKS `-o ProxyCommand`,
|
||||
* arbitrary `-o`) come from the shared `buildSshConnectionArgs(remote)`, so the
|
||||
* prereq tmux probe and this launch connect with identical options.
|
||||
*
|
||||
* - `new-session -A -s codeman-ssh-<id>` = attach-if-exists-else-create
|
||||
* (idempotent), so reconnect re-runs the same command and reattaches the
|
||||
* still-running agent.
|
||||
* - `-L codeman-remote` = a DEDICATED socket, NOT the canonical `-L codeman` a
|
||||
* remote Codeman would use, so our session never collides with / gets adopted by
|
||||
* an instance running on the remote host.
|
||||
* - The `set` options are scoped per-session (`set -t <name>` / server-level
|
||||
* `set -s`), never `-g`, so they never mutate other sessions' prefix/mouse.
|
||||
* - The whole tmux invocation is a SINGLE ssh argument (the remote login shell
|
||||
* runs it), so it is shell-quoted as one unit; the `cd && exec` command is in
|
||||
* turn a single tmux argument (tmux runs it via `/bin/sh -c`), so the path is
|
||||
* shell-quoted inside it too. This keeps escaping correct through every layer
|
||||
* even when the remote path contains spaces.
|
||||
*/
|
||||
export function buildRemoteLaunchCommand(options: {
|
||||
mode: SessionMode;
|
||||
remote: SessionRemote;
|
||||
sessionId: string;
|
||||
}): string {
|
||||
const { mode, remote, sessionId } = options;
|
||||
const modeCommand = remote.commands?.[mode] || defaultRemoteCommandForMode(mode);
|
||||
const remoteName = remoteTmuxSessionName(sessionId);
|
||||
|
||||
// Innermost: the command tmux runs in the new pane. Run via `/bin/sh -c` by
|
||||
// tmux, so the path needs shell-quoting here. `exec` replaces the shell with
|
||||
// the CLI so the pane PID is the agent itself.
|
||||
const paneCommand = `cd ${shellescape(remote.remotePath)} && ${modeCommand}`;
|
||||
|
||||
// The tmux command line, with `\;` separating commands so the config `set`s
|
||||
// apply on the SAME connection (and are idempotent on reattach). Options are
|
||||
// scoped per-session (`set -t <name>` / server `set -s`), NEVER `-g`, so a
|
||||
// shared remote tmux server's other sessions keep their own prefix/mouse.
|
||||
const tmuxInvocation = [
|
||||
`tmux -L ${REMOTE_TMUX_SOCKET} new-session -A -s ${remoteName} -c ${shellescape(remote.remotePath)} ${shellescape(paneCommand)}`,
|
||||
`set -t ${remoteName} status off`,
|
||||
`set -t ${remoteName} mouse off`,
|
||||
`set -t ${remoteName} prefix C-q`,
|
||||
'set -s escape-time 0',
|
||||
].join(' \\; ');
|
||||
|
||||
// ssh runs its trailing args through the remote login shell, so the entire
|
||||
// tmux invocation is passed as one shell-quoted argument.
|
||||
//
|
||||
// COD-107 — connection options (port, identity, SOCKS ProxyCommand, jump host,
|
||||
// arbitrary -o) come from the shared `buildSshConnectionArgs` so the launch and
|
||||
// the tmux-prereq probe connect IDENTICALLY. `-t` is inserted right after
|
||||
// `ssh -o BatchMode=yes` (preserving the historical token order), then the rest
|
||||
// of the connection args, then the target and the quoted tmux invocation.
|
||||
const [ssh, batchMode, ...connectionArgs] = buildSshConnectionArgs(remote);
|
||||
const sshParts = [ssh, batchMode, '-t', ...connectionArgs, remoteSshTarget(remote), shellescape(tmuxInvocation)];
|
||||
return sshParts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the SSH command that kills the durable remote tmux session created by
|
||||
* `buildRemoteLaunchCommand`. Because that session lives on a private socket
|
||||
* (`-L codeman-remote`) under a stable name, killing the LOCAL ssh wrapper alone
|
||||
* would orphan the remote agent forever (invisible to Codeman, still burning plan
|
||||
* quota). This is fired best-effort on session kill; the shared connection args
|
||||
* carry the default `-o ConnectTimeout=10` so an unreachable host fails fast.
|
||||
*/
|
||||
export function buildRemoteKillCommand(options: { remote: SessionRemote; sessionId: string }): string {
|
||||
const { remote, sessionId } = options;
|
||||
const remoteName = remoteTmuxSessionName(sessionId);
|
||||
const killCmd = `tmux -L ${REMOTE_TMUX_SOCKET} kill-session -t ${shellescape(remoteName)}`;
|
||||
const [ssh, ...connectionArgs] = buildSshConnectionArgs(remote);
|
||||
return [ssh, ...connectionArgs, remoteSshTarget(remote), shellescape(killCmd)].join(' ');
|
||||
}
|
||||
|
||||
// ========== Docker cases (COD-Docker) ==========
|
||||
//
|
||||
// The docker analog of the remote-SSH launch above. Instead of a local tmux pane
|
||||
// running `ssh -t host 'tmux new-session …'`, it runs `docker exec -it <container>
|
||||
// sh -lc 'tmux new-session …'` into a DURABLE in-container tmux server. The
|
||||
// container is per-CASE, so many sessions `docker exec` into the same one. See
|
||||
// docs/docker-cases-plan.md.
|
||||
|
||||
/**
|
||||
* DEDICATED in-container tmux socket. A Codeman running INSIDE the container uses
|
||||
* `-L codeman`; ours is `-L codeman-docker` with a `codeman-dkr-*` session name
|
||||
* that deliberately FAILS SAFE_MUX_NAME_PATTERN, so an in-container Codeman never
|
||||
* adopts/resizes/respawns our session (same defence as the remote socket).
|
||||
*/
|
||||
const DOCKER_TMUX_SOCKET = 'codeman-docker';
|
||||
|
||||
/**
|
||||
* Deterministic, reattach-stable in-container tmux session name. Derived from the
|
||||
* same stable field the local muxName uses (first 8 chars of the sessionId), so a
|
||||
* reconnect re-issues the exact same `new-session -A` and lands back in the SAME
|
||||
* in-container session. The `dkr` letters make it fail SAFE_MUX_NAME_PATTERN.
|
||||
*/
|
||||
export function dockerTmuxSessionName(sessionId: string): string {
|
||||
return `codeman-dkr-${sessionId.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
/** Resume ids are UUID-ish; reject anything with shell metacharacters (defensive). */
|
||||
const RESUME_ID_SAFE = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
/**
|
||||
* Append the CLI-specific resume flag to a pane command. Only fires when the
|
||||
* in-container tmux is RE-CREATED (`new-session -A` makes the flag inert on a
|
||||
* live reattach), i.e. exactly when the previous live agent was lost and we want
|
||||
* to resume the conversation from the bind-mounted transcript.
|
||||
*/
|
||||
function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: string): string {
|
||||
if (!RESUME_ID_SAFE.test(resumeId)) return modeCommand;
|
||||
switch (mode) {
|
||||
case 'claude':
|
||||
case 'gemini':
|
||||
return `${modeCommand} --resume ${resumeId}`;
|
||||
case 'codex':
|
||||
return `${modeCommand} resume ${resumeId}`;
|
||||
default:
|
||||
return modeCommand; // shell / opencode: no resume
|
||||
}
|
||||
}
|
||||
|
||||
/** Fully-resolved inputs for buildDockerLaunchCommand (pure). */
|
||||
export interface DockerLaunchOptions {
|
||||
mode: SessionMode;
|
||||
docker: SessionDocker;
|
||||
sessionId: string;
|
||||
resumeSessionId?: string;
|
||||
createContext: DockerCreateContext;
|
||||
/** exec-time inline env (non-secret): TERM, COLORTERM, CODEMAN_SESSION_ID, CODEMAN_MUX */
|
||||
execEnv: Record<string, string>;
|
||||
/** exec-time NAME-ONLY env forwarded from Codeman's process env (codex/gemini keys) */
|
||||
execEnvNames: string[];
|
||||
/**
|
||||
* Files to copy from read-only seed mounts into the container's writable HOME once
|
||||
* before launch (guarded so reconnects never clobber). Isolates Claude state: the
|
||||
* merged `~/.claude.json`, plus `~/.claude/.credentials.json` + `settings.json`,
|
||||
* are writable copies (not host mounts), so the container never re-auths and never
|
||||
* writes its runtime state back into the host `~/.claude`.
|
||||
*/
|
||||
seedCopies?: DockerSeedCopy[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ONE `bash -c` launch string for a docker session: image-check ->
|
||||
* ensure (inspect-or-create) -> start -> `exec docker exec -it` into the durable
|
||||
* in-container tmux (resume-aware). PURE and unit-testable. The escaping survives
|
||||
* four layers: outer `bash -c "…"` (JSON.stringify at respawn-pane) -> the joined
|
||||
* command -> `docker exec … sh -lc '<tmux>'` -> tmux `'<paneCommand>'`.
|
||||
*/
|
||||
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(' ');
|
||||
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);
|
||||
if (resumeSessionId) modeCommand = appendResumeFlag(modeCommand, mode, resumeSessionId);
|
||||
// Run by tmux via /bin/sh -c, so the path is shell-quoted here. `exec` makes the
|
||||
// pane PID the agent itself.
|
||||
const paneCommand = `cd ${workdir} && ${modeCommand}`;
|
||||
|
||||
// `setenv -g` primes the session id so reattaches / newly-created panes inherit
|
||||
// it. `new-session -A` = attach-or-create (idempotent + resume-aware). Options
|
||||
// are scoped per-session (`set -t`) or server (`set -s`), never `-g`, so a shared
|
||||
// in-container tmux server's other sessions keep their own prefix/mouse.
|
||||
const tmuxInvocation = [
|
||||
`tmux -L ${DOCKER_TMUX_SOCKET} setenv -g CODEMAN_SESSION_ID ${shellescape(sid)}`,
|
||||
'setenv -g CODEMAN_MUX 1',
|
||||
`new-session -A -s ${dkrName} -c ${workdir} ${shellescape(paneCommand)}`,
|
||||
`set -t ${dkrName} status off`,
|
||||
`set -t ${dkrName} mouse off`,
|
||||
`set -t ${dkrName} prefix C-q`,
|
||||
'set -s escape-time 0',
|
||||
].join(' \\; ');
|
||||
|
||||
const execEnvFlags: string[] = [];
|
||||
for (const [k, v] of Object.entries(execEnv)) execEnvFlags.push('--env', shellescape(`${k}=${v}`));
|
||||
// NAME-ONLY forwards: docker reads the VALUE from Codeman's own process env, so
|
||||
// the secret never appears in argv (no `ps` leak) and is not committed.
|
||||
for (const n of execEnvNames) execEnvFlags.push('--env', n);
|
||||
for (const extra of docker.extraExecArgs ?? []) execEnvFlags.push(shellescape(extra));
|
||||
|
||||
const imageMissingMsg = shellescape(
|
||||
`Codeman: base image ${docker.image} not present (it is normally auto-built on first use)`
|
||||
);
|
||||
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; }`;
|
||||
// create-if-missing (idempotent): reconnect / boot recovery re-runs this exact chain.
|
||||
const ensure = `${base} inspect ${name} >/dev/null 2>&1 || ${base} ${createArgs}`;
|
||||
const start = `${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) => {
|
||||
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`;
|
||||
});
|
||||
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(' ; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill ONLY this session's in-container tmux session. The container is shared by
|
||||
* the case's other sessions, so this NEVER `docker stop`s it — stopping/removing
|
||||
* the container is an explicit teardown (buildDockerStopCommand) or case-delete
|
||||
* (buildDockerRemoveCommand). Fired best-effort on session kill.
|
||||
*/
|
||||
export function buildDockerKillCommand(options: { docker: SessionDocker; sessionId: string }): string {
|
||||
const { docker, sessionId } = options;
|
||||
const base = buildDockerBaseArgs(docker).join(' ');
|
||||
const dkrName = dockerTmuxSessionName(sessionId);
|
||||
return `${base} exec ${shellescape(docker.containerName)} tmux -L ${DOCKER_TMUX_SOCKET} kill-session -t ${shellescape(dkrName)}`;
|
||||
}
|
||||
|
||||
/** Explicit container stop (frees RAM/CPU; conversation resumes on next launch via --resume). */
|
||||
export function buildDockerStopCommand(docker: SessionDocker): string {
|
||||
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 {
|
||||
return `${buildDockerBaseArgs(docker).join(' ')} rm -f ${shellescape(docker.containerName)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the environment-dependent bits of a docker launch (host uid, existing
|
||||
* credential mounts, derived api url, hook-secret mount, Desktop detection) into
|
||||
* the pure buildDockerLaunchCommand inputs. IO; only ever called from the real
|
||||
* launch path (createSession/respawnPane no-op under VITEST).
|
||||
*/
|
||||
export function resolveDockerLaunchOptions(
|
||||
mode: SessionMode,
|
||||
docker: SessionDocker,
|
||||
sessionId: string,
|
||||
resumeSessionId?: string
|
||||
): DockerLaunchOptions {
|
||||
const home = homedir();
|
||||
const isDesktop = process.platform === 'darwin'; // Docker Desktop translates uids + native host.docker.internal
|
||||
const uid = typeof process.getuid === 'function' ? process.getuid() : 1000;
|
||||
const userArgs: string[] =
|
||||
docker.engine === 'podman'
|
||||
? ['--userns=keep-id'] // rootless podman: map host uid to the image `agent` uid
|
||||
: isDesktop
|
||||
? [] // Desktop: run as the image's baked uid (a mac uid wouldn't own /home/agent)
|
||||
: ['--user', `${uid}:0`]; // Linux: host uid + GID 0 (OpenShift arbitrary-uid writable HOME)
|
||||
const gatewayAlias = hostGatewayAlias(docker.engine);
|
||||
|
||||
const credentialMounts: DockerMount[] = [];
|
||||
const extraMounts: DockerMount[] = [];
|
||||
// Isolated credential state (Claude + codex/gemini/gcloud/opencode): each store
|
||||
// shares ONLY what a host feature / --resume needs (Claude projects/, codex
|
||||
// sessions/+history) and seeds everything else (tokens, settings, configs) as
|
||||
// writable copies, so the container is authed WITHOUT re-auth and WITHOUT writing
|
||||
// its runtime state back into the host dirs. Only when credentials are mounted.
|
||||
let seedCopies: DockerSeedCopy[] = [];
|
||||
if (docker.mountCredentials) {
|
||||
const claudeArtifacts = resolveDockerClaudeArtifacts(home, docker.containerName, docker.containerWorkdir);
|
||||
const credArtifacts = resolveDockerCredentialArtifacts(home);
|
||||
extraMounts.push(...claudeArtifacts.mounts, ...credArtifacts.mounts);
|
||||
seedCopies = [...claudeArtifacts.seedCopies, ...credArtifacts.seedCopies];
|
||||
}
|
||||
const envCreate: Record<string, string> = {
|
||||
HOME: CONTAINER_HOME,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
// Force a UTF-8 locale (the base image defaults to POSIX/C). Without this, tmux
|
||||
// runs in non-UTF-8 mode and renders Claude's Unicode box-drawing (─│┌┐) as raw
|
||||
// VT100 ACS glyphs (`qqqq…`). `C.UTF-8` is built into glibc (no locale-gen).
|
||||
LANG: 'C.UTF-8',
|
||||
LC_ALL: 'C.UTF-8',
|
||||
// Give claude a temp dir it will own inside HOME. Its default `/tmp/claude-<uid>`
|
||||
// is refused when that path pre-exists root-owned — which happens when the
|
||||
// workspace bind-mount path traverses it (e.g. a workspace under /tmp/claude-<uid>).
|
||||
// A nonexistent HOME subpath is created+owned by the running uid, so this is robust
|
||||
// to any workspace location. Non-secret path, safe to be committed on export.
|
||||
CLAUDE_CODE_TMPDIR: `${CONTAINER_HOME}/.cache/codeman-claude-tmp`,
|
||||
};
|
||||
if (docker.hooksEnabled) {
|
||||
// Derive a container-reachable API url (scheme + port preserved; host swapped
|
||||
// for the engine gateway alias). Prod is HTTPS on 3000.
|
||||
envCreate.CODEMAN_API_URL = containerApiUrl(process.env.CODEMAN_API_URL, docker.engine);
|
||||
const hookSecretPath = dataPath('hook-secret');
|
||||
if (existsSync(hookSecretPath)) {
|
||||
const dst = `${CONTAINER_HOME}/.codeman/hook-secret`;
|
||||
extraMounts.push({ src: hookSecretPath, dst, readonly: true });
|
||||
envCreate.CODEMAN_HOOK_SECRET_FILE = dst; // a path is non-secret; the bytes ride the bind mount
|
||||
}
|
||||
}
|
||||
|
||||
const createContext: DockerCreateContext = {
|
||||
docker,
|
||||
sessionId,
|
||||
instance: CODEMAN_INSTANCE,
|
||||
userArgs,
|
||||
credentialMounts,
|
||||
extraMounts,
|
||||
envCreate,
|
||||
addHostGateway: !isDesktop,
|
||||
gatewayAlias,
|
||||
};
|
||||
|
||||
const execEnv: Record<string, string> = {
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
// UTF-8 at exec time too, so the tmux CLIENT this exec launches is UTF-8 and
|
||||
// renders box-drawing correctly even when reattaching to a container created
|
||||
// before this fix (client_utf8 is per-client, resolved from the exec's locale).
|
||||
LANG: 'C.UTF-8',
|
||||
LC_ALL: 'C.UTF-8',
|
||||
CODEMAN_SESSION_ID: sessionId.slice(0, 8),
|
||||
CODEMAN_MUX: '1',
|
||||
};
|
||||
// NAME-ONLY exec env forwarded from Codeman's process env (the docker client
|
||||
// inherits it), so API-key CLIs get their key without it appearing in argv.
|
||||
const execEnvNames =
|
||||
mode === 'codex'
|
||||
? ['OPENAI_API_KEY', 'CODEX_API_KEY']
|
||||
: mode === 'gemini'
|
||||
? ['GEMINI_API_KEY', 'GOOGLE_API_KEY']
|
||||
: [];
|
||||
|
||||
return { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set sensitive environment variables on a tmux session via setenv.
|
||||
* These are inherited by panes but not visible in ps output or tmux history.
|
||||
@@ -674,6 +1136,38 @@ function setCodexEnvVars(tmuxCmd: string, muxName: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set sensitive environment variables for Gemini on a tmux session via setenv.
|
||||
* Gemini Pro/Ultra users usually authenticate via cached Google login; these
|
||||
* variables cover API-key and Vertex AI paths without putting secrets in ps.
|
||||
*/
|
||||
function setGeminiEnvVars(tmuxCmd: string, muxName: string): void {
|
||||
const sensitiveVars = [
|
||||
'GEMINI_API_KEY',
|
||||
'GEMINI_MODEL',
|
||||
'GOOGLE_API_KEY',
|
||||
'GOOGLE_CLOUD_PROJECT',
|
||||
'GOOGLE_CLOUD_LOCATION',
|
||||
'GOOGLE_APPLICATION_CREDENTIALS',
|
||||
'GOOGLE_GENAI_USE_VERTEXAI',
|
||||
];
|
||||
for (const key of sensitiveVars) {
|
||||
const val = process.env[key];
|
||||
if (val) {
|
||||
const escaped = val.replace(/'/g, "'\\''");
|
||||
try {
|
||||
execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
|
||||
encoding: 'utf8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch {
|
||||
/* Non-critical — key may not be needed */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv.
|
||||
* Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON.
|
||||
@@ -856,8 +1350,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const exports = [
|
||||
'export LANG=en_US.UTF-8',
|
||||
'export LC_ALL=en_US.UTF-8',
|
||||
mode === 'codex' ? 'export COLORTERM=truecolor' : 'unset COLORTERM',
|
||||
...(mode === 'codex' ? ['unset NO_COLOR'] : []),
|
||||
mode === 'codex' || mode === 'gemini' ? 'export COLORTERM=truecolor' : 'unset COLORTERM',
|
||||
...(mode === 'codex' || mode === 'gemini' ? ['unset NO_COLOR'] : []),
|
||||
// Stamp each Codex pane with a unique originator so the response-viewer
|
||||
// can locate THIS pane's rollout exactly — codex writes the value into
|
||||
// session_meta.originator of every rollout it creates. Without it,
|
||||
// rollouts are matched by cwd+mtime and two panes in the same directory
|
||||
// bleed into each other.
|
||||
...(mode === 'codex' ? [`export CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codeman_${sessionId}`] : []),
|
||||
'export CODEMAN_MUX=1',
|
||||
`export CODEMAN_SESSION_ID=${sessionId}`,
|
||||
`export CODEMAN_MUX_NAME=${muxName}`,
|
||||
@@ -930,6 +1430,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const dir = resolveCodexDir();
|
||||
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||||
}
|
||||
if (mode === 'gemini') {
|
||||
const dir = resolveGeminiDir();
|
||||
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||||
}
|
||||
return { pathExport: '', dir: null };
|
||||
}
|
||||
|
||||
@@ -953,6 +1457,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
setCodexEnvVars(this.tmux(), muxName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Gemini-specific environment on a tmux session.
|
||||
*/
|
||||
private _configureGemini(muxName: string): void {
|
||||
setGeminiEnvVars(this.tmux(), muxName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new tmux session wrapping Claude CLI or a shell.
|
||||
* In test mode: creates an in-memory session only (no real tmux session).
|
||||
@@ -969,9 +1480,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
resumeSessionId,
|
||||
envOverrides,
|
||||
effort,
|
||||
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||||
remote,
|
||||
docker,
|
||||
} = options;
|
||||
const muxName = `codeman-${sessionId.slice(0, 8)}`;
|
||||
|
||||
@@ -990,6 +1505,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
pid: 99999,
|
||||
createdAt: Date.now(),
|
||||
workingDir,
|
||||
remote,
|
||||
docker,
|
||||
mode,
|
||||
attached: false,
|
||||
name,
|
||||
@@ -1007,6 +1524,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
if (mode === 'opencode' && !cliDir) {
|
||||
throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
|
||||
}
|
||||
if (mode === 'codex' && !cliDir) {
|
||||
throw new Error('Codex CLI not found. Install with: npm install -g @openai/codex');
|
||||
}
|
||||
if (mode === 'gemini' && !cliDir) {
|
||||
throw new Error('Gemini CLI not found. Install with: npm install -g @google/gemini-cli');
|
||||
}
|
||||
|
||||
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
||||
|
||||
@@ -1018,6 +1541,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
resumeSessionId,
|
||||
effort,
|
||||
});
|
||||
@@ -1027,7 +1551,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
try {
|
||||
// Build the full command to run inside tmux
|
||||
const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
|
||||
const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
|
||||
const fullCmd = docker
|
||||
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
|
||||
: remote
|
||||
? buildRemoteLaunchCommand({ mode, remote, sessionId })
|
||||
: localFullCmd;
|
||||
|
||||
// Create tmux session in three steps to handle cold-start (no server running)
|
||||
// and avoid the race where the command exits before remain-on-exit is set:
|
||||
@@ -1067,6 +1596,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
} else if (mode === 'codex') {
|
||||
this._configureCodex(muxName);
|
||||
}
|
||||
// For Gemini: set Gemini/Google auth env vars via tmux setenv
|
||||
if (mode === 'gemini') {
|
||||
this._configureGemini(muxName);
|
||||
}
|
||||
|
||||
// Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv
|
||||
// so secret values stay off the bash command line. Must run before respawn-pane.
|
||||
@@ -1074,7 +1607,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
// Replace the shell with the actual command (no echo in terminal). Keep
|
||||
// pane launch in /tmp, then cd inside bash against the current mount table.
|
||||
const launchCmd = `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||
const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||
execSync(
|
||||
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
|
||||
{
|
||||
@@ -1104,8 +1637,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
/* Already set globally as fallback */
|
||||
}),
|
||||
// Raise tmux scrollback from its 2000-line default so re-attach preserves
|
||||
// more context. Matches the xterm-side default in constants.js.
|
||||
execAsync(`${this.tmux()} set-option -t "${muxName}" history-limit 50000`, { timeout: EXEC_TIMEOUT_MS })
|
||||
// more context. Intentionally exceeds the xterm-side DEFAULT_SCROLLBACK (50k
|
||||
// in constants.js), which stays lower to protect browser/mobile memory.
|
||||
execAsync(`${this.tmux()} set-option -t "${muxName}" history-limit ${historyLimit}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
})
|
||||
.then(() => {})
|
||||
.catch(() => {
|
||||
/* Non-critical — falls back to tmux default */
|
||||
@@ -1145,6 +1681,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
pid,
|
||||
createdAt: Date.now(),
|
||||
workingDir,
|
||||
remote,
|
||||
docker,
|
||||
mode,
|
||||
attached: false,
|
||||
name,
|
||||
@@ -1224,9 +1762,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
resumeSessionId,
|
||||
envOverrides,
|
||||
effort,
|
||||
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||||
remote,
|
||||
docker,
|
||||
} = options;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
@@ -1234,6 +1776,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null;
|
||||
|
||||
// Re-apply the configured tmux history-limit after respawn (kept in sync
|
||||
// with the live setting via setHistoryLimit()).
|
||||
if (!IS_TEST_MODE) {
|
||||
await execAsync(`${this.tmux()} set-option -t ${shellescape(muxName)} history-limit ${historyLimit}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).catch(() => {
|
||||
/* Non-critical — keeps existing tmux history-limit */
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve CLI binary directory based on mode
|
||||
const { pathExport } = this.buildPathExport(mode);
|
||||
|
||||
@@ -1247,12 +1799,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
resumeSessionId,
|
||||
effort,
|
||||
});
|
||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||
const cmd = wrapWithNice(baseCmd, config);
|
||||
const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
|
||||
const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
|
||||
const fullCmd = docker
|
||||
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
|
||||
: remote
|
||||
? buildRemoteLaunchCommand({ mode, remote, sessionId })
|
||||
: localFullCmd;
|
||||
|
||||
try {
|
||||
// For OpenCode: set sensitive env vars via tmux setenv before respawn
|
||||
@@ -1261,11 +1819,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
} else if (mode === 'codex') {
|
||||
this._configureCodex(muxName);
|
||||
}
|
||||
// For Gemini: set Gemini/Google auth env vars via tmux setenv before respawn
|
||||
if (mode === 'gemini') {
|
||||
this._configureGemini(muxName);
|
||||
}
|
||||
|
||||
// Re-apply user env overrides before respawn so the new shell inherits them.
|
||||
this.applyEnvOverrides(muxName, envOverrides);
|
||||
|
||||
const launchCmd = `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||
// -c /tmp + cd bounce — see createSession() for rationale (stale FUSE state).
|
||||
const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||
await execAsync(
|
||||
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
|
||||
{
|
||||
@@ -1437,6 +2000,32 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3b: Remote sessions run a DURABLE tmux server on the remote host
|
||||
// (survives ssh drops), so killing only the local ssh wrapper above would
|
||||
// orphan the remote agent forever. Fire a best-effort `ssh … tmux kill-session`
|
||||
// — fire-and-forget so it NEVER blocks or throws the local kill (bounded by the
|
||||
// shared ConnectTimeout on an unreachable host).
|
||||
if (session.remote) {
|
||||
try {
|
||||
const remoteKillCmd = buildRemoteKillCommand({ remote: session.remote, sessionId });
|
||||
exec(remoteKillCmd, { timeout: EXEC_TIMEOUT_MS }, () => {});
|
||||
} catch {
|
||||
// Best-effort — a failure here must not affect the local kill result.
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3c: Docker sessions run a DURABLE in-container tmux session. Kill
|
||||
// ONLY this session's in-container tmux session (best-effort). The container is
|
||||
// PER-CASE and shared by the case's other sessions, so we deliberately do NOT
|
||||
// `docker stop` it here — stopping/removing is an explicit teardown/case-delete.
|
||||
if (session.docker && !IS_TEST_MODE) {
|
||||
try {
|
||||
exec(buildDockerKillCommand({ docker: session.docker, sessionId }), { timeout: EXEC_TIMEOUT_MS }, () => {});
|
||||
} catch {
|
||||
// Best-effort — never affects the local kill result.
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 4: Direct kill by PID as final fallback
|
||||
if (this.isProcessAlive(currentPid)) {
|
||||
try {
|
||||
@@ -1833,6 +2422,26 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a tmux history-limit to all tracked sessions (e.g. when the user
|
||||
* changes the terminal-history setting). Invalid limits fall back to the
|
||||
* default. Best-effort per session.
|
||||
*/
|
||||
async setHistoryLimit(limit: number): Promise<void> {
|
||||
const safeLimit = Number.isSafeInteger(limit) && limit > 0 ? Math.trunc(limit) : DEFAULT_TMUX_HISTORY_LIMIT;
|
||||
|
||||
if (IS_TEST_MODE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updates = Array.from(this.sessions.values()).map((session) =>
|
||||
execAsync(`${this.tmux()} set-option -t ${shellescape(session.muxName)} history-limit ${safeLimit}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
})
|
||||
);
|
||||
await Promise.allSettled(updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send input directly to a tmux session using `send-keys`.
|
||||
*
|
||||
@@ -2054,30 +2663,65 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the current visible text and SGR styles of a specific pane.
|
||||
* Capture a pane's text and SGR styles.
|
||||
*
|
||||
* `capture-pane -e` is sanitized by `formatPaneSnapshot`: SGR color/style
|
||||
* codes are preserved, while cursor/erase/scroll-region controls are stripped
|
||||
* before rows are repainted at absolute positions in browser xterm.
|
||||
* Two modes:
|
||||
* - Visible (default): `capture-pane -p -e` grabs only the on-screen frame,
|
||||
* then `formatPaneSnapshot` repaints each row at its absolute position so
|
||||
* the browser xterm reproduces the live frame. Used for fast tab switches.
|
||||
* - Full history (`opts.fullHistory`): `capture-pane -p -e -J -S -<N>` grabs
|
||||
* the tmux scrollback (COD-47, bounded to the configured history limit),
|
||||
* returned as linear scrollback text with SGR codes preserved (NOT
|
||||
* repositioned — a multi-screen history can't be painted into a single
|
||||
* visible frame, so the snapshot repaint is skipped). `-J` re-joins lines
|
||||
* hard-wrapped at the pane width so they reflow in the browser xterm.
|
||||
* Used for full page reloads so the user gets back their scroll history.
|
||||
* Caveat: lines tmux has already evicted past its history-limit are gone.
|
||||
*/
|
||||
capturePaneBuffer(muxName: string, paneTarget: string): string | null {
|
||||
capturePaneBuffer(muxName: string, paneTarget?: string, opts?: PaneCaptureOptions): string | null {
|
||||
if (IS_TEST_MODE) return '';
|
||||
if (!isValidMuxName(muxName)) {
|
||||
console.error('[TmuxManager] Invalid session name in capturePaneBuffer:', muxName);
|
||||
return null;
|
||||
}
|
||||
if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) {
|
||||
console.error('[TmuxManager] Invalid pane target:', paneTarget);
|
||||
const target = resolveTmuxPaneTarget(muxName, paneTarget);
|
||||
if (!target) {
|
||||
console.error('[TmuxManager] Invalid pane target in capturePaneBuffer:', { muxName, paneTarget });
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
||||
const fullHistory = opts?.fullHistory === true;
|
||||
|
||||
try {
|
||||
const buffer = execSync(`${this.tmux()} capture-pane -p -e -t ${shellescape(target)}`, {
|
||||
// `-S -<N>` starts the capture N lines above the visible frame (tmux
|
||||
// clamps to the top of history), so tmux never serializes more scrollback
|
||||
// than the configured history limit retains.
|
||||
const requestedLines = opts?.historyLimitLines;
|
||||
const historyLines =
|
||||
typeof requestedLines === 'number' && Number.isFinite(requestedLines) && requestedLines > 0
|
||||
? Math.trunc(requestedLines)
|
||||
: DEFAULT_TMUX_HISTORY_LIMIT;
|
||||
const captureFlags = fullHistory ? `capture-pane -p -e -J -S -${historyLines}` : 'capture-pane -p -e';
|
||||
// execSync's default maxBuffer (1MB) kills multi-MB scrollback dumps
|
||||
// (ENOBUFS) and would silently degrade full-history capture to the byte
|
||||
// buffer for exactly the long sessions it exists for — size it from the
|
||||
// consumer's byte cap plus ANSI-overhead slack instead.
|
||||
const execOpts: { encoding: 'utf-8'; timeout: number; maxBuffer?: number } = {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).replace(/\n+$/g, '');
|
||||
};
|
||||
if (fullHistory) {
|
||||
execOpts.maxBuffer =
|
||||
(opts?.maxCaptureBytes ?? DEFAULT_TERMINAL_BUFFER_MAX_BYTES) + FULL_HISTORY_CAPTURE_SLACK_BYTES;
|
||||
}
|
||||
const buffer = execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts).replace(
|
||||
/\n+$/g,
|
||||
''
|
||||
);
|
||||
// Full-history spans many screens — return it as raw linear scrollback
|
||||
// rather than repainting rows at single-screen absolute positions. tmux
|
||||
// joins scrollback rows with a bare `\n`; normalize to `\r\n` so a fresh
|
||||
// xterm (convertEol:false) starts each replayed line at column 0 instead
|
||||
// of staircasing diagonally (COD-138).
|
||||
if (fullHistory) {
|
||||
return normalizeScrollbackEol(buffer);
|
||||
}
|
||||
try {
|
||||
const cursor = execSync(
|
||||
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`,
|
||||
@@ -2102,9 +2746,19 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
} catch (cursorErr) {
|
||||
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
|
||||
}
|
||||
return buffer;
|
||||
// Cursor query failed or geometry was invalid, so we skip the absolute-
|
||||
// positioned snapshot repaint and fall back to the raw capture. Normalize
|
||||
// its bare `\n` line endings to `\r\n` so the replay doesn't staircase
|
||||
// diagonally in a fresh xterm (COD-138, same reason as the fullHistory path).
|
||||
return normalizeScrollbackEol(buffer);
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to capture pane buffer:', err);
|
||||
// ENOBUFS carries the truncated multi-MB stdout on the error object —
|
||||
// log a concise line instead of dumping it into the journal.
|
||||
if ((err as NodeJS.ErrnoException)?.code === 'ENOBUFS') {
|
||||
console.error('[TmuxManager] Pane capture exceeded maxBuffer (ENOBUFS); falling back to byte history');
|
||||
} else {
|
||||
console.error('[TmuxManager] Failed to capture pane buffer:', err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -2115,7 +2769,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* Pane ids are not stable across respawns or restores, so callers should not
|
||||
* assume the first pane remains `%0`.
|
||||
*/
|
||||
captureActivePaneBuffer(muxName: string): string | null {
|
||||
captureActivePaneBuffer(muxName: string, opts?: PaneCaptureOptions): string | null {
|
||||
if (IS_TEST_MODE) return '';
|
||||
if (!isValidMuxName(muxName)) {
|
||||
console.error('[TmuxManager] Invalid session name in captureActivePaneBuffer:', muxName);
|
||||
@@ -2128,7 +2782,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
const target = resolveActivePaneTarget(output);
|
||||
return target ? this.capturePaneBuffer(muxName, target) : null;
|
||||
return target ? this.capturePaneBuffer(muxName, target, opts) : null;
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to resolve active pane for capture:', err);
|
||||
return null;
|
||||
|
||||
@@ -123,6 +123,25 @@ export interface CaseInfo {
|
||||
path: string;
|
||||
/** Whether CLAUDE.md exists */
|
||||
hasClaudeMd?: boolean;
|
||||
/** Case storage/execution location */
|
||||
location?: 'local' | 'linked-local' | 'remote' | 'docker';
|
||||
/** Whether this is a linked local folder */
|
||||
linked?: boolean;
|
||||
/** Remote case metadata for display and session creation */
|
||||
remote?: {
|
||||
hostId: string;
|
||||
host: string;
|
||||
username: string;
|
||||
path: string;
|
||||
};
|
||||
/** Docker case metadata for display and session creation */
|
||||
docker?: {
|
||||
hostId: string;
|
||||
container: string;
|
||||
image?: string;
|
||||
path: string;
|
||||
network?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Error Handling Utilities ==========
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { SessionState } from './session.js';
|
||||
import type { TaskState } from './task.js';
|
||||
import type { RalphLoopState } from './ralph.js';
|
||||
import type { RespawnConfig } from './respawn.js';
|
||||
import type { CronJob, CronJobRun } from './cron.js';
|
||||
|
||||
// ========== Global Stats Types ==========
|
||||
|
||||
@@ -111,6 +112,10 @@ export interface AppState {
|
||||
tokenStats?: TokenStats;
|
||||
/** Orchestrator Loop state (phased plan execution) */
|
||||
orchestrator?: import('./orchestrator.js').OrchestratorPersistState;
|
||||
/** Cron-style scheduled jobs, keyed by job ID. */
|
||||
cronJobs?: Record<string, CronJob>;
|
||||
/** Scheduled job run history, keyed by run ID. */
|
||||
cronJobRuns?: Record<string, CronJobRun>;
|
||||
}
|
||||
|
||||
// ========== Default Configuration ==========
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @fileoverview Cron Jobs type definitions.
|
||||
*
|
||||
* NOTE: This is intentionally distinct from the existing `ScheduledRun` concept
|
||||
* (see src/web/ports/infra-port.ts), which is a run-now, duration-bounded
|
||||
* autonomous loop. A `CronJob` is a SAVED, NAMED job with a recurring
|
||||
* schedule (once/interval/daily/weekly), enable/disable, next-run calculation,
|
||||
* and a history of `CronJobRun` records. The two do not interact.
|
||||
*
|
||||
* Persisted to `~/.codeman/state.json` via StateStore (see AppState).
|
||||
*/
|
||||
|
||||
import type { SessionMode } from './session.js';
|
||||
|
||||
/** How a job's fire times are computed. */
|
||||
export type ScheduleType = 'once' | 'interval' | 'daily' | 'weekly';
|
||||
|
||||
/** Where the prompt text comes from. */
|
||||
export type PromptMode = 'inline_text' | 'prompt_file_path';
|
||||
|
||||
/** How the prompt is delivered into the session. */
|
||||
export type InputMode = 'paste' | 'typed';
|
||||
|
||||
/** Lifecycle status of a single job execution. */
|
||||
export type CronJobRunStatus = 'created' | 'session_started' | 'prompt_sent' | 'failed' | 'skipped';
|
||||
|
||||
/** What triggered a run. */
|
||||
export type TriggerType = 'scheduled' | 'manual_run_now';
|
||||
|
||||
/** What to do for an AUTOMATIC run when sessions of the same agent already exist. */
|
||||
export type ConcurrencyPolicy = 'warn_only' | 'skip_if_same_agent_running';
|
||||
|
||||
/**
|
||||
* A saved, named cron job.
|
||||
*/
|
||||
export interface CronJob {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Reuses Codeman's existing session modes; 'shell' covers Terminal/custom. */
|
||||
agentType: SessionMode;
|
||||
workingDir: string;
|
||||
/** Optional custom launch command (only meaningful for 'shell' mode). */
|
||||
launchCommand?: string;
|
||||
|
||||
promptMode: PromptMode;
|
||||
promptText?: string;
|
||||
promptFilePath?: string;
|
||||
inputMode: InputMode;
|
||||
|
||||
scheduleType: ScheduleType;
|
||||
/** once: absolute epoch-ms fire time. */
|
||||
runAt?: number;
|
||||
/** interval: minutes between fires. */
|
||||
intervalMinutes?: number;
|
||||
/** daily: 'HH:MM' (24h, server-local time). */
|
||||
dailyTime?: string;
|
||||
/** weekly: weekdays 0–6 (0=Sunday). */
|
||||
weeklyDays?: number[];
|
||||
/** weekly: 'HH:MM' (24h, server-local time). */
|
||||
weeklyTime?: string;
|
||||
|
||||
enabled: boolean;
|
||||
notes?: string;
|
||||
/** Applies to automatic (scheduled) runs only. Manual Run Now always warns client-side. */
|
||||
concurrencyPolicy: ConcurrencyPolicy;
|
||||
/**
|
||||
* Close the still-open session created by this job's previous run before the
|
||||
* next run launches (via the normal session-cleanup path), so unattended
|
||||
* recurring jobs don't accumulate tabs until the global session cap.
|
||||
* Default true. Ignored for 'once' schedules.
|
||||
*/
|
||||
autoClosePreviousSession?: boolean;
|
||||
|
||||
// ── Bookkeeping (server-maintained) ─────────────────────────────────────
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastRunAt: number | null;
|
||||
nextRunAt: number | null;
|
||||
lastStatus: CronJobRunStatus | null;
|
||||
/** Duplicate-launch guard: identifies the most recent due-time consumed. */
|
||||
lastDueKey: string | null;
|
||||
/** True once a 'once' job has fired (it is also disabled). */
|
||||
completedOnce?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single execution of a cron job (history record).
|
||||
*/
|
||||
export interface CronJobRun {
|
||||
id: string;
|
||||
cronJobId: string;
|
||||
sessionId: string | null;
|
||||
sessionName: string | null;
|
||||
startedAt: number;
|
||||
finishedAt: number | null;
|
||||
status: CronJobRunStatus;
|
||||
errorMessage?: string;
|
||||
triggerType: TriggerType;
|
||||
/** Best-effort deep link to the created session in the web UI. */
|
||||
createdSessionUrl: string | null;
|
||||
}
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
declare module 'heic-decode' {
|
||||
export interface DecodedHeicImage {
|
||||
width: number;
|
||||
height: number;
|
||||
data: Uint8ClampedArray;
|
||||
}
|
||||
|
||||
/** Handle exposing header-declared dimensions WITHOUT decoding pixels. */
|
||||
export interface HeicImageHandle {
|
||||
width: number;
|
||||
height: number;
|
||||
decode(): Promise<DecodedHeicImage>;
|
||||
}
|
||||
|
||||
export type HeicImageHandles = HeicImageHandle[] & { dispose(): void };
|
||||
|
||||
interface HeicDecode {
|
||||
(input: { buffer: Buffer | Uint8Array }): Promise<DecodedHeicImage>;
|
||||
all(input: { buffer: Buffer | Uint8Array }): Promise<HeicImageHandles>;
|
||||
}
|
||||
|
||||
const decode: HeicDecode;
|
||||
export default decode;
|
||||
}
|
||||
@@ -68,3 +68,4 @@ export * from './plan.js';
|
||||
export * from './orchestrator.js';
|
||||
export * from './update.js';
|
||||
export * from './workflow-run.js';
|
||||
export * from './search.js';
|
||||
|
||||
@@ -90,6 +90,10 @@ export interface RalphTrackerState {
|
||||
cycleCount: number;
|
||||
/** Maximum iterations if detected */
|
||||
maxIterations: number | null;
|
||||
/** Max todos retained for this session before FIFO eviction (persisted; default = global cap) */
|
||||
maxTodos?: number;
|
||||
/** Todo auto-expiry in minutes (persisted; default = global TODO_EXPIRY_MS) */
|
||||
todoExpirationMinutes?: number;
|
||||
/** Timestamp of last activity */
|
||||
lastActivity: number;
|
||||
/** Elapsed hours if detected */
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @fileoverview Cross-session federated search types (COD-9).
|
||||
*
|
||||
* Defines the typed shapes for `GET /api/search` — a bounded, in-memory
|
||||
* federated search across three v1 sources: live sessions/cases, run-summary
|
||||
* timeline events, and per-session attachment file paths. Terminal-buffer scans
|
||||
* and any persisted index are explicitly out of scope for v1.
|
||||
*
|
||||
* Key exports:
|
||||
* - SearchSourceType — the federated source kinds, also the group order key.
|
||||
* - SearchResult — a single typed result card (source, session id/name,
|
||||
* timestamp, snippet, jump-to action target).
|
||||
* - SearchJumpTarget — where the frontend should navigate when a card is opened.
|
||||
* - SearchResponseData — grouped result payload returned in the ApiResponse envelope.
|
||||
*
|
||||
* No I/O, no dependencies on other domain modules. The pure search core lives
|
||||
* in `src/search-service.ts`; the route wrapper in `src/web/routes/search-routes.ts`.
|
||||
*/
|
||||
|
||||
/** Federated source kinds. Group/render order is sessions → events → files. */
|
||||
export type SearchSourceType = 'session' | 'event' | 'file';
|
||||
|
||||
/** Where the frontend should jump when a result card is activated. */
|
||||
export interface SearchJumpTarget {
|
||||
/** Kind of navigation target. */
|
||||
kind: 'session' | 'run-summary' | 'file-preview';
|
||||
/** Owning Codeman session id (always present — every result is session-scoped). */
|
||||
sessionId: string;
|
||||
/**
|
||||
* Secondary identifier for the target:
|
||||
* - kind 'run-summary': the run-summary event id
|
||||
* - kind 'file-preview': the attachment history item id
|
||||
* - kind 'session': undefined (the sessionId is sufficient)
|
||||
*/
|
||||
targetId?: string;
|
||||
/**
|
||||
* Workspace-relative path for file-preview targets. Never an absolute path —
|
||||
* server-private external paths are intentionally omitted to avoid leakage.
|
||||
*/
|
||||
relativePath?: string;
|
||||
}
|
||||
|
||||
/** A single typed search result card. */
|
||||
export interface SearchResult {
|
||||
/** Which federated source produced this result. */
|
||||
type: SearchSourceType;
|
||||
/** Owning Codeman session id. */
|
||||
sessionId: string;
|
||||
/** Display name of the owning session / case. */
|
||||
sessionName: string;
|
||||
/** Millisecond timestamp used for recency ranking and display. */
|
||||
timestamp: number;
|
||||
/** Short, already-truncated snippet describing the match. */
|
||||
snippet: string;
|
||||
/** True when the query matched the primary name/path exactly (case-insensitive). */
|
||||
exactMatch: boolean;
|
||||
/** Navigation target for the jump-to action. */
|
||||
jumpTo: SearchJumpTarget;
|
||||
}
|
||||
|
||||
/** A group of results for one source type, in render order. */
|
||||
export interface SearchResultGroup {
|
||||
type: SearchSourceType;
|
||||
results: SearchResult[];
|
||||
}
|
||||
|
||||
/** Payload returned as `data` inside the standard ApiResponse envelope. */
|
||||
export interface SearchResponseData {
|
||||
/** The normalized query that was executed. */
|
||||
query: string;
|
||||
/** Results grouped by source type, ordered sessions → events → files. */
|
||||
groups: SearchResultGroup[];
|
||||
/** Total number of results across all groups (after caps applied). */
|
||||
totalResults: number;
|
||||
/** True if any group or the total was capped (more matches existed). */
|
||||
truncated: boolean;
|
||||
}
|
||||
+196
-2
@@ -8,10 +8,12 @@
|
||||
* - SessionConfig — creation-time config (id, workingDir, createdAt)
|
||||
* - SessionOutput — captured stdout/stderr/exitCode
|
||||
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
|
||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' (which CLI backend)
|
||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' (which CLI backend)
|
||||
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'normal' | 'allowedTools')
|
||||
* - SessionColor — visual differentiation color
|
||||
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
|
||||
* - CodexConfig — Codex (OpenAI CLI)-specific settings (model, resumeSessionId)
|
||||
* - GeminiConfig — Gemini CLI-specific settings (model, approvalMode, resumeSession)
|
||||
*
|
||||
* Cross-domain relationships:
|
||||
* - SessionState.respawnConfig embeds RespawnConfig (respawn domain)
|
||||
@@ -39,7 +41,178 @@ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
|
||||
export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedTools';
|
||||
|
||||
/** Session mode: which CLI backend a session runs */
|
||||
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex';
|
||||
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini';
|
||||
|
||||
export type RemoteCommandMode = Extract<SessionMode, 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini'>;
|
||||
|
||||
/**
|
||||
* Advanced SSH connection options shared by RemoteHost and SessionRemote.
|
||||
*
|
||||
* COD-107 — all fields are optional; every field absent reproduces today's
|
||||
* behavior (port-22, default-identity, directly-SSH-able hosts). These describe
|
||||
* HOW Codeman reaches the host (identity, proxy, jump host, arbitrary `-o`),
|
||||
* letting it connect to e.g. a host fronted by a cloudflared SOCKS5 proxy on a
|
||||
* custom port — the same connection `ssh-aa-desktop` makes — without a wrapper.
|
||||
*/
|
||||
export interface RemoteSshOptions {
|
||||
/**
|
||||
* Path to an SSH identity (private key) file — path ONLY, never key bytes.
|
||||
* A leading `~`/`$HOME` is expanded to an absolute path at command-build time
|
||||
* (ssh does not expand `~` in `-i`).
|
||||
*/
|
||||
identityFile?: string;
|
||||
/**
|
||||
* SOCKS5 proxy as `host:port` (e.g. `127.0.0.1:1080`). Expands to
|
||||
* `-o ProxyCommand=nc -X 5 -x <host:port> %h %p` (the cloudflared/SOCKS5 case).
|
||||
*/
|
||||
socksProxy?: string;
|
||||
/** SSH jump host (`[user@]host[:port]`) emitted as `-J <jumpHost>`. */
|
||||
jumpHost?: string;
|
||||
/** Arbitrary additional `-o KEY=VALUE` options (escape hatch). Each `KEY=VALUE`. */
|
||||
extraSshOptions?: string[];
|
||||
}
|
||||
|
||||
export interface RemoteHost extends RemoteSshOptions {
|
||||
id: string;
|
||||
label: string;
|
||||
host: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
commands?: Partial<Record<RemoteCommandMode, string>>;
|
||||
}
|
||||
|
||||
export interface RemoteCase {
|
||||
name: string;
|
||||
type: 'remote';
|
||||
hostId: string;
|
||||
remotePath: string;
|
||||
}
|
||||
|
||||
export interface SessionRemote extends RemoteSshOptions {
|
||||
hostId: string;
|
||||
label: string;
|
||||
host: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
remotePath: string;
|
||||
commands?: Partial<Record<RemoteCommandMode, string>>;
|
||||
}
|
||||
|
||||
// ========== Docker cases (COD-Docker) ==========
|
||||
//
|
||||
// Docker mode is a LOCATION OVERLAY on cases (never a 6th SessionMode), the exact
|
||||
// analog of the remote-SSH feature above: instead of a local tmux pane running
|
||||
// `ssh host` into a durable remote tmux server, a local tmux pane runs
|
||||
// `docker exec -it` into a durable in-container tmux server. The container is
|
||||
// scoped to the CASE (not the session), so multiple sessions can `docker exec`
|
||||
// into the same long-lived container. See `docs/docker-cases-plan.md`.
|
||||
|
||||
/** Which CLI backends a Docker case can run (same set as remote). */
|
||||
export type DockerCommandMode = Extract<SessionMode, 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini'>;
|
||||
|
||||
/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
|
||||
export type DockerEngine = 'docker' | 'podman';
|
||||
|
||||
/**
|
||||
* Container network mode. `host` and any inbound `-p` publish are deliberately
|
||||
* unrepresentable (never in this union, never emitted by the flag builder).
|
||||
* - `bridge`: own netns, NAT egress, no inbound (default — every API CLI needs egress)
|
||||
* - `none`: fully offline sandbox (breaks API CLIs; reserved for `shell`)
|
||||
* - `custom`: a user-defined bridge `codeman-net-<slug>` (future egress-allowlist chokepoint)
|
||||
*/
|
||||
export type DockerNetworkMode = 'bridge' | 'none' | 'custom';
|
||||
|
||||
/** Per-container resource caps. Advisory under non-delegated rootless (see `capsEnforced`). */
|
||||
export interface DockerResourceLimits {
|
||||
/** e.g. '4g' -> --memory 4g --memory-swap 4g (swap==memory: a real OOM cap) */
|
||||
memory?: string;
|
||||
/** e.g. '2' -> --cpus 2 */
|
||||
cpus?: string;
|
||||
/** e.g. 512 -> --pids-limit 512 (fork-bomb guard) */
|
||||
pidsLimit?: number;
|
||||
/** e.g. '4096:8192' -> --ulimit nofile=4096:8192 */
|
||||
nofile?: string;
|
||||
/** e.g. '256m' -> --shm-size (only when a tool needs /dev/shm) */
|
||||
shmSize?: string;
|
||||
}
|
||||
|
||||
/** A reusable Docker engine/image/network/resource profile (mirror of RemoteHost). */
|
||||
export interface DockerHost {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Engine; when absent the availability probe resolves it (docker, else podman). */
|
||||
engine?: DockerEngine;
|
||||
/** Base image ref (built locally by scripts/build-agent-image.mjs, e.g. codeman/agent:base). */
|
||||
image: string;
|
||||
/** Advanced: remote daemon (-H ssh://user@host or a DOCKER_HOST value). */
|
||||
daemonHost?: string;
|
||||
/** Advanced: docker `--context` name. */
|
||||
context?: string;
|
||||
/** Network mode (default 'bridge'). */
|
||||
network?: DockerNetworkMode;
|
||||
/** Custom bridge name when network === 'custom'. */
|
||||
networkName?: string;
|
||||
resources?: DockerResourceLimits;
|
||||
/** GPU allocation, e.g. 'all' / '1' / 'device=0,1' -> `--gpus <value>` (needs the NVIDIA container toolkit). */
|
||||
gpus?: string;
|
||||
/** true (default) = convenient: bind-mount host cred dirs RW. false = sealed (blocks full-image export). */
|
||||
mountCredentials?: boolean;
|
||||
/** true (default) = wire in-container hooks (host-gateway callback + workspace scaffold). */
|
||||
hooksEnabled?: boolean;
|
||||
/** true (default) = a relaunch resumes the last conversation from the bind-mounted transcript. */
|
||||
resumeOnStart?: boolean;
|
||||
/** Per-mode command overrides (mirror RemoteHost.commands). */
|
||||
commands?: Partial<Record<DockerCommandMode, string>>;
|
||||
/** Escape hatch: extra `docker create` args (validated like extraSshOptions). */
|
||||
extraCreateArgs?: string[];
|
||||
/** Escape hatch: extra `docker exec` args. */
|
||||
extraExecArgs?: string[];
|
||||
}
|
||||
|
||||
/** A case linked to a Docker container (mirror of RemoteCase). */
|
||||
export interface DockerCase {
|
||||
name: string;
|
||||
type: 'docker';
|
||||
hostId: string;
|
||||
/** Absolute HOST directory: the bind-mount source AND Session.workingDir (real host bytes). */
|
||||
hostWorkspacePath: string;
|
||||
/** Container path (default = hostWorkspacePath: mirror -> transcript projHash correlates). */
|
||||
containerWorkdir?: string;
|
||||
/** Container name (default codeman-case-<slug>). */
|
||||
container?: string;
|
||||
/** Last captured Claude conversation id, replayed via --resume on a fresh launch. */
|
||||
lastClaudeSessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattened Docker execution metadata carried on a live session (mirror of
|
||||
* SessionRemote). Round-trips through MuxSession/SessionState/mux-sessions.json.
|
||||
*/
|
||||
export interface SessionDocker {
|
||||
hostId: string;
|
||||
label: string;
|
||||
engine: DockerEngine;
|
||||
image: string;
|
||||
/** Per-CASE container name (shared by all sessions of the case). */
|
||||
containerName: string;
|
||||
hostWorkspacePath: string;
|
||||
containerWorkdir: string;
|
||||
network: DockerNetworkMode;
|
||||
networkName?: string;
|
||||
resources?: DockerResourceLimits;
|
||||
/** GPU allocation ('all' / '1' / 'device=0,1'). */
|
||||
gpus?: string;
|
||||
mountCredentials: boolean;
|
||||
hooksEnabled: boolean;
|
||||
resumeOnStart: boolean;
|
||||
daemonHost?: string;
|
||||
context?: string;
|
||||
commands?: Partial<Record<DockerCommandMode, string>>;
|
||||
extraCreateArgs?: string[];
|
||||
extraExecArgs?: string[];
|
||||
/** Stable hash of the drift-relevant create args (recreate-on-drift detection). */
|
||||
configHash?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid Claude CLI effort levels (claude >= 2.1.154).
|
||||
@@ -85,6 +258,16 @@ export interface CodexConfig {
|
||||
renderMode?: CodexRenderMode;
|
||||
}
|
||||
|
||||
/** Gemini CLI session configuration */
|
||||
export interface GeminiConfig {
|
||||
/** Model identifier (e.g., "gemini-2.5-pro"). Passed via --model. */
|
||||
model?: string;
|
||||
/** Gemini approval mode for tool calls. */
|
||||
approvalMode?: 'default' | 'auto_edit' | 'yolo' | 'plan';
|
||||
/** Resume a previous Gemini session ("latest", index, or session id). */
|
||||
resumeSession?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for creating a new session
|
||||
*/
|
||||
@@ -148,6 +331,10 @@ export interface SessionState {
|
||||
status: SessionStatus;
|
||||
/** Working directory path */
|
||||
workingDir: string;
|
||||
/** Remote execution metadata, present when this session runs over SSH through local tmux */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */
|
||||
docker?: SessionDocker;
|
||||
/** ID of currently assigned task, null if none */
|
||||
currentTaskId: string | null;
|
||||
/** Timestamp when session was created */
|
||||
@@ -214,12 +401,19 @@ export interface SessionState {
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
/** Codex-specific configuration (only for mode === 'codex') */
|
||||
codexConfig?: CodexConfig;
|
||||
/** Gemini-specific configuration (only for mode === 'gemini') */
|
||||
geminiConfig?: GeminiConfig;
|
||||
/** Claude conversation session ID to resume after reboot (set by restore script) */
|
||||
resumeSessionId?: string;
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort?: EffortLevel;
|
||||
/** Sanitized per-session attachment history. */
|
||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||
/**
|
||||
* PTY-exit circuit breaker tripped — respawn blocked until an explicit restart
|
||||
* (COD-118). Runtime-only: never restored on boot (fresh server = fresh breaker).
|
||||
*/
|
||||
respawnBlocked?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @module utils/claude-cli-resolver
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { execSync, execFileSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { delimiter, dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
@@ -83,3 +83,43 @@ export function getAugmentedPath(): string {
|
||||
_augmentedPath = currentPath;
|
||||
return _augmentedPath;
|
||||
}
|
||||
|
||||
/** Cached `claude --version` result: string = version, null = probed but unavailable, undefined = not probed */
|
||||
let _claudeVersion: string | null | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Returns the installed Claude CLI version (e.g. `"2.1.210"`), or null if it
|
||||
* can't be determined. Runs `claude --version` once and caches the result.
|
||||
*
|
||||
* This is a deterministic alternative to scraping the interactive startup
|
||||
* banner (`parseClaudeCodeInfo` in session.ts): newer Claude Code builds don't
|
||||
* reliably print `Claude Code vX.Y.Z` at startup, and resumed sessions never
|
||||
* show it, which left `cliVersion` undefined and silently disabled features
|
||||
* gated on it (e.g. wheel-forwarding to Claude's transcript — issue #154).
|
||||
*/
|
||||
export function getClaudeCliVersion(): string | null {
|
||||
if (_claudeVersion !== undefined) return _claudeVersion;
|
||||
// Keep the test suite hermetic — never spawn a real `claude` subprocess under
|
||||
// vitest (matches IS_TEST_MODE in tmux-manager). Tests that need a version set
|
||||
// it on the session directly.
|
||||
if (process.env.VITEST) {
|
||||
_claudeVersion = null;
|
||||
return _claudeVersion;
|
||||
}
|
||||
try {
|
||||
const dir = findClaudeDir();
|
||||
const bin = dir ? join(dir, 'claude') : 'claude';
|
||||
// execFileSync (no shell) — the resolved path may contain spaces, and there
|
||||
// is no untrusted input, but avoid a shell either way.
|
||||
const out = execFileSync(bin, ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
env: { ...process.env, PATH: getAugmentedPath() },
|
||||
});
|
||||
const match = out.match(/(\d+\.\d+\.\d+)/);
|
||||
_claudeVersion = match ? match[1] : null;
|
||||
} catch {
|
||||
_claudeVersion = null;
|
||||
}
|
||||
return _claudeVersion;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @fileoverview Resolve the Gemini CLI binary across common install paths.
|
||||
*
|
||||
* Mirrors codex-cli-resolver.ts and opencode-cli-resolver.ts. Finds the
|
||||
* `gemini` binary and provides an augmented PATH directory for tmux sessions.
|
||||
*
|
||||
* @module utils/gemini-cli-resolver
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
|
||||
/** Common directories where the Gemini CLI binary may be installed */
|
||||
const GEMINI_SEARCH_DIRS = [
|
||||
join(homedir(), '.gemini', 'bin'),
|
||||
join(homedir(), '.local', 'bin'),
|
||||
'/usr/local/bin',
|
||||
join(homedir(), '.bun', 'bin'),
|
||||
join(homedir(), '.npm-global', 'bin'),
|
||||
join(homedir(), 'bin'),
|
||||
];
|
||||
|
||||
/** Cached directory containing the gemini binary (empty string = searched but not found) */
|
||||
let _geminiDir: string | null = null;
|
||||
|
||||
/**
|
||||
* Finds the directory containing the `gemini` binary.
|
||||
* Checks `which gemini` first, then falls back to common install locations.
|
||||
*
|
||||
* @returns Directory path, or null if not found
|
||||
*/
|
||||
export function resolveGeminiDir(): string | null {
|
||||
if (_geminiDir !== null) return _geminiDir || null;
|
||||
|
||||
try {
|
||||
const result = execSync('which gemini', {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
if (result && existsSync(result)) {
|
||||
_geminiDir = dirname(result);
|
||||
return _geminiDir;
|
||||
}
|
||||
} catch {
|
||||
// Gemini not in PATH, will check common locations
|
||||
}
|
||||
|
||||
for (const dir of GEMINI_SEARCH_DIRS) {
|
||||
if (existsSync(join(dir, 'gemini'))) {
|
||||
_geminiDir = dir;
|
||||
return _geminiDir;
|
||||
}
|
||||
}
|
||||
|
||||
_geminiDir = '';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Gemini CLI is available on the system.
|
||||
*/
|
||||
export function isGeminiAvailable(): boolean {
|
||||
return resolveGeminiDir() !== null;
|
||||
}
|
||||
+2
-1
@@ -26,6 +26,7 @@ export { isSafePushEndpoint } from './push-endpoint-validation.js';
|
||||
export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
|
||||
export { assertNever } from './type-safety.js';
|
||||
export { wrapWithNice } from './nice-wrapper.js';
|
||||
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
|
||||
export { findClaudeDir, getAugmentedPath, getClaudeCliVersion } from './claude-cli-resolver.js';
|
||||
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
|
||||
export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
|
||||
export { resolveGeminiDir, isGeminiAvailable } from './gemini-cli-resolver.js';
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
import type { LifecycleEntry, RunSummary, RunSummaryEvent, TokenUsageEntry } from '../types.js';
|
||||
|
||||
export type AwayDigestRangeName = 'since-last-visit' | '1h' | 'today' | '24h' | 'custom';
|
||||
export type AwayDigestCategory = 'needs_attention' | 'completed' | 'still_running' | 'idle' | 'informational';
|
||||
export type AwayDigestSectionName = 'needsAttention' | 'completed' | 'stillRunning' | 'idle' | 'informational';
|
||||
export type AwayDigestSeverity = 'info' | 'success' | 'warning' | 'error';
|
||||
export type AwayDigestSource = 'lifecycle' | 'run_summary' | 'status' | 'token_stats' | 'subagent';
|
||||
export type AwayDigestTokenWindowPrecision = 'day' | 'none';
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
const VALID_RANGES = new Set<AwayDigestRangeName>(['since-last-visit', '1h', 'today', '24h', 'custom']);
|
||||
|
||||
export interface AwayDigestRange {
|
||||
range: AwayDigestRangeName;
|
||||
since: number;
|
||||
until: number;
|
||||
}
|
||||
|
||||
export interface AwayDigestRangeInput {
|
||||
range?: string;
|
||||
since?: number;
|
||||
until?: number;
|
||||
lastViewed?: number;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
export interface AwayDigestSession {
|
||||
id: string;
|
||||
name?: string;
|
||||
status?: string;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalCost?: number;
|
||||
}
|
||||
|
||||
export interface AwayDigestSubagent {
|
||||
id?: string;
|
||||
agentId?: string;
|
||||
sessionId?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
lastUpdated?: number;
|
||||
updatedAt?: number;
|
||||
completedAt?: number;
|
||||
modifiedAt?: number;
|
||||
lastActivityAt?: number;
|
||||
}
|
||||
|
||||
export interface AwayDigestItem {
|
||||
id: string;
|
||||
sessionId?: string;
|
||||
sessionName?: string;
|
||||
timestamp: number;
|
||||
category: AwayDigestCategory;
|
||||
severity: AwayDigestSeverity;
|
||||
title: string;
|
||||
detail?: string;
|
||||
source: AwayDigestSource;
|
||||
link?: {
|
||||
type: 'session' | 'run_summary' | 'lifecycle' | 'notification';
|
||||
sessionId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AwayDigestTotals {
|
||||
sessionsCreated: number;
|
||||
sessionsExited: number;
|
||||
activeSessions: number;
|
||||
needsAttention: number;
|
||||
completed: number;
|
||||
errors: number;
|
||||
warnings: number;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
estimatedCost?: number;
|
||||
tokenWindowPrecision: AwayDigestTokenWindowPrecision;
|
||||
}
|
||||
|
||||
export interface AwayDigestResponse {
|
||||
range: AwayDigestRange;
|
||||
generatedAt: number;
|
||||
dataFreshness: {
|
||||
lifecyclePersisted: true;
|
||||
tokenStatsPersisted: true;
|
||||
runSummariesLiveOnly: true;
|
||||
subagentsLiveOnly: true;
|
||||
};
|
||||
totals: AwayDigestTotals;
|
||||
sections: Record<AwayDigestSectionName, AwayDigestItem[]>;
|
||||
}
|
||||
|
||||
export interface AwayDigestInput {
|
||||
range: AwayDigestRange;
|
||||
lifecycleEntries: LifecycleEntry[];
|
||||
runSummaries: RunSummary[];
|
||||
sessions: AwayDigestSession[];
|
||||
dailyTokenStats: TokenUsageEntry[];
|
||||
subagents: AwayDigestSubagent[];
|
||||
now?: number;
|
||||
}
|
||||
|
||||
export function resolveAwayDigestRange(input: AwayDigestRangeInput): AwayDigestRange {
|
||||
const now = input.now ?? Date.now();
|
||||
const range = (input.range ?? 'since-last-visit') as AwayDigestRangeName;
|
||||
if (!VALID_RANGES.has(range)) {
|
||||
throw new Error(`Invalid away digest range: ${input.range}`);
|
||||
}
|
||||
|
||||
let since: number;
|
||||
const until = finiteOrDefault(input.until, now);
|
||||
|
||||
switch (range) {
|
||||
case 'since-last-visit':
|
||||
since = finiteOrDefault(input.lastViewed, now - DAY_MS);
|
||||
break;
|
||||
case '1h':
|
||||
since = now - HOUR_MS;
|
||||
break;
|
||||
case 'today': {
|
||||
const start = new Date(now);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
since = start.getTime();
|
||||
break;
|
||||
}
|
||||
case '24h':
|
||||
since = now - DAY_MS;
|
||||
break;
|
||||
case 'custom':
|
||||
if (!Number.isFinite(input.since)) {
|
||||
throw new Error('Custom away digest range requires a finite since timestamp');
|
||||
}
|
||||
since = input.since as number;
|
||||
break;
|
||||
}
|
||||
|
||||
if (until < since) {
|
||||
throw new Error('Away digest until timestamp must be greater than or equal to since');
|
||||
}
|
||||
|
||||
return { range, since, until };
|
||||
}
|
||||
|
||||
export function buildAwayDigest(input: AwayDigestInput): AwayDigestResponse {
|
||||
const now = input.now ?? Date.now();
|
||||
const sections: Record<AwayDigestSectionName, AwayDigestItem[]> = {
|
||||
needsAttention: [],
|
||||
completed: [],
|
||||
stillRunning: [],
|
||||
idle: [],
|
||||
informational: [],
|
||||
};
|
||||
|
||||
const sessionsById = new Map(input.sessions.map((session) => [session.id, session]));
|
||||
const lifecycleEntries = input.lifecycleEntries.filter((entry) => isInRange(entry.ts, input.range));
|
||||
|
||||
for (const entry of lifecycleEntries) {
|
||||
addItem(sections, lifecycleEntryToItem(entry));
|
||||
}
|
||||
|
||||
for (const summary of input.runSummaries) {
|
||||
for (const event of summary.events) {
|
||||
if (!isInRange(event.timestamp, input.range)) continue;
|
||||
addItem(sections, runSummaryEventToItem(summary, event));
|
||||
}
|
||||
}
|
||||
|
||||
for (const session of input.sessions) {
|
||||
const item = sessionToItem(session, now);
|
||||
addItem(sections, item);
|
||||
}
|
||||
|
||||
for (const subagent of input.subagents) {
|
||||
const timestamp = subagentTimestamp(subagent, now);
|
||||
if (!isInRange(timestamp, input.range) || subagent.status !== 'completed') continue;
|
||||
addItem(sections, subagentToItem(subagent, sessionsById, timestamp));
|
||||
}
|
||||
|
||||
const tokenTotals = aggregateTokenStats(input.dailyTokenStats, input.range);
|
||||
const totals = calculateTotals(sections, lifecycleEntries, input.sessions, tokenTotals);
|
||||
|
||||
return {
|
||||
range: input.range,
|
||||
generatedAt: now,
|
||||
dataFreshness: {
|
||||
lifecyclePersisted: true,
|
||||
tokenStatsPersisted: true,
|
||||
runSummariesLiveOnly: true,
|
||||
subagentsLiveOnly: true,
|
||||
},
|
||||
totals,
|
||||
sections,
|
||||
};
|
||||
}
|
||||
|
||||
function finiteOrDefault(value: number | undefined, fallback: number): number {
|
||||
return Number.isFinite(value) ? (value as number) : fallback;
|
||||
}
|
||||
|
||||
function isInRange(timestamp: number, range: AwayDigestRange): boolean {
|
||||
return timestamp >= range.since && timestamp <= range.until;
|
||||
}
|
||||
|
||||
function addItem(sections: Record<AwayDigestSectionName, AwayDigestItem[]>, item: AwayDigestItem): void {
|
||||
sections[sectionNameForCategory(item.category)].push(item);
|
||||
}
|
||||
|
||||
function sectionNameForCategory(category: AwayDigestCategory): AwayDigestSectionName {
|
||||
switch (category) {
|
||||
case 'needs_attention':
|
||||
return 'needsAttention';
|
||||
case 'still_running':
|
||||
return 'stillRunning';
|
||||
case 'completed':
|
||||
case 'idle':
|
||||
case 'informational':
|
||||
return category;
|
||||
}
|
||||
}
|
||||
|
||||
function lifecycleEntryToItem(entry: LifecycleEntry): AwayDigestItem {
|
||||
const needsAttention = entry.event === 'mux_died' || (entry.event === 'exit' && (entry.exitCode ?? 0) !== 0);
|
||||
return {
|
||||
id: `lifecycle-${entry.ts}-${entry.event}-${entry.sessionId}`,
|
||||
sessionId: entry.sessionId,
|
||||
sessionName: entry.name,
|
||||
timestamp: entry.ts,
|
||||
category: needsAttention ? 'needs_attention' : 'informational',
|
||||
severity: needsAttention ? 'error' : entry.event === 'exit' ? 'info' : 'info',
|
||||
title: lifecycleTitle(entry),
|
||||
detail: lifecycleDetail(entry),
|
||||
source: 'lifecycle',
|
||||
link: { type: 'lifecycle', sessionId: entry.sessionId },
|
||||
};
|
||||
}
|
||||
|
||||
function lifecycleTitle(entry: LifecycleEntry): string {
|
||||
if (entry.event === 'exit') {
|
||||
return (entry.exitCode ?? 0) === 0 ? 'Session exited' : 'Session exited with error';
|
||||
}
|
||||
if (entry.event === 'mux_died') return 'Tmux session died';
|
||||
return `Session ${entry.event.replaceAll('_', ' ')}`;
|
||||
}
|
||||
|
||||
function lifecycleDetail(entry: LifecycleEntry): string | undefined {
|
||||
if (entry.reason) return entry.reason;
|
||||
if (entry.event === 'exit' && entry.exitCode !== undefined && entry.exitCode !== null) {
|
||||
return `Exit code ${entry.exitCode}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function runSummaryEventToItem(summary: RunSummary, event: RunSummaryEvent): AwayDigestItem {
|
||||
const category = runSummaryCategory(event);
|
||||
return {
|
||||
id: `run-summary-${summary.sessionId}-${event.id}`,
|
||||
sessionId: summary.sessionId,
|
||||
sessionName: summary.sessionName,
|
||||
timestamp: event.timestamp,
|
||||
category,
|
||||
severity: runSummarySeverity(event, category),
|
||||
title: event.title,
|
||||
detail: event.details,
|
||||
source: 'run_summary',
|
||||
link: { type: 'run_summary', sessionId: summary.sessionId },
|
||||
};
|
||||
}
|
||||
|
||||
function runSummaryCategory(event: RunSummaryEvent): AwayDigestCategory {
|
||||
if (event.type === 'ralph_completion') return 'completed';
|
||||
if (event.severity === 'error' || event.severity === 'warning' || event.type === 'state_stuck') {
|
||||
return 'needs_attention';
|
||||
}
|
||||
return 'informational';
|
||||
}
|
||||
|
||||
function runSummarySeverity(event: RunSummaryEvent, category: AwayDigestCategory): AwayDigestSeverity {
|
||||
if (category === 'completed') return 'success';
|
||||
return event.severity;
|
||||
}
|
||||
|
||||
function sessionToItem(session: AwayDigestSession, now: number): AwayDigestItem {
|
||||
const isIdle = session.status === 'idle';
|
||||
return {
|
||||
id: `status-${session.id}`,
|
||||
sessionId: session.id,
|
||||
sessionName: session.name,
|
||||
timestamp: now,
|
||||
category: isIdle ? 'idle' : 'still_running',
|
||||
severity: isIdle ? 'info' : 'success',
|
||||
title: isIdle ? 'Session idle' : 'Session still running',
|
||||
detail: session.status ? `Status: ${session.status}` : undefined,
|
||||
source: 'status',
|
||||
link: { type: 'session', sessionId: session.id },
|
||||
};
|
||||
}
|
||||
|
||||
function subagentToItem(
|
||||
subagent: AwayDigestSubagent,
|
||||
sessionsById: Map<string, AwayDigestSession>,
|
||||
timestamp: number
|
||||
): AwayDigestItem {
|
||||
const session = subagent.sessionId ? sessionsById.get(subagent.sessionId) : undefined;
|
||||
const agentId = subagent.id ?? subagent.agentId ?? 'unknown';
|
||||
return {
|
||||
id: `subagent-${agentId}`,
|
||||
sessionId: subagent.sessionId,
|
||||
sessionName: session?.name,
|
||||
timestamp,
|
||||
category: 'informational',
|
||||
severity: 'success',
|
||||
title: 'Subagent completed',
|
||||
detail: subagent.description,
|
||||
source: 'subagent',
|
||||
link: subagent.sessionId ? { type: 'session', sessionId: subagent.sessionId } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function subagentTimestamp(subagent: AwayDigestSubagent, fallback: number): number {
|
||||
return (
|
||||
subagent.completedAt ??
|
||||
subagent.lastUpdated ??
|
||||
subagent.updatedAt ??
|
||||
subagent.modifiedAt ??
|
||||
subagent.lastActivityAt ??
|
||||
fallback
|
||||
);
|
||||
}
|
||||
|
||||
function aggregateTokenStats(
|
||||
dailyTokenStats: TokenUsageEntry[],
|
||||
range: AwayDigestRange
|
||||
): { inputTokens: number; outputTokens: number; estimatedCost: number; precision: AwayDigestTokenWindowPrecision } {
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let estimatedCost = 0;
|
||||
|
||||
for (const day of dailyTokenStats) {
|
||||
if (!dayOverlapsRange(day.date, range)) continue;
|
||||
inputTokens += day.inputTokens;
|
||||
outputTokens += day.outputTokens;
|
||||
estimatedCost += day.estimatedCost;
|
||||
}
|
||||
|
||||
return {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
estimatedCost,
|
||||
precision: inputTokens > 0 || outputTokens > 0 || estimatedCost > 0 ? 'day' : 'none',
|
||||
};
|
||||
}
|
||||
|
||||
function dayOverlapsRange(date: string, range: AwayDigestRange): boolean {
|
||||
const dayStart = new Date(`${date}T00:00:00`).getTime();
|
||||
const dayEnd = dayStart + DAY_MS - 1;
|
||||
return dayStart <= range.until && dayEnd >= range.since;
|
||||
}
|
||||
|
||||
function calculateTotals(
|
||||
sections: Record<AwayDigestSectionName, AwayDigestItem[]>,
|
||||
lifecycleEntries: LifecycleEntry[],
|
||||
sessions: AwayDigestSession[],
|
||||
tokenTotals: ReturnType<typeof aggregateTokenStats>
|
||||
): AwayDigestTotals {
|
||||
const allItems = Object.values(sections).flat();
|
||||
return {
|
||||
sessionsCreated: lifecycleEntries.filter((entry) => entry.event === 'created').length,
|
||||
sessionsExited: lifecycleEntries.filter((entry) => entry.event === 'exit').length,
|
||||
activeSessions: sessions.length,
|
||||
needsAttention: sections.needsAttention.length,
|
||||
completed: sections.completed.length,
|
||||
errors: allItems.filter((item) => item.severity === 'error').length,
|
||||
warnings: allItems.filter((item) => item.severity === 'warning').length,
|
||||
inputTokens: tokenTotals.inputTokens,
|
||||
outputTokens: tokenTotals.outputTokens,
|
||||
estimatedCost: tokenTotals.estimatedCost,
|
||||
tokenWindowPrecision: tokenTotals.precision,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @fileoverview Main-thread wrapper for HEIC/HEIF → JPEG conversion.
|
||||
*
|
||||
* The actual decode/encode (`heic-jpeg-worker.ts`) is CPU-synchronous WASM + JS,
|
||||
* so it runs in a dedicated `worker_threads` Worker per conversion — never on
|
||||
* the event loop that serves every session's SSE/PTY/WS traffic. On top of
|
||||
* the worker isolation this wrapper enforces:
|
||||
* - the global converter concurrency cap (`runWithConversionLimit`, shared
|
||||
* with the pdftoppm/soffice document converters) so N simultaneous uploads
|
||||
* can't pin N cores / N × 256MB decode buffers at once;
|
||||
* - a hard timeout that terminates the worker (a wedged WASM decode can't be
|
||||
* cancelled cooperatively);
|
||||
* - the paste-image size cap on the *output* — jpeg-js is a far less
|
||||
* efficient encoder than HEVC, so a within-limit HEIC can inflate past
|
||||
* MAX_PASTE_IMAGE_BYTES.
|
||||
*/
|
||||
|
||||
import { Worker } from 'node:worker_threads';
|
||||
import { runWithConversionLimit } from '../document-conversion-limiter.js';
|
||||
import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js';
|
||||
import { HEIC_JPEG_QUALITY, type HeicWorkerInput, type HeicWorkerResult } from './heic-jpeg-worker.js';
|
||||
|
||||
/** Hard cap on a single conversion; the worker is terminated when it fires. */
|
||||
export const HEIC_CONVERSION_TIMEOUT_MS = 30_000;
|
||||
|
||||
// V8-heap guardrails for the conversion worker — defense in depth only: large
|
||||
// TypedArray/WASM backing stores are external to the V8 heap, so the real
|
||||
// memory bound is the 64MP dimension pre-check in heic-jpeg-worker.ts.
|
||||
const WORKER_RESOURCE_LIMITS = { maxOldGenerationSizeMb: 1024, maxYoungGenerationSizeMb: 128, stackSizeMb: 8 };
|
||||
|
||||
function workerUrl(): URL {
|
||||
// Compiled installs run the tsc-emitted .js sibling in dist/; dev under tsx
|
||||
// runs the .ts source directly (tsx's loader propagates to worker threads).
|
||||
const file = import.meta.url.endsWith('.ts') ? './heic-jpeg-worker.ts' : './heic-jpeg-worker.js';
|
||||
return new URL(file, import.meta.url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert HEIC/HEIF bytes to JPEG bytes off-thread. Rejects on invalid input,
|
||||
* over-limit dimensions, oversized output, timeout, or worker failure.
|
||||
*/
|
||||
export async function convertHeicToJpeg(imageBytes: Buffer): Promise<Buffer> {
|
||||
return runWithConversionLimit(
|
||||
() =>
|
||||
new Promise<Buffer>((resolve, reject) => {
|
||||
const worker = new Worker(workerUrl(), {
|
||||
workerData: { heicInput: imageBytes, quality: HEIC_JPEG_QUALITY } satisfies HeicWorkerInput,
|
||||
resourceLimits: WORKER_RESOURCE_LIMITS,
|
||||
});
|
||||
let settled = false;
|
||||
const settle = (fn: () => void): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
fn();
|
||||
void worker.terminate();
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
settle(() => reject(new Error(`HEIC conversion timed out after ${HEIC_CONVERSION_TIMEOUT_MS}ms`)));
|
||||
}, HEIC_CONVERSION_TIMEOUT_MS);
|
||||
worker.on('message', (msg: HeicWorkerResult) => {
|
||||
settle(() => {
|
||||
if (!msg.ok) {
|
||||
reject(new Error(msg.error));
|
||||
return;
|
||||
}
|
||||
const out = Buffer.from(msg.data.buffer, msg.data.byteOffset, msg.data.byteLength);
|
||||
if (out.length > MAX_PASTE_IMAGE_BYTES) {
|
||||
const maxMb = Math.round(MAX_PASTE_IMAGE_BYTES / (1024 * 1024));
|
||||
reject(new Error(`converted JPEG (${out.length} bytes) exceeds the ${maxMb}MB upload limit`));
|
||||
return;
|
||||
}
|
||||
resolve(out);
|
||||
});
|
||||
});
|
||||
worker.on('error', (err) => settle(() => reject(err)));
|
||||
worker.on('exit', (code) => {
|
||||
settle(() => reject(new Error(`HEIC conversion worker exited unexpectedly (code ${code})`)));
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @fileoverview HEIC/HEIF → JPEG conversion core + worker-thread entry.
|
||||
*
|
||||
* Spawned per conversion by `heic-jpeg-converter.ts` so the CPU-synchronous
|
||||
* libheif WASM decode + jpeg-js encode never run on the server's main thread
|
||||
* (on the event loop they would freeze every session's SSE/PTY/WS handling
|
||||
* for seconds per photo). Input arrives via `workerData`; the result (or
|
||||
* error message) is posted back as a single message and the thread exits.
|
||||
*
|
||||
* The conversion logic lives in this same file (exported, guarded bootstrap)
|
||||
* rather than a sibling module: the worker runs from `.ts` source under tsx
|
||||
* in dev, where relative `.js` imports don't resolve inside worker threads —
|
||||
* only `node:` builtins are imported at top level. Unit tests import
|
||||
* `convertHeicBufferToJpeg` directly; the bootstrap only runs when spawned
|
||||
* with our `workerData` shape.
|
||||
*
|
||||
* Decompression-bomb guard: heic-decode's `.all` path exposes the
|
||||
* header-declared {width, height} per image WITHOUT decoding pixels, while
|
||||
* its plain decode path allocates `width * height * 4` bytes straight from
|
||||
* those header values — a <1KB crafted file declaring 30000×30000 would
|
||||
* demand a 3.6GB allocation. We reject anything above MAX_HEIC_DECODE_PIXELS
|
||||
* before calling `decode()`.
|
||||
*/
|
||||
|
||||
import { parentPort, workerData } from 'node:worker_threads';
|
||||
|
||||
/** Max header-declared pixel count we will decode (64MP ≈ 256MB RGBA). */
|
||||
export const MAX_HEIC_DECODE_PIXELS = 64_000_000;
|
||||
|
||||
/** JPEG quality used for converted HEIC uploads (matches heic-convert's default). */
|
||||
export const HEIC_JPEG_QUALITY = 0.92;
|
||||
|
||||
export interface HeicWorkerInput {
|
||||
heicInput: Uint8Array;
|
||||
quality: number;
|
||||
}
|
||||
|
||||
export type HeicWorkerResult = { ok: true; data: Uint8Array } | { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Convert HEIC/HEIF bytes to JPEG bytes. Throws on non-HEIC input, empty
|
||||
* containers, over-limit dimensions, and non-JPEG encoder output.
|
||||
*/
|
||||
export async function convertHeicBufferToJpeg(input: Uint8Array, quality: number = HEIC_JPEG_QUALITY): Promise<Buffer> {
|
||||
const { default: decode } = await import('heic-decode');
|
||||
const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input.buffer, input.byteOffset, input.byteLength);
|
||||
const images = await decode.all({ buffer });
|
||||
try {
|
||||
if (images.length === 0) throw new Error('no image found in HEIC container');
|
||||
const { width, height } = images[0];
|
||||
if (
|
||||
!Number.isSafeInteger(width) ||
|
||||
!Number.isSafeInteger(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0 ||
|
||||
width * height > MAX_HEIC_DECODE_PIXELS
|
||||
) {
|
||||
throw new Error(
|
||||
`HEIC dimensions ${width}x${height} exceed the ${Math.floor(MAX_HEIC_DECODE_PIXELS / 1_000_000)}MP decode limit`
|
||||
);
|
||||
}
|
||||
const decoded = await images[0].decode();
|
||||
const { encode } = await import('jpeg-js');
|
||||
// Same output path as heic-convert's JPEG format (jpeg-js at quality*100).
|
||||
const jpeg = encode(
|
||||
{ data: decoded.data, width: decoded.width, height: decoded.height },
|
||||
Math.floor(quality * 100)
|
||||
).data;
|
||||
const jpegBytes = Buffer.isBuffer(jpeg) ? jpeg : Buffer.from(jpeg);
|
||||
if (jpegBytes.length < 3 || jpegBytes[0] !== 0xff || jpegBytes[1] !== 0xd8 || jpegBytes[2] !== 0xff) {
|
||||
throw new Error('HEIC conversion did not produce JPEG bytes');
|
||||
}
|
||||
return jpegBytes;
|
||||
} finally {
|
||||
images.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Worker bootstrap ──────────────────────────────────────────────────────
|
||||
// Runs only when spawned by heic-jpeg-converter.ts: requires a parent port
|
||||
// AND our exact workerData shape, so importing this module from the main
|
||||
// thread (or a test runner's own worker pool) stays inert.
|
||||
const request = workerData as HeicWorkerInput | null | undefined;
|
||||
if (parentPort && request && request.heicInput instanceof Uint8Array && typeof request.quality === 'number') {
|
||||
const port = parentPort;
|
||||
try {
|
||||
const jpegBytes = await convertHeicBufferToJpeg(request.heicInput, request.quality);
|
||||
port.postMessage({ ok: true, data: jpegBytes } satisfies HeicWorkerResult);
|
||||
} catch (err: unknown) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
port.postMessage({ ok: false, error } satisfies HeicWorkerResult);
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,19 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
// Use get() instead of has() so refreshOnGet extends the TTL on active sessions
|
||||
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
|
||||
if (sessionToken && authSessions.get(sessionToken) !== undefined) {
|
||||
// Sliding cookie: re-issue on every authenticated request so the browser
|
||||
// cookie lifetime tracks the server-side sliding TTL (refreshOnGet above).
|
||||
// Without this the cookie has a fixed lifetime from login; the browser
|
||||
// drops it mid-use, the next request arrives cookie-less and falls through
|
||||
// to Basic Auth — popping the native username/password dialog, which reads
|
||||
// as a random logout while actively working.
|
||||
reply.setCookie(AUTH_COOKIE_NAME, sessionToken, {
|
||||
httpOnly: true,
|
||||
secure: https,
|
||||
sameSite: 'lax',
|
||||
maxAge: AUTH_SESSION_TTL_MS / 1000, // seconds
|
||||
path: '/',
|
||||
});
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,16 @@ export function isLoopbackBindHost(host: string): boolean {
|
||||
*/
|
||||
export const DEFAULT_TRUSTED_HOST_SUFFIXES = ['.ts.net', '.trycloudflare.com', '.cfargotunnel.com'];
|
||||
|
||||
/**
|
||||
* Container-to-host gateway aliases (Docker / Podman). A hook `curl` from INSIDE a
|
||||
* docker case carries `Host: host.docker.internal:<port>` (the derived
|
||||
* CODEMAN_API_URL), so the always-on host guard must allow it or every in-container
|
||||
* hook is blocked 403. These names only resolve to the host from within a
|
||||
* container's network namespace, so they are not a DNS-rebinding surface for a
|
||||
* normal browser. Both engines' aliases are allowed so a mixed fleet keeps working.
|
||||
*/
|
||||
export const DOCKER_HOST_GATEWAY_ALIASES = ['host.docker.internal', 'host.containers.internal'];
|
||||
|
||||
/** Policy inputs for the anti-DNS-rebinding Host allowlist + cross-site Origin guard. */
|
||||
export interface HostPolicy {
|
||||
/** The host the server is bound to (e.g. '127.0.0.1', '0.0.0.0', or a hostname). */
|
||||
@@ -98,6 +108,8 @@ function matchesHost(hostname: string, policy: HostPolicy): boolean {
|
||||
const bind = parseAuthorityHostname(policy.bindHost);
|
||||
if (bind && hostname === bind) return true;
|
||||
if (policy.tunnelHost && hostname === policy.tunnelHost) return true;
|
||||
// Docker/Podman container-to-host gateway aliases (for in-container hook curls).
|
||||
if (DOCKER_HOST_GATEWAY_ALIASES.includes(hostname)) return true;
|
||||
for (const suffix of DEFAULT_TRUSTED_HOST_SUFFIXES) {
|
||||
if (hostname === suffix.slice(1) || hostname.endsWith(suffix)) return true;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import type { ClaudeMode, NiceConfig } from '../../types.js';
|
||||
import type { StateStore } from '../../state-store.js';
|
||||
import type { TerminalHistoryConfig } from '../../config/terminal-history.js';
|
||||
|
||||
export interface ConfigPort {
|
||||
readonly store: StateStore;
|
||||
@@ -15,6 +16,7 @@ export interface ConfigPort {
|
||||
getGlobalNiceConfig(): Promise<NiceConfig | undefined>;
|
||||
getModelConfig(): Promise<{ defaultModel?: string; agentTypeOverrides?: Record<string, string> } | null>;
|
||||
getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }>;
|
||||
getTerminalHistoryConfig(): Promise<TerminalHistoryConfig>;
|
||||
getDefaultClaudeMdPath(): Promise<string | undefined>;
|
||||
getLightState(): unknown;
|
||||
getLightSessionsState(): unknown[];
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* @fileoverview Cron port — exposes the CronService to
|
||||
* route handlers via the shared route context.
|
||||
*/
|
||||
|
||||
import type { CronService } from '../../cron/cron-service.js';
|
||||
|
||||
export interface CronPort {
|
||||
readonly cron: CronService;
|
||||
}
|
||||
@@ -13,3 +13,4 @@ export type { ConfigPort } from './config-port.js';
|
||||
export type { InfraPort, ScheduledRun } from './infra-port.js';
|
||||
export type { AuthPort } from './auth-port.js';
|
||||
export type { OrchestratorPort } from './orchestrator-port.js';
|
||||
export type { CronPort } from './cron-port.js';
|
||||
|
||||
+962
-160
File diff suppressed because it is too large
Load Diff
@@ -111,6 +111,36 @@ function evaluateWebGLLongTaskTrip(recent, entries, now, config = WEBGL_FALLBACK
|
||||
return recent.length >= config.LONGTASK_COUNT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision for whether to skip the WebGL renderer at terminal init, and
|
||||
* whether to clear the auto-fallback sticky marker. Keeps the interaction
|
||||
* between device type, URL params, the sticky marker, and the user's settings
|
||||
* toggle in one testable place (terminal-ui.js calls this).
|
||||
*
|
||||
* Precedence (desktop only — mobile always skips):
|
||||
* 1. user toggle OFF -> skip (one-shot opt-out, sticky untouched)
|
||||
* 2. ?nowebgl -> skip (one-shot opt-out, sticky untouched)
|
||||
* 3. ?webgl=force -> enable + clear stale sticky marker
|
||||
* 4. toggle ON / untouched -> respect the auto-fallback sticky marker
|
||||
*
|
||||
* A stored `true` is treated like the untouched default here: the checkbox
|
||||
* ships checked on desktop, so any unrelated settings save stores `true` —
|
||||
* letting it clear the marker would permanently defeat the GPU-stall
|
||||
* auto-fallback safety net. The marker is only retired by ?webgl=force or by
|
||||
* a real OFF->ON toggle flip, which saveAppSettings() detects at save time.
|
||||
*
|
||||
* @param {{deviceType?: string, noWebglParam?: boolean, forceParam?: boolean,
|
||||
* stickyDisabled?: boolean, userPrefEnabled?: (boolean|undefined)}} [input]
|
||||
* @returns {{skip: boolean, clearSticky: boolean}}
|
||||
*/
|
||||
function shouldSkipWebGL(input = {}) {
|
||||
if (input.deviceType !== 'desktop') return { skip: true, clearSticky: false };
|
||||
if (input.userPrefEnabled === false) return { skip: true, clearSticky: false };
|
||||
if (input.noWebglParam) return { skip: true, clearSticky: false };
|
||||
if (input.forceParam) return { skip: false, clearSticky: true };
|
||||
return { skip: !!input.stickyDisabled, clearSticky: false };
|
||||
}
|
||||
|
||||
// Expose for tests. `const` declarations at the top of a non-module script
|
||||
// are global lexical bindings but not `window` properties, so explicit
|
||||
// assignment is the test-visible API surface.
|
||||
@@ -126,12 +156,40 @@ function shouldAutoWrapTabs(input) {
|
||||
return scrollWidth > clientWidth + 1;
|
||||
}
|
||||
|
||||
// COD-134 — Terminal WebSocket reconnect policy.
|
||||
//
|
||||
// Decide what to do after a terminal WebSocket closes, given the close `code`
|
||||
// and `attempt` (0-based count of consecutive reconnects already made):
|
||||
// - transient closes (code < 4004: 1000/1001/1005/1006/etc.) → 'reconnect'
|
||||
// with exponential backoff (0 on the first attempt; the caller adds jitter),
|
||||
// 250ms → 500 → 1000 → ... capped at 10s.
|
||||
// - 4004 (session not found) / 4009 (session terminated) → 'give-up': the
|
||||
// session is gone, retrying only wastes connections.
|
||||
// - 4008 (too many connections) and any other code >= 4004 → 'retry-fallback':
|
||||
// show the HTTP fallback but keep retrying on a bounded 5s timer so the
|
||||
// transport returns to WS once the transient condition clears (un-stick).
|
||||
// Pure: no DOM, no side effects.
|
||||
function planWsReconnect(code, attempt) {
|
||||
if (code === 4004 || code === 4009) {
|
||||
return { action: 'give-up', delayMs: 0 };
|
||||
}
|
||||
if (code >= 4004) {
|
||||
return { action: 'retry-fallback', delayMs: 5000 };
|
||||
}
|
||||
const delayMs = attempt <= 0 ? 0 : Math.min(250 * Math.pow(2, attempt - 1), 10000);
|
||||
return { action: 'reconnect', delayMs };
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
||||
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
||||
window.shouldSkipWebGL = shouldSkipWebGL;
|
||||
window.CodemanTabOverflow = {
|
||||
shouldAutoWrapTabs,
|
||||
};
|
||||
window.CodemanWsReconnect = {
|
||||
plan: planWsReconnect,
|
||||
};
|
||||
}
|
||||
|
||||
// Scheduler API — prioritize terminal writes over background UI updates.
|
||||
@@ -262,6 +320,7 @@ const SSE_EVENTS = {
|
||||
SESSION_LIMIT_PAUSE_SCHEDULED: 'session:limitPauseScheduled',
|
||||
SESSION_LIMIT_RESUME: 'session:limitResume',
|
||||
SESSION_LIMIT_RESUME_CANCELLED: 'session:limitResumeCancelled',
|
||||
SESSION_RESPAWN_BREAKER_TRIPPED: 'session:respawnBreakerTripped',
|
||||
SESSION_CLI_INFO: 'session:cliInfo',
|
||||
SESSION_MESSAGE: 'session:message',
|
||||
SESSION_INTERACTIVE: 'session:interactive',
|
||||
@@ -276,6 +335,12 @@ const SSE_EVENTS = {
|
||||
SCHEDULED_LOG: 'scheduled:log',
|
||||
SCHEDULED_DELETED: 'scheduled:deleted',
|
||||
|
||||
// Cron jobs
|
||||
CRON_JOBS_CHANGED: 'cron:jobsChanged',
|
||||
CRON_JOB_DELETED: 'cron:jobDeleted',
|
||||
CRON_RUN_CREATED: 'cron:runCreated',
|
||||
CRON_RUN_UPDATED: 'cron:runUpdated',
|
||||
|
||||
// Respawn
|
||||
RESPAWN_STARTED: 'respawn:started',
|
||||
RESPAWN_STOPPED: 'respawn:stopped',
|
||||
@@ -409,6 +474,13 @@ const SSE_EVENTS = {
|
||||
CASE_LINKED: 'case:linked',
|
||||
CASE_DELETED: 'case:deleted',
|
||||
CASE_ORDER_CHANGED: 'case:order-changed',
|
||||
DOCKER_EXPORT_COMPLETE: 'docker:exportComplete',
|
||||
DOCKER_EXPORT_FAILED: 'docker:exportFailed',
|
||||
DOCKER_IMPORT_COMPLETE: 'docker:importComplete',
|
||||
DOCKER_IMAGE_BUILD_STARTED: 'docker:imageBuildStarted',
|
||||
DOCKER_IMAGE_BUILD_PROGRESS: 'docker:imageBuildProgress',
|
||||
DOCKER_IMAGE_BUILD_COMPLETE: 'docker:imageBuildComplete',
|
||||
DOCKER_IMAGE_BUILD_FAILED: 'docker:imageBuildFailed',
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* @fileoverview Cron Jobs UI mixed into
|
||||
* CodemanApp.prototype. Renders the job list + create/edit form in the
|
||||
* #cronModal, and reacts to cron:* SSE events.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js, api-client.js, constants.js (escapeHtml)
|
||||
*/
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
// ── SSE handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
_onCronJobsChanged(data) {
|
||||
if (data && Array.isArray(data.jobs)) {
|
||||
this._cronJobs = data.jobs;
|
||||
if (this._isCronOpen()) this.renderCronJobs();
|
||||
} else if (this._isCronOpen()) {
|
||||
this.refreshCron();
|
||||
}
|
||||
},
|
||||
|
||||
_onCronRunChanged() {
|
||||
// A run's status changed — refresh the list so lastStatus stays current.
|
||||
if (this._isCronOpen()) this.refreshCron();
|
||||
},
|
||||
|
||||
// ── Modal open/close ──────────────────────────────────────────────────────
|
||||
|
||||
_isCronOpen() {
|
||||
const el = document.getElementById('cronModal');
|
||||
return !!el && el.classList.contains('active');
|
||||
},
|
||||
|
||||
openCron() {
|
||||
const el = document.getElementById('cronModal');
|
||||
if (!el) return;
|
||||
el.classList.add('active');
|
||||
this.cancelCronJobForm();
|
||||
this.refreshCron();
|
||||
},
|
||||
|
||||
closeCron() {
|
||||
const el = document.getElementById('cronModal');
|
||||
if (el) el.classList.remove('active');
|
||||
},
|
||||
|
||||
async refreshCron() {
|
||||
const jobs = await this._apiJson('/api/cron/jobs');
|
||||
this._cronJobs = Array.isArray(jobs) ? jobs : [];
|
||||
this.renderCronJobs();
|
||||
},
|
||||
|
||||
// ── List rendering ────────────────────────────────────────────────────────
|
||||
|
||||
renderCronJobs() {
|
||||
const list = document.getElementById('cronJobList');
|
||||
if (!list) return;
|
||||
const jobs = this._cronJobs || [];
|
||||
if (jobs.length === 0) {
|
||||
list.innerHTML = '<div class="form-hint">No cron jobs yet. Click “+ New Job”.</div>';
|
||||
return;
|
||||
}
|
||||
const rows = jobs.map((j) => {
|
||||
const next = j.enabled ? this._fmtTime(j.nextRunAt) : '—';
|
||||
const last = this._fmtTime(j.lastRunAt);
|
||||
const status = j.lastStatus ? escapeHtml(j.lastStatus) : '—';
|
||||
return `
|
||||
<div class="cron-job-row">
|
||||
<div class="cron-job-main">
|
||||
<div class="cron-job-name">${escapeHtml(j.name || '(unnamed)')}
|
||||
<span class="cron-badge">${escapeHtml(j.agentType)}</span>
|
||||
<span class="cron-badge">${escapeHtml(this._fmtSchedule(j))}</span>
|
||||
${j.enabled ? '' : '<span class="cron-badge cron-badge-off">disabled</span>'}
|
||||
</div>
|
||||
<div class="cron-job-meta">
|
||||
<span title="${escapeHtml(j.workingDir || '')}">${escapeHtml(j.workingDir || '')}</span>
|
||||
· next: ${escapeHtml(next)} · last: ${escapeHtml(last)} · status: ${status}
|
||||
</div>
|
||||
</div>
|
||||
<div class="cron-job-actions">
|
||||
<button class="btn-toolbar btn-sm btn-primary" onclick="app.runCronJob('${j.id}')">Run Now</button>
|
||||
<button class="btn-toolbar btn-sm" onclick="app.toggleCronJob('${j.id}', ${j.enabled ? 'false' : 'true'})">${j.enabled ? 'Disable' : 'Enable'}</button>
|
||||
<button class="btn-toolbar btn-sm" onclick="app.editCronJob('${j.id}')">Edit</button>
|
||||
<button class="btn-toolbar btn-sm btn-danger" onclick="app.deleteCronJob('${j.id}')">Delete</button>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
list.innerHTML = rows.join('');
|
||||
},
|
||||
|
||||
_fmtTime(ts) {
|
||||
if (!ts) return '—';
|
||||
try {
|
||||
return new Date(ts).toLocaleString();
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
},
|
||||
|
||||
_fmtSchedule(j) {
|
||||
switch (j.scheduleType) {
|
||||
case 'once':
|
||||
return 'once';
|
||||
case 'interval':
|
||||
return `every ${j.intervalMinutes}m`;
|
||||
case 'daily':
|
||||
return `daily ${j.dailyTime || ''}`;
|
||||
case 'weekly': {
|
||||
const names = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const days = (j.weeklyDays || []).map((d) => names[d] || d).join(',');
|
||||
return `weekly ${days} ${j.weeklyTime || ''}`;
|
||||
}
|
||||
default:
|
||||
return j.scheduleType || '';
|
||||
}
|
||||
},
|
||||
|
||||
// ── Create / edit form ────────────────────────────────────────────────────
|
||||
|
||||
openCronJobForm(job) {
|
||||
const form = document.getElementById('cronJobForm');
|
||||
if (!form) return;
|
||||
document.getElementById('cronFormError').textContent = '';
|
||||
document.getElementById('cronFormTitle').textContent = job ? 'Edit Cron Job' : 'New Cron Job';
|
||||
document.getElementById('schJobId').value = job ? job.id : '';
|
||||
document.getElementById('schName').value = job ? job.name || '' : '';
|
||||
document.getElementById('schAgentType').value = job ? job.agentType || 'claude' : 'claude';
|
||||
document.getElementById('schWorkingDir').value = job ? job.workingDir || '' : '';
|
||||
document.getElementById('schLaunchCommand').value = job ? job.launchCommand || '' : '';
|
||||
document.getElementById('schPromptMode').value = job ? job.promptMode || 'inline_text' : 'inline_text';
|
||||
document.getElementById('schPromptText').value = job ? job.promptText || '' : '';
|
||||
document.getElementById('schPromptFilePath').value = job ? job.promptFilePath || '' : '';
|
||||
document.getElementById('schInputMode').value = job ? job.inputMode || 'typed' : 'typed';
|
||||
document.getElementById('schScheduleType').value = job ? job.scheduleType || 'once' : 'once';
|
||||
document.getElementById('schRunAt').value = job && job.runAt ? this._toLocalInput(job.runAt) : '';
|
||||
document.getElementById('schIntervalMinutes').value = job && job.intervalMinutes ? job.intervalMinutes : 60;
|
||||
document.getElementById('schDailyTime').value = job ? job.dailyTime || '' : '';
|
||||
document.getElementById('schWeeklyTime').value = job ? job.weeklyTime || '' : '';
|
||||
const weekly = (job && job.weeklyDays) || [];
|
||||
document.querySelectorAll('#schWeeklyDays input[type=checkbox]').forEach((cb) => {
|
||||
cb.checked = weekly.includes(Number(cb.value));
|
||||
});
|
||||
document.getElementById('schConcurrencyPolicy').value = job ? job.concurrencyPolicy || 'warn_only' : 'warn_only';
|
||||
document.getElementById('schAutoClosePrev').checked = job ? job.autoClosePreviousSession !== false : true;
|
||||
document.getElementById('schEnabled').checked = job ? !!job.enabled : true;
|
||||
document.getElementById('schNotes').value = job ? job.notes || '' : '';
|
||||
|
||||
this.onCronAgentTypeChange();
|
||||
this.onCronPromptModeChange();
|
||||
this.onCronScheduleTypeChange();
|
||||
form.classList.remove('hidden');
|
||||
},
|
||||
|
||||
editCronJob(id) {
|
||||
const job = (this._cronJobs || []).find((j) => j.id === id);
|
||||
if (job) this.openCronJobForm(job);
|
||||
},
|
||||
|
||||
cancelCronJobForm() {
|
||||
const form = document.getElementById('cronJobForm');
|
||||
if (form) form.classList.add('hidden');
|
||||
},
|
||||
|
||||
onCronAgentTypeChange() {
|
||||
// Launch command is only meaningful for shell mode (first input line).
|
||||
const isShell = document.getElementById('schAgentType').value === 'shell';
|
||||
document.getElementById('schLaunchCommandRow').classList.toggle('hidden', !isShell);
|
||||
},
|
||||
|
||||
onCronPromptModeChange() {
|
||||
const mode = document.getElementById('schPromptMode').value;
|
||||
document.getElementById('schPromptTextRow').classList.toggle('hidden', mode !== 'inline_text');
|
||||
document.getElementById('schPromptFileRow').classList.toggle('hidden', mode !== 'prompt_file_path');
|
||||
},
|
||||
|
||||
onCronScheduleTypeChange() {
|
||||
const t = document.getElementById('schScheduleType').value;
|
||||
document.getElementById('schRunAtRow').classList.toggle('hidden', t !== 'once');
|
||||
document.getElementById('schIntervalRow').classList.toggle('hidden', t !== 'interval');
|
||||
document.getElementById('schDailyRow').classList.toggle('hidden', t !== 'daily');
|
||||
document.getElementById('schWeeklyDaysRow').classList.toggle('hidden', t !== 'weekly');
|
||||
document.getElementById('schWeeklyTimeRow').classList.toggle('hidden', t !== 'weekly');
|
||||
},
|
||||
|
||||
_toLocalInput(ts) {
|
||||
// epoch-ms → 'YYYY-MM-DDTHH:MM' in local time for <input datetime-local>.
|
||||
const d = new Date(ts);
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
},
|
||||
|
||||
_collectCronForm() {
|
||||
const t = document.getElementById('schScheduleType').value;
|
||||
const promptMode = document.getElementById('schPromptMode').value;
|
||||
const body = {
|
||||
name: document.getElementById('schName').value.trim(),
|
||||
agentType: document.getElementById('schAgentType').value,
|
||||
workingDir: document.getElementById('schWorkingDir').value.trim(),
|
||||
promptMode,
|
||||
inputMode: document.getElementById('schInputMode').value,
|
||||
scheduleType: t,
|
||||
concurrencyPolicy: document.getElementById('schConcurrencyPolicy').value,
|
||||
autoClosePreviousSession: document.getElementById('schAutoClosePrev').checked,
|
||||
enabled: document.getElementById('schEnabled').checked,
|
||||
notes: document.getElementById('schNotes').value.trim() || undefined,
|
||||
};
|
||||
// Always sent for shell (an emptied field must clear a saved command on edit).
|
||||
if (body.agentType === 'shell') body.launchCommand = document.getElementById('schLaunchCommand').value.trim();
|
||||
if (promptMode === 'inline_text') {
|
||||
// Prompt delivery is single-line only; trailing newlines are harmless, strip them.
|
||||
body.promptText = document.getElementById('schPromptText').value.replace(/[\r\n]+$/, '');
|
||||
} else {
|
||||
body.promptFilePath = document.getElementById('schPromptFilePath').value.trim();
|
||||
}
|
||||
|
||||
if (t === 'once') {
|
||||
const v = document.getElementById('schRunAt').value;
|
||||
body.runAt = v ? new Date(v).getTime() : undefined;
|
||||
} else if (t === 'interval') {
|
||||
body.intervalMinutes = Number(document.getElementById('schIntervalMinutes').value);
|
||||
} else if (t === 'daily') {
|
||||
body.dailyTime = document.getElementById('schDailyTime').value;
|
||||
} else if (t === 'weekly') {
|
||||
body.weeklyTime = document.getElementById('schWeeklyTime').value;
|
||||
body.weeklyDays = Array.from(document.querySelectorAll('#schWeeklyDays input:checked')).map((cb) =>
|
||||
Number(cb.value)
|
||||
);
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async saveCronJob() {
|
||||
const errEl = document.getElementById('cronFormError');
|
||||
errEl.textContent = '';
|
||||
const body = this._collectCronForm();
|
||||
if (!body.name) {
|
||||
errEl.textContent = 'Name is required.';
|
||||
return;
|
||||
}
|
||||
if (!body.workingDir) {
|
||||
errEl.textContent = 'Working directory is required.';
|
||||
return;
|
||||
}
|
||||
if (body.promptText !== undefined && /[\r\n]/.test(body.promptText)) {
|
||||
errEl.textContent = 'Prompt must be a single line — multi-line prompts are not supported.';
|
||||
return;
|
||||
}
|
||||
const id = document.getElementById('schJobId').value;
|
||||
const res = id ? await this._apiPut(`/api/cron/jobs/${id}`, body) : await this._apiPost('/api/cron/jobs', body);
|
||||
if (!res || !res.ok) {
|
||||
let msg = 'Failed to save job.';
|
||||
try {
|
||||
const j = await res.json();
|
||||
if (j && j.error) msg = j.error;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
errEl.textContent = msg;
|
||||
return;
|
||||
}
|
||||
this.showToast?.(id ? 'Cron job updated' : 'Cron job created', 'success');
|
||||
this.cancelCronJobForm();
|
||||
this.refreshCron();
|
||||
},
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────────
|
||||
|
||||
async runCronJob(id) {
|
||||
const job = (this._cronJobs || []).find((j) => j.id === id);
|
||||
if (job) {
|
||||
const active = this._countActiveAgents(job.agentType);
|
||||
if (active > 0 && !confirm(`${active} ${job.agentType} session(s) already active. Run this job anyway?`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const res = await this._apiPost(`/api/cron/jobs/${id}/run`, {});
|
||||
if (res && res.ok) {
|
||||
this.showToast?.('Run started — opening session', 'success');
|
||||
let data = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const run = data && (data.data ? data.data.run : data.run);
|
||||
if (run && run.sessionId) this._focusCronSession(run.sessionId);
|
||||
this.refreshCron();
|
||||
} else {
|
||||
this.showToast?.('Failed to run job', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
_focusCronSession(sessionId) {
|
||||
// Best-effort: switch to the created session tab if it exists.
|
||||
if (this.sessions && this.sessions.has(sessionId) && typeof this.switchSession === 'function') {
|
||||
this.closeCron();
|
||||
this.switchSession(sessionId);
|
||||
}
|
||||
},
|
||||
|
||||
_countActiveAgents(agentType) {
|
||||
// Mirrors the server's countActiveAgents: only LIVE sessions count — a
|
||||
// tab whose CLI already exited (stopped/error) doesn't block anything.
|
||||
if (!this.sessions) return 0;
|
||||
let n = 0;
|
||||
for (const s of this.sessions.values()) {
|
||||
if (s && s.mode === agentType && s.status !== 'stopped' && s.status !== 'error') n++;
|
||||
}
|
||||
return n;
|
||||
},
|
||||
|
||||
async toggleCronJob(id, enabled) {
|
||||
const res = await this._apiPut(`/api/cron/jobs/${id}/enabled`, { enabled });
|
||||
if (res && res.ok) this.refreshCron();
|
||||
else this.showToast?.('Failed to update job', 'error');
|
||||
},
|
||||
|
||||
async deleteCronJob(id) {
|
||||
if (!confirm('Delete this cron job and its run history?')) return;
|
||||
const res = await this._apiDelete(`/api/cron/jobs/${id}`);
|
||||
if (res && res.ok) {
|
||||
this.showToast?.('Cron job deleted', 'success');
|
||||
this.refreshCron();
|
||||
} else {
|
||||
this.showToast?.('Failed to delete job', 'error');
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -4449,6 +4449,7 @@ var GestureController = class {
|
||||
// packages/gesture-control/src/codeman/entry.ts
|
||||
var TAB_SELECTOR = ".session-tab";
|
||||
var PANEL_SELECTOR = ".cg-float";
|
||||
var WINDOW_SELECTOR = ".subagent-window, .ultracode-window";
|
||||
var DOCK_SELECTOR = ".session-tabs";
|
||||
var CLICK_SELECTOR = "#runBtn, .btn-shell";
|
||||
var Z2 = 2147483e3;
|
||||
@@ -4476,6 +4477,8 @@ var GestureBridge = class {
|
||||
__publicField(this, "taps", /* @__PURE__ */ new Map());
|
||||
/** Live floating panels, keyed by session id (idempotent per id). */
|
||||
__publicField(this, "floats", /* @__PURE__ */ new Map());
|
||||
/** rAF coalescing for connector-line redraws while dragging an agent window. */
|
||||
__publicField(this, "connectorRedrawScheduled", false);
|
||||
injectStyles();
|
||||
this.surface = el("div", "cg-surface");
|
||||
this.canvas = el("canvas", "cg-canvas");
|
||||
@@ -4530,7 +4533,7 @@ var GestureBridge = class {
|
||||
await this.gc.start();
|
||||
this.running = true;
|
||||
this.button.classList.add("on");
|
||||
this.status.textContent = "on \u2014 pinch a tab or button";
|
||||
this.status.textContent = "on \u2014 pinch a tab, window, or button";
|
||||
} catch (err) {
|
||||
const msg = describeError(err);
|
||||
this.status.textContent = `failed: ${msg}`;
|
||||
@@ -4575,6 +4578,16 @@ var GestureBridge = class {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const win = this.hitClosest(x2, y2, WINDOW_SELECTOR);
|
||||
if (win) {
|
||||
const rect = win.getBoundingClientRect();
|
||||
win.style.bottom = "auto";
|
||||
win.classList.add("cg-win-grabbed");
|
||||
this.bringWindowToFront(win);
|
||||
this.grabs.set(hand, { kind: "window", el: win, dx: x2 - rect.left, dy: y2 - rect.top });
|
||||
this.status.textContent = "moving window";
|
||||
return;
|
||||
}
|
||||
const tab = this.hitClosest(x2, y2, TAB_SELECTOR);
|
||||
const id = tab?.dataset.id;
|
||||
if (tab && id) {
|
||||
@@ -4620,11 +4633,15 @@ var GestureBridge = class {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (grab?.kind === "window") {
|
||||
this.moveWindow(grab.el, x2 - grab.dx, y2 - grab.dy);
|
||||
return;
|
||||
}
|
||||
const tap = this.taps.get(hand);
|
||||
if (tap && Math.hypot(x2 - tap.ox, y2 - tap.oy) > TAP_CANCEL_PX) {
|
||||
tap.el.classList.remove("cg-tap-armed");
|
||||
this.taps.delete(hand);
|
||||
this.status.textContent = "on \u2014 pinch a tab or button";
|
||||
this.status.textContent = "on \u2014 pinch a tab, window, or button";
|
||||
}
|
||||
}
|
||||
onDrop(hand, x2, y2) {
|
||||
@@ -4645,6 +4662,18 @@ var GestureBridge = class {
|
||||
else this.flash("placed");
|
||||
return;
|
||||
}
|
||||
if (grab?.kind === "window") {
|
||||
this.grabs.delete(hand);
|
||||
grab.el.classList.remove("cg-win-grabbed");
|
||||
this.connectorRedrawScheduled = false;
|
||||
this.redrawWindowConnectors();
|
||||
try {
|
||||
window.app?.saveSubagentWindowStates?.();
|
||||
} catch {
|
||||
}
|
||||
this.flash("placed window");
|
||||
return;
|
||||
}
|
||||
const tap = this.taps.get(hand);
|
||||
if (tap) {
|
||||
this.taps.delete(hand);
|
||||
@@ -4694,6 +4723,54 @@ var GestureBridge = class {
|
||||
float.el.style.left = `${l}px`;
|
||||
float.el.style.top = `${t2}px`;
|
||||
}
|
||||
/** Move a dashboard-owned agent window by its top-left, clamped on-screen, then
|
||||
* redraw its connector line. The window self-positions via `style.left/top` and
|
||||
* app.js's connector redraw reads live rects, so this tracks without touching
|
||||
* app.js internals. Guards on `isConnected`: ultracode windows can be torn down
|
||||
* (SSE reconnect / auto-close) while still held. Clamps to `innerWidth/Height`,
|
||||
* which equals the *spanned* viewport in a multi-monitor window — so the window
|
||||
* can still travel across the physical monitor seam, just not off-screen. */
|
||||
moveWindow(el2, left, top) {
|
||||
if (!el2.isConnected) return;
|
||||
const w2 = el2.offsetWidth || 380;
|
||||
const h2 = el2.offsetHeight || 320;
|
||||
const l = Math.min(Math.max(4, left), Math.max(4, window.innerWidth - w2 - 4));
|
||||
const t2 = Math.min(Math.max(4, top), Math.max(4, window.innerHeight - h2 - 4));
|
||||
el2.style.left = `${l}px`;
|
||||
el2.style.top = `${t2}px`;
|
||||
this.redrawWindowConnectors();
|
||||
}
|
||||
/** Ask app.js to redraw all connector lines (subagent + ultracode), coalesced to
|
||||
* one per frame so per-frame drags don't thrash. `updateConnectionLines()` is
|
||||
* itself debounced in app.js, but we rAF-gate too in case an older dashboard
|
||||
* build isn't, and to no-op cleanly when app.js isn't present (standalone). */
|
||||
redrawWindowConnectors() {
|
||||
if (this.connectorRedrawScheduled) return;
|
||||
this.connectorRedrawScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.connectorRedrawScheduled = false;
|
||||
try {
|
||||
window.app?.updateConnectionLines?.();
|
||||
} catch {
|
||||
}
|
||||
});
|
||||
}
|
||||
/** Pop a grabbed window above its siblings using app.js's own z-counter, so a
|
||||
* picked-up window comes to the front like a real focus. Cosmetic + best-effort. */
|
||||
bringWindowToFront(el2) {
|
||||
const app = window.app;
|
||||
if (!app) return;
|
||||
try {
|
||||
if (el2.classList.contains("ultracode-window")) {
|
||||
app.ultracodeWindowZIndex = (app.ultracodeWindowZIndex ?? 1e3) + 1;
|
||||
el2.style.zIndex = String(app.ultracodeWindowZIndex);
|
||||
} else {
|
||||
app.subagentWindowZIndex = (app.subagentWindowZIndex ?? 1e3) + 1;
|
||||
el2.style.zIndex = String(app.subagentWindowZIndex);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
positionGhost(ghost, x2, y2) {
|
||||
ghost.style.left = `${x2}px`;
|
||||
ghost.style.top = `${y2}px`;
|
||||
@@ -4703,15 +4780,17 @@ var GestureBridge = class {
|
||||
if (grab.kind === "tab") {
|
||||
grab.ghost.remove();
|
||||
grab.tab.classList.remove("cg-grabbed");
|
||||
} else {
|
||||
} else if (grab.kind === "panel") {
|
||||
grab.panel.el.style.pointerEvents = "";
|
||||
grab.panel.el.classList.remove("cg-float-grabbed", "cg-redock");
|
||||
} else {
|
||||
grab.el.classList.remove("cg-win-grabbed");
|
||||
}
|
||||
}
|
||||
this.grabs.clear();
|
||||
for (const tap of this.taps.values()) tap.el.classList.remove("cg-tap-armed");
|
||||
this.taps.clear();
|
||||
document.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed`).forEach((t2) => t2.classList.remove("cg-grabbed", "cg-tap-armed"));
|
||||
document.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed, .cg-win-grabbed`).forEach((t2) => t2.classList.remove("cg-grabbed", "cg-tap-armed", "cg-win-grabbed"));
|
||||
}
|
||||
onStatus(fps, hands) {
|
||||
const { width, height } = this.canvas;
|
||||
@@ -4797,6 +4876,10 @@ function injectStyles() {
|
||||
.cg-status { color: #9aa0a6; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.session-tab.cg-grabbed { opacity: .35; outline: 2px dashed #4ade80; outline-offset: -2px; }
|
||||
.cg-tap-armed { outline: 2px solid #4ade80 !important; outline-offset: 2px; box-shadow: 0 0 0 4px rgba(74,222,128,.25) !important; }
|
||||
.subagent-window.cg-win-grabbed, .ultracode-window.cg-win-grabbed {
|
||||
outline: 2px solid #4ade80 !important; outline-offset: -2px;
|
||||
box-shadow: 0 12px 48px rgba(74,222,128,.5) !important;
|
||||
}
|
||||
.cg-float {
|
||||
position: fixed; left: 0; top: 0; width: ${FLOAT_W}px; height: ${FLOAT_H}px;
|
||||
z-index: ${Z2}; display: flex; flex-direction: column; overflow: hidden;
|
||||
|
||||
@@ -104,34 +104,78 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.execCommand('paste');
|
||||
},
|
||||
|
||||
async _uploadAndInsertImages(files) {
|
||||
// Max images accepted in one batch (paste / drop / mobile picker). Each is
|
||||
// uploaded as its own request, so 20 stays under the server's 30 uploads/min
|
||||
// rate limit while covering "select a bunch of photos at once".
|
||||
_maxBatchImages: 20,
|
||||
// How many uploads to run concurrently. Small enough that decoding several
|
||||
// large images through <canvas> at once won't OOM a phone, large enough that
|
||||
// 20 photos don't crawl through serially.
|
||||
_uploadConcurrency: 3,
|
||||
|
||||
async _uploadAndInsertImages(fileList) {
|
||||
const sessionId = this.activeSessionId;
|
||||
if (!sessionId) return;
|
||||
|
||||
this.showToast('Uploading ' + files.length + ' image' + (files.length > 1 ? 's' : '') + '...', 'info');
|
||||
let files = Array.from(fileList || []);
|
||||
if (files.length === 0) return;
|
||||
|
||||
const paths = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
// Re-encode to a standard JPEG/PNG before upload. Galleries on some
|
||||
// phones (notably Android/MIUI) hand back a WebP/HEIF whose filename and
|
||||
// MIME claim "image/jpeg", which passes the server's extension allowlist
|
||||
// but fails its magic-byte check ("bytes do not match declared type").
|
||||
// Decoding through the browser and re-encoding guarantees the bytes
|
||||
// match the extension we send.
|
||||
const normalized = await this._normalizeImageForUpload(file);
|
||||
const path = await this._uploadPasteImage(sessionId, normalized);
|
||||
paths.push(path);
|
||||
} catch (err) {
|
||||
this.showToast('Upload failed: ' + (err.message || 'unknown error'), 'error');
|
||||
// Cap the batch and tell the user what got dropped (no silent truncation).
|
||||
let capped = false;
|
||||
if (files.length > this._maxBatchImages) {
|
||||
files = files.slice(0, this._maxBatchImages);
|
||||
capped = true;
|
||||
}
|
||||
|
||||
const total = files.length;
|
||||
let done = 0;
|
||||
let failed = 0;
|
||||
const results = new Array(total); // preserve selection order for insertion
|
||||
const progress = () =>
|
||||
this.showToast(`Uploading ${Math.min(done + 1, total)}/${total} image${total > 1 ? 's' : ''}…`, 'info');
|
||||
progress();
|
||||
|
||||
// Bounded-concurrency worker pool over the file list.
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
for (;;) {
|
||||
const i = next++;
|
||||
if (i >= total) return;
|
||||
try {
|
||||
// Re-encode to a standard JPEG/PNG (and downscale very large images)
|
||||
// before upload. Galleries on some phones (notably Android/MIUI) hand
|
||||
// back a WebP/HEIF whose filename and MIME claim "image/jpeg", which
|
||||
// passes the server's extension allowlist but fails its magic-byte
|
||||
// check. Decoding through the browser and re-encoding guarantees the
|
||||
// bytes match the extension we send — and shrinks huge photos so they
|
||||
// fit the upload limit and iOS's <canvas> area cap.
|
||||
const normalized = await this._normalizeImageForUpload(files[i]);
|
||||
results[i] = await this._uploadPasteImage(sessionId, normalized);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.warn('Image upload failed:', err);
|
||||
results[i] = null;
|
||||
} finally {
|
||||
done++;
|
||||
if (done < total) progress();
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(this._uploadConcurrency, total) }, () => worker()));
|
||||
|
||||
const paths = results.filter(Boolean);
|
||||
if (paths.length > 0) {
|
||||
// Insert all paths in one shot, space-separated, in selection order.
|
||||
await this.sendInput(paths.join(' '));
|
||||
}
|
||||
|
||||
if (paths.length > 0) {
|
||||
const pathStr = paths.join(' ');
|
||||
await this.sendInput(pathStr);
|
||||
this.showToast(paths.length + ' image' + (paths.length > 1 ? 's' : '') + ' ready', 'success');
|
||||
}
|
||||
// Final status: successes, plus any failures / cap so nothing is silent.
|
||||
const parts = [];
|
||||
if (paths.length > 0) parts.push(`${paths.length} image${paths.length > 1 ? 's' : ''} ready`);
|
||||
if (failed > 0) parts.push(`${failed} failed`);
|
||||
if (capped) parts.push(`max ${this._maxBatchImages} per batch`);
|
||||
const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error';
|
||||
this.showToast(parts.join(' · ') || 'No images uploaded', tone);
|
||||
},
|
||||
|
||||
async _uploadPasteImage(sessionId, file) {
|
||||
@@ -176,12 +220,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
const height = img.naturalHeight;
|
||||
if (!width || !height) return file;
|
||||
|
||||
// Downscale very large images. Two reasons: (1) iOS Safari refuses to
|
||||
// render a <canvas> larger than ~16.7M px (it returns a blank/null
|
||||
// blob), so a 48MP photo would otherwise fail to re-encode and fall back
|
||||
// to the original — which then trips the server's magic-byte check for
|
||||
// HEIF mislabeled as JPEG. (2) It keeps multi-photo uploads fast and well
|
||||
// under the size limit. Cap the longest edge so area stays safely below
|
||||
// the canvas limit while still uploading a large, high-quality image.
|
||||
const MAX_EDGE = 4096;
|
||||
const scale = Math.min(1, MAX_EDGE / Math.max(width, height));
|
||||
const w = Math.max(1, Math.round(width * scale));
|
||||
const h = Math.max(1, Math.round(height * scale));
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return file;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
|
||||
const mime = toPng ? 'image/png' : 'image/jpeg';
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, mime, 0.92));
|
||||
|
||||
+535
-15
@@ -118,10 +118,13 @@
|
||||
</div>
|
||||
<button class="btn-icon-header btn-redraw-terminal btn-redraw-terminal--hidden" onclick="app.restoreTerminalSize()" title="Redraw terminal to fit current screen (Ctrl+Shift+R)" aria-label="Redraw terminal"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
|
||||
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button class="btn-icon-header btn-away-digest btn-away-digest--hidden" onclick="app.openAwayDigest()" title="Away Digest" aria-label="Open away digest"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg></button>
|
||||
<button class="btn-icon-header btn-session-manager btn-session-manager--hidden" onclick="app.openSessionManager()" title="Session Manager" aria-label="Open session manager"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg></button>
|
||||
<button class="btn-icon-header btn-attachments-history btn-attachments-history--hidden" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
|
||||
</button>
|
||||
<button class="btn-icon-header btn-file-viewer btn-file-viewer--hidden" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button>
|
||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
|
||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude plan usage limits">—</div>
|
||||
@@ -305,16 +308,59 @@
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run OpenCode
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-gemini" onclick="app.setRunMode('gemini'); app.runGemini()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run Gemini
|
||||
</button>
|
||||
</div>
|
||||
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
|
||||
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
|
||||
<div class="welcome-qr-url" id="welcomeQrUrl"></div>
|
||||
</div>
|
||||
<div class="history-sessions" id="historySessions" style="display:none">
|
||||
<h3 class="history-title">Resume Conversation</h3>
|
||||
<div class="search-panel" id="searchPanel">
|
||||
<div class="search-input-row">
|
||||
<input
|
||||
type="search"
|
||||
id="searchInput"
|
||||
class="search-input"
|
||||
placeholder="Search sessions, events, files…"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
maxlength="200"
|
||||
aria-label="Search across sessions"
|
||||
/>
|
||||
<button type="button" id="searchClearBtn" class="search-clear-btn" aria-label="Clear search" hidden>×</button>
|
||||
</div>
|
||||
<div class="search-filters" id="searchFilters">
|
||||
<div class="search-filter-group" role="group" aria-label="Source type filter">
|
||||
<button type="button" class="search-filter-chip active" data-type-filter="session">Sessions</button>
|
||||
<button type="button" class="search-filter-chip active" data-type-filter="event">Events</button>
|
||||
<button type="button" class="search-filter-chip active" data-type-filter="file">Files</button>
|
||||
</div>
|
||||
<div class="search-filter-group search-filter-secondary">
|
||||
<select id="searchCaseFilter" class="search-select" aria-label="Filter by case">
|
||||
<option value="">All cases</option>
|
||||
</select>
|
||||
<select id="searchStatusFilter" class="search-select" aria-label="Filter by session status">
|
||||
<option value="">Any status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="history">History</option>
|
||||
</select>
|
||||
<select id="searchDateFilter" class="search-select" aria-label="Filter by date range">
|
||||
<option value="">Any time</option>
|
||||
<option value="1">Past 24h</option>
|
||||
<option value="7">Past 7 days</option>
|
||||
<option value="30">Past 30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-results" id="searchResults" hidden></div>
|
||||
</div>
|
||||
<h3 class="history-title" id="historyTitle">Resume Conversation</h3>
|
||||
<div class="history-list" id="historyList"></div>
|
||||
</div>
|
||||
<p class="welcome-hint">Or press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> to start</p>
|
||||
<p class="welcome-hint">Or click Run to start</p>
|
||||
<button class="welcome-ralph-link" onclick="app.showRalphWizard()">Start Ralph Loop →</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -383,7 +429,7 @@
|
||||
<!-- Run AI -->
|
||||
<div class="toolbar-group">
|
||||
<div class="run-btn-group">
|
||||
<button class="btn-toolbar btn-run" id="runBtn" onclick="app.run()" title="Run (Ctrl+Enter)">
|
||||
<button class="btn-toolbar btn-run" id="runBtn" onclick="app.run()" title="Run">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
<span id="runBtnLabel">Run</span>
|
||||
</button>
|
||||
@@ -400,6 +446,9 @@
|
||||
<button class="run-mode-option" data-mode="codex" onclick="app.setRunMode('codex')">
|
||||
<span class="run-mode-dot codex"></span>Codex
|
||||
</button>
|
||||
<button class="run-mode-option" data-mode="gemini" onclick="app.setRunMode('gemini')">
|
||||
<span class="run-mode-dot gemini"></span>Gemini
|
||||
</button>
|
||||
<div class="run-mode-sep"></div>
|
||||
<div class="run-mode-header">Recent Sessions</div>
|
||||
<div class="run-mode-history" id="runModeHistory"></div>
|
||||
@@ -422,7 +471,22 @@
|
||||
<button class="tab-count-btn" onclick="app.incrementShellCount()">+</button>
|
||||
</div>
|
||||
<div class="case-select-group">
|
||||
<select id="quickStartCase" class="toolbar-select" title="Select case">
|
||||
<div class="case-combobox" id="quickStartCasePicker">
|
||||
<input
|
||||
type="text"
|
||||
id="quickStartCaseSearch"
|
||||
class="case-combobox-input"
|
||||
role="combobox"
|
||||
aria-controls="quickStartCaseList"
|
||||
aria-expanded="false"
|
||||
aria-autocomplete="list"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
title="Select case"
|
||||
>
|
||||
<div id="quickStartCaseList" class="case-combobox-list hidden" role="listbox"></div>
|
||||
</div>
|
||||
<select id="quickStartCase" class="toolbar-select case-native-select" title="Select case" aria-hidden="true" tabindex="-1">
|
||||
<option value="testcase">testcase</option>
|
||||
</select>
|
||||
<button class="btn-case-add" onclick="app.showCreateCaseModal()" title="Create new case">+</button>
|
||||
@@ -494,6 +558,7 @@
|
||||
<div class="toolbar-right">
|
||||
<!-- Orchestrator button hidden until feature is ready -->
|
||||
<!-- <button class="btn-toolbar btn-sm" onclick="app.toggleOrchestratorPanel()" title="Orchestrator Loop">⚙ Orchestrator</button> -->
|
||||
<button class="btn-toolbar btn-sm btn-cron" onclick="app.openCron()" title="Cron Jobs">⏰ Cron</button>
|
||||
<span class="version-display" id="versionDisplay" title="Codeman version">v0.0.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -507,17 +572,158 @@
|
||||
<button class="modal-close" onclick="app.closeHelp()" aria-label="Close help">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="shortcuts-grid">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>?</kbd></div><div>Show Help</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>V</kbd></div><div>Voice Input</div>
|
||||
<div><kbd>Escape</kbd></div><div>Close Panels</div>
|
||||
<section class="shortcut-section">
|
||||
<h4>Session</h4>
|
||||
<div class="shortcuts-grid">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
|
||||
<div><kbd>Ctrl/Cmd/Option</kbd>+<kbd>K</kbd></div><div>Find Open Session</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="shortcut-section">
|
||||
<h4>Tabs</h4>
|
||||
<div class="shortcuts-grid">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>{</kbd></div><div>Move Active Tab Left</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>}</kbd></div><div>Move Active Tab Right</div>
|
||||
<div><kbd>ArrowLeft</kbd></div><div>Focus Previous Tab</div>
|
||||
<div><kbd>ArrowRight</kbd></div><div>Focus Next Tab</div>
|
||||
<div><kbd>Home</kbd></div><div>Focus First Tab</div>
|
||||
<div><kbd>End</kbd></div><div>Focus Last Tab</div>
|
||||
<div><kbd>Enter</kbd> / <kbd>Space</kbd></div><div>Activate Focused Tab</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="shortcut-section">
|
||||
<h4>Terminal</h4>
|
||||
<div class="shortcuts-grid">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>R</kbd></div><div>Restore Terminal Size</div>
|
||||
<div><kbd>Shift</kbd>+<kbd>Enter</kbd></div><div>Insert Newline</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Enter</kbd></div><div>Insert Newline</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>V</kbd></div><div>Voice Input</div>
|
||||
<div><kbd>Shift</kbd>+<kbd>Wheel</kbd></div><div>Scroll local history (when mouse passthrough is active)</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="shortcut-section">
|
||||
<h4>Panels</h4>
|
||||
<div class="shortcuts-grid">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>?</kbd></div><div>Show Shortcuts</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>?</kbd></div><div>Show Shortcuts</div>
|
||||
<div><kbd>Escape</kbd></div><div>Close Panels</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cron Jobs Modal -->
|
||||
<div class="modal" id="cronModal">
|
||||
<div class="modal-backdrop" onclick="app.closeCron()"></div>
|
||||
<div class="modal-content modal-lg">
|
||||
<div class="modal-header">
|
||||
<h3>Cron Jobs</h3>
|
||||
<button class="modal-close" onclick="app.closeCron()" aria-label="Close cron">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="form-hint cron-modal-hint">Times use the server's local timezone.</p>
|
||||
<div class="cron-toolbar">
|
||||
<button class="btn-toolbar btn-primary" onclick="app.openCronJobForm()">+ New Job</button>
|
||||
<button class="btn-toolbar" onclick="app.refreshCron()">Refresh</button>
|
||||
</div>
|
||||
<!-- Job list -->
|
||||
<div id="cronJobList" class="cron-job-list"></div>
|
||||
|
||||
<!-- Create/Edit form (hidden until New/Edit) -->
|
||||
<div id="cronJobForm" class="cron-job-form hidden">
|
||||
<div class="cron-form-title" id="cronFormTitle">New Cron Job</div>
|
||||
<input type="hidden" id="schJobId">
|
||||
|
||||
<div class="form-section-header">Basics</div>
|
||||
<div class="form-row"><label>Name</label><input type="text" id="schName" placeholder="My nightly job"></div>
|
||||
<div class="form-row"><label>Agent Type</label>
|
||||
<select id="schAgentType" class="form-select" onchange="app.onCronAgentTypeChange()">
|
||||
<option value="claude">Claude</option>
|
||||
<option value="shell">Terminal / Shell</option>
|
||||
<option value="opencode">OpenCode</option>
|
||||
<option value="codex">Codex</option>
|
||||
<option value="gemini">Gemini</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
|
||||
<div class="form-row hidden" id="schLaunchCommandRow"><label>Launch Command</label><input type="text" id="schLaunchCommand" placeholder="Optional — runs as the first command in the new shell"></div>
|
||||
|
||||
<div class="form-section-header">Prompt</div>
|
||||
<div class="form-row"><label>Prompt Source</label>
|
||||
<select id="schPromptMode" class="form-select" onchange="app.onCronPromptModeChange()">
|
||||
<option value="inline_text">Inline text</option>
|
||||
<option value="prompt_file_path">Prompt file path</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row" id="schPromptTextRow"><label>Prompt</label><textarea id="schPromptText" rows="4" placeholder="Prompt to send into the session"></textarea></div>
|
||||
<div class="form-row hidden" id="schPromptFileRow"><label>Prompt File Path</label><input type="text" id="schPromptFilePath" placeholder="/absolute/path/to/prompt.md"></div>
|
||||
<div class="form-row"><label>Input Mode</label>
|
||||
<select id="schInputMode" class="form-select">
|
||||
<option value="typed">Typed (via tmux)</option>
|
||||
<option value="paste">Paste (direct)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-section-header">Schedule</div>
|
||||
<div class="form-row"><label>Schedule Type</label>
|
||||
<select id="schScheduleType" class="form-select" onchange="app.onCronScheduleTypeChange()">
|
||||
<option value="once">Once</option>
|
||||
<option value="interval">Interval</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row" id="schRunAtRow"><label>Run At</label><input type="datetime-local" id="schRunAt"></div>
|
||||
<div class="form-row hidden" id="schIntervalRow"><label>Every (minutes)</label><input type="number" id="schIntervalMinutes" min="1" value="60"></div>
|
||||
<div class="form-row hidden" id="schDailyRow"><label>Daily Time (HH:MM)</label><input type="time" id="schDailyTime"></div>
|
||||
<div class="form-row hidden" id="schWeeklyDaysRow"><label>Weekdays</label>
|
||||
<span id="schWeeklyDays" class="cron-weekdays">
|
||||
<label><input type="checkbox" value="0">Sun</label>
|
||||
<label><input type="checkbox" value="1">Mon</label>
|
||||
<label><input type="checkbox" value="2">Tue</label>
|
||||
<label><input type="checkbox" value="3">Wed</label>
|
||||
<label><input type="checkbox" value="4">Thu</label>
|
||||
<label><input type="checkbox" value="5">Fri</label>
|
||||
<label><input type="checkbox" value="6">Sat</label>
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-row hidden" id="schWeeklyTimeRow"><label>Weekly Time (HH:MM)</label><input type="time" id="schWeeklyTime"></div>
|
||||
|
||||
<div class="form-section-header">Options</div>
|
||||
<div class="form-row"><label>On auto-run, if same agent running</label>
|
||||
<select id="schConcurrencyPolicy" class="form-select">
|
||||
<option value="warn_only">Run anyway</option>
|
||||
<option value="skip_if_same_agent_running">Skip this run</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row form-row-switch cron-switch-row">
|
||||
<div class="cron-switch-text">
|
||||
<span class="cron-switch-label">Auto-close previous run's session</span>
|
||||
<span class="cron-switch-desc">Recurring schedules only: close the prior run's still-open session before the next run fires</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="schAutoClosePrev" checked><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="form-row form-row-switch cron-switch-row">
|
||||
<div class="cron-switch-text">
|
||||
<span class="cron-switch-label">Enabled</span>
|
||||
<span class="cron-switch-desc">Off keeps the job saved but skips its schedule</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="schEnabled" checked><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="form-row"><label>Notes</label><input type="text" id="schNotes" placeholder="Optional"></div>
|
||||
|
||||
<div id="cronFormError" class="form-hint cron-form-error"></div>
|
||||
<div class="cron-form-actions">
|
||||
<button class="btn-toolbar" onclick="app.cancelCronJobForm()">Cancel</button>
|
||||
<button class="btn-toolbar btn-primary" onclick="app.saveCronJob()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -949,6 +1155,7 @@
|
||||
<button class="modal-tab-btn" data-tab="settings-paths">Paths</button>
|
||||
<button class="modal-tab-btn" data-tab="settings-notifications">Notifications</button>
|
||||
<button class="modal-tab-btn" data-tab="settings-voice">Voice</button>
|
||||
<button class="modal-tab-btn" data-tab="settings-shortcuts">Shortcuts</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Display Tab -->
|
||||
@@ -964,8 +1171,25 @@
|
||||
<option value="og">OG Codeman</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-item" id="appSettingsWebglRendererItem" title="Use the GPU-accelerated WebGL terminal renderer (desktop only). Turn off to force the DOM renderer if you hit GPU glitches. Codeman also auto-falls-back to the DOM renderer after repeated GPU stalls.">
|
||||
<span class="settings-item-label">WebGL Renderer</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsWebglRenderer">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Input Section -->
|
||||
<div class="settings-section-header">Input</div>
|
||||
<div class="settings-item settings-item-multiline" title="Scroll the terminal's own local scrollback with a plain mouse wheel / two-finger swipe, instead of forwarding the wheel to the CLI's transcript. Turn on if scrolling back through history doesn't work (e.g. macOS trackpad in Claude sessions). Shift+wheel always reaches local scrollback regardless.">
|
||||
<div class="settings-item-text">
|
||||
<span class="settings-item-label">Wheel Scrolls Local History</span>
|
||||
<span class="settings-item-desc">Plain wheel/trackpad pages the terminal scrollback</span>
|
||||
</div>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsTerminalWheelLocal">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item settings-item-multiline" title="Shows typed characters instantly via overlay while forwarding keystrokes to the server in the background. Enables Tab completion, preserves input across tab switches, and protects against session crashes. Recommended for mobile and high-latency connections.">
|
||||
<div class="settings-item-text">
|
||||
<span class="settings-item-label">Local Echo</span>
|
||||
@@ -1045,6 +1269,13 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the file viewer button in header (opens the file browser panel for the active session)">
|
||||
<span class="settings-item-label">File Viewer</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowFileViewerButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the attachments button in header (opens the attachment history drawer)">
|
||||
<span class="settings-item-label">Attachments Button</span>
|
||||
<label class="switch switch-sm">
|
||||
@@ -1059,6 +1290,27 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the session manager button in the header (opens the session manager — sessions also stay reachable via the Ctrl+K palette)">
|
||||
<span class="settings-item-label">Session Manager Button</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowSessionButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the away digest button in the header (opens the 'what happened while you were away' summary)">
|
||||
<span class="settings-item-label">Away Digest Button</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowAwayDigestButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the Cron button in the footer toolbar (opens the cron jobs manager)">
|
||||
<span class="settings-item-label">Cron Button</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowCronButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show a terminal redraw button in the header — refit the terminal to the current screen size (useful when switching between devices)">
|
||||
<span class="settings-item-label">Redraw Terminal Button</span>
|
||||
<label class="switch switch-sm">
|
||||
@@ -1555,6 +1807,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shortcuts tab -->
|
||||
<div class="modal-tab-content hidden" id="settings-shortcuts">
|
||||
<div class="settings-grid">
|
||||
<div class="settings-section-header" style="grid-column: 1 / -1;">Keyboard Shortcuts</div>
|
||||
<p class="form-hint" style="grid-column: 1 / -1; margin: 0 0 0.5rem;">
|
||||
Customize keyboard shortcuts. Click the binding to capture a new key combination.
|
||||
</p>
|
||||
<div id="appSettingsShortcutsList" style="grid-column: 1 / -1;">
|
||||
<!-- Populated by app.renderShortcutSettingsList() -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn-toolbar" onclick="app.closeAppSettings()">Cancel</button>
|
||||
@@ -1563,6 +1828,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shortcut Overlay Modal -->
|
||||
<div class="modal shortcut-overlay-modal" id="shortcutOverlayModal" tabindex="-1">
|
||||
<div class="modal-backdrop" onclick="app.closeShortcutOverlay()"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">Keyboard Shortcuts</span>
|
||||
<button class="modal-close" onclick="app.closeShortcutOverlay()" aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="shortcutOverlayList"></div>
|
||||
<div class="shortcut-overlay-footer">
|
||||
<button class="btn btn-sm" onclick="app.closeShortcutOverlay(); app.showHelp()">Full shortcut reference</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Case Modal -->
|
||||
<div class="modal" id="createCaseModal">
|
||||
<div class="modal-backdrop" onclick="app.closeCreateCaseModal()"></div>
|
||||
@@ -1574,6 +1856,8 @@
|
||||
<div class="modal-tabs">
|
||||
<button class="modal-tab-btn active" data-tab="case-create">Create New</button>
|
||||
<button class="modal-tab-btn" data-tab="case-link">Link Existing</button>
|
||||
<button class="modal-tab-btn" data-tab="case-remote">Remote</button>
|
||||
<button class="modal-tab-btn" data-tab="case-docker">Docker</button>
|
||||
<button class="modal-tab-btn" data-tab="case-manage">Manage</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@@ -1588,6 +1872,53 @@
|
||||
<label>Description (optional)</label>
|
||||
<input type="text" id="newCaseDescription" placeholder="A brief description..." autocomplete="off">
|
||||
</div>
|
||||
<div class="form-row docker-quick-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker"> 🐳 Run in an isolated Docker container</label>
|
||||
<span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use.</span>
|
||||
</div>
|
||||
<details class="advanced-options docker-quick-settings" id="dockerQuickSettings">
|
||||
<summary>Container settings (optional, sensible defaults)</summary>
|
||||
<div class="advanced-options-content">
|
||||
<div class="form-row">
|
||||
<label>Template</label>
|
||||
<select id="quickDockerTemplate" onchange="app.applyDockerTemplate()">
|
||||
<option value="small">Small — 2 GB RAM, 1 CPU</option>
|
||||
<option value="medium" selected>Medium — 4 GB RAM, 2 CPU (default)</option>
|
||||
<option value="large">Large — 8 GB RAM, 4 CPU</option>
|
||||
<option value="gpu">GPU — 8 GB RAM, 4 CPU, all GPUs</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<span class="form-hint">Disk is elastic: storage grows automatically as data flows in (no fixed cap).</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Memory</label>
|
||||
<input type="text" id="quickDockerMemory" placeholder="4g" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>CPUs</label>
|
||||
<input type="text" id="quickDockerCpus" placeholder="2" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>GPUs</label>
|
||||
<input type="text" id="quickDockerGpus" placeholder="none (e.g. all, or 1)" autocomplete="off" spellcheck="false">
|
||||
<span class="form-hint">Needs the NVIDIA container toolkit on the host.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Network</label>
|
||||
<select id="quickDockerNetwork">
|
||||
<option value="bridge">bridge (internet on)</option>
|
||||
<option value="none">none (fully isolated)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Image</label>
|
||||
<input type="text" id="quickDockerImage" placeholder="codeman/agent:base" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="quickDockerMountCreds" checked> Mount host credentials (~/.claude etc.)</label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<!-- Link Existing Tab -->
|
||||
<div class="modal-tab-content hidden" id="case-link">
|
||||
@@ -1602,12 +1933,132 @@
|
||||
<span class="form-hint">Absolute path to an existing project folder, e.g. /home/you/my-project</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Remote Tab -->
|
||||
<div class="modal-tab-content hidden" id="case-remote">
|
||||
<div class="form-row">
|
||||
<label>Case Name</label>
|
||||
<input type="text" id="remoteCaseName" placeholder="gpu-work" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Name to identify this remote case in Codeman</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Remote Path</label>
|
||||
<input type="text" id="remoteCasePath" placeholder="/home/user/projects/work" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<span class="form-hint">Absolute path on the remote host. Codeman will not create or delete it.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Host ID</label>
|
||||
<input type="text" id="remoteHostId" placeholder="gpu-box" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>SSH Host/IP</label>
|
||||
<input type="text" id="remoteHostAddress" placeholder="10.0.0.42" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>SSH Username</label>
|
||||
<input type="text" id="remoteHostUsername" placeholder="ubuntu" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>SSH Port</label>
|
||||
<input type="number" id="remoteHostPort" placeholder="22" min="1" max="65535" autocomplete="off">
|
||||
<span class="form-hint">Optional. Leave blank for the default port 22.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Codex Command Override</label>
|
||||
<input type="text" id="remoteHostCodexCommand" placeholder="exec codx personal" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Optional. Leave blank to use exec codex on the remote host.</span>
|
||||
</div>
|
||||
<details class="advanced-options">
|
||||
<summary>Advanced SSH</summary>
|
||||
<div class="advanced-options-content">
|
||||
<div class="form-row">
|
||||
<label>Identity File</label>
|
||||
<input type="text" id="remoteHostIdentityFile" placeholder="~/.ssh/remote_ed25519" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<span class="form-hint">Optional. Path to a private key on this machine (passed to ssh -i). Never the key contents.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>SOCKS Proxy</label>
|
||||
<input type="text" id="remoteHostSocksProxy" placeholder="127.0.0.1:1080" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Optional. host:port of a SOCKS5 proxy (e.g. cloudflared). Routes ssh through it via a ProxyCommand.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Jump Host</label>
|
||||
<input type="text" id="remoteHostJumpHost" placeholder="bastion@10.0.0.1:22" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Optional. [user@]host[:port] for ssh -J (jump/bastion host).</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Extra -o Options</label>
|
||||
<textarea id="remoteHostExtraSshOptions" rows="3" placeholder="StrictHostKeyChecking=accept-new ConnectTimeout=10" autocomplete="off" autocapitalize="off" spellcheck="false"></textarea>
|
||||
<span class="form-hint">Optional. One KEY=VALUE per line; each becomes an ssh -o option.</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<!-- Docker Tab -->
|
||||
<div class="modal-tab-content hidden" id="case-docker">
|
||||
<div class="form-row">
|
||||
<label>Case Name</label>
|
||||
<input type="text" id="dockerCaseName" placeholder="sandbox" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Runs inside an isolated container. Multiple sessions can share the same container.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Workspace Path</label>
|
||||
<input type="text" id="dockerWorkspacePath" placeholder="/home/user/projects/sandbox" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<span class="form-hint">Absolute HOST directory, bind-mounted into the container. Codeman scaffolds CLAUDE.md + hooks into it.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Host ID</label>
|
||||
<input type="text" id="dockerHostId" placeholder="local" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">A reusable docker host profile. Reuse the same ID across cases to share settings.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Image</label>
|
||||
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/codex/gemini + tmux.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Network</label>
|
||||
<select id="dockerNetwork">
|
||||
<option value="bridge">bridge (internet on, default)</option>
|
||||
<option value="none">none (fully isolated, no network)</option>
|
||||
<option value="custom">custom bridge</option>
|
||||
</select>
|
||||
</div>
|
||||
<details class="advanced-options">
|
||||
<summary>Advanced container settings</summary>
|
||||
<div class="advanced-options-content">
|
||||
<div class="form-row">
|
||||
<label>Memory</label>
|
||||
<input type="text" id="dockerMemory" placeholder="4g" autocomplete="off" spellcheck="false">
|
||||
<span class="form-hint">Optional, e.g. 4g / 512m. Enforced as a hard OOM cap where the engine supports it.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>CPUs</label>
|
||||
<input type="text" id="dockerCpus" placeholder="2" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="dockerMountCredentials" checked> Mount host credentials (~/.claude etc.)</label>
|
||||
<span class="form-hint">On: your existing login just works (creds stay on the host, never in exports). Off: sealed sandbox, log in inside the container.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="dockerResumeOnStart" checked> Resume last conversation on relaunch</label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<span class="form-hint" id="dockerLinkStatus" style="margin-top: 8px; display: block;"></span>
|
||||
</div>
|
||||
<!-- Manage Tab -->
|
||||
<div class="modal-tab-content hidden" id="case-manage">
|
||||
<div class="case-manage-list" id="caseManageList">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<span class="form-hint" style="margin-top: 8px; display: block;">Use arrows to reorder. Changes are saved automatically.</span>
|
||||
<div id="dockerExportsSection" style="margin-top: 16px; border-top: 1px solid var(--border, #333); padding-top: 12px;">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:8px;">
|
||||
<strong style="font-size: 13px;">Docker exports</strong>
|
||||
<button class="btn-toolbar" onclick="app.refreshDockerExports()">Refresh</button>
|
||||
</div>
|
||||
<div class="case-manage-list" id="dockerExportsList"><span class="form-hint">No exports yet. Export a docker case from its tab.</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
@@ -1863,6 +2314,74 @@
|
||||
<div class="notif-drawer-empty" id="notifEmpty">No notifications</div>
|
||||
</div>
|
||||
|
||||
<!-- Away Digest Modal -->
|
||||
<div class="modal" id="awayDigestModal">
|
||||
<div class="modal-backdrop" onclick="app.closeAwayDigest()"></div>
|
||||
<div class="modal-content away-digest-modal">
|
||||
<div class="modal-header">
|
||||
<h3>Away Digest</h3>
|
||||
<div class="modal-header-actions">
|
||||
<button class="btn-toolbar btn-sm" onclick="app.loadAwayDigest()" title="Refresh away digest">↻ Refresh</button>
|
||||
<button class="modal-close" onclick="app.closeAwayDigest()" aria-label="Close away digest">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="away-digest-ranges" role="group" aria-label="Away digest range">
|
||||
<button class="filter-btn active" data-away-range="since-last-visit" onclick="app.setAwayDigestRange('since-last-visit')">Since last visit</button>
|
||||
<button class="filter-btn" data-away-range="1h" onclick="app.setAwayDigestRange('1h')">Last hour</button>
|
||||
<button class="filter-btn" data-away-range="today" onclick="app.setAwayDigestRange('today')">Today</button>
|
||||
<button class="filter-btn" data-away-range="24h" onclick="app.setAwayDigestRange('24h')">24h</button>
|
||||
<button class="filter-btn" data-away-range="custom" onclick="app.setAwayDigestRange('custom')">Custom</button>
|
||||
</div>
|
||||
<div class="away-digest-custom-range" id="awayDigestCustomRange">
|
||||
<label>
|
||||
Since
|
||||
<input type="datetime-local" id="awayDigestCustomSince">
|
||||
</label>
|
||||
<label>
|
||||
Until
|
||||
<input type="datetime-local" id="awayDigestCustomUntil">
|
||||
</label>
|
||||
</div>
|
||||
<div class="away-digest-summary" id="awayDigestSummary"></div>
|
||||
<div class="away-digest-freshness" id="awayDigestFreshness"></div>
|
||||
<div class="away-digest-sections" id="awayDigestSections">
|
||||
<p class="empty-message">Open the digest to load recent activity</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Command Palette Modal -->
|
||||
<div class="modal command-palette-modal" id="commandPaletteModal">
|
||||
<div class="modal-backdrop" onclick="app.closeCommandPalette()"></div>
|
||||
<div class="command-palette-shell" role="dialog" aria-modal="true" aria-labelledby="commandPaletteTitle">
|
||||
<div class="command-palette-input-row">
|
||||
<span class="command-palette-search-icon" aria-hidden="true">⌕</span>
|
||||
<input type="search" id="commandPaletteSearch" class="command-palette-search" placeholder="Search open sessions or start a new one" autocomplete="off" maxlength="160" aria-labelledby="commandPaletteTitle">
|
||||
<kbd>Esc</kbd>
|
||||
</div>
|
||||
<div class="command-palette-label" id="commandPaletteTitle">Open sessions</div>
|
||||
<div id="commandPaletteList" class="command-palette-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Manager Modal -->
|
||||
<div class="modal" id="sessionManagerModal">
|
||||
<div class="modal-backdrop" onclick="app.closeSessionManager()"></div>
|
||||
<div class="modal-content session-manager-modal">
|
||||
<div class="modal-header">
|
||||
<h3>Sessions</h3>
|
||||
<button class="modal-close" onclick="app.closeSessionManager()" aria-label="Close session manager">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="search" id="sessionManagerSearch" class="search-input" placeholder="Search sessions by name, prompt, or path…" autocomplete="off" maxlength="200">
|
||||
<div id="sessionManagerList" class="session-manager-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Token Stats Modal -->
|
||||
<div class="modal" id="tokenStatsModal">
|
||||
<div class="modal-backdrop" onclick="app.closeTokenStats()"></div>
|
||||
@@ -1971,6 +2490,7 @@
|
||||
<script defer src="respawn-ui.js"></script>
|
||||
<script defer src="ralph-panel.js"></script>
|
||||
<script defer src="orchestrator-panel.js"></script>
|
||||
<script defer src="cron-ui.js"></script>
|
||||
<script defer src="settings-ui.js"></script>
|
||||
<script defer src="panels-ui.js"></script>
|
||||
<script defer src="ultracode-panel.js"></script>
|
||||
|
||||
+139
-5
@@ -53,13 +53,46 @@ const CjkInput = (() => {
|
||||
let _initialized = false;
|
||||
let _composing = false;
|
||||
let _flushTimer = null;
|
||||
let _compositionFlushTimer = null;
|
||||
let _dictationActive = false;
|
||||
let _dictationDecayTimer = null;
|
||||
let _keydownSentAt = 0;
|
||||
let _keydownSentText = '';
|
||||
const _listeners = {};
|
||||
|
||||
const PHANTOM = '';
|
||||
|
||||
// ── Diagnostic trace (intermittent CJK-loss investigation) ──
|
||||
// In-memory ring buffer of every IME event + flush decision. Mirrored into
|
||||
// the crash-diag breadcrumbs (app.js), which persist to localStorage and
|
||||
// beacon to the server every 2s — after a repro, `GET /api/crash-diag`
|
||||
// shows the exact event sequence.
|
||||
// PRIVACY: because the trace leaves the page, it must stay CONTENT-FREE —
|
||||
// event types, booleans, key classes, and value LENGTHS only. Never log a
|
||||
// typed character or the textarea value (pasted secrets would be captured).
|
||||
const TRACE_MAX = 200;
|
||||
const _trace = [];
|
||||
/** Content-free value descriptor: real-text length + phantom presence. */
|
||||
function _vdesc(v) {
|
||||
const s = String(v == null ? '' : v);
|
||||
return `len=${_strip(s).length}${s.includes(PHANTOM) ? '+ph' : ''}`;
|
||||
}
|
||||
/** Content-free key descriptor: named keys (Enter, Process…) pass through; any single code point is typed content. */
|
||||
function _kdesc(key) {
|
||||
const k = String(key == null ? '' : key);
|
||||
return [...k].length === 1 ? 'printable' : k;
|
||||
}
|
||||
function _t(msg) {
|
||||
_trace.push(`${Date.now() % 1000000} ${msg}`);
|
||||
if (_trace.length > TRACE_MAX) _trace.shift();
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
if (typeof _crashDiag !== 'undefined') _crashDiag.log('CJK ' + msg);
|
||||
} catch {
|
||||
/* crash-diag unavailable (tests) — ring buffer still records */
|
||||
}
|
||||
}
|
||||
|
||||
// Two-tier debounce for non-composition input:
|
||||
// - KEYBOARD: short debounce (third-party IMEs like Doubao may not fire
|
||||
// composition events even for keyboard CJK typing)
|
||||
@@ -93,6 +126,16 @@ const CjkInput = (() => {
|
||||
}
|
||||
|
||||
function _resetToPhantom() {
|
||||
// Skip redundant writes: every programmatic value/selection mutation can
|
||||
// desync an Android IME's input session (InputConnection) — after which
|
||||
// the keyboard composes in its own UI but NO events ever reach the page.
|
||||
// Only touch the DOM when the content actually differs.
|
||||
if (_textarea.value === PHANTOM) {
|
||||
if (_textarea.selectionStart !== 1 || _textarea.selectionEnd !== 1) {
|
||||
_textarea.setSelectionRange(1, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_textarea.value = PHANTOM;
|
||||
_textarea.setSelectionRange(1, 1);
|
||||
}
|
||||
@@ -103,7 +146,17 @@ const CjkInput = (() => {
|
||||
|
||||
/** Flush textarea: send real text to PTY and reset to phantom */
|
||||
function _flush() {
|
||||
// Never flush mid-composition: reading the value would send the IME's
|
||||
// provisional text, and resetting the textarea cancels the in-progress
|
||||
// composition on iOS Safari — silently eating the character being typed.
|
||||
// Any committed-but-unflushed text stays in the textarea and is sent
|
||||
// together by the next compositionend flush.
|
||||
if (_composing) {
|
||||
_t('flush SKIP composing');
|
||||
return;
|
||||
}
|
||||
const val = _strip(_textarea.value);
|
||||
_t(`flush ${val ? 'send len=' + val.length : 'empty'}`);
|
||||
if (val) {
|
||||
_send(val);
|
||||
}
|
||||
@@ -150,12 +203,37 @@ const CjkInput = (() => {
|
||||
|
||||
_resetToPhantom();
|
||||
|
||||
_t('init v2-trace');
|
||||
|
||||
_listeners.mousedown = (e) => { e.stopPropagation(); };
|
||||
|
||||
// ── Wedged-IME recovery (Android ONLY) ──
|
||||
// Some Android IMEs (esp. 9-key Sogou/Xiaomi/Baidu) can wedge their
|
||||
// InputConnection: the keyboard composes in its own candidate bar but
|
||||
// delivers ZERO DOM events to the focused textarea. JS cannot detect
|
||||
// this (nothing fires) — but re-tapping the already-focused empty field
|
||||
// is the user's natural "it's stuck" gesture. A blur→focus cycle forces
|
||||
// the browser to restart the IME input session, which un-wedges it.
|
||||
// iOS is excluded: tapping the focused empty field there is normal
|
||||
// (paste callout, habitual tap), and the setTimeout refocus runs outside
|
||||
// the user-gesture stack, so the cycle would just misbehave.
|
||||
if (/Android/i.test(navigator.userAgent)) {
|
||||
_listeners.pointerdown = () => {
|
||||
if (document.activeElement === _textarea && !_composing && _isEffectivelyEmpty()) {
|
||||
_t('ime-reset (retap)');
|
||||
_textarea.blur();
|
||||
setTimeout(() => _textarea.focus(), 0);
|
||||
}
|
||||
};
|
||||
_textarea.addEventListener('pointerdown', _listeners.pointerdown);
|
||||
}
|
||||
_listeners.focus = () => {
|
||||
_t(`focus ${_vdesc(_textarea.value)}`);
|
||||
window.cjkActive = true;
|
||||
if (!_textarea.value) _resetToPhantom();
|
||||
};
|
||||
_listeners.blur = () => {
|
||||
_t(`blur composing=${_composing} ${_vdesc(_textarea.value)}`);
|
||||
// Keep cjkActive while CJK input is visible — iOS dictation and system
|
||||
// UI may steal focus temporarily, and clearing the flag during that
|
||||
// window lets xterm's onData process duplicated input.
|
||||
@@ -173,23 +251,32 @@ const CjkInput = (() => {
|
||||
|
||||
// ── Composition tracking (keyboard IME — works for CJK typing) ──
|
||||
_listeners.compositionstart = () => {
|
||||
_t(`compstart ${_vdesc(_textarea.value)}`);
|
||||
_composing = true;
|
||||
_cancelDebouncedFlush();
|
||||
// Leave textarea.value untouched — programmatic changes during
|
||||
// compositionstart cancel the IME composition on iOS Safari.
|
||||
};
|
||||
_listeners.compositionend = () => {
|
||||
_t(`compend ${_vdesc(_textarea.value)}`);
|
||||
_composing = false;
|
||||
_cancelDebouncedFlush();
|
||||
// Defer flush: some Android IMEs haven't committed text to textarea
|
||||
// when compositionend fires. setTimeout(0) ensures we read the final value.
|
||||
setTimeout(_flush, 0);
|
||||
// Tracked so destroy() can cancel it; if the next composition starts
|
||||
// before it runs, _flush's _composing guard turns it into a no-op.
|
||||
clearTimeout(_compositionFlushTimer);
|
||||
_compositionFlushTimer = setTimeout(() => {
|
||||
_compositionFlushTimer = null;
|
||||
_flush();
|
||||
}, 0);
|
||||
};
|
||||
_textarea.addEventListener('compositionstart', _listeners.compositionstart);
|
||||
_textarea.addEventListener('compositionend', _listeners.compositionend);
|
||||
|
||||
// ── Keydown: special keys work REGARDLESS of composition state ──
|
||||
_listeners.keydown = (e) => {
|
||||
_t(`keydown ${_kdesc(e.key)} kc=${e.keyCode} ic=${e.isComposing} c=${_composing}`);
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
_composing = false;
|
||||
@@ -246,6 +333,7 @@ const CjkInput = (() => {
|
||||
e.preventDefault();
|
||||
_send(e.key);
|
||||
_keydownSentAt = performance.now();
|
||||
_keydownSentText = e.key;
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
@@ -254,6 +342,23 @@ const CjkInput = (() => {
|
||||
|
||||
// ── Input event: primary path for virtual keyboards + dictation ──
|
||||
_listeners.input = (e) => {
|
||||
_t(`input ${e.inputType || '?'} ic=${e.isComposing} c=${_composing} ${_vdesc(_textarea.value)}`);
|
||||
// ── Stuck-composition recovery ──
|
||||
// Some IMEs (WeChat/Sogou keyboards) fire compositionstart without a
|
||||
// matching compositionend. A stale _composing=true blocks every flush
|
||||
// below — committed CJK text piles up in the textarea and never
|
||||
// reaches the PTY. When the event itself says composition is over
|
||||
// (isComposing false AND a non-composition inputType), trust it.
|
||||
if (
|
||||
_composing &&
|
||||
e.isComposing === false &&
|
||||
e.inputType !== 'insertCompositionText' &&
|
||||
e.inputType !== 'deleteCompositionText'
|
||||
) {
|
||||
_t('UNSTICK composing');
|
||||
_composing = false;
|
||||
}
|
||||
|
||||
// ── Backspace / delete detection ──
|
||||
if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteWordBackward') {
|
||||
if (_composing) return;
|
||||
@@ -283,11 +388,18 @@ const CjkInput = (() => {
|
||||
|
||||
if (_composing) return;
|
||||
|
||||
// Keydown handler already sent this character — just clear the
|
||||
// textarea echo that the IME inserted despite preventDefault.
|
||||
// Keydown handler already sent this character — clear the textarea
|
||||
// echo that the IME inserted despite preventDefault. Content-checked:
|
||||
// only a value matching the sent char is an echo. Anything else (e.g.
|
||||
// an IME committing CJK text right after a keydown-sent char) is real
|
||||
// input and must flow through to the debounced flush, not be dropped.
|
||||
if (performance.now() - _keydownSentAt < 100) {
|
||||
_resetToPhantom();
|
||||
return;
|
||||
const cur = _strip(_textarea.value);
|
||||
if (cur === '' || cur === _keydownSentText) {
|
||||
_t('echo-drop');
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Outside composition: keyboard typing or voice dictation.
|
||||
@@ -301,8 +413,30 @@ const CjkInput = (() => {
|
||||
return this;
|
||||
},
|
||||
|
||||
/**
|
||||
* Discard pending text and timers (e.g. on session switch, so stale text
|
||||
* can't flush into the wrong session). Restores the phantom so backspace
|
||||
* forwarding keeps working — unlike a raw `textarea.value = ''`.
|
||||
*/
|
||||
clear() {
|
||||
if (!_initialized || !_textarea) return;
|
||||
_t('clear (external)');
|
||||
_cancelDebouncedFlush();
|
||||
clearTimeout(_compositionFlushTimer);
|
||||
_compositionFlushTimer = null;
|
||||
_composing = false;
|
||||
_resetToPhantom();
|
||||
},
|
||||
|
||||
/** Diagnostic: recent IME event trace (ring buffer). */
|
||||
getTrace() {
|
||||
return _trace.slice();
|
||||
},
|
||||
|
||||
destroy() {
|
||||
_cancelDebouncedFlush();
|
||||
clearTimeout(_compositionFlushTimer);
|
||||
_compositionFlushTimer = null;
|
||||
clearTimeout(_dictationDecayTimer);
|
||||
_dictationActive = false;
|
||||
if (_textarea) {
|
||||
|
||||
@@ -58,7 +58,6 @@ const KeyboardAccessoryBar = {
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
|
||||
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
|
||||
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<path d="M19 9l-7 7-7-7"/>
|
||||
|
||||
+115
-5
@@ -162,7 +162,11 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
|
||||
.case-select-group {
|
||||
max-width: 150px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.case-combobox {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.toolbar-select {
|
||||
@@ -194,6 +198,27 @@ html.mobile-init .file-browser-panel {
|
||||
z-index: 1300;
|
||||
}
|
||||
|
||||
.command-palette-modal {
|
||||
padding: 10vh 0.75rem 0;
|
||||
}
|
||||
|
||||
.command-palette-shell {
|
||||
width: 100%;
|
||||
max-height: 74vh;
|
||||
}
|
||||
|
||||
.command-palette-input-row {
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.command-palette-input-row kbd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.command-palette-item {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.modal-tabs {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
@@ -434,11 +459,16 @@ html.mobile-init .file-browser-panel {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* Hide header settings gear and lifecycle log on mobile - settings moved to toolbar.
|
||||
/* Hide header settings gear, lifecycle log, away digest, and session manager on
|
||||
mobile - settings moved to toolbar; away digest and the session manager are
|
||||
secondary controls that don't belong on the cramped phone header (the session
|
||||
manager stays reachable via the Ctrl+K palette's "Browse all sessions" item).
|
||||
(The attachments button is opt-in / default-hidden everywhere via its own
|
||||
--hidden marker, so it needs no mobile-specific rule here.) */
|
||||
.btn-icon-header.btn-settings,
|
||||
.btn-icon-header.btn-lifecycle-log {
|
||||
.btn-icon-header.btn-lifecycle-log,
|
||||
.btn-icon-header.btn-away-digest,
|
||||
.btn-icon-header.btn-session-manager {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -777,6 +807,20 @@ html.mobile-init .file-browser-panel {
|
||||
border-color: rgba(16, 185, 129, 0.5);
|
||||
}
|
||||
|
||||
/* Gemini mode colors on mobile */
|
||||
.btn-toolbar.btn-run.mode-gemini,
|
||||
.btn-toolbar.btn-run-gear.mode-gemini {
|
||||
background: #10243f;
|
||||
border-color: rgba(96, 165, 250, 0.3);
|
||||
color: #dbeafe;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-run.mode-gemini:active,
|
||||
.btn-toolbar.btn-run-gear.mode-gemini:active {
|
||||
background: #174ea6;
|
||||
border-color: rgba(96, 165, 250, 0.5);
|
||||
}
|
||||
|
||||
/* Run mode dropdown menu — positioned above toolbar on mobile */
|
||||
.run-mode-menu {
|
||||
bottom: 100%;
|
||||
@@ -1163,6 +1207,44 @@ html.mobile-init .file-browser-panel {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.away-digest-modal {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.away-digest-ranges {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.away-digest-ranges .filter-btn {
|
||||
min-height: 38px;
|
||||
padding: 0.4rem 0.5rem;
|
||||
}
|
||||
|
||||
.away-digest-custom-range,
|
||||
.away-digest-custom-range.active {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.away-digest-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.away-digest-item {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.away-digest-action {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.modal-footer,
|
||||
.form-actions {
|
||||
padding: 0.75rem 1rem;
|
||||
@@ -1204,13 +1286,41 @@ html.mobile-init .file-browser-panel {
|
||||
|
||||
.response-viewer {
|
||||
padding-bottom: var(--safe-area-bottom, 0px);
|
||||
/* dvh tracks the visible viewport on iOS Safari (vh = large viewport and would clip
|
||||
the header/close button off-screen); the vh line is the old-engine fallback */
|
||||
max-height: 88vh;
|
||||
max-height: 92dvh;
|
||||
}
|
||||
|
||||
.response-viewer-body {
|
||||
font-size: 12px;
|
||||
padding: 12px;
|
||||
font-size: 14.5px;
|
||||
line-height: 1.65;
|
||||
padding: 16px 16px 24px;
|
||||
--rv-content-max: 100%;
|
||||
}
|
||||
|
||||
.response-viewer-body .rv-text pre,
|
||||
.response-viewer-body pre {
|
||||
/* Slightly smaller on mobile so diagrams fit better before scrolling */
|
||||
padding: 12px 14px;
|
||||
margin-left: -4px;
|
||||
margin-right: -4px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.response-viewer-body .rv-text pre code,
|
||||
.response-viewer-body pre code {
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.response-viewer-body .rv-text h1,
|
||||
.response-viewer-body > h1 { font-size: 1.35em; }
|
||||
.response-viewer-body .rv-text h2,
|
||||
.response-viewer-body > h2 { font-size: 1.2em; }
|
||||
.response-viewer-body .rv-text h3,
|
||||
.response-viewer-body > h3 { font-size: 1.08em; }
|
||||
|
||||
/* Compact welcome overlay for mobile */
|
||||
.welcome-content {
|
||||
max-width: calc(100vw - 1.5rem);
|
||||
|
||||
@@ -273,7 +273,7 @@ class NotificationManager {
|
||||
const readClass = n.read ? '' : ' unread';
|
||||
const countLabel = n.count > 1 ? `<span class="notif-item-count">×${n.count}</span>` : '';
|
||||
const sessionChip = n.sessionName ? `<span class="notif-item-session">${escapeHtml(n.sessionName)}</span>` : '';
|
||||
return `<div class="notif-item ${urgencyClass}${readClass}" data-notif-id="${n.id}" data-session-id="${n.sessionId || ''}" onclick="app.notificationManager.clickNotification('${escapeHtml(n.id)}')">
|
||||
return `<div class="notif-item ${urgencyClass}${readClass}" data-notif-id="${n.id}" data-session-id="${n.sessionId || ''}" onclick="app.notificationManager.clickNotification(${escapeHtml(JSON.stringify(n.id))})">
|
||||
<div class="notif-item-header">
|
||||
<span class="notif-item-title">${escapeHtml(n.title)}${countLabel}</span>
|
||||
<span class="notif-item-time">${this.relativeTime(n.timestamp)}</span>
|
||||
|
||||
@@ -392,10 +392,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
let actions = '';
|
||||
if (orchState === 'executing' || orchState === 'failed') {
|
||||
if (phase.status === 'pending') {
|
||||
actions += `<button class="orch-phase-btn" onclick="app.orchestratorSkipPhase('${phase.id}')" title="Skip">skip</button>`;
|
||||
actions += `<button class="orch-phase-btn" onclick="app.orchestratorSkipPhase(${escapeHtml(JSON.stringify(phase.id))})" title="Skip">skip</button>`;
|
||||
}
|
||||
if (phase.status === 'failed') {
|
||||
actions += `<button class="orch-phase-btn" onclick="app.orchestratorRetryPhase('${phase.id}')" title="Retry">retry</button>`;
|
||||
actions += `<button class="orch-phase-btn" onclick="app.orchestratorRetryPhase(${escapeHtml(JSON.stringify(phase.id))})" title="Retry">retry</button>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+736
-17
@@ -13,6 +13,15 @@
|
||||
* @loadorder 11 of 15 — loaded after settings-ui.js, before session-ui.js
|
||||
*/
|
||||
|
||||
const AWAY_DIGEST_LAST_VIEWED_KEY = 'codeman-away-digest-last-viewed';
|
||||
const AWAY_DIGEST_SECTIONS = [
|
||||
['needsAttention', 'Needs Attention'],
|
||||
['completed', 'Completed'],
|
||||
['stillRunning', 'Still Running'],
|
||||
['idle', 'Idle'],
|
||||
['informational', 'Informational'],
|
||||
];
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
_addActivityEntry(agentId, entry, maxSize = 50) {
|
||||
const activity = this.subagentActivity.get(agentId) || [];
|
||||
@@ -241,6 +250,686 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.openImagePopup(data);
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Command Palette (COD-153)
|
||||
// Fast Cmd/Ctrl+K switcher for currently open sessions, plus launch-new.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
shouldOpenCommandPaletteFromShortcut(e) {
|
||||
if (!e) return false;
|
||||
// Every palette chord requires Ctrl/Cmd/Alt (capture enforces the same for
|
||||
// rebinds), so plain typing exits before any registry work — this runs on
|
||||
// the document AND xterm keydown hot paths.
|
||||
if (!e.ctrlKey && !e.metaKey && !e.altKey) return false;
|
||||
|
||||
// Registry-aware chord check (COD-157): honors a rebound or disabled
|
||||
// palette shortcut. Falls back to the default Ctrl/Cmd/Alt+K chord when the
|
||||
// registry isn't available (isolated test harnesses).
|
||||
const registryAvailable =
|
||||
typeof this.getShortcutRegistry === 'function' && typeof this.matchesShortcutEvent === 'function';
|
||||
const palette = registryAvailable
|
||||
? this.getShortcutRegistry().find((s) => s.id === 'command-palette')
|
||||
: null;
|
||||
if (palette) {
|
||||
if (palette.disabled || !this.matchesShortcutEvent(e, palette)) return false;
|
||||
} else {
|
||||
const key = (e.key || '').toLowerCase();
|
||||
if (key !== 'k' && e.code !== 'KeyK') return false;
|
||||
// Don't hijack chords with extra modifiers (Ctrl+Shift+K is the Firefox
|
||||
// devtools console; matchesShortcutEvent applies the same rule above).
|
||||
if (e.shiftKey) return false;
|
||||
}
|
||||
|
||||
const target = e.target;
|
||||
if (!target) return true;
|
||||
const tagName = (target.tagName || '').toUpperCase();
|
||||
const className = typeof target.className === 'string' ? target.className : '';
|
||||
const isXtermHelper =
|
||||
target.classList?.contains?.('xterm-helper-textarea') || className.includes('xterm-helper-textarea');
|
||||
if (isXtermHelper) return true;
|
||||
if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') return false;
|
||||
if (target.isContentEditable) return false;
|
||||
if (typeof target.closest === 'function' && target.closest('[contenteditable="true"]')) return false;
|
||||
return true;
|
||||
},
|
||||
|
||||
openCommandPalette() {
|
||||
const modal = document.getElementById('commandPaletteModal');
|
||||
const search = document.getElementById('commandPaletteSearch');
|
||||
if (!modal || !search) return;
|
||||
|
||||
this.commandPaletteActiveIndex = 0;
|
||||
search.value = '';
|
||||
modal.classList.add('active');
|
||||
|
||||
this._wireCommandPalette();
|
||||
this.renderCommandPalette();
|
||||
|
||||
search.focus();
|
||||
search.select?.();
|
||||
},
|
||||
|
||||
closeCommandPalette() {
|
||||
const modal = document.getElementById('commandPaletteModal');
|
||||
if (modal) modal.classList.remove('active');
|
||||
},
|
||||
|
||||
_wireCommandPalette() {
|
||||
if (this._commandPaletteWired) return;
|
||||
this._commandPaletteWired = true;
|
||||
|
||||
const modal = document.getElementById('commandPaletteModal');
|
||||
const search = document.getElementById('commandPaletteSearch');
|
||||
const list = document.getElementById('commandPaletteList');
|
||||
|
||||
search?.addEventListener('input', () => {
|
||||
this.commandPaletteActiveIndex = 0;
|
||||
this.renderCommandPalette();
|
||||
});
|
||||
|
||||
search?.addEventListener('keydown', async (e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
this.moveCommandPaletteSelection(1);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
this.moveCommandPaletteSelection(-1);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
await this.activateCommandPaletteItem();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.closeCommandPalette();
|
||||
}
|
||||
});
|
||||
|
||||
modal?.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.closeCommandPalette();
|
||||
}
|
||||
});
|
||||
|
||||
list?.addEventListener?.('click', (e) => {
|
||||
const row = e.target?.closest?.('[data-command-index]');
|
||||
if (!row) return;
|
||||
this.commandPaletteActiveIndex = Number(row.dataset.commandIndex) || 0;
|
||||
void this.activateCommandPaletteItem();
|
||||
});
|
||||
},
|
||||
|
||||
buildCommandPaletteItems(query = '') {
|
||||
const needle = query.trim().toLowerCase();
|
||||
const orderedIds = [
|
||||
...(Array.isArray(this.sessionOrder) ? this.sessionOrder : []),
|
||||
...Array.from(this.sessions?.keys?.() || []).filter((id) => !this.sessionOrder?.includes?.(id)),
|
||||
];
|
||||
const seen = new Set();
|
||||
const sessionItems = [];
|
||||
|
||||
for (const sessionId of orderedIds) {
|
||||
if (seen.has(sessionId)) continue;
|
||||
seen.add(sessionId);
|
||||
const session = this.sessions?.get?.(sessionId);
|
||||
if (!session) continue;
|
||||
const title = this.getSessionName?.(session) || session.name || session.title || sessionId.slice(0, 8);
|
||||
const subtitleParts = [session.workingDir, session.mode, session.status].filter(Boolean);
|
||||
const haystack = [title, session.workingDir, session.mode, session.status, sessionId].filter(Boolean).join(' ').toLowerCase();
|
||||
if (needle && !haystack.includes(needle)) continue;
|
||||
sessionItems.push({
|
||||
id: `session:${sessionId}`,
|
||||
type: 'session',
|
||||
sessionId,
|
||||
title,
|
||||
subtitle: subtitleParts.join(' · '),
|
||||
});
|
||||
}
|
||||
|
||||
sessionItems.push(this._buildCommandPaletteNewSessionItem(query));
|
||||
sessionItems.push({ id: 'browse-sessions', type: 'browse-sessions', title: 'Browse all sessions…', subtitle: 'Open Session Manager' });
|
||||
return sessionItems;
|
||||
},
|
||||
|
||||
_buildCommandPaletteNewSessionItem(query = '') {
|
||||
const mode = this.runMode || this._runMode || 'claude';
|
||||
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini' };
|
||||
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
|
||||
return {
|
||||
id: 'new-session',
|
||||
type: 'new-session',
|
||||
caseName,
|
||||
title: 'New session',
|
||||
subtitle: `Run ${labels[mode] || mode} in ${caseName}`,
|
||||
};
|
||||
},
|
||||
|
||||
_findCommandPaletteCaseMatch(query = '') {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle || !Array.isArray(this.cases)) return null;
|
||||
|
||||
const scoreCase = (caseItem) => {
|
||||
const name = String(caseItem?.name || '').trim();
|
||||
if (!name) return 0;
|
||||
const haystack = [
|
||||
name,
|
||||
caseItem?.path,
|
||||
caseItem?.casePath,
|
||||
caseItem?.workingDir,
|
||||
caseItem?.remote?.path,
|
||||
caseItem?.remote?.hostId,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
const lowerName = name.toLowerCase();
|
||||
if (lowerName === needle) return 100;
|
||||
if (lowerName.startsWith(needle)) return 90;
|
||||
if (lowerName.includes(needle)) return 80;
|
||||
if (haystack.includes(needle)) return 60;
|
||||
return 0;
|
||||
};
|
||||
|
||||
let best = null;
|
||||
let bestScore = 0;
|
||||
for (const caseItem of this.cases) {
|
||||
const score = scoreCase(caseItem);
|
||||
if (score > bestScore) {
|
||||
best = caseItem;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return best?.name || null;
|
||||
},
|
||||
|
||||
renderCommandPalette() {
|
||||
const search = document.getElementById('commandPaletteSearch');
|
||||
const list = document.getElementById('commandPaletteList');
|
||||
if (!list) return;
|
||||
|
||||
const query = search?.value || '';
|
||||
const items = this.buildCommandPaletteItems(query);
|
||||
this.commandPaletteItems = items;
|
||||
this.commandPaletteActiveIndex = Math.max(0, Math.min(this.commandPaletteActiveIndex || 0, items.length - 1));
|
||||
|
||||
list.innerHTML = items
|
||||
.map((item, index) => {
|
||||
const active = index === this.commandPaletteActiveIndex ? ' active' : '';
|
||||
const icon = item.type === 'new-session' ? '+' : item.type === 'browse-sessions' ? '≡' : '›';
|
||||
const browse = item.type === 'browse-sessions' ? ' command-palette-item--browse' : '';
|
||||
return `
|
||||
<button class="command-palette-item${active}${browse}" type="button" data-command-index="${index}">
|
||||
<span class="command-palette-icon" aria-hidden="true">${icon}</span>
|
||||
<span class="command-palette-text">
|
||||
<span class="command-palette-title">${escapeHtml(item.title)}</span>
|
||||
<span class="command-palette-subtitle">${escapeHtml(item.subtitle || '')}</span>
|
||||
</span>
|
||||
</button>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
},
|
||||
|
||||
moveCommandPaletteSelection(delta) {
|
||||
const items = this.commandPaletteItems || this.buildCommandPaletteItems(document.getElementById('commandPaletteSearch')?.value || '');
|
||||
if (!items.length) return;
|
||||
this.commandPaletteActiveIndex = (this.commandPaletteActiveIndex + delta + items.length) % items.length;
|
||||
this.renderCommandPalette();
|
||||
},
|
||||
|
||||
async activateCommandPaletteItem(index = this.commandPaletteActiveIndex || 0) {
|
||||
const item = (this.commandPaletteItems || [])[index];
|
||||
if (!item) return;
|
||||
|
||||
this.closeCommandPalette();
|
||||
if (item.type === 'session' && item.sessionId) {
|
||||
await this.selectSession(item.sessionId);
|
||||
return;
|
||||
}
|
||||
if (item.type === 'browse-sessions') {
|
||||
this.openSessionManager();
|
||||
return;
|
||||
}
|
||||
if (item.type === 'new-session') {
|
||||
const caseSelect = document.getElementById('quickStartCase');
|
||||
if (caseSelect && item.caseName) {
|
||||
if (
|
||||
caseSelect.tagName === 'SELECT' &&
|
||||
typeof caseSelect.appendChild === 'function' &&
|
||||
!Array.from(caseSelect.options || []).some((option) => option.value === item.caseName)
|
||||
) {
|
||||
const option = document.createElement('option');
|
||||
option.value = item.caseName;
|
||||
option.textContent = item.caseName;
|
||||
caseSelect.appendChild(option);
|
||||
}
|
||||
// selectQuickStartCase keeps the searchable combobox, dir display, and
|
||||
// persisted last-used case in sync with the palette's pick (COD-151);
|
||||
// fall back to a bare value set when the picker mixin isn't loaded.
|
||||
if (typeof this.selectQuickStartCase === 'function') {
|
||||
this.selectQuickStartCase(item.caseName);
|
||||
} else {
|
||||
caseSelect.value = item.caseName;
|
||||
}
|
||||
}
|
||||
await this.run();
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Manager Modal (COD-121)
|
||||
// Unified session list (GET /api/sessions/unified) reachable mid-session,
|
||||
// with a server-side search box. Reuses the history item renderer; clicking
|
||||
// a live row switches to it, a history row resumes the conversation.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
async openSessionManager() {
|
||||
const modal = document.getElementById('sessionManagerModal');
|
||||
if (modal) {
|
||||
modal.classList.add('active');
|
||||
// Escape closes the modal even while focus is in the search input. A
|
||||
// modal-scoped listener is robust regardless of the global Escape chain
|
||||
// (which runs other close handlers first and can short-circuit). Wire once.
|
||||
if (!this._sessionManagerEscWired) {
|
||||
this._sessionManagerEscWired = true;
|
||||
modal.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.closeSessionManager();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure cases are loaded so item subtitles can show "#caseName" labels.
|
||||
// Mirror loadHistorySessions(): prefer already-loaded this.cases.
|
||||
if (!Array.isArray(this.cases) || this.cases.length === 0) {
|
||||
try {
|
||||
const r = await fetch('/api/cases');
|
||||
const d = r.ok ? await r.json() : null;
|
||||
this.cases = d?.data || [];
|
||||
} catch {
|
||||
this.cases = this.cases || [];
|
||||
}
|
||||
}
|
||||
|
||||
const search = document.getElementById('sessionManagerSearch');
|
||||
if (search) {
|
||||
// Wire the debounced search input once (lazy — the element exists by
|
||||
// the time the modal is first opened, and mixin methods are bound).
|
||||
if (!this._sessionManagerSearchWired) {
|
||||
this._sessionManagerSearchWired = true;
|
||||
search.addEventListener('input', () => {
|
||||
const value = search.value.trim();
|
||||
this._debouncedCall('sessionManagerSearch', () => this._loadSessionManagerList(value), 200);
|
||||
});
|
||||
}
|
||||
search.value = '';
|
||||
search.focus();
|
||||
}
|
||||
await this._loadSessionManagerList('');
|
||||
},
|
||||
|
||||
closeSessionManager() {
|
||||
const modal = document.getElementById('sessionManagerModal');
|
||||
if (modal) modal.classList.remove('active');
|
||||
},
|
||||
|
||||
/** Replace the Session Manager list body with a single status line. */
|
||||
_setSessionManagerMessage(list, message) {
|
||||
list.replaceChildren();
|
||||
const line = document.createElement('p');
|
||||
line.className = 'empty-message';
|
||||
line.textContent = message;
|
||||
list.appendChild(line);
|
||||
},
|
||||
|
||||
async _loadSessionManagerList(q = '') {
|
||||
this._sessionManagerQuery = q;
|
||||
const list = document.getElementById('sessionManagerList');
|
||||
if (!list) return;
|
||||
try {
|
||||
const url = '/api/sessions/unified?limit=200' + (q ? '&q=' + encodeURIComponent(q) : '');
|
||||
const res = await fetch(url);
|
||||
const data = await res.json().catch(() => null);
|
||||
// ApiResponse envelope: { success: true, data: { sessions, total } }.
|
||||
// Surface failures instead of rendering them as an empty result set.
|
||||
if (!res.ok || !data || data.success === false || !data.data) {
|
||||
this._setSessionManagerMessage(list, data?.error || `Failed to load sessions (HTTP ${res.status})`);
|
||||
return;
|
||||
}
|
||||
const sessions = data.data.sessions || [];
|
||||
list.replaceChildren();
|
||||
if (sessions.length === 0) {
|
||||
this._setSessionManagerMessage(list, q ? 'No sessions match your search' : 'No sessions found');
|
||||
return;
|
||||
}
|
||||
for (const s of sessions) {
|
||||
// Adapt UnifiedSessionItem (lastActivityAt epoch-ms, optional fields) to
|
||||
// the history-record shape _buildHistoryItem renders (lastModified date
|
||||
// string, sizeBytes, firstPrompt).
|
||||
const record = {
|
||||
sessionId: s.sessionId,
|
||||
workingDir: s.workingDir || '',
|
||||
sizeBytes: s.sizeBytes ?? 0,
|
||||
lastModified: new Date(s.lastActivityAt ?? s.createdAt ?? Date.now()).toISOString(),
|
||||
firstPrompt: s.firstPrompt || s.name || '',
|
||||
};
|
||||
const isLive = !!this.sessions?.has?.(s.sessionId);
|
||||
const item = this._buildHistoryItem(record, this.cases, {
|
||||
showViewAll: false,
|
||||
onActivate: () => {
|
||||
this.closeSessionManager();
|
||||
if (isLive) {
|
||||
void this.selectSession(s.sessionId);
|
||||
} else if (record.workingDir) {
|
||||
// History rows are keyed by the Claude conversation UUID; resumed
|
||||
// sessions carry theirs separately as claudeSessionId.
|
||||
void this.resumeHistorySession(s.claudeSessionId || s.sessionId, record.workingDir);
|
||||
}
|
||||
},
|
||||
});
|
||||
list.appendChild(item);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[_loadSessionManagerList]', err);
|
||||
this._setSessionManagerMessage(list, 'Failed to load sessions');
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Away Digest Modal
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
async openAwayDigest(range = 'since-last-visit') {
|
||||
this.awayDigestRange = range;
|
||||
this._awayDigestLoadedSuccessfully = false;
|
||||
this._awayDigestSinceLastVisitGeneratedAt = undefined;
|
||||
const modal = document.getElementById('awayDigestModal');
|
||||
if (modal) modal.classList.add('active');
|
||||
this.updateAwayDigestRangeControls();
|
||||
await this.loadAwayDigest();
|
||||
},
|
||||
|
||||
closeAwayDigest() {
|
||||
const modal = document.getElementById('awayDigestModal');
|
||||
const generatedAt = this._awayDigestSinceLastVisitGeneratedAt;
|
||||
if (Number.isFinite(generatedAt)) {
|
||||
try {
|
||||
localStorage.setItem(AWAY_DIGEST_LAST_VIEWED_KEY, String(generatedAt));
|
||||
} catch (err) {
|
||||
console.warn('Failed to save away digest last-viewed marker:', err);
|
||||
}
|
||||
}
|
||||
if (modal) modal.classList.remove('active');
|
||||
},
|
||||
|
||||
/**
|
||||
* COD-121: live-refresh the unified session list when sessions change
|
||||
* (created/updated/deleted via SSE). Only touches surfaces that are currently
|
||||
* showing — the open Session Manager modal and/or the visible welcome list —
|
||||
* and is debounced so an event burst collapses into one re-fetch. The current
|
||||
* search query is preserved.
|
||||
*/
|
||||
_onSessionListMaybeChanged() {
|
||||
const modal = document.getElementById('sessionManagerModal');
|
||||
if (modal && modal.classList.contains('active')) {
|
||||
this._debouncedCall(
|
||||
'sessionManagerRefresh',
|
||||
() => this._loadSessionManagerList(this._sessionManagerQuery || ''),
|
||||
400
|
||||
);
|
||||
}
|
||||
const welcome = document.getElementById('welcomeOverlay');
|
||||
if (welcome && welcome.classList.contains('visible')) {
|
||||
this._debouncedCall('welcomeHistoryRefresh', () => this.loadHistorySessions(), 600);
|
||||
}
|
||||
},
|
||||
|
||||
setAwayDigestRange(range) {
|
||||
this.awayDigestRange = range;
|
||||
this._awayDigestLoadedSuccessfully = false;
|
||||
this.updateAwayDigestRangeControls();
|
||||
this.loadAwayDigest();
|
||||
},
|
||||
|
||||
updateAwayDigestRangeControls() {
|
||||
const range = this.awayDigestRange || 'since-last-visit';
|
||||
document.querySelectorAll('[data-away-range]').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.awayRange === range);
|
||||
});
|
||||
const customRange = document.getElementById('awayDigestCustomRange');
|
||||
if (customRange) customRange.classList.toggle('active', range === 'custom');
|
||||
if (range === 'custom') this.ensureAwayDigestCustomDefaults();
|
||||
},
|
||||
|
||||
ensureAwayDigestCustomDefaults() {
|
||||
const sinceInput = document.getElementById('awayDigestCustomSince');
|
||||
const untilInput = document.getElementById('awayDigestCustomUntil');
|
||||
if (!sinceInput || !untilInput) return;
|
||||
|
||||
const now = new Date();
|
||||
if (!untilInput.value) untilInput.value = this.formatAwayDigestDateTimeLocal(now);
|
||||
if (!sinceInput.value) {
|
||||
const since = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
sinceInput.value = this.formatAwayDigestDateTimeLocal(since);
|
||||
}
|
||||
},
|
||||
|
||||
formatAwayDigestDateTimeLocal(date) {
|
||||
const pad = value => String(value).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
},
|
||||
|
||||
async loadAwayDigest() {
|
||||
const summaryEl = document.getElementById('awayDigestSummary');
|
||||
const freshnessEl = document.getElementById('awayDigestFreshness');
|
||||
const sectionsEl = document.getElementById('awayDigestSections');
|
||||
if (summaryEl) summaryEl.innerHTML = '<div class="away-digest-loading">Loading digest...</div>';
|
||||
if (freshnessEl) freshnessEl.textContent = '';
|
||||
if (sectionsEl) sectionsEl.innerHTML = '';
|
||||
|
||||
try {
|
||||
const range = this.awayDigestRange || 'since-last-visit';
|
||||
const params = new URLSearchParams({ range });
|
||||
|
||||
if (range === 'since-last-visit') {
|
||||
const lastViewed = this.readAwayDigestLastViewed();
|
||||
if (Number.isFinite(lastViewed)) params.set('lastViewed', String(lastViewed));
|
||||
}
|
||||
|
||||
if (range === 'custom') {
|
||||
this.ensureAwayDigestCustomDefaults();
|
||||
const since = this.readAwayDigestDateTimeInput('awayDigestCustomSince');
|
||||
const until = this.readAwayDigestDateTimeInput('awayDigestCustomUntil');
|
||||
if (!Number.isFinite(since)) {
|
||||
throw new Error('Choose a custom start time');
|
||||
}
|
||||
params.set('since', String(since));
|
||||
if (Number.isFinite(until)) params.set('until', String(until));
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/away-digest?${params.toString()}`);
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || 'Failed to load away digest');
|
||||
}
|
||||
|
||||
this._awayDigestLoadedSuccessfully = true;
|
||||
this._awayDigestGeneratedAt = data.digest.generatedAt;
|
||||
if (range === 'since-last-visit') {
|
||||
this._awayDigestSinceLastVisitGeneratedAt = data.digest.generatedAt;
|
||||
}
|
||||
this.renderAwayDigest(data.digest);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load away digest';
|
||||
console.error('Failed to fetch away digest:', err);
|
||||
if (summaryEl) summaryEl.innerHTML = '<div class="away-digest-load-error">Failed to load away digest</div>';
|
||||
if (sectionsEl) {
|
||||
sectionsEl.innerHTML = `<div class="empty-message">${escapeHtml(message)}</div>`;
|
||||
}
|
||||
this.showToast(message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
readAwayDigestLastViewed() {
|
||||
try {
|
||||
const value = localStorage.getItem(AWAY_DIGEST_LAST_VIEWED_KEY);
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
readAwayDigestDateTimeInput(id) {
|
||||
const input = document.getElementById(id);
|
||||
if (!input || !input.value) return undefined;
|
||||
const parsed = Date.parse(input.value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
},
|
||||
|
||||
renderAwayDigest(digest) {
|
||||
const summaryEl = document.getElementById('awayDigestSummary');
|
||||
const freshnessEl = document.getElementById('awayDigestFreshness');
|
||||
const sectionsEl = document.getElementById('awayDigestSections');
|
||||
if (!summaryEl || !freshnessEl || !sectionsEl) return;
|
||||
|
||||
const inputTokens = digest.totals.inputTokens || 0;
|
||||
const outputTokens = digest.totals.outputTokens || 0;
|
||||
const estimatedCost = digest.totals.estimatedCost || 0;
|
||||
summaryEl.innerHTML = `
|
||||
<div class="away-digest-card">
|
||||
<span class="away-digest-card-label">Needs Attention</span>
|
||||
<span class="away-digest-card-value">${digest.totals.needsAttention}</span>
|
||||
</div>
|
||||
<div class="away-digest-card">
|
||||
<span class="away-digest-card-label">Completed</span>
|
||||
<span class="away-digest-card-value">${digest.totals.completed}</span>
|
||||
</div>
|
||||
<div class="away-digest-card">
|
||||
<span class="away-digest-card-label">Active Sessions</span>
|
||||
<span class="away-digest-card-value">${digest.totals.activeSessions}</span>
|
||||
</div>
|
||||
<div class="away-digest-card">
|
||||
<span class="away-digest-card-label">Tokens</span>
|
||||
<span class="away-digest-card-value">${this.formatTokens(inputTokens + outputTokens)}</span>
|
||||
<span class="away-digest-card-cost">~$${estimatedCost.toFixed(2)}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const freshnessNotes = [];
|
||||
if (digest.dataFreshness.runSummariesLiveOnly || digest.dataFreshness.subagentsLiveOnly) {
|
||||
freshnessNotes.push('Run summaries and subagent completions use recent live state; lifecycle and token stats are persisted.');
|
||||
}
|
||||
if (digest.totals.tokenWindowPrecision === 'day') {
|
||||
freshnessNotes.push('Token totals are aggregated at day precision.');
|
||||
}
|
||||
freshnessEl.textContent = freshnessNotes.join(' ');
|
||||
|
||||
sectionsEl.innerHTML = AWAY_DIGEST_SECTIONS
|
||||
.map(([key, title]) => this.renderAwayDigestSection(title, digest.sections[key] || []))
|
||||
.join('');
|
||||
this.attachAwayDigestActions();
|
||||
},
|
||||
|
||||
renderAwayDigestSection(title, items) {
|
||||
const count = items.length;
|
||||
const body = count
|
||||
? items.map(item => this.renderAwayDigestItem(item)).join('')
|
||||
: '<div class="away-digest-empty">No items</div>';
|
||||
return `
|
||||
<section class="away-digest-section">
|
||||
<div class="away-digest-section-title">
|
||||
<h4>${escapeHtml(title)}</h4>
|
||||
<span>${count}</span>
|
||||
</div>
|
||||
${body}
|
||||
</section>
|
||||
`;
|
||||
},
|
||||
|
||||
renderAwayDigestItem(item) {
|
||||
const sourceLabel = this.formatAwayDigestSource(item.source);
|
||||
const sessionLabel = item.sessionName || item.sessionId || '';
|
||||
const detail = item.detail ? `<div class="away-digest-item-detail">${escapeHtml(item.detail)}</div>` : '';
|
||||
const action = item.link ? `
|
||||
<button class="away-digest-action"
|
||||
data-away-link-type="${escapeHtml(item.link.type)}"
|
||||
data-away-session-id="${escapeHtml(item.link.sessionId || '')}">
|
||||
Open
|
||||
</button>
|
||||
` : '';
|
||||
return `
|
||||
<article class="away-digest-item away-digest-${escapeHtml(item.severity)}">
|
||||
<div class="away-digest-item-main">
|
||||
<div class="away-digest-item-meta">
|
||||
<span>${escapeHtml(this.formatAwayDigestTimestamp(item.timestamp))}</span>
|
||||
<span>${escapeHtml(sourceLabel)}</span>
|
||||
${sessionLabel ? `<span>${escapeHtml(sessionLabel)}</span>` : ''}
|
||||
</div>
|
||||
<div class="away-digest-item-title">${escapeHtml(item.title)}</div>
|
||||
${detail}
|
||||
</div>
|
||||
${action}
|
||||
</article>
|
||||
`;
|
||||
},
|
||||
|
||||
attachAwayDigestActions() {
|
||||
const sectionsEl = document.getElementById('awayDigestSections');
|
||||
if (!sectionsEl) return;
|
||||
sectionsEl.querySelectorAll('[data-away-link-type]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
this.openAwayDigestItem(button.dataset.awayLinkType, button.dataset.awaySessionId || undefined);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async openAwayDigestItem(type, sessionId) {
|
||||
if (type === 'session' && sessionId) {
|
||||
await this.selectSession(sessionId);
|
||||
this.closeAwayDigest();
|
||||
return;
|
||||
}
|
||||
if (type === 'run_summary' && sessionId) {
|
||||
await this.openRunSummary(sessionId);
|
||||
this.closeAwayDigest();
|
||||
return;
|
||||
}
|
||||
if (type === 'lifecycle') {
|
||||
this.openLifecycleLog();
|
||||
this.closeAwayDigest();
|
||||
}
|
||||
},
|
||||
|
||||
formatAwayDigestTimestamp(timestamp) {
|
||||
if (!Number.isFinite(timestamp)) return '';
|
||||
return new Date(timestamp).toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
},
|
||||
|
||||
formatAwayDigestSource(source) {
|
||||
const labels = {
|
||||
lifecycle: 'Lifecycle',
|
||||
run_summary: 'Run Summary',
|
||||
status: 'Status',
|
||||
token_stats: 'Token Stats',
|
||||
subagent: 'Subagent',
|
||||
};
|
||||
return labels[source] || source;
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Token Statistics Modal
|
||||
@@ -753,8 +1442,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const agentIcon = teammateInfo ? `<span class="subagent-icon teammate-dot teammate-color-${teammateInfo.color}">●</span>` : '<span class="subagent-icon">🤖</span>';
|
||||
html.push(`
|
||||
<div class="subagent-item ${statusClass} ${isActive ? 'selected' : ''}${teammateInfo ? ' is-teammate' : ''}"
|
||||
onclick="app.selectSubagent('${escapeHtml(agent.agentId)}')"
|
||||
ondblclick="app.openSubagentWindow('${escapeHtml(agent.agentId)}')"
|
||||
onclick="app.selectSubagent(${escapeHtml(JSON.stringify(agent.agentId))})"
|
||||
ondblclick="app.openSubagentWindow(${escapeHtml(JSON.stringify(agent.agentId))})"
|
||||
title="Double-click to open tracking window">
|
||||
<div class="subagent-header">
|
||||
${agentIcon}
|
||||
@@ -762,8 +1451,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
${teammateBadge}
|
||||
${modelBadge}
|
||||
<span class="subagent-status ${statusClass}">${agent.status}</span>
|
||||
${canKill ? `<button class="subagent-kill-btn" onclick="event.stopPropagation(); app.killSubagent('${escapeHtml(agent.agentId)}')" title="Kill agent">✕</button>` : ''}
|
||||
<button class="subagent-window-btn" onclick="event.stopPropagation(); app.${hasWindow ? 'closeSubagentWindow' : 'openSubagentWindow'}('${escapeHtml(agent.agentId)}')" title="${hasWindow ? 'Close window' : 'Open in window'}">
|
||||
${canKill ? `<button class="subagent-kill-btn" onclick="event.stopPropagation(); app.killSubagent(${escapeHtml(JSON.stringify(agent.agentId))})" title="Kill agent">✕</button>` : ''}
|
||||
<button class="subagent-window-btn" onclick="event.stopPropagation(); app.${hasWindow ? 'closeSubagentWindow' : 'openSubagentWindow'}(${escapeHtml(JSON.stringify(agent.agentId))})" title="${hasWindow ? 'Close window' : 'Open in window'}">
|
||||
${hasWindow ? '✕' : '⧉'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -810,7 +1499,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="icon">${this.getToolIcon(a.tool)}</span>
|
||||
<span class="name">${escapeHtml(a.tool)}</span>
|
||||
<span class="detail">${escapeHtml(toolDetail.primary)}</span>
|
||||
${toolDetail.hasMore ? `<button class="tool-expand-btn" onclick="app.toggleToolParams('${escapeHtml(a.toolUseId)}')">▶</button>` : ''}
|
||||
${toolDetail.hasMore ? `<button class="tool-expand-btn" onclick="app.toggleToolParams(${escapeHtml(JSON.stringify(a.toolUseId))})">▶</button>` : ''}
|
||||
${toolDetail.hasMore ? `<div class="tool-params-expanded" id="tool-params-${escapeHtml(a.toolUseId)}" style="display:none;"><pre>${escapeHtml(JSON.stringify(a.fullInput || a.input, null, 2))}</pre></div>` : ''}
|
||||
</div>`;
|
||||
} else if (a.type === 'tool_result') {
|
||||
@@ -859,7 +1548,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="subagent-id" title="${escapeHtml(agent.description || agent.agentId)}">${escapeHtml(detailTitle.length > 60 ? detailTitle.substring(0, 60) + '...' : detailTitle)}</span>
|
||||
${modelBadge}
|
||||
<span class="subagent-status ${agent.status}">${agent.status}</span>
|
||||
<button class="subagent-transcript-btn" onclick="app.viewSubagentTranscript('${escapeHtml(agent.agentId)}')">
|
||||
<button class="subagent-transcript-btn" onclick="app.viewSubagentTranscript(${escapeHtml(JSON.stringify(agent.agentId))})">
|
||||
View Full Transcript
|
||||
</button>
|
||||
</div>
|
||||
@@ -1195,7 +1884,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
parentDiv.dataset.parentSession = parentSessionId;
|
||||
parentDiv.innerHTML = `
|
||||
<span class="parent-label">from</span>
|
||||
<span class="parent-name" onclick="app.selectSession('${escapeHtml(parentSessionId)}')">${escapeHtml(parentName)}</span>
|
||||
<span class="parent-name" onclick="app.selectSession(${escapeHtml(JSON.stringify(parentSessionId))})">${escapeHtml(parentName)}</span>
|
||||
`;
|
||||
header.insertAdjacentElement('afterend', parentDiv);
|
||||
}
|
||||
@@ -1687,7 +2376,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="status running">terminal</span>
|
||||
</div>
|
||||
<div class="subagent-window-actions">
|
||||
<button onclick="app.closeSubagentWindow('${escapeHtml(windowId)}')" title="Minimize to tab">─</button>
|
||||
<button onclick="app.closeSubagentWindow(${escapeHtml(JSON.stringify(windowId))})" title="Minimize to tab">─</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subagent-window-body teammate-terminal-body" id="subagent-window-body-${windowId}">
|
||||
@@ -2200,7 +2889,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const fileName = path.split('/').pop();
|
||||
html.push(`
|
||||
<span class="project-insight-filepath"
|
||||
onclick="app.openLogViewerWindow('${escapeHtml(path)}', '${escapeHtml(tool.sessionId)}')"
|
||||
onclick="app.openLogViewerWindow(${escapeHtml(JSON.stringify(path))}, ${escapeHtml(JSON.stringify(tool.sessionId))})"
|
||||
title="${escapeHtml(path)}">${escapeHtml(fileName)}</span>
|
||||
`);
|
||||
}
|
||||
@@ -2409,6 +3098,32 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// Header "File Viewer" button (opt-in via App Settings → Header Displays →
|
||||
// File Viewer). Toggles the file browser panel open/closed without a trip
|
||||
// through settings. Persists via the same `showFileBrowser` flag the Panels
|
||||
// section + the panel's own close (X) use, so the three stay in sync.
|
||||
toggleFileBrowserButton() {
|
||||
const panel = this.$('fileBrowserPanel');
|
||||
const isOpen = panel?.classList.contains('visible');
|
||||
const btn = document.querySelector('.btn-file-viewer');
|
||||
if (isOpen) {
|
||||
this.closeFileBrowserPanel();
|
||||
if (btn) btn.setAttribute('aria-expanded', 'false');
|
||||
return;
|
||||
}
|
||||
if (!this.activeSessionId) {
|
||||
this.showToast('Open a session to browse its files', 'info');
|
||||
return;
|
||||
}
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
settings.showFileBrowser = true;
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
const checkbox = document.getElementById('appSettingsShowFileBrowser');
|
||||
if (checkbox) checkbox.checked = true;
|
||||
this.applyMonitorVisibility();
|
||||
if (btn) btn.setAttribute('aria-expanded', 'true');
|
||||
},
|
||||
|
||||
closeFileBrowserPanel() {
|
||||
const panel = this.$('fileBrowserPanel');
|
||||
if (panel) {
|
||||
@@ -2441,6 +3156,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
settings.showFileBrowser = false;
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
const checkbox = document.getElementById('appSettingsShowFileBrowser');
|
||||
if (checkbox) checkbox.checked = false;
|
||||
const headerBtn = document.querySelector('.btn-file-viewer');
|
||||
if (headerBtn) headerBtn.setAttribute('aria-expanded', 'false');
|
||||
},
|
||||
|
||||
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) {
|
||||
@@ -3099,7 +3818,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="status streaming">streaming</span>
|
||||
</div>
|
||||
<div class="log-viewer-window-actions">
|
||||
<button onclick="app.closeLogViewerWindow('${escapeHtml(windowId)}')" title="Close">×</button>
|
||||
<button onclick="app.closeLogViewerWindow(${escapeHtml(JSON.stringify(windowId))})" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-viewer-window-body" id="log-viewer-body-${windowId}">
|
||||
@@ -3275,14 +3994,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="size-badge">${sizeKB} KB</span>
|
||||
</div>
|
||||
<div class="image-popup-actions">
|
||||
<button onclick="app.openImageInNewTab('${escapeHtml(imageUrl)}')" title="Open in new tab">↗</button>
|
||||
<button onclick="app.closeImagePopup('${escapeHtml(imageId)}')" title="Close">×</button>
|
||||
<button onclick="app.openImageInNewTab(${escapeHtml(JSON.stringify(imageUrl))})" title="Open in new tab">↗</button>
|
||||
<button onclick="app.closeImagePopup(${escapeHtml(JSON.stringify(imageId))})" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-popup-body">
|
||||
<img src="${imageUrl}" alt="${escapeHtml(fileName)}"
|
||||
onerror="this.parentElement.innerHTML='<div class=\\'image-error\\'>Failed to load image</div>'"
|
||||
onclick="app.openImageInNewTab('${escapeHtml(imageUrl)}')" />
|
||||
onclick="app.openImageInNewTab(${escapeHtml(JSON.stringify(imageUrl))})" />
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -3505,9 +4224,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
modelHtml = `<span class="monitor-model-badge ${modelShort}">${modelShort}</span>`;
|
||||
}
|
||||
|
||||
const sid = escapeHtml(muxSession.sessionId);
|
||||
const sid = escapeHtml(JSON.stringify(muxSession.sessionId));
|
||||
html += `
|
||||
<div class="process-item process-item-clickable" onclick="app.selectSession('${sid}')" title="Switch to session">
|
||||
<div class="process-item process-item-clickable" onclick="app.selectSession(${sid})" title="Switch to session">
|
||||
<span class="monitor-status-badge ${statusClass}">${statusLabel}</span>
|
||||
<div class="process-info">
|
||||
<div class="process-name">${modelHtml} ${escapeHtml(muxSession.name || muxSession.muxName)}</div>
|
||||
@@ -3520,7 +4239,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
</div>
|
||||
</div>
|
||||
<div class="process-actions">
|
||||
<button class="btn-toolbar btn-sm btn-danger" onclick="event.stopPropagation(); app.killMuxSession('${sid}')" title="Kill session">Kill</button>
|
||||
<button class="btn-toolbar btn-sm btn-danger" onclick="event.stopPropagation(); app.killMuxSession(${sid})" title="Kill session">Kill</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -3563,7 +4282,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
</div>
|
||||
</div>
|
||||
<div class="process-actions">
|
||||
${agent.status !== 'completed' ? `<button class="btn-toolbar btn-sm btn-danger" onclick="app.killSubagent('${escapeHtml(agent.agentId)}')" title="Kill agent">Kill</button>` : ''}
|
||||
${agent.status !== 'completed' ? `<button class="btn-toolbar btn-sm btn-danger" onclick="app.killSubagent(${escapeHtml(JSON.stringify(agent.agentId))})" title="Kill agent">Kill</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
+770
-90
File diff suppressed because it is too large
Load Diff
@@ -307,8 +307,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true;
|
||||
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true;
|
||||
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
|
||||
document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? false;
|
||||
document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
|
||||
document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue';
|
||||
// WebGL renderer (desktop only — mobile always uses the DOM renderer, so hide
|
||||
// the toggle there so it can't promise something that won't apply).
|
||||
document.getElementById('appSettingsWebglRenderer').checked = settings.webglRendererEnabled ?? defaults.webglRendererEnabled ?? true;
|
||||
const webglItem = document.getElementById('appSettingsWebglRendererItem');
|
||||
if (webglItem) webglItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none';
|
||||
document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? false;
|
||||
document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false;
|
||||
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
|
||||
@@ -319,6 +325,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
|
||||
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
||||
// Session Manager + Away Digest buttons default OFF; Cron button defaults ON.
|
||||
document.getElementById('appSettingsShowSessionButton').checked = settings.showSessionButton ?? defaults.showSessionButton ?? false;
|
||||
document.getElementById('appSettingsShowAwayDigestButton').checked = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false;
|
||||
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? true;
|
||||
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
|
||||
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
|
||||
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
||||
@@ -332,6 +342,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
|
||||
this.loadTunnelStatus();
|
||||
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||
document.getElementById('appSettingsTerminalWheelLocal').checked =
|
||||
settings.terminalWheelLocalScrollback ?? defaults.terminalWheelLocalScrollback ?? false;
|
||||
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
|
||||
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
|
||||
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
|
||||
@@ -466,6 +478,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
modal.querySelectorAll('.modal-tab-content').forEach(content => {
|
||||
content.classList.toggle('hidden', content.id !== tabName);
|
||||
});
|
||||
// The Shortcuts tab renders lazily so the list reflects the CURRENT
|
||||
// registry (defaults + overrides) every time it is opened.
|
||||
if (tabName === 'settings-shortcuts') this.renderShortcutSettingsList?.();
|
||||
},
|
||||
|
||||
closeAppSettings() {
|
||||
@@ -1400,6 +1415,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// only takes effect on reload — remember the prior value to decide below.
|
||||
const _prev = this.loadAppSettingsFromStorage();
|
||||
const _prevGestureEnabled = (_prev.gestureControlEnabled ?? false) === true;
|
||||
// WebGL toggle: default ON (desktop), so only an explicit stored false counts
|
||||
// as "previously off" — used below to detect a real OFF→ON flip.
|
||||
const _prevWebglEnabled = (_prev.webglRendererEnabled ?? true) === true;
|
||||
const settings = {
|
||||
defaultClaudeMdPath: document.getElementById('appSettingsClaudeMdPath').value.trim(),
|
||||
defaultWorkingDir: document.getElementById('appSettingsDefaultDir').value.trim(),
|
||||
@@ -1409,6 +1427,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
|
||||
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
|
||||
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
|
||||
showFileViewerButton: document.getElementById('appSettingsShowFileViewerButton').checked,
|
||||
showAttachmentsButton: document.getElementById('appSettingsShowAttachmentsButton').checked,
|
||||
showMonitor: document.getElementById('appSettingsShowMonitor').checked,
|
||||
showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked,
|
||||
@@ -1419,13 +1438,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
||||
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
||||
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
|
||||
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
|
||||
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
||||
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
||||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||||
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
|
||||
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
|
||||
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
|
||||
terminalWheelLocalScrollback: document.getElementById('appSettingsTerminalWheelLocal').checked,
|
||||
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
|
||||
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
|
||||
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
|
||||
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
||||
skin: document.getElementById('appSettingsSkin').value,
|
||||
@@ -1455,11 +1479,23 @@ Object.assign(CodemanApp.prototype, {
|
||||
// with no UI left to turn it back off. Preserve the prior stored preference.
|
||||
if (_prev.showTokenCount !== undefined) settings.showTokenCount = _prev.showTokenCount;
|
||||
if (_prev.showCost !== undefined) settings.showCost = _prev.showCost;
|
||||
// Shortcut overrides are edited from the Shortcuts tab (not rebuilt from the
|
||||
// general-settings DOM), so the fresh rebuild would drop them on every save.
|
||||
if (_prev.shortcutOverrides !== undefined) settings.shortcutOverrides = _prev.shortcutOverrides;
|
||||
|
||||
// Save to localStorage
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
this._updateLocalEchoState();
|
||||
|
||||
// A real OFF→ON flip of the WebGL toggle retires the GPU-stall auto-fallback
|
||||
// marker so the next reload actually re-tries WebGL. Only the transition
|
||||
// clears it — an incidental save with the checkbox default-checked must NOT
|
||||
// defeat the sticky safety net (shouldSkipWebGL treats stored true like the
|
||||
// untouched default at page load).
|
||||
if (!_prevWebglEnabled && settings.webglRendererEnabled) {
|
||||
try { localStorage.removeItem('codeman-webgl-disabled'); } catch {}
|
||||
}
|
||||
|
||||
// Save voice settings to localStorage + include in server payload for cross-device sync
|
||||
const voiceSettings = {
|
||||
apiKey: document.getElementById('voiceDeepgramKey').value.trim(),
|
||||
@@ -1570,6 +1606,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Strip device-specific DISPLAY keys so they never sync across devices —
|
||||
// localEcho/cjk/extendedKeyboard/skin are per-platform, and showPlanUsageLimits
|
||||
// is per-device too (desktop can show the usage chip while mobile stays hidden).
|
||||
// webglRendererEnabled is per-device as well (renderer choice is GPU-specific,
|
||||
// and syncing would leak mobile's hidden-checkbox false onto desktop); it's
|
||||
// also absent from SettingsUpdateSchema, which is .strict() — sending it
|
||||
// would 400 the whole settings PUT.
|
||||
// Telemetry COLLECTION is requested out-of-band via statusLineTelemetry (sent on
|
||||
// ENABLE only, so a device with the chip OFF never strips the exporter that
|
||||
// another device's chip depends on — see system-routes settings handler).
|
||||
@@ -1580,6 +1620,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
skin: _skin,
|
||||
showPlanUsageLimits: _pul,
|
||||
showAttachmentsButton: _ahb,
|
||||
showFileViewerButton: _fvb,
|
||||
webglRendererEnabled: _wgl,
|
||||
terminalWheelLocalScrollback: _twls,
|
||||
// Per-device header/toolbar button toggles — client-only, and absent from
|
||||
// SettingsUpdateSchema (.strict()), so sending them would 400 the PUT.
|
||||
showSessionButton: _ssb,
|
||||
showAwayDigestButton: _adb,
|
||||
showCronButton: _crb,
|
||||
...serverSettings
|
||||
} = settings;
|
||||
try {
|
||||
@@ -1736,7 +1784,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
showMultiMonitorButton: false,
|
||||
showPlanUsageLimits: false,
|
||||
showAttachmentsButton: false,
|
||||
showFileViewerButton: false,
|
||||
showRedrawButton: false,
|
||||
showSessionButton: false,
|
||||
showAwayDigestButton: false,
|
||||
showCronButton: true,
|
||||
// Input
|
||||
gestureControlEnabled: false,
|
||||
// Feature toggles - keep tracking on even on mobile
|
||||
@@ -1746,6 +1798,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
ralphTrackerEnabled: false,
|
||||
tabTwoRows: false,
|
||||
cjkInputEnabled: false,
|
||||
terminalWheelLocalScrollback: false, // mobile scrolls via touch, not wheel
|
||||
webglRendererEnabled: false, // mobile always uses the DOM renderer
|
||||
skin: 'daylight-blue',
|
||||
};
|
||||
}
|
||||
@@ -1847,6 +1901,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
attachmentsBtn.classList.toggle('btn-attachments-history--hidden', !showAttachmentsButton);
|
||||
}
|
||||
|
||||
// File Viewer header button — opt-in, default OFF. Marker class (base is
|
||||
// display:inline-flex !important); clicking it toggles the file browser panel.
|
||||
const showFileViewerButton = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? false;
|
||||
const fileViewerBtn = document.querySelector('.btn-file-viewer');
|
||||
if (fileViewerBtn) {
|
||||
fileViewerBtn.classList.toggle('btn-file-viewer--hidden', !showFileViewerButton);
|
||||
}
|
||||
|
||||
// Multi-monitor button — hidden by default (App Settings → Display → "Header
|
||||
// Displays"). The server renders the correct initial state on every reload;
|
||||
// this handles a live toggle from a settings save (no reload). Toggle the
|
||||
@@ -1882,6 +1944,29 @@ Object.assign(CodemanApp.prototype, {
|
||||
redrawBtn.classList.toggle('btn-redraw-terminal--hidden', !showRedrawButton);
|
||||
}
|
||||
|
||||
// Session Manager button — opt-in, hidden by default (App Settings → Display).
|
||||
// Marker class (base is display:inline-flex !important); phones keep it hidden
|
||||
// via mobile.css regardless. Sessions stay reachable via the Ctrl+K palette.
|
||||
const showSessionButton = settings.showSessionButton ?? defaults.showSessionButton ?? false;
|
||||
const sessionBtn = document.querySelector('.btn-session-manager');
|
||||
if (sessionBtn) {
|
||||
sessionBtn.classList.toggle('btn-session-manager--hidden', !showSessionButton);
|
||||
}
|
||||
|
||||
// Away Digest button — opt-in, hidden by default. Same marker pattern.
|
||||
const showAwayDigestButton = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false;
|
||||
const awayDigestBtn = document.querySelector('.btn-away-digest');
|
||||
if (awayDigestBtn) {
|
||||
awayDigestBtn.classList.toggle('btn-away-digest--hidden', !showAwayDigestButton);
|
||||
}
|
||||
|
||||
// Cron button (footer toolbar) — shown by default; hide when disabled.
|
||||
const showCronButton = settings.showCronButton ?? defaults.showCronButton ?? true;
|
||||
const cronBtn = document.querySelector('.btn-cron');
|
||||
if (cronBtn) {
|
||||
cronBtn.classList.toggle('btn-cron--hidden', !showCronButton);
|
||||
}
|
||||
|
||||
// Notification bell is retired (notifications live in Settings → Notifications
|
||||
// + the drawer); keep it hidden regardless of the notification-enabled state.
|
||||
const notifBtn = document.querySelector('.btn-notifications');
|
||||
@@ -2118,7 +2203,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||
'terminalWheelLocalScrollback',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
||||
]);
|
||||
// The plan-usage chip is a PER-DEVICE display setting (default OFF): desktop
|
||||
// can show it while mobile stays hidden. It used to sync, so an older
|
||||
@@ -2363,6 +2450,138 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// ─── Shortcut Settings (App Settings → Shortcuts tab) ────────────────────────
|
||||
// Renders the list of shortcuts with capture buttons for key rebinding,
|
||||
// and persists overrides under settings.shortcutOverrides (saved through
|
||||
// saveAppSettingsToStorage so the device key + settings cache stay coherent).
|
||||
|
||||
renderShortcutSettingsList() {
|
||||
const list = document.getElementById('appSettingsShortcutsList');
|
||||
if (!list) return;
|
||||
const registry = this.getShortcutRegistry
|
||||
? this.getShortcutRegistry()
|
||||
: typeof DEFAULT_SHORTCUTS !== 'undefined'
|
||||
? DEFAULT_SHORTCUTS
|
||||
: [];
|
||||
const overrides = this.readShortcutOverridesFromSettings();
|
||||
list.innerHTML = registry
|
||||
.map((shortcut) => {
|
||||
const bindingLabel = shortcut.displayBindings
|
||||
? shortcut.displayBindings.join(' / ')
|
||||
: (shortcut.bindings || []).map((b) => [...(b.modifiers || []), b.key || b.code || ''].join('+')).join(' / ');
|
||||
// Only registry entries dispatched through matchesShortcutEvent() are
|
||||
// configurable; fixed keys (Escape, tab arrows, …) render read-only.
|
||||
const configurable = !!shortcut.action && Array.isArray(shortcut.bindings);
|
||||
const overridden = !!overrides[shortcut.id];
|
||||
const controls = configurable
|
||||
? `<button type="button" class="shortcut-capture-btn" data-shortcut-action="capture" title="Capture new binding">Edit</button>
|
||||
<button type="button" class="shortcut-reset-btn" data-shortcut-action="reset" title="Reset to default"${overridden ? '' : ' disabled'}>Reset</button>
|
||||
<input class="shortcut-enabled-checkbox" type="checkbox" ${shortcut.disabled ? '' : 'checked'} data-shortcut-action="toggle" title="Enable/disable">`
|
||||
: '';
|
||||
return `<div class="shortcut-setting-row${configurable ? '' : ' shortcut-setting-row--fixed'}" data-shortcut-id="${escapeHtml(shortcut.id)}">
|
||||
<label class="shortcut-setting-label">${escapeHtml(shortcut.label)}</label>
|
||||
<input class="shortcut-binding-input" type="text" readonly value="${escapeHtml(bindingLabel)}" placeholder="(none)" data-id="${escapeHtml(shortcut.id)}">
|
||||
${controls}
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
this._wireShortcutSettingsList(list);
|
||||
},
|
||||
|
||||
// Delegated handlers (no inline onclick — registry ids never land inside a
|
||||
// JS string context, and the listeners survive re-renders).
|
||||
_wireShortcutSettingsList(list) {
|
||||
if (list.dataset.shortcutListenersAdded) return;
|
||||
list.dataset.shortcutListenersAdded = 'true';
|
||||
list.addEventListener('click', (e) => {
|
||||
const btn = e.target?.closest?.('[data-shortcut-action]');
|
||||
if (!btn) return;
|
||||
const id = btn.closest?.('[data-shortcut-id]')?.dataset?.shortcutId;
|
||||
if (!id) return;
|
||||
if (btn.dataset.shortcutAction === 'capture') this.startShortcutCapture(id);
|
||||
else if (btn.dataset.shortcutAction === 'reset') this.resetShortcutOverride(id);
|
||||
});
|
||||
list.addEventListener('change', (e) => {
|
||||
const box = e.target;
|
||||
if (!box?.matches?.('[data-shortcut-action="toggle"]')) return;
|
||||
const id = box.closest?.('[data-shortcut-id]')?.dataset?.shortcutId;
|
||||
if (id) this.toggleShortcutEnabled(id, box.checked);
|
||||
});
|
||||
},
|
||||
|
||||
readShortcutOverridesFromSettings() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
return settings.shortcutOverrides || {};
|
||||
},
|
||||
|
||||
startShortcutCapture(shortcutId) {
|
||||
const input = document.querySelector(`.shortcut-binding-input[data-id="${shortcutId}"]`);
|
||||
if (!input) return;
|
||||
input.value = 'Press keys…';
|
||||
input.focus();
|
||||
this._capturingShortcutId = shortcutId;
|
||||
// Persistent listener (NOT {once}) — the first keydown of a combo like
|
||||
// Ctrl+Shift+P is the modifier itself ('Control'), which must not end the
|
||||
// capture. The first non-modifier key completes it.
|
||||
const onCaptureKeydown = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Control' || e.key === 'Shift' || e.key === 'Alt' || e.key === 'Meta') return;
|
||||
input.removeEventListener('keydown', onCaptureKeydown);
|
||||
this.onShortcutCaptureKeydown(e, shortcutId);
|
||||
};
|
||||
input.addEventListener('keydown', onCaptureKeydown);
|
||||
},
|
||||
|
||||
onShortcutCaptureKeydown(e, shortcutId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this._capturingShortcutId = null;
|
||||
if (e.key === 'Escape') {
|
||||
this.renderShortcutSettingsList();
|
||||
return;
|
||||
}
|
||||
// Require a real chord: the dispatcher has no focus-target guard, so a
|
||||
// bare-key binding would fire while typing in any input.
|
||||
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
this.renderShortcutSettingsList();
|
||||
this.showToast?.('Shortcut must include Ctrl, Cmd, or Alt', 'error');
|
||||
return;
|
||||
}
|
||||
const modifiers = [];
|
||||
if (e.ctrlKey) modifiers.push('ctrl');
|
||||
if (e.metaKey) modifiers.push('meta');
|
||||
if (e.shiftKey) modifiers.push('shift');
|
||||
if (e.altKey) modifiers.push('alt');
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
|
||||
shortcutOverrides[shortcutId] = {
|
||||
...(shortcutOverrides[shortcutId] || {}),
|
||||
bindings: [{ modifiers, key: e.key, code: e.code }],
|
||||
};
|
||||
settings.shortcutOverrides = shortcutOverrides;
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
this.renderShortcutSettingsList();
|
||||
},
|
||||
|
||||
resetShortcutOverride(shortcutId) {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
|
||||
delete shortcutOverrides[shortcutId];
|
||||
settings.shortcutOverrides = shortcutOverrides;
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
this.renderShortcutSettingsList();
|
||||
},
|
||||
|
||||
toggleShortcutEnabled(shortcutId, enabled) {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
|
||||
shortcutOverrides[shortcutId] = { ...(shortcutOverrides[shortcutId] || {}), disabled: !enabled };
|
||||
settings.shortcutOverrides = shortcutOverrides;
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
this.renderShortcutSettingsList();
|
||||
},
|
||||
|
||||
closeAllPanels() {
|
||||
this.closeSessionOptions();
|
||||
this.closeAppSettings();
|
||||
|
||||
+1245
-8
File diff suppressed because it is too large
Load Diff
@@ -33,10 +33,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const truncatedName = displayName.length > 25 ? displayName.substring(0, 25) + '…' : displayName;
|
||||
const statusClass = agent?.status || 'idle';
|
||||
agentItems.push(`
|
||||
<div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreMinimizedSubagent('${escapeHtml(agentId)}', '${escapeHtml(sessionId)}')" title="Click to restore">
|
||||
<div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreMinimizedSubagent(${escapeHtml(JSON.stringify(agentId))}, ${escapeHtml(JSON.stringify(sessionId))})" title="Click to restore">
|
||||
<span class="subagent-dropdown-status ${statusClass}"></span>
|
||||
<span class="subagent-dropdown-name">${escapeHtml(truncatedName)}</span>
|
||||
<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.permanentlyCloseMinimizedSubagent('${escapeHtml(agentId)}', '${escapeHtml(sessionId)}')" title="Dismiss">×</span>
|
||||
<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.permanentlyCloseMinimizedSubagent(${escapeHtml(JSON.stringify(agentId))}, ${escapeHtml(JSON.stringify(sessionId))})" title="Dismiss">×</span>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
@@ -461,6 +461,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (typeof this._appendUltracodeConnectionLines === 'function') {
|
||||
this._appendUltracodeConnectionLines(svg, rects);
|
||||
}
|
||||
// Agent-transcript windows → their run window / tab (ultracode-windows.js).
|
||||
if (typeof this._appendUltracodeAgentConnectionLines === 'function') {
|
||||
this._appendUltracodeAgentConnectionLines(svg, rects);
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -695,7 +699,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
parentSessionId && parentSessionName
|
||||
? `<div class="subagent-window-parent" data-parent-session="${parentSessionId}">
|
||||
<span class="parent-label">from</span>
|
||||
<span class="parent-name" onclick="app.selectSession('${escapeHtml(parentSessionId)}')">${escapeHtml(parentSessionName)}</span>
|
||||
<span class="parent-name" onclick="app.selectSession(${escapeHtml(JSON.stringify(parentSessionId))})">${escapeHtml(parentSessionName)}</span>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
@@ -716,7 +720,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="status ${agent.status}">${agent.status}</span>
|
||||
</div>
|
||||
<div class="subagent-window-actions">
|
||||
<button onclick="app.closeSubagentWindow('${escapeHtml(agentId)}')" title="Minimize to tab">─</button>
|
||||
<button onclick="app.closeSubagentWindow(${escapeHtml(JSON.stringify(agentId))})" title="Minimize to tab">─</button>
|
||||
</div>
|
||||
</div>
|
||||
${parentHeader}
|
||||
|
||||
+913
-45
File diff suppressed because it is too large
Load Diff
@@ -131,12 +131,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// Phase 4: open an agent's live transcript by agentId. The workflow agent's
|
||||
// Phase 4: fetch an agent's live transcript by agentId. The workflow agent's
|
||||
// agentId is byte-identical to the agent-<id>.jsonl stem already tracked by
|
||||
// subagent-watcher, so we reuse the existing transcript route — no watcher edits.
|
||||
// Graceful when the agent isn't tracked yet / aged out / tracking disabled.
|
||||
async openWorkflowAgentTranscript(agentId) {
|
||||
if (!agentId) return;
|
||||
// Returns { formatted: string[], entryCount } or null when nothing is available
|
||||
// (queued / aged out of tracking / tracking disabled). Rendering into a connected
|
||||
// in-page floating window lives in ultracode-windows.js (openUltracodeAgentWindow) —
|
||||
// we no longer spawn a detached browser popup.
|
||||
async _fetchWorkflowAgentTranscript(agentId) {
|
||||
if (!agentId) return null;
|
||||
let data = null;
|
||||
try {
|
||||
const res = await fetch(`/api/subagents/${encodeURIComponent(agentId)}/transcript?format=formatted`);
|
||||
@@ -147,21 +150,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const ok = data && data.success && data.data;
|
||||
const formatted = ok ? data.data.formatted : null;
|
||||
const entryCount = ok ? data.data.entryCount || 0 : 0;
|
||||
if (!formatted || !entryCount) {
|
||||
alert(
|
||||
'No transcript available for this agent yet — it may be queued, aged out of tracking, or subagent tracking is disabled.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const win = window.open('', '_blank', 'width=860,height=640');
|
||||
if (!win) return; // popup blocked
|
||||
win.document.write(
|
||||
`<html><head><title>Workflow agent ${escapeHtml(agentId)} transcript</title>` +
|
||||
`<style>body{background:#1a1a2e;color:#eee;font-family:monospace;padding:20px}pre{white-space:pre-wrap;word-wrap:break-word}</style>` +
|
||||
`</head><body><h2>Workflow agent ${escapeHtml(agentId)} (${entryCount} entries)</h2>` +
|
||||
`<pre>${escapeHtml(formatted.join('\n'))}</pre></body></html>`
|
||||
);
|
||||
win.document.close();
|
||||
if (!formatted || !entryCount) return null;
|
||||
return { formatted, entryCount };
|
||||
},
|
||||
|
||||
// ----- Render (debounced) -----
|
||||
@@ -214,7 +204,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
phasesHtml = `<div class="ultracode-phase-list">${chips.join('')}</div>`;
|
||||
}
|
||||
return (
|
||||
`<div class="ultracode-run-item${active ? ' selected' : ''}" onclick="app.selectWorkflowRun('${escapeHtml(r.runId)}')">` +
|
||||
`<div class="ultracode-run-item${active ? ' selected' : ''}" onclick="app.selectWorkflowRun(${escapeHtml(JSON.stringify(r.runId))})">` +
|
||||
`<div class="ultracode-run-head"><span class="ultracode-run-name">${name}</span>` +
|
||||
`<span class="ultracode-status ${statusCls}">${escapeHtml(status || '—')}</span></div>` +
|
||||
`<div class="ultracode-run-stats">${escapeHtml(stats)}</div>` +
|
||||
@@ -262,13 +252,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
const header =
|
||||
`<div class="ultracode-phase-header"><span>${escapeHtml(title)}</span>` +
|
||||
`<span class="ultracode-phase-sub">${this._fmtNum(tok)} tok · ${tools} tools</span></div>`;
|
||||
return header + group.map((a) => this._workflowAgentCardHtml(a)).join('');
|
||||
return header + group.map((a) => this._workflowAgentCardHtml(a, runId)).join('');
|
||||
})
|
||||
.join('');
|
||||
detail.innerHTML = html;
|
||||
},
|
||||
|
||||
_workflowAgentCardHtml(a) {
|
||||
_workflowAgentCardHtml(a, runId) {
|
||||
const state = String(a.state || 'start');
|
||||
const stateCls = this._workflowAgentStateClass(state);
|
||||
const stateLabel = state === 'start' ? 'queued' : state === 'progress' ? 'running' : state;
|
||||
@@ -289,7 +279,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const cardStateCls = state === 'done' ? ' uw-state-done' : state === 'progress' ? ' uw-state-working' : '';
|
||||
const cardAttrs = clickable
|
||||
? ` class="ultracode-agent-card ultracode-agent-card--clickable${cardStateCls}" role="button" tabindex="0"` +
|
||||
` title="View transcript" onclick="app.openWorkflowAgentTranscript('${escapeHtml(a.agentId)}')"`
|
||||
` title="View transcript" onclick="app.openUltracodeAgentWindow(${escapeHtml(JSON.stringify(a.agentId))},${escapeHtml(JSON.stringify(runId || ''))})"`
|
||||
: ` class="ultracode-agent-card${cardStateCls}"`;
|
||||
return (
|
||||
`<div${cardAttrs}>` +
|
||||
|
||||
@@ -33,9 +33,12 @@
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/** Lazily seed the floating-window state maps (constructor also seeds them). */
|
||||
_ensureUltracodeWindowState() {
|
||||
if (!this.ultracodeWindows) this.ultracodeWindows = new Map(); // runId -> { element, parentSessionId, dragListeners, collapsed }
|
||||
if (!this.ultracodeWindows) this.ultracodeWindows = new Map(); // runId -> { element, parentSessionId, dragListeners }
|
||||
if (!this.ultracodeWindowsClosed) this.ultracodeWindowsClosed = new Set(); // runIds the user dismissed
|
||||
if (!this.ultracodeWindowCloseTimers) this.ultracodeWindowCloseTimers = new Map(); // runId -> setTimeout id
|
||||
if (!this.ultracodeAgentWindows) this.ultracodeAgentWindows = new Map(); // agentId -> { element, runId, dragListeners }
|
||||
if (!this.minimizedUltracodeRuns) this.minimizedUltracodeRuns = new Map(); // sessionId -> Set<runId> minimized to a tab
|
||||
if (!this.minimizedUltracodeAgents) this.minimizedUltracodeAgents = new Map(); // sessionId -> Map<agentId,{runId,label}>
|
||||
if (this.ultracodeWindowZIndex === undefined) this.ultracodeWindowZIndex = 1000;
|
||||
},
|
||||
|
||||
@@ -86,6 +89,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const active = this._isWorkflowRunActive(run);
|
||||
|
||||
// Minimized to a tab — keep it there (don't re-pop a window). Clear the tab badge a
|
||||
// short while after the run finishes, mirroring the floating window's finish grace.
|
||||
if (this._isUltracodeRunMinimized(runId)) {
|
||||
if (!active && !this.ultracodeWindowCloseTimers.has(runId)) {
|
||||
const timer = setTimeout(() => {
|
||||
this.ultracodeWindowCloseTimers.delete(runId);
|
||||
this._removeMinimizedUltracodeRun(runId);
|
||||
this.renderSessionTabs();
|
||||
this.updateConnectionLines();
|
||||
}, 8000);
|
||||
this.ultracodeWindowCloseTimers.set(runId, timer);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (active) {
|
||||
// Run is alive — cancel any pending auto-close.
|
||||
const pending = this.ultracodeWindowCloseTimers.get(runId);
|
||||
@@ -134,11 +152,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
const run = this.workflowRuns && this.workflowRuns.get(runId);
|
||||
if (!run) return;
|
||||
this.ultracodeWindowsClosed.delete(runId); // an explicit open overrides a past dismissal
|
||||
this._removeMinimizedUltracodeRun(runId); // …and a past minimize-to-tab
|
||||
const existing = this.ultracodeWindows.get(runId);
|
||||
if (existing) {
|
||||
// Already open — bring to front, expand if collapsed, refresh.
|
||||
// Already open — bring to front and refresh.
|
||||
existing.element.style.zIndex = ++this.ultracodeWindowZIndex;
|
||||
if (existing.collapsed) this.toggleUltracodeWindowCollapse(runId);
|
||||
this.renderUltracodeWindowContent(runId);
|
||||
this._fetchWorkflowRunDetail(runId);
|
||||
this.updateConnectionLines();
|
||||
@@ -167,7 +185,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="uw-status"></span>
|
||||
</div>
|
||||
<div class="ultracode-window-actions">
|
||||
<button class="uw-min" type="button" title="Collapse">─</button>
|
||||
<button class="uw-min" type="button" title="Minimize to tab">─</button>
|
||||
<button class="uw-close" type="button" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,7 +216,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
win.querySelector('.uw-min').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleUltracodeWindowCollapse(runId);
|
||||
this.minimizeUltracodeWindowToTab(runId);
|
||||
});
|
||||
win.querySelector('.uw-close').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -211,19 +229,261 @@ Object.assign(CodemanApp.prototype, {
|
||||
nameEl.addEventListener('click', () => this.selectSession(parentSessionId));
|
||||
}
|
||||
|
||||
this.ultracodeWindows.set(runId, { element: win, parentSessionId, dragListeners, collapsed: false });
|
||||
this.ultracodeWindows.set(runId, { element: win, parentSessionId, dragListeners });
|
||||
|
||||
this.renderUltracodeWindowContent(runId);
|
||||
this._fetchWorkflowRunDetail(runId); // pull agents[] for the body
|
||||
this.updateConnectionLines();
|
||||
},
|
||||
|
||||
/** Collapse/expand the window to header-only (line stays connected). */
|
||||
toggleUltracodeWindowCollapse(runId) {
|
||||
// ── Minimize a run window into its originating tab (same idiom as subagent windows) ──
|
||||
|
||||
/** Is this run currently minimized to a tab (so auto-pop should leave it alone)? */
|
||||
_isUltracodeRunMinimized(runId) {
|
||||
if (!this.minimizedUltracodeRuns) return false;
|
||||
for (const set of this.minimizedUltracodeRuns.values()) {
|
||||
if (set.has(runId)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/** Drop a run from the minimized-to-tab tracking (all sessions, or a specific one). */
|
||||
_removeMinimizedUltracodeRun(runId, sessionId) {
|
||||
if (!this.minimizedUltracodeRuns) return;
|
||||
if (sessionId) {
|
||||
const set = this.minimizedUltracodeRuns.get(sessionId);
|
||||
if (set) {
|
||||
set.delete(runId);
|
||||
if (!set.size) this.minimizedUltracodeRuns.delete(sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const [sid, set] of this.minimizedUltracodeRuns) {
|
||||
if (set.delete(runId) && !set.size) this.minimizedUltracodeRuns.delete(sid);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Minimize the floating run window into its originating session tab: record it as
|
||||
* minimized (so a badge renders on the tab), genie-animate the window toward that
|
||||
* tab, then remove the floating element. Restorable from the tab badge dropdown.
|
||||
*/
|
||||
minimizeUltracodeWindowToTab(runId) {
|
||||
this._ensureUltracodeWindowState();
|
||||
const data = this.ultracodeWindows.get(runId);
|
||||
if (!data) return;
|
||||
data.collapsed = !data.collapsed;
|
||||
data.element.classList.toggle('collapsed', data.collapsed);
|
||||
|
||||
let parentSessionId = data.parentSessionId;
|
||||
if (!parentSessionId) {
|
||||
const summary = this.workflowRuns && this.workflowRuns.get(runId);
|
||||
parentSessionId = summary ? this._resolveUltracodeParentSession(summary) : null;
|
||||
}
|
||||
// No tab to fly into → fall back to a plain close so the window isn't orphaned.
|
||||
if (!parentSessionId) {
|
||||
this.closeUltracodeWindow(runId, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any pending finish auto-close — the badge owns the run's lifecycle now.
|
||||
const pending = this.ultracodeWindowCloseTimers.get(runId);
|
||||
if (pending) {
|
||||
clearTimeout(pending);
|
||||
this.ultracodeWindowCloseTimers.delete(runId);
|
||||
}
|
||||
|
||||
if (!this.minimizedUltracodeRuns.has(parentSessionId)) this.minimizedUltracodeRuns.set(parentSessionId, new Set());
|
||||
this.minimizedUltracodeRuns.get(parentSessionId).add(runId);
|
||||
|
||||
const element = data.element;
|
||||
const dragListeners = data.dragListeners;
|
||||
this._animateUltracodeWindowToTab(element, parentSessionId, () => {
|
||||
this._teardownUltracodeDrag(dragListeners);
|
||||
if (element) element.remove();
|
||||
this.ultracodeWindows.delete(runId);
|
||||
// Full rebuild so the tab badge renders (the incremental path only knows subagent badges).
|
||||
this._fullRenderSessionTabs();
|
||||
this.updateConnectionLines();
|
||||
});
|
||||
},
|
||||
|
||||
/** Genie the window toward the center of its tab, then invoke `done` to tear it down. */
|
||||
_animateUltracodeWindowToTab(element, sessionId, done) {
|
||||
const tab = sessionId ? document.querySelector(`.session-tab[data-id="${sessionId}"]`) : null;
|
||||
if (!tab || !element) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
const w = element.getBoundingClientRect();
|
||||
const t = tab.getBoundingClientRect();
|
||||
const dx = t.left + t.width / 2 - (w.left + w.width / 2);
|
||||
const dy = t.top + t.height / 2 - (w.top + w.height / 2);
|
||||
element.style.transformOrigin = 'center center';
|
||||
element.style.transition = 'transform 0.26s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.26s ease';
|
||||
element.style.pointerEvents = 'none';
|
||||
requestAnimationFrame(() => {
|
||||
element.style.transform = `translate(${dx}px, ${dy}px) scale(0.06)`;
|
||||
element.style.opacity = '0';
|
||||
});
|
||||
let finished = false;
|
||||
const finish = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
done();
|
||||
};
|
||||
element.addEventListener('transitionend', finish, { once: true });
|
||||
setTimeout(finish, 320); // fallback in case transitionend doesn't fire
|
||||
},
|
||||
|
||||
/** Tab badge (with restore/dismiss dropdown) for runs minimized to this session's tab. */
|
||||
renderUltracodeTabBadge(sessionId) {
|
||||
this._ensureUltracodeWindowState();
|
||||
const runSet = this.minimizedUltracodeRuns.get(sessionId);
|
||||
const agentMap = this.minimizedUltracodeAgents.get(sessionId);
|
||||
const total = (runSet ? runSet.size : 0) + (agentMap ? agentMap.size : 0);
|
||||
if (total === 0) return '';
|
||||
|
||||
const trunc = (s) => (s.length > 25 ? s.slice(0, 25) + '…' : s);
|
||||
const items = [];
|
||||
// Minimized run windows (🧬) first…
|
||||
if (runSet) {
|
||||
for (const runId of runSet) {
|
||||
const run = this.workflowRuns && this.workflowRuns.get(runId);
|
||||
const name = run ? run.workflowName || run.summary || runId : runId;
|
||||
const statusCls = this._workflowStatusClass(run ? String(run.status || '') : '');
|
||||
items.push(
|
||||
`<div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreUltracodeRunFromTab(${escapeHtml(JSON.stringify(runId))},${escapeHtml(JSON.stringify(sessionId))})" title="Click to restore run">` +
|
||||
`<span class="subagent-dropdown-status ${statusCls}"></span>` +
|
||||
`<span class="ultracode-dd-icon">🧬</span>` +
|
||||
`<span class="subagent-dropdown-name">${escapeHtml(trunc(name))}</span>` +
|
||||
`<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.dismissMinimizedUltracodeRun(${escapeHtml(JSON.stringify(runId))},${escapeHtml(JSON.stringify(sessionId))})" title="Dismiss">×</span>` +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
}
|
||||
// …then minimized agent transcripts (📄).
|
||||
if (agentMap) {
|
||||
for (const [agentId, entry] of agentMap) {
|
||||
const name = (entry && entry.label) || agentId;
|
||||
items.push(
|
||||
`<div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreUltracodeAgentFromTab(${escapeHtml(JSON.stringify(agentId))},${escapeHtml(JSON.stringify(sessionId))})" title="Click to restore transcript">` +
|
||||
`<span class="subagent-dropdown-status"></span>` +
|
||||
`<span class="ultracode-dd-icon">📄</span>` +
|
||||
`<span class="subagent-dropdown-name">${escapeHtml(trunc(name))}</span>` +
|
||||
`<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.dismissMinimizedUltracodeAgent(${escapeHtml(JSON.stringify(agentId))},${escapeHtml(JSON.stringify(sessionId))})" title="Dismiss">×</span>` +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
}
|
||||
const label = total === 1 ? 'ULTRA' : `ULTRA (${total})`;
|
||||
return (
|
||||
`<span class="tab-ultracode-badge" onmouseenter="app.showSubagentDropdown(this)" onmouseleave="app.scheduleHideSubagentDropdown(this)" onclick="event.stopPropagation(); app.pinSubagentDropdown(this);">` +
|
||||
`<span class="subagent-label">${label}</span>` +
|
||||
`<div class="subagent-dropdown" onmouseenter="app.cancelHideSubagentDropdown()" onmouseleave="app.scheduleHideSubagentDropdown(this.parentElement)">${items.join('')}</div>` +
|
||||
`</span>`
|
||||
);
|
||||
},
|
||||
|
||||
/** Restore a minimized run from its tab badge: re-open the floating window. */
|
||||
restoreUltracodeRunFromTab(runId, sessionId) {
|
||||
this._ensureUltracodeWindowState();
|
||||
this._removeMinimizedUltracodeRun(runId, sessionId);
|
||||
this._fullRenderSessionTabs();
|
||||
this.openUltracodeWindowForRun(runId);
|
||||
},
|
||||
|
||||
/** Dismiss a minimized run from its tab badge (don't re-pop it). */
|
||||
dismissMinimizedUltracodeRun(runId, sessionId) {
|
||||
this._ensureUltracodeWindowState();
|
||||
this._removeMinimizedUltracodeRun(runId, sessionId);
|
||||
this.ultracodeWindowsClosed.add(runId);
|
||||
this._fullRenderSessionTabs();
|
||||
this.updateConnectionLines();
|
||||
},
|
||||
|
||||
// ── Minimize an agent transcript window into its tab (same idiom as run windows) ──
|
||||
|
||||
/** Is this agent transcript currently minimized to a tab? */
|
||||
_isUltracodeAgentMinimized(agentId) {
|
||||
if (!this.minimizedUltracodeAgents) return false;
|
||||
for (const map of this.minimizedUltracodeAgents.values()) {
|
||||
if (map.has(agentId)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/** Look up a minimized agent's {runId,label} entry (across sessions). */
|
||||
_getMinimizedUltracodeAgent(agentId) {
|
||||
if (!this.minimizedUltracodeAgents) return null;
|
||||
for (const map of this.minimizedUltracodeAgents.values()) {
|
||||
if (map.has(agentId)) return map.get(agentId);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/** Drop an agent from minimized tracking (all sessions, or a specific one). */
|
||||
_removeMinimizedUltracodeAgent(agentId, sessionId) {
|
||||
if (!this.minimizedUltracodeAgents) return;
|
||||
if (sessionId) {
|
||||
const map = this.minimizedUltracodeAgents.get(sessionId);
|
||||
if (map) {
|
||||
map.delete(agentId);
|
||||
if (!map.size) this.minimizedUltracodeAgents.delete(sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const [sid, map] of this.minimizedUltracodeAgents) {
|
||||
if (map.delete(agentId) && !map.size) this.minimizedUltracodeAgents.delete(sid);
|
||||
}
|
||||
},
|
||||
|
||||
/** Minimize an agent transcript window into the run's originating session tab. */
|
||||
minimizeUltracodeAgentWindowToTab(agentId) {
|
||||
this._ensureUltracodeWindowState();
|
||||
const info = this.ultracodeAgentWindows.get(agentId);
|
||||
if (!info) return;
|
||||
const runId = info.runId;
|
||||
const summary = runId && this.workflowRuns ? this.workflowRuns.get(runId) : null;
|
||||
let parentSessionId = summary ? this._resolveUltracodeParentSession(summary) : null;
|
||||
if (!parentSessionId && this.activeSessionId && this.sessions && this.sessions.has(this.activeSessionId)) {
|
||||
parentSessionId = this.activeSessionId;
|
||||
}
|
||||
// No tab to fly into → plain close rather than orphan it.
|
||||
if (!parentSessionId) {
|
||||
this.closeUltracodeAgentWindow(agentId);
|
||||
return;
|
||||
}
|
||||
const labelEl = info.element.querySelector('.uw-name');
|
||||
const label = labelEl ? labelEl.textContent : agentId;
|
||||
if (!this.minimizedUltracodeAgents.has(parentSessionId))
|
||||
this.minimizedUltracodeAgents.set(parentSessionId, new Map());
|
||||
this.minimizedUltracodeAgents.get(parentSessionId).set(agentId, { runId, label });
|
||||
|
||||
const element = info.element;
|
||||
const dragListeners = info.dragListeners;
|
||||
this._animateUltracodeWindowToTab(element, parentSessionId, () => {
|
||||
this._teardownUltracodeDrag(dragListeners);
|
||||
if (element) element.remove();
|
||||
this.ultracodeAgentWindows.delete(agentId);
|
||||
this._fullRenderSessionTabs();
|
||||
this.updateConnectionLines();
|
||||
});
|
||||
},
|
||||
|
||||
/** Restore a minimized agent transcript from its tab badge: re-open its window. */
|
||||
restoreUltracodeAgentFromTab(agentId, sessionId) {
|
||||
this._ensureUltracodeWindowState();
|
||||
const entry = this._getMinimizedUltracodeAgent(agentId);
|
||||
const runId = entry ? entry.runId : null;
|
||||
this._removeMinimizedUltracodeAgent(agentId, sessionId);
|
||||
this._fullRenderSessionTabs();
|
||||
this.openUltracodeAgentWindow(agentId, runId);
|
||||
},
|
||||
|
||||
/** Dismiss a minimized agent transcript from its tab badge. */
|
||||
dismissMinimizedUltracodeAgent(agentId, sessionId) {
|
||||
this._ensureUltracodeWindowState();
|
||||
this._removeMinimizedUltracodeAgent(agentId, sessionId);
|
||||
this._fullRenderSessionTabs();
|
||||
this.updateConnectionLines();
|
||||
},
|
||||
|
||||
@@ -260,19 +520,149 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// ── Agent-transcript windows ────────────────────────────────────────────────
|
||||
// Clicking an agent card (in a run window OR the dock panel) opens the agent's
|
||||
// live transcript as its OWN in-page floating window, line-tied to its parent run
|
||||
// window (or the run's session tab when that window is closed). Replaces the old
|
||||
// detached `window.open` browser popup so the transcript stays inside the same
|
||||
// draggable, connector-line floating-window system as the run windows.
|
||||
|
||||
/** Open (or focus) the floating transcript window for a workflow agent. */
|
||||
async openUltracodeAgentWindow(agentId, runId) {
|
||||
this._ensureUltracodeWindowState();
|
||||
if (!agentId) return;
|
||||
this._removeMinimizedUltracodeAgent(agentId); // an explicit open overrides a past minimize
|
||||
const existing = this.ultracodeAgentWindows.get(agentId);
|
||||
if (existing && existing.element) {
|
||||
// Already open — bring to front and refresh transcript.
|
||||
existing.element.style.zIndex = ++this.ultracodeWindowZIndex;
|
||||
this.updateConnectionLines();
|
||||
} else if (!this.createUltracodeAgentWindow(agentId, runId)) {
|
||||
return;
|
||||
}
|
||||
// Body shows a loading state until the fetch lands (re-fetch on focus too, so a
|
||||
// still-running agent's transcript grows as you re-click).
|
||||
const data = this._fetchWorkflowAgentTranscript ? await this._fetchWorkflowAgentTranscript(agentId) : null;
|
||||
this.renderUltracodeAgentWindowContent(agentId, data);
|
||||
},
|
||||
|
||||
/** Build and mount the floating agent-transcript window shell near its parent. */
|
||||
createUltracodeAgentWindow(agentId, runId) {
|
||||
this._ensureUltracodeWindowState();
|
||||
if (this.ultracodeAgentWindows.has(agentId)) return this.ultracodeAgentWindows.get(agentId).element;
|
||||
|
||||
const label = this._ultracodeAgentLabel(agentId, runId) || agentId;
|
||||
const win = document.createElement('div');
|
||||
win.className = 'ultracode-window ultracode-agent-window spawning';
|
||||
win.id = `ultracode-agent-window-${agentId}`;
|
||||
win.style.zIndex = ++this.ultracodeWindowZIndex;
|
||||
win.innerHTML = `
|
||||
<div class="ultracode-window-header">
|
||||
<div class="ultracode-window-title" title="${escapeHtml(label)} — transcript">
|
||||
<span class="icon">📄</span>
|
||||
<span class="uw-name">${escapeHtml(label)}</span>
|
||||
</div>
|
||||
<div class="ultracode-window-actions">
|
||||
<button class="uw-min" type="button" title="Minimize to tab">─</button>
|
||||
<button class="uw-close" type="button" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ultracode-window-body">
|
||||
<div class="subagent-empty">Loading transcript…</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Position: offset from the parent run window if it's open, else cascade.
|
||||
const parentWin = runId ? this.ultracodeWindows.get(runId) : null;
|
||||
if (parentWin && parentWin.element) {
|
||||
const r = parentWin.element.getBoundingClientRect();
|
||||
win.style.left = `${Math.max(8, Math.min(r.left + 40, window.innerWidth - 472))}px`;
|
||||
win.style.top = `${Math.max(8, Math.min(r.top + 40, window.innerHeight - 160))}px`;
|
||||
} else {
|
||||
const n = this.ultracodeAgentWindows.size;
|
||||
win.style.left = `${Math.min(140 + n * 28, Math.max(8, window.innerWidth - 472))}px`;
|
||||
win.style.top = `${110 + n * 28}px`;
|
||||
}
|
||||
|
||||
document.body.appendChild(win);
|
||||
requestAnimationFrame(() => win.classList.remove('spawning'));
|
||||
|
||||
const header = win.querySelector('.ultracode-window-header');
|
||||
const dragListeners = this.makeWindowDraggable(win, header);
|
||||
win.querySelector('.uw-min').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.minimizeUltracodeAgentWindowToTab(agentId);
|
||||
});
|
||||
win.querySelector('.uw-close').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.closeUltracodeAgentWindow(agentId);
|
||||
});
|
||||
|
||||
this.ultracodeAgentWindows.set(agentId, { element: win, runId, dragListeners });
|
||||
this.updateConnectionLines();
|
||||
return win;
|
||||
},
|
||||
|
||||
/** Resolve a human label for an agent from the run's fetched detail.agents[]. */
|
||||
_ultracodeAgentLabel(agentId, runId) {
|
||||
const detail = this.workflowRunDetails && runId ? this.workflowRunDetails.get(runId) : null;
|
||||
const agents = detail && Array.isArray(detail.agents) ? detail.agents : null;
|
||||
if (agents) {
|
||||
const found = agents.find((a) => a.agentId === agentId);
|
||||
if (found && found.label) return found.label;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/** Fill an agent window's body with the fetched transcript (or a friendly empty state). */
|
||||
renderUltracodeAgentWindowContent(agentId, data) {
|
||||
const info = this.ultracodeAgentWindows.get(agentId);
|
||||
if (!info || !info.element) return;
|
||||
const body = info.element.querySelector('.ultracode-window-body');
|
||||
if (!body) return;
|
||||
if (!data || !data.formatted || !data.entryCount) {
|
||||
body.innerHTML =
|
||||
'<div class="subagent-empty">No transcript available yet — the agent may be queued, aged out of tracking, or subagent tracking is disabled.</div>';
|
||||
return;
|
||||
}
|
||||
const text = escapeHtml(data.formatted.join('\n'));
|
||||
body.innerHTML = `<div class="uw-summary">${data.entryCount} entries</div><pre class="uw-transcript">${text}</pre>`;
|
||||
},
|
||||
|
||||
/** Close one floating agent-transcript window. */
|
||||
closeUltracodeAgentWindow(agentId) {
|
||||
this._ensureUltracodeWindowState();
|
||||
const info = this.ultracodeAgentWindows.get(agentId);
|
||||
if (!info) return;
|
||||
this._teardownUltracodeDrag(info.dragListeners);
|
||||
if (info.element) info.element.remove();
|
||||
this.ultracodeAgentWindows.delete(agentId);
|
||||
this.updateConnectionLines();
|
||||
},
|
||||
|
||||
/** Tear down every floating window (called on SSE reconnect; keeps user dismissals). */
|
||||
removeAllUltracodeWindows() {
|
||||
this._ensureUltracodeWindowState();
|
||||
const had = this.ultracodeWindows.size > 0;
|
||||
const hadMinimized = this.minimizedUltracodeRuns.size > 0 || this.minimizedUltracodeAgents.size > 0;
|
||||
const had = this.ultracodeWindows.size > 0 || this.ultracodeAgentWindows.size > 0;
|
||||
for (const [, data] of this.ultracodeWindows) {
|
||||
this._teardownUltracodeDrag(data.dragListeners);
|
||||
if (data.element) data.element.remove();
|
||||
}
|
||||
this.ultracodeWindows.clear();
|
||||
for (const [, info] of this.ultracodeAgentWindows) {
|
||||
this._teardownUltracodeDrag(info.dragListeners);
|
||||
if (info.element) info.element.remove();
|
||||
}
|
||||
this.ultracodeAgentWindows.clear();
|
||||
for (const t of this.ultracodeWindowCloseTimers.values()) clearTimeout(t);
|
||||
this.ultracodeWindowCloseTimers.clear();
|
||||
this.minimizedUltracodeRuns.clear();
|
||||
this.minimizedUltracodeAgents.clear();
|
||||
// Redraw so the now-orphaned connector lines are cleared from the shared SVG.
|
||||
if (had) this.updateConnectionLines();
|
||||
// Drop any now-stale tab badges.
|
||||
if (hadMinimized) this.renderSessionTabs();
|
||||
},
|
||||
|
||||
/** When the feature is toggled on, pop windows for any currently-active runs. */
|
||||
@@ -355,7 +745,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const header =
|
||||
`<div class="ultracode-phase-header"><span>${escapeHtml(title)}</span>` +
|
||||
`<span class="ultracode-phase-sub">${this._fmtNum(tok)} tok · ${tools} tools</span></div>`;
|
||||
return header + group.map((a) => this._workflowAgentCardHtml(a)).join('');
|
||||
return header + group.map((a) => this._workflowAgentCardHtml(a, run.runId)).join('');
|
||||
})
|
||||
.join('');
|
||||
return head + grid;
|
||||
@@ -408,4 +798,51 @@ Object.assign(CodemanApp.prototype, {
|
||||
svg.appendChild(line);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Append agent-window → parent connector lines into the shared SVG. Parent is the
|
||||
* agent's run floating window when open, else the run's session tab. Called right
|
||||
* after `_appendUltracodeConnectionLines` so it shares the same batched pass + the
|
||||
* tab-rect cache.
|
||||
*/
|
||||
_appendUltracodeAgentConnectionLines(svg, rects) {
|
||||
this._ensureUltracodeWindowState();
|
||||
if (!svg || !this.ultracodeAgentWindows.size) return;
|
||||
if (!rects) rects = new Map();
|
||||
|
||||
for (const [agentId, info] of this.ultracodeAgentWindows) {
|
||||
if (!info.element) continue;
|
||||
const winRect = info.element.getBoundingClientRect();
|
||||
// Anchor: parent run window bottom-center if open, else the run's tab.
|
||||
let px, py;
|
||||
const runWin = info.runId ? this.ultracodeWindows.get(info.runId) : null;
|
||||
if (runWin && runWin.element) {
|
||||
const pr = runWin.element.getBoundingClientRect();
|
||||
px = pr.left + pr.width / 2;
|
||||
py = pr.bottom;
|
||||
} else {
|
||||
const summary = info.runId && this.workflowRuns ? this.workflowRuns.get(info.runId) : null;
|
||||
const parentSessionId = summary ? this._resolveUltracodeParentSession(summary) : null;
|
||||
if (!parentSessionId) continue;
|
||||
const tabKey = 'tab:' + parentSessionId;
|
||||
if (!rects.has(tabKey)) {
|
||||
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
|
||||
if (tab) rects.set(tabKey, tab.getBoundingClientRect());
|
||||
}
|
||||
const tabRect = rects.get(tabKey);
|
||||
if (!tabRect) continue;
|
||||
px = tabRect.left + tabRect.width / 2;
|
||||
py = tabRect.bottom;
|
||||
}
|
||||
const x2 = winRect.left + winRect.width / 2;
|
||||
const y2 = winRect.top;
|
||||
const midY = (py + y2) / 2;
|
||||
const path = `M ${px} ${py} C ${px} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
line.setAttribute('d', path);
|
||||
line.setAttribute('class', 'connection-line ultracode-connection ultracode-agent-connection');
|
||||
line.setAttribute('data-agent-id', agentId);
|
||||
svg.appendChild(line);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,22 +5,73 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, createReadStream } from 'node:fs';
|
||||
import { exec } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { join, resolve, basename } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
import type { ApiResponse, CaseInfo } from '../../types.js';
|
||||
import type { ApiResponse, CaseInfo, DockerHost, SessionDocker } from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { CreateCaseSchema, LinkCaseSchema, CaseOrderSchema } from '../schemas.js';
|
||||
import {
|
||||
CreateCaseSchema,
|
||||
LinkCaseSchema,
|
||||
CaseOrderSchema,
|
||||
RemoteCaseLinkSchema,
|
||||
RemoteHostSchema,
|
||||
DockerCaseLinkSchema,
|
||||
DockerHostSchema,
|
||||
DockerExportSchema,
|
||||
DockerImportSchema,
|
||||
DockerQuickCreateSchema,
|
||||
} from '../schemas.js';
|
||||
import { exportDockerCase, importDockerBundle, listDockerExports, exportBundleName } from '../../docker-export.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { writeHooksConfig } from '../../hooks-config.js';
|
||||
import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, ConfigPort } from '../ports/index.js';
|
||||
import { dataPath, getDataDir } from '../../config/instance.js';
|
||||
import {
|
||||
checkDockerAvailable,
|
||||
checkDockerImagePresent,
|
||||
checkDockerTmuxAvailable,
|
||||
ensureAgentBaseImage,
|
||||
DEFAULT_AGENT_IMAGE,
|
||||
dockerContainerName,
|
||||
dockerDisplayPath,
|
||||
readDockerCases,
|
||||
readDockerHosts,
|
||||
toSessionDocker,
|
||||
writeDockerCases,
|
||||
writeDockerHosts,
|
||||
} from '../../docker-hosts.js';
|
||||
import { buildDockerRemoveCommand } from '../../tmux-manager.js';
|
||||
import {
|
||||
checkRemoteTmuxAvailable,
|
||||
readRemoteCases,
|
||||
readRemoteHosts,
|
||||
remoteDisplayPath,
|
||||
writeRemoteCases,
|
||||
writeRemoteHosts,
|
||||
} from '../../remote-hosts.js';
|
||||
|
||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||
const CODEMAN_CONFIG_DIR = getDataDir();
|
||||
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
|
||||
const DOCKER_EXPORTS_DIR = dataPath('docker-exports');
|
||||
/** Auto-created host profile for the one-click "Run in Docker" case flow. */
|
||||
const DEFAULT_DOCKER_HOST_ID = 'default';
|
||||
|
||||
/** App version for export manifests (best-effort read of package.json). */
|
||||
const APP_VERSION = (() => {
|
||||
try {
|
||||
const pkgPath = fileURLToPath(new URL('../../../package.json', import.meta.url));
|
||||
return (JSON.parse(readFileSync(pkgPath, 'utf-8')).version as string) || 'unknown';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
})();
|
||||
|
||||
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
|
||||
async function readLinkedCases(): Promise<Record<string, string>> {
|
||||
@@ -34,6 +85,48 @@ async function resolveCasePath(name: string): Promise<string> {
|
||||
return join(CASES_DIR, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate a docker case on its base image, AUTO-BUILDING the default image on first
|
||||
* use so a missing image is never a blocker (the user's ask: "create it when it's
|
||||
* used for the first time"). Present image → verify tmux (hard prerequisite).
|
||||
* Default image missing → kick off a BACKGROUND build with SSE progress and return
|
||||
* `imageBuilding: true` (the case is created regardless; first launch awaits the
|
||||
* same dedup'd build). Custom image missing → a real error (we can't build a
|
||||
* foreign ref, and `--pull=never` forbids pulling).
|
||||
*/
|
||||
async function ensureCaseImage(
|
||||
broadcast: EventPort['broadcast'],
|
||||
sessionDocker: SessionDocker,
|
||||
name: string
|
||||
): Promise<{ ok: true; imageBuilding: boolean } | { ok: false; error: string }> {
|
||||
if (await checkDockerImagePresent(sessionDocker.engine, sessionDocker.image)) {
|
||||
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
|
||||
if (!tmuxCheck.ok) return { ok: false, error: tmuxCheck.error || 'base image is missing tmux' };
|
||||
return { ok: true, imageBuilding: false };
|
||||
}
|
||||
if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `base image ${sessionDocker.image} not present; only ${DEFAULT_AGENT_IMAGE} is auto-built. Build or pull it first.`,
|
||||
};
|
||||
}
|
||||
broadcast(SseEvent.DockerImageBuildStarted, { name, image: sessionDocker.image });
|
||||
void ensureAgentBaseImage(sessionDocker.engine, sessionDocker.image, {
|
||||
onProgress: (line) => broadcast(SseEvent.DockerImageBuildProgress, { name, line }),
|
||||
})
|
||||
.then((r) =>
|
||||
broadcast(r.ok ? SseEvent.DockerImageBuildComplete : SseEvent.DockerImageBuildFailed, {
|
||||
name,
|
||||
image: sessionDocker.image,
|
||||
error: r.error,
|
||||
})
|
||||
)
|
||||
.catch((err) =>
|
||||
broadcast(SseEvent.DockerImageBuildFailed, { name, image: sessionDocker.image, error: getErrorMessage(err) })
|
||||
);
|
||||
return { ok: true, imageBuilding: true };
|
||||
}
|
||||
|
||||
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Case CRUD (list, create, link, detail, fix-plan)
|
||||
@@ -53,6 +146,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
name: e.name,
|
||||
path: join(CASES_DIR, e.name),
|
||||
hasClaudeMd: existsSync(join(CASES_DIR, e.name, 'CLAUDE.md')),
|
||||
location: 'local',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -69,10 +163,68 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
name,
|
||||
path,
|
||||
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
|
||||
linked: true,
|
||||
location: 'linked-local',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get remote cases
|
||||
const remoteHosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||
const remoteHostMap = new Map(remoteHosts.map((host) => [host.id, host]));
|
||||
for (const remoteCase of await readRemoteCases(CODEMAN_CONFIG_DIR)) {
|
||||
const host = remoteHostMap.get(remoteCase.hostId);
|
||||
if (!host || !SAFE_CASE_NAME.test(remoteCase.name)) continue;
|
||||
existingNames.add(remoteCase.name);
|
||||
const remoteCaseInfo: CaseInfo = {
|
||||
name: remoteCase.name,
|
||||
path: remoteDisplayPath({ username: host.username, host: host.host, path: remoteCase.remotePath }),
|
||||
hasClaudeMd: false,
|
||||
location: 'remote',
|
||||
remote: {
|
||||
hostId: host.id,
|
||||
host: host.host,
|
||||
username: host.username,
|
||||
path: remoteCase.remotePath,
|
||||
},
|
||||
};
|
||||
const existingIndex = cases.findIndex((item) => item.name === remoteCase.name);
|
||||
if (existingIndex === -1) {
|
||||
cases.push(remoteCaseInfo);
|
||||
} else {
|
||||
cases[existingIndex] = remoteCaseInfo;
|
||||
}
|
||||
}
|
||||
|
||||
// Get docker cases
|
||||
const dockerHosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
const dockerHostMap = new Map(dockerHosts.map((host) => [host.id, host]));
|
||||
for (const dockerCase of await readDockerCases(CODEMAN_CONFIG_DIR)) {
|
||||
const host = dockerHostMap.get(dockerCase.hostId);
|
||||
if (!host || !SAFE_CASE_NAME.test(dockerCase.name)) continue;
|
||||
existingNames.add(dockerCase.name);
|
||||
const container = dockerCase.container ?? dockerContainerName(dockerCase.name);
|
||||
const dockerCaseInfo: CaseInfo = {
|
||||
name: dockerCase.name,
|
||||
path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }),
|
||||
hasClaudeMd: existsSync(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
|
||||
location: 'docker',
|
||||
docker: {
|
||||
hostId: host.id,
|
||||
container,
|
||||
image: host.image,
|
||||
path: dockerCase.hostWorkspacePath,
|
||||
network: host.network ?? 'bridge',
|
||||
},
|
||||
};
|
||||
const existingIndex = cases.findIndex((item) => item.name === dockerCase.name);
|
||||
if (existingIndex === -1) {
|
||||
cases.push(dockerCaseInfo);
|
||||
} else {
|
||||
cases[existingIndex] = dockerCaseInfo;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by persisted caseOrder from settings.json
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings', {});
|
||||
const caseOrder = Array.isArray(settings.caseOrder) ? (settings.caseOrder as string[]) : [];
|
||||
@@ -120,6 +272,421 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/remote-hosts', async () => readRemoteHosts(CODEMAN_CONFIG_DIR));
|
||||
|
||||
app.post('/api/remote-hosts', async (req): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
const host = parseBody(RemoteHostSchema, req.body);
|
||||
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||
if (hosts.some((item) => item.id === host.id)) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Remote host already exists');
|
||||
}
|
||||
await writeRemoteHosts(CODEMAN_CONFIG_DIR, [...hosts, host]);
|
||||
return { success: true, data: { host } };
|
||||
});
|
||||
|
||||
app.put('/api/remote-hosts/:id', async (req): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const host = parseBody(RemoteHostSchema, { ...(req.body as object), id });
|
||||
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||
const index = hosts.findIndex((item) => item.id === id);
|
||||
if (index === -1) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
const next = [...hosts];
|
||||
next[index] = host;
|
||||
await writeRemoteHosts(CODEMAN_CONFIG_DIR, next);
|
||||
return { success: true, data: { host } };
|
||||
});
|
||||
|
||||
app.delete('/api/remote-hosts/:id', async (req): Promise<ApiResponse<{ id: string }>> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const cases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
if (cases.some((item) => item.hostId === id)) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Remote host is still used by remote cases');
|
||||
}
|
||||
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||
await writeRemoteHosts(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
hosts.filter((item) => item.id !== id)
|
||||
);
|
||||
return { success: true, data: { id } };
|
||||
});
|
||||
|
||||
app.post('/api/cases/remote-link', async (req): Promise<ApiResponse<{ case: unknown }>> => {
|
||||
const remoteCase = { ...parseBody(RemoteCaseLinkSchema, req.body), type: 'remote' as const };
|
||||
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||
const host = hosts.find((item) => item.id === remoteCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
|
||||
const linkedCases = await readLinkedCases();
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
if (
|
||||
remoteCases.some((item) => item.name === remoteCase.name) ||
|
||||
linkedCases[remoteCase.name] ||
|
||||
existsSync(join(CASES_DIR, remoteCase.name))
|
||||
) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||
}
|
||||
|
||||
// Courtesy validation: tmux is a hard prerequisite for durable remote sessions.
|
||||
// Verify it up-front so linking surfaces a clear error now instead of a dead pane
|
||||
// at first launch (also confirms the SSH connection actually works).
|
||||
const tmuxCheck = await checkRemoteTmuxAvailable(host);
|
||||
if (!tmuxCheck.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'remote host is missing tmux');
|
||||
}
|
||||
|
||||
await writeRemoteCases(CODEMAN_CONFIG_DIR, [...remoteCases, remoteCase]);
|
||||
ctx.broadcast(SseEvent.CaseLinked, { name: remoteCase.name, path: remoteCase.remotePath, type: 'remote' });
|
||||
return { success: true, data: { case: remoteCase } };
|
||||
});
|
||||
|
||||
// ========== Docker hosts + docker cases (COD-Docker) ==========
|
||||
|
||||
app.get('/api/docker-hosts', async () => readDockerHosts(CODEMAN_CONFIG_DIR));
|
||||
|
||||
app.post('/api/docker-hosts', async (req): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
const host = parseBody(DockerHostSchema, req.body);
|
||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
if (hosts.some((item) => item.id === host.id)) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Docker host already exists');
|
||||
}
|
||||
await writeDockerHosts(CODEMAN_CONFIG_DIR, [...hosts, host]);
|
||||
return { success: true, data: { host } };
|
||||
});
|
||||
|
||||
app.put('/api/docker-hosts/:id', async (req): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const host = parseBody(DockerHostSchema, { ...(req.body as object), id });
|
||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
const index = hosts.findIndex((item) => item.id === id);
|
||||
if (index === -1) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
const next = [...hosts];
|
||||
next[index] = host;
|
||||
await writeDockerHosts(CODEMAN_CONFIG_DIR, next);
|
||||
return { success: true, data: { host } };
|
||||
});
|
||||
|
||||
app.delete('/api/docker-hosts/:id', async (req): Promise<ApiResponse<{ id: string }>> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const cases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
if (cases.some((item) => item.hostId === id)) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Docker host is still used by docker cases');
|
||||
}
|
||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
await writeDockerHosts(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
hosts.filter((item) => item.id !== id)
|
||||
);
|
||||
return { success: true, data: { id } };
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/api/cases/docker-link',
|
||||
async (
|
||||
req
|
||||
): Promise<
|
||||
ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean; imageBuilding?: boolean }>
|
||||
> => {
|
||||
const dockerCase = { ...parseBody(DockerCaseLinkSchema, req.body), type: 'docker' as const };
|
||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
const host = hosts.find((item) => item.id === dockerCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
|
||||
const linkedCases = await readLinkedCases();
|
||||
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
if (
|
||||
dockerCases.some((item) => item.name === dockerCase.name) ||
|
||||
linkedCases[dockerCase.name] ||
|
||||
existsSync(join(CASES_DIR, dockerCase.name))
|
||||
) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||
}
|
||||
|
||||
// The workspace is a REAL host directory (bind-mounted into the container), so
|
||||
// create it now if missing. Scaffolding (.claude/settings.local.json + CLAUDE.md)
|
||||
// is written by quick-start on first launch, matching local-case behaviour.
|
||||
if (!existsSync(dockerCase.hostWorkspacePath)) {
|
||||
try {
|
||||
mkdirSync(dockerCase.hostWorkspacePath, { recursive: true });
|
||||
} catch (err) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
`Could not create workspace: ${getErrorMessage(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Courtesy validation: docker daemon must be reachable. The base image is
|
||||
// auto-built on first use (default image) rather than being a link-time
|
||||
// blocker, so a missing image kicks off a background build instead of erroring.
|
||||
const availability = await checkDockerAvailable(host.engine);
|
||||
if (!availability.ok) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
availability.error || 'docker daemon is not available'
|
||||
);
|
||||
}
|
||||
const imageGate = await ensureCaseImage(ctx.broadcast, toSessionDocker(host, dockerCase), dockerCase.name);
|
||||
if (!imageGate.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, imageGate.error);
|
||||
}
|
||||
|
||||
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, dockerCase]);
|
||||
ctx.broadcast(SseEvent.CaseLinked, {
|
||||
name: dockerCase.name,
|
||||
path: dockerCase.hostWorkspacePath,
|
||||
type: 'docker',
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
case: dockerCase,
|
||||
capsEnforced: availability.capsEnforced,
|
||||
isDesktop: availability.isDesktop,
|
||||
imageBuilding: imageGate.imageBuilding,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// One-click "Run in Docker": create a NORMAL case (folder in CASES_DIR, scaffolded)
|
||||
// AND link it to a hardened container with default settings, auto-provisioning a
|
||||
// shared `default` docker host so the user never touches host/image/network fields.
|
||||
app.post(
|
||||
'/api/cases/docker-quickcreate',
|
||||
async (
|
||||
req
|
||||
): Promise<
|
||||
ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean; imageBuilding?: boolean }>
|
||||
> => {
|
||||
const body = parseBody(DockerQuickCreateSchema, req.body);
|
||||
const { name, description } = body;
|
||||
const casePath = validatePathWithinBase(name, CASES_DIR);
|
||||
if (!casePath) return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
|
||||
// Collision across every case kind.
|
||||
const linkedCases = await readLinkedCases();
|
||||
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
if (
|
||||
existsSync(casePath) ||
|
||||
dockerCases.some((item) => item.name === name) ||
|
||||
remoteCases.some((item) => item.name === name) ||
|
||||
linkedCases[name]
|
||||
) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||
}
|
||||
|
||||
// The checkbox alone (no overrides) uses the shared `default` host; any tweaked
|
||||
// setting gets a dedicated per-case host so it never mutates the shared default.
|
||||
const hasOverrides = !!(
|
||||
body.image ||
|
||||
body.network ||
|
||||
body.networkName ||
|
||||
body.memory ||
|
||||
body.cpus ||
|
||||
body.gpus ||
|
||||
body.mountCredentials !== undefined
|
||||
);
|
||||
const resources: { memory?: string; cpus?: string } = {};
|
||||
if (body.memory) resources.memory = body.memory;
|
||||
if (body.cpus) resources.cpus = body.cpus;
|
||||
const desiredHost: DockerHost = {
|
||||
id: hasOverrides ? `q-${name}` : DEFAULT_DOCKER_HOST_ID,
|
||||
label: hasOverrides ? `Case: ${name}` : 'Default',
|
||||
image: body.image || DEFAULT_AGENT_IMAGE,
|
||||
network: body.network || 'bridge',
|
||||
...(body.networkName ? { networkName: body.networkName } : {}),
|
||||
...(Object.keys(resources).length ? { resources } : {}),
|
||||
...(body.gpus ? { gpus: body.gpus } : {}),
|
||||
mountCredentials: body.mountCredentials ?? true,
|
||||
resumeOnStart: true,
|
||||
hooksEnabled: true,
|
||||
};
|
||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
const existing = hosts.find((item) => item.id === desiredHost.id);
|
||||
// Reuse the shared default if present; create/refresh a per-case host for overrides.
|
||||
const host = existing && !hasOverrides ? existing : desiredHost;
|
||||
if (!existing) {
|
||||
await writeDockerHosts(CODEMAN_CONFIG_DIR, [...hosts, desiredHost]);
|
||||
} else if (hasOverrides) {
|
||||
await writeDockerHosts(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
hosts.map((h) => (h.id === desiredHost.id ? desiredHost : h))
|
||||
);
|
||||
}
|
||||
|
||||
// Probe the daemon BEFORE scaffolding so a missing docker surfaces a clear
|
||||
// error instead of leaving an orphaned case folder. The base image is NOT a
|
||||
// blocker: a missing default image auto-builds in the background on first use.
|
||||
const availability = await checkDockerAvailable(host.engine);
|
||||
if (!availability.ok) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
availability.error || 'docker daemon is not available'
|
||||
);
|
||||
}
|
||||
const dockerCase = { name, type: 'docker' as const, hostId: host.id, hostWorkspacePath: casePath };
|
||||
const imageGate = await ensureCaseImage(ctx.broadcast, toSessionDocker(host, dockerCase), name);
|
||||
if (!imageGate.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, imageGate.error);
|
||||
}
|
||||
|
||||
// Scaffold the case folder exactly like a normal case.
|
||||
try {
|
||||
mkdirSync(casePath, { recursive: true });
|
||||
mkdirSync(join(casePath, 'src'), { recursive: true });
|
||||
const templatePath = await ctx.getDefaultClaudeMdPath();
|
||||
writeFileSync(join(casePath, 'CLAUDE.md'), generateClaudeMd(name, description || '', templatePath));
|
||||
await writeHooksConfig(casePath);
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
||||
}
|
||||
|
||||
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, dockerCase]);
|
||||
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
|
||||
ctx.broadcast(SseEvent.CaseLinked, { name, path: casePath, type: 'docker' });
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
case: dockerCase,
|
||||
capsEnforced: availability.capsEnforced,
|
||||
isDesktop: availability.isDesktop,
|
||||
imageBuilding: imageGate.imageBuilding,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ========== Docker export / import ==========
|
||||
|
||||
// Export a docker case to a portable bundle. Runs in the BACKGROUND (a full image
|
||||
// save can take minutes) and broadcasts docker:exportComplete / docker:exportFailed.
|
||||
app.post('/api/docker-cases/:name/export', async (req): Promise<ApiResponse<{ started: true; bundle: string }>> => {
|
||||
const { name } = req.params as { name: string };
|
||||
const { mode = 'full' } = parseBody(DockerExportSchema, req.body ?? {});
|
||||
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name);
|
||||
if (!dockerCase) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker case not found');
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
|
||||
const sessionDocker = toSessionDocker(host, dockerCase);
|
||||
if (mode === 'full' && !sessionDocker.mountCredentials) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'full-image export is refused for a sealed container (its in-container login would ride the committed layer). Use a workspace-only export.'
|
||||
);
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const bundle = exportBundleName(name, timestamp, mode);
|
||||
// Fire-and-forget: the client watches for the SSE completion event.
|
||||
void exportDockerCase({
|
||||
docker: sessionDocker,
|
||||
caseName: name,
|
||||
timestamp,
|
||||
exportsDir: DOCKER_EXPORTS_DIR,
|
||||
mode,
|
||||
codemanVersion: APP_VERSION,
|
||||
})
|
||||
.then((result) => {
|
||||
ctx.broadcast(SseEvent.DockerExportComplete, {
|
||||
name,
|
||||
bundle: basename(result.bundlePath),
|
||||
sizeBytes: result.sizeBytes,
|
||||
mode,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
ctx.broadcast(SseEvent.DockerExportFailed, { name, mode, error: getErrorMessage(err) });
|
||||
});
|
||||
|
||||
return { success: true, data: { started: true, bundle } };
|
||||
});
|
||||
|
||||
app.get('/api/docker-exports', async (): Promise<ApiResponse<{ exports: unknown[] }>> => {
|
||||
return { success: true, data: { exports: await listDockerExports(DOCKER_EXPORTS_DIR) } };
|
||||
});
|
||||
|
||||
// Download an export bundle (filename resolved WITHIN the exports dir — no traversal).
|
||||
app.get('/api/docker-exports/:filename', async (req, reply) => {
|
||||
const { filename } = req.params as { filename: string };
|
||||
if (!/^[a-zA-Z0-9._-]+\.tgz$/.test(filename)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid bundle filename');
|
||||
}
|
||||
const full = join(DOCKER_EXPORTS_DIR, filename);
|
||||
if (!existsSync(full)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Export not found');
|
||||
reply.header('Content-Type', 'application/gzip');
|
||||
reply.header('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
return reply.send(createReadStream(full));
|
||||
});
|
||||
|
||||
app.delete('/api/docker-exports/:filename', async (req): Promise<ApiResponse<{ filename: string }>> => {
|
||||
const { filename } = req.params as { filename: string };
|
||||
if (!/^[a-zA-Z0-9._-]+\.tgz$/.test(filename)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid bundle filename');
|
||||
}
|
||||
const full = join(DOCKER_EXPORTS_DIR, filename);
|
||||
if (!existsSync(full)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Export not found');
|
||||
await fs.rm(full, { force: true });
|
||||
return { success: true, data: { filename } };
|
||||
});
|
||||
|
||||
// Import a bundle (already present in the exports dir) into a NEW docker case.
|
||||
app.post('/api/docker-cases/import', async (req): Promise<ApiResponse<{ case: unknown }>> => {
|
||||
const { bundle, newCaseName, destWorkspacePath } = parseBody(DockerImportSchema, req.body);
|
||||
const bundlePath = join(DOCKER_EXPORTS_DIR, bundle);
|
||||
if (!existsSync(bundlePath)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Bundle not found in exports dir');
|
||||
|
||||
// Name-collision guard across ALL case kinds.
|
||||
const linkedCases = await readLinkedCases();
|
||||
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
if (
|
||||
dockerCases.some((item) => item.name === newCaseName) ||
|
||||
linkedCases[newCaseName] ||
|
||||
existsSync(join(CASES_DIR, newCaseName))
|
||||
) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
let result;
|
||||
try {
|
||||
result = await importDockerBundle({ bundlePath, destWorkspace: destWorkspacePath, engine: 'docker', timestamp });
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Import failed: ${getErrorMessage(err)}`);
|
||||
}
|
||||
|
||||
// Create a dedicated docker host pointing at the quarantined imported image
|
||||
// (full mode) or the manifest's base image (workspace-only).
|
||||
const hostId = `imported-${newCaseName}`;
|
||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
if (!hosts.some((h) => h.id === hostId)) {
|
||||
await writeDockerHosts(CODEMAN_CONFIG_DIR, [
|
||||
...hosts,
|
||||
{
|
||||
id: hostId,
|
||||
label: `Imported: ${newCaseName}`,
|
||||
engine: result.manifest.engine,
|
||||
image: result.importedImage ?? result.manifest.image,
|
||||
network: (['bridge', 'none', 'custom'].includes(result.manifest.network)
|
||||
? result.manifest.network
|
||||
: 'bridge') as 'bridge' | 'none' | 'custom',
|
||||
},
|
||||
]);
|
||||
}
|
||||
const newCase = {
|
||||
name: newCaseName,
|
||||
type: 'docker' as const,
|
||||
hostId,
|
||||
hostWorkspacePath: destWorkspacePath,
|
||||
containerWorkdir: result.manifest.containerWorkdir,
|
||||
};
|
||||
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, newCase]);
|
||||
ctx.broadcast(SseEvent.DockerImportComplete, { name: newCaseName, path: destWorkspacePath, type: 'docker' });
|
||||
return { success: true, data: { case: newCase } };
|
||||
});
|
||||
|
||||
// Link an existing folder as a case
|
||||
app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
|
||||
@@ -173,6 +740,42 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
if (remoteCases.some((item) => item.name === name)) {
|
||||
await writeRemoteCases(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
remoteCases.filter((item) => item.name !== name)
|
||||
);
|
||||
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'remote-unlinked' });
|
||||
return { success: true, data: { name } };
|
||||
}
|
||||
|
||||
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
const dockerCase = dockerCases.find((item) => item.name === name);
|
||||
if (dockerCase) {
|
||||
await writeDockerCases(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
dockerCases.filter((item) => item.name !== name)
|
||||
);
|
||||
// Best-effort `docker rm -f` the per-case container (case-delete is the
|
||||
// explicit teardown that removes it; the bind-mounted workspace survives).
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
if (host) {
|
||||
const sessionDocker = toSessionDocker(host, dockerCase);
|
||||
try {
|
||||
exec(buildDockerRemoveCommand(sessionDocker), { timeout: 15_000 }, () => {});
|
||||
} catch {
|
||||
/* best-effort — never blocks the unlink */
|
||||
}
|
||||
// Remove the per-container claude-config seed file (account metadata copy).
|
||||
await fs
|
||||
.rm(join(dataPath('docker-seeds'), `${sessionDocker.containerName}.json`), { force: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'docker-unlinked' });
|
||||
return { success: true, data: { name } };
|
||||
}
|
||||
|
||||
// Check linked cases first — unlink only, don't delete the actual directory
|
||||
const linkedCases = await readLinkedCases();
|
||||
if (linkedCases[name]) {
|
||||
@@ -233,6 +836,45 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
const remoteCase = remoteCases.find((item) => item.name === name);
|
||||
if (remoteCase) {
|
||||
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
return {
|
||||
name,
|
||||
path: remoteDisplayPath({ username: host.username, host: host.host, path: remoteCase.remotePath }),
|
||||
hasClaudeMd: false,
|
||||
location: 'remote',
|
||||
remote: {
|
||||
hostId: host.id,
|
||||
host: host.host,
|
||||
username: host.username,
|
||||
path: remoteCase.remotePath,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name);
|
||||
if (dockerCase) {
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
const container = dockerCase.container ?? dockerContainerName(dockerCase.name);
|
||||
return {
|
||||
name,
|
||||
path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }),
|
||||
hasClaudeMd: existsSync(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
|
||||
location: 'docker',
|
||||
docker: {
|
||||
hostId: host.id,
|
||||
container,
|
||||
image: host.image,
|
||||
path: dockerCase.hostWorkspacePath,
|
||||
network: host.network ?? 'bridge',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const casePath = await resolveCasePath(name);
|
||||
|
||||
if (!existsSync(casePath)) {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* @fileoverview Cron Jobs routes.
|
||||
*
|
||||
* CRUD + enable/disable + Run Now + run history for `CronJob`s. These are
|
||||
* separate from the legacy `/api/scheduled` (ScheduledRun) endpoints — see
|
||||
* docs/cron-discovery.md §0.
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { CronJobSchema, CronJobUpdateSchema, CronJobEnabledSchema } from '../schemas.js';
|
||||
import { parseBody } from '../route-helpers.js';
|
||||
import type { CronPort } from '../ports/index.js';
|
||||
|
||||
export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
|
||||
// ── Jobs ────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/cron/jobs', async () => {
|
||||
return ctx.cron.listJobs();
|
||||
});
|
||||
|
||||
app.post('/api/cron/jobs', async (req) => {
|
||||
// No custom errorMessage: surface the schema's field-specific messages
|
||||
// (e.g. "runAt is required for a one-time schedule").
|
||||
const body = parseBody(CronJobSchema, req.body);
|
||||
return { job: ctx.cron.createJob(body) };
|
||||
});
|
||||
|
||||
app.get('/api/cron/jobs/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const job = ctx.cron.getJob(id);
|
||||
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
return job;
|
||||
});
|
||||
|
||||
app.put('/api/cron/jobs/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(CronJobUpdateSchema, req.body);
|
||||
const job = ctx.cron.updateJob(id, body);
|
||||
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
return { job };
|
||||
});
|
||||
|
||||
app.delete('/api/cron/jobs/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
if (!ctx.cron.deleteJob(id)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
app.put('/api/cron/jobs/:id/enabled', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { enabled } = parseBody(CronJobEnabledSchema, req.body, 'Invalid request body');
|
||||
const job = ctx.cron.setEnabled(id, enabled);
|
||||
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
return { job };
|
||||
});
|
||||
|
||||
// ── Run Now ──────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/api/cron/jobs/:id/run', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const job = ctx.cron.getJob(id);
|
||||
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
const run = await ctx.cron.runNow(id);
|
||||
return { run, activeAgents: ctx.cron.countActiveAgents(job.agentType, job.id) };
|
||||
});
|
||||
|
||||
// ── Run history ──────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/cron/jobs/:id/runs', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
return ctx.cron.listRuns(id);
|
||||
});
|
||||
|
||||
app.get('/api/cron/runs', async () => {
|
||||
return ctx.cron.listRuns();
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export { registerTeamRoutes } from './team-routes.js';
|
||||
export { registerMuxRoutes } from './mux-routes.js';
|
||||
export { registerFileRoutes } from './file-routes.js';
|
||||
export { registerScheduledRoutes } from './scheduled-routes.js';
|
||||
export { registerCronRoutes } from './cron-routes.js';
|
||||
export { registerSystemRoutes } from './system-routes.js';
|
||||
export { registerHookEventRoutes } from './hook-event-routes.js';
|
||||
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
|
||||
@@ -17,4 +18,5 @@ export { registerRalphRoutes } from './ralph-routes.js';
|
||||
export { registerPlanRoutes } from './plan-routes.js';
|
||||
export { registerOrchestratorRoutes } from './orchestrator-routes.js';
|
||||
export { registerClipboardRoutes } from './clipboard-routes.js';
|
||||
export { registerSearchRoutes } from './search-routes.js';
|
||||
export { registerWsRoutes } from './ws-routes.js';
|
||||
|
||||
@@ -32,17 +32,16 @@ export function registerRalphRoutes(
|
||||
// Configure Ralph tracker for a session
|
||||
app.post('/api/sessions/:id/ralph-config', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = parseBody(
|
||||
RalphConfigSchema,
|
||||
req.body,
|
||||
'Invalid request body'
|
||||
) as {
|
||||
enabled?: boolean;
|
||||
completionPhrase?: string;
|
||||
maxIterations?: number;
|
||||
reset?: boolean | 'full';
|
||||
disableAutoEnable?: boolean;
|
||||
};
|
||||
const { enabled, completionPhrase, maxIterations, maxTodos, todoExpirationMinutes, reset, disableAutoEnable } =
|
||||
parseBody(RalphConfigSchema, req.body, 'Invalid request body') as {
|
||||
enabled?: boolean;
|
||||
completionPhrase?: string;
|
||||
maxIterations?: number;
|
||||
maxTodos?: number;
|
||||
todoExpirationMinutes?: number;
|
||||
reset?: boolean | 'full';
|
||||
disableAutoEnable?: boolean;
|
||||
};
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
// Ralph tracker is not supported for external-CLI sessions (opencode/codex)
|
||||
@@ -98,6 +97,14 @@ export function registerRalphRoutes(
|
||||
session.ralphTracker.setMaxIterations(maxIterations || null);
|
||||
}
|
||||
|
||||
if (maxTodos !== undefined) {
|
||||
session.ralphTracker.setMaxTodos(maxTodos);
|
||||
}
|
||||
|
||||
if (todoExpirationMinutes !== undefined) {
|
||||
session.ralphTracker.setTodoExpirationMinutes(todoExpirationMinutes);
|
||||
}
|
||||
|
||||
// Persist and broadcast the update
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast(SseEvent.SessionRalphLoopUpdate, {
|
||||
|
||||
@@ -246,6 +246,10 @@ export function registerRespawnRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Re-attach listener wiring if a prior PTY exit detached it (the wiring exit
|
||||
// handler removes ALL session listeners; idempotent — no-op while still attached).
|
||||
await ctx.setupSessionListeners(session);
|
||||
|
||||
// Start interactive session
|
||||
await session.startInteractive();
|
||||
getLifecycleLog().log({
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* @fileoverview Cross-session federated search route (COD-9).
|
||||
*
|
||||
* Registers `GET /api/search?q=&types=&limit=` — a bounded, in-memory search
|
||||
* across three v1 sources, returned in the standard ApiResponse envelope:
|
||||
* 1. sessions/cases — name, working directory, session id
|
||||
* 2. run-summary events — event title/details (from the live run-summary trackers)
|
||||
* 3. file paths — per-session attachment history (workspace-relative paths only)
|
||||
*
|
||||
* This route is a THIN wrapper: it harvests the source arrays from the live
|
||||
* server stores (held on the route context) in a bounded way, then delegates
|
||||
* grouping/ranking/capping to the pure `searchSources()` core in
|
||||
* `src/search-service.ts`. Terminal-buffer scanning and any persisted index are
|
||||
* out of scope for v1.
|
||||
*
|
||||
* Safety: query input is Zod-validated (length-bounded `q`, allowlisted `types`,
|
||||
* numeric `limit`); only workspace-relative file paths are ever exposed (the
|
||||
* server-private `externalPath` on attachment history is never read here); and
|
||||
* the pure core enforces a per-group and total result cap so a broad query
|
||||
* cannot return an unbounded payload. No terminal output is read.
|
||||
*
|
||||
* Endpoints: GET /api/search
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { parseBody } from '../route-helpers.js';
|
||||
import { SearchQuerySchema } from '../schemas.js';
|
||||
import {
|
||||
searchSources,
|
||||
type SearchSources,
|
||||
type SessionSearchInput,
|
||||
type EventSearchInput,
|
||||
type FileSearchInput,
|
||||
} from '../../search-service.js';
|
||||
import type { SearchSourceType } from '../../types/search.js';
|
||||
import type { SessionPort, InfraPort } from '../ports/index.js';
|
||||
|
||||
/**
|
||||
* Per-source harvest caps. These bound how much in-memory data we hand to the
|
||||
* pure core BEFORE it applies its own result caps — they keep the harvest itself
|
||||
* cheap on large deployments (e.g. 50 sessions × many events). They are
|
||||
* deliberately well above the result caps so ranking still sees enough candidates.
|
||||
*/
|
||||
const MAX_EVENTS_PER_SESSION = 500;
|
||||
|
||||
interface SessionLike {
|
||||
id: string;
|
||||
name: string;
|
||||
workingDir: string;
|
||||
lastActivityAt?: number;
|
||||
createdAt?: number;
|
||||
attachmentHistory?: Array<{
|
||||
id: string;
|
||||
fileName: string;
|
||||
relativePath?: string;
|
||||
timestamp?: number;
|
||||
mtimeMs?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Harvest the three source arrays from the live in-memory stores. Reads only
|
||||
* bounded, already-loaded data — no disk I/O, no terminal buffers.
|
||||
*/
|
||||
function harvestSources(ctx: SessionPort & InfraPort): SearchSources {
|
||||
const sessions: SessionSearchInput[] = [];
|
||||
const events: EventSearchInput[] = [];
|
||||
const files: FileSearchInput[] = [];
|
||||
|
||||
for (const raw of ctx.sessions.values()) {
|
||||
const s = raw as unknown as SessionLike;
|
||||
const sessionName = s.name ?? '';
|
||||
const timestamp = s.lastActivityAt ?? s.createdAt ?? 0;
|
||||
|
||||
sessions.push({
|
||||
sessionId: s.id,
|
||||
sessionName,
|
||||
workingDir: s.workingDir ?? '',
|
||||
timestamp,
|
||||
});
|
||||
|
||||
// Files: per-session attachment history. Only the workspace-relative path is
|
||||
// surfaced; the server-private externalPath is intentionally never read.
|
||||
const history = s.attachmentHistory ?? [];
|
||||
for (const item of history) {
|
||||
files.push({
|
||||
sessionId: s.id,
|
||||
sessionName,
|
||||
fileName: item.fileName,
|
||||
relativePath: item.relativePath,
|
||||
timestamp: item.timestamp ?? item.mtimeMs ?? timestamp,
|
||||
itemId: item.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Events: from the live run-summary trackers, keyed by session id.
|
||||
for (const [sessionId, tracker] of ctx.runSummaryTrackers) {
|
||||
const session = ctx.sessions.get(sessionId) as unknown as SessionLike | undefined;
|
||||
const sessionName = session?.name ?? '';
|
||||
const summary = tracker.getSummary();
|
||||
// Newest events are most relevant; cap the per-session harvest.
|
||||
const evts = summary.events.slice(-MAX_EVENTS_PER_SESSION);
|
||||
for (const e of evts) {
|
||||
events.push({
|
||||
sessionId,
|
||||
sessionName,
|
||||
eventId: e.id,
|
||||
title: e.title,
|
||||
details: e.details ?? '',
|
||||
timestamp: e.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { sessions, events, files };
|
||||
}
|
||||
|
||||
export function registerSearchRoutes(app: FastifyInstance, ctx: SessionPort & InfraPort): void {
|
||||
app.get('/api/search', async (req) => {
|
||||
// Zod-validate the query. parseBody throws a structured 400 on failure.
|
||||
const { q, types, limit } = parseBody(SearchQuerySchema, req.query);
|
||||
|
||||
const allowed: Set<SearchSourceType> | null = types
|
||||
? new Set(
|
||||
types
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean) as SearchSourceType[]
|
||||
)
|
||||
: null;
|
||||
|
||||
const sources = harvestSources(ctx);
|
||||
|
||||
// Apply the optional source-type filter before searching so excluded
|
||||
// sources never contribute to (or consume budget in) the result set.
|
||||
const filtered: SearchSources = {
|
||||
sessions: !allowed || allowed.has('session') ? sources.sessions : [],
|
||||
events: !allowed || allowed.has('event') ? sources.events : [],
|
||||
files: !allowed || allowed.has('file') ? sources.files : [],
|
||||
};
|
||||
|
||||
const result = searchSources(q, filtered);
|
||||
|
||||
// Optional caller-supplied total cap (always on top of the core's hard caps).
|
||||
if (limit !== undefined && result.totalResults > limit) {
|
||||
let remaining = limit;
|
||||
const cappedGroups = [];
|
||||
for (const group of result.groups) {
|
||||
if (remaining <= 0) break;
|
||||
const slice = group.results.slice(0, remaining);
|
||||
remaining -= slice.length;
|
||||
cappedGroups.push({ type: group.type, results: slice });
|
||||
}
|
||||
result.groups = cappedGroups;
|
||||
result.totalResults = limit;
|
||||
result.truncated = true;
|
||||
}
|
||||
|
||||
return { success: true, data: result };
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,12 @@ import { imageWatcher } from '../../image-watcher.js';
|
||||
import { workflowRunWatcher } from '../../workflow-run-watcher.js';
|
||||
import { applyStatusLineConfig } from '../../hooks-config.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import {
|
||||
buildAwayDigest,
|
||||
resolveAwayDigestRange,
|
||||
type AwayDigestSession,
|
||||
type AwayDigestSubagent,
|
||||
} from '../away-digest.js';
|
||||
import {
|
||||
findSessionOrFail,
|
||||
formatUptime,
|
||||
@@ -43,6 +49,7 @@ import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '..
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
|
||||
import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js';
|
||||
import { resolveTerminalHistoryConfig } from '../../config/terminal-history.js';
|
||||
|
||||
// Maximum screenshot upload size (10MB)
|
||||
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
|
||||
@@ -52,6 +59,12 @@ const SCREENSHOTS_DIR = dataPath('screenshots');
|
||||
/** Cached CPU count — doesn't change at runtime */
|
||||
const CPU_COUNT = cpus().length;
|
||||
|
||||
function parseOptionalNumber(value: string | undefined): number | undefined {
|
||||
if (value === undefined || value.trim() === '') return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : Number.NaN;
|
||||
}
|
||||
|
||||
/** Get system CPU and memory usage */
|
||||
function getSystemStats(): {
|
||||
cpu: number;
|
||||
@@ -331,7 +344,7 @@ export function registerSystemRoutes(
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CLI Integrations (OpenCode)
|
||||
// CLI Integrations (OpenCode, Codex, Gemini)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== OpenCode ==========
|
||||
@@ -352,6 +365,16 @@ export function registerSystemRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ========== Gemini ==========
|
||||
|
||||
app.get('/api/gemini/status', async () => {
|
||||
const { isGeminiAvailable, resolveGeminiDir } = await import('../../utils/gemini-cli-resolver.js');
|
||||
return {
|
||||
available: isGeminiAvailable(),
|
||||
path: resolveGeminiDir(),
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// State & Lifecycle (cleanup, lifecycle log, stats)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -415,6 +438,56 @@ export function registerSystemRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/away-digest', async (req, reply) => {
|
||||
const query = req.query as {
|
||||
range?: string;
|
||||
since?: string;
|
||||
until?: string;
|
||||
lastViewed?: string;
|
||||
};
|
||||
|
||||
let range;
|
||||
try {
|
||||
range = resolveAwayDigestRange({
|
||||
range: query.range,
|
||||
since: parseOptionalNumber(query.since),
|
||||
until: parseOptionalNumber(query.until),
|
||||
lastViewed: parseOptionalNumber(query.lastViewed),
|
||||
});
|
||||
} catch (err) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
|
||||
}
|
||||
|
||||
const lifecycleLog = getLifecycleLog();
|
||||
const lifecycleEntries = await lifecycleLog.query({
|
||||
since: range.since,
|
||||
limit: 1000,
|
||||
});
|
||||
|
||||
const sessions: AwayDigestSession[] = Array.from(ctx.sessions.values()).map((session) => ({
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
status: session.status,
|
||||
inputTokens: session.inputTokens,
|
||||
outputTokens: session.outputTokens,
|
||||
totalCost: session.totalCost,
|
||||
}));
|
||||
|
||||
const runSummaries = Array.from(ctx.runSummaryTrackers.values()).map((tracker) => tracker.getSummary());
|
||||
const digest = buildAwayDigest({
|
||||
range,
|
||||
lifecycleEntries,
|
||||
runSummaries,
|
||||
sessions,
|
||||
dailyTokenStats: ctx.store.getDailyStats(30),
|
||||
subagents: subagentWatcher.getRecentSubagents(60) as AwayDigestSubagent[],
|
||||
now: range.until,
|
||||
});
|
||||
|
||||
return { success: true, digest };
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Configuration & Settings (config, settings, model config, CPU priority)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -552,6 +625,11 @@ export function registerSystemRoutes(
|
||||
const merged = { ...existing, ...settingsToStore };
|
||||
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
|
||||
|
||||
// Apply a changed tmux history-limit to all live sessions immediately.
|
||||
if (settings.tmuxHistoryLimit !== undefined) {
|
||||
await ctx.mux.setHistoryLimit(resolveTerminalHistoryConfig(merged).tmuxHistoryLimit);
|
||||
}
|
||||
|
||||
// Handle subagent tracking toggle dynamically
|
||||
toggleService((settings.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
|
||||
|
||||
|
||||
+214
-155
@@ -21,8 +21,11 @@
|
||||
* {"t":"o","d":"..."} — terminal output
|
||||
* {"t":"c"} — clear terminal
|
||||
* {"t":"r"} — needs refresh (reload buffer)
|
||||
* {"t":"ia","seq":N} — input ACK (echoes the seq of an applied/deduped input frame)
|
||||
* Client -> Server:
|
||||
* {"t":"i","d":"..."} — input (keystroke or paste)
|
||||
* {"t":"i","d":"...","seq":N,"cid":"..."} — input (keystroke or paste). seq+cid are
|
||||
* optional reliable-delivery tags: the server applies each
|
||||
* (cid,seq) at-most-once and ACKs with {"t":"ia","seq":N}.
|
||||
* {"t":"z","c":N,"r":N,"f":bool} — resize terminal (f=true forces SIGWINCH even if dims unchanged)
|
||||
*/
|
||||
|
||||
@@ -31,6 +34,7 @@ import type { WebSocket } from 'ws';
|
||||
import type { SessionPort } from '../ports/session-port.js';
|
||||
import { MAX_INPUT_LENGTH } from '../../config/terminal-limits.js';
|
||||
import { isAllowedRequestHost, isAllowedRequestOrigin, type HostPolicy } from '../network-auth-policy.js';
|
||||
import { WsConnectionRegistry } from '../ws-connection-registry.js';
|
||||
|
||||
/** Micro-batch interval for terminal output (ms). Short enough for low latency,
|
||||
* long enough to group Ink's rapid cursor-up redraw sequences into single frames. */
|
||||
@@ -56,185 +60,240 @@ const DEC_2026_END = '\x1b[?2026l';
|
||||
/** Max concurrent WS connections per session. Prevents listener/bandwidth multiplication. */
|
||||
const MAX_WS_PER_SESSION = 5;
|
||||
|
||||
/** Track active WS connections per session for connection limiting. */
|
||||
const sessionWsCount = new Map<string, number>();
|
||||
/**
|
||||
* Track live WS connections per session, keyed by clientId (COD-137).
|
||||
* Replaces a bare counter that over-counted across the async-close gap on
|
||||
* reconnect (spurious 4008). A same-`cid` reconnect supersedes its own socket
|
||||
* (reclaims the slot) instead of consuming a new one; cid-less upgrades are
|
||||
* admitted anonymously up to the cap. See ws-connection-registry.ts.
|
||||
*/
|
||||
const sessionWsRegistry = new WsConnectionRegistry<WebSocket>(MAX_WS_PER_SESSION);
|
||||
|
||||
export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHostPolicy: () => HostPolicy): void {
|
||||
app.get<{ Params: { id: string } }>('/ws/sessions/:id/terminal', { websocket: true }, (socket: WebSocket, req) => {
|
||||
// Reject cross-site WebSocket hijacking (CSWSH) and DNS-rebinding before doing
|
||||
// anything: the upgrade must come from an allowed Host and (when the browser
|
||||
// sends one — it always does for WS) a same-site Origin. Writing to this socket
|
||||
// injects keystrokes into a --dangerously-skip-permissions agent, so this gate
|
||||
// matters even on the default no-password install. See security review H5.
|
||||
const policy = getHostPolicy();
|
||||
if (!isAllowedRequestHost(req.headers.host, policy) || !isAllowedRequestOrigin(req.headers.origin, policy)) {
|
||||
socket.close(4003, 'Forbidden');
|
||||
return;
|
||||
}
|
||||
app.get<{ Params: { id: string }; Querystring: { cid?: string } }>(
|
||||
'/ws/sessions/:id/terminal',
|
||||
{ websocket: true },
|
||||
(socket: WebSocket, req) => {
|
||||
// Reject cross-site WebSocket hijacking (CSWSH) and DNS-rebinding before doing
|
||||
// anything: the upgrade must come from an allowed Host and (when the browser
|
||||
// sends one — it always does for WS) a same-site Origin. Writing to this socket
|
||||
// injects keystrokes into a --dangerously-skip-permissions agent, so this gate
|
||||
// matters even on the default no-password install. See security review H5.
|
||||
const policy = getHostPolicy();
|
||||
if (!isAllowedRequestHost(req.headers.host, policy) || !isAllowedRequestOrigin(req.headers.origin, policy)) {
|
||||
socket.close(4003, 'Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const session = ctx.sessions.get(id);
|
||||
const { id } = req.params;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
socket.close(4004, 'Session not found');
|
||||
return;
|
||||
}
|
||||
if (!session) {
|
||||
socket.close(4004, 'Session not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Enforce per-session connection limit
|
||||
const currentCount = sessionWsCount.get(id) ?? 0;
|
||||
if (currentCount >= MAX_WS_PER_SESSION) {
|
||||
socket.close(4008, 'Too many connections');
|
||||
return;
|
||||
}
|
||||
sessionWsCount.set(id, currentCount + 1);
|
||||
// Structured transport logging — surfaces WS open/close/timeout churn so the
|
||||
// tunnel-flap behavior (COD-134) is observable in the server logs. Fastify is
|
||||
// configured logger:false, so we log via console (→ journald under systemd).
|
||||
|
||||
// Swallow socket errors — cleanup happens in 'close'
|
||||
socket.on('error', () => {});
|
||||
// Enforce per-session connection limit, scoped by clientId. A same-cid
|
||||
// reconnect reclaims its own slot (registry evicts the stale socket), so a
|
||||
// drop+reconnect burst can no longer over-count across the async-close gap
|
||||
// and trip a spurious 4008. cid-less upgrades are admitted anonymously.
|
||||
const cid = typeof req.query?.cid === 'string' && req.query.cid.length > 0 ? req.query.cid : null;
|
||||
const { admitted, evictedSocket } = sessionWsRegistry.register(id, cid, socket);
|
||||
if (!admitted) {
|
||||
console.warn('[ws] terminal rejected: too many connections', {
|
||||
sessionId: id,
|
||||
wsCount: sessionWsRegistry.liveCount(id),
|
||||
});
|
||||
socket.close(4008, 'Too many connections');
|
||||
return;
|
||||
}
|
||||
if (evictedSocket) {
|
||||
// Same client reconnected; retire the stale socket so it doesn't linger.
|
||||
console.info('[ws] terminal superseded by reconnect', { sessionId: id });
|
||||
try {
|
||||
evictedSocket.close(4010, 'Superseded by reconnect');
|
||||
} catch {
|
||||
/* socket may already be closing */
|
||||
}
|
||||
}
|
||||
console.info('[ws] terminal open', { sessionId: id, wsCount: sessionWsRegistry.liveCount(id) });
|
||||
|
||||
// Per-connection micro-batch state
|
||||
let batchChunks: string[] = [];
|
||||
let batchSize = 0;
|
||||
let batchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// Eagerly free the slot on error/terminate — don't wait for the async
|
||||
// 'close' (idempotent with the 'close' handler below). This is what kills
|
||||
// the reconnect over-count: the slot is released the instant the socket dies.
|
||||
socket.on('error', () => {
|
||||
sessionWsRegistry.unregister(id, socket);
|
||||
});
|
||||
|
||||
const flushBatch = () => {
|
||||
batchTimer = null;
|
||||
if (batchChunks.length === 0 || socket.readyState !== 1) {
|
||||
// Per-connection micro-batch state
|
||||
let batchChunks: string[] = [];
|
||||
let batchSize = 0;
|
||||
let batchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const flushBatch = () => {
|
||||
batchTimer = null;
|
||||
if (batchChunks.length === 0 || socket.readyState !== 1) {
|
||||
batchChunks = [];
|
||||
batchSize = 0;
|
||||
return;
|
||||
}
|
||||
const data = batchChunks.join('');
|
||||
batchChunks = [];
|
||||
batchSize = 0;
|
||||
return;
|
||||
}
|
||||
const data = batchChunks.join('');
|
||||
batchChunks = [];
|
||||
batchSize = 0;
|
||||
socket.send(`{"t":"o","d":${JSON.stringify(DEC_2026_START + data + DEC_2026_END)}}`);
|
||||
};
|
||||
socket.send(`{"t":"o","d":${JSON.stringify(DEC_2026_START + data + DEC_2026_END)}}`);
|
||||
};
|
||||
|
||||
// Per-connection desktop sizing claim — registered on the first
|
||||
// desktop-typed resize and released on socket close, so Session.resize()
|
||||
// can ignore small-viewport resizes only while a desktop is actually
|
||||
// connected (see Session._desktopSizeClaims).
|
||||
const sizingToken = Symbol('ws-desktop-sizing');
|
||||
let holdsDesktopClaim = false;
|
||||
// Per-connection desktop sizing claim — registered on the first
|
||||
// desktop-typed resize and released on socket close, so Session.resize()
|
||||
// can ignore small-viewport resizes only while a desktop is actually
|
||||
// connected (see Session._desktopSizeClaims).
|
||||
const sizingToken = Symbol('ws-desktop-sizing');
|
||||
let holdsDesktopClaim = false;
|
||||
|
||||
// Attach message handler synchronously BEFORE any async work
|
||||
// (@fastify/websocket requirement to avoid dropped messages).
|
||||
socket.on('message', (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(String(raw));
|
||||
if (msg.t === 'i' && typeof msg.d === 'string') {
|
||||
if (msg.d.length > MAX_INPUT_LENGTH) return;
|
||||
// Typed input from a claim-holding desktop keeps the claim "hot"
|
||||
// and re-asserts the desktop layout after a mobile override.
|
||||
if (holdsDesktopClaim) session.noteDesktopActivity();
|
||||
session.write(msg.d);
|
||||
} else if (
|
||||
msg.t === 'z' &&
|
||||
Number.isInteger(msg.c) &&
|
||||
Number.isInteger(msg.r) &&
|
||||
msg.c >= 1 &&
|
||||
msg.c <= 500 &&
|
||||
msg.r >= 1 &&
|
||||
msg.r <= 200
|
||||
) {
|
||||
const viewportType = msg.v === 'mobile' || msg.v === 'tablet' || msg.v === 'desktop' ? msg.v : undefined;
|
||||
if (viewportType === 'desktop') {
|
||||
session.claimDesktopSizing(sizingToken);
|
||||
holdsDesktopClaim = true;
|
||||
} else if (viewportType) {
|
||||
// The connection's viewport can change (e.g. browser window
|
||||
// narrowed past the tablet breakpoint) — drop a stale claim.
|
||||
session.releaseDesktopSizing(sizingToken);
|
||||
holdsDesktopClaim = false;
|
||||
// Attach message handler synchronously BEFORE any async work
|
||||
// (@fastify/websocket requirement to avoid dropped messages).
|
||||
socket.on('message', (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(String(raw));
|
||||
if (msg.t === 'i' && typeof msg.d === 'string') {
|
||||
if (msg.d.length > MAX_INPUT_LENGTH) return;
|
||||
// Reliable delivery: when the frame carries a clientId + seq, apply it
|
||||
// exactly once (skip a duplicate redelivery) but ACK it regardless so
|
||||
// the client can drop it from its durable queue. Frames without seq
|
||||
// (legacy/other tools) are applied as-is — no behavior change.
|
||||
const cid = typeof msg.cid === 'string' ? msg.cid : null;
|
||||
const seq = Number.isInteger(msg.seq) ? (msg.seq as number) : null;
|
||||
const apply = cid && seq !== null ? session.shouldApplyInput(cid, seq) : true;
|
||||
if (apply) {
|
||||
// Typed input from a claim-holding desktop keeps the claim "hot"
|
||||
// and re-asserts the desktop layout after a mobile override.
|
||||
if (holdsDesktopClaim) session.noteDesktopActivity();
|
||||
session.write(msg.d);
|
||||
}
|
||||
if (seq !== null && socket.readyState === 1) {
|
||||
socket.send(`{"t":"ia","seq":${seq}}`);
|
||||
}
|
||||
} else if (
|
||||
msg.t === 'z' &&
|
||||
Number.isInteger(msg.c) &&
|
||||
Number.isInteger(msg.r) &&
|
||||
msg.c >= 1 &&
|
||||
msg.c <= 500 &&
|
||||
msg.r >= 1 &&
|
||||
msg.r <= 200
|
||||
) {
|
||||
const viewportType = msg.v === 'mobile' || msg.v === 'tablet' || msg.v === 'desktop' ? msg.v : undefined;
|
||||
if (viewportType === 'desktop') {
|
||||
session.claimDesktopSizing(sizingToken);
|
||||
holdsDesktopClaim = true;
|
||||
} else if (viewportType) {
|
||||
// The connection's viewport can change (e.g. browser window
|
||||
// narrowed past the tablet breakpoint) — drop a stale claim.
|
||||
session.releaseDesktopSizing(sizingToken);
|
||||
holdsDesktopClaim = false;
|
||||
}
|
||||
const force = msg.f === true;
|
||||
session.resize(msg.c, msg.r, { viewportType, force });
|
||||
}
|
||||
const force = msg.f === true;
|
||||
session.resize(msg.c, msg.r, { viewportType, force });
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Terminal output -> micro-batched WS send
|
||||
const onTerminal = (data: string) => {
|
||||
if (socket.readyState !== 1) return;
|
||||
batchChunks.push(data);
|
||||
batchSize += data.length;
|
||||
// Terminal output -> micro-batched WS send
|
||||
const onTerminal = (data: string) => {
|
||||
if (socket.readyState !== 1) return;
|
||||
batchChunks.push(data);
|
||||
batchSize += data.length;
|
||||
|
||||
// Flush immediately for large batches (responsiveness during bulk output)
|
||||
if (batchSize > WS_BATCH_FLUSH_THRESHOLD) {
|
||||
if (batchTimer) {
|
||||
clearTimeout(batchTimer);
|
||||
// Flush immediately for large batches (responsiveness during bulk output)
|
||||
if (batchSize > WS_BATCH_FLUSH_THRESHOLD) {
|
||||
if (batchTimer) {
|
||||
clearTimeout(batchTimer);
|
||||
}
|
||||
flushBatch();
|
||||
return;
|
||||
}
|
||||
flushBatch();
|
||||
return;
|
||||
}
|
||||
|
||||
// Start timer if not already running
|
||||
if (!batchTimer) {
|
||||
batchTimer = setTimeout(flushBatch, WS_BATCH_INTERVAL_MS);
|
||||
}
|
||||
};
|
||||
// Start timer if not already running
|
||||
if (!batchTimer) {
|
||||
batchTimer = setTimeout(flushBatch, WS_BATCH_INTERVAL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
const onClearTerminal = () => {
|
||||
if (socket.readyState === 1) {
|
||||
socket.send('{"t":"c"}');
|
||||
}
|
||||
};
|
||||
const onClearTerminal = () => {
|
||||
if (socket.readyState === 1) {
|
||||
socket.send('{"t":"c"}');
|
||||
}
|
||||
};
|
||||
|
||||
const onNeedsRefresh = () => {
|
||||
if (socket.readyState === 1) {
|
||||
socket.send('{"t":"r"}');
|
||||
}
|
||||
};
|
||||
const onNeedsRefresh = () => {
|
||||
if (socket.readyState === 1) {
|
||||
socket.send('{"t":"r"}');
|
||||
}
|
||||
};
|
||||
|
||||
// Close WS when session exits (deleted, respawned, or crashed) — prevents
|
||||
// orphaned listeners and stale writes to a dead PTY.
|
||||
const onSessionExit = () => {
|
||||
socket.close(4009, 'Session terminated');
|
||||
};
|
||||
// Close WS when session exits (deleted, respawned, or crashed) — prevents
|
||||
// orphaned listeners and stale writes to a dead PTY.
|
||||
const onSessionExit = () => {
|
||||
socket.close(4009, 'Session terminated');
|
||||
};
|
||||
|
||||
session.on('terminal', onTerminal);
|
||||
session.on('clearTerminal', onClearTerminal);
|
||||
session.on('needsRefresh', onNeedsRefresh);
|
||||
session.on('exit', onSessionExit);
|
||||
session.on('terminal', onTerminal);
|
||||
session.on('clearTerminal', onClearTerminal);
|
||||
session.on('needsRefresh', onNeedsRefresh);
|
||||
session.on('exit', onSessionExit);
|
||||
|
||||
// Heartbeat: detect stale connections (especially through tunnels where
|
||||
// TCP RST can take minutes to propagate).
|
||||
let pongTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
// Heartbeat: detect stale connections (especially through tunnels where
|
||||
// TCP RST can take minutes to propagate).
|
||||
let pongTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
socket.on('pong', () => {
|
||||
if (pongTimeout) {
|
||||
clearTimeout(pongTimeout);
|
||||
pongTimeout = null;
|
||||
}
|
||||
});
|
||||
socket.on('pong', () => {
|
||||
if (pongTimeout) {
|
||||
clearTimeout(pongTimeout);
|
||||
pongTimeout = null;
|
||||
}
|
||||
});
|
||||
|
||||
const pingInterval = setInterval(() => {
|
||||
if (socket.readyState !== 1) return;
|
||||
socket.ping();
|
||||
pongTimeout = setTimeout(() => {
|
||||
socket.terminate();
|
||||
}, WS_PONG_TIMEOUT_MS);
|
||||
}, WS_PING_INTERVAL_MS);
|
||||
const pingInterval = setInterval(() => {
|
||||
if (socket.readyState !== 1) return;
|
||||
socket.ping();
|
||||
pongTimeout = setTimeout(() => {
|
||||
console.warn('[ws] terminal ping timeout — terminating', { sessionId: id });
|
||||
// Free the slot eagerly — terminate()'s 'close' may lag, and a client
|
||||
// reconnecting after a stale-connection drop must not be over-counted.
|
||||
sessionWsRegistry.unregister(id, socket);
|
||||
socket.terminate();
|
||||
}, WS_PONG_TIMEOUT_MS);
|
||||
}, WS_PING_INTERVAL_MS);
|
||||
|
||||
socket.on('close', () => {
|
||||
clearInterval(pingInterval);
|
||||
if (pongTimeout) clearTimeout(pongTimeout);
|
||||
if (batchTimer) clearTimeout(batchTimer);
|
||||
batchChunks = [];
|
||||
session.off('terminal', onTerminal);
|
||||
session.off('clearTerminal', onClearTerminal);
|
||||
session.off('needsRefresh', onNeedsRefresh);
|
||||
session.off('exit', onSessionExit);
|
||||
session.releaseDesktopSizing(sizingToken);
|
||||
socket.on('close', (code: number, reason: Buffer) => {
|
||||
clearInterval(pingInterval);
|
||||
if (pongTimeout) clearTimeout(pongTimeout);
|
||||
if (batchTimer) clearTimeout(batchTimer);
|
||||
batchChunks = [];
|
||||
session.off('terminal', onTerminal);
|
||||
session.off('clearTerminal', onClearTerminal);
|
||||
session.off('needsRefresh', onNeedsRefresh);
|
||||
session.off('exit', onSessionExit);
|
||||
session.releaseDesktopSizing(sizingToken);
|
||||
|
||||
// Decrement per-session connection count
|
||||
const count = sessionWsCount.get(id) ?? 1;
|
||||
if (count <= 1) {
|
||||
sessionWsCount.delete(id);
|
||||
} else {
|
||||
sessionWsCount.set(id, count - 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
// Release this socket's slot. Idempotent and identity-matched: if this
|
||||
// socket was already superseded (a same-cid reconnect took its slot) or
|
||||
// eagerly unregistered on terminate/error, this is a no-op and the
|
||||
// reconnected socket keeps the slot.
|
||||
sessionWsRegistry.unregister(id, socket);
|
||||
console.info('[ws] terminal close', {
|
||||
sessionId: id,
|
||||
code,
|
||||
reason: String(reason),
|
||||
wsCount: sessionWsRegistry.liveCount(id),
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
+430
-5
@@ -9,6 +9,12 @@
|
||||
|
||||
import { z } from 'zod';
|
||||
import { SAFE_PATH_PATTERN, isSafePushEndpoint } from '../utils/index.js';
|
||||
import {
|
||||
MAX_TERMINAL_BUFFER_BYTES,
|
||||
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||
MIN_TERMINAL_BUFFER_BYTES,
|
||||
MIN_TERMINAL_SCROLLBACK_LINES,
|
||||
} from '../config/terminal-history.js';
|
||||
|
||||
// ========== Path Validation ==========
|
||||
|
||||
@@ -46,7 +52,7 @@ const safePathSchema = z.string().max(1000).refine(isValidWorkingDir, {
|
||||
// ========== Env Var Allowlist ==========
|
||||
|
||||
/** Allowlisted env var key prefixes */
|
||||
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_'];
|
||||
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_', 'GEMINI_', 'GOOGLE_'];
|
||||
|
||||
/** Env var keys that are always blocked (security-sensitive) */
|
||||
const BLOCKED_ENV_KEYS = new Set([
|
||||
@@ -76,7 +82,7 @@ const safeEnvOverridesSchema = z
|
||||
},
|
||||
{
|
||||
message:
|
||||
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, and CODEX_* keys are allowed.',
|
||||
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, and GOOGLE_* keys are allowed.',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -149,9 +155,26 @@ const CodexConfigSchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
/** Schema for Gemini CLI-specific configuration */
|
||||
const GeminiConfigSchema = z
|
||||
.object({
|
||||
model: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9._\-/]+$/)
|
||||
.optional(),
|
||||
approvalMode: z.enum(['default', 'auto_edit', 'yolo', 'plan']).optional(),
|
||||
resumeSession: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9._-]+$/)
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const CreateSessionSchema = z.object({
|
||||
workingDir: safePathSchema.optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex']).optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(),
|
||||
name: z.string().max(100).optional(),
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
@@ -162,6 +185,7 @@ export const CreateSessionSchema = z.object({
|
||||
statusLineTelemetry: z.boolean().optional(),
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
codexConfig: CodexConfigSchema,
|
||||
geminiConfig: GeminiConfigSchema,
|
||||
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
||||
resumeSessionId: z
|
||||
.string()
|
||||
@@ -247,6 +271,260 @@ export const CreateCaseSchema = z.object({
|
||||
description: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
const RemoteCommandOverridesSchema = z
|
||||
.object({
|
||||
shell: z.string().min(1).max(300).optional(),
|
||||
claude: z.string().min(1).max(300).optional(),
|
||||
opencode: z.string().min(1).max(300).optional(),
|
||||
codex: z.string().min(1).max(300).optional(),
|
||||
gemini: z.string().min(1).max(300).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
// COD-107 — advanced SSH connection options. These ultimately exec as shell
|
||||
// (ProxyCommand etc.), but are OPERATOR-entered host config (never attacker- or
|
||||
// terminal-output-influenced), so we validate as defense-in-depth, not as the
|
||||
// security boundary. Reject newline/NUL/backtick/`$(` shell-injection vectors.
|
||||
const NO_SHELL_INJECTION = /^[^\n\r\0`]*$/;
|
||||
const noCommandSubstitution = (s: string) => !s.includes('$(');
|
||||
|
||||
// `remotePath`/`identityFile` are shell-escaped, then the whole launch command is
|
||||
// embedded via `JSON.stringify(...)` inside `bash -c "..."` (tmux-manager). That
|
||||
// outer DOUBLE-quote layer re-exposes `$(...)`, backticks, and `$VAR` even though
|
||||
// the inner value is single-quoted — so a `$(cmd)` in the path would run LOCALLY at
|
||||
// launch. Reject `$` and backtick (and newline/CR/NUL) entirely at the boundary.
|
||||
const NO_SHELL_META = /^[^\n\r\0`$]*$/;
|
||||
|
||||
export const RemoteHostSchema = z.object({
|
||||
id: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid remote host id'),
|
||||
label: z.string().min(1).max(100),
|
||||
host: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.regex(/^[a-zA-Z0-9._:-]+$/, 'Invalid SSH host'),
|
||||
username: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9._-]+$/, 'Invalid SSH username'),
|
||||
port: z.number().int().min(1).max(65535).optional(),
|
||||
// Identity (private-key) file PATH only — never key bytes. Reject shell
|
||||
// metacharacters ($, backtick) that survive into the `bash -c` launch layer.
|
||||
identityFile: z.string().min(1).max(4096).regex(NO_SHELL_META, 'Invalid identity file path').optional(),
|
||||
// SOCKS5 proxy as host:port (e.g. 127.0.0.1:1080).
|
||||
socksProxy: z
|
||||
.string()
|
||||
.regex(/^[\w.-]+:\d{1,5}$/, 'SOCKS proxy must be host:port')
|
||||
.optional(),
|
||||
// SSH jump host: a comma-separated chain of [user@]host[:port] hops. Structural
|
||||
// ALLOWLIST (not an open denylist) — only chars valid in user/host/port/IPv6,
|
||||
// so no shell metacharacter (;, |, &, space, $, quotes, …) can appear. The value
|
||||
// is also shellescaped at command-build time (buildSshConnectionArgs); this is the
|
||||
// belt to that suspenders.
|
||||
jumpHost: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.regex(
|
||||
/^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9.:[\]-]+(?::\d{1,5})?(?:,(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9.:[\]-]+(?::\d{1,5})?)*$/,
|
||||
'Jump host must be [user@]host[:port] (comma-separated for multiple hops)'
|
||||
)
|
||||
.optional(),
|
||||
// Arbitrary extra -o KEY=VALUE options (escape hatch); each must be KEY=VALUE.
|
||||
extraSshOptions: z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(1024)
|
||||
.regex(/^[A-Za-z][A-Za-z0-9]*=.+$/, 'Extra SSH option must be KEY=VALUE')
|
||||
.regex(NO_SHELL_INJECTION, 'Invalid characters in SSH option')
|
||||
.refine(noCommandSubstitution, 'Invalid characters in SSH option')
|
||||
)
|
||||
.max(32)
|
||||
.optional(),
|
||||
commands: RemoteCommandOverridesSchema,
|
||||
});
|
||||
|
||||
export const RemoteCaseLinkSchema = z.object({
|
||||
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
|
||||
hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid remote host id'),
|
||||
remotePath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.regex(/^\//, 'Remote path must be absolute')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in remote path'),
|
||||
});
|
||||
|
||||
// ========== Docker cases ==========
|
||||
//
|
||||
// Docker mode is a location overlay on cases (see docs/docker-cases-plan.md),
|
||||
// the analog of the remote-SSH schemas above. `image`, `hostWorkspacePath`,
|
||||
// `containerWorkdir`, and `container` all reach the outer `bash -c "..."` launch
|
||||
// layer, so they carry NO_SHELL_META (rejects `$`/backtick that survive the
|
||||
// double-quote layer) exactly like remotePath/identityFile. `--privileged` and
|
||||
// any docker-socket mount are structurally unrepresentable (never accepted).
|
||||
|
||||
const DockerResourceLimitsSchema = z
|
||||
.object({
|
||||
memory: z
|
||||
.string()
|
||||
.regex(/^\d+[bkmg]?$/i, 'Memory must be like 512m / 4g')
|
||||
.optional(),
|
||||
cpus: z
|
||||
.string()
|
||||
.regex(/^\d+(\.\d+)?$/, 'CPUs must be a number')
|
||||
.optional(),
|
||||
pidsLimit: z.number().int().positive().max(100000).optional(),
|
||||
nofile: z
|
||||
.string()
|
||||
.regex(/^\d+:\d+$/, 'nofile must be soft:hard')
|
||||
.optional(),
|
||||
shmSize: z
|
||||
.string()
|
||||
.regex(/^\d+[bkmg]?$/i, 'shm-size must be like 256m')
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const DockerHostSchema = z.object({
|
||||
id: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'),
|
||||
label: z.string().min(1).max(100),
|
||||
engine: z.enum(['docker', 'podman']).optional(),
|
||||
image: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(512)
|
||||
.regex(/^[a-zA-Z0-9][\w./:@-]*$/, 'Invalid image reference')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in image reference'),
|
||||
daemonHost: z.string().max(512).regex(NO_SHELL_META, 'Invalid daemon host').optional(),
|
||||
context: z
|
||||
.string()
|
||||
.max(128)
|
||||
.regex(/^[a-zA-Z0-9._-]+$/, 'Invalid docker context')
|
||||
.optional(),
|
||||
network: z.enum(['bridge', 'none', 'custom']).optional(),
|
||||
networkName: z
|
||||
.string()
|
||||
.max(128)
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid network name')
|
||||
.optional(),
|
||||
resources: DockerResourceLimitsSchema.optional(),
|
||||
gpus: z
|
||||
.string()
|
||||
.max(128)
|
||||
.regex(/^(all|\d+|device=[a-zA-Z0-9,:._-]+)$/, 'GPUs must be all / a count / device=...')
|
||||
.optional(),
|
||||
mountCredentials: z.boolean().optional(),
|
||||
hooksEnabled: z.boolean().optional(),
|
||||
resumeOnStart: z.boolean().optional(),
|
||||
commands: RemoteCommandOverridesSchema, // same shell/claude/opencode/codex/gemini shape
|
||||
extraCreateArgs: z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(1024)
|
||||
.regex(NO_SHELL_INJECTION, 'Invalid characters in create arg')
|
||||
.refine(noCommandSubstitution, 'Invalid characters in create arg')
|
||||
)
|
||||
.max(32)
|
||||
.optional(),
|
||||
extraExecArgs: z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(1024)
|
||||
.regex(NO_SHELL_INJECTION, 'Invalid characters in exec arg')
|
||||
.refine(noCommandSubstitution, 'Invalid characters in exec arg')
|
||||
)
|
||||
.max(32)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const DockerCaseLinkSchema = z.object({
|
||||
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
|
||||
hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'),
|
||||
hostWorkspacePath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.regex(/^\//, 'Workspace path must be absolute')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in workspace path'),
|
||||
containerWorkdir: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.regex(/^\//, 'Container workdir must be absolute')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in container workdir')
|
||||
.optional(),
|
||||
container: z
|
||||
.string()
|
||||
.min(2)
|
||||
.max(128)
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid container name')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const DockerExportSchema = z.object({
|
||||
mode: z.enum(['full', 'workspace']).optional(),
|
||||
});
|
||||
|
||||
export const DockerImportSchema = z.object({
|
||||
// A bare filename resolved WITHIN the exports dir (never an arbitrary path).
|
||||
bundle: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(300)
|
||||
.regex(/^[a-zA-Z0-9._-]+\.tgz$/, 'Invalid bundle filename'),
|
||||
newCaseName: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
|
||||
destWorkspacePath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.regex(/^\//, 'Destination path must be absolute')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in destination path'),
|
||||
});
|
||||
|
||||
// One-click "Run in Docker" case creation. name/description behave like a normal
|
||||
// case; the docker fields are OPTIONAL overrides of the predefined defaults (the
|
||||
// checkbox alone, with no overrides, uses the shared `default` host).
|
||||
export const DockerQuickCreateSchema = z.object({
|
||||
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
|
||||
description: z.string().max(1000).optional(),
|
||||
image: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(512)
|
||||
.regex(/^[a-zA-Z0-9][\w./:@-]*$/, 'Invalid image reference')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in image reference')
|
||||
.optional(),
|
||||
network: z.enum(['bridge', 'none', 'custom']).optional(),
|
||||
networkName: z
|
||||
.string()
|
||||
.max(128)
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid network name')
|
||||
.optional(),
|
||||
memory: z
|
||||
.string()
|
||||
.regex(/^\d+[bkmg]?$/i, 'Memory must be like 512m / 4g')
|
||||
.optional(),
|
||||
cpus: z
|
||||
.string()
|
||||
.regex(/^\d+(\.\d+)?$/, 'CPUs must be a number')
|
||||
.optional(),
|
||||
gpus: z
|
||||
.string()
|
||||
.max(128)
|
||||
.regex(/^(all|\d+|device=[a-zA-Z0-9,:._-]+)$/, 'GPUs must be all / a count / device=...')
|
||||
.optional(),
|
||||
mountCredentials: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// ========== Quick Start ==========
|
||||
|
||||
/**
|
||||
@@ -258,9 +536,13 @@ export const QuickStartSchema = z.object({
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.')
|
||||
.optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex']).optional(),
|
||||
/** Display name for the created session tab (e.g. w1-mycase). Cosmetic; the durable
|
||||
* mux/container names derive from the session id, not this. Defaults server-side. */
|
||||
sessionName: z.string().max(128).optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(),
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
codexConfig: CodexConfigSchema,
|
||||
geminiConfig: GeminiConfigSchema,
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort: effortLevelSchema,
|
||||
@@ -393,6 +675,16 @@ export const SettingsUpdateSchema = z
|
||||
allowedTools: z.string().max(2000).optional(),
|
||||
// Codex CLI settings
|
||||
codexDangerouslyBypassApprovals: z.boolean().optional(),
|
||||
// Terminal history and retention
|
||||
terminalScrollbackLines: z
|
||||
.number()
|
||||
.int()
|
||||
.min(MIN_TERMINAL_SCROLLBACK_LINES)
|
||||
.max(MAX_TERMINAL_SCROLLBACK_LINES)
|
||||
.optional(),
|
||||
tmuxHistoryLimit: z.number().int().min(MIN_TERMINAL_SCROLLBACK_LINES).max(MAX_TERMINAL_SCROLLBACK_LINES).optional(),
|
||||
terminalBufferMaxBytes: z.number().int().min(MIN_TERMINAL_BUFFER_BYTES).max(MAX_TERMINAL_BUFFER_BYTES).optional(),
|
||||
terminalBufferTrimBytes: z.number().int().min(MIN_TERMINAL_BUFFER_BYTES).max(MAX_TERMINAL_BUFFER_BYTES).optional(),
|
||||
// CPU priority
|
||||
nice: z
|
||||
.object({
|
||||
@@ -461,7 +753,20 @@ export const SettingsUpdateSchema = z
|
||||
.max(20)
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
.strict()
|
||||
.superRefine((settings, ctx) => {
|
||||
if (
|
||||
settings.terminalBufferMaxBytes !== undefined &&
|
||||
settings.terminalBufferTrimBytes !== undefined &&
|
||||
settings.terminalBufferTrimBytes > settings.terminalBufferMaxBytes
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['terminalBufferTrimBytes'],
|
||||
message: 'terminalBufferTrimBytes must be less than or equal to terminalBufferMaxBytes',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for POST /api/sessions/:id/input with length limit
|
||||
@@ -469,6 +774,15 @@ export const SettingsUpdateSchema = z
|
||||
export const SessionInputWithLimitSchema = z.object({
|
||||
input: z.string().max(100000), // 100KB max input
|
||||
useMux: z.boolean().optional(),
|
||||
// Reliable-delivery dedup (optional; absent for curl/legacy clients). The web
|
||||
// client tags each input with a stable clientId + a monotonic per-session seq
|
||||
// and redelivers anything it hasn't seen ACKed (e.g. a frame silently dropped
|
||||
// by a half-open WebSocket on a flaky link). The server applies each (clientId,
|
||||
// seq) at-most-once via Session.shouldApplyInput so a redelivery can't type the
|
||||
// prompt twice. `.optional()` (not `.nullish()`) — the client omits them when
|
||||
// unset rather than sending null. See docs/reliable-input-delivery.md.
|
||||
seq: z.number().int().nonnegative().optional(),
|
||||
clientId: z.string().max(128).optional(),
|
||||
});
|
||||
|
||||
// ========== Session Mutation Routes ==========
|
||||
@@ -488,6 +802,8 @@ export const RalphConfigSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
completionPhrase: z.string().max(500).optional(),
|
||||
maxIterations: z.number().int().min(0).max(10000).optional(),
|
||||
maxTodos: z.number().int().positive().max(10000).optional(),
|
||||
todoExpirationMinutes: z.number().int().positive().max(525600).optional(),
|
||||
reset: z.union([z.boolean(), z.literal('full')]).optional(),
|
||||
disableAutoEnable: z.boolean().optional(),
|
||||
});
|
||||
@@ -544,6 +860,73 @@ export const ScheduledRunSchema = z.object({
|
||||
durationMinutes: z.number().int().min(1).max(14400).optional(),
|
||||
});
|
||||
|
||||
// ========== Cron Jobs ==========
|
||||
|
||||
/** 'HH:MM' 24-hour time. */
|
||||
const hhmmSchema = z.string().regex(/^([01]?\d|2[0-3]):[0-5]\d$/, 'Time must be HH:MM (24-hour)');
|
||||
|
||||
/** Prompt delivery is single-line only (writeViaMux/Ink constraint) — reject newlines outright. */
|
||||
const noNewlines = (v: string) => !/[\r\n]/.test(v);
|
||||
|
||||
/** Shared field shape for creating/updating a scheduled job. */
|
||||
const CronJobBaseSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']),
|
||||
workingDir: safePathSchema,
|
||||
launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(),
|
||||
promptMode: z.enum(['inline_text', 'prompt_file_path']),
|
||||
promptText: z
|
||||
.string()
|
||||
.max(100000)
|
||||
.refine(noNewlines, 'promptText must be a single line (multi-line prompts are not supported)')
|
||||
.optional(),
|
||||
promptFilePath: safePathSchema.optional(),
|
||||
inputMode: z.enum(['paste', 'typed']),
|
||||
scheduleType: z.enum(['once', 'interval', 'daily', 'weekly']),
|
||||
runAt: z.number().int().positive().optional(),
|
||||
intervalMinutes: z.number().int().min(1).max(525600).optional(),
|
||||
dailyTime: hhmmSchema.optional(),
|
||||
weeklyDays: z.array(z.number().int().min(0).max(6)).min(1).max(7).optional(),
|
||||
weeklyTime: hhmmSchema.optional(),
|
||||
enabled: z.boolean(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
concurrencyPolicy: z.enum(['warn_only', 'skip_if_same_agent_running']),
|
||||
autoClosePreviousSession: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/** Cross-field validation: required fields depend on promptMode + scheduleType. */
|
||||
function refineCronJob(val: z.infer<typeof CronJobBaseSchema>, ctx: z.RefinementCtx): void {
|
||||
const add = (message: string, path: string) => ctx.addIssue({ code: 'custom', message, path: [path] });
|
||||
|
||||
if (val.promptMode === 'inline_text' && !val.promptText) {
|
||||
add('promptText is required when promptMode is inline_text', 'promptText');
|
||||
}
|
||||
if (val.promptMode === 'prompt_file_path' && !val.promptFilePath) {
|
||||
add('promptFilePath is required when promptMode is prompt_file_path', 'promptFilePath');
|
||||
}
|
||||
if (val.scheduleType === 'once' && val.runAt === undefined) {
|
||||
add('runAt is required for a one-time schedule', 'runAt');
|
||||
}
|
||||
if (val.scheduleType === 'interval' && val.intervalMinutes === undefined) {
|
||||
add('intervalMinutes is required for an interval schedule', 'intervalMinutes');
|
||||
}
|
||||
if (val.scheduleType === 'daily' && !val.dailyTime) {
|
||||
add('dailyTime is required for a daily schedule', 'dailyTime');
|
||||
}
|
||||
if (val.scheduleType === 'weekly' && (!val.weeklyTime || !val.weeklyDays?.length)) {
|
||||
add('weeklyDays and weeklyTime are required for a weekly schedule', 'weeklyTime');
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/cron/jobs — full job definition. */
|
||||
export const CronJobSchema = CronJobBaseSchema.superRefine(refineCronJob);
|
||||
|
||||
/** PUT /api/cron/jobs/:id — partial update. */
|
||||
export const CronJobUpdateSchema = CronJobBaseSchema.partial();
|
||||
|
||||
/** PUT /api/cron/jobs/:id/enabled */
|
||||
export const CronJobEnabledSchema = z.object({ enabled: z.boolean() });
|
||||
|
||||
/** POST /api/cases/link */
|
||||
export const LinkCaseSchema = z.object({
|
||||
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
|
||||
@@ -614,6 +997,16 @@ export const SubagentWindowStatesSchema = z
|
||||
/** PUT /api/subagent-parents */
|
||||
export const SubagentParentMapSchema = z.record(z.string(), z.string());
|
||||
|
||||
/** POST /api/sessions/:id/interactive */
|
||||
export const InteractiveStartSchema = z.object({
|
||||
/**
|
||||
* COD-118: explicit user-initiated restart — clears a tripped PTY-exit circuit
|
||||
* breaker before starting. Automatic reconnect/re-attach callers (e.g. the
|
||||
* frontend's selectSession auto-attach) must NOT send this flag.
|
||||
*/
|
||||
clearBreaker: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/** POST /api/sessions/:id/interactive-respawn */
|
||||
export const InteractiveRespawnSchema = z.object({
|
||||
respawnConfig: RespawnConfigSchema.optional(),
|
||||
@@ -699,3 +1092,35 @@ export const OrchestratorStartSchema = z.object({
|
||||
export const OrchestratorRejectSchema = z.object({
|
||||
feedback: z.string().min(1).max(10000),
|
||||
});
|
||||
|
||||
// ========== Cross-Session Search (COD-9) ==========
|
||||
|
||||
/** Valid federated source kinds for `GET /api/search?types=`. */
|
||||
export const SEARCH_SOURCE_TYPES = ['session', 'event', 'file'] as const;
|
||||
|
||||
/**
|
||||
* GET /api/search query validation.
|
||||
*
|
||||
* Query params arrive as strings: `q` is bounded (1..200 chars), `types` is an
|
||||
* optional comma-separated allowlisted CSV, and `limit` is an optional coerced
|
||||
* integer clamped to 1..60. Validation is the first line of defense — a missing
|
||||
* or oversized `q`, an unknown type, or a non-numeric limit is rejected with 400.
|
||||
*/
|
||||
export const SearchQuerySchema = z.object({
|
||||
q: z.string().trim().min(1, 'Query is required').max(200, 'Query too long (max 200 chars)'),
|
||||
types: z
|
||||
.string()
|
||||
.max(100)
|
||||
.optional()
|
||||
.refine(
|
||||
(v) =>
|
||||
v === undefined ||
|
||||
v
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
.every((t) => (SEARCH_SOURCE_TYPES as readonly string[]).includes(t)),
|
||||
{ message: 'Invalid types value' }
|
||||
),
|
||||
limit: z.coerce.number().int().min(1).max(60).optional(),
|
||||
});
|
||||
|
||||
+189
-15
@@ -40,7 +40,7 @@ import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync, statSync } from
|
||||
import fs from 'node:fs/promises';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { hostname as getHostname } from 'node:os';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
|
||||
import { getHookSecret } from '../config/hook-secret.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Session, isExternalCliMode, type BackgroundTask } from '../session.js';
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
import { imageWatcher } from '../image-watcher.js';
|
||||
import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js';
|
||||
import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js';
|
||||
import { registerGeneratedArtifactAttachment } from '../generated-artifact-attachments.js';
|
||||
import {
|
||||
buildDetectedAttachmentHistoryItem,
|
||||
buildExternalAttachmentHistoryItem,
|
||||
@@ -128,6 +129,8 @@ import {
|
||||
} from '../utils/index.js';
|
||||
import type { EventLoopMonitorHandle } from '../utils/index.js';
|
||||
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
|
||||
import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js';
|
||||
import { resolveTerminalHistoryConfig } from '../config/terminal-history.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import { getLatestPlanUsage } from './plan-usage-latest.js';
|
||||
import type { ScheduledRun } from './ports/index.js';
|
||||
@@ -149,9 +152,12 @@ import {
|
||||
registerRalphRoutes,
|
||||
registerPlanRoutes,
|
||||
registerClipboardRoutes,
|
||||
registerSearchRoutes,
|
||||
registerOrchestratorRoutes,
|
||||
registerCronRoutes,
|
||||
registerWsRoutes,
|
||||
} from './routes/index.js';
|
||||
import { CronService } from '../cron/cron-service.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -173,6 +179,7 @@ import {
|
||||
ITERATION_PAUSE_MS,
|
||||
STATS_COLLECTION_INTERVAL_MS,
|
||||
INACTIVITY_TIMEOUT_MS,
|
||||
CRON_TICK_INTERVAL,
|
||||
} from '../config/server-timing.js';
|
||||
|
||||
/**
|
||||
@@ -221,6 +228,8 @@ export class WebServer extends EventEmitter {
|
||||
// Store session listener references for explicit cleanup (prevents memory leaks)
|
||||
private sessionListenerRefs: Map<string, SessionListenerRefs> = new Map();
|
||||
private scheduledRuns: Map<string, ScheduledRun> = new Map();
|
||||
/** Cron service (assigned in setupRoutes). */
|
||||
private cronService!: CronService;
|
||||
private sse: SseStreamManager;
|
||||
private store = getStore();
|
||||
private port: number;
|
||||
@@ -279,6 +288,8 @@ export class WebServer extends EventEmitter {
|
||||
private readonly allowUnauthenticatedNetwork: boolean;
|
||||
private _pasteImageGcStop: (() => void) | null = null;
|
||||
private _eventLoopMonitor: EventLoopMonitorHandle | null = null;
|
||||
/** Opt-in hooks-only listener on the docker bridge gateway (CODEMAN_DOCKER_BRIDGE_HOOKS). */
|
||||
private _dockerBridgeServer: import('node:http').Server | import('node:https').Server | null = null;
|
||||
private teamWatcherHandlers: {
|
||||
teamCreated: (config: unknown) => void;
|
||||
teamUpdated: (config: unknown) => void;
|
||||
@@ -585,6 +596,7 @@ export class WebServer extends EventEmitter {
|
||||
getGlobalNiceConfig: this.getGlobalNiceConfig.bind(this),
|
||||
getModelConfig: this.getModelConfig.bind(this),
|
||||
getClaudeModeConfig: this.getClaudeModeConfig.bind(this),
|
||||
getTerminalHistoryConfig: this.getTerminalHistoryConfig.bind(this),
|
||||
getDefaultClaudeMdPath: this.getDefaultClaudeMdPath.bind(this),
|
||||
getLightState: this.getLightState.bind(this),
|
||||
getLightSessionsState: this.getLightSessionsState.bind(this),
|
||||
@@ -679,8 +691,8 @@ export class WebServer extends EventEmitter {
|
||||
// last byte (hard-coded \r\n offsets), and there was no part-count cap.
|
||||
await this.app.register(fastifyMultipart, {
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB per file
|
||||
files: 1, // paste-image only ever sends one file
|
||||
fileSize: MAX_PASTE_IMAGE_BYTES, // per file (default 50MB) — large phone photos / screenshots
|
||||
files: 1, // paste-image sends one file per request (clients batch up to 20 requests)
|
||||
fields: 4, // small headroom for accompanying form fields
|
||||
},
|
||||
});
|
||||
@@ -830,7 +842,13 @@ export class WebServer extends EventEmitter {
|
||||
// Keep the body as a RAW STRING and parse it inside the handler — a global
|
||||
// text/plain -> JSON parser would let a cross-site "simple request" (no CORS
|
||||
// preflight) submit JSON to any route. See security review C2.
|
||||
let _crashBreadcrumbs = '';
|
||||
// Keyed by the client's per-page-load id so (a) a page reload (fresh id)
|
||||
// ARCHIVES the previous page's breadcrumbs instead of overwriting them —
|
||||
// iOS PWA reloads used to wipe the trace of the very bug being chased —
|
||||
// and (b) concurrent clients (desktop + phone) don't clobber each other.
|
||||
// Same id replaces in place (each beacon carries the full ring buffer).
|
||||
const MAX_CRASH_PAGES = 10;
|
||||
const _crashPages = new Map<string, { at: number; data: string }>();
|
||||
this.app.addContentTypeParser('text/plain;charset=UTF-8', { parseAs: 'string' }, (_req, body, done) => {
|
||||
done(null, body);
|
||||
});
|
||||
@@ -840,17 +858,28 @@ export class WebServer extends EventEmitter {
|
||||
this.app.post('/api/crash-diag', (req, reply) => {
|
||||
const raw = typeof req.body === 'string' ? req.body : '';
|
||||
let data = raw;
|
||||
let pageId = 'legacy';
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { data?: unknown };
|
||||
const parsed = JSON.parse(raw) as { data?: unknown; id?: unknown };
|
||||
if (parsed && typeof parsed.data === 'string') data = parsed.data;
|
||||
if (parsed && typeof parsed.id === 'string' && parsed.id) pageId = parsed.id.slice(0, 64);
|
||||
} catch {
|
||||
/* not JSON — treat the raw beacon text as the breadcrumbs */
|
||||
}
|
||||
_crashBreadcrumbs = String(data || '');
|
||||
_crashPages.delete(pageId); // re-insert = move to MRU end
|
||||
_crashPages.set(pageId, { at: Date.now(), data: String(data || '') });
|
||||
if (_crashPages.size > MAX_CRASH_PAGES) {
|
||||
const oldest = _crashPages.keys().next().value;
|
||||
if (oldest !== undefined) _crashPages.delete(oldest);
|
||||
}
|
||||
reply.code(204).send();
|
||||
});
|
||||
this.app.get('/api/crash-diag', (_req, reply) => {
|
||||
reply.code(200).send({ breadcrumbs: _crashBreadcrumbs, timestamp: Date.now() });
|
||||
const pages = [..._crashPages.entries()].map(([id, p]) => ({ id, at: p.at, data: p.data }));
|
||||
const breadcrumbs = pages
|
||||
.map((p) => `═══ page ${p.id} (last beacon ${new Date(p.at).toISOString()}) ═══\n${p.data}`)
|
||||
.join('\n\n');
|
||||
reply.code(200).send({ breadcrumbs, pages, timestamp: Date.now() });
|
||||
});
|
||||
|
||||
// Register all route modules
|
||||
@@ -869,7 +898,15 @@ export class WebServer extends EventEmitter {
|
||||
registerRalphRoutes(this.app, ctx);
|
||||
registerPlanRoutes(this.app, ctx);
|
||||
registerClipboardRoutes(this.app, ctx);
|
||||
registerSearchRoutes(this.app, ctx);
|
||||
registerOrchestratorRoutes(this.app, ctx);
|
||||
|
||||
// Cron: build the service from the same context, recompute
|
||||
// due times for any persisted jobs, then expose it to its routes.
|
||||
this.cronService = new CronService(ctx);
|
||||
this.cronService.init();
|
||||
registerCronRoutes(this.app, { ...ctx, cron: this.cronService });
|
||||
|
||||
registerWsRoutes(this.app, ctx, () => this.getHostPolicy());
|
||||
}
|
||||
|
||||
@@ -1259,6 +1296,13 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
|
||||
private async setupSessionListeners(session: Session): Promise<void> {
|
||||
// Idempotent: the wiring exit handler detaches ALL listeners on every PTY exit
|
||||
// (removeSessionListenerRefs), so the re-attach routes (/interactive,
|
||||
// /interactive-respawn, /shell) call this again to restore observability
|
||||
// (terminal SSE, error/exit broadcasts, the COD-118 respawnBreakerTripped
|
||||
// handler). Skip when the refs are still attached to avoid double-wiring.
|
||||
if (this.sessionListenerRefs.has(session.id)) return;
|
||||
|
||||
// Create run summary tracker for this session
|
||||
const summaryTracker = new RunSummaryTracker(session.id, session.name);
|
||||
this.runSummaryTrackers.set(session.id, summaryTracker);
|
||||
@@ -1324,7 +1368,8 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
},
|
||||
getStore: () => this.store,
|
||||
registerAttachment: (id: string, filePath: string) => this.registerAttachment(id, filePath),
|
||||
registerAttachment: (id: string, filePath: string, source: 'external' | 'codex-generated') =>
|
||||
this.registerAttachment(id, filePath, source),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1337,16 +1382,31 @@ export class WebServer extends EventEmitter {
|
||||
* session workspace — passive magic links can't expose arbitrary host files.
|
||||
* Deliberate cross-workspace attachment goes through the explicit,
|
||||
* Origin-guarded `POST /attachments` route (and `codeman attach`, which POSTs
|
||||
* directly inside a managed session). Registration also enforces the COD-53
|
||||
* blocklist as defense-in-depth.
|
||||
* directly inside a managed session). Codex-mode `Saved to:` requests
|
||||
* (`source: 'codex-generated'`) instead go through
|
||||
* registerGeneratedArtifactAttachment, which stays force-confined unless the
|
||||
* realpath-resolved target is inside the workspace or a home-anchored
|
||||
* `~/.codex*` generated-artifact directory. Registration also enforces the
|
||||
* COD-53 blocklist as defense-in-depth.
|
||||
*/
|
||||
private async registerAttachment(sessionId: string, filePath: string): Promise<void> {
|
||||
private async registerAttachment(
|
||||
sessionId: string,
|
||||
filePath: string,
|
||||
source: 'external' | 'codex-generated'
|
||||
): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
const event = await registerExternalAttachment(sessionId, filePath, {
|
||||
sessionWorkingDir: session.workingDir,
|
||||
forceWorkspaceConfinement: true,
|
||||
});
|
||||
const event =
|
||||
source === 'codex-generated'
|
||||
? await registerGeneratedArtifactAttachment({
|
||||
sessionId,
|
||||
filePath,
|
||||
sessionWorkingDir: session.workingDir,
|
||||
})
|
||||
: await registerExternalAttachment(sessionId, filePath, {
|
||||
sessionWorkingDir: session.workingDir,
|
||||
forceWorkspaceConfinement: true,
|
||||
});
|
||||
const record = attachmentRegistry.get(sessionId, event.attachmentId);
|
||||
if (record) {
|
||||
session.upsertAttachmentHistory(
|
||||
@@ -1460,6 +1520,12 @@ export class WebServer extends EventEmitter {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Resolve the bounds-clamped terminal-history config from settings.json.
|
||||
private async getTerminalHistoryConfig() {
|
||||
const settings = await this.readSettings();
|
||||
return resolveTerminalHistoryConfig(settings);
|
||||
}
|
||||
|
||||
// Helper to get model configuration from settings
|
||||
private async getModelConfig(): Promise<{
|
||||
defaultModel?: string;
|
||||
@@ -1754,6 +1820,7 @@ export class WebServer extends EventEmitter {
|
||||
[SseEvent.HookStop]: { title: 'Response Complete', urgency: 'info' },
|
||||
[SseEvent.SessionError]: { title: 'Session Error', urgency: 'critical' },
|
||||
[SseEvent.RespawnBlocked]: { title: 'Respawn Blocked', urgency: 'critical' },
|
||||
[SseEvent.SessionRespawnBreakerTripped]: { title: 'Session crash loop stopped', urgency: 'critical' },
|
||||
[SseEvent.SessionRalphCompletionDetected]: { title: 'Task Complete', urgency: 'warning' },
|
||||
};
|
||||
|
||||
@@ -1786,6 +1853,9 @@ export class WebServer extends EventEmitter {
|
||||
} else if (event === SseEvent.SessionRalphCompletionDetected && data.phrase) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.phrase);
|
||||
} else if (event === SseEvent.SessionRespawnBreakerTripped && data.count) {
|
||||
body += body ? ' ' : '';
|
||||
body += `Stopped after ${Number(data.count)} rapid crashes — restart the session to retry`;
|
||||
} else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
|
||||
body += body ? ' ' : '';
|
||||
body += `Tool: ${String(data.tool_name)}`;
|
||||
@@ -1873,6 +1943,20 @@ export class WebServer extends EventEmitter {
|
||||
// CRITICAL: Skip in test mode to prevent tests from picking up user sessions
|
||||
if (!this.testMode) {
|
||||
await this.restoreMuxSessions();
|
||||
|
||||
// Instance-scoped reaper: after restore, `docker rm -f` managed containers of
|
||||
// THIS instance whose case is gone from docker-cases.json (best-effort, never
|
||||
// touches another instance's containers). Runs after restore so containers
|
||||
// still referenced by a restored session are preserved.
|
||||
void import('../docker-hosts.js')
|
||||
.then(({ reapOrphanedDockerContainers }) => reapOrphanedDockerContainers(getDataDir(), CODEMAN_INSTANCE))
|
||||
.then((reaped) => {
|
||||
if (reaped.length > 0)
|
||||
console.log(`[Docker] reaped ${reaped.length} orphaned container(s): ${reaped.join(', ')}`);
|
||||
})
|
||||
.catch(() => {
|
||||
/* best-effort — daemon may be absent */
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up stale sessions from state file that don't have active mux sessions
|
||||
@@ -1893,6 +1977,15 @@ export class WebServer extends EventEmitter {
|
||||
const displayHost = this.host === '0.0.0.0' ? 'localhost' : this.host;
|
||||
console.log(`Codeman web interface running at ${protocol}://${displayHost}:${this.port}`);
|
||||
|
||||
// Opt-in: also serve the HOOK endpoints on the docker bridge gateway so
|
||||
// in-container hooks (permission/idle/stop callbacks) can reach a loopback-bound
|
||||
// server. Hooks-only + secret-gated, and the bridge is host-internal (not the LAN).
|
||||
if (!this.testMode) {
|
||||
await this._startDockerBridgeHooksListener().catch((err) =>
|
||||
console.error(`[Docker] bridge-hooks listener error: ${err?.message || err}`)
|
||||
);
|
||||
}
|
||||
|
||||
// Anti-DNS-rebinding Host allowlist is always on. Localhost, any bare IP, the
|
||||
// bind host, *.ts.net / *.trycloudflare.com / *.cfargotunnel.com, and the active
|
||||
// managed tunnel are accepted automatically; add any other domain you front this
|
||||
@@ -1944,6 +2037,17 @@ export class WebServer extends EventEmitter {
|
||||
{ description: 'scheduled runs cleanup' }
|
||||
);
|
||||
|
||||
// Start the cron loop (fires due CronJobs).
|
||||
this.cleanup.setInterval(
|
||||
() => {
|
||||
this.cronService.tickDueJobs().catch((err) => {
|
||||
console.error('[cron] tick failed:', getErrorMessage(err));
|
||||
});
|
||||
},
|
||||
CRON_TICK_INTERVAL,
|
||||
{ description: 'scheduled jobs due-checker' }
|
||||
);
|
||||
|
||||
// Start SSE client health check timer (prevents memory leaks from dead connections)
|
||||
this.cleanup.setInterval(
|
||||
() => {
|
||||
@@ -2123,9 +2227,22 @@ export class WebServer extends EventEmitter {
|
||||
muxSession: muxSession, // Pass the existing session so startInteractive() can attach to it
|
||||
claudeMode: recoveryClaudeMode.claudeMode,
|
||||
allowedTools: recoveryClaudeMode.allowedTools,
|
||||
openCodeConfig: muxSession.mode === 'opencode' ? savedState?.openCodeConfig : undefined,
|
||||
codexConfig: muxSession.mode === 'codex' ? savedState?.codexConfig : undefined,
|
||||
geminiConfig: muxSession.mode === 'gemini' ? savedState?.geminiConfig : undefined,
|
||||
envOverrides: savedEnvOverrides,
|
||||
effort: savedState?.effort,
|
||||
attachmentHistory: savedAttachmentHistory,
|
||||
// Remote SSH metadata must round-trip on recovery: without it the
|
||||
// attach cwd falls back to the (nonexistent-locally) remote path and
|
||||
// respawn rebuilds a LOCAL command, breaking the pane and silently
|
||||
// erasing `remote` from state.json on the next persist. mux-sessions.json
|
||||
// round-trips MuxSession.remote; state.json carries SessionState.remote.
|
||||
remote: muxSession.remote ?? savedState?.remote,
|
||||
// Docker metadata round-trips the same way (mux-sessions.json carries
|
||||
// MuxSession.docker; state.json carries SessionState.docker), so recovery
|
||||
// rebuilds the `docker exec` launch instead of a broken local command.
|
||||
docker: muxSession.docker ?? savedState?.docker,
|
||||
});
|
||||
|
||||
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
|
||||
@@ -2311,6 +2428,58 @@ export class WebServer extends EventEmitter {
|
||||
return this._orchestratorLoop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-in (CODEMAN_DOCKER_BRIDGE_HOOKS=1): start a SECOND listener on the docker
|
||||
* bridge gateway IP that serves ONLY the hook endpoints and delegates them into
|
||||
* the main Fastify pipeline. This lets in-container hooks reach a loopback-bound
|
||||
* server (they call back via host.docker.internal = the bridge gateway) without
|
||||
* exposing the full API or the LAN. Bind IP is auto-detected (default bridge
|
||||
* gateway) or set via CODEMAN_DOCKER_BRIDGE_HOST.
|
||||
*/
|
||||
private async _startDockerBridgeHooksListener(): Promise<void> {
|
||||
if (!isExplicitlyEnabled(process.env.CODEMAN_DOCKER_BRIDGE_HOOKS)) return;
|
||||
const { detectDockerBridgeGateway } = await import('../docker-hosts.js');
|
||||
const bridgeHost = (process.env.CODEMAN_DOCKER_BRIDGE_HOST || '').trim() || (await detectDockerBridgeGateway());
|
||||
if (!bridgeHost) {
|
||||
console.log('[Docker] CODEMAN_DOCKER_BRIDGE_HOOKS set but no docker bridge gateway found — skipping');
|
||||
return;
|
||||
}
|
||||
// Only the hook endpoints are served on the bridge — never the full API.
|
||||
const HOOK_PATHS = new Set([
|
||||
'/api/hook-event',
|
||||
'/api/status-telemetry',
|
||||
'/api/v1/hook-event',
|
||||
'/api/v1/status-telemetry',
|
||||
]);
|
||||
const handler = (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse): void => {
|
||||
const path = (req.url || '').split('?')[0];
|
||||
if (!HOOK_PATHS.has(path)) {
|
||||
res.statusCode = 403;
|
||||
res.end('forbidden: the docker bridge listener serves hook endpoints only');
|
||||
return;
|
||||
}
|
||||
// Delegate into Fastify (host-guard, Origin/CSRF, and hook-secret gate all apply).
|
||||
(this.app as unknown as { routing: (r: unknown, s: unknown) => void }).routing(req, res);
|
||||
};
|
||||
let server: import('node:http').Server | import('node:https').Server;
|
||||
if (this.https) {
|
||||
const https = await import('node:https');
|
||||
const { key, cert } = getOrCreateSelfSignedCert();
|
||||
server = https.createServer({ key, cert }, handler);
|
||||
} else {
|
||||
const http = await import('node:http');
|
||||
server = http.createServer(handler);
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(this.port, bridgeHost, () => resolve());
|
||||
});
|
||||
this._dockerBridgeServer = server;
|
||||
console.log(
|
||||
`[Docker] in-container hooks reachable at ${this.https ? 'https' : 'http'}://${bridgeHost}:${this.port} (hook endpoints only)`
|
||||
);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
getLifecycleLog().log({ event: 'server_stopped', sessionId: '*' });
|
||||
// Set stopping flag to prevent new timer creation during shutdown
|
||||
@@ -2326,6 +2495,11 @@ export class WebServer extends EventEmitter {
|
||||
this._eventLoopMonitor = null;
|
||||
}
|
||||
|
||||
if (this._dockerBridgeServer) {
|
||||
this._dockerBridgeServer.close();
|
||||
this._dockerBridgeServer = null;
|
||||
}
|
||||
|
||||
// Dispose all managed timers (intervals + resettable timeouts)
|
||||
this.cleanup.dispose();
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface SessionListenerRefs {
|
||||
limitPauseScheduled: (data: { resetAt: number; resumeAt: number; matched: string }) => void;
|
||||
limitResume: (data: { attempt: number }) => void;
|
||||
limitResumeCancelled: (data: { reason: string }) => void;
|
||||
respawnBreakerTripped: (data: { count: number }) => void;
|
||||
cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => void;
|
||||
ralphLoopUpdate: (state: RalphTrackerState) => void;
|
||||
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
|
||||
@@ -58,7 +59,7 @@ export interface SessionListenerRefs {
|
||||
bashToolStart: (tool: ActiveBashTool) => void;
|
||||
bashToolEnd: (tool: ActiveBashTool) => void;
|
||||
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
|
||||
attachmentRequested: (event: { path: string }) => void;
|
||||
attachmentRequested: (event: { path: string; source: 'external' | 'codex-generated' }) => void;
|
||||
}
|
||||
|
||||
/** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */
|
||||
@@ -78,7 +79,7 @@ interface SessionListenerDeps {
|
||||
removeSessionListenerRefs(sessionId: string): void;
|
||||
cleanupRespawnOnExit(sessionId: string): void;
|
||||
getStore(): import('../state-store.js').StateStore;
|
||||
registerAttachment(sessionId: string, filePath: string): Promise<void>;
|
||||
registerAttachment(sessionId: string, filePath: string, source: 'external' | 'codex-generated'): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -270,6 +271,27 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
deps.persistSessionState(session);
|
||||
},
|
||||
|
||||
/**
|
||||
* Broadcasts `session:respawnBreakerTripped` (COD-118) — repeated non-zero PTY exits
|
||||
* tripped the circuit breaker; the session is now errored and respawn is blocked.
|
||||
* Also pushes the errored state (`session:updated`) so the tab renders the error,
|
||||
* persists it, and notifies for diagnostic visibility.
|
||||
*/
|
||||
respawnBreakerTripped: (data: { count: number }) => {
|
||||
deps.broadcast(SseEvent.SessionRespawnBreakerTripped, { sessionId: session.id, ...data });
|
||||
deps.broadcast(SseEvent.SessionUpdated, deps.getSessionStateWithRespawn(session));
|
||||
deps.persistSessionState(session);
|
||||
deps.sendPushNotifications(SseEvent.SessionRespawnBreakerTripped, {
|
||||
sessionId: session.id,
|
||||
sessionName: session.name,
|
||||
count: data.count,
|
||||
});
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) {
|
||||
tracker.recordError('Respawn circuit breaker tripped', `${data.count} non-zero PTY exits within window`);
|
||||
}
|
||||
},
|
||||
|
||||
// ─── CLI Info ────────────────────────────────────────────
|
||||
|
||||
/** Broadcasts `session:cliInfo` — Claude Code version, model, account type parsed from terminal */
|
||||
@@ -359,8 +381,8 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
},
|
||||
|
||||
/** Registers an explicit attachment card requested by terminal magic text. */
|
||||
attachmentRequested: (event: { path: string }) => {
|
||||
deps.registerAttachment(session.id, event.path).catch((err) => {
|
||||
attachmentRequested: (event: { path: string; source: 'external' | 'codex-generated' }) => {
|
||||
deps.registerAttachment(session.id, event.path, event.source).catch((err) => {
|
||||
console.error(`[Attachment] Failed to register ${event.path} for ${session.id}:`, err);
|
||||
});
|
||||
},
|
||||
@@ -387,6 +409,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.on('limitPauseScheduled', refs.limitPauseScheduled);
|
||||
session.on('limitResume', refs.limitResume);
|
||||
session.on('limitResumeCancelled', refs.limitResumeCancelled);
|
||||
session.on('respawnBreakerTripped', refs.respawnBreakerTripped);
|
||||
session.on('cliInfoUpdated', refs.cliInfoUpdated);
|
||||
session.on('ralphLoopUpdate', refs.ralphLoopUpdate);
|
||||
session.on('ralphTodoUpdate', refs.ralphTodoUpdate);
|
||||
@@ -420,6 +443,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.off('limitPauseScheduled', refs.limitPauseScheduled);
|
||||
session.off('limitResume', refs.limitResume);
|
||||
session.off('limitResumeCancelled', refs.limitResumeCancelled);
|
||||
session.off('respawnBreakerTripped', refs.respawnBreakerTripped);
|
||||
session.off('cliInfoUpdated', refs.cliInfoUpdated);
|
||||
session.off('ralphLoopUpdate', refs.ralphLoopUpdate);
|
||||
session.off('ralphTodoUpdate', refs.ralphTodoUpdate);
|
||||
|
||||
@@ -80,6 +80,8 @@ export const SessionLimitPauseScheduled = 'session:limitPauseScheduled' as const
|
||||
export const SessionLimitResume = 'session:limitResume' as const;
|
||||
/** Pending usage-limit auto-resume cancelled (session resumed or feature disabled). */
|
||||
export const SessionLimitResumeCancelled = 'session:limitResumeCancelled' as const;
|
||||
/** Interactive-PTY exit circuit breaker tripped (COD-118): repeated non-zero exits; respawn blocked, session errored. */
|
||||
export const SessionRespawnBreakerTripped = 'session:respawnBreakerTripped' as const;
|
||||
/** CLI version/model info detected from session output. */
|
||||
export const SessionCliInfo = 'session:cliInfo' as const;
|
||||
/** General session message (e.g. status text). */
|
||||
@@ -240,6 +242,17 @@ export const ScheduledLog = 'scheduled:log' as const;
|
||||
/** Scheduled run deleted. */
|
||||
export const ScheduledDeleted = 'scheduled:deleted' as const;
|
||||
|
||||
// ─── Cron Jobs ───────────────────────────────────
|
||||
|
||||
/** The scheduled-jobs list changed (created/updated/enabled/run-status). Payload: { jobs }. */
|
||||
export const CronJobsChanged = 'cron:jobsChanged' as const;
|
||||
/** A scheduled job was deleted. Payload: { id }. */
|
||||
export const CronJobDeleted = 'cron:jobDeleted' as const;
|
||||
/** A scheduled-job run (history record) was created. Payload: CronJobRun. */
|
||||
export const CronRunCreated = 'cron:runCreated' as const;
|
||||
/** A scheduled-job run (history record) was updated. Payload: CronJobRun. */
|
||||
export const CronRunUpdated = 'cron:runUpdated' as const;
|
||||
|
||||
// ─── Teams ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Agent team created. */
|
||||
@@ -357,6 +370,22 @@ export const CaseDeleted = 'case:deleted' as const;
|
||||
/** Case ordering changed. */
|
||||
export const CaseOrderChanged = 'case:order-changed' as const;
|
||||
|
||||
// ─── Docker cases ────────────────────────────────────────────────────────────
|
||||
/** A docker case export bundle finished writing. */
|
||||
export const DockerExportComplete = 'docker:exportComplete' as const;
|
||||
/** A docker case export failed. */
|
||||
export const DockerExportFailed = 'docker:exportFailed' as const;
|
||||
/** A docker bundle was imported into a new case. */
|
||||
export const DockerImportComplete = 'docker:importComplete' as const;
|
||||
/** The agent base image started building (first Docker case; auto-build on first use). */
|
||||
export const DockerImageBuildStarted = 'docker:imageBuildStarted' as const;
|
||||
/** A line of agent base-image build output (progress surfacing). */
|
||||
export const DockerImageBuildProgress = 'docker:imageBuildProgress' as const;
|
||||
/** The agent base image finished building successfully. */
|
||||
export const DockerImageBuildComplete = 'docker:imageBuildComplete' as const;
|
||||
/** The agent base image build failed. */
|
||||
export const DockerImageBuildFailed = 'docker:imageBuildFailed' as const;
|
||||
|
||||
// ─── Namespace Re-export ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -384,6 +413,7 @@ export const SseEvent = {
|
||||
SessionLimitPauseScheduled,
|
||||
SessionLimitResume,
|
||||
SessionLimitResumeCancelled,
|
||||
SessionRespawnBreakerTripped,
|
||||
SessionCliInfo,
|
||||
SessionMessage,
|
||||
SessionInteractive,
|
||||
@@ -469,6 +499,12 @@ export const SseEvent = {
|
||||
ScheduledLog,
|
||||
ScheduledDeleted,
|
||||
|
||||
// Cron jobs
|
||||
CronJobsChanged,
|
||||
CronJobDeleted,
|
||||
CronRunCreated,
|
||||
CronRunUpdated,
|
||||
|
||||
// Teams
|
||||
TeamCreated,
|
||||
TeamUpdated,
|
||||
@@ -531,4 +567,13 @@ export const SseEvent = {
|
||||
CaseLinked,
|
||||
CaseDeleted,
|
||||
CaseOrderChanged,
|
||||
|
||||
// Docker cases
|
||||
DockerExportComplete,
|
||||
DockerExportFailed,
|
||||
DockerImportComplete,
|
||||
DockerImageBuildStarted,
|
||||
DockerImageBuildProgress,
|
||||
DockerImageBuildComplete,
|
||||
DockerImageBuildFailed,
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @fileoverview Per-session WebSocket connection registry (COD-137).
|
||||
*
|
||||
* Replaces the bare `Map<sessionId, number>` counter that previously gated
|
||||
* `MAX_WS_PER_SESSION`. That counter had two defects:
|
||||
*
|
||||
* 1. Transient over-count on reconnect: a client that drops and immediately
|
||||
* reconnects could land its new upgrade BEFORE the old socket's async
|
||||
* `close` fired, so the count briefly exceeded the live connection number.
|
||||
* A reconnect burst could hit the cap and the next upgrade was rejected
|
||||
* with 4008 → the client fell back to HTTP. (The real spurious-4008 defect.)
|
||||
* 2. No clientId scoping: the limit counted raw sockets, so a reconnecting
|
||||
* client consumed a NEW slot instead of replacing its own.
|
||||
*
|
||||
* This registry tracks the live socket(s) per session keyed by a per-TAB
|
||||
* connection identity (`cid`, parsed from the upgrade URL query). The browser
|
||||
* sends `clientId:tabNonce`, NOT the bare localStorage clientId — that one is
|
||||
* shared by every tab/window of a profile, so keying on it would make two tabs
|
||||
* on one session evict each other in a 4010 ping-pong. A new upgrade for a
|
||||
* `cid` that already holds a socket is a SUPERSEDE — the registry evicts the
|
||||
* stale socket and reuses its slot, which makes a reconnect reclaim rather
|
||||
* than double-count (fixes #1 and #2). The cid is opaque here; input-frame
|
||||
* dedup uses the bare clientId separately (`session.shouldApplyInput`).
|
||||
*
|
||||
* Backward-compat: an upgrade with NO `cid` (legacy clients, other tools) is
|
||||
* admitted anonymously — it counts toward the limit but never evicts another
|
||||
* client, and several anonymous sockets can coexist up to the cap.
|
||||
*
|
||||
* The class is pure (no `ws`/Fastify imports) and generic over a minimal socket
|
||||
* shape so it can be unit-tested with plain fakes. The route owns the actual
|
||||
* socket close/terminate; the registry only decides admit/evict and tracks slots.
|
||||
*/
|
||||
|
||||
/** Minimal socket shape the registry needs — satisfied by `ws` WebSocket. */
|
||||
export interface RegistrableSocket {
|
||||
/** Identity comparison only; never dereferenced beyond `===`. */
|
||||
readonly readyState?: number;
|
||||
}
|
||||
|
||||
export interface RegisterResult<S> {
|
||||
/** Whether the new socket was admitted (false → caller should reject with 4008). */
|
||||
admitted: boolean;
|
||||
/**
|
||||
* A stale socket whose slot the new socket reclaimed (same `cid`). The caller
|
||||
* should close it. Present only on a keyed supersede; never set for anonymous
|
||||
* upgrades or fresh slots.
|
||||
*/
|
||||
evictedSocket?: S;
|
||||
}
|
||||
|
||||
/** A single live entry: the socket plus its clientId (null = anonymous). */
|
||||
interface Entry<S> {
|
||||
socket: S;
|
||||
cid: string | null;
|
||||
}
|
||||
|
||||
export class WsConnectionRegistry<S extends RegistrableSocket = RegistrableSocket> {
|
||||
/** sessionId → live entries (keyed + anonymous). */
|
||||
private readonly bySession = new Map<string, Entry<S>[]>();
|
||||
|
||||
constructor(private readonly maxPerSession: number) {}
|
||||
|
||||
/**
|
||||
* Attempt to register a new socket for `(sessionId, cid)`.
|
||||
*
|
||||
* - cid present and already holds a socket → SUPERSEDE: evict the old one,
|
||||
* reuse its slot, always admit.
|
||||
* - otherwise → admit iff distinct-entry count < maxPerSession.
|
||||
*
|
||||
* A null/empty `cid` is anonymous: it never matches an existing entry and so
|
||||
* never evicts; it just consumes a slot.
|
||||
*/
|
||||
register(sessionId: string, cid: string | null, socket: S): RegisterResult<S> {
|
||||
const entries = this.bySession.get(sessionId) ?? [];
|
||||
|
||||
if (cid) {
|
||||
const existingIdx = entries.findIndex((e) => e.cid === cid);
|
||||
if (existingIdx !== -1) {
|
||||
const evicted = entries[existingIdx].socket;
|
||||
// Reuse the slot in place — no net change to the live count, so a
|
||||
// reconnect can never be rejected by the cap.
|
||||
entries[existingIdx] = { socket, cid };
|
||||
this.bySession.set(sessionId, entries);
|
||||
return { admitted: true, evictedSocket: evicted === socket ? undefined : evicted };
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length >= this.maxPerSession) {
|
||||
return { admitted: false };
|
||||
}
|
||||
|
||||
entries.push({ socket, cid: cid || null });
|
||||
this.bySession.set(sessionId, entries);
|
||||
return { admitted: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a socket from its session. Idempotent — safe to call on `close`,
|
||||
* `error`, AND eagerly on `terminate()` (the over-count fix relies on eager
|
||||
* removal freeing the slot before the async `close` fires).
|
||||
*
|
||||
* Matches by socket identity, so a socket that was already superseded
|
||||
* (replaced in-slot by a same-cid reconnect) is NOT removed by its late
|
||||
* `close` — the new socket keeps the slot.
|
||||
*/
|
||||
unregister(sessionId: string, socket: S): void {
|
||||
const entries = this.bySession.get(sessionId);
|
||||
if (!entries) return;
|
||||
const idx = entries.findIndex((e) => e.socket === socket);
|
||||
if (idx === -1) return;
|
||||
entries.splice(idx, 1);
|
||||
if (entries.length === 0) {
|
||||
this.bySession.delete(sessionId);
|
||||
} else {
|
||||
this.bySession.set(sessionId, entries);
|
||||
}
|
||||
}
|
||||
|
||||
/** Number of live entries for a session (0 if none). */
|
||||
liveCount(sessionId: string): number {
|
||||
return this.bySession.get(sessionId)?.length ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { parseAttachmentMagicLinks } from '../src/attachment-magic.js';
|
||||
import { parseAttachmentMagicLinks, parseTerminalAttachmentRequests } from '../src/attachment-magic.js';
|
||||
import { isSupportedAttachmentExtension } from '../src/attachment-registry.js';
|
||||
|
||||
describe('attachment magic links', () => {
|
||||
it('extracts absolute paths from codeman attach magic URLs', () => {
|
||||
@@ -54,4 +55,79 @@ describe('attachment magic links', () => {
|
||||
|
||||
expect(requested).toEqual(['/tmp/deck.pptx']);
|
||||
});
|
||||
|
||||
it('extracts Codex generated image file URLs from saved-to terminal output', () => {
|
||||
const requests = parseTerminalAttachmentRequests(
|
||||
'Saved to: file:///Users/aamer/.codex-personal/generated_images/mockup%20one.png',
|
||||
{ codexArtifacts: true }
|
||||
);
|
||||
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
path: '/Users/aamer/.codex-personal/generated_images/mockup one.png',
|
||||
source: 'codex-generated',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores Codex saved-to output unless the codex scanner is enabled', () => {
|
||||
const requests = parseTerminalAttachmentRequests(
|
||||
'Saved to: file:///Users/aamer/.codex-personal/generated_images/mockup.png'
|
||||
);
|
||||
|
||||
expect(requests).toEqual([]);
|
||||
});
|
||||
|
||||
it('strips ANSI styling around Codex saved-to lines before capturing the URL', () => {
|
||||
const requests = parseTerminalAttachmentRequests(
|
||||
'\x1b[1mSaved to:\x1b[0m file:///Users/aamer/.codex/generated_images/mockup.png\x1b[0m\r\n',
|
||||
{ codexArtifacts: true }
|
||||
);
|
||||
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
path: '/Users/aamer/.codex/generated_images/mockup.png',
|
||||
source: 'codex-generated',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits generated artifact requests from Codex saved-to output', () => {
|
||||
const session = new Session({ id: 'session-generated-artifact-test', workingDir: '/tmp', mode: 'codex' });
|
||||
const requested: Array<{ path: string; source?: string }> = [];
|
||||
session.on('attachmentRequested', (event: { path: string; source?: string }) => requested.push(event));
|
||||
|
||||
(session as unknown as { _handleTerminalOutput(data: string): void })._handleTerminalOutput(
|
||||
'Saved to: file:///Users/aamer/.codex-personal/generated_images/output.png'
|
||||
);
|
||||
|
||||
expect(requested).toEqual([
|
||||
{
|
||||
sessionId: 'session-generated-artifact-test',
|
||||
path: '/Users/aamer/.codex-personal/generated_images/output.png',
|
||||
source: 'codex-generated',
|
||||
timestamp: expect.any(Number),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not emit codex-generated requests from non-codex session modes', () => {
|
||||
for (const mode of ['claude', 'shell'] as const) {
|
||||
const session = new Session({ id: `session-generated-artifact-${mode}`, workingDir: '/tmp', mode });
|
||||
const requested: Array<{ path: string }> = [];
|
||||
session.on('attachmentRequested', (event: { path: string }) => requested.push(event));
|
||||
|
||||
(session as unknown as { _handleTerminalOutput(data: string): void })._handleTerminalOutput(
|
||||
'Saved to: file:///Users/aamer/.codex-personal/generated_images/output.png'
|
||||
);
|
||||
|
||||
expect(requested).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('supports generated image attachment extensions beyond png', () => {
|
||||
expect(isSupportedAttachmentExtension('jpg')).toBe(true);
|
||||
expect(isSupportedAttachmentExtension('jpeg')).toBe(true);
|
||||
expect(isSupportedAttachmentExtension('webp')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const indexHtml = readFileSync(join(process.cwd(), 'src/web/public/index.html'), 'utf-8');
|
||||
const panelsJs = readFileSync(join(process.cwd(), 'src/web/public/panels-ui.js'), 'utf-8');
|
||||
const stylesCss = readFileSync(join(process.cwd(), 'src/web/public/styles.css'), 'utf-8');
|
||||
const mobileCss = readFileSync(join(process.cwd(), 'src/web/public/mobile.css'), 'utf-8');
|
||||
|
||||
describe('away digest UI', () => {
|
||||
it('exposes a header entry point and modal shell', () => {
|
||||
expect(indexHtml).toContain('onclick="app.openAwayDigest()"');
|
||||
expect(indexHtml).toContain('aria-label="Open away digest"');
|
||||
expect(indexHtml).toContain('id="awayDigestModal"');
|
||||
expect(indexHtml).toContain('id="awayDigestSummary"');
|
||||
expect(indexHtml).toContain('id="awayDigestSections"');
|
||||
});
|
||||
|
||||
it('offers supported range controls', () => {
|
||||
expect(indexHtml).toContain('data-away-range="since-last-visit"');
|
||||
expect(indexHtml).toContain('data-away-range="1h"');
|
||||
expect(indexHtml).toContain('data-away-range="today"');
|
||||
expect(indexHtml).toContain('data-away-range="24h"');
|
||||
expect(indexHtml).toContain('id="awayDigestCustomSince"');
|
||||
expect(indexHtml).toContain('id="awayDigestCustomUntil"');
|
||||
});
|
||||
|
||||
it('fetches the aggregate endpoint and preserves since-last-visit until successful close', () => {
|
||||
expect(panelsJs).toContain('/api/away-digest');
|
||||
expect(panelsJs).toContain('codeman-away-digest-last-viewed');
|
||||
expect(panelsJs).toContain('openAwayDigest');
|
||||
expect(panelsJs).toContain('closeAwayDigest');
|
||||
});
|
||||
|
||||
it('has desktop and mobile layout styles', () => {
|
||||
expect(stylesCss).toContain('.away-digest-modal');
|
||||
expect(stylesCss).toContain('.away-digest-summary');
|
||||
expect(stylesCss).toContain('.away-digest-item');
|
||||
expect(mobileCss).toContain('.away-digest-modal');
|
||||
expect(mobileCss).toContain('.away-digest-ranges');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildAwayDigest, resolveAwayDigestRange } from '../src/web/away-digest.js';
|
||||
import type { LifecycleEntry, RunSummary } from '../src/types.js';
|
||||
|
||||
const NOW = Date.UTC(2026, 5, 7, 12, 0, 0);
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
function summary(sessionId: string, events: RunSummary['events']): RunSummary {
|
||||
return {
|
||||
sessionId,
|
||||
sessionName: `${sessionId} name`,
|
||||
startedAt: NOW - 4 * HOUR,
|
||||
lastUpdatedAt: NOW - HOUR,
|
||||
events,
|
||||
stats: {
|
||||
totalRespawnCycles: 0,
|
||||
totalTokensUsed: 0,
|
||||
peakTokens: 0,
|
||||
totalTimeActiveMs: 0,
|
||||
totalTimeIdleMs: 0,
|
||||
errorCount: events.filter((event) => event.severity === 'error').length,
|
||||
warningCount: events.filter((event) => event.severity === 'warning').length,
|
||||
aiCheckCount: 0,
|
||||
lastIdleAt: null,
|
||||
lastWorkingAt: null,
|
||||
stateTransitions: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('away digest', () => {
|
||||
it('resolves since-last-visit after the stored marker and defaults to 24h', () => {
|
||||
expect(resolveAwayDigestRange({ range: 'since-last-visit', lastViewed: NOW - HOUR, now: NOW })).toMatchObject({
|
||||
range: 'since-last-visit',
|
||||
since: NOW - HOUR,
|
||||
until: NOW,
|
||||
});
|
||||
|
||||
expect(resolveAwayDigestRange({ range: 'since-last-visit', now: NOW })).toMatchObject({
|
||||
since: NOW - 24 * HOUR,
|
||||
until: NOW,
|
||||
});
|
||||
});
|
||||
|
||||
it('separates action-required, completed, live, idle, and informational items', () => {
|
||||
const lifecycleEntries: LifecycleEntry[] = [
|
||||
{ ts: NOW - 10_000, event: 'exit', sessionId: 'failed', name: 'Failed Session', exitCode: 2 },
|
||||
{ ts: NOW - 9_000, event: 'exit', sessionId: 'clean', name: 'Clean Exit', exitCode: 0 },
|
||||
{ ts: NOW - 8_000, event: 'mux_died', sessionId: 'mux', name: 'Mux Session' },
|
||||
];
|
||||
|
||||
const digest = buildAwayDigest({
|
||||
range: resolveAwayDigestRange({ range: '1h', now: NOW }),
|
||||
lifecycleEntries,
|
||||
runSummaries: [
|
||||
summary('ralph', [
|
||||
{
|
||||
id: 'complete-1',
|
||||
timestamp: NOW - 7_000,
|
||||
type: 'ralph_completion',
|
||||
severity: 'success',
|
||||
title: 'Ralph completion detected',
|
||||
details: 'Phrase: COMPLETE',
|
||||
},
|
||||
]),
|
||||
summary('error-session', [
|
||||
{
|
||||
id: 'error-1',
|
||||
timestamp: NOW - 6_000,
|
||||
type: 'error',
|
||||
severity: 'error',
|
||||
title: 'Session error',
|
||||
details: 'Tool failed',
|
||||
},
|
||||
]),
|
||||
],
|
||||
sessions: [
|
||||
{ id: 'active', name: 'Active Session', status: 'working' },
|
||||
{ id: 'idle', name: 'Idle Session', status: 'idle' },
|
||||
],
|
||||
dailyTokenStats: [
|
||||
{
|
||||
date: '2026-06-07',
|
||||
inputTokens: 1_000,
|
||||
outputTokens: 2_000,
|
||||
estimatedCost: 0.18,
|
||||
sessions: 2,
|
||||
},
|
||||
],
|
||||
subagents: [
|
||||
{
|
||||
id: 'agent-1',
|
||||
sessionId: 'active',
|
||||
description: 'Research complete',
|
||||
status: 'completed',
|
||||
lastUpdated: NOW - 5_000,
|
||||
},
|
||||
],
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
expect(digest.sections.needsAttention.map((item) => item.sessionId)).toEqual(['failed', 'mux', 'error-session']);
|
||||
expect(digest.sections.completed.map((item) => item.sessionId)).toEqual(['ralph']);
|
||||
expect(digest.sections.stillRunning.map((item) => item.sessionId)).toEqual(['active']);
|
||||
expect(digest.sections.idle.map((item) => item.sessionId)).toEqual(['idle']);
|
||||
expect(digest.sections.informational.map((item) => item.sessionId)).toContain('clean');
|
||||
expect(digest.sections.informational.some((item) => item.source === 'subagent')).toBe(true);
|
||||
expect(digest.totals).toMatchObject({
|
||||
needsAttention: 3,
|
||||
completed: 1,
|
||||
activeSessions: 2,
|
||||
inputTokens: 1_000,
|
||||
outputTokens: 2_000,
|
||||
estimatedCost: 0.18,
|
||||
tokenWindowPrecision: 'day',
|
||||
});
|
||||
expect(digest.dataFreshness).toMatchObject({
|
||||
lifecyclePersisted: true,
|
||||
tokenStatsPersisted: true,
|
||||
runSummariesLiveOnly: true,
|
||||
subagentsLiveOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('excludes entries outside the selected range', () => {
|
||||
const digest = buildAwayDigest({
|
||||
range: resolveAwayDigestRange({ range: '1h', now: NOW }),
|
||||
lifecycleEntries: [
|
||||
{ ts: NOW - 2 * HOUR, event: 'mux_died', sessionId: 'old' },
|
||||
{ ts: NOW - 1000, event: 'mux_died', sessionId: 'recent' },
|
||||
],
|
||||
runSummaries: [],
|
||||
sessions: [],
|
||||
dailyTokenStats: [],
|
||||
subagents: [],
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
expect(digest.sections.needsAttention.map((item) => item.sessionId)).toEqual(['recent']);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user