mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge master into PR #153 (unified Session Manager)
Resolve the 4 conflicted files toward master's merged #146 work while keeping PR #153's genuinely-new additions: - app.js: keep the full Escape chain (closeSessionManager + closeCommandPalette + closeShortcutOverlay). - index.html: keep master's Command Palette modal markup alongside the PR's Session Manager modal + header button. - styles.css: keep master's Command Palette + COD-157 shortcut CSS AND the PR's COD-130 session-row kebab-menu CSS (both inserted at the same spot — reunited each with its own closing brace). - terminal-ui.js: resolve _buildHistoryItem's main-row click handler to master's options.onActivate contract with a liveness + claudeSessionId -aware resume default, preserving the PR's two-shape/badges/kebab body. - panels-ui.js: the PR's pre-#146 Session Manager block auto-merged as a duplicate AFTER master's fixed block (last-key-wins regression) — drop it, keep master's implementation plus the PR's new _onSessionListMaybeChanged. Backend projectKey plumbing and the SSE live-refresh listeners in app.js merge additively and are kept as-is.
This commit is contained in:
@@ -52,7 +52,7 @@ When user says "COM":
|
||||
3. **Consume the changeset**: `npm run version-packages` (auto-bumps `package.json` files, updates `CHANGELOG.md`, runs `npm install --package-lock-only`, and verifies lockfile sync via `scripts/check-lockfile-sync.mjs` — all in one command; never hand-edit `CHANGELOG.md` or `package-lock.json` versions)
|
||||
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
|
||||
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
|
||||
6. **Wait for CI**: after `git push`, find the run with `gh run list -L 1 --json databaseId,headBranch -q '.[0].databaseId'` and watch it with `gh run watch <id> --exit-status`. Confirm all checks pass before considering the release done.
|
||||
6. **Wait for CI**: after `git push`, TWO workflows fire per master push — `CI` and `Release` (the npm publish + GitHub release). List both runs for the pushed commit with `gh run list --commit $(git rev-parse HEAD) --json databaseId,workflowName` and watch EACH with `gh run watch <id> --exit-status`. Confirm both pass before considering the release done (`gh run list -L 1` returns only one of the two).
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
@@ -80,6 +80,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| Override window title hostname | `npx tsx src/index.ts web --title-hostname <name>` (default: `os.hostname()` — `codeman:<name>` is used for tab title, title-flash, and OS desktop notification prefix) |
|
||||
| Bind a non-loopback host | `npx tsx src/index.ts web --host 0.0.0.0` (or `-H`; env `CODEMAN_HOST`; default `127.0.0.1`). Without `CODEMAN_PASSWORD` it **starts but warns loudly** — see Common Gotchas + `docs/security-architecture.md` |
|
||||
| Continuous typecheck | `tsc --noEmit --watch` |
|
||||
| Watch-mode test | `npm run test:watch -- test/<file>.test.ts` (always pass a file — bare watch includes the browser suites) |
|
||||
| Test coverage | `npm run test:coverage` |
|
||||
| Dead-code sweep | `npm run knip` (config in `knip.json`) |
|
||||
| Rebuild gesture overlay | `npm run build:gesture` (esbuild `packages/gesture-control/src/codeman/entry.ts` → `src/web/public/gesture/gesture-codeman.js`; commit the result) |
|
||||
@@ -119,29 +120,31 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| Domain | Key files | Notes |
|
||||
|--------|-----------|-------|
|
||||
| **Entry** | `src/index.ts`, `src/cli.ts` | |
|
||||
| **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts`, `src/session-lifecycle-log.ts`, `src/session-task-cache.ts`, `src/usage-limit-patterns.ts`, `src/usage-telemetry.ts` | |
|
||||
| **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts`, `src/session-lifecycle-log.ts`, `src/session-task-cache.ts`, `src/session-pty-exit-breaker.ts`, `src/usage-limit-patterns.ts`, `src/usage-telemetry.ts`; `src/services/unified-session-service.ts` (merges live/persisted/lifecycle/transcript rows for `GET /api/sessions/unified`) | |
|
||||
| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` ★ | |
|
||||
| **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first |
|
||||
| **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Read `docs/ralph-wiggum-guide.md` first |
|
||||
| **Orchestrator** | `src/orchestrator-loop.ts`, `src/orchestrator-planner.ts`, `src/orchestrator-verifier.ts` | Read `docs/orchestrator-loop-architecture.md` first |
|
||||
| **Cron** | `src/cron/cron-service.ts`, `src/cron/cron-time.ts` (pure next-run math), `src/cron/cron-input.ts` | Cron-style `CronJob`s. Read `docs/cron-discovery.md` first; distinct from legacy `ScheduledRun` (`/api/scheduled`) — see Key Patterns |
|
||||
| **Agents** | `src/subagent-watcher.ts` ★, `src/team-watcher.ts`, `src/bash-tool-parser.ts`, `src/transcript-watcher.ts`, `src/workflow-run-watcher.ts` | `workflow-run-watcher` is STANDALONE (never touches `subagent-watcher`) — see Key Patterns |
|
||||
| **AI** | `src/ai-checker-base.ts`, `src/ai-idle-checker.ts`, `src/ai-plan-checker.ts` | |
|
||||
| **Tasks** | `src/task.ts`, `src/task-queue.ts`, `src/task-tracker.ts` | |
|
||||
| **State** | `src/state-store.ts`, `src/run-summary.ts`, `src/session-lifecycle-log.ts` | |
|
||||
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
|
||||
| **Attachments** | `src/attachment-registry.ts`, `src/attachment-magic.ts`, `src/session-attachment-history.ts`, `src/document-preview-cache.ts`, `src/document-thumbnailer.ts`, `src/document-conversion-limiter.ts`, `src/config/attachment-guard.ts` | See Key Patterns |
|
||||
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts`, `src/remote-hosts.ts` (remote SSH hosts/cases — see Key Patterns) | |
|
||||
| **Search** | `src/search-service.ts` | Pure in-memory core for `GET /api/search` — see Key Patterns |
|
||||
| **Attachments** | `src/attachment-registry.ts`, `src/attachment-magic.ts`, `src/generated-artifact-attachments.ts` (Codex `Saved to:` artifacts), `src/session-attachment-history.ts`, `src/document-preview-cache.ts`, `src/document-thumbnailer.ts`, `src/document-conversion-limiter.ts`, `src/config/attachment-guard.ts` | See Key Patterns |
|
||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`, the CLAUDE.md scaffold generated into new cases) | |
|
||||
| **Web** | `src/web/server.ts` ★, `src/web/sse-events.ts`, `src/web/routes/*.ts` (17 route modules + barrel; `session-routes.ts` ★), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts`, `src/web/plan-usage-latest.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~4K lines, core) + 6 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`, `sanitize-html.js` — DOMPurify mXSS allowlist, COD-56) + 8 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `ultracode-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 6 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `ultracode-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | `ultracode-windows.js` = floating run windows w/ tab connector lines (additional to the dock panel) |
|
||||
| **Types** | `src/types/index.ts` (barrel) → 17 domain files (incl. `workflow-run.ts`, `search.ts`); also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
| **Web** | `src/web/server.ts` ★, `src/web/sse-events.ts`, `src/web/routes/*.ts` (18 route modules + barrel; `session-routes.ts` ★), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts`, `src/web/plan-usage-latest.ts`, `src/web/ws-connection-registry.ts` (per-tab WS supersede), `src/web/heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` (HEIC→JPEG off-thread) | |
|
||||
| **Frontend** | `src/web/public/app.js` (~4K lines, core) + 6 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`, `sanitize-html.js` — DOMPurify mXSS allowlist, COD-56) + 9 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `ultracode-panel.js`, `cron-ui.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 6 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `ultracode-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | `ultracode-windows.js` = floating run windows w/ tab connector lines (additional to the dock panel) |
|
||||
| **Types** | `src/types/index.ts` (barrel) → 18 domain files (incl. `workflow-run.ts`, `search.ts`, `cron.ts`); also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
|
||||
★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
|
||||
|
||||
**Local packages**: `packages/xterm-zerolag-input/` — local echo overlay for xterm.js; single-source, bundled to the gitignored `vendor/xterm-zerolag-input.js` and consumed by `app.js` (see Gotchas). `packages/gesture-control/` (`codeman-gesture-control`) — hand-tracking overlay source; built to `src/web/public/gesture/gesture-codeman.js` via `npm run build:gesture` (see Frontend → Gesture control).
|
||||
|
||||
**Config**: `src/config/` — 14 files, no barrel (`index.ts`) exists; import from the specific file.
|
||||
**Config**: `src/config/` — 15 files, no barrel (`index.ts`) exists; import from the specific file.
|
||||
|
||||
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority).
|
||||
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap` (⚠ NOT in the barrel — import from `./utils/lru-map.js` directly), `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver`/`gemini-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority).
|
||||
|
||||
### Data Flow
|
||||
|
||||
@@ -152,31 +155,39 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**Input**: `session.writeViaMux()` for programmatic/curl input — tmux `send-keys -l` (literal) + `send-keys Enter`. Single-line only (fire-and-once). Interactive **browser** input goes through a durable **exactly-once** layer: each frame carries a stable `clientId` + monotonic per-session `seq`, persisted to localStorage until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt.
|
||||
**Input**: `session.writeViaMux()` for programmatic/curl input — tmux `send-keys -l` (literal) + `send-keys Enter`. Single-line only (fire-and-once). Interactive **browser** input goes through a durable **exactly-once** layer: each frame carries a stable `clientId` + monotonic per-session `seq`, persisted to localStorage until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt. **WS resilience** (#149): the upgrade URL carries `cid = clientId + ':' + perTabNonce`, and `ws-connection-registry.ts` supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare `clientId` for seq dedup); reconnects back off exponentially (attempts preserved across `_connectWs`), and the header connection chip renders from a real `_wsState` lifecycle (`connecting`/`connected`/`fallback`/`reconnecting`/`disconnected`).
|
||||
|
||||
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
|
||||
|
||||
**Auto-resume on usage limit** ("token pause" control, opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit ("5-hour limit reached ∙ resets 8pm" and all 1.0.x–2.1.x variants), `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time from cleaned output; `SessionAutoOps` arms a timer for reset+2min, then sends Esc (dismisses the rate-limit dialog) + `continue`. Still-limited responses re-arm the loop (5-min retry on stale times); a `working` transition cancels it. Claude-mode only (detection rides `_processExpensiveParsers`). Persists/recovers via `SessionState.autoResumeEnabled`/`autoResumeAt`; respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected` — prevents `/clear` from wiping the paused conversation). Endpoint: `POST /api/sessions/:id/auto-resume`; SSE: `session:limitPauseScheduled`/`limitResume`/`limitResumeCancelled`. Tests: `test/usage-limit-patterns.test.ts`, `test/session-auto-resume.test.ts`.
|
||||
|
||||
**Plan-usage chip** (statusLine telemetry, opt-in `showPlanUsageLimits`, default OFF): Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). Codeman injects its OWN statusLine exporter (`generateStatusLineCommand()` in `hooks-config.ts`, identified by the `/api/status-telemetry` marker — it only ever adds/updates/removes a statusLine that is *ours*, never a user's hand-authored one) that POSTs the blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated under a tunnel) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (so injecting our statusLine doesn't blank the in-terminal footer). `plan-usage-latest.ts` holds the process-wide last value, replayed in the SSE init snapshot (`getLightState`) so the header chip (`#planUsageChip`, toggled by `showPlanUsageLimits` in settings-ui.js) renders immediately on page load / reconnect without per-browser localStorage. Claude-mode only. **Distinct from auto-resume** (which reacts to the limit *message*; this proactively shows the live %). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`.
|
||||
**Plan-usage chip** (statusLine telemetry, opt-in `showPlanUsageLimits`, default OFF): Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). Codeman injects its OWN statusLine exporter (`generateStatusLineCommand()` in `hooks-config.ts`, identified by the `/api/status-telemetry` marker — it only ever adds/updates/removes a statusLine that is *ours*, never a user's hand-authored one) that POSTs the blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (so injecting our statusLine doesn't blank the in-terminal footer). `plan-usage-latest.ts` holds the process-wide last value, replayed in the SSE init snapshot (`getLightState`) so the header chip (`#planUsageChip`, toggled by `showPlanUsageLimits` in settings-ui.js) renders immediately on page load / reconnect without per-browser localStorage. Claude-mode only. **Distinct from auto-resume** (which reacts to the limit *message*; this proactively shows the live %). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`.
|
||||
|
||||
**Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`.
|
||||
|
||||
**Cron (cron-style `CronJob`s)**: saved, named jobs with a recurring schedule (`once`/`interval`/`daily`/`weekly`), enable/disable, Run Now, next-run calc, and per-job run history (`CronJobRun`). ⚠️ **Distinct from the legacy `ScheduledRun`** (`/api/scheduled`, a run-now duration-bounded autonomous loop) — the two never interact; the legacy concept keeps the `Scheduled*` names, the recurring-job feature is `Cron*`. `CronService` (`src/cron/cron-service.ts`) owns CRUD + the 30s background due-tick (`tickDueJobs`, registered via `cleanup.setInterval` in `server.ts`; `init()` recomputes nextRunAt on boot) and **reuses the existing session layer** (create → `addSession` → `setupSessionListeners` → `startInteractive`/`startShell` → prompt via `writeViaMux`/`write`) rather than rebuilding tmux logic. Next-run math is pure/unit-tested in `cron-time.ts` (SERVER-LOCAL timezone for daily/weekly). Dup-launch guard = `lastDueKey` (jobId:fireTime); schedule is advanced BEFORE launch so a slow launch can't re-trigger. `once` jobs self-disable after firing (`completedOnce`). Persisted via `AppState.cronJobs`/`cronJobRuns` (StateStore accessors). Routes `/api/cron/jobs*` + `/api/cron/runs` (`cron-routes.ts`, `CronPort`); schema `CronJobSchema` (cross-field `superRefine`; the `.partial()` update schema does NOT re-run it); SSE `cron:*`. Frontend `cron-ui.js` (#cronModal). Claude/shell/opencode/codex/gemini agent types. Tests: `test/cron-time.test.ts`, `test/cron-service.test.ts`. Design: `docs/cron-discovery.md`.
|
||||
|
||||
**External CLI modes (OpenCode, Codex, Gemini)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All three modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM).
|
||||
|
||||
**Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to `exec claude --dangerously-skip-permissions`; per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`.
|
||||
|
||||
**Unified session list** (COD-160/#139): `GET /api/sessions/unified?limit=&q=` merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). Transcript rows are keyed by conversation UUID and folded into their owning session via a `claudeSessionId → Codeman id` alias map (resumed//clear-respawned sessions must not appear twice); lifecycle name/mode resolution is first-seen-wins (the log returns entries NEWEST-first). No terminal buffers in the response (unlike `/api/sessions`). Consumed by the Cmd+K Session Manager (#146).
|
||||
|
||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`.
|
||||
|
||||
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`.
|
||||
|
||||
**Circuit breaker**: Prevents respawn thrashing. States: `CLOSED` → `HALF_OPEN` → `OPEN`. Reset: `/api/sessions/:id/ralph-circuit-breaker/reset`.
|
||||
**Circuit breaker**: Prevents respawn thrashing. States: `CLOSED` → `HALF_OPEN` → `OPEN`. Reset: `/api/sessions/:id/ralph-circuit-breaker/reset`. **Distinct: PTY-exit breaker** (COD-115/118/#147, `session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits (crash loops on attach), blocks further auto-restarts, broadcasts SSE `session:respawnBreakerTripped` + push (in `PUSH_EVENT_MAP`). Reset ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive` (sent by the user-facing restart control) — the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. Sessions also scrub inherited `TMUX`/`TMUX_PANE` env so Codeman-in-tmux doesn't nest. Tests: `test/respawn-pty-breaker.test.ts`.
|
||||
|
||||
**Full-scrollback replay** (COD-164/#148): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -<lines>` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). Only the FIRST buffer load after a page load requests `full=1` (one-shot `_initialFullBufferLoad` flag in app.js); tab switches keep the cheap `?tail=` visible-frame path. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`.
|
||||
|
||||
**Self-update** (App Settings → Updates): in-app updater for **git-clone installs** supervised by systemd/launchd. Supervisors: `systemd` (user unit), `launchd` (GUI LaunchAgent, gui-domain kickstart), `launchd-daemon` (KeepAlive system LaunchDaemon on headless Macs — restarts rootlessly by killing the server PID and letting launchd respawn it; detected only when the daemon is bootstrapped AND KeepAlive), else `none` → "restart manually" message; on next boot a manual-restart status auto-completes when the running version matches the target. The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` (`git checkout <release tag> && npm install && npm run build && restart`) that outlives the restart; it writes progress to `dataPath('update-status.json')`, which the browser polls across the connection drop. Channel = latest `codeman@X.Y.Z` release tag; dirty trees are auto-stashed. `src/web/self-update.ts` splits PURE helpers (semver/tag parsing, reconcile decision — unit-tested) from IO wrappers (`getInstallInfo`/`checkForUpdate`/`startUpdate`/`reconcileUpdateOnBoot`). Routes: `GET /api/system/update/check`, `POST /api/system/update`, `GET /api/system/update/status`. Types: `src/types/update.ts`. npm installs report as non-updatable.
|
||||
|
||||
**Attachments** (live external document references; COD-37/#119 core, COD-38/#120 previews, COD-39/#121 history): all wiring in `file-routes.ts`. **Registry** (`attachment-registry.ts`): an **in-memory** map of a stable `attachmentId` → an absolute, `realpath`-resolved, extension-allowlisted file path, so browser requests (`GET /api/sessions/:id/attachments/:attachmentId/raw`) never carry arbitrary absolute paths; `POST /api/sessions/:id/attachments` registers one. **Magic links** (`attachment-magic.ts`): parses `codeman://attach?...` out of terminal output — ⚠️ this scanner is prompt-injectable, so the scan path is **force-confined to the session workspace** (a hostile prompt could otherwise make it read arbitrary host files over SSE); emits the `attachment:detected` SSE event. Security gate is an extension **allowlist** (`isSupportedAttachmentExtension`, in the registry/magic modules), not a blocklist; a separate path layer (`config/attachment-guard.ts`) confines reads to the workspace (`attachmentConfineToWorkspace`) and blocks sensitive trees (`/root`, `/etc`). **Previews + thumbnails** (COD-38): `:attachmentId/preview` + `:attachmentId/thumbnail` (and the workspace-file equivalents `file-preview`/`file-thumbnail`) render Office docs/PDFs via external converters (`pdftoppm` / LibreOffice `soffice` / Word-COM `powershell`); `document-preview-cache.ts` is a shared disk cache (de-dups *identical* in-flight inputs), `document-thumbnailer.ts` does best-effort first-page images, and `document-conversion-limiter.ts` is a **global converter-spawn concurrency cap** (`runWithConversionLimit`) — without it, N distinct large docs detected at once fork N multi-minute converter processes = a localhost fork-bomb-shaped resource-exhaustion vector. **History drawer** (COD-39): `session-attachment-history.ts` tracks the last `ATTACHMENT_HISTORY_LIMIT` (100) attachments per session (`Session._attachmentHistory`, persisted via `SessionState.attachmentHistory`, replayed so externals re-register on reconnect); `GET /api/sessions/:id/attachments` is the list endpoint. ⚠️ The history drawer's launcher button is desktop-only — hidden on phones (regression-guarded; see `mobile-header-buttons-policy` test). Session-local files keep using the existing workspace-scoped `file-routes` paths; the registry is only for explicit live externals.
|
||||
**Attachments** (live external document references; COD-37/#119 core, COD-38/#120 previews, COD-39/#121 history): all wiring in `file-routes.ts`. **Registry** (`attachment-registry.ts`): an **in-memory** map of a stable `attachmentId` → an absolute, `realpath`-resolved, extension-allowlisted file path, so browser requests (`GET /api/sessions/:id/attachments/:attachmentId/raw`) never carry arbitrary absolute paths; `POST /api/sessions/:id/attachments` registers one. **Magic links** (`attachment-magic.ts`): parses `codeman://attach?...` out of terminal output — ⚠️ this scanner is prompt-injectable, so the scan path is **force-confined to the session workspace** (a hostile prompt could otherwise make it read arbitrary host files over SSE); emits the `attachment:detected` SSE event. Security gate is an extension **allowlist** (`isSupportedAttachmentExtension`, in the registry/magic modules), not a blocklist; a separate path layer (`config/attachment-guard.ts`) confines reads to the workspace (`attachmentConfineToWorkspace`) and blocks sensitive trees (`/root`, `/etc`). **Previews + thumbnails** (COD-38): `:attachmentId/preview` + `:attachmentId/thumbnail` (and the workspace-file equivalents `file-preview`/`file-thumbnail`) render Office docs/PDFs via external converters (`pdftoppm` / LibreOffice `soffice` / Word-COM `powershell`); `document-preview-cache.ts` is a shared disk cache (de-dups *identical* in-flight inputs), `document-thumbnailer.ts` does best-effort first-page images, and `document-conversion-limiter.ts` is a **global converter-spawn concurrency cap** (`runWithConversionLimit`) — without it, N distinct large docs detected at once fork N multi-minute converter processes = a localhost fork-bomb-shaped resource-exhaustion vector. **History drawer** (COD-39): `session-attachment-history.ts` tracks the last `ATTACHMENT_HISTORY_LIMIT` (100) attachments per session (`Session._attachmentHistory`, persisted via `SessionState.attachmentHistory`, replayed so externals re-register on reconnect); `GET /api/sessions/:id/attachments` is the list endpoint. ⚠️ The history drawer's launcher button is desktop-only — hidden on phones (regression-guarded; see `mobile-header-buttons-policy` test). Session-local files keep using the existing workspace-scoped `file-routes` paths; the registry is only for explicit live externals. **Codex generated artifacts** (COD-166/#150, `generated-artifact-attachments.ts`): codex-mode sessions ALSO scan (ANSI-stripped) output for `Saved to: file:///…` lines and surface those files as attachment cards with a relaxed trust policy — the allow decision runs on the **realpath-resolved** path against `os.homedir()`-anchored `~/.codex` marker dirs (symlink escapes fall back to force-confinement); gated to `mode === 'codex'` only (`source` is a REQUIRED param through the listener-deps chain — a dropped arg here silently kills the feature). Image thumbnails pass through jpg/jpeg/gif/webp.
|
||||
|
||||
**Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a run-state JSON per run at `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json`. `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; disjoint directory tree, separate singleton) globs that tree via periodic poll + per-run chokidar watcher with per-file mtime skip, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is started when **either** `showUltracodeAgents` **or** `ultracodeFloatingWindows` is on (`server.ts` `_updateWorkflowWatcher` returns `(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on a **DEDICATED** `ultracodeFloatingWindows` toggle, default OFF — independent of the dock panel's `showUltracodeAgents`; see `_ultracodeFloatingEnabled()`), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an **in-page** connected transcript window (not a browser popup); both run and transcript windows minimize **into** the originating session tab as a merged `ULTRA` badge (🧬 runs / 📄 transcripts) with a restore/dismiss dropdown — minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (a `window` grab kind in `entry.ts`). Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`.
|
||||
**Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a COMPLETION artifact per run at `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_<id>/` (journal.jsonl + agent-*.jsonl). `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; separate singleton, though it independently reads the same `subagents/workflows/` tree) scans BOTH sources via periodic poll + per-directory chokidar watchers with per-source mtime skip (LRU agentStatCache + journalCache), synthesizing ACTIVE runs (live per-agent tokens/tools/state from transcripts, title/phases from the workflow script) until the completion `wf_*.json` appears and supersedes, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is started when **either** `showUltracodeAgents` **or** `ultracodeFloatingWindows` is on (`server.ts` `isWorkflowAgentTrackingEnabled()` returns `(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on a **DEDICATED** `ultracodeFloatingWindows` toggle, default OFF — independent of the dock panel's `showUltracodeAgents`; see `_ultracodeFloatingEnabled()`), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an **in-page** connected transcript window (not a browser popup); both run and transcript windows minimize **into** the originating session tab as a merged `ULTRA` badge (🧬 runs / 📄 transcripts) with a restore/dismiss dropdown — minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (a `window` grab kind in `entry.ts`). Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`.
|
||||
|
||||
**Cross-session search** (COD-113/#133): `GET /api/search?q=&types=&limit=` federates an **in-memory** search across all live sessions — session metadata (name/workingDir/id), run-summary events, and per-session attachment-history file entries (workspace-relative path only; the server-private `externalPath` is never read). Pure core in `search-service.ts` (`harvestSources()` gathers, `searchSources()` substring-matches with hard per-type caps — no regex, so no ReDoS; no filesystem reads, so no traversal). `SearchQuerySchema` bounds `q` (1–200), allowlists `types` (`session,event,file`), clamps `limit` (1–60). Returns the `{success,data}` envelope. Frontend: history-panel search box in `terminal-ui.js`. Types: `src/types/search.ts`.
|
||||
**Cross-session search** (COD-113/#133): `GET /api/search?q=&types=&limit=` federates an **in-memory** search across all live sessions — session metadata (name/workingDir/id), run-summary events, and per-session attachment-history file entries (workspace-relative path only; the server-private `externalPath` is never read). Pure core `searchSources()` in `search-service.ts` (substring-matches with hard per-type caps — no regex, so no ReDoS; no filesystem reads, so no traversal); `harvestSources()` in `search-routes.ts` gathers the in-memory sources. `SearchQuerySchema` bounds `q` (1–200), allowlists `types` (`session,event,file`), clamps `limit` (1–60). Returns the `{success,data}` envelope. Frontend: history-panel search box in `terminal-ui.js`. Types: `src/types/search.ts`.
|
||||
|
||||
**Away digest** (COD-41/#136): `GET /api/away-digest?range=&since=&until=&lastViewed=` aggregates "what happened while you were away" from the lifecycle log + run-summary events + live sessions + daily token stats + recently-completed subagents into needs-attention/completed/still-running/idle/informational sections. Pure aggregator in `web/away-digest.ts` (`resolveAwayDigestRange()` validates the window — `since-last-visit`/`1h`/`today`/`24h`/`custom`, server-local TZ; `buildAwayDigest()` classifies). Header-button modal in `panels-ui.js` (button hidden on phones — regression-guarded). ⚠️ Returns `{success:true,digest}` (a legacy raw-ish shape, consistent with the other raw GET handlers in `system-routes.ts` — `{entries}`/`{config}`/`{files}`/`getSystemStats()`); frontend + tests read `.digest`. Subagent lookback is a fixed 60-min window regardless of range.
|
||||
|
||||
@@ -186,23 +197,27 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
### Frontend
|
||||
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `settings-ui.js`(10) → `panels-ui.js`(11) → `ultracode-panel.js`(11.5) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `ultracode-panel.js`(11.5) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
|
||||
|
||||
**Command palette + shortcut registry** (COD-151/153/157/192, #146): `Ctrl/Cmd/Alt+K` opens the session palette (fuzzy search over live sessions; "Browse all sessions" → the Session Manager modal backed by `GET /api/sessions/unified`); the quick-start case `<select>` is fronted by a searchable picker (`buildCasePickerOptions`/`formatCasePickerLabel` — remote cases render `name @ hostId`). Shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js; overrides persist under `settings.shortcutOverrides` via `saveAppSettingsToStorage`); App Settings → Shortcuts renders capture/disable rows; `Ctrl+?` opens the registry-driven overlay (footer links to the full `#helpModal` reference). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM — keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over.
|
||||
|
||||
**WebGL renderer toggle** (#140, `webglRendererEnabled`): per-device (`displayKeys` set, stripped from the server payload — NOT in `SettingsUpdateSchema`, which is `.strict()`). The GPU-stall watchdog's sticky `codeman-webgl-disabled` marker survives page loads; it's cleared only by an explicit OFF→ON save transition or `?webgl=force` (`shouldSkipWebGL` in constants.js). `?nowebgl` still forces the DOM renderer per-load.
|
||||
|
||||
**Z-index layers**: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried; bug fixed in `b8cb467`), log viewers (2000), image popups (3000), local echo overlay (7).
|
||||
|
||||
**Multi-monitor button** (header, top-right; the notification bell it sits beside stays hidden — notifications live in Settings → Notifications). `app.launchMultiMonitor()` (in `panels-ui.js`) POSTs `/api/system/span-displays`, which spawns `scripts/span-codeman.sh` — a fresh, maximized browser `--app` window sized to the union of all displays (macOS; needs "Displays have separate Spaces" OFF). Supports the gesture layer's in-page floating session panels dragging across the physical monitor seam. **Opt-in:** hidden by default; enable under App Settings → Display → **Header Displays** ("Multi-monitor Button", `showMultiMonitorButton`). The button carries a `btn-multimonitor--hidden` class in the template; `renderIndexHtml` strips that class at render when the setting is on (a unique class token, not a brittle match on the aria-label/style copy), and `applyHeaderVisibilitySettings()` toggles the same class live on save. Solo (detached) windows hide it via `body.solo-mode`.
|
||||
|
||||
**Response-viewer (eye) button** (header) is likewise **hidden by default** — enable under App Settings → Display → **Response Viewer** (`showResponseViewer`). Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices.
|
||||
**Response-viewer (eye) button** (header) is likewise **hidden by default** — enable under App Settings → Display → **Response Viewer** (`showResponseViewer`). Works for Claude AND Codex sessions (#152): Codex last-responses are located via a 4-layer rollout resolution under `CODEX_HOME` (history pin → originator match → resume-UUID → cwd fallback with other-pane exclusion), with injected-context filtering and event/legacy dedup — tests in `test/routes/session-routes-codex-last-response.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices.
|
||||
|
||||
**Gesture control** (the camera hand-tracking overlay) is **opt-in, default OFF**, under App Settings → Display → **Input** (`gestureControlEnabled`). `CODEMAN_GESTURE=1` makes the feature *available* on the instance (CSP widening + `/gesture/` assets) and sets `window.__codemanGestureAvailable` (the Input section only shows when set); the overlay bundle is injected by `renderIndexHtml` **only when the setting is enabled**, so that method is `async` and reads `settings.json` via `readSettings(true)` — the `true` forces a **fresh** read (bypassing the 2s `_settingsCache`), because a post-save reload happens within that TTL and the cached value would otherwise render the pre-toggle state. Toggling the setting reloads the page (the bundle is render-injected).
|
||||
|
||||
**Gesture-control source lives in-repo** at `packages/gesture-control/` (workspace package `codeman-gesture-control`, was the standalone `Ark0N/codeman-gesture-control` repo). The transport-agnostic core is `src/gesture/*` (MediaPipe GestureRecognizer → One-Euro-filtered cursor → pinch state machine); `src/codeman/entry.ts` is the Codeman *consumer* that maps grab/drag/drop onto real `.session-tab`/toolbar buttons and is the bundle entry. **Edit there, then run `npm run build:gesture`** (`scripts/build-gesture-bundle.mjs` → esbuild bundles `entry.ts`, MediaPipe JS included, into `src/web/public/gesture/gesture-codeman.js`) and **commit the regenerated bundle** — the committed bundle is what dev/`tsx` serves (no bundler at runtime), and `scripts/build.mjs` reruns the same step so prod always reflects current source. The MediaPipe **wasm + model** are NOT bundled — loaded at runtime from same-origin `/gesture/wasm` + `/gesture/gesture_recognizer.task`, fetched by `scripts/fetch-gesture-assets.mjs` (gitignored, see Gotchas). `entry.ts` mounts `window.__codemanGesture = new GestureBridge()` idempotently at module-eval. A standalone vite playground (`npm run dev` in the package — fake tabs, no Codeman) lets you iterate on gesture *feel* in isolation. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision` in `packages/gesture-control/package.json`.
|
||||
|
||||
**Theme skins** (App Settings → Display): the `skin` setting selects a palette via a `data-skin` attribute on `<html>`. Values: `daylight-blue` (default), `daylight-green`, `og` (OG Codeman). CSS lives under `[data-skin="…"]` blocks in `styles.css`. To avoid a flash-of-wrong-theme, an **inline pre-paint script** in `index.html` (`<head>`) reads `localStorage['codeman:skin']` and sets `data-skin` before first paint; `settings-ui.js` `applyTheme()`/`applyTerminalSkin()` apply it live on save and keep the standalone `codeman:skin` key + the settings object in sync. `skin` is a **per-device/client-only** setting — it's destructured OUT of the server payload (settings-ui.js, alongside `localEchoEnabled`/`cjkInputEnabled`/`extendedKeyboardBar`), so it does NOT sync across devices.
|
||||
**Theme skins** (App Settings → Display): the `skin` setting selects a palette via a `data-skin` attribute on `<html>`. Values: `daylight-blue` (default), `daylight-green`, `og` (OG Codeman). CSS lives under `[data-skin="…"]` blocks in `styles.css`. To avoid a flash-of-wrong-theme, an **inline pre-paint script** in `index.html` (`<head>`) reads `localStorage['codeman:skin']` and sets `data-skin` before first paint; `settings-ui.js` `applySkin()` applies it live on save (sets `html[data-skin]` + `window.__codemanSkin`, syncs the standalone `codeman:skin` key with the settings blob, and calls terminal-ui.js `applyTerminalSkin()` to re-theme live terminals). `skin` is a **per-device/client-only** setting — it's destructured OUT of the server payload (settings-ui.js, alongside `localEchoEnabled`/`cjkInputEnabled`/`extendedKeyboardBar`), so it does NOT sync across devices.
|
||||
|
||||
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
||||
|
||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (help), Ctrl+W (kill), Ctrl+Tab (next), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font).
|
||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+W (kill), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active). Rebindable via the registry (see Command palette above).
|
||||
|
||||
### Security
|
||||
|
||||
@@ -217,18 +232,18 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` |
|
||||
| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
|
||||
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
|
||||
| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) exempt from auth (localhost-only, schema-validated). While the **managed tunnel** runs, the bypass additionally requires the per-instance `X-Codeman-Hook-Secret` header (COD-54, `config/hook-secret.ts`): hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env), failures rate-limit in a dedicated bucket (never lock out login). External loopback proxies (own cloudflared/`tailscale serve`) aren't detected — plain bypass still applies there. Tunnel enable **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (env, COD-55) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9): the welcome/settings tunnel toggle pops a security confirm dialog and, on confirm, resends with that flag (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted |
|
||||
| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) skip Basic auth (localhost-only, schema-validated). When auth is active (`CODEMAN_PASSWORD` set), the loopback bypass requires the per-instance `X-Codeman-Hook-Secret` header **unconditionally** — COD-54 introduced it tunnel-gated; COD-91 (PR #127) made it always-on because Codeman can't detect a user's own loopback reverse proxy (own cloudflared/`tailscale serve`/nginx → 127.0.0.1), closing that residual plain-bypass gap. Hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env, `config/hook-secret.ts`); a missing/wrong secret gets 401 and rate-limits in a dedicated bucket (never locks out login). Tunnel enable **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (env, COD-55) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9): the welcome/settings tunnel toggle pops a security confirm dialog and, on confirm, resends with that flag (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted |
|
||||
| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains) |
|
||||
| **Validation** | Zod schemas, path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`) |
|
||||
| **Validation** | Zod schemas, path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`) |
|
||||
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
|
||||
|
||||
### SSE Event Registry
|
||||
|
||||
~127 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
|
||||
~133 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
|
||||
|
||||
### API Routes
|
||||
|
||||
~150 handlers across 17 route files in `src/web/routes/`: system (45, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, `GET /api/codex/status`, `GET /api/gemini/status`, and `GET /api/away-digest`), sessions (29), orchestrator (10), cases (9), ralph (9), plan (8), files (14, incl. attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), mux (5), push (4), scheduled (4), teams (2), search (1, `GET /api/search`), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
~166 handlers across 18 route files in `src/web/routes/`: system (45, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, `GET /api/codex/status`, `GET /api/gemini/status`, and `GET /api/away-digest`), sessions (30, incl. `GET /api/sessions/unified`), orchestrator (10), cases (14, incl. remote hosts CRUD + remote case-link), ralph (9), plan (8), files (14, incl. attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), cron (9, cron-style `CronJob` jobs/runs), teams (2), search (1, `GET /api/search`), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
|
||||
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
|
||||
|
||||
@@ -245,7 +260,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
## State Files
|
||||
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` (VAPID), `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart).
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, `cronJobs`/`cronJobRuns`), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` (VAPID), `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `linked-cases.json` (linked-case registry used for case-path resolution), `remote-hosts.json` + `remote-cases.json` (remote SSH hosts/cases, COD-94), `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance hook secret, COD-54), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI). Transient: `self-update-runner.sh`.
|
||||
|
||||
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
|
||||
|
||||
@@ -264,7 +279,7 @@ Raw `npx vitest` skips `config/vitest.config.ts`; always use `npm test --` or pa
|
||||
|
||||
**Config**: Vitest with `globals: true`, `fileParallelism: false`. Timeout 30s, teardown 60s. `config/vitest.ci.config.ts` = same minus the browser/perf excludes — keep the two configs in sync when changing shared options.
|
||||
|
||||
**Tmux safety**: under vitest (`VITEST` env var, set automatically), `TmuxManager` no-ops ALL shell commands and becomes a pure in-memory mock — tests physically cannot create/kill/attach real tmux sessions (`IS_TEST_MODE` in `src/tmux-manager.ts`). `test/setup.ts` additionally strips `CODEMAN_PASSWORD`/`CODEMAN_USERNAME` so auth state from the running instance can't leak into tests.
|
||||
**Tmux safety**: under vitest (`VITEST` env var, set automatically), `TmuxManager` no-ops ALL shell commands and becomes a pure in-memory mock — tests physically cannot create/kill/attach real tmux sessions (`IS_TEST_MODE` in `src/tmux-manager.ts`). `test/setup.ts` additionally strips `CODEMAN_PASSWORD`/`CODEMAN_USERNAME` (so auth state from the running instance can't leak into tests) and `CODEMAN_GESTURE` (a shell-exported gesture flag would flip render-injection assertions).
|
||||
|
||||
**Ports**: Pick unique ports manually. Search `const PORT =` before adding new tests.
|
||||
|
||||
@@ -284,10 +299,10 @@ Mobile screenshots: `~/.codeman/screenshots/`, accessed via `GET/POST /api/scree
|
||||
|
||||
## Performance & Limits
|
||||
|
||||
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 2MB, text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
|
||||
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired live); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
|
||||
|
||||
**Memory leaks (24+ hour sessions)**: use `CleanupManager`, clear Maps in `stop()`, guard async with `if (this.cleanup.isStopped) return`. Frontend: store handler refs, clean in `close*()`. Verify: `npm test -- test/memory-leak-prevention.test.ts`.
|
||||
|
||||
## Scripts & Tunnel
|
||||
|
||||
Key scripts: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh start|stop|url` (tunnel). Production services: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel.
|
||||
Key scripts: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh [quick|named] start|stop|status|url` (quick = random trycloudflare URL, default; `named setup|enable` = fixed-hostname tunnel via `scripts/codeman-tunnel-named.service`; bare `start|stop|url` still means quick). Production services: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -298,8 +369,8 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
||||
- **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
|
||||
|
||||
@@ -372,14 +443,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 +475,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 +499,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 +550,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 +798,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,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).
|
||||
Generated
+29
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,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}`;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+32
-4
@@ -17,6 +17,7 @@ import type {
|
||||
CodexConfig,
|
||||
EffortLevel,
|
||||
GeminiConfig,
|
||||
SessionRemote,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
@@ -33,6 +34,8 @@ export interface MuxSession {
|
||||
createdAt: number;
|
||||
/** Working directory */
|
||||
workingDir: string;
|
||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||
remote?: SessionRemote;
|
||||
/** Session mode */
|
||||
mode: SessionMode;
|
||||
/** Whether webserver is attached to this session */
|
||||
@@ -74,6 +77,8 @@ export interface CreateSessionOptions {
|
||||
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;
|
||||
}
|
||||
|
||||
/** Options for respawning a dead pane. */
|
||||
@@ -96,6 +101,22 @@ export interface RespawnPaneOptions {
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,9 +246,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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+110
-11
@@ -49,6 +49,7 @@ import {
|
||||
type CodexConfig,
|
||||
type EffortLevel,
|
||||
type GeminiConfig,
|
||||
type SessionRemote,
|
||||
} from './types.js';
|
||||
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
||||
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
||||
@@ -82,7 +83,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,
|
||||
@@ -209,6 +211,10 @@ 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): string {
|
||||
return remote ? '/tmp' : workingDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a JSON message from Claude CLI's stream-json output format.
|
||||
* Messages are newline-delimited JSON objects parsed from PTY output.
|
||||
@@ -288,6 +294,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);
|
||||
@@ -385,6 +398,9 @@ export class Session extends EventEmitter {
|
||||
// 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;
|
||||
|
||||
// Session color for visual differentiation
|
||||
private _color: import('./types.js').SessionColor = 'default';
|
||||
|
||||
@@ -456,6 +472,8 @@ export class Session extends EventEmitter {
|
||||
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;
|
||||
}
|
||||
) {
|
||||
super();
|
||||
@@ -528,6 +546,7 @@ export class Session extends EventEmitter {
|
||||
this._effort = config.effort;
|
||||
}
|
||||
this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT;
|
||||
this._remote = config.remote;
|
||||
if (config.attachmentHistory && config.attachmentHistory.length > 0) {
|
||||
this.restoreAttachmentHistory(config.attachmentHistory);
|
||||
}
|
||||
@@ -987,6 +1006,7 @@ export class Session extends EventEmitter {
|
||||
pid: this.pid,
|
||||
status: this._status,
|
||||
workingDir: this.workingDir,
|
||||
remote: this._remote,
|
||||
currentTaskId: this._currentTaskId,
|
||||
createdAt: this.createdAt,
|
||||
lastActivityAt: this._lastActivityAt,
|
||||
@@ -1021,6 +1041,11 @@ export class Session extends EventEmitter {
|
||||
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
|
||||
@@ -1171,8 +1196,10 @@ export class Session extends EventEmitter {
|
||||
name: 'xterm-256color',
|
||||
cols: ptyCols,
|
||||
rows: ptyRows,
|
||||
cwd: this.workingDir,
|
||||
env: buildMuxAttachEnv(),
|
||||
cwd: resolveMuxAttachCwd(this.workingDir, this._remote),
|
||||
// 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);
|
||||
@@ -1230,18 +1257,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
|
||||
@@ -1256,6 +1291,16 @@ 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 = getModeLabel(this.mode);
|
||||
@@ -1282,6 +1327,7 @@ export class Session extends EventEmitter {
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
},
|
||||
createSessionOptions: {
|
||||
sessionId: this.id,
|
||||
@@ -1299,6 +1345,7 @@ export class Session extends EventEmitter {
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
},
|
||||
spawnErrLabel: 'mux attachment',
|
||||
});
|
||||
@@ -1496,6 +1543,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';
|
||||
@@ -1523,10 +1573,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
|
||||
@@ -1637,6 +1715,7 @@ export class Session extends EventEmitter {
|
||||
niceConfig: this._niceConfig,
|
||||
envOverrides: this._envOverrides,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
},
|
||||
createSessionOptions: {
|
||||
sessionId: this.id,
|
||||
@@ -1646,6 +1725,7 @@ export class Session extends EventEmitter {
|
||||
niceConfig: this._niceConfig,
|
||||
envOverrides: this._envOverrides,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
},
|
||||
spawnErrLabel: 'shell mux attachment',
|
||||
});
|
||||
@@ -2252,11 +2332,29 @@ 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
|
||||
@@ -2310,6 +2408,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;
|
||||
|
||||
+238
-23
@@ -42,8 +42,10 @@ import {
|
||||
type CodexConfig,
|
||||
type EffortLevel,
|
||||
type GeminiConfig,
|
||||
type SessionRemote,
|
||||
} from './types.js';
|
||||
import { buildEffortCliArgs } from './session-cli-builder.js';
|
||||
import { buildSshConnectionArgs, defaultRemoteCommandForMode, remoteSshTarget } from './remote-hosts.js';
|
||||
import {
|
||||
wrapWithNice,
|
||||
SAFE_PATH_PATTERN,
|
||||
@@ -58,6 +60,7 @@ import type {
|
||||
MuxSessionWithStats,
|
||||
CreateSessionOptions,
|
||||
RespawnPaneOptions,
|
||||
PaneCaptureOptions,
|
||||
} from './mux-interface.js';
|
||||
|
||||
// ============================================================================
|
||||
@@ -65,7 +68,15 @@ import type {
|
||||
// ============================================================================
|
||||
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.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;
|
||||
@@ -430,6 +441,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 }
|
||||
@@ -669,6 +700,118 @@ function buildSpawnCommand(options: {
|
||||
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(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set sensitive environment variables on a tmux session via setenv.
|
||||
* These are inherited by panes but not visible in ps output or tmux history.
|
||||
@@ -932,6 +1075,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
'export LC_ALL=en_US.UTF-8',
|
||||
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}`,
|
||||
@@ -1059,6 +1208,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
envOverrides,
|
||||
effort,
|
||||
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||||
remote,
|
||||
} = options;
|
||||
const muxName = `codeman-${sessionId.slice(0, 8)}`;
|
||||
|
||||
@@ -1077,6 +1227,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
pid: 99999,
|
||||
createdAt: Date.now(),
|
||||
workingDir,
|
||||
remote,
|
||||
mode,
|
||||
attached: false,
|
||||
name,
|
||||
@@ -1121,7 +1272,8 @@ 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 = 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:
|
||||
@@ -1172,7 +1324,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 ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||
execSync(
|
||||
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
|
||||
{
|
||||
@@ -1246,6 +1398,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
pid,
|
||||
createdAt: Date.now(),
|
||||
workingDir,
|
||||
remote,
|
||||
mode,
|
||||
attached: false,
|
||||
name,
|
||||
@@ -1330,6 +1483,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
envOverrides,
|
||||
effort,
|
||||
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||||
remote,
|
||||
} = options;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
@@ -1366,7 +1520,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
});
|
||||
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 = remote ? buildRemoteLaunchCommand({ mode, remote, sessionId }) : localFullCmd;
|
||||
|
||||
try {
|
||||
// For OpenCode: set sensitive env vars via tmux setenv before respawn
|
||||
@@ -1383,7 +1538,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// 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 ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||
await execAsync(
|
||||
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
|
||||
{
|
||||
@@ -1555,6 +1711,20 @@ 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 4: Direct kill by PID as final fallback
|
||||
if (this.isProcessAlive(currentPid)) {
|
||||
try {
|
||||
@@ -2192,30 +2362,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}'`,
|
||||
@@ -2240,9 +2445,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;
|
||||
}
|
||||
}
|
||||
@@ -2253,7 +2468,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);
|
||||
@@ -2266,7 +2481,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,17 @@ export interface CaseInfo {
|
||||
path: string;
|
||||
/** Whether CLAUDE.md exists */
|
||||
hasClaudeMd?: boolean;
|
||||
/** Case storage/execution location */
|
||||
location?: 'local' | 'linked-local' | 'remote';
|
||||
/** 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;
|
||||
};
|
||||
}
|
||||
|
||||
// ========== 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;
|
||||
}
|
||||
@@ -43,6 +43,61 @@ export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedToo
|
||||
/** Session mode: which CLI backend a session runs */
|
||||
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>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid Claude CLI effort levels (claude >= 2.1.154).
|
||||
* `ultracode` = xhigh effort + standing dynamic-workflow orchestration; it is a
|
||||
@@ -160,6 +215,8 @@ 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;
|
||||
/** ID of currently assigned task, null if none */
|
||||
currentTaskId: string | null;
|
||||
/** Timestamp when session was created */
|
||||
@@ -234,6 +291,11 @@ export interface SessionState {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
+531
-82
@@ -174,6 +174,7 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.SESSION_LIMIT_PAUSE_SCHEDULED, '_onSessionLimitPauseScheduled'],
|
||||
[SSE_EVENTS.SESSION_LIMIT_RESUME, '_onSessionLimitResume'],
|
||||
[SSE_EVENTS.SESSION_LIMIT_RESUME_CANCELLED, '_onSessionLimitResumeCancelled'],
|
||||
[SSE_EVENTS.SESSION_RESPAWN_BREAKER_TRIPPED, '_onSessionRespawnBreakerTripped'],
|
||||
[SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'],
|
||||
[SSE_EVENTS.SESSION_STATUS_TELEMETRY, '_onSessionStatusTelemetry'],
|
||||
|
||||
@@ -183,6 +184,12 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.SCHEDULED_COMPLETED, '_onScheduledCompleted'],
|
||||
[SSE_EVENTS.SCHEDULED_STOPPED, '_onScheduledStopped'],
|
||||
|
||||
// Scheduled jobs (cron-style scheduler)
|
||||
[SSE_EVENTS.CRON_JOBS_CHANGED, '_onCronJobsChanged'],
|
||||
[SSE_EVENTS.CRON_JOB_DELETED, '_onCronJobsChanged'],
|
||||
[SSE_EVENTS.CRON_RUN_CREATED, '_onCronRunChanged'],
|
||||
[SSE_EVENTS.CRON_RUN_UPDATED, '_onCronRunChanged'],
|
||||
|
||||
// Respawn
|
||||
[SSE_EVENTS.RESPAWN_STARTED, '_onRespawnStarted'],
|
||||
[SSE_EVENTS.RESPAWN_STOPPED, '_onRespawnStopped'],
|
||||
@@ -299,6 +306,134 @@ function parseSessionPrefix(name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const DEFAULT_SHORTCUTS = [
|
||||
{
|
||||
id: 'show-shortcuts',
|
||||
group: 'Panels',
|
||||
label: 'Show Shortcuts',
|
||||
bindings: [
|
||||
{ modifiers: ['ctrl'], key: '?', code: 'Slash' },
|
||||
{ modifiers: ['ctrl', 'shift'], key: '?' },
|
||||
{ modifiers: ['alt'], key: '?', code: 'Slash' },
|
||||
],
|
||||
action: 'showShortcutOverlay',
|
||||
},
|
||||
{
|
||||
id: 'close-session',
|
||||
group: 'Session',
|
||||
label: 'Close Session',
|
||||
bindings: [{ modifiers: ['ctrl'], key: 'w' }],
|
||||
action: 'killActiveSession',
|
||||
},
|
||||
{
|
||||
id: 'next-session',
|
||||
group: 'Session',
|
||||
label: 'Next Session',
|
||||
bindings: [{ modifiers: ['ctrl'], key: 'Tab' }],
|
||||
action: 'nextSession',
|
||||
},
|
||||
{
|
||||
id: 'clear-terminal',
|
||||
group: 'Terminal',
|
||||
label: 'Clear Terminal',
|
||||
bindings: [{ modifiers: ['ctrl'], key: 'l' }],
|
||||
action: 'clearTerminal',
|
||||
},
|
||||
{
|
||||
id: 'increase-font',
|
||||
group: 'Terminal',
|
||||
label: 'Increase Font',
|
||||
bindings: [
|
||||
{ modifiers: ['ctrl'], key: '=', code: 'Equal' },
|
||||
{ modifiers: ['ctrl'], key: '+', code: 'Equal' },
|
||||
],
|
||||
action: 'increaseFontSize',
|
||||
},
|
||||
{
|
||||
id: 'decrease-font',
|
||||
group: 'Terminal',
|
||||
label: 'Decrease Font',
|
||||
bindings: [{ modifiers: ['ctrl'], key: '-', code: 'Minus' }],
|
||||
action: 'decreaseFontSize',
|
||||
},
|
||||
{
|
||||
id: 'voice-input',
|
||||
group: 'Terminal',
|
||||
label: 'Voice Input',
|
||||
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'V' }],
|
||||
action: 'toggleVoiceInput',
|
||||
},
|
||||
{
|
||||
id: 'restore-terminal-size',
|
||||
group: 'Terminal',
|
||||
label: 'Restore Terminal Size',
|
||||
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'R' }],
|
||||
action: 'restoreTerminalSize',
|
||||
},
|
||||
{
|
||||
id: 'move-tab-left',
|
||||
group: 'Tabs',
|
||||
label: 'Move Active Tab Left',
|
||||
bindings: [{ modifiers: ['ctrl', 'shift'], key: '{', code: 'BracketLeft' }],
|
||||
action: 'moveActiveTabLeft',
|
||||
},
|
||||
{
|
||||
id: 'move-tab-right',
|
||||
group: 'Tabs',
|
||||
label: 'Move Active Tab Right',
|
||||
bindings: [{ modifiers: ['ctrl', 'shift'], key: '}', code: 'BracketRight' }],
|
||||
action: 'moveActiveTabRight',
|
||||
},
|
||||
{
|
||||
id: 'command-palette',
|
||||
group: 'Session',
|
||||
label: 'Find Open Session',
|
||||
bindings: [
|
||||
{ modifiers: ['ctrl'], key: 'k', code: 'KeyK' },
|
||||
{ modifiers: ['meta'], key: 'k', code: 'KeyK' },
|
||||
{ modifiers: ['alt'], key: 'k', code: 'KeyK' },
|
||||
],
|
||||
action: 'openCommandPalette',
|
||||
},
|
||||
{
|
||||
id: 'previous-next-session',
|
||||
group: 'Session',
|
||||
label: 'Previous / Next Session',
|
||||
displayBindings: ['Alt/Option+[', 'Alt/Option+]'],
|
||||
},
|
||||
{
|
||||
id: 'switch-tab-n',
|
||||
group: 'Session',
|
||||
label: 'Switch to Tab N',
|
||||
displayBindings: ['Alt/Option+1-9'],
|
||||
},
|
||||
{
|
||||
id: 'focus-tabs',
|
||||
group: 'Tabs',
|
||||
label: 'Focus Tabs',
|
||||
displayBindings: ['ArrowLeft', 'ArrowRight', 'Home', 'End'],
|
||||
},
|
||||
{
|
||||
id: 'activate-focused-tab',
|
||||
group: 'Tabs',
|
||||
label: 'Activate Focused Tab',
|
||||
displayBindings: ['Enter', 'Space'],
|
||||
},
|
||||
{
|
||||
id: 'insert-newline',
|
||||
group: 'Terminal',
|
||||
label: 'Insert Newline',
|
||||
displayBindings: ['Shift+Enter', 'Ctrl+Enter'],
|
||||
},
|
||||
{
|
||||
id: 'close-panels',
|
||||
group: 'Panels',
|
||||
label: 'Close Panels',
|
||||
displayBindings: ['Escape'],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CodemanApp Class — constructor and global state
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -320,6 +455,14 @@ class CodemanApp {
|
||||
this._clientId = (typeof crypto !== 'undefined' && crypto.randomUUID)
|
||||
? crypto.randomUUID()
|
||||
: 'c-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
// Per-TAB nonce for the WS registry key (COD-137). _loadReliableState()
|
||||
// later replaces _clientId with the browser-wide localStorage identity
|
||||
// (shared by every tab/window of this profile), so the WS upgrade sends
|
||||
// `clientId:nonce` instead — a same-tab reconnect still supersedes its own
|
||||
// socket, but two tabs on one session coexist instead of evicting each
|
||||
// other in a 4010 ping-pong. Input frames keep the bare clientId for seq
|
||||
// dedup.
|
||||
this._wsTabNonce = this._clientId;
|
||||
this.terminal = null;
|
||||
this.fitAddon = null;
|
||||
this.activeSessionId = null;
|
||||
@@ -342,6 +485,7 @@ class CodemanApp {
|
||||
this._initGeneration = 0; // dedup concurrent handleInit calls
|
||||
this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive
|
||||
this._selectGeneration = 0; // cancel stale selectSession loads
|
||||
this._initialFullBufferLoad = true; // first buffer load after a page load fetches full tmux scrollback (COD-47)
|
||||
this.terminalLoadStates = new Map(); // Map<sessionId, { generation, phase }>
|
||||
this.respawnStatus = {};
|
||||
this.respawnTimers = {}; // Track timed respawn timers
|
||||
@@ -453,6 +597,8 @@ class CodemanApp {
|
||||
this._ws = null; // WebSocket instance for active session
|
||||
this._wsSessionId = null; // Session ID the WS is connected to
|
||||
this._wsReady = false; // True when WS is open and ready for I/O
|
||||
this._wsState = 'disconnected'; // connecting | connected | reconnecting | fallback | disconnected
|
||||
this._wsLastRecvAt = 0; // ms timestamp of the last frame received on the active WS
|
||||
|
||||
// Terminal write batching with DEC 2026 sync support
|
||||
this.pendingWrites = [];
|
||||
@@ -496,6 +642,9 @@ class CodemanApp {
|
||||
this._clientId = '';
|
||||
this._seqCounters = new Map(); // sessionId -> last issued seq
|
||||
this._pendingDeliveries = new Map(); // sessionId -> [{seq,data,useMux,ts,tries,sentAt}]
|
||||
// Last rendered connection-indicator tuple; the hot input path skips DOM
|
||||
// writes when the freshly computed descriptor is identical (COD-136).
|
||||
this._lastIndicatorDescriptor = null;
|
||||
this._postDraining = new Set(); // sessionIds with an in-flight POST drainer
|
||||
this._persistReliableTimer = null;
|
||||
this._reliableAckTimeoutMs = 4000; // unacked WS frame older than this ⇒ socket likely dead
|
||||
@@ -801,33 +950,43 @@ class CodemanApp {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
setupEventListeners() {
|
||||
// Keyboard shortcut lookup table — data-driven to avoid 12 separate if-blocks.
|
||||
// Each entry: { key, altKey? (alternative key match), ctrl? (require Ctrl/Cmd),
|
||||
// shift? (require Shift), action }.
|
||||
const SHORTCUTS = [
|
||||
{ key: '?', altKey: '/', ctrl: true, action: () => this.showHelp() },
|
||||
{ key: 'w', ctrl: true, action: () => this.killActiveSession() },
|
||||
{ key: 'Tab', ctrl: true, action: () => this.nextSession() },
|
||||
{ key: 'l', ctrl: true, action: () => this.clearTerminal() },
|
||||
{ key: 'R', ctrl: true, shift: true, action: () => this.restoreTerminalSize() },
|
||||
{ key: '=', altKey: '+', ctrl: true, action: () => this.increaseFontSize() },
|
||||
{ key: '-', ctrl: true, action: () => this.decreaseFontSize() },
|
||||
{ key: 'V', ctrl: true, shift: true, action: () => VoiceInput.toggle() },
|
||||
{ key: '{', ctrl: true, shift: true, action: () => this.moveActiveTabLeft() },
|
||||
{ key: '}', ctrl: true, shift: true, action: () => this.moveActiveTabRight() },
|
||||
];
|
||||
// Action name → handler map for the shortcut registry (DEFAULT_SHORTCUTS +
|
||||
// user overrides from settings.shortcutOverrides, merged by
|
||||
// getShortcutRegistry()). The command palette chord is deliberately NOT in
|
||||
// this map — shouldOpenCommandPaletteFromShortcut() dispatches it above with
|
||||
// focus-target awareness (it must fire from the terminal but not from inputs).
|
||||
const SHORTCUT_ACTIONS = {
|
||||
showShortcutOverlay: () => this.showShortcutOverlay(),
|
||||
killActiveSession: () => this.killActiveSession(),
|
||||
nextSession: () => this.nextSession(),
|
||||
clearTerminal: () => this.clearTerminal(),
|
||||
restoreTerminalSize: () => this.restoreTerminalSize(),
|
||||
increaseFontSize: () => this.increaseFontSize(),
|
||||
decreaseFontSize: () => this.decreaseFontSize(),
|
||||
toggleVoiceInput: () => VoiceInput.toggle(),
|
||||
moveActiveTabLeft: () => this.moveActiveTabLeft(),
|
||||
moveActiveTabRight: () => this.moveActiveTabRight(),
|
||||
};
|
||||
|
||||
// Use capture to handle before terminal
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Don't intercept keys during CJK IME composition
|
||||
if (e.isComposing || e.keyCode === 229) return;
|
||||
|
||||
if (this.shouldOpenCommandPaletteFromShortcut?.(e)) {
|
||||
e.preventDefault();
|
||||
this.openCommandPalette();
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape - close panels and modals (different logic: no preventDefault, no return)
|
||||
if (e.key === 'Escape') {
|
||||
this.closeAllPanels();
|
||||
this.closeHelp();
|
||||
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
|
||||
this.closeSessionManager();
|
||||
this.closeCommandPalette?.();
|
||||
this.closeShortcutOverlay?.();
|
||||
}
|
||||
|
||||
// Option/Alt session navigation uses physical key CODES, not e.key, so macOS
|
||||
@@ -857,14 +1016,18 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Match against shortcut table
|
||||
for (const s of SHORTCUTS) {
|
||||
const keyMatch = e.key === s.key || (s.altKey && e.key === s.altKey);
|
||||
const ctrlMatch = s.ctrl ? (e.ctrlKey || e.metaKey) : true;
|
||||
const shiftMatch = s.shift ? e.shiftKey : !e.shiftKey;
|
||||
if (keyMatch && ctrlMatch && shiftMatch) {
|
||||
// Match against the shortcut registry so user rebinds and per-shortcut
|
||||
// disables (App Settings → Shortcuts) take effect. Every dispatchable
|
||||
// binding requires Ctrl/Cmd/Alt (capture enforces the same), so plain
|
||||
// typing exits early without touching the registry.
|
||||
if (!e.ctrlKey && !e.metaKey && !e.altKey) return;
|
||||
for (const shortcut of this.getShortcutRegistry()) {
|
||||
if (shortcut.disabled || !shortcut.action) continue;
|
||||
const action = SHORTCUT_ACTIONS[shortcut.action];
|
||||
if (!action) continue;
|
||||
if (this.matchesShortcutEvent(e, shortcut)) {
|
||||
e.preventDefault();
|
||||
s.action();
|
||||
action();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1640,8 +1803,13 @@ class CodemanApp {
|
||||
const data = (await res.json())?.data ?? {};
|
||||
let lastResponse = data.text || '';
|
||||
|
||||
// Source 2: Terminal buffer fallback — strip ANSI, drop Claude CLI chrome
|
||||
if (!lastResponse) {
|
||||
// Source 2: Terminal buffer fallback — strip ANSI, drop Claude CLI chrome.
|
||||
// Claude + shell only: _cleanTerminalBuffer knows Claude CLI's output, and
|
||||
// shell sessions have no transcript source at all; for TUI modes
|
||||
// (codex/opencode/gemini) it yields repaint garbage, so a clear
|
||||
// placeholder beats a messy screen dump there.
|
||||
const sessionMode = this.sessions.get(this.activeSessionId)?.mode || 'claude';
|
||||
if (!lastResponse && (sessionMode === 'claude' || sessionMode === 'shell')) {
|
||||
const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`);
|
||||
const termData = (await termRes.json())?.data ?? {};
|
||||
if (termData.terminalBuffer) {
|
||||
@@ -1650,8 +1818,12 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
const body = document.getElementById('responseViewerBody');
|
||||
body.innerHTML = this._renderMarkdown(lastResponse);
|
||||
this._bindResponseViewerInteractions(body);
|
||||
if (lastResponse) {
|
||||
body.innerHTML = this._renderMarkdown(lastResponse);
|
||||
this._bindResponseViewerInteractions(body);
|
||||
} else {
|
||||
body.textContent = 'No response yet — send a message in this session first.';
|
||||
}
|
||||
|
||||
// Reset state for fresh open
|
||||
const title = document.getElementById('responseViewerTitle');
|
||||
@@ -1685,6 +1857,9 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
// Render conversation thread
|
||||
const mode = this.sessions.get(this.activeSessionId)?.mode;
|
||||
const agentLabel =
|
||||
mode === 'codex' ? 'Codex' : mode === 'gemini' ? 'Gemini' : mode === 'opencode' ? 'OpenCode' : 'Claude';
|
||||
body.innerHTML = '';
|
||||
for (const msg of messages) {
|
||||
const div = document.createElement('div');
|
||||
@@ -1693,7 +1868,7 @@ class CodemanApp {
|
||||
|
||||
const role = document.createElement('div');
|
||||
role.className = 'rv-role ' + (isUser ? 'rv-role-user' : 'rv-role-assistant');
|
||||
role.textContent = isUser ? 'You' : 'Claude';
|
||||
role.textContent = isUser ? 'You' : agentLabel;
|
||||
div.appendChild(role);
|
||||
|
||||
const text = document.createElement('div');
|
||||
@@ -1880,6 +2055,15 @@ class CodemanApp {
|
||||
this.updateAutoResumeStatus(data.sessionId);
|
||||
}
|
||||
|
||||
// COD-118: the interactive PTY exit circuit breaker tripped (repeated non-zero exits).
|
||||
// The errored status itself arrives via session:updated; this just surfaces a toast for
|
||||
// diagnostic clarity so a silently-looping session is obvious. Restart clears the breaker.
|
||||
_onSessionRespawnBreakerTripped(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
const label = session?.name || 'Session';
|
||||
this.showToast?.(`${label} stopped: repeated crashes detected. Restart to retry.`, 'error');
|
||||
}
|
||||
|
||||
_onSessionCliInfo(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
if (session) {
|
||||
@@ -1992,9 +2176,21 @@ class CodemanApp {
|
||||
*/
|
||||
_connectWs(sessionId) {
|
||||
this._disconnectWs();
|
||||
this._wsState = 'connecting';
|
||||
this._updateConnectionIndicator();
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${proto}//${location.host}/ws/sessions/${sessionId}/terminal`;
|
||||
// Pass a per-TAB identity on the upgrade URL so the server's connection
|
||||
// registry scopes the per-session limit by connection (COD-137): a same-tab
|
||||
// reconnect supersedes its own socket instead of consuming a new slot and
|
||||
// tripping a spurious 4008, while two tabs of the same browser (which share
|
||||
// the localStorage clientId) each keep their own socket. The bare clientId
|
||||
// still rides the input frames for seq dedup. Omitted if clientId is
|
||||
// unavailable (server then treats the upgrade as anonymous — still admitted
|
||||
// up to the limit).
|
||||
const cid = this._clientId ? `${this._clientId}:${this._wsTabNonce}` : '';
|
||||
const cidQuery = cid ? `?cid=${encodeURIComponent(cid)}` : '';
|
||||
const url = `${proto}//${location.host}/ws/sessions/${sessionId}/terminal${cidQuery}`;
|
||||
const ws = new WebSocket(url);
|
||||
this._ws = ws;
|
||||
this._wsSessionId = sessionId;
|
||||
@@ -2003,7 +2199,9 @@ class CodemanApp {
|
||||
// Only mark ready if this is still the intended session
|
||||
if (this._ws === ws) {
|
||||
this._wsReady = true;
|
||||
this._wsState = 'connected';
|
||||
this._wsReconnectAttempts = 0;
|
||||
this._updateConnectionIndicator();
|
||||
// Send a typed resize over the fresh socket: syncs PTY dims after
|
||||
// (re)connects AND registers the desktop sizing claim server-side —
|
||||
// selectSession's earlier resizes ran before this WS existed, so they
|
||||
@@ -2018,6 +2216,9 @@ class CodemanApp {
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (this._ws !== ws) return;
|
||||
// Mark the socket as alive on every received frame (output, ACK, etc.) so
|
||||
// the redeliver sweep only force-closes a genuinely silent connection.
|
||||
this._wsLastRecvAt = Date.now();
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.t === 'o') {
|
||||
@@ -2044,18 +2245,53 @@ class CodemanApp {
|
||||
this._wsReady = false;
|
||||
this._stopMobileResizeRetry();
|
||||
|
||||
// Reconnect on unexpected close (server restart, network blip, ping timeout).
|
||||
// Don't reconnect if we intentionally disconnected (_disconnectWs nulls onclose)
|
||||
// or if the server rejected the session (4004=not found, 4008=too many, 4009=terminated).
|
||||
if (event.code < 4004 && this.activeSessionId === sessionId) {
|
||||
const delay = Math.min(1000 * Math.pow(2, this._wsReconnectAttempts || 0), 10000);
|
||||
this._wsReconnectAttempts = (this._wsReconnectAttempts || 0) + 1;
|
||||
this._wsReconnectTimer = setTimeout(() => {
|
||||
this._wsReconnectTimer = null;
|
||||
if (this.activeSessionId === sessionId) {
|
||||
this._connectWs(sessionId);
|
||||
}
|
||||
}, delay);
|
||||
// Decide what to do next from the close code + how many consecutive
|
||||
// reconnects we've already made (pure policy in constants.js):
|
||||
// reconnect → transient (server restart, network blip, ping timeout);
|
||||
// schedule a backoff retry while this session stays active.
|
||||
// retry-fallback → too-many-connections / unknown rejection; show the HTTP
|
||||
// fallback but keep retrying so we return to WS when it clears.
|
||||
// give-up → 4004 (not found) / 4009 (terminated); the session is gone.
|
||||
// _disconnectWs() nulls onclose for intentional disconnects, so we never land here for those.
|
||||
const plan = window.CodemanWsReconnect.plan(event.code, this._wsReconnectAttempts || 0);
|
||||
_crashDiag.log(
|
||||
`WS CLOSE code=${event.code} reason=${event.reason || ''} action=${plan.action} attempts=${this._wsReconnectAttempts || 0}`
|
||||
);
|
||||
|
||||
const stillActive = this.activeSessionId === sessionId;
|
||||
if (plan.action === 'give-up') {
|
||||
this._wsState = stillActive ? 'fallback' : 'disconnected';
|
||||
this._updateConnectionIndicator();
|
||||
} else if (plan.action === 'reconnect') {
|
||||
if (stillActive) {
|
||||
this._wsState = 'reconnecting';
|
||||
this._updateConnectionIndicator();
|
||||
const delay = plan.delayMs + Math.floor(Math.random() * 250); // jitter to de-sync herds
|
||||
this._wsReconnectAttempts = (this._wsReconnectAttempts || 0) + 1;
|
||||
this._wsReconnectTimer = setTimeout(() => {
|
||||
this._wsReconnectTimer = null;
|
||||
if (this.activeSessionId === sessionId) {
|
||||
this._connectWs(sessionId);
|
||||
}
|
||||
}, delay);
|
||||
} else {
|
||||
this._wsState = 'disconnected';
|
||||
this._updateConnectionIndicator();
|
||||
}
|
||||
} else {
|
||||
// retry-fallback: surface the HTTP fallback, but keep trying on a bounded
|
||||
// timer so the transport returns to WS once the transient condition clears.
|
||||
this._wsState = stillActive ? 'fallback' : 'disconnected';
|
||||
this._updateConnectionIndicator();
|
||||
if (stillActive) {
|
||||
this._wsReconnectAttempts = (this._wsReconnectAttempts || 0) + 1;
|
||||
this._wsReconnectTimer = setTimeout(() => {
|
||||
this._wsReconnectTimer = null;
|
||||
if (this.activeSessionId === sessionId) {
|
||||
this._connectWs(sessionId);
|
||||
}
|
||||
}, plan.delayMs);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2067,7 +2303,11 @@ class CodemanApp {
|
||||
/** Close the active WebSocket connection (if any). */
|
||||
_disconnectWs() {
|
||||
this._clearTimer('_wsReconnectTimer');
|
||||
this._wsReconnectAttempts = 0;
|
||||
// Deliberately do NOT reset _wsReconnectAttempts here: _connectWs() calls
|
||||
// this first, so a reset would restart the exponential backoff ladder at
|
||||
// attempt 0 on every retry (≈0ms tight reconnect loop during an outage).
|
||||
// ws.onopen zeroes the counter once a connection actually succeeds.
|
||||
this._wsState = 'disconnected';
|
||||
this._stopMobileResizeRetry();
|
||||
if (this._ws) {
|
||||
this._ws.onclose = null; // Prevent re-entrant cleanup
|
||||
@@ -2262,7 +2502,13 @@ class CodemanApp {
|
||||
this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId;
|
||||
if (isActiveWs) {
|
||||
const oldest = list[0];
|
||||
if (oldest && oldest.sentAt && Date.now() - oldest.sentAt > this._reliableAckTimeoutMs) {
|
||||
// Only tear the socket down when the oldest unacked frame is stale AND the
|
||||
// socket has been silent for the timeout: a connection still delivering
|
||||
// output/ACKs is alive (the ACK is just behind), so force-closing it would
|
||||
// cause needless WS↔HTTP flapping. A truly half-open socket goes quiet.
|
||||
const stale = oldest && oldest.sentAt && Date.now() - oldest.sentAt > this._reliableAckTimeoutMs;
|
||||
const silent = Date.now() - this._wsLastRecvAt > this._reliableAckTimeoutMs;
|
||||
if (stale && silent) {
|
||||
try {
|
||||
this._ws.close(); // half-open: never recovers on its own — force reconnect
|
||||
} catch {
|
||||
@@ -2270,6 +2516,18 @@ class CodemanApp {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (stale) {
|
||||
// Stale but the socket is still delivering output: the ACK was lost,
|
||||
// not the connection. Force-closing isn't warranted (the link is fine),
|
||||
// but the fast path skips anything with sentAt!==0, so the stranded
|
||||
// frame would never re-send. Reset sentAt=0 on every stale unacked
|
||||
// frame so the _drainSession below re-drives them over the live socket
|
||||
// (server dedups by seq, so a re-sent lost-ACK frame is harmless).
|
||||
// Frames sent recently (not yet stale) are left untouched.
|
||||
for (const rec of list) {
|
||||
if (rec.sentAt && Date.now() - rec.sentAt > this._reliableAckTimeoutMs) rec.sentAt = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
this._drainSession(sessionId);
|
||||
}
|
||||
@@ -2380,39 +2638,107 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Pure render of the header connection indicator: reads only `this.*` state,
|
||||
// touches NO DOM. Returns the exact { display, dotClass, text, title } tuple the
|
||||
// writer applies. When hidden (display:'none') the other three are normalized to
|
||||
// '' so the cache compare in _updateConnectionIndicator() is well-defined.
|
||||
// Every branch/string here must stay byte-identical to what's rendered today.
|
||||
_computeConnectionDescriptor() {
|
||||
const { bytes: totalBytes, count } = this._pendingBytes();
|
||||
const hasQueue = count > 0;
|
||||
// Only surface a backlog once it's more than a few bytes. A single keystroke
|
||||
// (1B) ACKs in milliseconds, so without this the label flickered "sending 1B"
|
||||
// on every key press. Above this threshold means input is genuinely backing up.
|
||||
const BACKLOG_HINT_BYTES = 4;
|
||||
const showBacklog = totalBytes > BACKLOG_HINT_BYTES;
|
||||
const formatBytes = (b) => (b < 1024 ? `${b}B` : `${(b / 1024).toFixed(1)}KB`);
|
||||
const queuedSuffix = showBacklog ? ` · ${formatBytes(totalBytes)} queued` : '';
|
||||
|
||||
// Hard offline (browser reports no network) dominates everything.
|
||||
if (!this.isOnline || this._connectionStatus === 'offline') {
|
||||
return {
|
||||
display: 'flex',
|
||||
dotClass: 'connection-dot offline',
|
||||
text: showBacklog ? `Offline (${formatBytes(totalBytes)} queued)` : 'Offline',
|
||||
title: 'No network connection',
|
||||
};
|
||||
}
|
||||
|
||||
// With an active terminal, show its transport (WebSocket vs HTTP fallback).
|
||||
if (this.activeSessionId) {
|
||||
let cls, label, detail;
|
||||
switch (this._wsState) {
|
||||
case 'connected':
|
||||
cls = 'connected'; label = 'WS'; detail = 'Terminal connected over WebSocket';
|
||||
break;
|
||||
case 'fallback':
|
||||
cls = 'fallback'; label = 'HTTP'; detail = 'WebSocket unavailable — input sent over HTTP';
|
||||
break;
|
||||
case 'reconnecting':
|
||||
cls = 'reconnecting'; label = 'WS…'; detail = 'Reconnecting WebSocket';
|
||||
break;
|
||||
case 'connecting':
|
||||
default:
|
||||
cls = 'reconnecting'; label = 'WS…'; detail = 'Connecting WebSocket';
|
||||
break;
|
||||
}
|
||||
return {
|
||||
display: 'flex',
|
||||
dotClass: `connection-dot ${cls}`,
|
||||
text: `${label}${queuedSuffix}`,
|
||||
title: detail,
|
||||
};
|
||||
}
|
||||
|
||||
// No active terminal — reflect the SSE event stream only when it needs attention.
|
||||
if (this._connectionStatus === 'reconnecting' || this._connectionStatus === 'disconnected') {
|
||||
return {
|
||||
display: 'flex',
|
||||
dotClass: 'connection-dot reconnecting',
|
||||
text: showBacklog ? `Reconnecting (${formatBytes(totalBytes)} queued)` : 'Reconnecting...',
|
||||
title: 'Reconnecting to server',
|
||||
};
|
||||
}
|
||||
|
||||
// Idle dashboard, healthy stream — hide unless input is genuinely queued.
|
||||
if (!hasQueue) {
|
||||
return { display: 'none', dotClass: '', text: '', title: '' };
|
||||
}
|
||||
return {
|
||||
display: 'flex',
|
||||
dotClass: 'connection-dot draining',
|
||||
text: showBacklog ? `Sending ${formatBytes(totalBytes)}...` : 'Sending...',
|
||||
title: 'Delivering queued input',
|
||||
};
|
||||
}
|
||||
|
||||
_updateConnectionIndicator() {
|
||||
const indicator = this.$('connectionIndicator');
|
||||
const dot = this.$('connectionDot');
|
||||
const text = this.$('connectionText');
|
||||
if (!indicator || !dot || !text) return;
|
||||
|
||||
const status = this._connectionStatus;
|
||||
|
||||
// While the connection is healthy, never surface the input queue. With the
|
||||
// reliable-delivery layer every keystroke is briefly "pending" until its ACK
|
||||
// lands a few ms later — showing that flashed "Sending 1B…" on every single
|
||||
// character. The indicator is only meaningful for an actual connection
|
||||
// problem (reconnecting / offline), where the queued byte count reassures
|
||||
// the user their typing is safely buffered and will be sent.
|
||||
if (status === 'connected' || status === 'connecting') {
|
||||
indicator.style.display = 'none';
|
||||
// Called on EVERY keystroke (_reliableSend) and EVERY ACK (_ackDelivery).
|
||||
// During fast typing the rendered tuple is usually identical, so skip the DOM
|
||||
// writes when nothing changed (COD-136) — the compute above is DOM-free.
|
||||
const next = this._computeConnectionDescriptor();
|
||||
const prev = this._lastIndicatorDescriptor;
|
||||
if (
|
||||
prev &&
|
||||
prev.display === next.display &&
|
||||
prev.dotClass === next.dotClass &&
|
||||
prev.text === next.text &&
|
||||
prev.title === next.title
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._lastIndicatorDescriptor = next;
|
||||
|
||||
const { bytes: totalBytes, count } = this._pendingBytes();
|
||||
const hasQueue = count > 0;
|
||||
indicator.style.display = 'flex';
|
||||
dot.className = 'connection-dot';
|
||||
|
||||
const formatBytes = (b) => (b < 1024 ? `${b}B` : `${(b / 1024).toFixed(1)}KB`);
|
||||
|
||||
if (status === 'reconnecting') {
|
||||
dot.classList.add('reconnecting');
|
||||
text.textContent = hasQueue ? `Reconnecting (${formatBytes(totalBytes)} queued)` : 'Reconnecting...';
|
||||
} else {
|
||||
// Offline or disconnected
|
||||
dot.classList.add('offline');
|
||||
text.textContent = hasQueue ? `Offline (${formatBytes(totalBytes)} queued)` : 'Offline';
|
||||
indicator.style.display = next.display;
|
||||
if (next.display !== 'none') {
|
||||
dot.className = next.dotClass;
|
||||
text.textContent = next.text;
|
||||
indicator.title = next.title;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3599,17 +3925,40 @@ class CodemanApp {
|
||||
// Track working directory for path normalization in Project Insights
|
||||
this.currentSessionWorkingDir = session?.workingDir || null;
|
||||
if (session && session.pid === null) {
|
||||
// Session has no PTY attached — either restored after server restart
|
||||
// or detached for some other reason. Re-attach regardless of status.
|
||||
try {
|
||||
const endpoint = session.mode === 'shell'
|
||||
? `/api/sessions/${sessionId}/shell`
|
||||
: `/api/sessions/${sessionId}/interactive`;
|
||||
await fetch(endpoint, { method: 'POST' });
|
||||
// Update local session state
|
||||
session.status = 'busy';
|
||||
} catch (err) {
|
||||
console.error('Failed to attach to restored session:', err);
|
||||
if (session.respawnBlocked) {
|
||||
// COD-118: the PTY-exit circuit breaker tripped for this session — the
|
||||
// automatic re-attach must NOT silently clear it (that would re-arm the
|
||||
// crash loop on every tab click / page load). Restart only on explicit
|
||||
// user confirmation; the confirmed request carries clearBreaker:true.
|
||||
const label = session.name || 'Session';
|
||||
if (window.confirm(`${label} was stopped after crashing repeatedly. Restart it?`)) {
|
||||
try {
|
||||
await fetch(`/api/sessions/${sessionId}/interactive`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clearBreaker: true }),
|
||||
});
|
||||
session.respawnBlocked = false;
|
||||
session.status = 'busy';
|
||||
} catch (err) {
|
||||
console.error('Failed to restart crash-looped session:', err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Session has no PTY attached — either restored after server restart
|
||||
// or detached for some other reason. Re-attach regardless of status.
|
||||
// Deliberately NO body: this automatic path must never clear a tripped
|
||||
// PTY-exit breaker (COD-118).
|
||||
try {
|
||||
const endpoint = session.mode === 'shell'
|
||||
? `/api/sessions/${sessionId}/shell`
|
||||
: `/api/sessions/${sessionId}/interactive`;
|
||||
await fetch(endpoint, { method: 'POST' });
|
||||
// Update local session state
|
||||
session.status = 'busy';
|
||||
} catch (err) {
|
||||
console.error('Failed to attach to restored session:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3628,6 +3977,9 @@ class CodemanApp {
|
||||
// the buffer write, causing 70KB+ single-frame flushes that stall WebGL.
|
||||
// chunkedTerminalWrite also sets this, but we need it before the fetch too.
|
||||
const bufferLoadOwner = this._beginBufferLoad(selectGen);
|
||||
// COD-144: track whether the load painted nothing (empty fetch + no cache).
|
||||
// For that just-created-session case we flush (not discard) queued SSE events.
|
||||
let bufferWasEmpty = false;
|
||||
try {
|
||||
// Fit terminal to container BEFORE writing any buffer data.
|
||||
// If the browser was resized while viewing another session, the terminal
|
||||
@@ -3739,7 +4091,16 @@ class CodemanApp {
|
||||
|
||||
this._setTerminalLoadState(sessionId, selectGen, 'fetching');
|
||||
_crashDiag.log('FETCH_START');
|
||||
const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
||||
// The FIRST buffer load after a page load requests the full tmux scrollback
|
||||
// (?full=1, COD-47) so history that scrolled off the server's byte buffer
|
||||
// comes back after a reload. Tab switches keep the fast ?tail= frame path.
|
||||
const useFullHistory = this._initialFullBufferLoad === true;
|
||||
this._initialFullBufferLoad = false;
|
||||
const res = await fetch(
|
||||
useFullHistory
|
||||
? `/api/sessions/${sessionId}/terminal?full=1`
|
||||
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
|
||||
);
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
return;
|
||||
@@ -3784,13 +4145,16 @@ class CodemanApp {
|
||||
} else if (!cachedBuffer) {
|
||||
// No fresh buffer and no cache — clear any stale content
|
||||
this._resetTerminalForReplay();
|
||||
bufferWasEmpty = true;
|
||||
}
|
||||
|
||||
// Buffer load complete — unblock live SSE writes (queued events are discarded
|
||||
// to prevent duplicate content). chunkedTerminalWrite calls _finishBufferLoad
|
||||
// internally, but if we skipped the write (cache hit or empty), call it here.
|
||||
// Buffer load complete — unblock live SSE writes. chunkedTerminalWrite calls
|
||||
// _finishBufferLoad internally (discarding queued events to prevent duplicate
|
||||
// content); if we skipped the write (cache hit or empty), call it here.
|
||||
// COD-144: when the load painted nothing, FLUSH the queued events instead of
|
||||
// discarding — a new session's prompt arrives only as a queued SSE event.
|
||||
if (this._isLoadingBuffer) {
|
||||
this._finishBufferLoad(bufferLoadOwner);
|
||||
this._finishBufferLoad(bufferLoadOwner, { flushQueued: bufferWasEmpty });
|
||||
}
|
||||
// Drop the guard so user input clears state normally
|
||||
this._restoringFlushedState = false;
|
||||
@@ -4256,6 +4620,91 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Shortcut Registry ───────────────────────────────────────────────────────
|
||||
// Returns the merged shortcut list: DEFAULT_SHORTCUTS with any per-shortcut
|
||||
// overrides from settings.shortcutOverrides applied on top.
|
||||
|
||||
getShortcutRegistry() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const shortcutOverrides = settings.shortcutOverrides || {};
|
||||
return DEFAULT_SHORTCUTS.map((shortcut) => {
|
||||
const override = shortcutOverrides[shortcut.id];
|
||||
if (!override) return shortcut;
|
||||
// Only binding-shaped fields may come from storage — id/label/group/action
|
||||
// stay trusted so persisted data can never redirect a shortcut's action or
|
||||
// spoof another row in the settings/overlay renderers.
|
||||
const merged = { ...shortcut };
|
||||
if (Array.isArray(override.bindings)) {
|
||||
merged.bindings = override.bindings;
|
||||
delete merged.displayBindings; // show the override, not the stale default label
|
||||
}
|
||||
if (typeof override.disabled === 'boolean') merged.disabled = override.disabled;
|
||||
return merged;
|
||||
});
|
||||
}
|
||||
|
||||
matchesShortcutEvent(e, shortcut) {
|
||||
if (!shortcut || !Array.isArray(shortcut.bindings)) return false;
|
||||
return shortcut.bindings.some((binding) => {
|
||||
const mods = binding.modifiers || [];
|
||||
// Ctrl and Cmd are interchangeable as the primary modifier (parity with
|
||||
// the legacy shortcut table), but every OTHER pressed modifier must be
|
||||
// declared by the binding — a plain Ctrl+K binding must not also swallow
|
||||
// Ctrl+Shift+K (the Firefox devtools chord).
|
||||
const wantsPrimary = mods.includes('ctrl') || mods.includes('meta');
|
||||
if (wantsPrimary !== !!(e.ctrlKey || e.metaKey)) return false;
|
||||
if (mods.includes('shift') !== !!e.shiftKey) return false;
|
||||
if (mods.includes('alt') !== !!e.altKey) return false;
|
||||
// Match the physical key when the binding pins one (layout-independent),
|
||||
// or the produced character otherwise (layout-dependent keys like '+').
|
||||
if (binding.code && e.code === binding.code) return true;
|
||||
if (binding.key && typeof e.key === 'string' && e.key.toLowerCase() === binding.key.toLowerCase()) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Shortcut Overlay Modal ───────────────────────────────────────────────────
|
||||
// Ctrl/Alt+? opens a floating overlay listing all keyboard shortcuts, grouped
|
||||
// by category. Uses the merged registry so user overrides are reflected.
|
||||
|
||||
showShortcutOverlay() {
|
||||
const modal = document.getElementById('shortcutOverlayModal');
|
||||
if (!modal) return;
|
||||
this.renderShortcutOverlay();
|
||||
modal.classList.add('active');
|
||||
modal.focus?.();
|
||||
}
|
||||
|
||||
renderShortcutOverlay() {
|
||||
const list = document.getElementById('shortcutOverlayList');
|
||||
if (!list) return;
|
||||
const registry = this.getShortcutRegistry();
|
||||
const groups = {};
|
||||
for (const shortcut of registry) {
|
||||
const g = shortcut.group || 'General';
|
||||
if (!groups[g]) groups[g] = [];
|
||||
groups[g].push(shortcut);
|
||||
}
|
||||
const fmtBindings = (s) => {
|
||||
if (s.displayBindings) return s.displayBindings.map((b) => `<kbd>${escapeHtml(b)}</kbd>`).join(' / ');
|
||||
if (!s.bindings) return '';
|
||||
return s.bindings.map((b) => {
|
||||
const parts = [...(b.modifiers || []).map((m) => m.charAt(0).toUpperCase() + m.slice(1)), b.key || b.code || ''];
|
||||
return `<kbd>${escapeHtml(parts.join('+'))}</kbd>`;
|
||||
}).join(' / ');
|
||||
};
|
||||
list.innerHTML = Object.entries(groups).map(([group, items]) =>
|
||||
`<div class="shortcut-overlay-group"><div class="shortcut-overlay-group-label">${escapeHtml(group)}</div>` +
|
||||
items.map((s) => `<div class="shortcut-overlay-row"><span class="shortcut-overlay-label">${escapeHtml(s.label)}</span><span class="shortcut-overlay-keys">${fmtBindings(s)}</span></div>`).join('') +
|
||||
`</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
closeShortcutOverlay() {
|
||||
const modal = document.getElementById('shortcutOverlayModal');
|
||||
if (modal) modal.classList.remove('active');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -156,6 +156,30 @@ 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;
|
||||
@@ -163,6 +187,9 @@ if (typeof window !== 'undefined') {
|
||||
window.CodemanTabOverflow = {
|
||||
shouldAutoWrapTabs,
|
||||
};
|
||||
window.CodemanWsReconnect = {
|
||||
plan: planWsReconnect,
|
||||
};
|
||||
}
|
||||
|
||||
// Scheduler API — prioritize terminal writes over background UI updates.
|
||||
@@ -293,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',
|
||||
@@ -307,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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
},
|
||||
});
|
||||
+262
-15
@@ -358,7 +358,7 @@
|
||||
<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>
|
||||
@@ -427,7 +427,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>
|
||||
@@ -469,7 +469,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>
|
||||
@@ -541,6 +556,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" onclick="app.openCron()" title="Cron Jobs">⏰ Cron</button>
|
||||
<span class="version-display" id="versionDisplay" title="Codeman version">v0.0.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -554,18 +570,141 @@
|
||||
<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>Shift</kbd>+<kbd>Wheel</kbd></div><div>Scroll local history (when mouse passthrough is active)</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" style="margin-bottom:10px;">Times use the server's local timezone.</p>
|
||||
<div style="margin-bottom:10px;">
|
||||
<button class="btn-toolbar btn-sm btn-primary" onclick="app.openCronJobForm()">+ New Job</button>
|
||||
<button class="btn-toolbar btn-sm" 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="form-section-header" id="cronFormTitle">New Cron Job</div>
|
||||
<input type="hidden" id="schJobId">
|
||||
<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" 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-row"><label>Prompt Source</label>
|
||||
<select id="schPromptMode" 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">
|
||||
<option value="typed">Typed (via tmux)</option>
|
||||
<option value="paste">Paste (direct)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row"><label>Schedule Type</label>
|
||||
<select id="schScheduleType" 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-row"><label>On auto-run, if same agent running</label>
|
||||
<select id="schConcurrencyPolicy">
|
||||
<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"><label title="Recurring schedules only: when the next run fires, the still-open session created by this job's previous run is closed first">Auto-close previous run's session</label>
|
||||
<label class="switch"><input type="checkbox" id="schAutoClosePrev" checked><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="form-row form-row-switch"><label>Enabled</label>
|
||||
<label class="switch"><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" style="color:var(--danger,#e55);"></div>
|
||||
<div style="margin-top:10px;">
|
||||
<button class="btn-toolbar btn-sm btn-primary" onclick="app.saveCronJob()">Save</button>
|
||||
<button class="btn-toolbar btn-sm" onclick="app.cancelCronJobForm()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -997,6 +1136,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 -->
|
||||
@@ -1610,6 +1750,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>
|
||||
@@ -1618,6 +1771,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>
|
||||
@@ -1629,6 +1799,7 @@
|
||||
<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-manage">Manage</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@@ -1657,6 +1828,66 @@
|
||||
<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>
|
||||
<!-- Manage Tab -->
|
||||
<div class="modal-tab-content hidden" id="case-manage">
|
||||
<div class="case-manage-list" id="caseManageList">
|
||||
@@ -1956,6 +2187,20 @@
|
||||
</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>
|
||||
@@ -1971,6 +2216,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Token Stats Modal -->
|
||||
<div class="modal" id="tokenStatsModal">
|
||||
<div class="modal-backdrop" onclick="app.closeTokenStats()"></div>
|
||||
@@ -2079,6 +2325,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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
+355
-57
@@ -250,39 +250,283 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.openImagePopup(data);
|
||||
},
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Away Digest Modal
|
||||
// Command Palette (COD-153)
|
||||
// Fast Cmd/Ctrl+K switcher for currently open sessions, plus launch-new.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
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();
|
||||
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;
|
||||
},
|
||||
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (modal) modal.classList.remove('active');
|
||||
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)
|
||||
// Persistent, header-reachable session list (GET /api/sessions/unified)
|
||||
// reachable mid-session, with a server-side search box. Reuses the
|
||||
// unit-2 history item renderer so clicking resumes/switches sessions.
|
||||
// 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() {
|
||||
@@ -338,6 +582,95 @@ Object.assign(CodemanApp.prototype, {
|
||||
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
|
||||
@@ -360,41 +693,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
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();
|
||||
const sessions = data.data?.sessions || [];
|
||||
list.replaceChildren();
|
||||
if (sessions.length === 0) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'empty-message';
|
||||
empty.textContent = q ? 'No sessions match your search' : 'No sessions found';
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
for (const s of sessions) {
|
||||
const item = this._buildHistoryItem(s, this.cases, { showViewAll: false });
|
||||
// COD-130: scope the modal-close to the main (resume) row only, in the
|
||||
// bubble phase. The ⋯ kebab button calls stopPropagation(), so clicking
|
||||
// it (or its menu) no longer closes the Session Manager modal.
|
||||
item.querySelector('.history-item-main')?.addEventListener('click', () => this.closeSessionManager());
|
||||
list.appendChild(item);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[_loadSessionManagerList]', err);
|
||||
list.replaceChildren();
|
||||
const errLine = document.createElement('p');
|
||||
errLine.className = 'empty-message';
|
||||
errLine.textContent = 'Failed to load sessions';
|
||||
list.appendChild(errLine);
|
||||
}
|
||||
},
|
||||
|
||||
setAwayDigestRange(range) {
|
||||
this.awayDigestRange = range;
|
||||
this._awayDigestLoadedSuccessfully = false;
|
||||
|
||||
+414
-55
@@ -44,6 +44,203 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Quick Start
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
formatCasePickerLabel(c) {
|
||||
return c?.location === 'remote' && c.remote?.hostId ? `${c.name} @ ${c.remote.hostId}` : c?.name || '';
|
||||
},
|
||||
|
||||
buildCasePickerOptions(cases = []) {
|
||||
const normalized = [];
|
||||
const seen = new Set();
|
||||
for (const c of cases) {
|
||||
if (!c?.name || seen.has(c.name)) continue;
|
||||
seen.add(c.name);
|
||||
normalized.push(c);
|
||||
}
|
||||
if (!seen.has('testcase')) {
|
||||
normalized.push({ name: 'testcase' });
|
||||
}
|
||||
|
||||
return normalized
|
||||
.map(c => {
|
||||
const label = this.formatCasePickerLabel(c);
|
||||
const searchText = [
|
||||
c.name,
|
||||
label,
|
||||
c.path,
|
||||
c.location,
|
||||
c.remote?.hostId,
|
||||
c.remote?.label,
|
||||
c.remote?.path
|
||||
].filter(Boolean).join(' ').toLowerCase();
|
||||
return { name: c.name, label, case: c, searchText };
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base', numeric: true }));
|
||||
},
|
||||
|
||||
filterCasePickerOptions(options, query) {
|
||||
const terms = String(query || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (terms.length === 0) return options;
|
||||
return options.filter(option => terms.every(term => option.searchText.includes(term)));
|
||||
},
|
||||
|
||||
getCasePickerOptions() {
|
||||
return this.buildCasePickerOptions(this.cases || []);
|
||||
},
|
||||
|
||||
updateCasePickerInput(caseName) {
|
||||
const input = document.getElementById('quickStartCaseSearch');
|
||||
if (!input) return;
|
||||
const option = this.getCasePickerOptions().find(item => item.name === caseName);
|
||||
input.value = option?.label || caseName || 'testcase';
|
||||
input.title = option?.label || input.value;
|
||||
},
|
||||
|
||||
renderQuickStartCaseSelectOptions(select, options) {
|
||||
if (!select) return;
|
||||
select.innerHTML = options
|
||||
.map(option => `<option value="${escapeHtml(option.name)}">${escapeHtml(option.label)}</option>`)
|
||||
.join('');
|
||||
},
|
||||
|
||||
openCasePicker(filter = '') {
|
||||
const input = document.getElementById('quickStartCaseSearch');
|
||||
const list = document.getElementById('quickStartCaseList');
|
||||
if (!input || !list) return;
|
||||
this._casePickerOpen = true;
|
||||
this._casePickerFilter = filter;
|
||||
this._casePickerActiveIndex = 0;
|
||||
input.setAttribute('aria-expanded', 'true');
|
||||
this.renderCasePickerList();
|
||||
},
|
||||
|
||||
closeCasePicker() {
|
||||
const input = document.getElementById('quickStartCaseSearch');
|
||||
const list = document.getElementById('quickStartCaseList');
|
||||
this._casePickerOpen = false;
|
||||
this._casePickerFilter = '';
|
||||
input?.setAttribute('aria-expanded', 'false');
|
||||
input?.removeAttribute('aria-activedescendant');
|
||||
list?.classList.add('hidden');
|
||||
},
|
||||
|
||||
renderCasePickerList() {
|
||||
const input = document.getElementById('quickStartCaseSearch');
|
||||
const list = document.getElementById('quickStartCaseList');
|
||||
const select = document.getElementById('quickStartCase');
|
||||
if (!input || !list || !select) return;
|
||||
|
||||
const options = this.filterCasePickerOptions(this.getCasePickerOptions(), this._casePickerFilter || '');
|
||||
const selectedName = select.value || 'testcase';
|
||||
const maxIndex = Math.max(0, options.length - 1);
|
||||
this._casePickerActiveIndex = Math.min(Math.max(this._casePickerActiveIndex || 0, 0), maxIndex);
|
||||
|
||||
if (options.length === 0) {
|
||||
list.innerHTML = '<div class="case-combobox-empty">No cases match</div>';
|
||||
list.classList.remove('hidden');
|
||||
input.removeAttribute('aria-activedescendant');
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = options
|
||||
.map((option, index) => {
|
||||
const active = index === this._casePickerActiveIndex;
|
||||
const selected = option.name === selectedName;
|
||||
const id = `quickStartCaseOption-${index}`;
|
||||
return `
|
||||
<button
|
||||
type="button"
|
||||
id="${id}"
|
||||
class="case-combobox-option ${active ? 'active' : ''} ${selected ? 'selected' : ''}"
|
||||
role="option"
|
||||
aria-selected="${selected ? 'true' : 'false'}"
|
||||
data-case="${escapeHtml(option.name)}"
|
||||
title="${escapeHtml(option.label)}">
|
||||
<span class="case-combobox-check">${selected ? '✓' : ''}</span>
|
||||
<span class="case-combobox-option-label">${escapeHtml(option.label)}</span>
|
||||
</button>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
list.classList.remove('hidden');
|
||||
input.setAttribute('aria-activedescendant', `quickStartCaseOption-${this._casePickerActiveIndex}`);
|
||||
},
|
||||
|
||||
selectQuickStartCase(caseName, { save = true } = {}) {
|
||||
const select = document.getElementById('quickStartCase');
|
||||
if (!select) return;
|
||||
select.value = caseName || 'testcase';
|
||||
this.updateCasePickerInput(select.value);
|
||||
this.closeCasePicker();
|
||||
this.updateDirDisplayForCase(select.value);
|
||||
this.updateMobileCaseLabel(select.value);
|
||||
if (save) {
|
||||
this.saveLastUsedCase(select.value);
|
||||
}
|
||||
},
|
||||
|
||||
setupQuickStartCasePicker() {
|
||||
const select = document.getElementById('quickStartCase');
|
||||
const input = document.getElementById('quickStartCaseSearch');
|
||||
const list = document.getElementById('quickStartCaseList');
|
||||
const picker = document.getElementById('quickStartCasePicker');
|
||||
if (!select || !input || !list || !picker || input.dataset.listenerAdded) return;
|
||||
|
||||
input.addEventListener('focus', () => {
|
||||
input.select?.();
|
||||
this.openCasePicker('');
|
||||
});
|
||||
input.addEventListener('click', () => {
|
||||
input.select?.();
|
||||
this.openCasePicker('');
|
||||
});
|
||||
input.addEventListener('input', () => {
|
||||
this.openCasePicker(input.value);
|
||||
});
|
||||
input.addEventListener('keydown', event => {
|
||||
const options = this.filterCasePickerOptions(this.getCasePickerOptions(), this._casePickerFilter || input.value);
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
this._casePickerActiveIndex = Math.min((this._casePickerActiveIndex || 0) + 1, Math.max(0, options.length - 1));
|
||||
this._casePickerOpen ? this.renderCasePickerList() : this.openCasePicker(input.value);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
this._casePickerActiveIndex = Math.max((this._casePickerActiveIndex || 0) - 1, 0);
|
||||
this._casePickerOpen ? this.renderCasePickerList() : this.openCasePicker(input.value);
|
||||
} else if (event.key === 'Enter') {
|
||||
const option = options[this._casePickerActiveIndex || 0];
|
||||
if (option) {
|
||||
event.preventDefault();
|
||||
this.selectQuickStartCase(option.name);
|
||||
this.run?.();
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.updateCasePickerInput(select.value);
|
||||
this.closeCasePicker();
|
||||
} else if (event.key === 'Tab') {
|
||||
this.updateCasePickerInput(select.value);
|
||||
this.closeCasePicker();
|
||||
}
|
||||
});
|
||||
list.addEventListener('mousedown', event => event.preventDefault());
|
||||
list.addEventListener('click', event => {
|
||||
const option = event.target.closest?.('.case-combobox-option');
|
||||
if (option?.dataset?.case) {
|
||||
this.selectQuickStartCase(option.dataset.case);
|
||||
}
|
||||
});
|
||||
if (document.addEventListener && !this._casePickerDocumentListenerAdded) {
|
||||
document.addEventListener('pointerdown', event => {
|
||||
if (!picker.contains(event.target)) {
|
||||
this.updateCasePickerInput(select.value);
|
||||
this.closeCasePicker();
|
||||
}
|
||||
});
|
||||
this._casePickerDocumentListenerAdded = true;
|
||||
}
|
||||
input.dataset.listenerAdded = 'true';
|
||||
},
|
||||
|
||||
async loadQuickStartCases(selectCaseName = null, settingsPromise = null) {
|
||||
try {
|
||||
// Load settings to get lastUsedCase (reuse shared promise if provided)
|
||||
@@ -64,25 +261,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const select = document.getElementById('quickStartCase');
|
||||
|
||||
// Build options - existing cases first, then testcase as fallback if not present
|
||||
let options = '';
|
||||
const hasTestcase = cases.some(c => c.name === 'testcase');
|
||||
const isMobile = MobileDetection.getDeviceType() === 'mobile';
|
||||
const maxNameLength = isMobile ? 8 : 20; // Truncate to 8 chars on mobile
|
||||
|
||||
cases.forEach(c => {
|
||||
const displayName = c.name.length > maxNameLength
|
||||
? c.name.substring(0, maxNameLength) + '…'
|
||||
: c.name;
|
||||
options += `<option value="${escapeHtml(c.name)}">${escapeHtml(displayName)}</option>`;
|
||||
});
|
||||
|
||||
// Add testcase option if it doesn't exist (will be created on first run)
|
||||
if (!hasTestcase) {
|
||||
options = `<option value="testcase">testcase</option>` + options;
|
||||
}
|
||||
|
||||
select.innerHTML = options;
|
||||
const options = this.getCasePickerOptions();
|
||||
this.renderQuickStartCaseSelectOptions(select, options);
|
||||
console.log('[loadQuickStartCases] Set options:', select.innerHTML.substring(0, 200));
|
||||
|
||||
// If a specific case was requested, select it
|
||||
@@ -107,6 +287,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('dirDisplay').textContent = '~/codeman-cases/testcase';
|
||||
this.updateMobileCaseLabel('testcase');
|
||||
}
|
||||
this.updateCasePickerInput(select.value);
|
||||
this.renderCasePickerList();
|
||||
this.closeCasePicker();
|
||||
|
||||
// Only add event listener once (on first load)
|
||||
if (!select.dataset.listenerAdded) {
|
||||
@@ -114,9 +297,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.updateDirDisplayForCase(select.value);
|
||||
this.saveLastUsedCase(select.value);
|
||||
this.updateMobileCaseLabel(select.value);
|
||||
this.updateCasePickerInput(select.value);
|
||||
});
|
||||
select.dataset.listenerAdded = 'true';
|
||||
}
|
||||
this.setupQuickStartCasePicker();
|
||||
} catch (err) {
|
||||
console.error('Failed to load cases:', err);
|
||||
}
|
||||
@@ -328,6 +513,31 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const workingDir = caseData.path;
|
||||
if (!workingDir) throw new Error('Case path not found');
|
||||
|
||||
// Remote cases run over ssh — POST /api/sessions stat-validates workingDir on
|
||||
// the LOCAL fs (a remote user@host:/path never exists locally), so route them
|
||||
// through /api/quick-start, which resolves the remote case + launches via ssh.
|
||||
if (caseData.location === 'remote') {
|
||||
const remoteIds = [];
|
||||
for (let i = 0; i < tabCount; i++) {
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName, mode: 'claude' })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session');
|
||||
remoteIds.push(data.data.sessionId);
|
||||
}
|
||||
this.terminal.writeln(`\x1b[90m All ${tabCount} remote session(s) ready\x1b[0m`);
|
||||
if (remoteIds[0]) {
|
||||
await this.selectSession(remoteIds[0]);
|
||||
this.loadQuickStartCases();
|
||||
}
|
||||
this.terminal.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
let firstSessionId = null;
|
||||
|
||||
// Find the highest existing w-number for THIS case to avoid duplicates
|
||||
@@ -483,9 +693,32 @@ Object.assign(CodemanApp.prototype, {
|
||||
caseData = createCaseData.data.case;
|
||||
}
|
||||
|
||||
const selectedCase = (this.cases || []).find(c => c.name === caseName);
|
||||
const isRemoteCase = caseData.location === 'remote' || selectedCase?.location === 'remote';
|
||||
const workingDir = caseData.path;
|
||||
if (!workingDir) throw new Error('Case path not found');
|
||||
|
||||
// Remote cases run over ssh — route through /api/quick-start (see runClaude).
|
||||
if (caseData.location === 'remote') {
|
||||
const remoteIds = [];
|
||||
for (let i = 0; i < shellCount; i++) {
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName, mode: 'shell' })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start remote shell session');
|
||||
remoteIds.push(data.data.sessionId);
|
||||
}
|
||||
if (remoteIds[0]) {
|
||||
this.activeSessionId = remoteIds[0];
|
||||
await this.selectSession(remoteIds[0]);
|
||||
}
|
||||
this.terminal.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the highest existing s-number for THIS case to avoid duplicates
|
||||
let startNumber = 1;
|
||||
for (const [, session] of this.sessions) {
|
||||
@@ -509,7 +742,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir, mode: 'shell', name })
|
||||
body: JSON.stringify({ ...(isRemoteCase ? { caseName } : { workingDir }), mode: 'shell', name })
|
||||
}).then(r => r.json())
|
||||
);
|
||||
const createResults = await Promise.all(createPromises);
|
||||
@@ -551,6 +784,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
async runOpenCode() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote cases run the CLI on the REMOTE host — the local /api/opencode/status
|
||||
// probe and the local-only config/env below don't apply (quick-start rejects them).
|
||||
const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`);
|
||||
@@ -559,13 +795,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
// Check if OpenCode is available
|
||||
const statusRes = await fetch('/api/opencode/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m OpenCode CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://opencode.ai/install | bash\x1b[0m');
|
||||
return;
|
||||
// Check if OpenCode is available (local sessions only)
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/opencode/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m OpenCode CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://opencode.ai/install | bash\x1b[0m');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Quick-start with opencode mode (auto-allow tools by default).
|
||||
@@ -577,8 +815,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'opencode',
|
||||
openCodeConfig: { autoAllowTools: true },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(isRemote ? {} : {
|
||||
openCodeConfig: { autoAllowTools: true },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
}),
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -598,6 +838,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
async runCodex() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote cases run Codex on the REMOTE host — skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting Codex session in ${caseName}...\x1b[0m`);
|
||||
@@ -605,12 +848,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
const statusRes = await fetch('/api/codex/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m Codex CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: npm install -g @openai/codex\x1b[0m');
|
||||
return;
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/codex/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m Codex CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: npm install -g @openai/codex\x1b[0m');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const globalSettings = this.loadAppSettingsFromStorage();
|
||||
@@ -621,11 +866,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'codex',
|
||||
codexConfig: {
|
||||
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
|
||||
renderMode: 'hybrid',
|
||||
},
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(isRemote ? {} : {
|
||||
codexConfig: {
|
||||
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
|
||||
renderMode: 'hybrid',
|
||||
},
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
}),
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -645,6 +892,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
async runGemini() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote cases run Gemini on the REMOTE host — skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting Gemini session in ${caseName}...\x1b[0m`);
|
||||
@@ -652,12 +902,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
const statusRes = await fetch('/api/gemini/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m Gemini CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: npm install -g @google/gemini-cli\x1b[0m');
|
||||
return;
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/gemini/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m Gemini CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: npm install -g @google/gemini-cli\x1b[0m');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
@@ -667,8 +919,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'gemini',
|
||||
geminiConfig: { approvalMode: 'yolo' },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(isRemote ? {} : {
|
||||
geminiConfig: { approvalMode: 'yolo' },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
}),
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -1254,6 +1508,23 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('newCaseDescription').value = '';
|
||||
document.getElementById('linkCaseName').value = '';
|
||||
document.getElementById('linkCasePath').value = '';
|
||||
const remoteFields = [
|
||||
'remoteCaseName',
|
||||
'remoteCasePath',
|
||||
'remoteHostId',
|
||||
'remoteHostAddress',
|
||||
'remoteHostUsername',
|
||||
'remoteHostPort',
|
||||
'remoteHostCodexCommand',
|
||||
'remoteHostIdentityFile',
|
||||
'remoteHostSocksProxy',
|
||||
'remoteHostJumpHost',
|
||||
'remoteHostExtraSshOptions',
|
||||
];
|
||||
remoteFields.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = '';
|
||||
});
|
||||
// Reset to first tab
|
||||
this.caseModalTab = 'case-create';
|
||||
this.switchCaseModalTab('case-create');
|
||||
@@ -1295,13 +1566,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.renderCaseManageList();
|
||||
} else {
|
||||
submitBtn.style.display = '';
|
||||
submitBtn.textContent = tabName === 'case-create' ? 'Create' : 'Link';
|
||||
submitBtn.textContent =
|
||||
tabName === 'case-create' ? 'Create' : tabName === 'case-remote' ? 'Link Remote' : 'Link';
|
||||
}
|
||||
// Focus appropriate input
|
||||
if (tabName === 'case-create') {
|
||||
document.getElementById('newCaseName').focus();
|
||||
} else if (tabName === 'case-link') {
|
||||
document.getElementById('linkCaseName').focus();
|
||||
} else if (tabName === 'case-remote') {
|
||||
document.getElementById('remoteCaseName').focus();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1317,6 +1591,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
try {
|
||||
if (this.caseModalTab === 'case-create') {
|
||||
await this.createCase();
|
||||
} else if (this.caseModalTab === 'case-remote') {
|
||||
await this.linkRemoteCase();
|
||||
} else {
|
||||
await this.linkCase();
|
||||
}
|
||||
@@ -1407,6 +1683,86 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
async linkRemoteCase() {
|
||||
const name = document.getElementById('remoteCaseName').value.trim();
|
||||
const remotePath = document.getElementById('remoteCasePath').value.trim();
|
||||
const hostId = document.getElementById('remoteHostId').value.trim();
|
||||
const host = document.getElementById('remoteHostAddress').value.trim();
|
||||
const username = document.getElementById('remoteHostUsername').value.trim();
|
||||
const codexCommand = document.getElementById('remoteHostCodexCommand').value.trim();
|
||||
// COD-107 — port + advanced SSH connection options.
|
||||
const portRaw = document.getElementById('remoteHostPort').value.trim();
|
||||
const identityFile = document.getElementById('remoteHostIdentityFile').value.trim();
|
||||
const socksProxy = document.getElementById('remoteHostSocksProxy').value.trim();
|
||||
const jumpHost = document.getElementById('remoteHostJumpHost').value.trim();
|
||||
const extraSshOptions = document.getElementById('remoteHostExtraSshOptions').value
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
|
||||
if (!name || !remotePath || !hostId || !host || !username) {
|
||||
this.showToast('Please complete all required remote fields', 'error');
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name) || !/^[a-zA-Z0-9_-]+$/.test(hostId)) {
|
||||
this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
|
||||
return;
|
||||
}
|
||||
if (!remotePath.startsWith('/')) {
|
||||
this.showToast('Remote path must be absolute', 'error');
|
||||
return;
|
||||
}
|
||||
let port;
|
||||
if (portRaw) {
|
||||
port = Number(portRaw);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
this.showToast('SSH port must be a number between 1 and 65535', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const hostPayload = {
|
||||
id: hostId,
|
||||
label: hostId,
|
||||
host,
|
||||
username,
|
||||
...(port ? { port } : {}),
|
||||
...(identityFile ? { identityFile } : {}),
|
||||
...(socksProxy ? { socksProxy } : {}),
|
||||
...(jumpHost ? { jumpHost } : {}),
|
||||
...(extraSshOptions.length ? { extraSshOptions } : {}),
|
||||
...(codexCommand ? { commands: { codex: codexCommand } } : {}),
|
||||
};
|
||||
const hostRes = await fetch('/api/remote-hosts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(hostPayload)
|
||||
});
|
||||
const hostData = await hostRes.json();
|
||||
if (!hostData.success && hostData.errorCode !== 'ALREADY_EXISTS') {
|
||||
throw new Error(hostData.error || 'Failed to save remote host');
|
||||
}
|
||||
|
||||
const caseRes = await fetch('/api/cases/remote-link', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, hostId, remotePath })
|
||||
});
|
||||
const caseData = await caseRes.json();
|
||||
if (caseData.success) {
|
||||
this.closeCreateCaseModal();
|
||||
this.showToast(`Remote case "${name}" linked`, 'success');
|
||||
await this.loadQuickStartCases(name);
|
||||
await this.saveLastUsedCase(name);
|
||||
} else {
|
||||
this.showToast(caseData.error || 'Failed to link remote case', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to link remote case:', err);
|
||||
this.showToast('Failed to link remote case: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Case Management (reorder + delete)
|
||||
@@ -1484,7 +1840,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Refresh the dropdown
|
||||
const select = document.getElementById('quickStartCase');
|
||||
const currentCase = select.value;
|
||||
if (currentCase === name) {
|
||||
// Blur the native picker before reload so it doesn't show the stale value
|
||||
select.blur?.();
|
||||
}
|
||||
await this.loadQuickStartCases(currentCase === name ? null : currentCase);
|
||||
if (currentCase === name) {
|
||||
await this.saveLastUsedCase(document.getElementById('quickStartCase')?.value || 'testcase');
|
||||
}
|
||||
} else {
|
||||
this.showToast(data.error || 'Failed to delete case', 'error');
|
||||
}
|
||||
@@ -1521,11 +1884,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Build case list HTML
|
||||
let html = '';
|
||||
const cases = this.cases || [];
|
||||
|
||||
// Add testcase if not in list
|
||||
const hasTestcase = cases.some(c => c.name === 'testcase');
|
||||
const allCases = hasTestcase ? cases : [{ name: 'testcase' }, ...cases];
|
||||
const allCases = this.getCasePickerOptions();
|
||||
|
||||
for (const c of allCases) {
|
||||
const isSelected = c.name === currentCase;
|
||||
@@ -1537,7 +1896,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="mobile-case-item-name">${escapeHtml(c.name)}</span>
|
||||
<span class="mobile-case-item-name">${escapeHtml(c.label)}</span>
|
||||
<span class="mobile-case-item-delete" onclick="event.stopPropagation(); app.deleteCaseMobile(${escapeHtml(JSON.stringify(c.name))})" title="Delete">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
|
||||
@@ -471,6 +471,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() {
|
||||
@@ -1464,6 +1467,9 @@ 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);
|
||||
@@ -2387,6 +2393,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();
|
||||
|
||||
+406
-1
@@ -626,6 +626,15 @@ body {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.connection-dot.connected {
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.connection-dot.fallback {
|
||||
background: var(--yellow);
|
||||
box-shadow: 0 0 6px var(--yellow);
|
||||
}
|
||||
|
||||
.connection-dot.offline {
|
||||
background: var(--red);
|
||||
box-shadow: 0 0 6px var(--red);
|
||||
@@ -3060,7 +3069,8 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
/* backdrop-filter creates a stacking context, trapping the popover's
|
||||
z-index inside the toolbar. When the popover is open, raise the toolbar
|
||||
above the CJK input (z-index 52) so the popover is interactable. */
|
||||
.toolbar:has(.case-settings-popover:not(.hidden)) {
|
||||
.toolbar:has(.case-settings-popover:not(.hidden)),
|
||||
.toolbar:has(.case-combobox-list:not(.hidden)) {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@@ -3694,6 +3704,103 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.case-combobox {
|
||||
position: relative;
|
||||
width: clamp(170px, 18vw, 280px);
|
||||
}
|
||||
|
||||
.case-combobox-input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 28px;
|
||||
padding: 0.4rem 1.6rem 0.4rem 0.65rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: var(--btn-radius) 0 0 var(--btn-radius);
|
||||
color: var(--text-dim);
|
||||
font-size: 0.75rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b8b97' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.5rem center;
|
||||
transition: border-color var(--transition-smooth), background var(--transition-smooth), box-shadow var(--transition-smooth);
|
||||
}
|
||||
|
||||
.case-combobox-input:hover,
|
||||
.case-combobox-input:focus {
|
||||
background-color: rgba(255, 255, 255, 0.07);
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.case-combobox-list {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(100% + 8px);
|
||||
width: min(340px, calc(100vw - 24px));
|
||||
max-height: min(320px, 55vh);
|
||||
overflow-y: auto;
|
||||
padding: 0.35rem;
|
||||
background: rgba(22, 27, 35, 0.98);
|
||||
border: 1px solid rgba(120, 141, 170, 0.28);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 255, 255, 0.04) inset;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.case-combobox-list.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.case-combobox-option {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 0.35rem 0.45rem;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--text-dim);
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.case-combobox-option:hover,
|
||||
.case-combobox-option.active {
|
||||
background: rgba(59, 130, 246, 0.16);
|
||||
border-color: rgba(96, 165, 250, 0.28);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.case-combobox-option.selected {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.case-combobox-check {
|
||||
color: var(--green);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.case-combobox-option-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.case-combobox-empty {
|
||||
padding: 0.85rem 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toolbar-select {
|
||||
padding: 0.4rem 1.5rem 0.4rem 0.6rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
@@ -3712,6 +3819,16 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
transition: all var(--transition-smooth);
|
||||
}
|
||||
|
||||
.toolbar-select.case-native-select {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toolbar-select:hover {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
@@ -5443,6 +5560,157 @@ kbd {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Command palette (COD-153) */
|
||||
.command-palette-modal {
|
||||
align-items: flex-start;
|
||||
padding-top: min(16vh, 120px);
|
||||
}
|
||||
|
||||
.command-palette-shell {
|
||||
position: relative;
|
||||
width: min(92vw, 620px);
|
||||
max-height: min(70vh, 620px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: rgba(19, 19, 22, 0.97);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 20px 70px rgba(0, 0, 0, 0.55), 0 4px 18px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.command-palette-input-row {
|
||||
display: grid;
|
||||
grid-template-columns: 22px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.command-palette-search-icon {
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.command-palette-search {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.command-palette-search:focus-visible {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.command-palette-search::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.command-palette-input-row kbd {
|
||||
padding: 0.15rem 0.35rem;
|
||||
color: var(--text-muted);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.command-palette-label {
|
||||
padding: 0.55rem 0.8rem 0.25rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.command-palette-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem 0.45rem 0.55rem;
|
||||
}
|
||||
|
||||
.command-palette-item {
|
||||
display: grid;
|
||||
grid-template-columns: 26px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
padding: 0.45rem 0.55rem;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.command-palette-item:hover,
|
||||
.command-palette-item.active {
|
||||
background: rgba(59, 130, 246, 0.14);
|
||||
border-color: rgba(59, 130, 246, 0.28);
|
||||
}
|
||||
|
||||
.command-palette-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
color: var(--accent-hover);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.command-palette-text {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.command-palette-title,
|
||||
.command-palette-subtitle {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.command-palette-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.command-palette-subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
/* COD-192: "Browse all sessions…" escape hatch — visually secondary */
|
||||
.command-palette-item--browse {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.command-palette-item--browse .command-palette-icon {
|
||||
color: var(--text-muted);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.command-palette-item--browse .command-palette-title {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Session Manager modal (COD-121) */
|
||||
.session-manager-modal {
|
||||
max-width: 720px;
|
||||
@@ -5516,6 +5784,121 @@ kbd {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
/* Shortcut settings rows (App Settings → Shortcuts, COD-157) */
|
||||
.shortcut-setting-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 190px) auto auto auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.shortcut-setting-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.shortcut-setting-label {
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shortcut-binding-input {
|
||||
min-width: 0;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--btn-radius);
|
||||
color: var(--text-dim);
|
||||
font-family: monospace;
|
||||
font-size: 0.72rem;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.shortcut-binding-input:focus {
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.shortcut-capture-btn,
|
||||
.shortcut-reset-btn {
|
||||
padding: 0.3rem 0.6rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--btn-radius);
|
||||
color: var(--text-dim);
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-smooth);
|
||||
}
|
||||
|
||||
.shortcut-capture-btn:hover,
|
||||
.shortcut-reset-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.shortcut-reset-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.shortcut-enabled-checkbox {
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Shortcut overlay modal (registry-driven; opened with Ctrl+? / Alt+?) */
|
||||
.shortcut-overlay-modal .modal-content {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.shortcut-overlay-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.shortcut-overlay-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.shortcut-overlay-group-label {
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.shortcut-overlay-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
|
||||
.shortcut-overlay-label {
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.shortcut-overlay-keys {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.shortcut-overlay-footer {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.away-digest-ranges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -11170,3 +11553,25 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
background: linear-gradient(135deg, #7c3aed, #8b5cf6);
|
||||
box-shadow: 0 0 28px -4px rgba(124, 58, 237, 0.5);
|
||||
}
|
||||
|
||||
/* ── Cron Jobs ───────────────────────────────── */
|
||||
.cron-job-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.cron-job-row {
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 12px;
|
||||
padding: 10px 12px; border: 1px solid var(--border, #333); border-radius: 8px;
|
||||
background: var(--panel-bg, rgba(255,255,255,0.02));
|
||||
}
|
||||
.cron-job-main { min-width: 0; flex: 1; }
|
||||
.cron-job-name { font-weight: 600; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.cron-job-meta { font-size: 12px; opacity: 0.7; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.cron-job-actions { display: flex; gap: 6px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.cron-badge {
|
||||
font-size: 11px; padding: 1px 6px; border-radius: 10px;
|
||||
background: var(--accent-bg, rgba(120,160,255,0.15)); opacity: 0.9;
|
||||
}
|
||||
.cron-badge-off { background: rgba(200,80,80,0.18); }
|
||||
.cron-weekdays { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.cron-weekdays label { display: inline-flex; align-items: center; gap: 3px; font-weight: 400; }
|
||||
.cron-job-form {
|
||||
margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border, #333);
|
||||
}
|
||||
|
||||
@@ -120,12 +120,28 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.attachCustomKeyEventHandler((ev) => {
|
||||
if (ev.isComposing || ev.keyCode === 229) return false;
|
||||
|
||||
// Let the app's Alt/Option session-nav shortcuts reach the document keydown handler
|
||||
// Let the app's Alt/Option session-nav and Command Palette shortcuts reach the document keydown handler
|
||||
// (app.js switches tabs by PHYSICAL e.code) instead of xterm injecting ESC<char> into
|
||||
// the PTY. Mirror app.js's gate exactly — same physical codes + modifier guard — so
|
||||
// macOS Option layouts (Option+1 -> "¡", Option+[ -> "“") are suppressed here too and
|
||||
// macOS Option layouts (Option+1 -> "¡", Option+[ -> "“", Option+K -> "˚") are suppressed here too and
|
||||
// don't leak an escape sequence into the focused terminal on every tab switch.
|
||||
if (ev.altKey && !ev.ctrlKey && !ev.shiftKey && /^(Digit[1-9]|BracketLeft|BracketRight)$/.test(ev.code || '')) {
|
||||
if (
|
||||
ev.altKey &&
|
||||
!ev.ctrlKey &&
|
||||
!ev.shiftKey &&
|
||||
/^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Command palette chord (COD-153): keep it out of the PTY. The document
|
||||
// CAPTURE handler has already opened the palette by the time xterm sees
|
||||
// this keydown, but its preventDefault() does NOT stop xterm — without
|
||||
// this gate Ctrl+K would ALSO write 0x0b (readline kill-line) into the
|
||||
// live session behind the palette, truncating whatever the user had
|
||||
// typed. Route through the registry-aware checker so a rebound or
|
||||
// disabled palette shortcut restores normal terminal Ctrl+K.
|
||||
if (ev.type === 'keydown' && this.shouldOpenCommandPaletteFromShortcut?.(ev)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1178,6 +1194,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
* @param {Array} cases linked cases (for #caseName label)
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.showViewAll=true] show "View all in folder" button in detail panel
|
||||
* @param {Function} [options.onActivate] main-row click handler override (default: resume the conversation)
|
||||
*/
|
||||
_buildHistoryItem(s, cases, options) {
|
||||
const showViewAll = options?.showViewAll !== false;
|
||||
@@ -1220,16 +1237,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
item.className = 'history-item';
|
||||
item.title = s.workingDir || '';
|
||||
|
||||
// Main row: clickable surface that triggers resume (or focuses the live tab).
|
||||
// Main row: clickable surface. A caller-supplied onActivate wins (the
|
||||
// Session Manager routes live rows to selectSession and history rows to
|
||||
// resume). Otherwise the default focuses the live tab when the row is a
|
||||
// still-running session, else resumes the conversation — keyed by the Claude
|
||||
// conversation UUID (claudeSessionId) when present, since resumed sessions
|
||||
// carry theirs separately from their Codeman id.
|
||||
const mainRow = document.createElement('div');
|
||||
mainRow.className = 'history-item-main';
|
||||
mainRow.addEventListener('click', () => {
|
||||
if (isLive && this.sessions.has(s.sessionId)) {
|
||||
this.selectSession(s.sessionId);
|
||||
} else {
|
||||
this.resumeHistorySession(s.sessionId, s.workingDir || '');
|
||||
}
|
||||
});
|
||||
mainRow.addEventListener(
|
||||
'click',
|
||||
options?.onActivate ||
|
||||
(() => {
|
||||
if (isLive && this.sessions.has(s.sessionId)) {
|
||||
this.selectSession(s.sessionId);
|
||||
} else {
|
||||
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '');
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const textCol = document.createElement('div');
|
||||
textCol.className = 'history-item-text';
|
||||
@@ -2247,11 +2273,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Complete a buffer load: unblock live SSE writes.
|
||||
* Called when chunkedTerminalWrite finishes (or is skipped for empty buffers).
|
||||
*
|
||||
* Queued SSE events are DISCARDED, not flushed. The loaded buffer from the API
|
||||
* is the source of truth up to the response timestamp. SSE events queued during
|
||||
* the fetch+write overlap with the buffer — flushing them writes duplicate data
|
||||
* (especially Ink cursor-up redraws), corrupting the terminal display.
|
||||
* By default queued SSE events are DISCARDED, not flushed. For an established
|
||||
* session the loaded buffer from the API is the source of truth up to the
|
||||
* response timestamp; SSE events queued during the fetch+write overlap already
|
||||
* appear in that buffer, so flushing them writes duplicate data (especially Ink
|
||||
* cursor-up redraws), corrupting the terminal display.
|
||||
*
|
||||
* COD-144: a brand-new session is the exception. Its terminal fetch can resolve
|
||||
* BEFORE the PTY emits its first prompt, so the fetched buffer is empty and the
|
||||
* prompt arrives only as a queued SSE event. Discarding it leaves the terminal
|
||||
* blank until a tab-switch re-fetches a now-populated buffer. When the caller
|
||||
* knows the load painted nothing (empty fetch + no cache), it passes
|
||||
* `{ flushQueued: true }` so the queued events are REPLAYED through
|
||||
* `batchTerminalWrite()` instead of dropped. Replay runs after `_isLoadingBuffer`
|
||||
* is cleared, so the events write through normally and are not re-queued.
|
||||
*
|
||||
* After unblocking, new SSE/WS events deliver subsequent output normally.
|
||||
*
|
||||
* @param {string} [owner] Load token from `_beginBufferLoad`; a stale owner is a no-op.
|
||||
* @param {{ flushQueued?: boolean }} [opts] When `flushQueued` is true, replay any queued events.
|
||||
*/
|
||||
_beginBufferLoad(owner) {
|
||||
if (this._bufferLoadSeq === undefined) this._bufferLoadSeq = 0;
|
||||
@@ -2262,13 +2302,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
return loadOwner;
|
||||
},
|
||||
|
||||
_finishBufferLoad(owner) {
|
||||
_finishBufferLoad(owner, opts) {
|
||||
if (owner !== undefined && this._bufferLoadOwner !== owner) {
|
||||
return false;
|
||||
}
|
||||
const queued = this._loadBufferQueue;
|
||||
this._isLoadingBuffer = false;
|
||||
this._loadBufferQueue = null;
|
||||
this._bufferLoadOwner = null;
|
||||
// COD-144: replay (rather than discard) queued live events when the load
|
||||
// painted nothing — the queued prompt is the only content a new session has.
|
||||
if (opts?.flushQueued && queued && queued.length) {
|
||||
for (const data of queued) {
|
||||
this.batchTerminalWrite(data);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
|
||||
@@ -11,15 +11,30 @@ import { join, resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import type { ApiResponse, CaseInfo } from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { CreateCaseSchema, LinkCaseSchema, CaseOrderSchema } from '../schemas.js';
|
||||
import {
|
||||
CreateCaseSchema,
|
||||
LinkCaseSchema,
|
||||
CaseOrderSchema,
|
||||
RemoteCaseLinkSchema,
|
||||
RemoteHostSchema,
|
||||
} from '../schemas.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 {
|
||||
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_-]+$/;
|
||||
|
||||
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
|
||||
@@ -53,6 +68,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 +85,39 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +165,73 @@ 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 } };
|
||||
});
|
||||
|
||||
// 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 +285,16 @@ 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 } };
|
||||
}
|
||||
|
||||
// Check linked cases first — unlink only, don't delete the actual directory
|
||||
const linkedCases = await readLinkedCases();
|
||||
if (linkedCases[name]) {
|
||||
@@ -233,6 +355,25 @@ 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 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';
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { join, dirname, extname } from 'node:path';
|
||||
import { join, dirname, extname, basename } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { execFile } from 'node:child_process';
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
FlickerFilterSchema,
|
||||
QuickRunSchema,
|
||||
QuickStartSchema,
|
||||
InteractiveStartSchema,
|
||||
} from '../schemas.js';
|
||||
import {
|
||||
autoConfigureRalph,
|
||||
@@ -54,6 +55,7 @@ import {
|
||||
} from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { imageWatcher } from '../../image-watcher.js';
|
||||
import { convertHeicToJpeg } from '../heic-jpeg-converter.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import {
|
||||
mergeUnifiedSessions,
|
||||
@@ -70,10 +72,13 @@ import { RunSummaryTracker } from '../../run-summary.js';
|
||||
|
||||
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
||||
import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
|
||||
import { dataPath } from '../../config/instance.js';
|
||||
import { dataPath, getDataDir } from '../../config/instance.js';
|
||||
import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toSessionRemote } from '../../remote-hosts.js';
|
||||
import { LRUMap } from '../../utils/lru-map.js';
|
||||
|
||||
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
|
||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||
const CODEMAN_CONFIG_DIR = getDataDir();
|
||||
|
||||
// Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
@@ -198,6 +203,15 @@ export function imageMagicMatchesExt(data: Buffer, ext: string): boolean {
|
||||
return u32be(0) === 0x52494646 && u32be(8) === 0x57454250;
|
||||
case '.bmp':
|
||||
return data[0] === 0x42 && data[1] === 0x4d;
|
||||
case '.heic':
|
||||
case '.heif': {
|
||||
// ISO Base Media File Format: size + "ftyp" + major brand. The brand
|
||||
// list matches heic-decode's own isHeic() — accepting more brands here
|
||||
// would only route bytes into a conversion that always throws.
|
||||
if (u32be(4) !== 0x66747970) return false;
|
||||
const brand = data.subarray(8, 12).toString('ascii');
|
||||
return ['heic', 'heix', 'hevc', 'hevx', 'mif1', 'msf1'].includes(brand);
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -620,6 +634,14 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/interactive', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
// Body is optional (auto-reattach callers send none) — same idiom as /interactive-respawn.
|
||||
const bodyResult = req.body
|
||||
? InteractiveStartSchema.safeParse(req.body)
|
||||
: { success: true as const, data: {} as { clearBreaker?: boolean } };
|
||||
if (!bodyResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const { clearBreaker } = bodyResult.data;
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
if (session.isBusy()) {
|
||||
@@ -642,6 +664,20 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// COD-118: ONLY an explicit user-initiated restart (body {clearBreaker:true})
|
||||
// clears a tripped PTY-exit circuit breaker. This endpoint is ALSO the frontend's
|
||||
// automatic re-attach path (selectSession auto-POSTs it for any pid===null
|
||||
// session), so an unconditional reset here would re-arm the exact crash loop
|
||||
// the breaker exists to stop — auto-reattach sends no body and must not clear.
|
||||
if (clearBreaker) {
|
||||
session.resetRespawnBreaker();
|
||||
}
|
||||
// Re-attach listener wiring if a prior PTY exit detached it: the wiring exit
|
||||
// handler removes ALL session listeners (incl. respawnBreakerTripped), and only
|
||||
// session-create/boot-recovery paths ran setupSessionListeners before this fix —
|
||||
// without this, a re-attached session's SSE/terminal/trip events go unobserved.
|
||||
// setupSessionListeners is idempotent (no-op while refs are still attached).
|
||||
await ctx.setupSessionListeners(session);
|
||||
await session.startInteractive();
|
||||
getLifecycleLog().log({
|
||||
event: 'started',
|
||||
@@ -669,6 +705,8 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
// Re-attach listener wiring if a prior PTY exit detached it (see /interactive).
|
||||
await ctx.setupSessionListeners(session);
|
||||
await session.startShell();
|
||||
getLifecycleLog().log({
|
||||
event: 'started',
|
||||
@@ -872,6 +910,14 @@ export function registerSessionRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
// Codex sessions don't write to ~/.claude/projects — their transcripts
|
||||
// live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the
|
||||
// response-viewer works for Codex panes too.
|
||||
if (session.mode === 'codex') {
|
||||
const codexQuery = req.query as { context?: string };
|
||||
return await readCodexLastResponse(session, codexQuery.context === 'full');
|
||||
}
|
||||
|
||||
// Scan ~/.claude/projects/*/ for the transcript file
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
|
||||
@@ -985,15 +1031,346 @@ export function registerSessionRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
function isCodexInjectedContext(text: string): boolean {
|
||||
return (
|
||||
/^# AGENTS\.md instructions\b/i.test(text) ||
|
||||
/^<environment_context\b/i.test(text) ||
|
||||
/^<turn_aborted\b/i.test(text) ||
|
||||
/^<codex_internal_context\b/i.test(text) ||
|
||||
/^<recommended_plugins\b/i.test(text) ||
|
||||
/^<user_instructions\b/i.test(text) ||
|
||||
/^# Options\b/i.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Codex response-viewer support ───────────────────────────────────────────────────────
|
||||
// Read the rollout's session_meta identity fields (plus turn_context cwd as
|
||||
// a fallback when the huge session_meta line got truncated by the head read).
|
||||
function readCodexRolloutMeta(head: string): { cwd?: string; originator?: string } {
|
||||
let cwd: string | undefined;
|
||||
let originator: string | undefined;
|
||||
for (const line of head.split('\n')) {
|
||||
if (!line) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line) as {
|
||||
type?: string;
|
||||
payload?: { cwd?: string; originator?: string };
|
||||
};
|
||||
if (entry.type === 'session_meta') {
|
||||
cwd ??= entry.payload?.cwd;
|
||||
originator ??= entry.payload?.originator;
|
||||
} else if (entry.type === 'turn_context') {
|
||||
cwd ??= entry.payload?.cwd;
|
||||
}
|
||||
} catch {
|
||||
// Malformed or truncated head line — keep scanning.
|
||||
}
|
||||
if (cwd && originator) break;
|
||||
}
|
||||
return { cwd, originator };
|
||||
}
|
||||
|
||||
// The pane's last Enter (Session.codexLastSubmitAt) correlated against
|
||||
// ~/.codex/history.jsonl, which logs every submitted user message as
|
||||
// {session_id, ts}. This identifies the thread the pane is ACTUALLY on and
|
||||
// is the only signal that survives /resume, /new and /fork typed inside the
|
||||
// codex TUI itself. An entry is credited to this pane only when its Enter is
|
||||
// the closest among all codex panes, so a menu keystroke in another pane
|
||||
// can't steal the attribution.
|
||||
const codexHistoryPinCache = new LRUMap<string, { submitAt: number; threadId: string }>({ maxSize: 1024 });
|
||||
async function resolveCodexThreadFromHistory(
|
||||
session: { id: string; codexLastSubmitAt?: number },
|
||||
codexHome: string
|
||||
): Promise<string | null> {
|
||||
const submitAt = session.codexLastSubmitAt || 0;
|
||||
if (!submitAt) return null;
|
||||
const cached = codexHistoryPinCache.get(session.id);
|
||||
if (cached && cached.submitAt === submitAt) return cached.threadId;
|
||||
|
||||
const histPath = join(codexHome, 'history.jsonl');
|
||||
const st = await fs.stat(histPath).catch(() => null);
|
||||
if (!st || st.size === 0) return null;
|
||||
const tail = await readFileTail(histPath, Buffer.alloc(65536), st.size);
|
||||
if (!tail) return null;
|
||||
|
||||
const WINDOW_MS = 15_000;
|
||||
const otherSubmits: number[] = [];
|
||||
for (const s of ctx.sessions.values()) {
|
||||
if (s.id !== session.id && s.mode === 'codex' && s.codexLastSubmitAt) {
|
||||
otherSubmits.push(s.codexLastSubmitAt);
|
||||
}
|
||||
}
|
||||
|
||||
let best: { threadId: string; dist: number } | undefined;
|
||||
for (const line of tail.split('\n')) {
|
||||
if (!line) continue;
|
||||
let e: { session_id?: string; ts?: number };
|
||||
try {
|
||||
e = JSON.parse(line);
|
||||
} catch {
|
||||
continue; // first tail line may be cut mid-JSON
|
||||
}
|
||||
if (!e.session_id || typeof e.ts !== 'number') continue;
|
||||
const tsMs = e.ts * 1000; // history timestamps are unix seconds
|
||||
const dist = Math.abs(tsMs - submitAt);
|
||||
if (dist > WINDOW_MS) continue;
|
||||
if (otherSubmits.some((o) => Math.abs(tsMs - o) < dist)) continue; // another pane is closer
|
||||
if (!best || dist < best.dist) best = { threadId: e.session_id, dist };
|
||||
}
|
||||
if (!best) return null;
|
||||
codexHistoryPinCache.set(session.id, { submitAt, threadId: best.threadId });
|
||||
return best.threadId;
|
||||
}
|
||||
|
||||
// Locate THIS pane's rollout, in order of confidence:
|
||||
// 0. history match — the thread the pane last submitted a message to
|
||||
// (see resolveCodexThreadFromHistory); tracks the pane through
|
||||
// /resume //new //fork typed inside the TUI.
|
||||
// 1. originator match — Codeman spawns codex panes with
|
||||
// CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codeman_<sessionId>, which codex
|
||||
// writes into session_meta.originator of every rollout it creates
|
||||
// (including new files after /new in the same pane; newest match wins).
|
||||
// 2. resume-id match — resumed rollouts keep their ORIGINAL session_meta
|
||||
// (codex appends without rewriting it), so originator matching can't
|
||||
// see them; but the rollout uuid is in the filename and we know the id.
|
||||
// 3. legacy cwd+mtime heuristic — panes started before this feature, or
|
||||
// TUI-resumed threads before their first tracked submit. Case-blind
|
||||
// cwd compare (codex records the launch-time case, /mnt paths vary)
|
||||
// and rollouts claimed by OTHER codeman panes are excluded.
|
||||
async function findActiveCodexFile(session: {
|
||||
id: string;
|
||||
workingDir: string;
|
||||
codexLastSubmitAt?: number;
|
||||
codexConfig?: { resumeSessionId?: string };
|
||||
}): Promise<string | null> {
|
||||
const codexHome = process.env.CODEX_HOME || join(process.env.HOME || '/tmp', '.codex');
|
||||
const sessionsDir = join(codexHome, 'sessions');
|
||||
|
||||
const files: Array<{ path: string; mtimeMs: number }> = [];
|
||||
const walk = async (dir: string): Promise<void> => {
|
||||
let entries: import('node:fs').Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(fullPath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
|
||||
const st = await fs.stat(fullPath).catch(() => null);
|
||||
if (!st || st.size < 100) continue;
|
||||
files.push({ path: fullPath, mtimeMs: st.mtimeMs });
|
||||
}
|
||||
};
|
||||
await walk(sessionsDir);
|
||||
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
|
||||
const historyThreadId = await resolveCodexThreadFromHistory(session, codexHome);
|
||||
if (historyThreadId) {
|
||||
const hit = files.find((f) => basename(f.path).endsWith(`-${historyThreadId}.jsonl`));
|
||||
if (hit) return hit.path;
|
||||
}
|
||||
|
||||
const rawResumeId = session.codexConfig?.resumeSessionId;
|
||||
const resumeId =
|
||||
rawResumeId && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(rawResumeId)
|
||||
? rawResumeId
|
||||
: undefined;
|
||||
const idMatch = resumeId ? files.find((f) => basename(f.path).endsWith(`-${resumeId}.jsonl`)) : undefined;
|
||||
|
||||
// Scan newest-first for our originator; anything strictly older than the
|
||||
// id match can never beat it, so the head reads stop there (mtime ties are
|
||||
// still scanned — a /new rollout may land in the same clock tick). The
|
||||
// 128 KiB head budget covers the session_meta line, which embeds full
|
||||
// base_instructions (observed max ~22 KiB on codex 0.144).
|
||||
const originator = `codeman_${session.id}`;
|
||||
const wantCwd = session.workingDir.toLowerCase();
|
||||
const headBuf = Buffer.alloc(131072);
|
||||
let cwdFallback: { path: string; mtimeMs: number } | undefined;
|
||||
for (const f of files) {
|
||||
if (idMatch && f.mtimeMs < idMatch.mtimeMs) break;
|
||||
const meta = await readCodexRolloutMetaCached(f.path, headBuf);
|
||||
if (!meta) continue;
|
||||
if (meta.originator === originator) return f.path; // newest-first → first hit wins
|
||||
if (
|
||||
!cwdFallback &&
|
||||
!idMatch &&
|
||||
meta.cwd?.toLowerCase() === wantCwd &&
|
||||
// A rollout stamped by another codeman pane belongs to that pane.
|
||||
!(meta.originator?.startsWith('codeman_') && meta.originator !== originator)
|
||||
) {
|
||||
cwdFallback = f;
|
||||
}
|
||||
}
|
||||
|
||||
return idMatch?.path ?? cwdFallback?.path ?? null;
|
||||
}
|
||||
|
||||
// session_meta is written once when codex creates the rollout and never
|
||||
// rewritten (verified: resume appends without touching it), so the parsed
|
||||
// identity of a given path can be cached forever. This turns the per-request
|
||||
// scan into stat calls plus head reads for new files only.
|
||||
const codexRolloutMetaCache = new LRUMap<string, { cwd?: string; originator?: string }>({ maxSize: 4096 });
|
||||
async function readCodexRolloutMetaCached(
|
||||
filePath: string,
|
||||
headBuf: Buffer
|
||||
): Promise<{ cwd?: string; originator?: string } | null> {
|
||||
const cached = codexRolloutMetaCache.get(filePath);
|
||||
if (cached) return cached;
|
||||
const head = await readFileHead(filePath, headBuf);
|
||||
if (!head) return null;
|
||||
const meta = readCodexRolloutMeta(head);
|
||||
// Don't cache a still-incomplete head: a rollout being created may not
|
||||
// have flushed session_meta/turn_context yet.
|
||||
if (!meta.cwd && !meta.originator) return meta;
|
||||
codexRolloutMetaCache.set(filePath, meta);
|
||||
return meta;
|
||||
}
|
||||
|
||||
function extractCodexBlockText(content: unknown, kinds: string[]): string {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.filter(
|
||||
(b): b is { type: string; text: string } =>
|
||||
!!b &&
|
||||
typeof b === 'object' &&
|
||||
kinds.includes((b as { type?: string }).type || '') &&
|
||||
typeof (b as { text?: string }).text === 'string'
|
||||
)
|
||||
.map((b) => b.text)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
// Single pass over a Codex rollout: track the last assistant message (for the
|
||||
// default eye view) and, when `full`, the whole user/assistant thread.
|
||||
//
|
||||
// User turns come from event_msg/user_message when available: codex emits one
|
||||
// per REAL user input, and injected context (AGENTS.md, environment_context,
|
||||
// compaction summaries, …) never appears there — so no filtering heuristics.
|
||||
// response_item user rows duplicate those inputs mixed with the injections;
|
||||
// they are kept only as a fallback for old rollouts without event_msg rows.
|
||||
async function readCodexLastResponse(
|
||||
session: { id: string; workingDir: string; codexConfig?: { resumeSessionId?: string } },
|
||||
full: boolean
|
||||
): Promise<{
|
||||
text: string;
|
||||
timestamp: string;
|
||||
messages?: Array<{ role: string; text: string; timestamp?: string }>;
|
||||
}> {
|
||||
const empty = full ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
|
||||
const filePath = await findActiveCodexFile(session);
|
||||
if (!filePath) return empty;
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(filePath, 'utf8');
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
|
||||
let lastText = '';
|
||||
let lastTimestamp = '';
|
||||
const messages: Array<{ role: string; text: string; timestamp?: string; legacyUser?: boolean }> = [];
|
||||
// Multiset of event-sourced user texts: a real input appears BOTH as an
|
||||
// event_msg and as a response_item row, so each event text cancels exactly
|
||||
// one legacy twin. Legacy rows without an event twin (turns written by an
|
||||
// older codex appending to the same rollout) survive — a file-wide boolean
|
||||
// would wrongly drop them.
|
||||
const eventUserTexts = new Map<string, number>();
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
if (!line) continue;
|
||||
let entry: {
|
||||
timestamp?: string;
|
||||
type?: string;
|
||||
payload?: {
|
||||
type?: string;
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
message?: unknown;
|
||||
images?: unknown;
|
||||
local_images?: unknown;
|
||||
};
|
||||
};
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (full && entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
|
||||
let text = typeof entry.payload.message === 'string' ? entry.payload.message.trim() : '';
|
||||
if (text && isCodexInjectedContext(text)) continue;
|
||||
if (text) eventUserTexts.set(text, (eventUserTexts.get(text) || 0) + 1);
|
||||
// Image-only (or image+text) inputs: the text field alone would make
|
||||
// the turn vanish, so surface a placeholder.
|
||||
const imageCount =
|
||||
(Array.isArray(entry.payload.images) ? entry.payload.images.length : 0) +
|
||||
(Array.isArray(entry.payload.local_images) ? entry.payload.local_images.length : 0);
|
||||
if (imageCount > 0) text = text ? `${text}\n\n*[image ×${imageCount}]*` : `*[image ×${imageCount}]*`;
|
||||
if (text) messages.push({ role: 'user', text, timestamp: entry.timestamp });
|
||||
continue;
|
||||
}
|
||||
if (entry.type !== 'response_item' || entry.payload?.type !== 'message') continue;
|
||||
const role = entry.payload?.role;
|
||||
if (role === 'assistant') {
|
||||
const text = extractCodexBlockText(entry.payload?.content, ['output_text', 'text']);
|
||||
if (text) {
|
||||
lastText = text;
|
||||
lastTimestamp = entry.timestamp || '';
|
||||
if (full) messages.push({ role: 'assistant', text, timestamp: entry.timestamp });
|
||||
}
|
||||
} else if (role === 'user' && full) {
|
||||
const text = extractCodexBlockText(entry.payload?.content, ['input_text', 'text']).trim();
|
||||
// Drop Codex's injected context turns (AGENTS.md, environment_context, …)
|
||||
// so the thread shows real user prompts only.
|
||||
if (text && !isCodexInjectedContext(text)) {
|
||||
messages.push({ role: 'user', text, timestamp: entry.timestamp, legacyUser: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const thread = messages
|
||||
.filter((m) => {
|
||||
if (!m.legacyUser) return true;
|
||||
const n = eventUserTexts.get(m.text) || 0;
|
||||
if (n > 0) {
|
||||
eventUserTexts.set(m.text, n - 1);
|
||||
return false; // duplicate of an event_msg row already in the thread
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map(({ role, text, timestamp }) => ({ role, text, timestamp }));
|
||||
|
||||
return full
|
||||
? { text: lastText, timestamp: lastTimestamp, messages: thread }
|
||||
: { text: lastText, timestamp: lastTimestamp };
|
||||
}
|
||||
|
||||
// ========== Get Terminal Buffer ==========
|
||||
|
||||
// Query params:
|
||||
// tail=<bytes> - Only return last N bytes (faster initial load)
|
||||
// full=1 - Full page reload: replay the entire tmux scrollback (COD-47)
|
||||
app.get('/api/sessions/:id/terminal', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const query = req.query as { tail?: string };
|
||||
const query = req.query as { tail?: string; full?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
// `full=1` is the EXPLICIT full-reload signal (COD-47): the browser reloaded
|
||||
// the page and wants the whole scroll history back, so we capture the ENTIRE
|
||||
// tmux scrollback and the user gets back history that scrolled off Codeman's
|
||||
// byte buffer. Requests WITHOUT it — tab switches (`tail=`) and the legacy
|
||||
// no-param callers (response-viewer fallback, clearTerminal refresh) — keep
|
||||
// the fast visible-frame capture.
|
||||
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
||||
const isFullReload = query.full === '1' || query.full === 'true';
|
||||
const { tmuxHistoryLimit, terminalBufferMaxBytes } = await ctx.getTerminalHistoryConfig();
|
||||
|
||||
// Prepend the live tmux pane buffer so tab-switch replay shows the current
|
||||
// on-screen frame, not just the accumulated byte history. This matters for
|
||||
// TUI modes (codex/opencode) that repaint only their latest frame: the
|
||||
@@ -1004,24 +1381,57 @@ export function registerSessionRoutes(
|
||||
const muxName = session.muxName;
|
||||
const liveMuxBuffer =
|
||||
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
|
||||
? ctx.mux.captureActivePaneBuffer(muxName)
|
||||
? ctx.mux.captureActivePaneBuffer(
|
||||
muxName,
|
||||
isFullReload
|
||||
? { fullHistory: true, historyLimitLines: tmuxHistoryLimit, maxCaptureBytes: terminalBufferMaxBytes }
|
||||
: undefined
|
||||
)
|
||||
: null;
|
||||
const rawBuffer =
|
||||
liveMuxBuffer !== null && liveMuxBuffer.length > 0
|
||||
? session.terminalBufferLength > 0
|
||||
const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0;
|
||||
const source: 'history' | 'mux-visible' | 'mux-full-history' = hasLiveMuxBuffer
|
||||
? isFullReload
|
||||
? 'mux-full-history'
|
||||
: 'mux-visible'
|
||||
: 'history';
|
||||
let rawBuffer: string;
|
||||
if (liveMuxBuffer !== null && liveMuxBuffer.length > 0) {
|
||||
// Full-history capture is the RENDERED form of everything already in the
|
||||
// byte buffer (up to tmux eviction) — return it alone. Prepending the byte
|
||||
// history would replay the whole conversation twice: `\x1b[2J` clears only
|
||||
// the viewport, not xterm scrollback. The history+clear+frame concat stays
|
||||
// for the visible-frame path, where the single pane frame lacks history.
|
||||
rawBuffer = isFullReload
|
||||
? liveMuxBuffer
|
||||
: session.terminalBufferLength > 0
|
||||
? `${session.terminalBuffer}\x1b[H\x1b[2J${liveMuxBuffer}`
|
||||
: liveMuxBuffer
|
||||
: session.terminalBuffer;
|
||||
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
||||
: liveMuxBuffer;
|
||||
} else {
|
||||
rawBuffer = session.terminalBuffer;
|
||||
}
|
||||
const fullSize = rawBuffer.length;
|
||||
let truncated = false;
|
||||
let cleanBuffer: string;
|
||||
|
||||
// Cap the payload EARLY — before the regex normalization passes below run
|
||||
// over it. A full-history tmux capture can be tens of MB of scrollback;
|
||||
// normalizing all of it would stall the event loop only to discard most
|
||||
// bytes anyway. Keep the most RECENT bytes (slice from the end) and align
|
||||
// to a line boundary so we never start mid-ANSI-escape.
|
||||
if (terminalBufferMaxBytes > 0 && rawBuffer.length > terminalBufferMaxBytes) {
|
||||
rawBuffer = rawBuffer.slice(-terminalBufferMaxBytes);
|
||||
truncated = true;
|
||||
const capNewline = rawBuffer.indexOf('\n');
|
||||
if (capNewline > 0 && capNewline < 4096) {
|
||||
rawBuffer = rawBuffer.slice(capNewline + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Strip redundant Ink spinner/status redraws BEFORE tailing.
|
||||
// During long thinking phases, Ink rewrites the same rows thousands of times
|
||||
// (500KB+). Without stripping, tail mode returns only spinner frames and
|
||||
// the terminal appears empty when switching tabs.
|
||||
let strippedBuffer = stripInkRedrawBloat(rawBuffer);
|
||||
let strippedBuffer = session.mode === 'shell' ? rawBuffer : stripInkRedrawBloat(rawBuffer);
|
||||
|
||||
// Strip alt-screen toggles and scrollback-erase from Codex/Claude byte
|
||||
// streams. xterm.js obeys them by switching to its scrollback-less alt
|
||||
@@ -1069,6 +1479,7 @@ export function registerSessionRoutes(
|
||||
status: session.status,
|
||||
fullSize,
|
||||
truncated,
|
||||
source,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1269,81 +1680,125 @@ export function registerSessionRoutes(
|
||||
effort,
|
||||
} = parseBody(QuickStartSchema, req.body);
|
||||
|
||||
// Check OpenCode availability if requested
|
||||
if (mode === 'opencode') {
|
||||
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
||||
if (!isOpenCodeAvailable()) {
|
||||
// Resolve the remote case FIRST — the CLI executes on the REMOTE host over ssh,
|
||||
// so the LOCAL availability gates below (isCodexAvailable() etc.) don't apply and
|
||||
// would wrongly reject a machine that hasn't got the CLI installed locally.
|
||||
let remote = undefined;
|
||||
let casePath: string | null = null;
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
const remoteCase = remoteCases.find((item) => item.name === caseName);
|
||||
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');
|
||||
|
||||
// Per-session config that is applied to the LOCAL tmux/CLI wrapper (env vars via
|
||||
// tmux setenv, effort/model CLI args, codex/gemini/opencode config) does NOT
|
||||
// cross ssh, so it would silently no-op. Reject rather than pretend it worked —
|
||||
// remote command/env customization goes through the per-host command override.
|
||||
if (
|
||||
(envOverrides && Object.keys(envOverrides).length > 0) ||
|
||||
effort ||
|
||||
codexConfig ||
|
||||
geminiConfig ||
|
||||
openCodeConfig
|
||||
) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'envOverrides, effort, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check Codex availability if requested
|
||||
if (mode === 'codex') {
|
||||
const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
|
||||
if (!isCodexAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Codex CLI not found. Install with: npm install -g @openai/codex'
|
||||
);
|
||||
// tmux is a hard prerequisite on the remote host (the agent runs inside a remote
|
||||
// tmux server so it survives ssh drops). Probe before spawning so a missing tmux
|
||||
// surfaces a clear, structured error instead of a dead "tmux: command not found" pane.
|
||||
const tmuxCheck = await checkRemoteTmuxAvailable(host);
|
||||
if (!tmuxCheck.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'remote host is missing tmux');
|
||||
}
|
||||
}
|
||||
|
||||
// Check Gemini availability if requested
|
||||
if (mode === 'gemini') {
|
||||
const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
|
||||
if (!isGeminiAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
||||
);
|
||||
casePath = remoteCase.remotePath;
|
||||
remote = toSessionRemote(host, remoteCase);
|
||||
} else {
|
||||
// Check OpenCode availability if requested
|
||||
if (mode === 'opencode') {
|
||||
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
||||
if (!isOpenCodeAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
|
||||
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
|
||||
// external project directories are honoured by quick-start just like regular case routes.
|
||||
let linkedCases: Record<string, string> = {};
|
||||
try {
|
||||
const raw = await fs.readFile(LINKED_CASES_FILE, 'utf-8');
|
||||
linkedCases = JSON.parse(raw);
|
||||
} catch {
|
||||
// File missing or unparseable — treat as empty registry
|
||||
}
|
||||
const linkedCasePath = linkedCases[caseName];
|
||||
const casePath = linkedCasePath || validatePathWithinBase(caseName, CASES_DIR);
|
||||
if (!casePath) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
}
|
||||
// Check Codex availability if requested
|
||||
if (mode === 'codex') {
|
||||
const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
|
||||
if (!isCodexAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Codex CLI not found. Install with: npm install -g @openai/codex'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked cases)
|
||||
if (!existsSync(casePath)) {
|
||||
// Check Gemini availability if requested
|
||||
if (mode === 'gemini') {
|
||||
const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
|
||||
if (!isGeminiAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
|
||||
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
|
||||
// external project directories are honoured by quick-start just like regular case routes.
|
||||
let linkedCases: Record<string, string> = {};
|
||||
try {
|
||||
mkdirSync(casePath, { recursive: true });
|
||||
mkdirSync(join(casePath, 'src'), { recursive: true });
|
||||
const raw = await fs.readFile(LINKED_CASES_FILE, 'utf-8');
|
||||
linkedCases = JSON.parse(raw);
|
||||
} catch {
|
||||
// File missing or unparseable — treat as empty registry
|
||||
}
|
||||
casePath = linkedCases[caseName] || validatePathWithinBase(caseName, CASES_DIR);
|
||||
if (!casePath) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
}
|
||||
}
|
||||
|
||||
// By this point casePath is guaranteed non-null: for remote cases it was set from remoteCase.remotePath,
|
||||
// for local cases the !casePath guard above returned early. TypeScript can't narrow across the if/else.
|
||||
const resolvedCasePath = casePath as string;
|
||||
|
||||
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote cases)
|
||||
if (!remote && !existsSync(resolvedCasePath)) {
|
||||
try {
|
||||
mkdirSync(resolvedCasePath, { recursive: true });
|
||||
mkdirSync(join(resolvedCasePath, 'src'), { recursive: true });
|
||||
|
||||
// Read settings to get custom template path
|
||||
const templatePath = await ctx.getDefaultClaudeMdPath();
|
||||
const claudeMd = generateClaudeMd(caseName, '', templatePath);
|
||||
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
|
||||
writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd);
|
||||
|
||||
// Write .claude/settings.local.json with hooks for desktop notifications
|
||||
// (Claude-specific — OpenCode, Codex, and Gemini use their own systems)
|
||||
if (mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini') {
|
||||
await writeHooksConfig(casePath);
|
||||
await writeHooksConfig(resolvedCasePath);
|
||||
}
|
||||
|
||||
ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: casePath });
|
||||
ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: resolvedCasePath });
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
||||
}
|
||||
} else if (mode !== 'opencode') {
|
||||
} else if (!remote && mode !== 'opencode') {
|
||||
// COD-91 self-heal for an EXISTING case: refresh a pre-secret hooks block so the
|
||||
// now-unconditional hook-secret gate keeps accepting its hook events. No-op when
|
||||
// the hooks aren't ours or already carry the secret.
|
||||
await refreshStaleHookSecret(casePath).catch(() => {});
|
||||
// the hooks aren't ours or already carry the secret. Skipped for remote cases —
|
||||
// resolvedCasePath is a REMOTE path that doesn't exist on the local filesystem.
|
||||
await refreshStaleHookSecret(resolvedCasePath).catch(() => {});
|
||||
}
|
||||
|
||||
// Strip stale disk entries for keys this request is actively setting (Claude only —
|
||||
@@ -1352,10 +1807,11 @@ export function registerSessionRoutes(
|
||||
mode !== 'opencode' &&
|
||||
mode !== 'codex' &&
|
||||
mode !== 'gemini' &&
|
||||
!remote &&
|
||||
envOverrides &&
|
||||
Object.keys(envOverrides).length > 0
|
||||
) {
|
||||
await stripCaseEnvKeys(casePath, Object.keys(envOverrides));
|
||||
await stripCaseEnvKeys(resolvedCasePath, Object.keys(envOverrides));
|
||||
}
|
||||
|
||||
// Create a new session with the case as working directory
|
||||
@@ -1375,7 +1831,7 @@ export function registerSessionRoutes(
|
||||
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||
const session = new Session({
|
||||
workingDir: casePath,
|
||||
workingDir: resolvedCasePath,
|
||||
mux: ctx.mux,
|
||||
useMux: true,
|
||||
mode: mode,
|
||||
@@ -1388,13 +1844,14 @@ export function registerSessionRoutes(
|
||||
geminiConfig: mode === 'gemini' ? geminiConfig : undefined,
|
||||
envOverrides,
|
||||
effort,
|
||||
remote,
|
||||
tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
|
||||
});
|
||||
|
||||
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
||||
// so the initial state already has the phrase configured (only if globally enabled)
|
||||
if (mode === 'claude' && ctx.store.getConfig().ralphEnabled) {
|
||||
autoConfigureRalph(session, casePath, ctx);
|
||||
if (mode === 'claude' && !remote && ctx.store.getConfig().ralphEnabled) {
|
||||
autoConfigureRalph(session, resolvedCasePath, ctx);
|
||||
if (!session.ralphTracker.enabled) {
|
||||
session.ralphTracker.enable();
|
||||
session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart
|
||||
@@ -1463,7 +1920,7 @@ export function registerSessionRoutes(
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
casePath,
|
||||
casePath: resolvedCasePath,
|
||||
caseName,
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -1875,7 +2332,7 @@ export function registerSessionRoutes(
|
||||
// Paste Image (clipboard / drag-drop upload)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']);
|
||||
const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.heic', '.heif']);
|
||||
// The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts).
|
||||
|
||||
app.post('/api/sessions/:id/paste-image', async (req, reply) => {
|
||||
@@ -1967,7 +2424,7 @@ export function registerSessionRoutes(
|
||||
const origExt = extname(part.filename).toLowerCase();
|
||||
if (ALLOWED_IMAGE_EXTS.has(origExt)) ext = origExt;
|
||||
}
|
||||
const mimeMatch = (part.mimetype || '').toLowerCase().match(/^image\/(png|jpeg|jpg|webp|gif|bmp)$/);
|
||||
const mimeMatch = (part.mimetype || '').toLowerCase().match(/^image\/(png|jpeg|jpg|webp|gif|bmp|heic|heif)$/);
|
||||
if (mimeMatch) {
|
||||
const map: Record<string, string> = {
|
||||
png: '.png',
|
||||
@@ -1976,6 +2433,8 @@ export function registerSessionRoutes(
|
||||
webp: '.webp',
|
||||
gif: '.gif',
|
||||
bmp: '.bmp',
|
||||
heic: '.heic',
|
||||
heif: '.heif',
|
||||
};
|
||||
ext = map[mimeMatch[1]] ?? ext;
|
||||
}
|
||||
@@ -1988,14 +2447,27 @@ export function registerSessionRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
// Sniff actual bytes — filename and Content-Type are both attacker-supplied.
|
||||
// Polyglot HTML/PNG would otherwise pass and serve back with image/png MIME.
|
||||
if (!imageMagicMatchesExt(imageBytes, ext)) {
|
||||
// Diagnostic: on some Android galleries (e.g. MIUI) a WebP/HEIF is
|
||||
// mislabeled as image/jpeg, so the declared ext passes the allowlist but
|
||||
// the magic bytes do not. Log the real header so format mismatches can be
|
||||
// pinned down without a reproduce-and-guess loop. The client now
|
||||
// re-encodes images to JPEG/PNG before upload, so this should be rare.
|
||||
// Route HEIC on the raw bytes, NOT the declared ext/mime: on some Android
|
||||
// galleries (e.g. MIUI) a HEIF comes back mislabeled as image/jpeg, and
|
||||
// browsers that cannot decode HEIF upload the original file as-is — so a
|
||||
// HEIC payload can arrive under any declared type. Filename and
|
||||
// Content-Type are attacker-supplied anyway; only the bytes are trusted.
|
||||
if (imageMagicMatchesExt(imageBytes, '.heic')) {
|
||||
try {
|
||||
imageBytes = await convertHeicToJpeg(imageBytes);
|
||||
ext = '.jpg';
|
||||
} catch (err: unknown) {
|
||||
console.warn(
|
||||
`[paste-image] HEIC conversion failed: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} error=${getErrorMessage(err)}`
|
||||
);
|
||||
reply.code(415);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Could not convert HEIC image to JPEG');
|
||||
}
|
||||
} else if (!imageMagicMatchesExt(imageBytes, ext)) {
|
||||
// Sniff actual bytes — a polyglot HTML/PNG would otherwise pass and
|
||||
// serve back with image/png MIME. Log the real header so format
|
||||
// mismatches can be pinned down without a reproduce-and-guess loop. The
|
||||
// client re-encodes images to JPEG/PNG before upload, so this is rare.
|
||||
console.warn(
|
||||
`[paste-image] magic mismatch: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} declaredExt=${ext} magic=${imageBytes.subarray(0, 12).toString('hex')}`
|
||||
);
|
||||
|
||||
+210
-166
@@ -34,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. */
|
||||
@@ -59,197 +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;
|
||||
// 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);
|
||||
// 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 });
|
||||
}
|
||||
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 });
|
||||
} 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),
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -271,6 +271,94 @@ 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'),
|
||||
});
|
||||
|
||||
// ========== Quick Start ==========
|
||||
|
||||
/**
|
||||
@@ -603,6 +691,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'),
|
||||
@@ -673,6 +828,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(),
|
||||
|
||||
+65
-8
@@ -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,
|
||||
@@ -153,8 +154,10 @@ import {
|
||||
registerClipboardRoutes,
|
||||
registerSearchRoutes,
|
||||
registerOrchestratorRoutes,
|
||||
registerCronRoutes,
|
||||
registerWsRoutes,
|
||||
} from './routes/index.js';
|
||||
import { CronService } from '../cron/cron-service.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -176,6 +179,7 @@ import {
|
||||
ITERATION_PAUSE_MS,
|
||||
STATS_COLLECTION_INTERVAL_MS,
|
||||
INACTIVITY_TIMEOUT_MS,
|
||||
CRON_TICK_INTERVAL,
|
||||
} from '../config/server-timing.js';
|
||||
|
||||
/**
|
||||
@@ -224,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;
|
||||
@@ -892,6 +898,13 @@ export class WebServer extends EventEmitter {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -1281,6 +1294,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);
|
||||
@@ -1346,7 +1366,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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1359,16 +1380,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(
|
||||
@@ -1782,6 +1818,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' },
|
||||
};
|
||||
|
||||
@@ -1814,6 +1851,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)}`;
|
||||
@@ -1972,6 +2012,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(
|
||||
() => {
|
||||
@@ -2157,6 +2208,12 @@ export class WebServer extends EventEmitter {
|
||||
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,
|
||||
});
|
||||
|
||||
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
|
||||
|
||||
@@ -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. */
|
||||
@@ -384,6 +397,7 @@ export const SseEvent = {
|
||||
SessionLimitPauseScheduled,
|
||||
SessionLimitResume,
|
||||
SessionLimitResumeCancelled,
|
||||
SessionRespawnBreakerTripped,
|
||||
SessionCliInfo,
|
||||
SessionMessage,
|
||||
SessionInteractive,
|
||||
@@ -469,6 +483,12 @@ export const SseEvent = {
|
||||
ScheduledLog,
|
||||
ScheduledDeleted,
|
||||
|
||||
// Cron jobs
|
||||
CronJobsChanged,
|
||||
CronJobDeleted,
|
||||
CronRunCreated,
|
||||
CronRunUpdated,
|
||||
|
||||
// Teams
|
||||
TeamCreated,
|
||||
TeamUpdated,
|
||||
|
||||
@@ -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,454 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function loadPaletteHarness(overrides: Record<string, any> = {}) {
|
||||
const elements: Record<string, any> = {};
|
||||
const listeners: Record<string, (event: any) => void> = {};
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
|
||||
const makeClassList = () => {
|
||||
const classes = new Set<string>();
|
||||
return {
|
||||
add: (...names: string[]) => names.forEach((name) => classes.add(name)),
|
||||
remove: (...names: string[]) => names.forEach((name) => classes.delete(name)),
|
||||
contains: (name: string) => classes.has(name),
|
||||
toggle: (name: string, force?: boolean) => {
|
||||
const shouldAdd = force ?? !classes.has(name);
|
||||
if (shouldAdd) classes.add(name);
|
||||
else classes.delete(name);
|
||||
return shouldAdd;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
elements.commandPaletteModal = {
|
||||
classList: makeClassList(),
|
||||
addEventListener: vi.fn((event: string, handler: (event: any) => void) => {
|
||||
listeners[`modal:${event}`] = handler;
|
||||
}),
|
||||
};
|
||||
elements.commandPaletteSearch = {
|
||||
value: '',
|
||||
focus: vi.fn(),
|
||||
select: vi.fn(),
|
||||
addEventListener: vi.fn((event: string, handler: (event: any) => void) => {
|
||||
listeners[`search:${event}`] = handler;
|
||||
}),
|
||||
};
|
||||
elements.commandPaletteList = {
|
||||
innerHTML: '',
|
||||
addEventListener: vi.fn((event: string, handler: (event: any) => void) => {
|
||||
listeners[`list:${event}`] = handler;
|
||||
}),
|
||||
};
|
||||
elements.quickStartCase = {
|
||||
value: 'plex-previews',
|
||||
};
|
||||
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
document: {
|
||||
getElementById: (id: string) => elements[id] ?? null,
|
||||
createElement: (tagName: string) => ({
|
||||
tagName: tagName.toUpperCase(),
|
||||
value: '',
|
||||
textContent: '',
|
||||
}),
|
||||
},
|
||||
console,
|
||||
escapeHtml: (value: string) =>
|
||||
String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, '''),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const panelsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/panels-ui.js'), 'utf8');
|
||||
vm.runInContext(panelsUi, context, { filename: 'panels-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
app.sessions = new Map([
|
||||
[
|
||||
'sess-alpha',
|
||||
{
|
||||
id: 'sess-alpha',
|
||||
name: 'Alpha API cleanup',
|
||||
workingDir: '/repo/api',
|
||||
mode: 'codex',
|
||||
status: 'busy',
|
||||
},
|
||||
],
|
||||
[
|
||||
'sess-beta',
|
||||
{
|
||||
id: 'sess-beta',
|
||||
name: 'Billing prompt polish',
|
||||
workingDir: '/repo/billing',
|
||||
mode: 'claude',
|
||||
status: 'idle',
|
||||
},
|
||||
],
|
||||
[
|
||||
'sess-gamma',
|
||||
{
|
||||
id: 'sess-gamma',
|
||||
workingDir: '/repo/flux-player',
|
||||
mode: 'codex',
|
||||
status: 'busy',
|
||||
},
|
||||
],
|
||||
]);
|
||||
app.sessionOrder = ['sess-beta', 'sess-alpha', 'sess-gamma'];
|
||||
app.cases = [{ name: 'plex-previews' }, { name: 'flux-player' }, { name: 'api-tools' }];
|
||||
app.selectSession = vi.fn();
|
||||
app.run = vi.fn();
|
||||
app.getShortId = (id: string) => id.slice(0, 8);
|
||||
app.getSessionName = (session: any) =>
|
||||
session.name || session.workingDir?.split('/').pop() || app.getShortId(session.id);
|
||||
|
||||
return { app, elements, listeners };
|
||||
}
|
||||
|
||||
describe('Command-K session palette', () => {
|
||||
it('recognizes Cmd/Ctrl-K outside text-entry contexts only', () => {
|
||||
const { app } = loadPaletteHarness();
|
||||
|
||||
expect(app.shouldOpenCommandPaletteFromShortcut({ key: 'k', metaKey: true, ctrlKey: false, target: null })).toBe(
|
||||
true
|
||||
);
|
||||
expect(app.shouldOpenCommandPaletteFromShortcut({ key: 'K', metaKey: false, ctrlKey: true, target: null })).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
app.shouldOpenCommandPaletteFromShortcut({
|
||||
key: 'k',
|
||||
metaKey: true,
|
||||
ctrlKey: false,
|
||||
target: { tagName: 'INPUT', isContentEditable: false },
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
app.shouldOpenCommandPaletteFromShortcut({
|
||||
key: 'k',
|
||||
metaKey: false,
|
||||
ctrlKey: true,
|
||||
target: { tagName: 'DIV', isContentEditable: true },
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('recognizes Ctrl-K from the focused xterm helper textarea', () => {
|
||||
const { app } = loadPaletteHarness();
|
||||
|
||||
expect(
|
||||
app.shouldOpenCommandPaletteFromShortcut({
|
||||
key: 'k',
|
||||
code: 'KeyK',
|
||||
metaKey: false,
|
||||
ctrlKey: true,
|
||||
altKey: false,
|
||||
target: {
|
||||
tagName: 'TEXTAREA',
|
||||
isContentEditable: false,
|
||||
classList: { contains: (name: string) => name === 'xterm-helper-textarea' },
|
||||
},
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes macOS Option-K by physical key code', () => {
|
||||
const { app } = loadPaletteHarness();
|
||||
|
||||
expect(
|
||||
app.shouldOpenCommandPaletteFromShortcut({
|
||||
key: '˚',
|
||||
code: 'KeyK',
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: true,
|
||||
target: null,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects the palette chord when extra modifiers are held (Ctrl+Shift+K is the Firefox devtools console)', () => {
|
||||
const { app } = loadPaletteHarness();
|
||||
|
||||
expect(
|
||||
app.shouldOpenCommandPaletteFromShortcut({ key: 'K', code: 'KeyK', ctrlKey: true, shiftKey: true, target: null })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('honors a disabled or rebound palette shortcut from the registry', () => {
|
||||
const { app } = loadPaletteHarness();
|
||||
|
||||
// Disabled entry → never opens, even for the default chord.
|
||||
app.getShortcutRegistry = () => [
|
||||
{ id: 'command-palette', disabled: true, bindings: [{ modifiers: ['ctrl'], key: 'k', code: 'KeyK' }] },
|
||||
];
|
||||
app.matchesShortcutEvent = () => true;
|
||||
expect(app.shouldOpenCommandPaletteFromShortcut({ key: 'k', code: 'KeyK', ctrlKey: true, target: null })).toBe(
|
||||
false
|
||||
);
|
||||
|
||||
// Rebound entry → the new chord opens, the old default no longer does.
|
||||
app.getShortcutRegistry = () => [{ id: 'command-palette', bindings: [{ modifiers: ['ctrl'], code: 'KeyP' }] }];
|
||||
app.matchesShortcutEvent = (e: any, s: any) => e.code === s.bindings[0].code;
|
||||
expect(app.shouldOpenCommandPaletteFromShortcut({ key: 'k', code: 'KeyK', ctrlKey: true, target: null })).toBe(
|
||||
false
|
||||
);
|
||||
expect(app.shouldOpenCommandPaletteFromShortcut({ key: 'p', code: 'KeyP', ctrlKey: true, target: null })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('opens and focuses the palette search box', () => {
|
||||
const { app, elements } = loadPaletteHarness();
|
||||
|
||||
app.openCommandPalette();
|
||||
|
||||
expect(elements.commandPaletteModal.classList.contains('active')).toBe(true);
|
||||
expect(elements.commandPaletteSearch.focus).toHaveBeenCalledTimes(1);
|
||||
expect(elements.commandPaletteList.innerHTML).toContain('Alpha API cleanup');
|
||||
});
|
||||
|
||||
it('filters currently open sessions and always includes a new-session action', () => {
|
||||
const { app } = loadPaletteHarness();
|
||||
|
||||
const results = app.buildCommandPaletteItems('bill');
|
||||
|
||||
expect(results.map((item: any) => item.id)).toEqual(['session:sess-beta', 'new-session', 'browse-sessions']);
|
||||
expect(results[0]).toMatchObject({ type: 'session', sessionId: 'sess-beta', title: 'Billing prompt polish' });
|
||||
expect(results[1]).toMatchObject({ type: 'new-session', title: 'New session' });
|
||||
expect(results[2]).toMatchObject({ type: 'browse-sessions' });
|
||||
});
|
||||
|
||||
it('uses the tab name instead of the short session id for unnamed sessions', () => {
|
||||
const { app } = loadPaletteHarness();
|
||||
|
||||
const results = app.buildCommandPaletteItems('flux-player');
|
||||
|
||||
expect(results[0]).toMatchObject({
|
||||
type: 'session',
|
||||
sessionId: 'sess-gamma',
|
||||
title: 'flux-player',
|
||||
});
|
||||
expect(results[0].title).not.toBe('sess-gam');
|
||||
});
|
||||
|
||||
it('uses the best matching case for the new-session action', async () => {
|
||||
const { app, elements } = loadPaletteHarness();
|
||||
|
||||
const results = app.buildCommandPaletteItems('flux');
|
||||
const newSession = results.find((item: any) => item.type === 'new-session');
|
||||
|
||||
expect(newSession).toMatchObject({
|
||||
type: 'new-session',
|
||||
caseName: 'flux-player',
|
||||
subtitle: 'Run Claude in flux-player',
|
||||
});
|
||||
|
||||
app.commandPaletteItems = [newSession];
|
||||
app.commandPaletteActiveIndex = 0;
|
||||
await app.activateCommandPaletteItem();
|
||||
|
||||
expect(elements.quickStartCase.value).toBe('flux-player');
|
||||
expect(app.run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('adds the matched case option before selecting it for a new session', async () => {
|
||||
const { app, elements } = loadPaletteHarness();
|
||||
const options = [{ value: 'plex-previews' }];
|
||||
elements.quickStartCase = {
|
||||
tagName: 'SELECT',
|
||||
options,
|
||||
appendChild: vi.fn((option: any) => options.push(option)),
|
||||
get value() {
|
||||
return this._value || '';
|
||||
},
|
||||
set value(next: string) {
|
||||
this._value = options.some((option) => option.value === next) ? next : '';
|
||||
},
|
||||
};
|
||||
elements.quickStartCase.value = 'plex-previews';
|
||||
|
||||
const newSession = app.buildCommandPaletteItems('flux').find((item: any) => item.type === 'new-session');
|
||||
app.commandPaletteItems = [newSession];
|
||||
app.commandPaletteActiveIndex = 0;
|
||||
|
||||
await app.activateCommandPaletteItem();
|
||||
|
||||
expect(elements.quickStartCase.appendChild).toHaveBeenCalledTimes(1);
|
||||
expect(elements.quickStartCase.value).toBe('flux-player');
|
||||
expect(app.run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('routes the new-session case pick through selectQuickStartCase when the picker mixin is loaded', async () => {
|
||||
const { app } = loadPaletteHarness();
|
||||
app.selectQuickStartCase = vi.fn();
|
||||
|
||||
const newSession = app.buildCommandPaletteItems('flux').find((item: any) => item.type === 'new-session');
|
||||
app.commandPaletteItems = [newSession];
|
||||
app.commandPaletteActiveIndex = 0;
|
||||
await app.activateCommandPaletteItem();
|
||||
|
||||
// Keeps the searchable combobox, dir display, and lastUsedCase in sync
|
||||
// instead of silently mutating the hidden native <select>.
|
||||
expect(app.selectQuickStartCase).toHaveBeenCalledWith('flux-player');
|
||||
expect(app.run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('activates the highlighted session result', async () => {
|
||||
const { app } = loadPaletteHarness();
|
||||
app.openCommandPalette();
|
||||
app.commandPaletteItems = app.buildCommandPaletteItems('api');
|
||||
app.commandPaletteActiveIndex = 0;
|
||||
|
||||
await app.activateCommandPaletteItem();
|
||||
|
||||
expect(app.selectSession).toHaveBeenCalledWith('sess-alpha');
|
||||
expect(app.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('activates the new-session result through the current run path', async () => {
|
||||
const { app } = loadPaletteHarness();
|
||||
app.openCommandPalette();
|
||||
app.commandPaletteItems = app.buildCommandPaletteItems('does-not-match');
|
||||
app.commandPaletteActiveIndex = 0;
|
||||
|
||||
await app.activateCommandPaletteItem();
|
||||
|
||||
expect(app.run).toHaveBeenCalledTimes(1);
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes Enter from the palette search to the current result', async () => {
|
||||
const { app, listeners } = loadPaletteHarness();
|
||||
app.openCommandPalette();
|
||||
app.commandPaletteItems = app.buildCommandPaletteItems('api');
|
||||
app.commandPaletteActiveIndex = 0;
|
||||
|
||||
const event = { key: 'Enter', preventDefault: vi.fn(), stopPropagation: vi.fn() };
|
||||
await listeners['search:keydown'](event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(app.selectSession).toHaveBeenCalledWith('sess-alpha');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Manager unified list', () => {
|
||||
it('maps UnifiedSessionItem fields to the history-record shape and routes clicks by liveness', async () => {
|
||||
const { app, elements } = loadPaletteHarness({
|
||||
fetch: async (url: string) => {
|
||||
expect(url).toBe('/api/sessions/unified?limit=200&q=api');
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'sess-alpha',
|
||||
name: 'Alpha API cleanup',
|
||||
workingDir: '/repo/api',
|
||||
lastActivityAt: 1751000000000,
|
||||
sources: ['live'],
|
||||
},
|
||||
{
|
||||
sessionId: 'conv-uuid-1',
|
||||
workingDir: '/repo/old',
|
||||
sizeBytes: 2048,
|
||||
firstPrompt: 'old prompt',
|
||||
lastActivityAt: 1750000000000,
|
||||
sources: ['history'],
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
elements.sessionManagerList = { replaceChildren: vi.fn(), appendChild: vi.fn() };
|
||||
app._buildHistoryItem = vi.fn(() => ({}));
|
||||
app.resumeHistorySession = vi.fn();
|
||||
|
||||
await app._loadSessionManagerList('api');
|
||||
|
||||
expect(app._buildHistoryItem).toHaveBeenCalledTimes(2);
|
||||
const [liveRecord, , liveOptions] = app._buildHistoryItem.mock.calls[0];
|
||||
expect(liveRecord).toMatchObject({
|
||||
sessionId: 'sess-alpha',
|
||||
workingDir: '/repo/api',
|
||||
sizeBytes: 0,
|
||||
firstPrompt: 'Alpha API cleanup',
|
||||
});
|
||||
expect(new Date(liveRecord.lastModified).getTime()).toBe(1751000000000);
|
||||
expect(liveOptions.showViewAll).toBe(false);
|
||||
|
||||
// Live row → switch to the session (resuming it would spawn a duplicate).
|
||||
liveOptions.onActivate();
|
||||
expect(app.selectSession).toHaveBeenCalledWith('sess-alpha');
|
||||
expect(app.resumeHistorySession).not.toHaveBeenCalled();
|
||||
|
||||
// History row → resume by conversation UUID.
|
||||
const [historyRecord, , historyOptions] = app._buildHistoryItem.mock.calls[1];
|
||||
expect(historyRecord).toMatchObject({ sessionId: 'conv-uuid-1', sizeBytes: 2048, firstPrompt: 'old prompt' });
|
||||
historyOptions.onActivate();
|
||||
expect(app.resumeHistorySession).toHaveBeenCalledWith('conv-uuid-1', '/repo/old');
|
||||
});
|
||||
|
||||
it('surfaces an error message instead of an empty list when the endpoint fails', async () => {
|
||||
const appended: any[] = [];
|
||||
const { app, elements } = loadPaletteHarness({
|
||||
fetch: async () => ({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: async () => ({ success: false, error: 'unified list unavailable', errorCode: 'OPERATION_FAILED' }),
|
||||
}),
|
||||
});
|
||||
elements.sessionManagerList = { replaceChildren: vi.fn(), appendChild: (el: any) => appended.push(el) };
|
||||
|
||||
await app._loadSessionManagerList('');
|
||||
|
||||
expect(appended).toHaveLength(1);
|
||||
expect(appended[0].textContent).toBe('unified list unavailable');
|
||||
expect(appended[0].textContent).not.toBe('No sessions found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('panel close helpers', () => {
|
||||
it('closes panels when the mobile header helper is unavailable', () => {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const elements: Record<string, any> = {
|
||||
monitorPanel: { classList: { remove: vi.fn() } },
|
||||
subagentsPanel: { classList: { remove: vi.fn() } },
|
||||
};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
document: {
|
||||
getElementById: (id: string) => elements[id] ?? null,
|
||||
},
|
||||
console,
|
||||
});
|
||||
|
||||
const settingsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8');
|
||||
vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
app.closeSessionOptions = vi.fn();
|
||||
app.closeAppSettings = vi.fn();
|
||||
app.cancelCloseSession = vi.fn();
|
||||
app.closeTokenStats = vi.fn();
|
||||
|
||||
expect(() => app.closeAllPanels()).not.toThrow();
|
||||
expect(elements.monitorPanel.classList.remove).toHaveBeenCalledWith('open');
|
||||
expect(elements.subagentsPanel.classList.remove).toHaveBeenCalledWith('open');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* @fileoverview Regression tests for the header connection indicator
|
||||
* (`CodemanApp._updateConnectionIndicator`) and the invariant that updating it
|
||||
* never aborts durable input delivery.
|
||||
*
|
||||
* Guards two regressions from the upstream v1.1.15 merge (fixed in COD-133):
|
||||
* 1. The indicator body referenced an undefined `transport` object, throwing
|
||||
* `ReferenceError: transport is not defined` on every queued state. Because
|
||||
* `_reliableSend()` calls `_updateConnectionIndicator()` *before*
|
||||
* `_drainSession()`, the throw skipped immediate delivery on every keystroke
|
||||
* → input only flushed on the 2s sweep (large typing lag) and the indicator
|
||||
* never rendered (missing "WS" status).
|
||||
* 2. The restored body only read the SSE `_connectionStatus`, so it never
|
||||
* surfaced the terminal WebSocket transport ("WS" / "HTTP"), and it flashed
|
||||
* "sending 1B" on every single keystroke.
|
||||
*
|
||||
* COD-136 (perf, no behavior change) extracts the pure render into
|
||||
* `_computeConnectionDescriptor()` and makes `_updateConnectionIndicator()`
|
||||
* early-return when that descriptor is byte-identical to the last render — so
|
||||
* fast typing stops doing redundant DOM writes on the hot input path. The
|
||||
* `_computeConnectionDescriptor` block below pins the exact rendered strings per
|
||||
* state (so a future refactor can't silently relabel), and the unchanged-skip
|
||||
* block asserts the DOM is written once across two identical calls and re-written
|
||||
* when state changes.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed context (no jsdom — see input-send-order.test.ts).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function loadCodemanAppClass() {
|
||||
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
fetch: (...args: Parameters<typeof fetch>) => global.fetch(...args),
|
||||
document: { addEventListener: vi.fn() },
|
||||
localStorage: {
|
||||
length: 0,
|
||||
key: vi.fn(),
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
},
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
MobileDetection: {},
|
||||
});
|
||||
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||
return (context as { __CodemanApp: new () => unknown }).__CodemanApp;
|
||||
}
|
||||
|
||||
const CodemanApp = loadCodemanAppClass();
|
||||
|
||||
function fakeElement() {
|
||||
return { style: { display: '' }, title: '', textContent: '', className: '' };
|
||||
}
|
||||
|
||||
type Indicator = {
|
||||
$: (id: string) => unknown;
|
||||
_pendingDeliveries: Map<string, Array<{ seq: number; data: string }>>;
|
||||
_connectionStatus: string;
|
||||
_wsState: string;
|
||||
activeSessionId: string | null;
|
||||
isOnline: boolean;
|
||||
_updateConnectionIndicator: () => void;
|
||||
};
|
||||
|
||||
function makeApp(overrides: Partial<Indicator> & { queuedBytes?: number } = {}) {
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as Indicator & {
|
||||
queuedBytes?: number;
|
||||
};
|
||||
const els: Record<string, ReturnType<typeof fakeElement>> = {
|
||||
connectionIndicator: fakeElement(),
|
||||
connectionDot: fakeElement(),
|
||||
connectionText: fakeElement(),
|
||||
};
|
||||
app.$ = (id: string) => els[id];
|
||||
app._pendingDeliveries = new Map();
|
||||
app._connectionStatus = 'connected';
|
||||
app._wsState = 'disconnected';
|
||||
app.activeSessionId = null;
|
||||
app.isOnline = true;
|
||||
Object.assign(app, overrides);
|
||||
const queued = overrides.queuedBytes ?? 0;
|
||||
if (queued > 0) {
|
||||
app._pendingDeliveries.set('s1', [{ seq: 1, data: 'x'.repeat(queued) }]);
|
||||
}
|
||||
return { app, els };
|
||||
}
|
||||
|
||||
describe('connection indicator — transport display', () => {
|
||||
it('shows "WS" with a connected dot when the terminal WebSocket is open', () => {
|
||||
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected' });
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionIndicator.style.display).toBe('flex');
|
||||
expect(els.connectionText.textContent).toBe('WS');
|
||||
expect(els.connectionDot.className).toContain('connected');
|
||||
});
|
||||
|
||||
it('shows "HTTP" with a fallback dot when the socket dropped to HTTP POST', () => {
|
||||
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'fallback' });
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionText.textContent).toBe('HTTP');
|
||||
expect(els.connectionDot.className).toContain('fallback');
|
||||
});
|
||||
|
||||
it('shows "WS…" while connecting or reconnecting the socket', () => {
|
||||
for (const state of ['connecting', 'reconnecting']) {
|
||||
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: state });
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionText.textContent).toBe('WS…');
|
||||
expect(els.connectionDot.className).toContain('reconnecting');
|
||||
}
|
||||
});
|
||||
|
||||
it('shows "Offline" when the browser reports no network', () => {
|
||||
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', isOnline: false });
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionText.textContent).toBe('Offline');
|
||||
expect(els.connectionDot.className).toContain('offline');
|
||||
});
|
||||
|
||||
it('hides on an idle dashboard (no active session, healthy stream, no queue)', () => {
|
||||
const { app, els } = makeApp({ activeSessionId: null, _connectionStatus: 'connected' });
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionIndicator.style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('surfaces SSE reconnecting on the dashboard when there is no active terminal', () => {
|
||||
const { app, els } = makeApp({ activeSessionId: null, _connectionStatus: 'reconnecting' });
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionText.textContent).toContain('Reconnecting');
|
||||
expect(els.connectionDot.className).toContain('reconnecting');
|
||||
});
|
||||
});
|
||||
|
||||
describe('connection indicator — keystroke backlog threshold', () => {
|
||||
it('does NOT annotate a single-keystroke (1B) queue — no "sending 1B" flicker', () => {
|
||||
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 1 });
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionText.textContent).toBe('WS');
|
||||
expect(els.connectionText.textContent).not.toMatch(/queued/);
|
||||
});
|
||||
|
||||
it('does NOT annotate at the 4B threshold boundary', () => {
|
||||
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 4 });
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionText.textContent).toBe('WS');
|
||||
});
|
||||
|
||||
it('annotates a genuine backlog (>4B) with a queued byte count', () => {
|
||||
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionText.textContent).toMatch(/^WS · 40B queued$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('connection indicator — never throws (the ReferenceError regression)', () => {
|
||||
it('renders every transport × stream × queue combination without throwing', () => {
|
||||
const wsStates = ['disconnected', 'connecting', 'connected', 'reconnecting', 'fallback'];
|
||||
const sseStates = ['connected', 'connecting', 'reconnecting', 'disconnected', 'offline'];
|
||||
for (const ws of wsStates) {
|
||||
for (const sse of sseStates) {
|
||||
for (const active of ['s1', null] as const) {
|
||||
for (const queuedBytes of [0, 1, 4, 200]) {
|
||||
for (const isOnline of [true, false]) {
|
||||
const { app } = makeApp({
|
||||
activeSessionId: active,
|
||||
_wsState: ws,
|
||||
_connectionStatus: sse,
|
||||
isOnline,
|
||||
queuedBytes,
|
||||
});
|
||||
expect(() => app._updateConnectionIndicator()).not.toThrow();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('durable input delivery is not aborted by the indicator (typing-lag regression)', () => {
|
||||
it('_reliableSend reaches _drainSession after updating the indicator', () => {
|
||||
const { app } = makeApp({ activeSessionId: 's1', _wsState: 'connected' });
|
||||
const a = app as unknown as {
|
||||
_seqCounters: Map<string, number>;
|
||||
_persistReliableState: () => void;
|
||||
_drainSession: (id: string) => void;
|
||||
_reliableSend: (id: string, data: string, useMux: boolean) => void;
|
||||
};
|
||||
a._seqCounters = new Map();
|
||||
a._persistReliableState = vi.fn();
|
||||
const drain = vi.fn();
|
||||
a._drainSession = drain;
|
||||
|
||||
// A single keystroke. The indicator runs first; if it throws, drain is skipped.
|
||||
a._reliableSend('s1', 'x', false);
|
||||
|
||||
expect(drain).toHaveBeenCalledWith('s1');
|
||||
expect(app._pendingDeliveries.get('s1')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- COD-136: pure descriptor + cache-skip on the hot input path --------------
|
||||
|
||||
type Descriptor = { display: string; dotClass: string; text: string; title: string };
|
||||
|
||||
type DescriptorApp = Indicator & {
|
||||
_computeConnectionDescriptor: () => Descriptor;
|
||||
_lastIndicatorDescriptor: Descriptor | null;
|
||||
};
|
||||
|
||||
function computeApp(overrides: Partial<Indicator> & { queuedBytes?: number } = {}) {
|
||||
const { app } = makeApp(overrides);
|
||||
return app as unknown as DescriptorApp;
|
||||
}
|
||||
|
||||
describe('_computeConnectionDescriptor — pure render per state (COD-136)', () => {
|
||||
it('offline dominates everything (even an active connected terminal)', () => {
|
||||
const app = computeApp({ activeSessionId: 's1', _wsState: 'connected', isOnline: false });
|
||||
expect(app._computeConnectionDescriptor()).toEqual({
|
||||
display: 'flex',
|
||||
dotClass: 'connection-dot offline',
|
||||
text: 'Offline',
|
||||
title: 'No network connection',
|
||||
});
|
||||
});
|
||||
|
||||
it('active terminal — connected → WS', () => {
|
||||
const app = computeApp({ activeSessionId: 's1', _wsState: 'connected' });
|
||||
expect(app._computeConnectionDescriptor()).toEqual({
|
||||
display: 'flex',
|
||||
dotClass: 'connection-dot connected',
|
||||
text: 'WS',
|
||||
title: 'Terminal connected over WebSocket',
|
||||
});
|
||||
});
|
||||
|
||||
it('active terminal — fallback → HTTP', () => {
|
||||
const app = computeApp({ activeSessionId: 's1', _wsState: 'fallback' });
|
||||
expect(app._computeConnectionDescriptor()).toEqual({
|
||||
display: 'flex',
|
||||
dotClass: 'connection-dot fallback',
|
||||
text: 'HTTP',
|
||||
title: 'WebSocket unavailable — input sent over HTTP',
|
||||
});
|
||||
});
|
||||
|
||||
it('active terminal — reconnecting → WS…', () => {
|
||||
const app = computeApp({ activeSessionId: 's1', _wsState: 'reconnecting' });
|
||||
expect(app._computeConnectionDescriptor()).toEqual({
|
||||
display: 'flex',
|
||||
dotClass: 'connection-dot reconnecting',
|
||||
text: 'WS…',
|
||||
title: 'Reconnecting WebSocket',
|
||||
});
|
||||
});
|
||||
|
||||
it('active terminal — connecting (default branch) → WS…', () => {
|
||||
const app = computeApp({ activeSessionId: 's1', _wsState: 'connecting' });
|
||||
expect(app._computeConnectionDescriptor()).toEqual({
|
||||
display: 'flex',
|
||||
dotClass: 'connection-dot reconnecting',
|
||||
text: 'WS…',
|
||||
title: 'Connecting WebSocket',
|
||||
});
|
||||
});
|
||||
|
||||
it('active terminal — a queued backlog (>4B) adds the " · …KB queued" suffix', () => {
|
||||
const app = computeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 2048 });
|
||||
expect(app._computeConnectionDescriptor()).toEqual({
|
||||
display: 'flex',
|
||||
dotClass: 'connection-dot connected',
|
||||
text: 'WS · 2.0KB queued',
|
||||
title: 'Terminal connected over WebSocket',
|
||||
});
|
||||
});
|
||||
|
||||
it('no terminal, SSE reconnecting → Reconnecting...', () => {
|
||||
const app = computeApp({ activeSessionId: null, _connectionStatus: 'reconnecting' });
|
||||
expect(app._computeConnectionDescriptor()).toEqual({
|
||||
display: 'flex',
|
||||
dotClass: 'connection-dot reconnecting',
|
||||
text: 'Reconnecting...',
|
||||
title: 'Reconnecting to server',
|
||||
});
|
||||
});
|
||||
|
||||
it('idle dashboard, healthy stream, no queue → hidden (display:none, others normalized to "")', () => {
|
||||
const app = computeApp({ activeSessionId: null, _connectionStatus: 'connected' });
|
||||
expect(app._computeConnectionDescriptor()).toEqual({
|
||||
display: 'none',
|
||||
dotClass: '',
|
||||
text: '',
|
||||
title: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('idle dashboard with a small queue (≤4B) → draining "Sending..."', () => {
|
||||
const app = computeApp({ activeSessionId: null, _connectionStatus: 'connected', queuedBytes: 2 });
|
||||
expect(app._computeConnectionDescriptor()).toEqual({
|
||||
display: 'flex',
|
||||
dotClass: 'connection-dot draining',
|
||||
text: 'Sending...',
|
||||
title: 'Delivering queued input',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('connection-dot CSS — every emitted dot class has a styles.css rule', () => {
|
||||
// The descriptor emits these dot variants; each needs a visible rule or the
|
||||
// 8px dot renders as an invisible blob (the base .connection-dot rule has no
|
||||
// background). 'connected' and 'fallback' were missing when this PR shipped.
|
||||
const DOT_CLASSES = ['connected', 'fallback', 'offline', 'reconnecting', 'draining'];
|
||||
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
|
||||
|
||||
for (const cls of DOT_CLASSES) {
|
||||
it(`.connection-dot.${cls} is styled`, () => {
|
||||
const rule = new RegExp(`\\.connection-dot\\.${cls}\\s*\\{[^}]*background`, 'm');
|
||||
expect(css).toMatch(rule);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/** A DOM element fake that COUNTS each property write — used to detect the skip. */
|
||||
function countingElement() {
|
||||
const writes = { display: 0, className: 0, textContent: 0, title: 0 };
|
||||
let _display = '';
|
||||
let _className = '';
|
||||
let _textContent = '';
|
||||
let _title = '';
|
||||
return {
|
||||
writes,
|
||||
style: {
|
||||
get display() {
|
||||
return _display;
|
||||
},
|
||||
set display(v: string) {
|
||||
_display = v;
|
||||
writes.display++;
|
||||
},
|
||||
},
|
||||
get className() {
|
||||
return _className;
|
||||
},
|
||||
set className(v: string) {
|
||||
_className = v;
|
||||
writes.className++;
|
||||
},
|
||||
get textContent() {
|
||||
return _textContent;
|
||||
},
|
||||
set textContent(v: string) {
|
||||
_textContent = v;
|
||||
writes.textContent++;
|
||||
},
|
||||
get title() {
|
||||
return _title;
|
||||
},
|
||||
set title(v: string) {
|
||||
_title = v;
|
||||
writes.title++;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeCountingApp(overrides: Partial<Indicator> & { queuedBytes?: number } = {}) {
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as DescriptorApp & {
|
||||
queuedBytes?: number;
|
||||
};
|
||||
const els = {
|
||||
connectionIndicator: countingElement(),
|
||||
connectionDot: countingElement(),
|
||||
connectionText: countingElement(),
|
||||
};
|
||||
app.$ = (id: string) => (els as Record<string, ReturnType<typeof countingElement>>)[id];
|
||||
app._pendingDeliveries = new Map();
|
||||
app._connectionStatus = 'connected';
|
||||
app._wsState = 'disconnected';
|
||||
app.activeSessionId = null;
|
||||
app.isOnline = true;
|
||||
app._lastIndicatorDescriptor = null;
|
||||
Object.assign(app, overrides);
|
||||
const queued = overrides.queuedBytes ?? 0;
|
||||
if (queued > 0) {
|
||||
app._pendingDeliveries.set('s1', [{ seq: 1, data: 'x'.repeat(queued) }]);
|
||||
}
|
||||
return { app, els };
|
||||
}
|
||||
|
||||
describe('_updateConnectionIndicator — COD-136 unchanged-skip', () => {
|
||||
it('writes the DOM on the first call (cache starts null → renders)', () => {
|
||||
const { app, els } = makeCountingApp({ activeSessionId: 's1', _wsState: 'connected' });
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionIndicator.style.display).toBe('flex');
|
||||
expect(els.connectionDot.className).toBe('connection-dot connected');
|
||||
expect(els.connectionText.textContent).toBe('WS');
|
||||
expect(els.connectionText.writes.textContent).toBe(1);
|
||||
});
|
||||
|
||||
it('skips redundant DOM writes when the descriptor is unchanged across two calls', () => {
|
||||
const { app, els } = makeCountingApp({ activeSessionId: 's1', _wsState: 'connected' });
|
||||
|
||||
app._updateConnectionIndicator(); // first render
|
||||
const before = {
|
||||
display: els.connectionIndicator.writes.display,
|
||||
className: els.connectionDot.writes.className,
|
||||
textContent: els.connectionText.writes.textContent,
|
||||
title: els.connectionIndicator.writes.title,
|
||||
};
|
||||
|
||||
app._updateConnectionIndicator(); // identical state → must early-return, no writes
|
||||
|
||||
expect(els.connectionIndicator.writes.display).toBe(before.display);
|
||||
expect(els.connectionDot.writes.className).toBe(before.className);
|
||||
expect(els.connectionText.writes.textContent).toBe(before.textContent);
|
||||
expect(els.connectionIndicator.writes.title).toBe(before.title);
|
||||
});
|
||||
|
||||
it('re-renders when state changes between calls (WS → HTTP)', () => {
|
||||
const { app, els } = makeCountingApp({ activeSessionId: 's1', _wsState: 'connected' });
|
||||
|
||||
app._updateConnectionIndicator(); // WS
|
||||
const writesAfterFirst = els.connectionText.writes.textContent;
|
||||
|
||||
app._wsState = 'fallback';
|
||||
app._updateConnectionIndicator(); // HTTP — must write again
|
||||
|
||||
expect(els.connectionText.writes.textContent).toBe(writesAfterFirst + 1);
|
||||
expect(els.connectionText.textContent).toBe('HTTP');
|
||||
expect(els.connectionDot.className).toBe('connection-dot fallback');
|
||||
});
|
||||
|
||||
it('re-renders display when the hidden→shown transition occurs (none → flex)', () => {
|
||||
const { app, els } = makeCountingApp({ activeSessionId: null, _connectionStatus: 'connected' });
|
||||
|
||||
app._updateConnectionIndicator(); // hidden (display:none)
|
||||
expect(els.connectionIndicator.style.display).toBe('none');
|
||||
const displayWrites = els.connectionIndicator.writes.display;
|
||||
|
||||
app.activeSessionId = 's1';
|
||||
app._wsState = 'connected';
|
||||
app._updateConnectionIndicator(); // now shown
|
||||
|
||||
expect(els.connectionIndicator.writes.display).toBe(displayWrites + 1);
|
||||
expect(els.connectionIndicator.style.display).toBe('flex');
|
||||
expect(els.connectionText.textContent).toBe('WS');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,634 @@
|
||||
/**
|
||||
* @fileoverview Tests for CronService — the CRUD/bookkeeping + due-tick
|
||||
* state machine of the cron. The pure next-run math lives in
|
||||
* cron-time.test.ts; this exercises the service that sits on top of it.
|
||||
*
|
||||
* Launch attempts are steered down the "Session launch failed" path (the mock
|
||||
* deps lack the session-construction config getters) so no real Session/tmux
|
||||
* objects are constructed — we assert the scheduling state machine (due
|
||||
* detection, dedup guard, schedule advance, once-completion, concurrency skip,
|
||||
* auto-close, run-history recording), not the session layer it reuses.
|
||||
*
|
||||
* Port: N/A (no HTTP server).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { existsSync, mkdtempSync, mkdirSync, writeFileSync, symlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { CronService, type CronDeps } from '../src/cron/cron-service.js';
|
||||
import { CronJobSchema } from '../src/web/schemas.js';
|
||||
import { MAX_CRON_JOBS } from '../src/config/map-limits.js';
|
||||
import type { CronJob, CronJobRun } from '../src/types/cron.js';
|
||||
import type { CronJobInput } from '../src/cron/cron-input.js';
|
||||
|
||||
const MISSING_DIR = '/nonexistent-codeman-cron-test-dir';
|
||||
/** A real dir: createJob/updateJob validate workingDir existence up front. */
|
||||
const VALID_DIR = mkdtempSync(join(tmpdir(), 'codeman-cron-wd-'));
|
||||
const flush = (): Promise<void> => new Promise((r) => setImmediate(r));
|
||||
|
||||
type FakeSession = { mode: string; status?: string };
|
||||
|
||||
function makeStore() {
|
||||
const jobs: Record<string, CronJob> = {};
|
||||
const runs: Record<string, CronJobRun> = {};
|
||||
return {
|
||||
getCronJobs: () => jobs,
|
||||
getCronJob: (id: string) => jobs[id] ?? null,
|
||||
setCronJob: (id: string, j: CronJob) => {
|
||||
jobs[id] = j;
|
||||
},
|
||||
removeCronJob: (id: string) => {
|
||||
delete jobs[id];
|
||||
},
|
||||
getCronJobRuns: () => runs,
|
||||
setCronJobRun: (id: string, r: CronJobRun) => {
|
||||
runs[id] = r;
|
||||
},
|
||||
removeCronJobRun: (id: string) => {
|
||||
delete runs[id];
|
||||
},
|
||||
incrementSessionsCreated: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeService(sessions = new Map<string, FakeSession>()) {
|
||||
const store = makeStore();
|
||||
const broadcast = vi.fn();
|
||||
const cleanupSession = vi.fn(async (id: string) => {
|
||||
sessions.delete(id);
|
||||
});
|
||||
const deps = {
|
||||
store,
|
||||
broadcast,
|
||||
sessions,
|
||||
cleanupSession,
|
||||
} as unknown as CronDeps;
|
||||
return { service: new CronService(deps), store, broadcast, sessions, cleanupSession };
|
||||
}
|
||||
|
||||
function mkInput(overrides: Partial<CronJobInput> = {}): CronJobInput {
|
||||
return {
|
||||
name: 'job',
|
||||
agentType: 'claude',
|
||||
workingDir: VALID_DIR,
|
||||
promptMode: 'inline_text',
|
||||
promptText: 'hello',
|
||||
inputMode: 'typed',
|
||||
scheduleType: 'interval',
|
||||
intervalMinutes: 10,
|
||||
enabled: true,
|
||||
concurrencyPolicy: 'warn_only',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CronService', () => {
|
||||
let svc: ReturnType<typeof makeService>;
|
||||
|
||||
beforeEach(() => {
|
||||
svc = makeService();
|
||||
});
|
||||
|
||||
describe('createJob', () => {
|
||||
it('computes nextRunAt for an enabled interval job', () => {
|
||||
const before = Date.now();
|
||||
const job = svc.service.createJob(mkInput({ intervalMinutes: 10 }));
|
||||
expect(job.id).toBeTruthy();
|
||||
expect(job.nextRunAt).not.toBeNull();
|
||||
expect(job.nextRunAt!).toBeGreaterThanOrEqual(before + 10 * 60_000);
|
||||
expect(job.lastRunAt).toBeNull();
|
||||
expect(job.lastStatus).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves nextRunAt null for a disabled job', () => {
|
||||
const job = svc.service.createJob(mkInput({ enabled: false }));
|
||||
expect(job.nextRunAt).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the absolute runAt for a one-time job', () => {
|
||||
const runAt = Date.now() + 3_600_000;
|
||||
const job = svc.service.createJob(mkInput({ scheduleType: 'once', runAt, intervalMinutes: undefined }));
|
||||
expect(job.nextRunAt).toBe(runAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setEnabled', () => {
|
||||
it('clears nextRunAt when disabling and recomputes when re-enabling', () => {
|
||||
const job = svc.service.createJob(mkInput());
|
||||
const disabled = svc.service.setEnabled(job.id, false);
|
||||
expect(disabled!.nextRunAt).toBeNull();
|
||||
const reenabled = svc.service.setEnabled(job.id, true);
|
||||
expect(reenabled!.nextRunAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an unknown id', () => {
|
||||
expect(svc.service.setEnabled('nope', true)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateJob', () => {
|
||||
it('clears the dup-guard and preserves createdAt', () => {
|
||||
const job = svc.service.createJob(mkInput({ intervalMinutes: 10 }));
|
||||
job.lastDueKey = 'stale';
|
||||
svc.store.setCronJob(job.id, job);
|
||||
const updated = svc.service.updateJob(job.id, { name: 'renamed' });
|
||||
expect(updated!.name).toBe('renamed');
|
||||
expect(updated!.lastDueKey).toBeNull();
|
||||
expect(updated!.createdAt).toBe(job.createdAt);
|
||||
});
|
||||
|
||||
it('does NOT re-fire a completed once-job when editing a non-schedule field', async () => {
|
||||
const job = svc.service.createJob(
|
||||
mkInput({ scheduleType: 'once', runAt: Date.now() - 1000, intervalMinutes: undefined })
|
||||
);
|
||||
await svc.service.tickDueJobs(Date.now());
|
||||
await flush();
|
||||
expect(svc.service.getJob(job.id)!.completedOnce).toBe(true);
|
||||
|
||||
const updated = svc.service.updateJob(job.id, { name: 'renamed' });
|
||||
expect(updated!.name).toBe('renamed');
|
||||
// Cosmetic edit must not resurrect a fired one-time job.
|
||||
expect(updated!.completedOnce).toBe(true);
|
||||
expect(updated!.nextRunAt).toBeNull();
|
||||
});
|
||||
|
||||
it('does NOT resurrect a fired once-job when the edit form round-trips the unchanged schedule', async () => {
|
||||
// Reproduces the real UI flow: the edit modal re-sends the FULL job
|
||||
// (scheduleType + runAt unchanged) on every save. A field-presence check
|
||||
// would wrongly re-arm; we compare VALUES, so an unchanged schedule does not.
|
||||
const runAt = Date.now() - 1000;
|
||||
const job = svc.service.createJob(mkInput({ scheduleType: 'once', runAt, intervalMinutes: undefined }));
|
||||
await svc.service.tickDueJobs(Date.now());
|
||||
await flush();
|
||||
expect(svc.service.getJob(job.id)!.completedOnce).toBe(true);
|
||||
|
||||
// Full-body edit changing only the name; schedule values identical.
|
||||
const updated = svc.service.updateJob(job.id, { name: 'renamed', scheduleType: 'once', runAt, enabled: false });
|
||||
expect(updated!.completedOnce).toBe(true);
|
||||
|
||||
// Even re-enabling afterward must not bring the dead job back to life.
|
||||
const reenabled = svc.service.setEnabled(job.id, true);
|
||||
expect(reenabled!.nextRunAt).toBeNull();
|
||||
});
|
||||
|
||||
it('re-arms a completed once-job when the schedule itself is edited', async () => {
|
||||
const job = svc.service.createJob(
|
||||
mkInput({ scheduleType: 'once', runAt: Date.now() - 1000, intervalMinutes: undefined })
|
||||
);
|
||||
await svc.service.tickDueJobs(Date.now());
|
||||
await flush();
|
||||
expect(svc.service.getJob(job.id)!.completedOnce).toBe(true);
|
||||
|
||||
const future = Date.now() + 3_600_000;
|
||||
const updated = svc.service.updateJob(job.id, { runAt: future, enabled: true });
|
||||
expect(updated!.completedOnce).toBe(false);
|
||||
expect(updated!.nextRunAt).toBe(future);
|
||||
});
|
||||
|
||||
it('rejects a partial update that leaves an inconsistent schedule (no dead enabled job)', () => {
|
||||
const job = svc.service.createJob(mkInput({ scheduleType: 'interval', intervalMinutes: 10 }));
|
||||
// Switch to 'once' WITHOUT a runAt → would otherwise yield a dead nextRunAt:null.
|
||||
expect(() => svc.service.updateJob(job.id, { scheduleType: 'once', intervalMinutes: undefined })).toThrow();
|
||||
// The stored job is left untouched.
|
||||
const after = svc.service.getJob(job.id)!;
|
||||
expect(after.scheduleType).toBe('interval');
|
||||
expect(after.nextRunAt).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteJob', () => {
|
||||
it('removes the job and its run history', async () => {
|
||||
const job = svc.service.createJob(
|
||||
mkInput({ scheduleType: 'once', runAt: Date.now() - 1000, intervalMinutes: undefined })
|
||||
);
|
||||
await svc.service.tickDueJobs(Date.now());
|
||||
await flush();
|
||||
expect(svc.service.listRuns(job.id).length).toBe(1);
|
||||
expect(svc.service.deleteJob(job.id)).toBe(true);
|
||||
expect(svc.service.getJob(job.id)).toBeNull();
|
||||
expect(svc.service.listRuns(job.id).length).toBe(0);
|
||||
});
|
||||
|
||||
it('returns false for an unknown id', () => {
|
||||
expect(svc.service.deleteJob('nope')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listRuns', () => {
|
||||
it('returns runs newest-first and filters by job id', async () => {
|
||||
const a = svc.service.createJob(
|
||||
mkInput({ name: 'a', scheduleType: 'once', runAt: Date.now() - 1000, intervalMinutes: undefined })
|
||||
);
|
||||
const b = svc.service.createJob(
|
||||
mkInput({ name: 'b', scheduleType: 'once', runAt: Date.now() - 1000, intervalMinutes: undefined })
|
||||
);
|
||||
await svc.service.runNow(a.id);
|
||||
await svc.service.runNow(b.id);
|
||||
const all = svc.service.listRuns();
|
||||
expect(all.length).toBe(2);
|
||||
expect(all[0].startedAt).toBeGreaterThanOrEqual(all[1].startedAt);
|
||||
expect(svc.service.listRuns(a.id).every((r) => r.cronJobId === a.id)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('recomputes nextRunAt for enabled jobs missing one, but skips a completed once-job', () => {
|
||||
const live = svc.service.createJob(mkInput());
|
||||
live.nextRunAt = null;
|
||||
svc.store.setCronJob(live.id, live);
|
||||
|
||||
const dead = svc.service.createJob(
|
||||
mkInput({ scheduleType: 'once', runAt: Date.now(), intervalMinutes: undefined })
|
||||
);
|
||||
dead.completedOnce = true;
|
||||
dead.nextRunAt = null;
|
||||
svc.store.setCronJob(dead.id, dead);
|
||||
|
||||
svc.service.init();
|
||||
expect(svc.service.getJob(live.id)!.nextRunAt).not.toBeNull();
|
||||
expect(svc.service.getJob(dead.id)!.nextRunAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tickDueJobs', () => {
|
||||
it('fires a due one-time job exactly once and disables it', async () => {
|
||||
const runAt = Date.now() - 5000;
|
||||
const job = svc.service.createJob(mkInput({ scheduleType: 'once', runAt, intervalMinutes: undefined }));
|
||||
|
||||
await svc.service.tickDueJobs(Date.now());
|
||||
await flush();
|
||||
|
||||
const after = svc.service.getJob(job.id)!;
|
||||
expect(after.completedOnce).toBe(true);
|
||||
expect(after.enabled).toBe(false);
|
||||
expect(after.nextRunAt).toBeNull();
|
||||
const runs = svc.service.listRuns(job.id);
|
||||
expect(runs.length).toBe(1);
|
||||
expect(runs[0].status).toBe('failed'); // mock deps can't construct a Session → fails at launch
|
||||
|
||||
// A second tick must not re-fire it.
|
||||
await svc.service.tickDueJobs(Date.now());
|
||||
await flush();
|
||||
expect(svc.service.listRuns(job.id).length).toBe(1);
|
||||
});
|
||||
|
||||
it('advances an interval job to a future nextRunAt after firing', async () => {
|
||||
const job = svc.service.createJob(mkInput({ intervalMinutes: 10 }));
|
||||
const fireAt = job.nextRunAt! + 1000;
|
||||
|
||||
await svc.service.tickDueJobs(fireAt);
|
||||
await flush();
|
||||
|
||||
const after = svc.service.getJob(job.id)!;
|
||||
expect(after.enabled).toBe(true);
|
||||
expect(after.nextRunAt!).toBeGreaterThan(fireAt);
|
||||
expect(after.lastDueKey).not.toBeNull();
|
||||
expect(svc.service.listRuns(job.id).length).toBe(1);
|
||||
});
|
||||
|
||||
it('does not fire a job whose nextRunAt is still in the future', async () => {
|
||||
const job = svc.service.createJob(mkInput({ intervalMinutes: 60 }));
|
||||
await svc.service.tickDueJobs(Date.now());
|
||||
await flush();
|
||||
expect(svc.service.listRuns(job.id).length).toBe(0);
|
||||
});
|
||||
|
||||
it('skips an automatic run when concurrency policy is skip_if_same_agent_running', async () => {
|
||||
const sessions = new Map<string, { mode: string }>([['s1', { mode: 'claude' }]]);
|
||||
const local = makeService(sessions);
|
||||
const job = local.service.createJob(
|
||||
mkInput({ agentType: 'claude', concurrencyPolicy: 'skip_if_same_agent_running', intervalMinutes: 10 })
|
||||
);
|
||||
const fireAt = job.nextRunAt! + 1000;
|
||||
|
||||
await local.service.tickDueJobs(fireAt);
|
||||
await flush();
|
||||
|
||||
// A 'skipped' run is recorded (so the history isn't silently empty), and
|
||||
// the schedule still advanced past the skipped slot.
|
||||
const runs = local.service.listRuns(job.id);
|
||||
expect(runs.length).toBe(1);
|
||||
expect(runs[0].status).toBe('skipped');
|
||||
const after = local.service.getJob(job.id)!;
|
||||
expect(after.lastStatus).toBe('skipped');
|
||||
// A skip is not a run: lastRunAt must NOT advance.
|
||||
expect(after.lastRunAt).toBeNull();
|
||||
expect(after.nextRunAt!).toBeGreaterThan(fireAt);
|
||||
expect(after.lastDueKey).not.toBeNull();
|
||||
});
|
||||
|
||||
it('coalesces consecutive skips — a perpetually-skipped job does not flood run history', async () => {
|
||||
const sessions = new Map<string, { mode: string }>([['s1', { mode: 'claude' }]]);
|
||||
const local = makeService(sessions);
|
||||
const job = local.service.createJob(
|
||||
mkInput({ agentType: 'claude', concurrencyPolicy: 'skip_if_same_agent_running', intervalMinutes: 10 })
|
||||
);
|
||||
|
||||
// Drive 50 due ticks; the same-mode session keeps it skipped every time.
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const due = local.service.getJob(job.id)!.nextRunAt! + 1;
|
||||
await local.service.tickDueJobs(due);
|
||||
await flush();
|
||||
}
|
||||
|
||||
// Exactly ONE skipped run is recorded for the whole skip streak.
|
||||
expect(local.service.listRuns(job.id).length).toBe(1);
|
||||
expect(local.service.listRuns(job.id)[0].status).toBe('skipped');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePrompt path guard', () => {
|
||||
// A real workspace dir for the in-workspace / confinement cases.
|
||||
let ws: string;
|
||||
beforeEach(() => {
|
||||
ws = mkdtempSync(join(tmpdir(), 'codeman-cron-ws-'));
|
||||
});
|
||||
|
||||
const fileJob = (promptFilePath: string, workingDir: string) =>
|
||||
svc.service.createJob(
|
||||
mkInput({ promptMode: 'prompt_file_path', promptFilePath, promptText: undefined, workingDir })
|
||||
);
|
||||
|
||||
it('blocks a sensitive system file via the blocklist (/etc/passwd)', async () => {
|
||||
const run = await svc.service.runNow(fileJob('/etc/passwd', ws).id);
|
||||
expect(run!.status).toBe('failed');
|
||||
// Fails at prompt resolution (blocked) — content is never read.
|
||||
expect(run!.errorMessage).toMatch(/Prompt error/i);
|
||||
expect(run!.errorMessage).toMatch(/block/i);
|
||||
expect(svc.sessions.size).toBe(0);
|
||||
});
|
||||
|
||||
it('blocks /proc/self/environ (server-process env exfil) via the pseudo-fs blocklist', async () => {
|
||||
const run = await svc.service.runNow(fileJob('/proc/self/environ', ws).id);
|
||||
expect(run!.status).toBe('failed');
|
||||
expect(run!.errorMessage).toMatch(/Prompt error/i);
|
||||
expect(run!.errorMessage).toMatch(/block/i);
|
||||
});
|
||||
|
||||
it.skipIf(!existsSync('/proc/self/environ'))(
|
||||
'blocks the workingDir=/proc + /proc/self/environ confinement bypass at fire time',
|
||||
async () => {
|
||||
// Create-time validation rejects a /proc workingDir, so simulate a
|
||||
// legacy/hand-edited job by mutating the stored record directly.
|
||||
const job = fileJob('/proc/self/environ', ws);
|
||||
const stored = svc.store.getCronJob(job.id)!;
|
||||
stored.workingDir = '/proc';
|
||||
svc.store.setCronJob(stored.id, stored);
|
||||
const run = await svc.service.runNow(job.id);
|
||||
expect(run!.status).toBe('failed');
|
||||
expect(run!.errorMessage).toMatch(/Prompt error/i);
|
||||
expect(run!.errorMessage).toMatch(/blocked/i);
|
||||
}
|
||||
);
|
||||
|
||||
it('blocks a regular file that lives OUTSIDE the job workspace', async () => {
|
||||
const outside = mkdtempSync(join(tmpdir(), 'codeman-cron-outside-'));
|
||||
const file = join(outside, 'prompt.md');
|
||||
writeFileSync(file, 'do the thing');
|
||||
const run = await svc.service.runNow(fileJob(file, ws).id);
|
||||
expect(run!.status).toBe('failed');
|
||||
expect(run!.errorMessage).toMatch(/inside the job working directory/i);
|
||||
});
|
||||
|
||||
it('blocks a non-regular file (directory) inside the workspace', async () => {
|
||||
const sub = join(ws, 'adir');
|
||||
mkdirSync(sub);
|
||||
const run = await svc.service.runNow(fileJob(sub, ws).id);
|
||||
expect(run!.status).toBe('failed');
|
||||
expect(run!.errorMessage).toMatch(/not a regular file/i);
|
||||
});
|
||||
|
||||
it('blocks an oversized prompt file (unbounded-read DoS)', async () => {
|
||||
const file = join(ws, 'huge.md');
|
||||
writeFileSync(file, Buffer.alloc(1024 * 1024 + 1, 0x61));
|
||||
const run = await svc.service.runNow(fileJob(file, ws).id);
|
||||
expect(run!.status).toBe('failed');
|
||||
expect(run!.errorMessage).toMatch(/too large/i);
|
||||
});
|
||||
|
||||
it('fails cleanly (no throw) when the prompt file does not exist', async () => {
|
||||
const run = await svc.service.runNow(fileJob(join(ws, 'nope.md'), ws).id);
|
||||
expect(run!.status).toBe('failed');
|
||||
expect(run!.errorMessage).toMatch(/Prompt error/i);
|
||||
});
|
||||
|
||||
it('allows a regular prompt file INSIDE the job workspace (passes resolution)', async () => {
|
||||
const file = join(ws, 'prompt.md');
|
||||
writeFileSync(file, 'do the thing');
|
||||
const run = await svc.service.runNow(fileJob(file, ws).id);
|
||||
// Got past prompt resolution + workingDir checks; fails only at the
|
||||
// (mock-incomplete) session-launch step — NOT a prompt error.
|
||||
expect(run!.status).toBe('failed');
|
||||
expect(run!.errorMessage).not.toMatch(/Prompt error/i);
|
||||
expect(run!.errorMessage).toMatch(/Session launch failed/i);
|
||||
});
|
||||
|
||||
it('blocks a symlink inside the workspace that escapes to /etc/passwd', async () => {
|
||||
const link = join(ws, 'sneaky.md');
|
||||
symlinkSync('/etc/passwd', link);
|
||||
const run = await svc.service.runNow(fileJob(link, ws).id);
|
||||
expect(run!.status).toBe('failed');
|
||||
expect(run!.errorMessage).toMatch(/Prompt error/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runNow', () => {
|
||||
it('launches regardless of enabled/schedule state', async () => {
|
||||
const job = svc.service.createJob(mkInput({ enabled: false }));
|
||||
const run = await svc.service.runNow(job.id);
|
||||
expect(run).not.toBeNull();
|
||||
expect(run!.triggerType).toBe('manual_run_now');
|
||||
// Disabled job stays disabled; a manual run doesn't arm the schedule.
|
||||
expect(svc.service.getJob(job.id)!.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null for an unknown id', async () => {
|
||||
expect(await svc.service.runNow('nope')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('workingDir validation (create/update)', () => {
|
||||
it('rejects a nonexistent workingDir at create', () => {
|
||||
expect(() => svc.service.createJob(mkInput({ workingDir: MISSING_DIR }))).toThrow(/does not exist/);
|
||||
});
|
||||
|
||||
it('rejects blocked trees and the filesystem root at create', () => {
|
||||
expect(() => svc.service.createJob(mkInput({ workingDir: '/etc' }))).toThrow(/not allowed/);
|
||||
expect(() => svc.service.createJob(mkInput({ workingDir: '/' }))).toThrow(/not allowed/);
|
||||
});
|
||||
|
||||
it('rejects an invalid workingDir on update and leaves the job untouched', () => {
|
||||
const job = svc.service.createJob(mkInput());
|
||||
expect(() => svc.service.updateJob(job.id, { workingDir: MISSING_DIR })).toThrow(/does not exist/);
|
||||
expect(svc.service.getJob(job.id)!.workingDir).toBe(VALID_DIR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('single-line prompt enforcement', () => {
|
||||
it('schema rejects a multi-line promptText and launchCommand', () => {
|
||||
expect(CronJobSchema.safeParse(mkInput({ promptText: 'a\nb' })).success).toBe(false);
|
||||
expect(CronJobSchema.safeParse(mkInput({ agentType: 'shell', launchCommand: 'a\nb' })).success).toBe(false);
|
||||
expect(CronJobSchema.safeParse(mkInput()).success).toBe(true);
|
||||
});
|
||||
|
||||
it('fails the run when a legacy job carries a multi-line promptText', async () => {
|
||||
const job = svc.service.createJob(mkInput());
|
||||
const stored = svc.store.getCronJob(job.id)!;
|
||||
stored.promptText = 'line one\nline two';
|
||||
svc.store.setCronJob(stored.id, stored);
|
||||
const run = await svc.service.runNow(job.id);
|
||||
expect(run!.status).toBe('failed');
|
||||
expect(run!.errorMessage).toMatch(/single line/i);
|
||||
});
|
||||
|
||||
it('fails the run when the prompt file is multi-line', async () => {
|
||||
const ws = mkdtempSync(join(tmpdir(), 'codeman-cron-ml-'));
|
||||
const file = join(ws, 'prompt.md');
|
||||
writeFileSync(file, 'line one\nline two\n');
|
||||
const job = svc.service.createJob(
|
||||
mkInput({ promptMode: 'prompt_file_path', promptFilePath: file, promptText: undefined, workingDir: ws })
|
||||
);
|
||||
const run = await svc.service.runNow(job.id);
|
||||
expect(run!.status).toBe('failed');
|
||||
expect(run!.errorMessage).toMatch(/single line/i);
|
||||
});
|
||||
|
||||
it('tolerates trailing newlines in a prompt file (every editor writes one)', async () => {
|
||||
const ws = mkdtempSync(join(tmpdir(), 'codeman-cron-tn-'));
|
||||
const file = join(ws, 'prompt.md');
|
||||
writeFileSync(file, 'do the thing\n');
|
||||
const job = svc.service.createJob(
|
||||
mkInput({ promptMode: 'prompt_file_path', promptFilePath: file, promptText: undefined, workingDir: ws })
|
||||
);
|
||||
const run = await svc.service.runNow(job.id);
|
||||
// Past prompt resolution; fails only at the mock-incomplete session step.
|
||||
expect(run!.errorMessage).toMatch(/Session launch failed/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrency-skip session filtering', () => {
|
||||
const prevRun = (jobId: string, sessionId: string): CronJobRun => ({
|
||||
id: `r-${sessionId}`,
|
||||
cronJobId: jobId,
|
||||
sessionId,
|
||||
sessionName: 'job',
|
||||
startedAt: Date.now() - 60_000,
|
||||
finishedAt: Date.now() - 59_000,
|
||||
status: 'prompt_sent',
|
||||
triggerType: 'scheduled',
|
||||
createdSessionUrl: null,
|
||||
});
|
||||
|
||||
it('does not skip when the only same-mode sessions are stopped/error (dead tabs)', async () => {
|
||||
const sessions = new Map<string, FakeSession>([
|
||||
['dead1', { mode: 'claude', status: 'stopped' }],
|
||||
['dead2', { mode: 'claude', status: 'error' }],
|
||||
]);
|
||||
const local = makeService(sessions);
|
||||
const job = local.service.createJob(
|
||||
mkInput({ concurrencyPolicy: 'skip_if_same_agent_running', intervalMinutes: 10 })
|
||||
);
|
||||
await local.service.tickDueJobs(job.nextRunAt! + 1000);
|
||||
await flush();
|
||||
const runs = local.service.listRuns(job.id);
|
||||
expect(runs.length).toBe(1);
|
||||
expect(runs[0].status).toBe('failed'); // launched (mock session step), NOT skipped
|
||||
});
|
||||
|
||||
it("does not skip on the job's own previous-run session (no self-deadlock)", async () => {
|
||||
const sessions = new Map<string, FakeSession>([['own-1', { mode: 'claude' }]]);
|
||||
const local = makeService(sessions);
|
||||
const job = local.service.createJob(
|
||||
mkInput({ concurrencyPolicy: 'skip_if_same_agent_running', intervalMinutes: 10 })
|
||||
);
|
||||
local.store.setCronJobRun('r-own-1', prevRun(job.id, 'own-1'));
|
||||
await local.service.tickDueJobs(job.nextRunAt! + 1000);
|
||||
await flush();
|
||||
expect(local.service.listRuns(job.id)[0].status).not.toBe('skipped');
|
||||
});
|
||||
|
||||
it('does not consume a skipped once-job — it stays armed and fires when unblocked', async () => {
|
||||
const sessions = new Map<string, FakeSession>([['s1', { mode: 'claude' }]]);
|
||||
const local = makeService(sessions);
|
||||
const runAt = Date.now() - 1000;
|
||||
const job = local.service.createJob(
|
||||
mkInput({
|
||||
scheduleType: 'once',
|
||||
runAt,
|
||||
intervalMinutes: undefined,
|
||||
concurrencyPolicy: 'skip_if_same_agent_running',
|
||||
})
|
||||
);
|
||||
|
||||
await local.service.tickDueJobs(Date.now());
|
||||
await flush();
|
||||
let after = local.service.getJob(job.id)!;
|
||||
expect(after.lastStatus).toBe('skipped');
|
||||
expect(after.completedOnce).toBeFalsy();
|
||||
expect(after.enabled).toBe(true);
|
||||
expect(after.nextRunAt).toBe(runAt); // still armed
|
||||
|
||||
// The blocking session goes away → the next tick fires the single run.
|
||||
sessions.delete('s1');
|
||||
await local.service.tickDueJobs(Date.now());
|
||||
await flush();
|
||||
after = local.service.getJob(job.id)!;
|
||||
expect(after.completedOnce).toBe(true);
|
||||
expect(after.enabled).toBe(false);
|
||||
expect(local.service.listRuns(job.id).some((r) => r.status === 'failed')).toBe(true); // it launched
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoClosePreviousSession', () => {
|
||||
const prevRun = (jobId: string, sessionId: string): CronJobRun => ({
|
||||
id: `r-${sessionId}`,
|
||||
cronJobId: jobId,
|
||||
sessionId,
|
||||
sessionName: 'job',
|
||||
startedAt: Date.now() - 60_000,
|
||||
finishedAt: Date.now() - 59_000,
|
||||
status: 'prompt_sent',
|
||||
triggerType: 'scheduled',
|
||||
createdSessionUrl: null,
|
||||
});
|
||||
|
||||
it("closes the previous run's still-open session before launching (default on)", async () => {
|
||||
const sessions = new Map<string, FakeSession>([['prev-1', { mode: 'claude' }]]);
|
||||
const local = makeService(sessions);
|
||||
const job = local.service.createJob(mkInput({ intervalMinutes: 10 }));
|
||||
local.store.setCronJobRun('r-prev-1', prevRun(job.id, 'prev-1'));
|
||||
await local.service.runNow(job.id);
|
||||
expect(local.cleanupSession).toHaveBeenCalledWith('prev-1', true, expect.stringContaining('cron'));
|
||||
expect(sessions.has('prev-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not close anything when autoClosePreviousSession is false', async () => {
|
||||
const sessions = new Map<string, FakeSession>([['prev-1', { mode: 'claude' }]]);
|
||||
const local = makeService(sessions);
|
||||
const job = local.service.createJob(mkInput({ intervalMinutes: 10, autoClosePreviousSession: false }));
|
||||
local.store.setCronJobRun('r-prev-1', prevRun(job.id, 'prev-1'));
|
||||
await local.service.runNow(job.id);
|
||||
expect(local.cleanupSession).not.toHaveBeenCalled();
|
||||
expect(sessions.has('prev-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('never auto-closes for a once schedule', async () => {
|
||||
const sessions = new Map<string, FakeSession>([['prev-1', { mode: 'claude' }]]);
|
||||
const local = makeService(sessions);
|
||||
const job = local.service.createJob(
|
||||
mkInput({ scheduleType: 'once', runAt: Date.now() + 3_600_000, intervalMinutes: undefined })
|
||||
);
|
||||
local.store.setCronJobRun('r-prev-1', prevRun(job.id, 'prev-1'));
|
||||
await local.service.runNow(job.id);
|
||||
expect(local.cleanupSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('job-count cap', () => {
|
||||
it(`rejects creation beyond MAX_CRON_JOBS (${MAX_CRON_JOBS})`, () => {
|
||||
for (let i = 0; i < MAX_CRON_JOBS; i++) svc.service.createJob(mkInput({ name: `j${i}` }));
|
||||
expect(() => svc.service.createJob(mkInput({ name: 'overflow' }))).toThrow(/Maximum number of cron jobs/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Unit tests for the cron's pure next-run-time calculations.
|
||||
* Timezone-independent: daily/weekly expectations are asserted via local
|
||||
* Date getters rather than hardcoded epoch values.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseHHMM, computeNextRunAt, dueKeyFor } from '../src/cron/cron-time.js';
|
||||
import type { CronJob } from '../src/types/cron.js';
|
||||
|
||||
function baseJob(partial: Partial<CronJob>): CronJob {
|
||||
return {
|
||||
id: 'j1',
|
||||
name: 'test',
|
||||
agentType: 'claude',
|
||||
workingDir: '/tmp',
|
||||
promptMode: 'inline_text',
|
||||
promptText: 'hi',
|
||||
inputMode: 'typed',
|
||||
scheduleType: 'once',
|
||||
enabled: true,
|
||||
concurrencyPolicy: 'warn_only',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
lastRunAt: null,
|
||||
nextRunAt: null,
|
||||
lastStatus: null,
|
||||
lastDueKey: null,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('parseHHMM', () => {
|
||||
it('parses valid 24h times', () => {
|
||||
expect(parseHHMM('09:30')).toEqual({ hours: 9, minutes: 30 });
|
||||
expect(parseHHMM('23:59')).toEqual({ hours: 23, minutes: 59 });
|
||||
expect(parseHHMM('0:00')).toEqual({ hours: 0, minutes: 0 });
|
||||
});
|
||||
it('rejects invalid times', () => {
|
||||
expect(parseHHMM('24:00')).toBeNull();
|
||||
expect(parseHHMM('12:60')).toBeNull();
|
||||
expect(parseHHMM('9:5')).toBeNull(); // minutes must be 2 digits
|
||||
expect(parseHHMM('abc')).toBeNull();
|
||||
expect(parseHHMM(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeNextRunAt — once', () => {
|
||||
it('returns runAt even when already in the past (missed one-time job still fires)', () => {
|
||||
const job = baseJob({ scheduleType: 'once', runAt: 1000 });
|
||||
expect(computeNextRunAt(job, 500)).toBe(1000);
|
||||
expect(computeNextRunAt(job, 5000)).toBe(1000);
|
||||
});
|
||||
it('returns null once completed', () => {
|
||||
const job = baseJob({ scheduleType: 'once', runAt: 1000, completedOnce: true });
|
||||
expect(computeNextRunAt(job, 500)).toBeNull();
|
||||
});
|
||||
it('returns null with no runAt', () => {
|
||||
expect(computeNextRunAt(baseJob({ scheduleType: 'once' }), 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeNextRunAt — interval', () => {
|
||||
it('adds intervalMinutes to the after time', () => {
|
||||
const job = baseJob({ scheduleType: 'interval', intervalMinutes: 60 });
|
||||
expect(computeNextRunAt(job, 1000)).toBe(1000 + 60 * 60_000);
|
||||
});
|
||||
it('returns null with no/invalid interval', () => {
|
||||
expect(computeNextRunAt(baseJob({ scheduleType: 'interval' }), 0)).toBeNull();
|
||||
expect(computeNextRunAt(baseJob({ scheduleType: 'interval', intervalMinutes: 0 }), 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeNextRunAt — daily', () => {
|
||||
it('schedules today when the time is still ahead', () => {
|
||||
const after = new Date(2026, 0, 1, 10, 0, 0).getTime();
|
||||
const next = computeNextRunAt(baseJob({ scheduleType: 'daily', dailyTime: '14:30' }), after)!;
|
||||
const d = new Date(next);
|
||||
expect(d.getHours()).toBe(14);
|
||||
expect(d.getMinutes()).toBe(30);
|
||||
expect(d.getDate()).toBe(1);
|
||||
expect(next).toBeGreaterThan(after);
|
||||
});
|
||||
it('rolls to tomorrow when the time has passed', () => {
|
||||
const after = new Date(2026, 0, 1, 16, 0, 0).getTime();
|
||||
const next = computeNextRunAt(baseJob({ scheduleType: 'daily', dailyTime: '14:30' }), after)!;
|
||||
const d = new Date(next);
|
||||
expect(d.getHours()).toBe(14);
|
||||
expect(d.getDate()).toBe(2);
|
||||
expect(next).toBeGreaterThan(after);
|
||||
});
|
||||
it('returns null with no time', () => {
|
||||
expect(computeNextRunAt(baseJob({ scheduleType: 'daily' }), 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeNextRunAt — weekly', () => {
|
||||
it('finds the next selected weekday at the configured time', () => {
|
||||
const after = new Date(2026, 0, 1, 12, 0, 0).getTime();
|
||||
const targetDay = (new Date(after).getDay() + 2) % 7;
|
||||
const job = baseJob({ scheduleType: 'weekly', weeklyDays: [targetDay], weeklyTime: '08:00' });
|
||||
const next = computeNextRunAt(job, after)!;
|
||||
const d = new Date(next);
|
||||
expect(d.getDay()).toBe(targetDay);
|
||||
expect(d.getHours()).toBe(8);
|
||||
expect(next).toBeGreaterThan(after);
|
||||
// Within the coming week.
|
||||
expect(next - after).toBeLessThanOrEqual(7 * 24 * 60 * 60_000);
|
||||
});
|
||||
it('picks the soonest of multiple selected days', () => {
|
||||
const after = new Date(2026, 0, 1, 12, 0, 0).getTime();
|
||||
const soon = (new Date(after).getDay() + 1) % 7;
|
||||
const later = (new Date(after).getDay() + 3) % 7;
|
||||
const job = baseJob({ scheduleType: 'weekly', weeklyDays: [later, soon], weeklyTime: '09:00' });
|
||||
const next = computeNextRunAt(job, after)!;
|
||||
expect(new Date(next).getDay()).toBe(soon);
|
||||
});
|
||||
it('returns null with no days or no time', () => {
|
||||
expect(computeNextRunAt(baseJob({ scheduleType: 'weekly', weeklyTime: '09:00' }), 0)).toBeNull();
|
||||
expect(computeNextRunAt(baseJob({ scheduleType: 'weekly', weeklyDays: [1] }), 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dueKeyFor', () => {
|
||||
it('combines job id and fire time', () => {
|
||||
expect(dueKeyFor('j1', 123)).toBe('j1:123');
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,22 @@ describe('document-thumbnailer', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes through generated image formats with per-extension content types', async () => {
|
||||
const expectations: Array<[string, string]> = [
|
||||
['jpg', 'image/jpeg'],
|
||||
['jpeg', 'image/jpeg'],
|
||||
['gif', 'image/gif'],
|
||||
['webp', 'image/webp'],
|
||||
['png', 'image/png'],
|
||||
];
|
||||
|
||||
for (const [ext, contentType] of expectations) {
|
||||
const result = await generateFirstPageThumbnail(`/tmp/mockup.${ext}`, ext);
|
||||
expect(result).toEqual({ content: Buffer.from('large thumbnail'), contentType });
|
||||
}
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders Office thumbnails from the cached converted PDF after conversion cleanup', async () => {
|
||||
mockedMkdtemp.mockImplementation(async (prefix) =>
|
||||
String(prefix).includes('codeman-document-preview-cache')
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
isAllowedGeneratedArtifactPath,
|
||||
registerGeneratedArtifactAttachment,
|
||||
} from '../src/generated-artifact-attachments.js';
|
||||
import { attachmentRegistry } from '../src/attachment-registry.js';
|
||||
|
||||
describe('generated artifact attachments', () => {
|
||||
it('allows workspace artifacts and home-anchored Codex generated image directories', () => {
|
||||
const home = homedir();
|
||||
expect(isAllowedGeneratedArtifactPath('/repo/out/mockup.png', '/repo')).toBe(true);
|
||||
expect(
|
||||
isAllowedGeneratedArtifactPath(join(home, '.codex-personal', 'generated_images', 'mockup.png'), '/repo')
|
||||
).toBe(true);
|
||||
expect(isAllowedGeneratedArtifactPath(join(home, '.codex', 'generated_artifacts', 'report.pdf'), '/repo')).toBe(
|
||||
true
|
||||
);
|
||||
expect(isAllowedGeneratedArtifactPath('/etc/secret.png', '/repo')).toBe(false);
|
||||
expect(
|
||||
isAllowedGeneratedArtifactPath(
|
||||
join(home, '.codex-personal', 'generated_images', '..', '..', '.ssh', 'id_rsa.png'),
|
||||
'/repo'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects .codex marker directories that are not anchored at the user home', () => {
|
||||
expect(isAllowedGeneratedArtifactPath('/var/tmp/staging/.codex/generated_images/leak.png', '/repo')).toBe(false);
|
||||
expect(isAllowedGeneratedArtifactPath('/var/tmp/.codex-personal/generated_artifacts/leak.md', '/repo')).toBe(false);
|
||||
});
|
||||
|
||||
describe('symlink resolution', () => {
|
||||
let workspaceDir: string | undefined;
|
||||
let outsideDir: string | undefined;
|
||||
const sessionId = 'generated-artifact-symlink-test';
|
||||
|
||||
afterEach(async () => {
|
||||
attachmentRegistry.clearSession(sessionId);
|
||||
for (const dir of [workspaceDir, outsideDir]) {
|
||||
if (dir) await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
workspaceDir = undefined;
|
||||
outsideDir = undefined;
|
||||
});
|
||||
|
||||
it('confines on the resolved path: a workspace symlink to an outside file is rejected', async () => {
|
||||
// realpath so a symlinked tmpdir (e.g. macOS /var -> /private/var) can't skew containment checks
|
||||
workspaceDir = await fs.realpath(await fs.mkdtemp(join(tmpdir(), 'codeman-genart-ws-')));
|
||||
outsideDir = await fs.realpath(await fs.mkdtemp(join(tmpdir(), 'codeman-genart-out-')));
|
||||
const outsideFile = join(outsideDir, 'private-notes.md');
|
||||
await fs.writeFile(outsideFile, 'secret');
|
||||
const linkPath = join(workspaceDir, 'x.md');
|
||||
await fs.symlink(outsideFile, linkPath);
|
||||
|
||||
await expect(
|
||||
registerGeneratedArtifactAttachment({ sessionId, filePath: linkPath, sessionWorkingDir: workspaceDir })
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('registers a real workspace file', async () => {
|
||||
workspaceDir = await fs.realpath(await fs.mkdtemp(join(tmpdir(), 'codeman-genart-ws-')));
|
||||
const filePath = join(workspaceDir, 'mockup.png');
|
||||
await fs.writeFile(filePath, 'png-bytes');
|
||||
|
||||
const event = await registerGeneratedArtifactAttachment({
|
||||
sessionId,
|
||||
filePath,
|
||||
sessionWorkingDir: workspaceDir,
|
||||
});
|
||||
|
||||
expect(event.fileName).toBe('mockup.png');
|
||||
expect(event.attachmentType).toBe('image');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @fileoverview Tests for the HEIC → JPEG conversion core (heic-jpeg-worker.ts).
|
||||
*
|
||||
* Exercises the REAL heic-decode WASM parse path (no mocks) for the
|
||||
* decompression-bomb guard: a crafted <300-byte HEIC can declare arbitrary
|
||||
* dimensions in its `ispe` box, and heic-decode's plain decode path allocates
|
||||
* `width * height * 4` bytes straight from those header values (30000×30000 →
|
||||
* a 3.6GB allocation). The guard must reject via the allocation-free `.all`
|
||||
* dimension read BEFORE decode().
|
||||
*
|
||||
* Port: N/A (pure module test, no server).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { convertHeicBufferToJpeg, MAX_HEIC_DECODE_PIXELS } from '../src/web/heic-jpeg-worker.js';
|
||||
|
||||
// ── Minimal ISOBMFF/HEIF builder — just enough boxes (ftyp/meta/hdlr/pitm/
|
||||
// iloc/iinf/iprp[hvcC+ispe]/mdat) for libheif to parse the image handle and
|
||||
// report the ispe-declared dimensions. There is no real HEVC bitstream, so
|
||||
// pixel decode of these files always fails — which is the point: the guard
|
||||
// must fire before any decode is attempted.
|
||||
|
||||
function box(type: string, ...payloads: (Buffer | string)[]): Buffer {
|
||||
const payload = Buffer.concat(payloads.map((p) => (Buffer.isBuffer(p) ? p : Buffer.from(p))));
|
||||
const header = Buffer.alloc(8);
|
||||
header.writeUInt32BE(8 + payload.length, 0);
|
||||
header.write(type, 4, 'ascii');
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
|
||||
function fullbox(type: string, version: number, flags: number, ...payloads: (Buffer | string)[]): Buffer {
|
||||
const vf = Buffer.alloc(4);
|
||||
vf.writeUInt32BE((version << 24) | flags, 0);
|
||||
return box(type, vf, ...payloads);
|
||||
}
|
||||
|
||||
function u16(n: number): Buffer {
|
||||
const b = Buffer.alloc(2);
|
||||
b.writeUInt16BE(n, 0);
|
||||
return b;
|
||||
}
|
||||
|
||||
function u32(n: number): Buffer {
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeUInt32BE(n, 0);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** Craft a HEIC container whose header declares `width`×`height`. */
|
||||
function craftHeic(width: number, height: number): Buffer {
|
||||
const ftyp = box('ftyp', 'heic', u32(0), 'mif1heic');
|
||||
const hdlr = fullbox('hdlr', 0, 0, u32(0), 'pict', u32(0), u32(0), u32(0), Buffer.from([0]));
|
||||
const pitm = fullbox('pitm', 0, 0, u16(1));
|
||||
const infe = fullbox('infe', 2, 0, u16(1), u16(0), 'hvc1', Buffer.from([0]));
|
||||
const iinf = fullbox('iinf', 0, 0, u16(1), infe);
|
||||
const ispe = fullbox('ispe', 0, 0, u32(width), u32(height));
|
||||
// Minimal HEVCDecoderConfigurationRecord (23 bytes, zero parameter-set arrays).
|
||||
const hvcC = box(
|
||||
'hvcC',
|
||||
Buffer.from([
|
||||
0x01, 0x01, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5d, 0xf0, 0x00, 0xfc, 0xfd, 0xf8, 0xf8, 0x00,
|
||||
0x00, 0x03, 0x00,
|
||||
]),
|
||||
Buffer.from([0x00])
|
||||
);
|
||||
const ipco = box('ipco', hvcC, ispe);
|
||||
const ipma = fullbox('ipma', 0, 0, u32(1), u16(1), Buffer.from([2]), Buffer.from([0x81, 0x02]));
|
||||
const iprp = box('iprp', ipco, ipma);
|
||||
// iloc v0: offset_size=4, length_size=4, base_offset_size=0; one extent in mdat.
|
||||
const ilocItem = Buffer.concat([u16(1), u16(0), u16(1), u32(0), u32(16)]);
|
||||
const iloc = fullbox('iloc', 0, 0, Buffer.from([0x44, 0x00]), u16(1), ilocItem);
|
||||
const meta = fullbox('meta', 0, 0, hdlr, pitm, iloc, iinf, iprp);
|
||||
const mdat = box('mdat', Buffer.alloc(16));
|
||||
return Buffer.concat([ftyp, meta, mdat]);
|
||||
}
|
||||
|
||||
describe('heic-jpeg-core', () => {
|
||||
it('rejects a crafted bomb header (30000×30000 declared, 3.6GB decode) before decoding', async () => {
|
||||
const bomb = craftHeic(30000, 30000);
|
||||
expect(bomb.length).toBeLessThan(1024); // tiny input, huge declared output
|
||||
await expect(convertHeicBufferToJpeg(bomb)).rejects.toThrow(/30000x30000 exceed the 64MP decode limit/);
|
||||
});
|
||||
|
||||
it('rejects dimensions just over the cap', async () => {
|
||||
// 8000×8001 = 64,008,000 px — barely over MAX_HEIC_DECODE_PIXELS (64MP).
|
||||
expect(8000 * 8001).toBeGreaterThan(MAX_HEIC_DECODE_PIXELS);
|
||||
await expect(convertHeicBufferToJpeg(craftHeic(8000, 8001))).rejects.toThrow(/decode limit/);
|
||||
});
|
||||
|
||||
it('lets dimensions under the cap through the guard (failure, if any, comes from pixel decode)', async () => {
|
||||
// The crafted file has no real HEVC bitstream, so decode fails — but NOT
|
||||
// with the dimension-limit error, proving the guard ran and passed.
|
||||
await expect(convertHeicBufferToJpeg(craftHeic(100, 100))).rejects.toThrow(/^(?!.*decode limit).*$/);
|
||||
});
|
||||
|
||||
it('rejects non-HEIC bytes', async () => {
|
||||
await expect(convertHeicBufferToJpeg(Buffer.from('this is definitely not a HEIC image'))).rejects.toThrow(
|
||||
/not a HEIC image/i
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes decoded RGBA into JPEG bytes with valid magic (heic-decode mocked, real jpeg-js)', async () => {
|
||||
const dispose = vi.fn();
|
||||
const handle = {
|
||||
width: 2,
|
||||
height: 2,
|
||||
decode: async () => ({ width: 2, height: 2, data: new Uint8ClampedArray(16).fill(128) }),
|
||||
};
|
||||
vi.doMock('heic-decode', () => {
|
||||
const decode = Object.assign(async () => handle.decode(), {
|
||||
all: async () => Object.assign([handle], { dispose }),
|
||||
});
|
||||
return { default: decode };
|
||||
});
|
||||
try {
|
||||
const jpeg = await convertHeicBufferToJpeg(Buffer.from('mock input'));
|
||||
expect(jpeg[0]).toBe(0xff);
|
||||
expect(jpeg[1]).toBe(0xd8);
|
||||
expect(jpeg[2]).toBe(0xff);
|
||||
expect(dispose).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.doUnmock('heic-decode');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const INDEX_HTML = readFileSync(join(process.cwd(), 'src/web/public/index.html'), 'utf-8');
|
||||
|
||||
function normalizedHtml(value: string): string {
|
||||
return value.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function extractElementById(html: string, id: string): string {
|
||||
const idIndex = html.indexOf(`id="${id}"`);
|
||||
expect(idIndex, `expected #${id} to exist`).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const start = html.lastIndexOf('<', idIndex);
|
||||
expect(start, `expected #${id} start tag`).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// Bound at the next HTML comment (every following section is comment-labeled) so
|
||||
// sections inserted between this element and any fixed marker don't leak into the
|
||||
// slice — the cron modal's "Run At" text false-positived the stale-shortcut check.
|
||||
const nextSection = html.indexOf('<!--', idIndex);
|
||||
expect(nextSection, `expected section marker after #${id}`).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const end = nextSection;
|
||||
return html.slice(start, end);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function expectShortcut(html: string, keys: string[], label: string): void {
|
||||
const keyPattern = keys.map((key) => `<kbd>${escapeRegExp(key)}</kbd>`).join('\\s*\\+\\s*');
|
||||
expect(html).toMatch(new RegExp(`${keyPattern}.*?${label}`, 'i'));
|
||||
}
|
||||
|
||||
describe('help modal shortcuts', () => {
|
||||
const helpModal = normalizedHtml(extractElementById(INDEX_HTML, 'helpModal'));
|
||||
|
||||
it('documents implemented global and tab shortcuts', () => {
|
||||
expectShortcut(helpModal, ['Ctrl', 'W'], 'Close Session');
|
||||
expectShortcut(helpModal, ['Ctrl', 'Tab'], 'Next Session');
|
||||
expectShortcut(helpModal, ['Alt/Option', '['], 'Previous / Next Session');
|
||||
expectShortcut(helpModal, ['Alt/Option', ']'], 'Previous / Next Session');
|
||||
expectShortcut(helpModal, ['Alt/Option', '1-9'], 'Switch to Tab N');
|
||||
expectShortcut(helpModal, ['Ctrl', '{'], 'Move Active Tab Left');
|
||||
expectShortcut(helpModal, ['Ctrl', '}'], 'Move Active Tab Right');
|
||||
expectShortcut(helpModal, ['Ctrl', '?'], 'Show Shortcuts');
|
||||
expect(helpModal).not.toMatch(/Ctrl<\/kbd>\s*\+\s*<kbd>\/<\/kbd>.*?Show Shortcuts/i);
|
||||
expectShortcut(helpModal, ['Ctrl', 'Shift', 'V'], 'Voice Input');
|
||||
expectShortcut(helpModal, ['Escape'], 'Close Panels');
|
||||
});
|
||||
|
||||
it('documents terminal input shortcuts without advertising stale run shortcuts', () => {
|
||||
expectShortcut(helpModal, ['Ctrl', 'L'], 'Clear Terminal');
|
||||
expectShortcut(helpModal, ['Ctrl', '+'], 'Increase Font');
|
||||
expectShortcut(helpModal, ['Ctrl', '-'], 'Decrease Font');
|
||||
expectShortcut(helpModal, ['Shift', 'Enter'], 'Insert Newline');
|
||||
expectShortcut(helpModal, ['Ctrl', 'Enter'], 'Insert Newline');
|
||||
// Ctrl+Shift+R (restore terminal size) is still dispatched — keep it documented.
|
||||
expectShortcut(helpModal, ['Ctrl', 'Shift', 'R'], 'Restore Terminal Size');
|
||||
|
||||
expect(helpModal).not.toMatch(/Ctrl<\/kbd>\s*\+\s*<kbd>K<\/kbd>/i);
|
||||
expect(helpModal).not.toMatch(/Ctrl<\/kbd>\s*\+\s*<kbd>Enter<\/kbd>.*?(Run|Start)/i);
|
||||
});
|
||||
|
||||
it('does not advertise the removed Ctrl+Enter run binding in launch UI hints', () => {
|
||||
expect(INDEX_HTML).not.toContain('Or press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> to start');
|
||||
expect(INDEX_HTML).not.toContain('title="Run (Ctrl+Enter)"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* @fileoverview Input dispatch ordering for the durable, acknowledged delivery
|
||||
* layer (`CodemanApp._sendInputAsync` → `_reliableSend` → `_drainSession`).
|
||||
*
|
||||
* Local replaced the upstream best-effort "coalescing fallback queue" with the
|
||||
* durable per-(clientId, seq) layer in commit 1255e28 (docs/reliable-input-
|
||||
* delivery.md). This suite verifies the client-side ordering guarantees of that
|
||||
* layer: each input is a distinct seq-tagged frame, delivered in order over the
|
||||
* WebSocket when open, serialized over HTTP POST when not, and only dropped on a
|
||||
* server ACK (HTTP 2xx). Exactly-once application is covered server-side in
|
||||
* test/reliable-input-dedup.test.ts; the header transport indicator ("WS"/"HTTP")
|
||||
* is covered in test/connection-indicator.test.ts.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed context (no jsdom).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function loadCodemanAppClass() {
|
||||
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
WebSocket: { OPEN: 1 },
|
||||
fetch: (...args: Parameters<typeof fetch>) => global.fetch(...args),
|
||||
document: { addEventListener: vi.fn() },
|
||||
localStorage: {
|
||||
length: 0,
|
||||
key: vi.fn(),
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
},
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
MobileDetection: {},
|
||||
});
|
||||
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||
return (context as { __CodemanApp: new () => unknown }).__CodemanApp;
|
||||
}
|
||||
|
||||
const CodemanApp = loadCodemanAppClass();
|
||||
|
||||
async function waitForCalls(calls: unknown[], count: number) {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
if (calls.length >= count) return;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
}
|
||||
|
||||
type Frame = { t: string; d: string; seq: number; cid: string };
|
||||
type PostBody = { input: string; seq: number; clientId: string };
|
||||
|
||||
type App = {
|
||||
_sendInputAsync: (sessionId: string, input: string, opts?: { useMux?: boolean }) => void;
|
||||
_pendingDeliveries: Map<string, Array<{ seq: number; data: string; sentAt: number }>>;
|
||||
_ws: { readyState: number; send: (data: string) => void } | null;
|
||||
_wsSessionId: string | null;
|
||||
activeSessionId: string | null;
|
||||
};
|
||||
|
||||
function makeApp(): App {
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as App & Record<string, unknown>;
|
||||
app._clientId = 'c-test';
|
||||
app._seqCounters = new Map();
|
||||
app._pendingDeliveries = new Map();
|
||||
app._postDraining = new Set();
|
||||
app._persistReliableState = vi.fn();
|
||||
app._persistReliableNow = vi.fn();
|
||||
app._updateConnectionIndicator = vi.fn();
|
||||
app.clearPendingHooks = vi.fn();
|
||||
app.activeSessionId = 'session-1';
|
||||
app.isOnline = true;
|
||||
app._connectionStatus = 'connected';
|
||||
app._ws = null;
|
||||
app._wsSessionId = null;
|
||||
return app as unknown as App;
|
||||
}
|
||||
|
||||
describe('durable input delivery — send ordering', () => {
|
||||
it('delivers rapid input as distinct ordered seq frames over an open WebSocket (no coalescing)', () => {
|
||||
const app = makeApp();
|
||||
const frames: Frame[] = [];
|
||||
app._ws = { readyState: 1, send: (d: string) => frames.push(JSON.parse(d)) };
|
||||
app._wsSessionId = 'session-1';
|
||||
|
||||
app._sendInputAsync('session-1', 'a');
|
||||
app._sendInputAsync('session-1', 'b');
|
||||
app._sendInputAsync('session-1', 'c');
|
||||
|
||||
// Each keystroke is its own frame, in seq order — never merged into "abc".
|
||||
expect(frames.map((f) => f.d)).toEqual(['a', 'b', 'c']);
|
||||
expect(frames.map((f) => f.seq)).toEqual([1, 2, 3]);
|
||||
expect(frames.every((f) => f.t === 'i' && f.cid === 'c-test')).toBe(true);
|
||||
});
|
||||
|
||||
it('POSTs queued input one frame at a time in seq order when no socket is open', async () => {
|
||||
const app = makeApp();
|
||||
const calls: PostBody[] = [];
|
||||
const completions: Array<() => void> = [];
|
||||
global.fetch = vi.fn(async (_url, init) => {
|
||||
calls.push(JSON.parse(String(init?.body)) as PostBody);
|
||||
await new Promise<void>((r) => completions.push(r));
|
||||
return new Response('{}', { status: 200 });
|
||||
});
|
||||
|
||||
app._sendInputAsync('session-1', 'a');
|
||||
app._sendInputAsync('session-1', 'b');
|
||||
|
||||
// Serialized: only the first frame is in flight until its 2xx ACK lands.
|
||||
await waitForCalls(calls, 1);
|
||||
expect(calls.map((c) => c.input)).toEqual(['a']);
|
||||
|
||||
completions.shift()?.(); // ACK 'a'
|
||||
await waitForCalls(calls, 2);
|
||||
expect(calls.map((c) => c.input)).toEqual(['a', 'b']);
|
||||
expect(calls.map((c) => c.seq)).toEqual([1, 2]);
|
||||
|
||||
completions.shift()?.();
|
||||
await waitForCalls(calls, 2);
|
||||
});
|
||||
|
||||
it('leaves a frame queued (unacked) when HTTP delivery fails', async () => {
|
||||
const app = makeApp();
|
||||
const calls: PostBody[] = [];
|
||||
global.fetch = vi.fn(async (_url, init) => {
|
||||
calls.push(JSON.parse(String(init?.body)) as PostBody);
|
||||
return new Response('busy', { status: 503 });
|
||||
});
|
||||
|
||||
app._sendInputAsync('session-1', 'a');
|
||||
await waitForCalls(calls, 1);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
// 5xx is not an ACK — the frame must survive for the sweep/reconnect to retry.
|
||||
expect(app._pendingDeliveries.get('session-1')).toHaveLength(1);
|
||||
expect(app._pendingDeliveries.get('session-1')?.[0].data).toBe('a');
|
||||
});
|
||||
|
||||
it('drops a frame addressed to a vanished session (404) instead of retrying forever', async () => {
|
||||
const app = makeApp();
|
||||
global.fetch = vi.fn(async () => new Response('gone', { status: 404 }));
|
||||
|
||||
app._sendInputAsync('session-1', 'a');
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(app._pendingDeliveries.get('session-1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// COD-135 — durable redelivery sweep when an ACK is lost.
|
||||
type RedriveApp = App & {
|
||||
_redeliverSweep: () => void;
|
||||
_reliableAckTimeoutMs: number;
|
||||
_wsLastRecvAt: number;
|
||||
};
|
||||
|
||||
describe('durable input delivery — _redeliverSweep ACK-loss recovery (COD-135)', () => {
|
||||
it('re-drives a stale unacked frame over a STILL-LIVE socket (lost ACK, not silent)', () => {
|
||||
const app = makeApp() as RedriveApp;
|
||||
const frames: Frame[] = [];
|
||||
const close = vi.fn();
|
||||
app._ws = { readyState: 1, send: (d: string) => frames.push(JSON.parse(d)), close } as never;
|
||||
app._wsSessionId = 'session-1';
|
||||
app._reliableAckTimeoutMs = 4000;
|
||||
|
||||
// Frame sent once over the open socket; ACK never arrives.
|
||||
app._sendInputAsync('session-1', 'a');
|
||||
expect(frames.map((f) => f.d)).toEqual(['a']);
|
||||
|
||||
// ACK is lost, but the socket KEEPS receiving output → it is NOT silent.
|
||||
// Backdate the send so the frame is stale; keep recv timestamp fresh.
|
||||
const list = app._pendingDeliveries.get('session-1')!;
|
||||
list[0].sentAt = Date.now() - (app._reliableAckTimeoutMs + 1000);
|
||||
app._wsLastRecvAt = Date.now();
|
||||
|
||||
app._redeliverSweep();
|
||||
|
||||
// The stale frame must be re-sent over the live socket (a second send),
|
||||
// and the socket must NOT be force-closed (it's alive, just the ACK was lost).
|
||||
expect(frames.map((f) => f.d)).toEqual(['a', 'a']);
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
expect(app._pendingDeliveries.get('session-1')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does NOT re-drive a not-yet-stale frame (sent recently)', () => {
|
||||
const app = makeApp() as RedriveApp;
|
||||
const frames: Frame[] = [];
|
||||
const close = vi.fn();
|
||||
app._ws = { readyState: 1, send: (d: string) => frames.push(JSON.parse(d)), close } as never;
|
||||
app._wsSessionId = 'session-1';
|
||||
app._reliableAckTimeoutMs = 4000;
|
||||
|
||||
app._sendInputAsync('session-1', 'a');
|
||||
app._wsLastRecvAt = Date.now(); // not silent
|
||||
|
||||
// sentAt is fresh (just sent) → below the stale threshold → leave it alone.
|
||||
app._redeliverSweep();
|
||||
|
||||
expect(frames.map((f) => f.d)).toEqual(['a']); // no second send
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('force-closes the socket when stale AND silent (half-open — COD-134 fallback preserved)', () => {
|
||||
const app = makeApp() as RedriveApp;
|
||||
const frames: Frame[] = [];
|
||||
const close = vi.fn();
|
||||
app._ws = { readyState: 1, send: (d: string) => frames.push(JSON.parse(d)), close } as never;
|
||||
app._wsSessionId = 'session-1';
|
||||
app._reliableAckTimeoutMs = 4000;
|
||||
|
||||
app._sendInputAsync('session-1', 'a');
|
||||
const list = app._pendingDeliveries.get('session-1')!;
|
||||
list[0].sentAt = Date.now() - (app._reliableAckTimeoutMs + 1000); // stale
|
||||
app._wsLastRecvAt = Date.now() - (app._reliableAckTimeoutMs + 1000); // silent
|
||||
|
||||
app._redeliverSweep();
|
||||
|
||||
// Half-open socket never recovers on its own → force-close to reconnect.
|
||||
// It must NOT have re-sent over the dead socket.
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(frames.map((f) => f.d)).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ describe('keyboard shortcuts', () => {
|
||||
// terminal-ui.js must gate its xterm pass-through on the SAME physical e.code set the
|
||||
// app.js handler consumes; otherwise Alt+[ / Alt+] (and Option+digit on remapped macOS
|
||||
// layouts) switch tabs AND inject ESC<char> into the focused terminal. Keep in sync.
|
||||
expect(terminalUiSource).toContain('/^(Digit[1-9]|BracketLeft|BracketRight)$/.test(ev.code');
|
||||
expect(terminalUiSource).toContain('/^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code');
|
||||
});
|
||||
|
||||
it('documents the Alt/Option shortcuts in help and README', () => {
|
||||
@@ -34,4 +34,28 @@ describe('keyboard shortcuts', () => {
|
||||
expect(readme).toContain('`Alt/Option+[` / `Alt/Option+]`');
|
||||
expect(readme).toContain('`Alt/Option+1`-`Alt/Option+9`');
|
||||
});
|
||||
|
||||
it('documents the Command-K open-session palette in help and README', () => {
|
||||
expect(appSource).toContain('this.openCommandPalette()');
|
||||
expect(helpHtml).toContain('<kbd>Ctrl/Cmd/Option</kbd>+<kbd>K</kbd>');
|
||||
expect(readme).toMatch(/\| `Ctrl\/Cmd\/Option\+K`\s+\| Find open session or start a new one\s+\|/);
|
||||
});
|
||||
|
||||
it('gates the palette chord in the xterm custom key handler (no 0x0b kill-line into the PTY)', () => {
|
||||
// The document-level capture handler opens the palette, but preventDefault()
|
||||
// does NOT stop xterm from evaluating Ctrl+K into 0x0b and writing it to the
|
||||
// live PTY — terminal-ui.js must return false for the palette chord.
|
||||
expect(terminalUiSource).toMatch(/ev\.type === 'keydown' && this\.shouldOpenCommandPaletteFromShortcut\?\.\(ev\)/);
|
||||
});
|
||||
|
||||
it('dispatches document shortcuts through the shortcut registry (rebind/disable aware)', () => {
|
||||
// The legacy hardcoded SHORTCUTS table must stay gone — dispatch goes through
|
||||
// getShortcutRegistry() + matchesShortcutEvent() so overrides and per-shortcut
|
||||
// disables (App Settings → Shortcuts) actually take effect.
|
||||
expect(appSource).not.toContain('const SHORTCUTS = [');
|
||||
expect(appSource).toContain('const SHORTCUT_ACTIONS = {');
|
||||
expect(appSource).toContain('for (const shortcut of this.getShortcutRegistry())');
|
||||
expect(appSource).toContain('if (this.matchesShortcutEvent(e, shortcut))');
|
||||
expect(appSource).toContain('if (shortcut.disabled || !shortcut.action) continue;');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -266,6 +266,9 @@ export class MockSession extends EventEmitter {
|
||||
/** Stub for startInteractive */
|
||||
startInteractive = vi.fn(async () => {});
|
||||
|
||||
/** Stub for resetRespawnBreaker (COD-118) */
|
||||
resetRespawnBreaker = vi.fn();
|
||||
|
||||
/** Stub for startShell */
|
||||
startShell = vi.fn(async () => {});
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
defaultRemoteCommandForMode,
|
||||
readRemoteCases,
|
||||
readRemoteHosts,
|
||||
remoteDisplayPath,
|
||||
remoteSshTarget,
|
||||
writeRemoteCases,
|
||||
writeRemoteHosts,
|
||||
} from '../src/remote-hosts.js';
|
||||
|
||||
describe('remote-hosts domain', () => {
|
||||
let dir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||
dir = null;
|
||||
});
|
||||
|
||||
function configDir(): string {
|
||||
dir = mkdtempSync(join(tmpdir(), 'codeman-remote-hosts-'));
|
||||
return dir;
|
||||
}
|
||||
|
||||
it('round-trips remote hosts and remote cases from a config directory', async () => {
|
||||
const root = configDir();
|
||||
await writeRemoteHosts(root, [
|
||||
{
|
||||
id: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
commands: { codex: 'exec codx personal' },
|
||||
},
|
||||
]);
|
||||
await writeRemoteCases(root, [
|
||||
{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' },
|
||||
]);
|
||||
|
||||
await expect(readRemoteHosts(root)).resolves.toEqual([
|
||||
{
|
||||
id: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
commands: { codex: 'exec codx personal' },
|
||||
},
|
||||
]);
|
||||
await expect(readRemoteCases(root)).resolves.toEqual([
|
||||
{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns safe mode defaults and remote display values', () => {
|
||||
expect(defaultRemoteCommandForMode('shell')).toBe('exec bash -l');
|
||||
expect(defaultRemoteCommandForMode('codex')).toBe('exec codex');
|
||||
// Mirrors the local claude default so the remote agent runs non-interactively.
|
||||
expect(defaultRemoteCommandForMode('claude')).toBe('exec claude --dangerously-skip-permissions');
|
||||
expect(remoteSshTarget({ id: 'h1', label: 'H1', host: 'box.local', username: 'aamer' })).toBe('aamer@box.local');
|
||||
expect(remoteDisplayPath({ username: 'aamer', host: 'box.local', path: '/opt/work' })).toBe(
|
||||
'aamer@box.local:/opt/work'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* @fileoverview COD-107 — Remote-host SSH: custom port + advanced connection options.
|
||||
*
|
||||
* Unit tests for the shared, pure `buildSshConnectionArgs(remote)` and its two
|
||||
* consumers (`buildRemoteLaunchCommand`, `buildRemoteTmuxCheckCommand`). The
|
||||
* acceptance target is the aa-desktop option set (custom port 2222, ed25519
|
||||
* identity under `~`, a cloudflared SOCKS5 ProxyCommand, plus an arbitrary
|
||||
* `-o` escape-hatch option) — the same connection `~/repos/claude-config/bin/
|
||||
* ssh-aa-desktop` makes, WITHOUT shelling out to that wrapper.
|
||||
*
|
||||
* Critical, easy-to-break invariants pinned here:
|
||||
* - `%h %p` in the ProxyCommand reach ssh LITERALLY (one shellescaped
|
||||
* `-o ProxyCommand=…` token; the local shell must not expand/mangle them).
|
||||
* - a leading `~`/`$HOME` in `identityFile` is expanded to an absolute path at
|
||||
* build time (ssh does NOT expand `~` in `-i`), then shellescaped.
|
||||
* - empty options ⇒ byte-identical ssh to today (full back-compat).
|
||||
*
|
||||
* Pure command-string construction; no real tmux, no ssh. Port: N/A.
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildSshConnectionArgs, buildRemoteTmuxCheckCommand, remoteSshTarget } from '../src/remote-hosts.js';
|
||||
import { buildRemoteLaunchCommand } from '../src/tmux-manager.js';
|
||||
import type { SessionRemote } from '../src/types.js';
|
||||
|
||||
const HOME = homedir();
|
||||
|
||||
const baseRemote: SessionRemote = {
|
||||
hostId: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
};
|
||||
|
||||
// The acceptance host: aa-desktop reached over the cloudflared SOCKS5 proxy.
|
||||
const aaDesktop: SessionRemote = {
|
||||
hostId: 'aa-desktop',
|
||||
label: 'aa-desktop',
|
||||
host: '192.168.55.170',
|
||||
username: 'aakht',
|
||||
port: 2222,
|
||||
remotePath: '/tmp',
|
||||
identityFile: '~/.ssh/remote_ed25519',
|
||||
socksProxy: '127.0.0.1:1080',
|
||||
extraSshOptions: ['StrictHostKeyChecking=accept-new'],
|
||||
commands: { shell: 'exec bash -l' },
|
||||
};
|
||||
|
||||
const SESSION_ID = 'cod107chk';
|
||||
|
||||
describe('COD-107 buildSshConnectionArgs — shared ssh connection tokens', () => {
|
||||
it('always leads with -o BatchMode=yes then the default -o ConnectTimeout=10', () => {
|
||||
expect(buildSshConnectionArgs(baseRemote)).toEqual(['ssh', '-o BatchMode=yes', '-o ConnectTimeout=10']);
|
||||
});
|
||||
|
||||
it('emits the full aa-desktop option set in order with escaping + %h %p intact', () => {
|
||||
const args = buildSshConnectionArgs(aaDesktop);
|
||||
const joined = args.join(' ');
|
||||
|
||||
// -p before -i before the proxy -o; identity ~ expanded absolute, then escaped.
|
||||
expect(joined).toContain('-o BatchMode=yes');
|
||||
expect(joined).toContain('-p 2222');
|
||||
expect(joined).toContain(`-i '${HOME}/.ssh/remote_ed25519'`);
|
||||
// No literal tilde survives into the -i token.
|
||||
expect(joined).not.toContain('-i ~');
|
||||
expect(joined).not.toContain("-i '~");
|
||||
|
||||
// The whole ProxyCommand (with its spaces and %h %p) is ONE shellescaped -o token.
|
||||
expect(joined).toContain("-o 'ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p'");
|
||||
// %h %p must survive verbatim — they are ssh tokens, not shell tokens.
|
||||
expect(joined).toContain('%h %p');
|
||||
|
||||
// The escape-hatch extra option, shellescaped.
|
||||
expect(joined).toContain("-o 'StrictHostKeyChecking=accept-new'");
|
||||
|
||||
// Ordering: BatchMode -> port -> identity -> ProxyCommand -> extras.
|
||||
const idxBatch = joined.indexOf('BatchMode=yes');
|
||||
const idxPort = joined.indexOf('-p 2222');
|
||||
const idxIdentity = joined.indexOf('-i ');
|
||||
const idxProxy = joined.indexOf('ProxyCommand=');
|
||||
const idxExtra = joined.indexOf('StrictHostKeyChecking');
|
||||
expect(idxBatch).toBeLessThan(idxPort);
|
||||
expect(idxPort).toBeLessThan(idxIdentity);
|
||||
expect(idxIdentity).toBeLessThan(idxProxy);
|
||||
expect(idxProxy).toBeLessThan(idxExtra);
|
||||
});
|
||||
|
||||
it('supports an explicit -J jump host (shellescaped, like its siblings)', () => {
|
||||
const args = buildSshConnectionArgs({ ...baseRemote, jumpHost: 'bastion@10.0.0.1:22' });
|
||||
expect(args.join(' ')).toContain("-J 'bastion@10.0.0.1:22'");
|
||||
});
|
||||
|
||||
it('shellescapes a -J jump host containing shell metacharacters (no injection)', () => {
|
||||
// Defense-in-depth: even if a metachar-laden value slipped past schema validation,
|
||||
// it must stay a single shell token and never break out of the ssh command.
|
||||
const args = buildSshConnectionArgs({ ...baseRemote, jumpHost: 'x; touch /tmp/pwned' });
|
||||
const joined = args.join(' ');
|
||||
// The whole value is wrapped in single quotes — the `;` cannot start a new command.
|
||||
expect(joined).toContain("-J 'x; touch /tmp/pwned'");
|
||||
expect(joined).not.toContain('-J x;');
|
||||
});
|
||||
|
||||
it('expands a $HOME-prefixed identity path', () => {
|
||||
const args = buildSshConnectionArgs({ ...baseRemote, identityFile: '$HOME/.ssh/id_ed25519' });
|
||||
expect(args.join(' ')).toContain(`-i '${HOME}/.ssh/id_ed25519'`);
|
||||
});
|
||||
|
||||
it('empty options ⇒ BatchMode + the default ConnectTimeout (+ -p only when set)', () => {
|
||||
expect(buildSshConnectionArgs(baseRemote)).toEqual(['ssh', '-o BatchMode=yes', '-o ConnectTimeout=10']);
|
||||
expect(buildSshConnectionArgs({ ...baseRemote, port: 2200 })).toEqual([
|
||||
'ssh',
|
||||
'-o BatchMode=yes',
|
||||
'-o ConnectTimeout=10',
|
||||
'-p 2200',
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits the default ConnectTimeout when extraSshOptions already sets it (operator wins)', () => {
|
||||
const args = buildSshConnectionArgs({ ...baseRemote, extraSshOptions: ['ConnectTimeout=3'] });
|
||||
expect(args.filter((a) => a.includes('ConnectTimeout'))).toEqual(["-o 'ConnectTimeout=3'"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => {
|
||||
it('emits the aa-desktop ssh connection options ahead of -t and the target', () => {
|
||||
const command = buildRemoteLaunchCommand({ mode: 'shell', remote: aaDesktop, sessionId: SESSION_ID });
|
||||
|
||||
expect(command).toContain('-p 2222');
|
||||
expect(command).toContain(`-i '${HOME}/.ssh/remote_ed25519'`);
|
||||
expect(command).toContain("-o 'ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p'");
|
||||
expect(command).toContain("-o 'StrictHostKeyChecking=accept-new'");
|
||||
expect(command).toContain('-t');
|
||||
expect(command).toContain('aakht@192.168.55.170');
|
||||
expect(command).toContain('tmux -L codeman-remote new-session -A');
|
||||
|
||||
// Connection options come BEFORE -t / the target / the tmux command.
|
||||
const idxProxy = command.indexOf('ProxyCommand=');
|
||||
const idxTarget = command.indexOf('aakht@192.168.55.170');
|
||||
expect(idxProxy).toBeLessThan(idxTarget);
|
||||
});
|
||||
|
||||
it('a remote with no advanced options is byte-identical to the expected form', () => {
|
||||
const command = buildRemoteLaunchCommand({ mode: 'shell', remote: baseRemote, sessionId: SESSION_ID });
|
||||
// Reconstruct the command using the SAME nested POSIX single-quote escaping the
|
||||
// production code uses, to prove byte-identity. Session runs on the DEDICATED
|
||||
// `-L codeman-remote` socket under a `codeman-ssh-` name that a remote Codeman's
|
||||
// discovery ignores; set-options are scoped per-session (never `-g`).
|
||||
const sh = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'";
|
||||
const remoteName = `codeman-ssh-${SESSION_ID.slice(0, 8)}`;
|
||||
const path = sh('/home/ubuntu/work');
|
||||
const paneCommand = `cd ${path} && exec bash -l`;
|
||||
const tmuxInvocation = [
|
||||
`tmux -L codeman-remote new-session -A -s ${remoteName} -c ${path} ${sh(paneCommand)}`,
|
||||
`set -t ${remoteName} status off`,
|
||||
`set -t ${remoteName} mouse off`,
|
||||
`set -t ${remoteName} prefix C-q`,
|
||||
'set -s escape-time 0',
|
||||
].join(' \\; ');
|
||||
// Connection args (with the default -o ConnectTimeout=10) sit after -t.
|
||||
const expected = `ssh -o BatchMode=yes -t -o ConnectTimeout=10 ${remoteSshTarget(baseRemote)} ${sh(tmuxInvocation)}`;
|
||||
expect(command).toBe(expected);
|
||||
});
|
||||
|
||||
it('port-only remote places -p after the -t/ConnectTimeout tokens', () => {
|
||||
const command = buildRemoteLaunchCommand({
|
||||
mode: 'shell',
|
||||
remote: { ...baseRemote, port: 2222 },
|
||||
sessionId: SESSION_ID,
|
||||
});
|
||||
expect(command).toMatch(/^ssh -o BatchMode=yes -t -o ConnectTimeout=10 -p 2222 ubuntu@10\.0\.0\.42 /);
|
||||
});
|
||||
});
|
||||
|
||||
describe('COD-107 buildRemoteTmuxCheckCommand — same connection options as the launch', () => {
|
||||
it('uses the shared connection args (proxy/identity/port) plus ConnectTimeout', () => {
|
||||
const cmd = buildRemoteTmuxCheckCommand(aaDesktop);
|
||||
expect(cmd).toContain('-o BatchMode=yes');
|
||||
expect(cmd).toContain('-o ConnectTimeout=10');
|
||||
expect(cmd).toContain('-p 2222');
|
||||
expect(cmd).toContain(`-i '${HOME}/.ssh/remote_ed25519'`);
|
||||
expect(cmd).toContain("-o 'ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p'");
|
||||
expect(cmd).toContain('aakht@192.168.55.170');
|
||||
expect(cmd).toContain("'command -v tmux'");
|
||||
});
|
||||
|
||||
it('back-compat: no advanced options ⇒ unchanged probe string', () => {
|
||||
expect(buildRemoteTmuxCheckCommand({ username: 'ubuntu', host: '10.0.0.42' })).toBe(
|
||||
"ssh -o BatchMode=yes -o ConnectTimeout=10 ubuntu@10.0.0.42 'command -v tmux'"
|
||||
);
|
||||
expect(buildRemoteTmuxCheckCommand({ username: 'ubuntu', host: '10.0.0.42', port: 2222 })).toContain('-p 2222');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for the interactive-PTY exit circuit breaker (COD-118).
|
||||
*
|
||||
* Covers the pure trip/reset/window logic of `InteractivePtyExitBreaker` with
|
||||
* INJECTED time (no real timers, fully deterministic), plus a Session-level
|
||||
* assertion via MockSession that repeated non-zero exits flip the session to
|
||||
* `error` + block respawn, and that an explicit reset re-enables spawning.
|
||||
* Also covers the REAL listener wiring lifecycle (createSessionListeners /
|
||||
* attach / detach): the wiring exit handler detaches everything on each PTY
|
||||
* exit, so the re-attach routes must re-wire or the trip goes unobserved.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
InteractivePtyExitBreaker,
|
||||
DEFAULT_BREAKER_THRESHOLD,
|
||||
DEFAULT_BREAKER_WINDOW_MS,
|
||||
} from '../src/session-pty-exit-breaker.js';
|
||||
import { MockSession } from './mocks/index.js';
|
||||
import {
|
||||
createSessionListeners,
|
||||
attachSessionListeners,
|
||||
detachSessionListeners,
|
||||
type SessionListenerRefs,
|
||||
} from '../src/web/session-listener-wiring.js';
|
||||
import { SseEvent } from '../src/web/sse-events.js';
|
||||
import type { Session } from '../src/session.js';
|
||||
|
||||
describe('InteractivePtyExitBreaker — pure logic', () => {
|
||||
it('exports sane default constants', () => {
|
||||
expect(DEFAULT_BREAKER_THRESHOLD).toBeGreaterThanOrEqual(3);
|
||||
expect(DEFAULT_BREAKER_WINDOW_MS).toBe(10_000);
|
||||
});
|
||||
|
||||
it('starts untripped with a zero count', () => {
|
||||
const b = new InteractivePtyExitBreaker();
|
||||
expect(b.tripped).toBe(false);
|
||||
});
|
||||
|
||||
it('trips after exactly N non-zero exits within the window', () => {
|
||||
const b = new InteractivePtyExitBreaker({ threshold: 5, windowMs: 10_000 });
|
||||
let result = { tripped: false, count: 0 };
|
||||
// 5 rapid non-zero exits at t=0,1,2,3,4 ms
|
||||
for (let i = 0; i < 5; i++) {
|
||||
result = b.recordExit(1, i);
|
||||
}
|
||||
expect(result.count).toBe(5);
|
||||
expect(result.tripped).toBe(true);
|
||||
expect(b.tripped).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT trip on N-1 non-zero exits', () => {
|
||||
const b = new InteractivePtyExitBreaker({ threshold: 5, windowMs: 10_000 });
|
||||
let result = { tripped: false, count: 0 };
|
||||
for (let i = 0; i < 4; i++) {
|
||||
result = b.recordExit(1, i);
|
||||
}
|
||||
expect(result.count).toBe(4);
|
||||
expect(result.tripped).toBe(false);
|
||||
expect(b.tripped).toBe(false);
|
||||
});
|
||||
|
||||
it('evicts exits older than the window (rapid repeats spread across time do not trip)', () => {
|
||||
const b = new InteractivePtyExitBreaker({ threshold: 3, windowMs: 10_000 });
|
||||
// Three exits but spaced 6s apart: by the 3rd, the 1st is outside the 10s window.
|
||||
expect(b.recordExit(1, 0).tripped).toBe(false); // window: [0]
|
||||
expect(b.recordExit(1, 6_000).tripped).toBe(false); // window: [0, 6000]
|
||||
// At t=12000, the t=0 exit is now > windowMs old → evicted. Count = {6000,12000} = 2.
|
||||
const r = b.recordExit(1, 12_000);
|
||||
expect(r.count).toBe(2);
|
||||
expect(r.tripped).toBe(false);
|
||||
});
|
||||
|
||||
it('trips when N non-zero exits land inside the window despite earlier evictions', () => {
|
||||
const b = new InteractivePtyExitBreaker({ threshold: 3, windowMs: 10_000 });
|
||||
b.recordExit(1, 0); // evicted later
|
||||
b.recordExit(1, 100);
|
||||
b.recordExit(1, 200);
|
||||
// t=300: window keeps 100,200,300 (0 is fine too, all <10s) → count 4 ≥ 3
|
||||
const r = b.recordExit(1, 300);
|
||||
expect(r.tripped).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the window boundary inclusively/exclusively consistently (exactly windowMs old is evicted)', () => {
|
||||
const b = new InteractivePtyExitBreaker({ threshold: 2, windowMs: 10_000 });
|
||||
b.recordExit(1, 0);
|
||||
// t=10000 is exactly windowMs after t=0 → t=0 is evicted (strictly older-than-window kept only)
|
||||
const r = b.recordExit(1, 10_000);
|
||||
expect(r.count).toBe(1);
|
||||
expect(r.tripped).toBe(false);
|
||||
});
|
||||
|
||||
it('a clean (zero) exit resets the counter', () => {
|
||||
const b = new InteractivePtyExitBreaker({ threshold: 3, windowMs: 10_000 });
|
||||
b.recordExit(1, 0);
|
||||
b.recordExit(1, 1);
|
||||
const clean = b.recordExit(0, 2);
|
||||
expect(clean.count).toBe(0);
|
||||
expect(clean.tripped).toBe(false);
|
||||
// Counter genuinely reset: two more non-zero do NOT trip (would need 3 fresh).
|
||||
expect(b.recordExit(1, 3).tripped).toBe(false);
|
||||
expect(b.recordExit(1, 4).count).toBe(2);
|
||||
});
|
||||
|
||||
it('stays tripped once tripped until reset(), even on further exits', () => {
|
||||
const b = new InteractivePtyExitBreaker({ threshold: 2, windowMs: 10_000 });
|
||||
b.recordExit(1, 0);
|
||||
expect(b.recordExit(1, 1).tripped).toBe(true);
|
||||
// Further non-zero exits keep it tripped.
|
||||
expect(b.recordExit(1, 2).tripped).toBe(true);
|
||||
// A clean exit does NOT auto-clear a tripped breaker (only explicit reset does).
|
||||
expect(b.recordExit(0, 3).tripped).toBe(true);
|
||||
expect(b.tripped).toBe(true);
|
||||
});
|
||||
|
||||
it('reset() clears the tripped state and the counter', () => {
|
||||
const b = new InteractivePtyExitBreaker({ threshold: 2, windowMs: 10_000 });
|
||||
b.recordExit(1, 0);
|
||||
b.recordExit(1, 1);
|
||||
expect(b.tripped).toBe(true);
|
||||
b.reset();
|
||||
expect(b.tripped).toBe(false);
|
||||
// After reset, it takes a full fresh threshold to trip again.
|
||||
expect(b.recordExit(1, 2).tripped).toBe(false);
|
||||
expect(b.recordExit(1, 3).tripped).toBe(true);
|
||||
});
|
||||
|
||||
it('is deterministic with injected time (no reliance on Date.now)', () => {
|
||||
const a = new InteractivePtyExitBreaker({ threshold: 3, windowMs: 1_000 });
|
||||
const b = new InteractivePtyExitBreaker({ threshold: 3, windowMs: 1_000 });
|
||||
const times = [0, 100, 200, 999, 1500];
|
||||
const ra = times.map((t) => a.recordExit(1, t));
|
||||
const rb = times.map((t) => b.recordExit(1, t));
|
||||
expect(ra).toEqual(rb);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session-level trip/reset (AC#4, via MockSession)', () => {
|
||||
// Lightweight harness mirroring how Session wires the breaker into its PTY
|
||||
// exit handler: record exit → on trip, flip status to 'error', block respawn,
|
||||
// emit a signal. An explicit reset re-enables respawn.
|
||||
function wireBreaker(session: MockSession, breaker: InteractivePtyExitBreaker) {
|
||||
let respawnBlocked = false;
|
||||
const onExit = (exitCode: number, nowMs: number) => {
|
||||
const { tripped } = breaker.recordExit(exitCode, nowMs);
|
||||
if (tripped) {
|
||||
respawnBlocked = true;
|
||||
session.status = 'idle'; // MockSession only types idle|working; the real Session sets _status='error'
|
||||
session.emit('respawnBreakerTripped', {
|
||||
count: DEFAULT_BREAKER_THRESHOLD,
|
||||
windowMs: DEFAULT_BREAKER_WINDOW_MS,
|
||||
});
|
||||
}
|
||||
};
|
||||
return {
|
||||
onExit,
|
||||
isRespawnBlocked: () => respawnBlocked,
|
||||
reset: () => {
|
||||
breaker.reset();
|
||||
respawnBlocked = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('repeated non-zero exits trip → respawn blocked + event emitted; explicit reset re-enables', () => {
|
||||
const session = new MockSession('breaker-session');
|
||||
const breaker = new InteractivePtyExitBreaker({ threshold: 3, windowMs: 10_000 });
|
||||
const harness = wireBreaker(session, breaker);
|
||||
|
||||
let trippedEvents = 0;
|
||||
session.on('respawnBreakerTripped', () => {
|
||||
trippedEvents++;
|
||||
});
|
||||
|
||||
// Two non-zero exits: not yet blocked.
|
||||
harness.onExit(1, 0);
|
||||
harness.onExit(1, 1);
|
||||
expect(harness.isRespawnBlocked()).toBe(false);
|
||||
expect(trippedEvents).toBe(0);
|
||||
|
||||
// Third within window → trips.
|
||||
harness.onExit(1, 2);
|
||||
expect(harness.isRespawnBlocked()).toBe(true);
|
||||
expect(trippedEvents).toBe(1);
|
||||
expect(breaker.tripped).toBe(true);
|
||||
|
||||
// Explicit (user-initiated) reset re-enables respawn.
|
||||
harness.reset();
|
||||
expect(harness.isRespawnBlocked()).toBe(false);
|
||||
expect(breaker.tripped).toBe(false);
|
||||
});
|
||||
|
||||
it('a single normal exit never blocks respawn', () => {
|
||||
const session = new MockSession('normal-exit-session');
|
||||
const breaker = new InteractivePtyExitBreaker();
|
||||
const harness = wireBreaker(session, breaker);
|
||||
harness.onExit(1, 0); // one crash
|
||||
harness.onExit(0, 50); // then a clean exit
|
||||
expect(harness.isRespawnBlocked()).toBe(false);
|
||||
expect(breaker.tripped).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trip observability through the REAL listener wiring (COD-118)', () => {
|
||||
// Mirrors the server: refs map + removeSessionListenerRefs (called by the wiring
|
||||
// exit handler on EVERY PTY exit) detaching all listeners, and an idempotent
|
||||
// setup() like WebServer.setupSessionListeners that the re-attach routes
|
||||
// (/interactive, /interactive-respawn, /shell) now re-run.
|
||||
function makeHarness() {
|
||||
const session = new MockSession('wiring-breaker-session');
|
||||
const refsMap = new Map<string, SessionListenerRefs>();
|
||||
const deps = {
|
||||
broadcast: vi.fn(),
|
||||
batchTerminalData: vi.fn(),
|
||||
batchTaskUpdate: vi.fn(),
|
||||
broadcastSessionStateDebounced: vi.fn(),
|
||||
sendPushNotifications: vi.fn(),
|
||||
persistSessionState: vi.fn(),
|
||||
getSessionStateWithRespawn: vi.fn(() => ({ id: session.id })),
|
||||
getRunSummaryTracker: vi.fn(() => undefined),
|
||||
stopTranscriptWatcher: vi.fn(),
|
||||
cleanupSessionBatches: vi.fn(),
|
||||
cancelPersistDebounce: vi.fn(),
|
||||
removeRunSummaryTracker: vi.fn(),
|
||||
// Same as server.ts removeSessionListenerRefs: detach ALL wiring listeners.
|
||||
removeSessionListenerRefs: (id: string) => {
|
||||
const refs = refsMap.get(id);
|
||||
if (refs) detachSessionListeners(session as unknown as Session, refs);
|
||||
refsMap.delete(id);
|
||||
},
|
||||
cleanupRespawnOnExit: vi.fn(),
|
||||
getStore: vi.fn(),
|
||||
registerAttachment: vi.fn(async () => {}),
|
||||
};
|
||||
const setup = () => {
|
||||
if (refsMap.has(session.id)) return; // idempotence guard, as in server.ts
|
||||
const refs = createSessionListeners(
|
||||
session as unknown as Session,
|
||||
deps as unknown as Parameters<typeof createSessionListeners>[1]
|
||||
);
|
||||
refsMap.set(session.id, refs);
|
||||
attachSessionListeners(session as unknown as Session, refs);
|
||||
};
|
||||
return { session, deps, setup };
|
||||
}
|
||||
|
||||
it('every PTY exit detaches ALL wiring listeners (the gap the re-attach routes must close)', () => {
|
||||
const { session, setup } = makeHarness();
|
||||
setup(); // session-create wiring
|
||||
expect(session.listenerCount('respawnBreakerTripped')).toBe(1);
|
||||
session.emit('exit', 1);
|
||||
// After exit #1 the trip listener is gone — a later trip would be unobserved.
|
||||
expect(session.listenerCount('respawnBreakerTripped')).toBe(0);
|
||||
expect(session.listenerCount('terminal')).toBe(0);
|
||||
});
|
||||
|
||||
it('re-running setup() after each exit keeps the 5th-exit trip observable (SSE + push + persist)', () => {
|
||||
const { session, deps, setup } = makeHarness();
|
||||
setup(); // session-create wiring
|
||||
// Exits 1–4: each detaches the wiring; the /interactive re-attach re-wires it.
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
session.emit('exit', 1);
|
||||
setup(); // what the fixed re-attach routes now do
|
||||
}
|
||||
// 5th rapid non-zero exit: the real Session emits respawnBreakerTripped
|
||||
// (inside its onExit handler) BEFORE emitting 'exit'.
|
||||
session.emit('respawnBreakerTripped', { count: 5 });
|
||||
session.emit('exit', 1);
|
||||
|
||||
expect(deps.broadcast).toHaveBeenCalledWith(SseEvent.SessionRespawnBreakerTripped, {
|
||||
sessionId: session.id,
|
||||
count: 5,
|
||||
});
|
||||
expect(deps.sendPushNotifications).toHaveBeenCalledWith(
|
||||
SseEvent.SessionRespawnBreakerTripped,
|
||||
expect.objectContaining({ sessionId: session.id, count: 5 })
|
||||
);
|
||||
expect(deps.persistSessionState).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('setup() is idempotent — re-running while still wired must not double-attach', () => {
|
||||
const { session, setup } = makeHarness();
|
||||
setup();
|
||||
setup(); // e.g. POST /interactive on a freshly created session
|
||||
expect(session.listenerCount('respawnBreakerTripped')).toBe(1);
|
||||
expect(session.listenerCount('exit')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('push template registration (COD-118)', () => {
|
||||
it('SessionRespawnBreakerTripped has a PUSH_EVENT_MAP entry (sendPushNotifications silently no-ops without one)', async () => {
|
||||
const { WebServer } = await import('../src/web/server.js');
|
||||
const map = (WebServer as unknown as Record<string, Record<string, { title: string; urgency: string }>>)[
|
||||
'PUSH_EVENT_MAP'
|
||||
];
|
||||
expect(map).toBeDefined();
|
||||
const entry = map[SseEvent.SessionRespawnBreakerTripped];
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.urgency).toBe('critical');
|
||||
expect(entry.title.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -53,15 +53,28 @@ vi.mock('../../src/hooks-config.js', () => ({
|
||||
writeHooksConfig: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
// Stub the remote-tmux prereq probe so remote-link tests never shell out to ssh
|
||||
// (readRemoteHosts/writeRemoteHosts stay real, backed by the mocked fs).
|
||||
vi.mock('../../src/remote-hosts.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/remote-hosts.js')>();
|
||||
return {
|
||||
...actual,
|
||||
checkRemoteTmuxAvailable: vi.fn(async () => ({ ok: true, tmuxPath: '/usr/bin/tmux' })),
|
||||
};
|
||||
});
|
||||
|
||||
// Import mocked modules for test control
|
||||
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { checkRemoteTmuxAvailable } from '../../src/remote-hosts.js';
|
||||
|
||||
const mockedExistsSync = vi.mocked(existsSync);
|
||||
const mockedMkdirSync = vi.mocked(mkdirSync);
|
||||
const mockedReaddirSync = vi.mocked(readdirSync);
|
||||
const mockedReaddir = vi.mocked(fs.readdir);
|
||||
const mockedReadFile = vi.mocked(fs.readFile);
|
||||
const mockedWriteFile = vi.mocked(fs.writeFile);
|
||||
const mockedCheckRemoteTmux = vi.mocked(checkRemoteTmuxAvailable);
|
||||
|
||||
interface CaseRouteHarness {
|
||||
app: FastifyInstance;
|
||||
@@ -199,6 +212,278 @@ describe('case-routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote host and remote case routes', () => {
|
||||
function setupRemoteConfigStore() {
|
||||
const store = new Map<string, string>();
|
||||
mockedReadFile.mockImplementation(async (path) => {
|
||||
const key = String(path);
|
||||
if (store.has(key)) return store.get(key) || '';
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
mockedWriteFile.mockImplementation(async (path, data) => {
|
||||
store.set(String(path), String(data));
|
||||
});
|
||||
}
|
||||
|
||||
it('creates a remote host and lists it', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
const create = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: {
|
||||
id: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
commands: { codex: 'exec codx personal' },
|
||||
},
|
||||
});
|
||||
expect(create.statusCode).toBe(200);
|
||||
expect(JSON.parse(create.body)).toMatchObject({ success: true });
|
||||
|
||||
const list = await harness.app.inject({ method: 'GET', url: '/api/remote-hosts' });
|
||||
expect(list.statusCode).toBe(200);
|
||||
expect(JSON.parse(list.body).data).toEqual([
|
||||
expect.objectContaining({ id: 'gpu-box', label: 'GPU Box', commands: { codex: 'exec codx personal' } }),
|
||||
]);
|
||||
});
|
||||
|
||||
// COD-107 — advanced SSH connection options (port, identity, SOCKS proxy,
|
||||
// jump host, escape-hatch -o options) round-trip through the host schema.
|
||||
it('persists advanced SSH options (port/identity/socks/jump/extra) on a remote host', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
const create = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: {
|
||||
id: 'aa-desktop',
|
||||
label: 'aa-desktop',
|
||||
host: '192.168.55.170',
|
||||
username: 'aakht',
|
||||
port: 2222,
|
||||
identityFile: '~/.ssh/remote_ed25519',
|
||||
socksProxy: '127.0.0.1:1080',
|
||||
jumpHost: 'bastion@10.0.0.1:22',
|
||||
extraSshOptions: ['StrictHostKeyChecking=accept-new'],
|
||||
},
|
||||
});
|
||||
expect(create.statusCode).toBe(200);
|
||||
expect(JSON.parse(create.body)).toMatchObject({ success: true });
|
||||
|
||||
const list = await harness.app.inject({ method: 'GET', url: '/api/remote-hosts' });
|
||||
expect(JSON.parse(list.body).data).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'aa-desktop',
|
||||
port: 2222,
|
||||
identityFile: '~/.ssh/remote_ed25519',
|
||||
socksProxy: '127.0.0.1:1080',
|
||||
jumpHost: 'bastion@10.0.0.1:22',
|
||||
extraSshOptions: ['StrictHostKeyChecking=accept-new'],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a malformed extraSshOptions entry (not KEY=VALUE) with INVALID_INPUT', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
const create = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: {
|
||||
id: 'bad-host',
|
||||
label: 'bad',
|
||||
host: '10.0.0.9',
|
||||
username: 'ubuntu',
|
||||
extraSshOptions: ['not a valid option'],
|
||||
},
|
||||
});
|
||||
expect(create.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(create.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('rejects a malformed socksProxy (missing port) with INVALID_INPUT', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
const create = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: { id: 'bad2', label: 'bad2', host: '10.0.0.9', username: 'ubuntu', socksProxy: '127.0.0.1' },
|
||||
});
|
||||
expect(create.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(create.body)).toMatchObject({ success: false });
|
||||
});
|
||||
|
||||
it('links a remote case and includes it in GET /api/cases', async () => {
|
||||
setupRemoteConfigStore();
|
||||
mockedReaddir.mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' },
|
||||
});
|
||||
|
||||
const link = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/remote-link',
|
||||
payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' },
|
||||
});
|
||||
expect(link.statusCode).toBe(200);
|
||||
|
||||
const cases = await harness.app.inject({ method: 'GET', url: '/api/cases' });
|
||||
expect(JSON.parse(cases.body).data).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'gpu-work',
|
||||
location: 'remote',
|
||||
path: 'ubuntu@10.0.0.42:/home/ubuntu/work',
|
||||
remote: expect.objectContaining({ hostId: 'gpu-box', path: '/home/ubuntu/work' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers remote case metadata over a same-name local managed case', async () => {
|
||||
setupRemoteConfigStore();
|
||||
mockedReaddir.mockResolvedValue([{ name: 'gpu-work', isDirectory: () => true }] as never);
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' },
|
||||
});
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/remote-link',
|
||||
payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' },
|
||||
});
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
|
||||
const cases = await harness.app.inject({ method: 'GET', url: '/api/cases' });
|
||||
expect(JSON.parse(cases.body).data).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'gpu-work',
|
||||
location: 'remote',
|
||||
path: 'ubuntu@10.0.0.42:/home/ubuntu/work',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes remote case metadata only', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' },
|
||||
});
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/remote-link',
|
||||
payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' },
|
||||
});
|
||||
|
||||
const deleted = await harness.app.inject({ method: 'DELETE', url: '/api/cases/gpu-work' });
|
||||
expect(deleted.statusCode).toBe(200);
|
||||
expect(JSON.parse(deleted.body)).toEqual({ success: true, data: { name: 'gpu-work' } });
|
||||
});
|
||||
|
||||
// Injection hardening: remotePath/identityFile are shell-escaped, then embedded
|
||||
// via JSON.stringify() inside `bash -c "..."` — a DOUBLE-quote layer that
|
||||
// re-exposes `$(...)`/backticks even inside the inner single quotes. The schema
|
||||
// MUST reject those before they reach the launch command.
|
||||
it('rejects an identityFile containing $(...) command substitution', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
const create = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: {
|
||||
id: 'evil-host',
|
||||
label: 'evil',
|
||||
host: '10.0.0.9',
|
||||
username: 'ubuntu',
|
||||
identityFile: '/home/u/$(touch /tmp/pwned)',
|
||||
},
|
||||
});
|
||||
expect(create.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(create.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('rejects an identityFile containing a backtick', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
const create = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: {
|
||||
id: 'evil-host2',
|
||||
label: 'evil2',
|
||||
host: '10.0.0.9',
|
||||
username: 'ubuntu',
|
||||
identityFile: '/home/u/`touch /tmp/pwned`',
|
||||
},
|
||||
});
|
||||
expect(create.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(create.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('rejects a remotePath containing $(...) command substitution', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' },
|
||||
});
|
||||
const link = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/remote-link',
|
||||
payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/tmp/$(touch /tmp/pwned)' },
|
||||
});
|
||||
expect(link.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(link.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('rejects a remotePath containing a backtick', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' },
|
||||
});
|
||||
const link = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/remote-link',
|
||||
payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/tmp/`touch /tmp/pwned`' },
|
||||
});
|
||||
expect(link.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(link.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('refuses remote-link when the remote host lacks tmux (courtesy prereq probe)', async () => {
|
||||
setupRemoteConfigStore();
|
||||
mockedCheckRemoteTmux.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: 'remote host 10.0.0.42 needs tmux installed for durable remote sessions',
|
||||
});
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' },
|
||||
});
|
||||
const link = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/remote-link',
|
||||
payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' },
|
||||
});
|
||||
expect(link.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.OPERATION_FAILED));
|
||||
expect(JSON.parse(link.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.OPERATION_FAILED });
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/cases ==========
|
||||
|
||||
describe('POST /api/cases', () => {
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* @fileoverview Tests for the Codex branch of GET /api/sessions/:id/last-response (PR #152).
|
||||
*
|
||||
* Uses app.inject() — no real HTTP ports needed.
|
||||
* Port: N/A (app.inject doesn't open ports)
|
||||
*
|
||||
* Fixture rollouts live in a per-test temp CODEX_HOME (the route resolves
|
||||
* `process.env.CODEX_HOME || ~/.codex` at request time), exercising the real
|
||||
* locator/parser code paths against real files:
|
||||
* - originator match beats the cwd+mtime fallback when two panes share a dir
|
||||
* - resume-uuid filename match (resumed rollouts keep foreign session_meta)
|
||||
* - history.jsonl pin outranks the originator match
|
||||
* - event_msg vs legacy response_item user-turn dedup keeps old-codex turns
|
||||
* - injected-context rows (AGENTS.md, environment_context, …) are filtered
|
||||
* - response envelope shape; Claude-mode behavior unchanged (regression guard)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, utimesSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createMockRouteContext, createMockSession, type MockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
|
||||
interface LocalHarness {
|
||||
app: FastifyInstance;
|
||||
ctx: MockRouteContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of the production uniform-envelope hook (server.ts) — same local
|
||||
* harness idiom as session-routes.test.ts, so assertions match the wire format.
|
||||
*/
|
||||
async function createEnvelopeHarness(
|
||||
registerFn: (app: FastifyInstance, ctx: MockRouteContext) => void
|
||||
): Promise<LocalHarness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
const ctx = createMockRouteContext();
|
||||
registerFn(app, ctx);
|
||||
|
||||
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
|
||||
if (!req.url.startsWith('/api')) return done(null, payload);
|
||||
if (payload === null || typeof payload !== 'object') return done(null, payload);
|
||||
if (Buffer.isBuffer(payload) || typeof (payload as { pipe?: unknown }).pipe === 'function') {
|
||||
return done(null, payload);
|
||||
}
|
||||
const p = payload as { success?: unknown; errorCode?: unknown };
|
||||
if (p.success === false) {
|
||||
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
|
||||
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
|
||||
}
|
||||
return done(null, payload);
|
||||
}
|
||||
if (p.success === true) return done(null, payload);
|
||||
return done(null, { success: true, data: payload });
|
||||
});
|
||||
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
|
||||
return { app, ctx };
|
||||
}
|
||||
|
||||
// ── Rollout fixture helpers (shapes observed on codex-cli 0.144) ──────────────
|
||||
|
||||
const sessionMeta = (cwd: string, originator?: string) => ({
|
||||
type: 'session_meta',
|
||||
payload: { cwd, originator },
|
||||
});
|
||||
|
||||
const assistantMsg = (text: string, timestamp = '2026-07-01T00:00:00Z') => ({
|
||||
timestamp,
|
||||
type: 'response_item',
|
||||
payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }] },
|
||||
});
|
||||
|
||||
const legacyUserMsg = (text: string, timestamp = '2026-07-01T00:00:00Z') => ({
|
||||
timestamp,
|
||||
type: 'response_item',
|
||||
payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text }] },
|
||||
});
|
||||
|
||||
const eventUserMsg = (message: string, timestamp = '2026-07-01T00:00:00Z') => ({
|
||||
timestamp,
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message },
|
||||
});
|
||||
|
||||
const UUID_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||
const UUID_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
|
||||
const UUID_C = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
||||
|
||||
// Fixed epoch (seconds) for deterministic mtime ordering.
|
||||
const BASE_MTIME = 1_750_000_000;
|
||||
|
||||
describe('GET /api/sessions/:id/last-response (codex)', () => {
|
||||
let harness: LocalHarness;
|
||||
let codexHome: string;
|
||||
let prevCodexHome: string | undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let session: any; // MockSession, loosened for codex-only fields (codexConfig, codexLastSubmitAt)
|
||||
let workdir: string;
|
||||
|
||||
/** Write a rollout under CODEX_HOME/sessions/<date>/ with a controlled mtime. */
|
||||
function writeRollout(name: string, entries: unknown[], mtimeSec: number): string {
|
||||
const dir = join(codexHome, 'sessions', '2026', '07', '01');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const filePath = join(dir, name);
|
||||
let content = entries.map((e) => JSON.stringify(e)).join('\n') + '\n';
|
||||
// The locator skips files under 100 bytes (blank padding lines are ignored by the parser).
|
||||
while (content.length < 100) content += '\n';
|
||||
writeFileSync(filePath, content);
|
||||
utimesSync(filePath, mtimeSec, mtimeSec);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function writeHistory(entries: Array<{ session_id: string; ts: number }>): void {
|
||||
writeFileSync(join(codexHome, 'history.jsonl'), entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
|
||||
}
|
||||
|
||||
async function getLastResponse(id: string, full = false) {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${id}/last-response${full ? '?context=full' : ''}`,
|
||||
});
|
||||
return { res, body: JSON.parse(res.body) };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
codexHome = mkdtempSync(join(tmpdir(), 'codeman-codex-rv-'));
|
||||
prevCodexHome = process.env.CODEX_HOME;
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
|
||||
harness = await createEnvelopeHarness(registerSessionRoutes);
|
||||
session = harness.ctx._session;
|
||||
session.mode = 'codex';
|
||||
workdir = join(codexHome, 'workdir');
|
||||
session.workingDir = workdir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevCodexHome === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prevCodexHome;
|
||||
rmSync(codexHome, { recursive: true, force: true });
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
// ── Locator: originator vs cwd fallback ─────────────────────────────────
|
||||
|
||||
it('originator match beats the cwd+mtime fallback when two panes share a dir', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const paneB: any = createMockSession('codex-b');
|
||||
paneB.mode = 'codex';
|
||||
paneB.workingDir = workdir;
|
||||
harness.ctx.sessions.set('codex-b', paneB);
|
||||
|
||||
// Pane B's rollout is NEWER — the naive cwd+mtime heuristic would show it for pane A.
|
||||
writeRollout(
|
||||
`rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`,
|
||||
[sessionMeta(workdir, `codeman_${session.id}`), assistantMsg('answer A')],
|
||||
BASE_MTIME
|
||||
);
|
||||
writeRollout(
|
||||
`rollout-2026-07-01T00-01-00-${UUID_B}.jsonl`,
|
||||
[sessionMeta(workdir, 'codeman_codex-b'), assistantMsg('answer B')],
|
||||
BASE_MTIME + 100
|
||||
);
|
||||
|
||||
const a = await getLastResponse(session.id);
|
||||
expect(a.res.statusCode).toBe(200);
|
||||
expect(a.body.data.text).toBe('answer A');
|
||||
|
||||
const b = await getLastResponse('codex-b');
|
||||
expect(b.body.data.text).toBe('answer B');
|
||||
});
|
||||
|
||||
it('cwd fallback excludes rollouts claimed by other panes and skips foreign cwds', async () => {
|
||||
// Pane has no originator-stamped rollout (pre-existing pane). Newest same-cwd
|
||||
// rollout belongs to another codeman pane → must fall through to the unclaimed one.
|
||||
writeRollout(
|
||||
`rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`,
|
||||
[sessionMeta(workdir), assistantMsg('unclaimed answer')],
|
||||
BASE_MTIME
|
||||
);
|
||||
writeRollout(
|
||||
`rollout-2026-07-01T00-01-00-${UUID_B}.jsonl`,
|
||||
[sessionMeta(workdir, 'codeman_some-other-pane'), assistantMsg('sibling answer')],
|
||||
BASE_MTIME + 100
|
||||
);
|
||||
writeRollout(
|
||||
`rollout-2026-07-01T00-02-00-${UUID_C}.jsonl`,
|
||||
[sessionMeta('/elsewhere/entirely'), assistantMsg('foreign-cwd answer')],
|
||||
BASE_MTIME + 200
|
||||
);
|
||||
|
||||
const { body } = await getLastResponse(session.id);
|
||||
expect(body.data.text).toBe('unclaimed answer');
|
||||
});
|
||||
|
||||
// ── Locator: resume-uuid match ───────────────────────────────────────────
|
||||
|
||||
it('resolves a resumed pane via the rollout filename uuid despite foreign session_meta', async () => {
|
||||
session.codexConfig = { resumeSessionId: UUID_A };
|
||||
|
||||
// Resumed rollouts keep the ORIGINAL session_meta (foreign originator + launch cwd),
|
||||
// so neither originator nor cwd matching can find them — only the filename uuid.
|
||||
writeRollout(
|
||||
`rollout-2026-06-30T12-00-00-${UUID_A}.jsonl`,
|
||||
[sessionMeta('/original/launch/dir', 'codex_cli_rs'), assistantMsg('resumed answer')],
|
||||
BASE_MTIME
|
||||
);
|
||||
// A newer same-cwd decoy must NOT win over the uuid match.
|
||||
writeRollout(
|
||||
`rollout-2026-07-01T00-00-00-${UUID_B}.jsonl`,
|
||||
[sessionMeta(workdir), assistantMsg('decoy answer')],
|
||||
BASE_MTIME + 100
|
||||
);
|
||||
|
||||
const { body } = await getLastResponse(session.id);
|
||||
expect(body.data.text).toBe('resumed answer');
|
||||
});
|
||||
|
||||
// ── Locator: history.jsonl pin ───────────────────────────────────────────
|
||||
|
||||
it('history.jsonl pin (pane last-submit correlation) outranks the originator match', async () => {
|
||||
const submitAtSec = BASE_MTIME + 500;
|
||||
session.codexLastSubmitAt = submitAtSec * 1000;
|
||||
writeHistory([{ session_id: UUID_B, ts: submitAtSec }]);
|
||||
|
||||
// Originator-stamped rollout exists and is NEWER, but the pane /resume'd onto
|
||||
// UUID_B inside the TUI — the history pin must follow it there.
|
||||
writeRollout(
|
||||
`rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`,
|
||||
[sessionMeta(workdir, `codeman_${session.id}`), assistantMsg('originator answer')],
|
||||
BASE_MTIME + 600
|
||||
);
|
||||
writeRollout(
|
||||
`rollout-2026-06-30T12-00-00-${UUID_B}.jsonl`,
|
||||
[sessionMeta('/original/launch/dir', 'codex_cli_rs'), assistantMsg('history answer')],
|
||||
BASE_MTIME
|
||||
);
|
||||
|
||||
const { body } = await getLastResponse(session.id);
|
||||
expect(body.data.text).toBe('history answer');
|
||||
});
|
||||
|
||||
// ── Reader: dedup + filtering ────────────────────────────────────────────
|
||||
|
||||
it('event_msg/legacy dedup keeps old-codex turns and drops event twins (mixed-version rollout)', async () => {
|
||||
writeRollout(
|
||||
`rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`,
|
||||
[
|
||||
sessionMeta(workdir, `codeman_${session.id}`),
|
||||
// Old-codex turn: response_item only, no event_msg twin — must survive.
|
||||
legacyUserMsg('old prompt'),
|
||||
assistantMsg('old answer'),
|
||||
// Modern turn: event_msg + duplicate response_item row — one user row only.
|
||||
eventUserMsg('new prompt'),
|
||||
legacyUserMsg('new prompt'),
|
||||
assistantMsg('new answer'),
|
||||
],
|
||||
BASE_MTIME
|
||||
);
|
||||
|
||||
const { body } = await getLastResponse(session.id, true);
|
||||
expect(body.data.text).toBe('new answer');
|
||||
expect(body.data.messages.map((m: { role: string; text: string }) => [m.role, m.text])).toEqual([
|
||||
['user', 'old prompt'],
|
||||
['assistant', 'old answer'],
|
||||
['user', 'new prompt'],
|
||||
['assistant', 'new answer'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters injected-context rows from the full thread', async () => {
|
||||
writeRollout(
|
||||
`rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`,
|
||||
[
|
||||
sessionMeta(workdir, `codeman_${session.id}`),
|
||||
legacyUserMsg('# AGENTS.md instructions for the workspace'),
|
||||
legacyUserMsg('<environment_context>\n<cwd>/somewhere</cwd>'),
|
||||
eventUserMsg('<user_instructions>be nice</user_instructions>'),
|
||||
legacyUserMsg('real question'),
|
||||
assistantMsg('real answer'),
|
||||
],
|
||||
BASE_MTIME
|
||||
);
|
||||
|
||||
const { body } = await getLastResponse(session.id, true);
|
||||
expect(body.data.messages).toEqual([
|
||||
{ role: 'user', text: 'real question', timestamp: '2026-07-01T00:00:00Z' },
|
||||
{ role: 'assistant', text: 'real answer', timestamp: '2026-07-01T00:00:00Z' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders an image placeholder for image-only event_msg inputs', async () => {
|
||||
writeRollout(
|
||||
`rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`,
|
||||
[
|
||||
sessionMeta(workdir, `codeman_${session.id}`),
|
||||
{ timestamp: '2026-07-01T00:00:00Z', type: 'event_msg', payload: { type: 'user_message', images: ['a', 'b'] } },
|
||||
assistantMsg('looked at the images'),
|
||||
],
|
||||
BASE_MTIME
|
||||
);
|
||||
|
||||
const { body } = await getLastResponse(session.id, true);
|
||||
expect(body.data.messages[0]).toEqual({
|
||||
role: 'user',
|
||||
text: '*[image ×2]*',
|
||||
timestamp: '2026-07-01T00:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
// ── Envelope shape + Claude-mode regression guard ────────────────────────
|
||||
|
||||
it('returns the {success:true,data:{text,timestamp}} envelope; messages only with ?context=full', async () => {
|
||||
writeRollout(
|
||||
`rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`,
|
||||
[sessionMeta(workdir, `codeman_${session.id}`), assistantMsg('the answer', '2026-07-01T01:02:03Z')],
|
||||
BASE_MTIME
|
||||
);
|
||||
|
||||
const brief = await getLastResponse(session.id);
|
||||
expect(brief.res.statusCode).toBe(200);
|
||||
expect(brief.body).toEqual({
|
||||
success: true,
|
||||
data: { text: 'the answer', timestamp: '2026-07-01T01:02:03Z' },
|
||||
});
|
||||
|
||||
const full = await getLastResponse(session.id, true);
|
||||
expect(full.body.success).toBe(true);
|
||||
expect(Array.isArray(full.body.data.messages)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns an empty envelope (not an error) when no rollout matches', async () => {
|
||||
const brief = await getLastResponse(session.id);
|
||||
expect(brief.res.statusCode).toBe(200);
|
||||
expect(brief.body).toEqual({ success: true, data: { text: '', timestamp: '' } });
|
||||
|
||||
const full = await getLastResponse(session.id, true);
|
||||
expect(full.body.data.messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves Claude-mode sessions on the ~/.claude/projects reader (regression guard)', async () => {
|
||||
const fakeHome = mkdtempSync(join(tmpdir(), 'codeman-claude-home-'));
|
||||
const prevHome = process.env.HOME;
|
||||
process.env.HOME = fakeHome;
|
||||
try {
|
||||
session.mode = 'claude';
|
||||
const projDir = join(fakeHome, '.claude', 'projects', 'proj1');
|
||||
mkdirSync(projDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(projDir, `${session.id}.jsonl`),
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: '2026-07-01T00:00:00Z',
|
||||
message: { content: [{ type: 'text', text: 'claude answer' }] },
|
||||
}) + '\n'
|
||||
);
|
||||
// A codex rollout for the same session id must NOT be consulted in claude mode.
|
||||
writeRollout(
|
||||
`rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`,
|
||||
[sessionMeta(workdir, `codeman_${session.id}`), assistantMsg('codex answer')],
|
||||
BASE_MTIME
|
||||
);
|
||||
|
||||
const { res, body } = await getLastResponse(session.id);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(body.data.text).toBe('claude answer');
|
||||
expect(body.data.timestamp).toBe('2026-07-01T00:00:00Z');
|
||||
} finally {
|
||||
process.env.HOME = prevHome;
|
||||
rmSync(fakeHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -16,16 +16,43 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import { join } from 'node:path';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||
import { Session } from '../../src/session.js';
|
||||
|
||||
// Mock execFile so the send-key route's `tmux` invocation is observable (not run for real).
|
||||
const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() }));
|
||||
// The real converter spawns a worker thread (TS worker file — not loadable
|
||||
// under vitest); the conversion pipeline itself is covered by
|
||||
// test/heic-jpeg-core.test.ts against the real heic-decode WASM.
|
||||
const heicConvert = vi.hoisted(() => vi.fn(async () => Buffer.from('ffd8ffe000104a4649460001', 'hex')));
|
||||
vi.mock('node:child_process', async (orig) => {
|
||||
const actual = await orig<typeof import('node:child_process')>();
|
||||
return { ...actual, execFile };
|
||||
});
|
||||
vi.mock('../../src/web/heic-jpeg-converter.js', () => ({ convertHeicToJpeg: heicConvert }));
|
||||
|
||||
// In-memory remote store so remote-case tests can inject hosts/cases without real JSON files.
|
||||
const remoteStore = vi.hoisted(() => ({
|
||||
hosts: [] as unknown[],
|
||||
cases: [] as unknown[],
|
||||
tmuxCheck: { ok: true, tmuxPath: '/usr/bin/tmux' } as { ok: boolean; tmuxPath?: string; error?: string },
|
||||
}));
|
||||
vi.mock('../../src/remote-hosts.js', async (orig) => {
|
||||
const actual = await orig<typeof import('../../src/remote-hosts.js')>();
|
||||
return {
|
||||
...actual,
|
||||
readRemoteHosts: vi.fn(async () => remoteStore.hosts),
|
||||
readRemoteCases: vi.fn(async () => remoteStore.cases),
|
||||
// Stub the remote-tmux prereq probe so quick-start never shells out to ssh.
|
||||
checkRemoteTmuxAvailable: vi.fn(async () => remoteStore.tmuxCheck),
|
||||
};
|
||||
});
|
||||
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
|
||||
@@ -45,6 +72,9 @@ async function createEnvelopeHarness(
|
||||
): Promise<LocalHarness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
await app.register(fastifyMultipart, {
|
||||
limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 4, parts: 5 },
|
||||
});
|
||||
|
||||
const ctx = createMockRouteContext();
|
||||
registerFn(app, ctx);
|
||||
@@ -78,6 +108,10 @@ describe('session-routes', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createEnvelopeHarness(registerSessionRoutes);
|
||||
// Reset remote store so tests start with empty hosts/cases and a passing tmux probe
|
||||
remoteStore.hosts = [];
|
||||
remoteStore.cases = [];
|
||||
remoteStore.tmuxCheck = { ok: true, tmuxPath: '/usr/bin/tmux' };
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -122,6 +156,123 @@ describe('session-routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/paste-image ==========
|
||||
|
||||
describe('POST /api/sessions/:id/paste-image', () => {
|
||||
function imageUploadBody(boundary: string, filename: string, mimetype: string, imageBytes: Buffer): Buffer {
|
||||
return Buffer.concat([
|
||||
Buffer.from(
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Disposition: form-data; name="image"; filename="${filename}"\r\n` +
|
||||
`Content-Type: ${mimetype}\r\n\r\n`
|
||||
),
|
||||
imageBytes,
|
||||
Buffer.from(`\r\n--${boundary}--\r\n`),
|
||||
]);
|
||||
}
|
||||
|
||||
it('converts HEIC paste images to JPEG attachments when browser-side normalization falls back', async () => {
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'codeman-heic-'));
|
||||
harness.ctx._session.workingDir = workDir;
|
||||
heicConvert.mockClear();
|
||||
|
||||
const boundary = 'codeman-test-boundary';
|
||||
const heic = Buffer.from('00000034667479706865696300000000', 'hex');
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/paste-image`,
|
||||
headers: {
|
||||
host: 'codeman.test',
|
||||
origin: 'http://codeman.test',
|
||||
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
payload: imageUploadBody(boundary, 'IMG_4996.HEIC', 'image/heic', heic),
|
||||
});
|
||||
|
||||
await rm(workDir, { recursive: true });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.path).toMatch(/\/\.claude-images\/paste-\d+-[a-f0-9]{8}\.jpg$/);
|
||||
expect(heicConvert).toHaveBeenCalledWith(heic);
|
||||
});
|
||||
|
||||
it('converts mislabeled HEIC (declared image/jpeg, HEIF bytes — the MIUI/Android case) via magic sniff', async () => {
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'codeman-heic-mislabel-'));
|
||||
harness.ctx._session.workingDir = workDir;
|
||||
heicConvert.mockClear();
|
||||
|
||||
const boundary = 'codeman-test-boundary';
|
||||
// ftyp brand mif1 — HEIF bytes hiding under a JPEG filename + MIME.
|
||||
const heic = Buffer.from('000000346674797061696631000000006d69663168656963', 'hex');
|
||||
heic.write('mif1', 8, 'ascii'); // major brand
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/paste-image`,
|
||||
headers: {
|
||||
host: 'codeman.test',
|
||||
origin: 'http://codeman.test',
|
||||
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
payload: imageUploadBody(boundary, 'IMG_2001.jpg', 'image/jpeg', heic),
|
||||
});
|
||||
|
||||
await rm(workDir, { recursive: true });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.path).toMatch(/\/\.claude-images\/paste-\d+-[a-f0-9]{8}\.jpg$/);
|
||||
expect(heicConvert).toHaveBeenCalledWith(heic);
|
||||
});
|
||||
|
||||
it('returns 415 with the error envelope when HEIC conversion fails', async () => {
|
||||
heicConvert.mockClear();
|
||||
heicConvert.mockRejectedValueOnce(new Error('HEIC dimensions 30000x30000 exceed the 64MP decode limit'));
|
||||
|
||||
const boundary = 'codeman-test-boundary';
|
||||
const heic = Buffer.from('00000034667479706865696300000000', 'hex');
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/paste-image`,
|
||||
headers: {
|
||||
host: 'codeman.test',
|
||||
origin: 'http://codeman.test',
|
||||
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
payload: imageUploadBody(boundary, 'IMG_4997.HEIC', 'image/heic', heic),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(415);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.errorCode).toBe('INVALID_INPUT');
|
||||
expect(body.error).toMatch(/HEIC/);
|
||||
});
|
||||
|
||||
it('rejects ftyp brands heic-decode cannot convert (e.g. heim) without invoking the converter', async () => {
|
||||
heicConvert.mockClear();
|
||||
|
||||
const boundary = 'codeman-test-boundary';
|
||||
const heim = Buffer.from('00000034667479706865696d00000000', 'hex');
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/paste-image`,
|
||||
headers: {
|
||||
host: 'codeman.test',
|
||||
origin: 'http://codeman.test',
|
||||
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
payload: imageUploadBody(boundary, 'IMG_4998.HEIC', 'image/heic', heim),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(415);
|
||||
expect(JSON.parse(res.body).success).toBe(false);
|
||||
expect(heicConvert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/sessions ==========
|
||||
|
||||
describe('GET /api/sessions', () => {
|
||||
@@ -481,6 +632,333 @@ describe('session-routes', () => {
|
||||
expect(body.data.terminalBuffer).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not strip VPA-like shell scrollback as Ink redraw bloat', async () => {
|
||||
const shellHistory = Array.from(
|
||||
{ length: 3000 },
|
||||
(_, index) => `SHELL_SCROLLBACK_${String(index + 1).padStart(6, '0')} payload payload payload \x1b[1d`
|
||||
).join('\n');
|
||||
harness.ctx._session.terminalBuffer = shellHistory;
|
||||
harness.ctx._session.mode = 'shell';
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(() => null);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.terminalBuffer).toContain('SHELL_SCROLLBACK_000001');
|
||||
expect(body.data.terminalBuffer).toContain('SHELL_SCROLLBACK_003000');
|
||||
});
|
||||
|
||||
it('preserves accumulated history before the live mux pane snapshot for Codex TUI replay', async () => {
|
||||
harness.ctx._session.terminalBuffer = 'hello world\nlater accumulated history';
|
||||
harness.ctx._session.mode = 'codex';
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
|
||||
() => 'visible tmux pane only\n› current prompt'
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.terminalBuffer).toContain('hello world');
|
||||
expect(body.data.terminalBuffer).toContain('later accumulated history');
|
||||
expect(body.data.terminalBuffer).toContain('\x1b[H\x1b[2Jvisible tmux pane only');
|
||||
expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
|
||||
body.data.terminalBuffer.indexOf('visible tmux pane only')
|
||||
);
|
||||
// No ?full=1 → visible-frame capture (no fullHistory opts).
|
||||
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
|
||||
});
|
||||
|
||||
// ── COD-47: full tmux scrollback replay on full page reload ──
|
||||
it('full reload (?full=1) requests full tmux history and replays boundary markers', async () => {
|
||||
// A realistic scrollback-length capture: ~5000 lines, well past one screen.
|
||||
const firstLine = 'SCROLLBACK_FIRST_LINE_0001';
|
||||
const lastLine = 'SCROLLBACK_LAST_LINE_5000';
|
||||
const lines: string[] = [firstLine];
|
||||
for (let i = 2; i <= 4999; i++) {
|
||||
lines.push(`scrollback line ${String(i).padStart(4, '0')} lorem ipsum payload`);
|
||||
}
|
||||
lines.push(lastLine);
|
||||
const fullHistoryCapture = lines.join('\n');
|
||||
|
||||
harness.ctx._session.mode = 'shell';
|
||||
harness.ctx._session.terminalBuffer = '';
|
||||
const captureSpy = vi.fn((_name: string, opts?: { fullHistory?: boolean }) =>
|
||||
opts?.fullHistory ? fullHistoryCapture : 'only the visible frame'
|
||||
);
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy;
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
// Full reload asked tmux for the entire scrollback, with the configured
|
||||
// capture bounds (history-line limit + byte cap for exec maxBuffer).
|
||||
expect(captureSpy).toHaveBeenCalledWith(
|
||||
harness.ctx._session.muxName,
|
||||
expect.objectContaining({
|
||||
fullHistory: true,
|
||||
historyLimitLines: expect.any(Number),
|
||||
maxCaptureBytes: expect.any(Number),
|
||||
})
|
||||
);
|
||||
// Both boundary markers survived the capture → route pipeline.
|
||||
expect(body.data.terminalBuffer).toContain(firstLine);
|
||||
expect(body.data.terminalBuffer).toContain(lastLine);
|
||||
expect(body.data.source).toBe('mux-full-history');
|
||||
expect(typeof body.data.fullSize).toBe('number');
|
||||
});
|
||||
|
||||
it('full reload (?full=1) returns the tmux capture ALONE — byte history is not duplicated', async () => {
|
||||
// The full-history capture is the rendered form of everything already in
|
||||
// the byte buffer; prepending the byte history would replay the whole
|
||||
// conversation twice (\x1b[2J clears the viewport, not xterm scrollback).
|
||||
harness.ctx._session.mode = 'claude';
|
||||
harness.ctx._session.terminalBuffer = 'BYTE_BUFFER_COPY of the conversation';
|
||||
const rendered = 'BYTE_BUFFER_COPY of the conversation\r\nplus older scrollback\r\n› prompt';
|
||||
const captureSpy = vi.fn((_name: string, opts?: { fullHistory?: boolean }) =>
|
||||
opts?.fullHistory ? rendered : 'visible frame only'
|
||||
);
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy;
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.source).toBe('mux-full-history');
|
||||
expect(body.data.terminalBuffer).toContain('plus older scrollback');
|
||||
// Capture alone: no history+clear-viewport concat, and the byte-buffer
|
||||
// content appears exactly once (from the capture, not a duplicate prepend).
|
||||
expect(body.data.terminalBuffer).not.toContain('\x1b[H\x1b[2J');
|
||||
expect(body.data.terminalBuffer.indexOf('BYTE_BUFFER_COPY')).toBe(
|
||||
body.data.terminalBuffer.lastIndexOf('BYTE_BUFFER_COPY')
|
||||
);
|
||||
});
|
||||
|
||||
it('full reload (?full=1) falls back to the byte history when the capture is unavailable', async () => {
|
||||
harness.ctx._session.mode = 'claude';
|
||||
harness.ctx._session.terminalBuffer = 'byte history survives';
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(() => null);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.source).toBe('history');
|
||||
expect(body.data.terminalBuffer).toContain('byte history survives');
|
||||
});
|
||||
|
||||
it('full reload forwards the configured history-line limit and byte cap to the capture', async () => {
|
||||
harness.ctx.getTerminalHistoryConfig = vi.fn(async () => ({
|
||||
terminalScrollbackLines: 60_000,
|
||||
tmuxHistoryLimit: 123_456,
|
||||
terminalBufferMaxBytes: 5 * 1024 * 1024,
|
||||
terminalBufferTrimBytes: 4 * 1024 * 1024,
|
||||
}));
|
||||
const captureSpy = vi.fn(() => 'full scrollback');
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy;
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(captureSpy).toHaveBeenCalledWith(harness.ctx._session.muxName, {
|
||||
fullHistory: true,
|
||||
historyLimitLines: 123_456,
|
||||
maxCaptureBytes: 5 * 1024 * 1024,
|
||||
});
|
||||
});
|
||||
|
||||
it('tab switch (with tail) uses the visible frame, not full history', async () => {
|
||||
harness.ctx._session.mode = 'codex';
|
||||
harness.ctx._session.terminalBuffer = 'accumulated history';
|
||||
const captureSpy = vi.fn((_name: string, opts?: { fullHistory?: boolean }) =>
|
||||
opts?.fullHistory ? 'FULL_HISTORY_SHOULD_NOT_APPEAR' : 'visible frame only\n› prompt'
|
||||
);
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy;
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal?tail=65536`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
// Tail/tab-switch must NOT request fullHistory (undefined opts).
|
||||
expect(captureSpy).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
|
||||
expect(body.data.terminalBuffer).toContain('visible frame only');
|
||||
expect(body.data.terminalBuffer).not.toContain('FULL_HISTORY_SHOULD_NOT_APPEAR');
|
||||
expect(body.data.source).toBe('mux-visible');
|
||||
});
|
||||
|
||||
it('caps huge full-history at the configured terminal buffer limit and marks truncated', async () => {
|
||||
// Shrink the cap so the test can exceed it without allocating 32MB.
|
||||
harness.ctx.getTerminalHistoryConfig = vi.fn(async () => ({
|
||||
terminalScrollbackLines: 100_000,
|
||||
tmuxHistoryLimit: 100_000,
|
||||
terminalBufferMaxBytes: 4096,
|
||||
terminalBufferTrimBytes: 4096,
|
||||
}));
|
||||
harness.ctx._session.mode = 'shell';
|
||||
harness.ctx._session.terminalBuffer = '';
|
||||
const oldestMarker = 'OLDEST_EVICTED_MARKER';
|
||||
const newestMarker = 'NEWEST_KEPT_MARKER';
|
||||
const filler = Array.from({ length: 400 }, (_, i) => `line ${i} ${'x'.repeat(30)}`).join('\n');
|
||||
const huge = `${oldestMarker}\n${filler}\n${newestMarker}`;
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
|
||||
(_name: string, opts?: { fullHistory?: boolean }) => (opts?.fullHistory ? huge : 'visible')
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.truncated).toBe(true);
|
||||
expect(body.data.fullSize).toBeGreaterThan(4096);
|
||||
expect(body.data.terminalBuffer.length).toBeLessThanOrEqual(4096);
|
||||
// Cap keeps the most RECENT bytes: newest marker survives, oldest is dropped.
|
||||
expect(body.data.terminalBuffer).toContain(newestMarker);
|
||||
expect(body.data.terminalBuffer).not.toContain(oldestMarker);
|
||||
});
|
||||
|
||||
it('treats stale Codex scrollback config as TUI replay', async () => {
|
||||
harness.ctx._session.terminalBuffer = 'hello world\nlater accumulated history';
|
||||
harness.ctx._session.mode = 'codex';
|
||||
harness.ctx._session.codexConfig = { renderMode: 'scrollback' } as any;
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
|
||||
() => 'visible tmux pane only\n› current prompt'
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.terminalBuffer).toContain('hello world');
|
||||
expect(body.data.terminalBuffer).toContain('later accumulated history');
|
||||
expect(body.data.terminalBuffer).toContain('\x1b[H\x1b[2Jvisible tmux pane only');
|
||||
expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
|
||||
body.data.terminalBuffer.indexOf('visible tmux pane only')
|
||||
);
|
||||
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
|
||||
});
|
||||
|
||||
it('preserves one-time OAuth authorization URLs in Codex TUI replay history', async () => {
|
||||
const authUrl =
|
||||
'https://auth.atlassian.com/authorize?response_type=code&client_id=abc&redirect_uri=http%3A%2F%2F127.0.0.1%3A35547%2Fcallback%2Fxyz';
|
||||
harness.ctx._session.terminalBuffer =
|
||||
'Authorize `atlassian` by opening this URL in your browser:\n' +
|
||||
authUrl +
|
||||
'\n(Browser launch failed; please copy the URL above manually.)\n';
|
||||
harness.ctx._session.mode = 'codex';
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
|
||||
() => 'visible tmux pane only\n› current prompt'
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.terminalBuffer).toContain(authUrl);
|
||||
expect(body.data.terminalBuffer).toContain('visible tmux pane only');
|
||||
expect(body.data.terminalBuffer.indexOf(authUrl)).toBeLessThan(
|
||||
body.data.terminalBuffer.indexOf('visible tmux pane only')
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves incidental OAuth URL mentions as ordinary Codex TUI history', async () => {
|
||||
const authUrl =
|
||||
'https://auth.atlassian.com/authorize?response_type=code&client_id=abc&redirect_uri=http%3A%2F%2F127.0.0.1%3A35547%2Fcallback%2Fxyz';
|
||||
harness.ctx._session.terminalBuffer =
|
||||
'Root cause: URLs like ' +
|
||||
authUrl +
|
||||
' could be present in history but missing from browser-rendered terminal replay.\n' +
|
||||
"+ 'Authorize `atlassian` by opening this URL in your browser:\\n' +\n";
|
||||
harness.ctx._session.mode = 'codex';
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
|
||||
() => 'visible tmux pane only\n› current prompt'
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.terminalBuffer).toContain(authUrl);
|
||||
expect(body.data.terminalBuffer).toContain('visible tmux pane only');
|
||||
});
|
||||
|
||||
it('preserves accumulated history before a live mux pane snapshot for non-Codex sessions', async () => {
|
||||
harness.ctx._session.terminalBuffer = 'hello world\nlater accumulated history';
|
||||
harness.ctx._session.mode = 'claude';
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
|
||||
() => 'visible tmux pane only\n› current prompt'
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.terminalBuffer).toContain('hello world');
|
||||
expect(body.data.terminalBuffer).toContain('later accumulated history');
|
||||
expect(body.data.terminalBuffer).toContain('visible tmux pane only');
|
||||
expect(body.data.terminalBuffer).toContain('\x1b[H\x1b[2Jvisible tmux pane only');
|
||||
expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
|
||||
body.data.terminalBuffer.indexOf('visible tmux pane only')
|
||||
);
|
||||
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
|
||||
});
|
||||
|
||||
it('uses live mux pane capture only when the accumulated buffer is empty', async () => {
|
||||
harness.ctx._session.terminalBuffer = '';
|
||||
harness.ctx._session.mode = 'codex';
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
|
||||
() => 'visible restored tmux pane\n› current prompt'
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.terminalBuffer).toContain('visible restored tmux pane');
|
||||
expect(body.data.terminalBuffer).toContain('› current prompt');
|
||||
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
|
||||
});
|
||||
|
||||
it('returns error for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
@@ -506,7 +984,7 @@ describe('session-routes', () => {
|
||||
expect(buf).toContain('\x1b[H\x1b[2J');
|
||||
expect(buf).toContain('LIVE-PANE-FRAME');
|
||||
expect(buf.indexOf('history-bytes')).toBeLessThan(buf.indexOf('LIVE-PANE-FRAME'));
|
||||
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName);
|
||||
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
|
||||
});
|
||||
|
||||
it('falls back to the byte history when no live pane buffer is available', async () => {
|
||||
@@ -608,6 +1086,54 @@ describe('session-routes', () => {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
// COD-118: this endpoint is ALSO the frontend's automatic re-attach path, so it
|
||||
// must never clear a tripped PTY-exit breaker unless the request explicitly asks.
|
||||
it('does NOT clear the PTY-exit breaker on an automatic re-attach (no body)', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/interactive`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(harness.ctx._session.resetRespawnBreaker).not.toHaveBeenCalled();
|
||||
expect(harness.ctx._session.startInteractive).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears the PTY-exit breaker when the explicit restart flag is sent (COD-118)', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/interactive`,
|
||||
payload: { clearBreaker: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(harness.ctx._session.resetRespawnBreaker).toHaveBeenCalledTimes(1);
|
||||
expect(harness.ctx._session.startInteractive).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a non-boolean clearBreaker flag', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/interactive`,
|
||||
payload: { clearBreaker: 'yes' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.errorCode).toBe(ApiErrorCode.INVALID_INPUT);
|
||||
expect(harness.ctx._session.resetRespawnBreaker).not.toHaveBeenCalled();
|
||||
expect(harness.ctx._session.startInteractive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// COD-118: the wiring exit handler detaches ALL session listeners on PTY exit;
|
||||
// re-attach must restore them or later trips/output go unobserved.
|
||||
it('re-runs session listener wiring before starting', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/interactive`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(harness.ctx.setupSessionListeners).toHaveBeenCalledWith(harness.ctx._session);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/shell ==========
|
||||
@@ -622,6 +1148,8 @@ describe('session-routes', () => {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx._session.startShell).toHaveBeenCalled();
|
||||
// COD-118: re-attach restores listener wiring detached by a prior PTY exit.
|
||||
expect(harness.ctx.setupSessionListeners).toHaveBeenCalledWith(harness.ctx._session);
|
||||
});
|
||||
|
||||
it('returns error if session is busy', async () => {
|
||||
@@ -765,6 +1293,142 @@ describe('session-routes', () => {
|
||||
// ========== POST /api/sessions (with resumeSessionId) ==========
|
||||
|
||||
describe('POST /api/sessions with resumeSessionId', () => {
|
||||
it('creates session from a remote case without local stat validation', async () => {
|
||||
// Remote cases go through /api/quick-start which skips local stat() of the workingDir.
|
||||
// /api/sessions always requires workingDir to exist on the local filesystem.
|
||||
const startShell = vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
|
||||
try {
|
||||
remoteStore.hosts = [
|
||||
{
|
||||
id: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
commands: { codex: 'exec codx personal' },
|
||||
},
|
||||
];
|
||||
remoteStore.cases = [{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }];
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/quick-start',
|
||||
payload: { caseName: 'gpu-work', mode: 'shell', name: 'Remote Shell' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.casePath).toBe('/home/ubuntu/work');
|
||||
const session = [...harness.ctx.sessions.values()].find((item) => item.id === body.data.sessionId);
|
||||
expect(session?.toState()).toMatchObject({
|
||||
workingDir: '/home/ubuntu/work',
|
||||
remote: expect.objectContaining({
|
||||
hostId: 'gpu-box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
commands: { codex: 'exec codx personal' },
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
startShell.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('quick-start creates remote case sessions through ssh metadata', async () => {
|
||||
const startShell = vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
|
||||
try {
|
||||
remoteStore.hosts = [
|
||||
{
|
||||
id: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
commands: { codex: 'exec codx personal' },
|
||||
},
|
||||
];
|
||||
remoteStore.cases = [{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }];
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/quick-start',
|
||||
payload: { caseName: 'gpu-work', mode: 'shell' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.casePath).toBe('/home/ubuntu/work');
|
||||
const session = [...harness.ctx.sessions.values()].find((item) => item.id === body.data.sessionId);
|
||||
expect(session?.toState()).toMatchObject({
|
||||
workingDir: '/home/ubuntu/work',
|
||||
remote: expect.objectContaining({
|
||||
hostId: 'gpu-box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
startShell.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a remote quick-start that carries envOverrides (inert over ssh)', async () => {
|
||||
remoteStore.hosts = [{ id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' }];
|
||||
remoteStore.cases = [{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }];
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/quick-start',
|
||||
payload: { caseName: 'gpu-work', mode: 'claude', envOverrides: { CLAUDE_CODE_FOO: 'bar' } },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('rejects a remote quick-start when the remote host lacks tmux', async () => {
|
||||
remoteStore.hosts = [{ id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' }];
|
||||
remoteStore.cases = [{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }];
|
||||
remoteStore.tmuxCheck = {
|
||||
ok: false,
|
||||
error: 'remote host 10.0.0.42 needs tmux installed for durable remote sessions',
|
||||
};
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/quick-start',
|
||||
payload: { caseName: 'gpu-work', mode: 'shell' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.OPERATION_FAILED));
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.OPERATION_FAILED });
|
||||
});
|
||||
|
||||
it('does not run local codex availability check for a remote codex case', async () => {
|
||||
// A remote codex case must NOT be blocked by the LOCAL codex availability gate
|
||||
// (the CLI runs on the remote host). Probe is stubbed ok in remoteStore.tmuxCheck.
|
||||
const startInteractive = vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
|
||||
try {
|
||||
remoteStore.hosts = [
|
||||
{ id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', commands: { codex: 'exec codx' } },
|
||||
];
|
||||
remoteStore.cases = [{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }];
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/quick-start',
|
||||
payload: { caseName: 'gpu-work', mode: 'codex' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body).success).toBe(true);
|
||||
} finally {
|
||||
startInteractive.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('creates session with valid resumeSessionId', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
|
||||
@@ -491,6 +491,29 @@ describe('ws-routes', () => {
|
||||
for (const ws of connections) ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('reconnecting client (same cid) is admitted at the cap instead of 4008 (COD-137)', async () => {
|
||||
const connections: WebSocket[] = [];
|
||||
try {
|
||||
// Fill all 5 slots with DISTINCT clients, one of which is "alice".
|
||||
for (const c of ['alice', 'b', 'c', 'd', 'e']) {
|
||||
connections.push(await connectWs(`/ws/sessions/ws-test-session/terminal?cid=${c}`));
|
||||
}
|
||||
|
||||
// Alice reconnects WHILE her old socket is still registered (the
|
||||
// over-count window). This must reclaim her slot, not hit the cap.
|
||||
const aliceNew = await connectWs('/ws/sessions/ws-test-session/terminal?cid=alice');
|
||||
connections.push(aliceNew);
|
||||
|
||||
// Sanity: the reconnected socket is live and usable.
|
||||
ctx._session.emit('terminal', 'reconnected-ok');
|
||||
const msg = (await nextMessage(aliceNew)) as { t: string; d: string };
|
||||
expect(msg.t).toBe('o');
|
||||
expect(msg.d).toContain('reconnected-ok');
|
||||
} finally {
|
||||
for (const ws of connections) ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Heartbeat ==========
|
||||
|
||||
@@ -144,6 +144,236 @@ describe('Codex quick start settings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('case selector refresh', () => {
|
||||
it('sorts case picker options alphabetically and filters by case or host label', () => {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: { getElementById: () => null },
|
||||
console,
|
||||
});
|
||||
|
||||
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
const cases = [
|
||||
{ name: 'zeta' },
|
||||
{ name: 'moneytrove', location: 'remote', remote: { hostId: 'mac-mini', path: '/Users/saqeb/moneytrove' } },
|
||||
{ name: 'Alpha' },
|
||||
{ name: 'plex-previews' },
|
||||
];
|
||||
|
||||
const options = app.buildCasePickerOptions(cases);
|
||||
|
||||
expect(options.map((option: any) => option.name)).toEqual([
|
||||
'Alpha',
|
||||
'moneytrove',
|
||||
'plex-previews',
|
||||
'testcase',
|
||||
'zeta',
|
||||
]);
|
||||
expect(options.find((option: any) => option.name === 'moneytrove')?.label).toBe('moneytrove @ mac-mini');
|
||||
expect(app.filterCasePickerOptions(options, 'MAC').map((option: any) => option.name)).toEqual(['moneytrove']);
|
||||
expect(app.filterCasePickerOptions(options, 'plex').map((option: any) => option.name)).toEqual(['plex-previews']);
|
||||
});
|
||||
|
||||
it('launches the highlighted case with the current run mode when pressing Enter in the picker', () => {
|
||||
const elements: Record<string, any> = {};
|
||||
const listeners: Record<string, (event: any) => void> = {};
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
|
||||
elements.quickStartCase = {
|
||||
value: 'Alpha',
|
||||
dataset: {},
|
||||
};
|
||||
elements.quickStartCaseSearch = {
|
||||
value: 'mon',
|
||||
dataset: {},
|
||||
setAttribute: vi.fn(),
|
||||
removeAttribute: vi.fn(),
|
||||
addEventListener: vi.fn((event: string, handler: (event: any) => void) => {
|
||||
listeners[event] = handler;
|
||||
}),
|
||||
select: vi.fn(),
|
||||
};
|
||||
elements.quickStartCaseList = {
|
||||
innerHTML: '',
|
||||
classList: { add: vi.fn(), remove: vi.fn() },
|
||||
addEventListener: vi.fn(),
|
||||
};
|
||||
elements.quickStartCasePicker = {
|
||||
contains: () => true,
|
||||
};
|
||||
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: {
|
||||
getElementById: (id: string) => elements[id] ?? null,
|
||||
addEventListener: vi.fn(),
|
||||
},
|
||||
console,
|
||||
escapeHtml: (s: string) => s,
|
||||
});
|
||||
|
||||
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
app.cases = [
|
||||
{ name: 'Alpha' },
|
||||
{ name: 'moneytrove', location: 'remote', remote: { hostId: 'mac-mini', path: '/Users/saqeb/moneytrove' } },
|
||||
{ name: 'zeta' },
|
||||
];
|
||||
app.updateDirDisplayForCase = vi.fn();
|
||||
app.updateMobileCaseLabel = vi.fn();
|
||||
app.saveLastUsedCase = vi.fn();
|
||||
app.run = vi.fn(async () => {});
|
||||
|
||||
app.setupQuickStartCasePicker();
|
||||
listeners.keydown({ key: 'Enter', preventDefault: vi.fn() });
|
||||
|
||||
expect(elements.quickStartCase.value).toBe('moneytrove');
|
||||
expect(app.run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('creates remote shell sessions by caseName instead of remote display path', async () => {
|
||||
const elements: Record<string, any> = {
|
||||
quickStartCase: { value: 'gpu-work' },
|
||||
shellCount: { value: '1' },
|
||||
};
|
||||
const requests: Array<{ url: string; body?: any }> = [];
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
localStorage: {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
},
|
||||
document: {
|
||||
getElementById: (id: string) => elements[id] ?? null,
|
||||
},
|
||||
fetch: async (url: string, init?: { body?: string }) => {
|
||||
requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined });
|
||||
if (url === '/api/cases/gpu-work') {
|
||||
return {
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
name: 'gpu-work',
|
||||
path: 'ubuntu@10.0.0.42:/home/ubuntu/work',
|
||||
location: 'remote',
|
||||
remote: { hostId: 'gpu-box', path: '/home/ubuntu/work' },
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url === '/api/quick-start') {
|
||||
return { json: async () => ({ success: true, data: { sessionId: 'sess-1' } }) };
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
},
|
||||
console,
|
||||
});
|
||||
|
||||
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
|
||||
app.sessions = new Map();
|
||||
app.cases = [{ name: 'gpu-work', path: 'ubuntu@10.0.0.42:/home/ubuntu/work', location: 'remote' }];
|
||||
app.getTerminalDimensions = () => null;
|
||||
app.selectSession = async () => {};
|
||||
|
||||
await app.runShell();
|
||||
|
||||
// Remote cases must ride /api/quick-start (which resolves the remote case and
|
||||
// launches over ssh) — POST /api/sessions stat-validates workingDir locally and
|
||||
// its schema has no caseName, so the remote display path must never reach it.
|
||||
expect(requests.find((req) => req.url === '/api/quick-start')?.body).toMatchObject({
|
||||
caseName: 'gpu-work',
|
||||
mode: 'shell',
|
||||
});
|
||||
expect(requests.find((req) => req.url === '/api/quick-start')?.body).not.toHaveProperty('workingDir');
|
||||
expect(requests.some((req) => req.url === '/api/sessions')).toBe(false);
|
||||
});
|
||||
|
||||
it('removes a deleted selected case from the dropdown and blurs the native picker', async () => {
|
||||
const elements: Record<string, any> = {};
|
||||
const requests: Array<{ url: string; method: string; body?: any }> = [];
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const quickStartCase = {
|
||||
value: 'deleted-case',
|
||||
innerHTML: '<option value="deleted-case">deleted-case</option><option value="kept-case">kept-case</option>',
|
||||
dataset: {},
|
||||
blur: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
};
|
||||
|
||||
elements.quickStartCase = quickStartCase;
|
||||
elements.caseManageList = { innerHTML: '' };
|
||||
elements.mobileCaseName = { textContent: '' };
|
||||
elements.dirDisplay = { textContent: '' };
|
||||
elements.dirInput = { value: '' };
|
||||
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
MobileDetection: { getDeviceType: () => 'desktop' },
|
||||
localStorage: {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
},
|
||||
document: {
|
||||
getElementById: (id: string) => elements[id] ?? null,
|
||||
},
|
||||
confirm: () => true,
|
||||
fetch: async (url: string, init?: { method?: string; body?: string }) => {
|
||||
requests.push({ url, method: init?.method ?? 'GET', body: init?.body ? JSON.parse(init.body) : undefined });
|
||||
if (url === '/api/cases/deleted-case')
|
||||
return { json: async () => ({ success: true, data: { name: 'deleted-case' } }) };
|
||||
// The server's preSerialization hook wraps bare payloads as { success, data },
|
||||
// so the frontend reads `.data` off every JSON response — mirror that here.
|
||||
if (url === '/api/settings')
|
||||
return { ok: true, json: async () => ({ success: true, data: { lastUsedCase: 'deleted-case' } }) };
|
||||
if (url === '/api/cases')
|
||||
return { json: async () => ({ success: true, data: [{ name: 'kept-case', path: '/tmp/kept-case' }] }) };
|
||||
if (url === '/api/cases/kept-case')
|
||||
return { json: async () => ({ success: true, data: { path: '/tmp/kept-case' } }) };
|
||||
if (url === '/api/settings' && init?.method === 'PUT') return { json: async () => ({ success: true }) };
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
},
|
||||
console,
|
||||
escapeHtml: (s: string) => s,
|
||||
});
|
||||
|
||||
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
app.cases = [
|
||||
{ name: 'deleted-case', path: '/tmp/deleted-case' },
|
||||
{ name: 'kept-case', path: '/tmp/kept-case' },
|
||||
];
|
||||
app.showToast = vi.fn();
|
||||
|
||||
await app.deleteCase('deleted-case');
|
||||
|
||||
expect(quickStartCase.blur).toHaveBeenCalled();
|
||||
expect(quickStartCase.innerHTML).not.toContain('deleted-case');
|
||||
expect(quickStartCase.innerHTML).toContain('kept-case');
|
||||
expect(elements.mobileCaseName.textContent).toBe('kept-case');
|
||||
expect(requests).toContainEqual({
|
||||
url: '/api/settings',
|
||||
method: 'PUT',
|
||||
body: { lastUsedCase: 'kept-case' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gemini quick start', () => {
|
||||
// Regression guard for the ApiResponse-envelope unwrap in runGemini(): the
|
||||
// status check must read `.data.available` and the quick-start response must
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { resolveMuxAttachCwd, Session } from '../src/session.js';
|
||||
import type { SessionAttachmentHistoryItem } from '../src/types/session.js';
|
||||
import {
|
||||
ATTACHMENT_HISTORY_LIMIT,
|
||||
@@ -161,4 +161,37 @@ describe('session attachment history', () => {
|
||||
expect(persisted).toHaveLength(1);
|
||||
expect(persisted?.[0].fileName).toBe('ok.png');
|
||||
});
|
||||
|
||||
it('attaches remote mux sessions from a local cwd', () => {
|
||||
expect(
|
||||
resolveMuxAttachCwd('/Users/remote/project', {
|
||||
hostId: 'mac-mini',
|
||||
label: 'Mac Mini',
|
||||
host: '192.168.21.109',
|
||||
username: 'saqebakhter',
|
||||
remotePath: '/Users/remote/project',
|
||||
})
|
||||
).toBe('/tmp');
|
||||
expect(resolveMuxAttachCwd('/opt/projects/Codeman')).toBe('/opt/projects/Codeman');
|
||||
});
|
||||
|
||||
it('round-trips remote metadata through the Session constructor (restart-recovery contract)', () => {
|
||||
// restoreMuxSessions() reconstructs recovered sessions via
|
||||
// `new Session({ ..., remote: muxSession.remote ?? savedState.remote })`. If that
|
||||
// remote does not survive toState(), the next persistSessionState() erases it from
|
||||
// state.json AND the attach cwd falls back to the (nonexistent-locally) remote path.
|
||||
const remote = {
|
||||
hostId: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
commands: { claude: 'exec claude --dangerously-skip-permissions' },
|
||||
};
|
||||
const restored = new Session({ workingDir: '/home/ubuntu/work', remote });
|
||||
const state = restored.toState();
|
||||
expect(state.remote).toEqual(remote);
|
||||
// Recovered attach cwd must be a LOCAL path, never the remote-only workingDir.
|
||||
expect(resolveMuxAttachCwd(state.workingDir, state.remote)).toBe('/tmp');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @fileoverview Tests for CLI environment builders.
|
||||
*
|
||||
* Port: N/A (no server needed)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildMuxAttachEnv } from '../src/session-cli-builder.js';
|
||||
|
||||
describe('buildMuxAttachEnv', () => {
|
||||
it('does not pass an inherited tmux context into tmux attach clients', () => {
|
||||
const originalTmux = process.env.TMUX;
|
||||
const originalTmuxPane = process.env.TMUX_PANE;
|
||||
process.env.TMUX = '/tmp/tmux-1000/codeman,1169416,9';
|
||||
process.env.TMUX_PANE = '%9';
|
||||
|
||||
try {
|
||||
const env = buildMuxAttachEnv();
|
||||
|
||||
expect(env.TMUX).toBeUndefined();
|
||||
expect(env.TMUX_PANE).toBeUndefined();
|
||||
} finally {
|
||||
if (originalTmux === undefined) {
|
||||
delete process.env.TMUX;
|
||||
} else {
|
||||
process.env.TMUX = originalTmux;
|
||||
}
|
||||
if (originalTmuxPane === undefined) {
|
||||
delete process.env.TMUX_PANE;
|
||||
} else {
|
||||
process.env.TMUX_PANE = originalTmuxPane;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// COD-115: `{...process.env, TMUX: undefined}` leaves the KEY present with value
|
||||
// undefined; node-pty serializes that as the literal string "TMUX=undefined", which
|
||||
// still trips tmux's nesting guard and kills the attach-bridge PTY (exit 1 → respawn
|
||||
// loop). The keys must be genuinely ABSENT, which only `delete` achieves.
|
||||
it('deletes tmux/claude context keys entirely (absent, not present-with-undefined) (COD-115)', () => {
|
||||
const saved = {
|
||||
TMUX: process.env.TMUX,
|
||||
TMUX_PANE: process.env.TMUX_PANE,
|
||||
CLAUDECODE: process.env.CLAUDECODE,
|
||||
};
|
||||
process.env.TMUX = '/tmp/tmux-1000/codeman,1169416,9';
|
||||
process.env.TMUX_PANE = '%9';
|
||||
process.env.CLAUDECODE = '1';
|
||||
|
||||
try {
|
||||
const env = buildMuxAttachEnv();
|
||||
|
||||
expect('TMUX' in env).toBe(false);
|
||||
expect('TMUX_PANE' in env).toBe(false);
|
||||
expect('CLAUDECODE' in env).toBe(false);
|
||||
} finally {
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) {
|
||||
delete process.env[k];
|
||||
} else {
|
||||
process.env[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { createSessionListeners } from '../src/web/session-listener-wiring.js';
|
||||
|
||||
describe('session listener wiring', () => {
|
||||
it('forwards the attachment request source through registerAttachment', async () => {
|
||||
const session = new Session({ id: 'wiring-attach-source-test', workingDir: '/tmp', mode: 'codex' });
|
||||
const registerAttachment = vi.fn(async () => undefined);
|
||||
const deps = { registerAttachment } as unknown as Parameters<typeof createSessionListeners>[1];
|
||||
|
||||
const refs = createSessionListeners(session, deps);
|
||||
refs.attachmentRequested({ path: '/tmp/mockup.png', source: 'codex-generated' });
|
||||
refs.attachmentRequested({ path: '/tmp/report.pdf', source: 'external' });
|
||||
|
||||
expect(registerAttachment).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'wiring-attach-source-test',
|
||||
'/tmp/mockup.png',
|
||||
'codex-generated'
|
||||
);
|
||||
expect(registerAttachment).toHaveBeenNthCalledWith(2, 'wiring-attach-source-test', '/tmp/report.pdf', 'external');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const appSource = readFileSync('src/web/public/app.js', 'utf8');
|
||||
const settingsSource = readFileSync('src/web/public/settings-ui.js', 'utf8');
|
||||
const htmlSource = readFileSync('src/web/public/index.html', 'utf8');
|
||||
|
||||
describe('shortcut registry and overlay', () => {
|
||||
it('defines shortcut metadata that can be overridden from global settings', () => {
|
||||
expect(appSource).toContain('const DEFAULT_SHORTCUTS = [');
|
||||
expect(appSource).toContain('shortcutOverrides');
|
||||
expect(appSource).toContain('getShortcutRegistry()');
|
||||
expect(appSource).toContain('matchesShortcutEvent(e, shortcut)');
|
||||
});
|
||||
|
||||
it('renders a shortcut overlay modal from the registry', () => {
|
||||
expect(htmlSource).toContain('id="shortcutOverlayModal"');
|
||||
expect(htmlSource).toContain('id="shortcutOverlayList"');
|
||||
expect(appSource).toContain('showShortcutOverlay()');
|
||||
expect(appSource).toContain('renderShortcutOverlay()');
|
||||
expect(appSource).toContain('closeShortcutOverlay()');
|
||||
});
|
||||
|
||||
it('adds Ctrl/Option question-mark bindings for the overlay', () => {
|
||||
expect(appSource).toContain("id: 'show-shortcuts'");
|
||||
expect(appSource).toContain("modifiers: ['ctrl']");
|
||||
expect(appSource).toContain("modifiers: ['alt']");
|
||||
expect(appSource).toContain("key: '?'");
|
||||
expect(appSource).toContain("code: 'Slash'");
|
||||
expect(appSource).not.toContain("key: '/'");
|
||||
});
|
||||
|
||||
it('exposes shortcut overrides in a dedicated App Settings shortcuts tab', () => {
|
||||
expect(htmlSource).toContain('data-tab="settings-shortcuts"');
|
||||
expect(htmlSource).toContain('id="settings-shortcuts"');
|
||||
expect(htmlSource).toContain('id="appSettingsShortcutsList"');
|
||||
expect(htmlSource).not.toContain('id="appSettingsShortcutOverrides"');
|
||||
expect(htmlSource).not.toContain('Shortcut Overrides</span>');
|
||||
expect(settingsSource).toContain('renderShortcutSettingsList');
|
||||
expect(settingsSource).toContain('readShortcutOverridesFromSettings');
|
||||
expect(settingsSource).toContain('startShortcutCapture');
|
||||
expect(settingsSource).toContain('onShortcutCaptureKeydown');
|
||||
expect(settingsSource).toContain('settings.shortcutOverrides');
|
||||
});
|
||||
|
||||
it('renders shortcut rows with capture, typed input, reset, and disable controls', () => {
|
||||
expect(settingsSource).toContain('shortcut-setting-row');
|
||||
expect(settingsSource).toContain('shortcut-capture-btn');
|
||||
expect(settingsSource).toContain('shortcut-binding-input');
|
||||
expect(settingsSource).toContain('shortcut-reset-btn');
|
||||
expect(settingsSource).toContain('shortcut-enabled-checkbox');
|
||||
});
|
||||
|
||||
it('styles the shortcut settings rows and overlay (no unstyled tab)', () => {
|
||||
const css = readFileSync('src/web/public/styles.css', 'utf8');
|
||||
expect(css).toContain('.shortcut-setting-row {');
|
||||
expect(css).toContain('.shortcut-capture-btn,');
|
||||
expect(css).toContain('.shortcut-overlay-row {');
|
||||
});
|
||||
|
||||
it('saveAppSettings preserves shortcutOverrides (rebuilt-from-DOM saves must not wipe them)', () => {
|
||||
// Same trap as showTokenCount/showCost: saveAppSettings() rebuilds the settings
|
||||
// object fresh from the DOM, so keys edited elsewhere (the Shortcuts tab) must be
|
||||
// explicitly carried over from the previously stored blob.
|
||||
expect(settingsSource).toContain(
|
||||
'if (_prev.shortcutOverrides !== undefined) settings.shortcutOverrides = _prev.shortcutOverrides;'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the full help modal reachable now that Ctrl+? opens the registry overlay', () => {
|
||||
// The legacy #helpModal (full shortcut reference) lost its only opener when
|
||||
// Ctrl+? was rerouted to the overlay; the overlay footer must link to it.
|
||||
expect(htmlSource).toContain('shortcut-overlay-footer');
|
||||
expect(htmlSource).toContain('app.closeShortcutOverlay(); app.showHelp()');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Functional coverage (vm-sandbox harness, mirrors run-mode-ui.test.ts) ────
|
||||
// The grep assertions above pin the wiring; these exercise the actual
|
||||
// persistence round-trip and capture flow that were broken in review.
|
||||
|
||||
function makeLocalStorage() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
|
||||
setItem: (k: string, v: string) => void store.set(k, String(v)),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
key: (i: number) => [...store.keys()][i] ?? null,
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function loadSettingsHarness() {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const localStorage = makeLocalStorage();
|
||||
const elements: Record<string, any> = {};
|
||||
const holder: { queryResult: any } = { queryResult: null };
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
MobileDetection: { getDeviceType: () => 'desktop', isMobile: () => false, isTouchDevice: () => false },
|
||||
localStorage,
|
||||
document: {
|
||||
getElementById: (id: string) => elements[id] ?? null,
|
||||
querySelector: () => holder.queryResult,
|
||||
},
|
||||
console,
|
||||
escapeHtml: (s: string) => String(s),
|
||||
});
|
||||
|
||||
const settingsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8');
|
||||
vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
return { app, localStorage, elements, holder };
|
||||
}
|
||||
|
||||
describe('shortcut settings persistence and capture', () => {
|
||||
it('persists overrides under the app-settings storage key and round-trips through the cache', () => {
|
||||
const { app, localStorage } = loadSettingsHarness();
|
||||
|
||||
app.toggleShortcutEnabled('close-session', false);
|
||||
|
||||
// Written to the SAME key loadAppSettingsFromStorage() reads (NOT the
|
||||
// legacy 'codeman:settings' key), and the in-memory cache stays coherent.
|
||||
const raw = localStorage.getItem('codeman-app-settings');
|
||||
expect(raw).toBeTruthy();
|
||||
expect(JSON.parse(raw!).shortcutOverrides['close-session']).toMatchObject({ disabled: true });
|
||||
expect(localStorage.getItem('codeman:settings')).toBeNull();
|
||||
expect(app.readShortcutOverridesFromSettings()['close-session']).toMatchObject({ disabled: true });
|
||||
|
||||
app.resetShortcutOverride('close-session');
|
||||
const after = JSON.parse(localStorage.getItem('codeman-app-settings')!);
|
||||
expect(after.shortcutOverrides['close-session']).toBeUndefined();
|
||||
expect(app.readShortcutOverridesFromSettings()['close-session']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('captures multi-modifier combos: bare modifier keydowns do not end the capture', () => {
|
||||
const { app, localStorage, holder } = loadSettingsHarness();
|
||||
const listeners: Array<(e: any) => void> = [];
|
||||
const input = {
|
||||
value: '',
|
||||
focus: vi.fn(),
|
||||
addEventListener: vi.fn((_ev: string, fn: (e: any) => void) => listeners.push(fn)),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
holder.queryResult = input;
|
||||
|
||||
app.startShortcutCapture('clear-terminal');
|
||||
expect(input.value).toBe('Press keys…');
|
||||
const handler = listeners[0];
|
||||
|
||||
// First keydown of Ctrl+Shift+P is 'Control' — must not finalize.
|
||||
handler({ key: 'Control', ctrlKey: true, preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
||||
expect(input.removeEventListener).not.toHaveBeenCalled();
|
||||
|
||||
handler({
|
||||
key: 'P',
|
||||
code: 'KeyP',
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
});
|
||||
expect(input.removeEventListener).toHaveBeenCalledTimes(1);
|
||||
const stored = JSON.parse(localStorage.getItem('codeman-app-settings')!);
|
||||
expect(stored.shortcutOverrides['clear-terminal'].bindings[0]).toMatchObject({
|
||||
modifiers: ['ctrl', 'shift'],
|
||||
key: 'P',
|
||||
code: 'KeyP',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects captures without a Ctrl/Cmd/Alt modifier (a bare key would fire while typing)', () => {
|
||||
const { app, localStorage, holder } = loadSettingsHarness();
|
||||
const listeners: Array<(e: any) => void> = [];
|
||||
holder.queryResult = {
|
||||
value: '',
|
||||
focus: vi.fn(),
|
||||
addEventListener: vi.fn((_ev: string, fn: (e: any) => void) => listeners.push(fn)),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
app.showToast = vi.fn();
|
||||
|
||||
app.startShortcutCapture('clear-terminal');
|
||||
listeners[0]({ key: 'x', code: 'KeyX', preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
||||
|
||||
expect(localStorage.getItem('codeman-app-settings')).toBeNull();
|
||||
expect(app.showToast).toHaveBeenCalledWith('Shortcut must include Ctrl, Cmd, or Alt', 'error');
|
||||
});
|
||||
|
||||
it('renders the shortcuts list when the Shortcuts settings tab is opened', () => {
|
||||
const { app, elements } = loadSettingsHarness();
|
||||
elements.appSettingsModal = { querySelectorAll: () => [] };
|
||||
app.renderShortcutSettingsList = vi.fn();
|
||||
|
||||
app.switchSettingsTab('settings-shortcuts');
|
||||
expect(app.renderShortcutSettingsList).toHaveBeenCalledTimes(1);
|
||||
|
||||
app.switchSettingsTab('settings-display');
|
||||
expect(app.renderShortcutSettingsList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders configurable rows with delegated controls (no inline onclick) and fixed rows read-only', () => {
|
||||
const { app, elements } = loadSettingsHarness();
|
||||
const list: any = { innerHTML: '', dataset: {}, addEventListener: vi.fn() };
|
||||
elements.appSettingsShortcutsList = list;
|
||||
app.getShortcutRegistry = () => [
|
||||
{
|
||||
id: 'clear-terminal',
|
||||
group: 'Terminal',
|
||||
label: 'Clear Terminal',
|
||||
bindings: [{ modifiers: ['ctrl'], key: 'l' }],
|
||||
action: 'clearTerminal',
|
||||
},
|
||||
{ id: 'close-panels', group: 'Panels', label: 'Close Panels', displayBindings: ['Escape'] },
|
||||
];
|
||||
|
||||
app.renderShortcutSettingsList();
|
||||
|
||||
expect(list.innerHTML).not.toContain('onclick=');
|
||||
expect((list.innerHTML.match(/shortcut-capture-btn/g) || []).length).toBe(1);
|
||||
expect(list.innerHTML).toContain('shortcut-setting-row--fixed');
|
||||
// Delegated listeners wired exactly once.
|
||||
expect(list.addEventListener).toHaveBeenCalledTimes(2);
|
||||
app.renderShortcutSettingsList();
|
||||
expect(list.addEventListener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* @fileoverview Regression tests for the buffer-load flush path (COD-144).
|
||||
*
|
||||
* Bug: newly launched Shell sessions rendered BLANK until a tab-switch. The
|
||||
* buffer-load path (`selectSession` → `_beginBufferLoad`/`_finishBufferLoad`)
|
||||
* QUEUES live SSE terminal events while `_isLoadingBuffer` is true, then on
|
||||
* completion DISCARDS the queue (`_loadBufferQueue = null`). That de-dup is
|
||||
* correct for an established session (the fetched buffer already contains the
|
||||
* queued output, so replaying it would duplicate Ink redraws). But for a
|
||||
* brand-new shell the fetch resolves BEFORE the PTY emits its prompt — the
|
||||
* fetched buffer is empty and the prompt arrives only as a queued event, which
|
||||
* then gets discarded → blank terminal.
|
||||
*
|
||||
* Fix: `_finishBufferLoad(owner, { flushQueued })` REPLAYS the queued events
|
||||
* through `batchTerminalWrite()` (after `_isLoadingBuffer` is cleared, so they
|
||||
* write through normally) ONLY when the load painted nothing. The default path
|
||||
* (no opts) still discards, preserving de-dup for established sessions.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed context (no jsdom — jsdom is broken on this
|
||||
* box; see connection-indicator.test.ts). We extract the REAL
|
||||
* `_beginBufferLoad`/`_finishBufferLoad` mixin methods from terminal-ui.js by
|
||||
* running it against a fake `CodemanApp` and capturing `CodemanApp.prototype`,
|
||||
* then copy them onto a minimal stub whose `batchTerminalWrite` is a spy. This
|
||||
* exercises the real flush/discard logic without a full xterm fake.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/** Run terminal-ui.js in a vm against a fake CodemanApp and return the captured prototype mixin. */
|
||||
function loadTerminalMixin(): Record<string, unknown> {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
||||
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, unknown> };
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
requestAnimationFrame: vi.fn(),
|
||||
CodemanApp: FakeCodemanApp,
|
||||
// terminal-ui.js IIFE is invoked with `window`; it reads/writes a few globals.
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
document: { addEventListener: vi.fn() },
|
||||
});
|
||||
vm.runInContext(source, context);
|
||||
return FakeCodemanApp.prototype;
|
||||
}
|
||||
|
||||
const mixin = loadTerminalMixin();
|
||||
|
||||
type BufferLoadApp = {
|
||||
_bufferLoadSeq: number;
|
||||
_bufferLoadOwner: string | null;
|
||||
_isLoadingBuffer: boolean;
|
||||
_loadBufferQueue: string[] | null;
|
||||
batchTerminalWrite: (data: string) => void;
|
||||
_beginBufferLoad: (owner?: string) => string;
|
||||
_finishBufferLoad: (owner?: string, opts?: { flushQueued?: boolean }) => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimal stub carrying the buffer-load state plus the REAL begin/finish methods.
|
||||
* `batchTerminalWrite` is a spy so flushed events are observable without a real
|
||||
* xterm terminal. The real `batchTerminalWrite` would queue while loading, but
|
||||
* the flush runs AFTER `_isLoadingBuffer` is cleared, so a spy is faithful here.
|
||||
*/
|
||||
function makeApp() {
|
||||
const writes: string[] = [];
|
||||
const app: BufferLoadApp = {
|
||||
_bufferLoadSeq: 0,
|
||||
_bufferLoadOwner: null,
|
||||
_isLoadingBuffer: false,
|
||||
_loadBufferQueue: null,
|
||||
batchTerminalWrite: vi.fn((data: string) => {
|
||||
writes.push(data);
|
||||
}),
|
||||
_beginBufferLoad: mixin._beginBufferLoad as BufferLoadApp['_beginBufferLoad'],
|
||||
_finishBufferLoad: mixin._finishBufferLoad as BufferLoadApp['_finishBufferLoad'],
|
||||
};
|
||||
return { app, writes };
|
||||
}
|
||||
|
||||
/** Simulate live SSE events arriving while a buffer load is in progress (the queue path). */
|
||||
function pushWhileLoading(app: BufferLoadApp, data: string) {
|
||||
// Mirrors batchTerminalWrite's queue branch: if loading, push to the queue.
|
||||
if (app._isLoadingBuffer && app._loadBufferQueue) app._loadBufferQueue.push(data);
|
||||
}
|
||||
|
||||
describe('buffer-load flush (COD-144)', () => {
|
||||
it('finish WITHOUT flushQueued discards the queue (de-dup preserved for established sessions)', () => {
|
||||
const { app, writes } = makeApp();
|
||||
const owner = app._beginBufferLoad('load-1');
|
||||
pushWhileLoading(app, 'chunk-a');
|
||||
pushWhileLoading(app, 'chunk-b');
|
||||
|
||||
const ok = app._finishBufferLoad(owner); // default: discard
|
||||
expect(ok).toBe(true);
|
||||
expect(app._isLoadingBuffer).toBe(false);
|
||||
expect(app._loadBufferQueue).toBeNull();
|
||||
// Queued events were NOT replayed.
|
||||
expect(app.batchTerminalWrite).not.toHaveBeenCalled();
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
|
||||
it('finish WITH { flushQueued: true } replays queued events in order, exactly once each', () => {
|
||||
const { app, writes } = makeApp();
|
||||
const owner = app._beginBufferLoad('load-2');
|
||||
pushWhileLoading(app, 'prompt-1');
|
||||
pushWhileLoading(app, 'prompt-2');
|
||||
|
||||
const ok = app._finishBufferLoad(owner, { flushQueued: true });
|
||||
expect(ok).toBe(true);
|
||||
expect(app._isLoadingBuffer).toBe(false);
|
||||
expect(app._loadBufferQueue).toBeNull();
|
||||
// Both chunks replayed, IN ORDER, exactly once each.
|
||||
expect(writes).toEqual(['prompt-1', 'prompt-2']);
|
||||
expect(app.batchTerminalWrite).toHaveBeenCalledTimes(2);
|
||||
expect(app.batchTerminalWrite).toHaveBeenNthCalledWith(1, 'prompt-1');
|
||||
expect(app.batchTerminalWrite).toHaveBeenNthCalledWith(2, 'prompt-2');
|
||||
});
|
||||
|
||||
it('flushed events are not re-queued (the queue is null when batchTerminalWrite runs)', () => {
|
||||
const { app } = makeApp();
|
||||
const owner = app._beginBufferLoad('load-3');
|
||||
pushWhileLoading(app, 'only');
|
||||
|
||||
// Spy that, like the real method, would re-queue if loading were still active.
|
||||
let reQueued = false;
|
||||
app.batchTerminalWrite = vi.fn((data: string) => {
|
||||
if (app._isLoadingBuffer && app._loadBufferQueue) {
|
||||
app._loadBufferQueue.push(data);
|
||||
reQueued = true;
|
||||
}
|
||||
});
|
||||
|
||||
app._finishBufferLoad(owner, { flushQueued: true });
|
||||
expect(reQueued).toBe(false);
|
||||
expect(app._isLoadingBuffer).toBe(false);
|
||||
expect(app._loadBufferQueue).toBeNull();
|
||||
});
|
||||
|
||||
it('owner mismatch returns false and does NOT flush or clear state', () => {
|
||||
const { app, writes } = makeApp();
|
||||
app._beginBufferLoad('real-owner');
|
||||
pushWhileLoading(app, 'queued');
|
||||
|
||||
const ok = app._finishBufferLoad('wrong-owner', { flushQueued: true });
|
||||
expect(ok).toBe(false);
|
||||
// State untouched — still loading, queue intact, nothing replayed.
|
||||
expect(app._isLoadingBuffer).toBe(true);
|
||||
expect(app._bufferLoadOwner).toBe('real-owner');
|
||||
expect(app._loadBufferQueue).toEqual(['queued']);
|
||||
expect(app.batchTerminalWrite).not.toHaveBeenCalled();
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
|
||||
it('empty queue + flushQueued is a no-op (no throw, no writes)', () => {
|
||||
const { app, writes } = makeApp();
|
||||
const owner = app._beginBufferLoad('load-empty');
|
||||
// No events queued.
|
||||
|
||||
expect(() => app._finishBufferLoad(owner, { flushQueued: true })).not.toThrow();
|
||||
expect(app._isLoadingBuffer).toBe(false);
|
||||
expect(app._loadBufferQueue).toBeNull();
|
||||
expect(app.batchTerminalWrite).not.toHaveBeenCalled();
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* COD-47: full tmux scrollback replay on reload.
|
||||
*
|
||||
* Under VITEST, TmuxManager no-ops execSync (IS_TEST_MODE), so we can't drive
|
||||
* real tmux. Instead we assert the capture-arg construction directly from
|
||||
* source (same approach as tmux-capture-color.test.ts): a full-history capture
|
||||
* must use `capture-pane -p -e -J -S -<N>` (bounded to the configured history
|
||||
* limit, with an explicit exec maxBuffer) and skip the single-screen snapshot
|
||||
* repaint, while the visible capture keeps `capture-pane -p -e`.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('tmux full-history pane capture (COD-47)', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
|
||||
const methodStart = source.indexOf('capturePaneBuffer(muxName: string');
|
||||
const methodBody = source.slice(methodStart, methodStart + 4000);
|
||||
|
||||
it('capturePaneBuffer accepts pane-capture options with a fullHistory flag', () => {
|
||||
expect(methodStart).toBeGreaterThan(-1);
|
||||
// The method signature must carry the opts channel...
|
||||
expect(source.slice(methodStart, methodStart + 160)).toContain('PaneCaptureOptions');
|
||||
// ...and the body must branch on opts.fullHistory.
|
||||
expect(methodBody).toContain('opts?.fullHistory === true');
|
||||
});
|
||||
|
||||
it('full-history mode captures scrollback bounded to the configured history limit (-J -S -<N>)', () => {
|
||||
// `-S -<N>` (not unbounded `-S -`) keeps tmux from serializing more
|
||||
// scrollback than the configured history limit retains; `-J` re-joins
|
||||
// lines hard-wrapped at the capture-time pane width.
|
||||
expect(source).toContain('capture-pane -p -e -J -S -${historyLines}');
|
||||
});
|
||||
|
||||
it('full-history exec sets an explicit maxBuffer (default 1MB would ENOBUFS multi-MB dumps)', () => {
|
||||
expect(methodBody).toContain('maxBuffer');
|
||||
expect(methodBody).toContain('FULL_HISTORY_CAPTURE_SLACK_BYTES');
|
||||
});
|
||||
|
||||
it('still offers the visible single-screen capture for fast tab switches', () => {
|
||||
expect(source).toContain("'capture-pane -p -e'");
|
||||
});
|
||||
|
||||
it('returns full-history capture as raw scrollback (skips the single-screen repaint)', () => {
|
||||
// When fullHistory, return the raw buffer BEFORE the formatPaneSnapshot
|
||||
// repaint (which is single-screen and would clip a multi-screen history).
|
||||
const earlyReturn = methodBody.indexOf('return normalizeScrollbackEol(buffer);');
|
||||
const snapshot = methodBody.indexOf('formatPaneSnapshot(');
|
||||
expect(earlyReturn).toBeGreaterThan(-1);
|
||||
expect(snapshot).toBeGreaterThan(-1);
|
||||
expect(earlyReturn).toBeLessThan(snapshot);
|
||||
});
|
||||
|
||||
it('captureActivePaneBuffer forwards the capture options', () => {
|
||||
const sig = source.indexOf('captureActivePaneBuffer(muxName: string');
|
||||
expect(sig).toBeGreaterThan(-1);
|
||||
const body = source.slice(sig, sig + 800);
|
||||
expect(body).toContain('opts');
|
||||
expect(body).toContain('this.capturePaneBuffer(muxName, target, opts)');
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,14 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { TmuxManager, formatPaneSnapshot, parsePaneList, resolveActivePaneTarget } from '../src/tmux-manager.js';
|
||||
import {
|
||||
TmuxManager,
|
||||
buildRemoteKillCommand,
|
||||
buildRemoteLaunchCommand,
|
||||
formatPaneSnapshot,
|
||||
parsePaneList,
|
||||
resolveActivePaneTarget,
|
||||
} from '../src/tmux-manager.js';
|
||||
import { execSync, exec } from 'node:child_process';
|
||||
|
||||
// ============================================================================
|
||||
@@ -91,6 +98,80 @@ describe('TmuxManager (unit)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote launch command builder', () => {
|
||||
it('wraps codex command overrides in ssh with remote tmux launch', () => {
|
||||
const command = buildRemoteLaunchCommand({
|
||||
mode: 'codex',
|
||||
remote: {
|
||||
hostId: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
commands: { codex: 'exec codx personal' },
|
||||
},
|
||||
sessionId: 'abc123def456',
|
||||
});
|
||||
|
||||
expect(command).toContain('ssh');
|
||||
expect(command).toContain('BatchMode=yes');
|
||||
expect(command).toContain('ubuntu@10.0.0.42');
|
||||
expect(command).toContain('/home/ubuntu/work');
|
||||
// Dedicated socket + a name that fails a remote Codeman's SAFE_MUX_NAME_PATTERN.
|
||||
expect(command).toContain('tmux -L codeman-remote new-session -A -s codeman-ssh-abc123de');
|
||||
expect(command).toContain('exec codx personal');
|
||||
// Session options are scoped per-session, never global (-g).
|
||||
expect(command).not.toContain('set -g');
|
||||
});
|
||||
|
||||
it('uses default shell command when no override is configured', () => {
|
||||
const command = buildRemoteLaunchCommand({
|
||||
mode: 'shell',
|
||||
remote: {
|
||||
hostId: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
},
|
||||
sessionId: 'abc123def456',
|
||||
});
|
||||
|
||||
expect(command).toContain('exec bash -l');
|
||||
});
|
||||
|
||||
it('defaults claude to a non-interactive launch (--dangerously-skip-permissions)', () => {
|
||||
const command = buildRemoteLaunchCommand({
|
||||
mode: 'claude',
|
||||
remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' },
|
||||
sessionId: 'abc123def456',
|
||||
});
|
||||
expect(command).toContain('exec claude --dangerously-skip-permissions');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote kill command builder', () => {
|
||||
it('kills the durable remote tmux session on the dedicated socket via ssh', () => {
|
||||
const command = buildRemoteKillCommand({
|
||||
remote: {
|
||||
hostId: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
},
|
||||
sessionId: 'abc123def456',
|
||||
});
|
||||
|
||||
expect(command).toContain('ssh');
|
||||
// Shares the default ConnectTimeout so an unreachable host fails fast (never blocks kill).
|
||||
expect(command).toContain('-o ConnectTimeout=10');
|
||||
expect(command).toContain('ubuntu@10.0.0.42');
|
||||
expect(command).toContain('tmux -L codeman-remote kill-session -t');
|
||||
expect(command).toContain('codeman-ssh-abc123de');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAttachCommand', () => {
|
||||
it('should return tmux', () => {
|
||||
expect(manager.getAttachCommand()).toBe('tmux');
|
||||
|
||||
@@ -66,7 +66,14 @@ describe('TmuxManager restart recovery (test mode safety)', () => {
|
||||
mode: 'claude',
|
||||
attached: false,
|
||||
name: 'Recovery Test',
|
||||
respawnConfig: { enabled: true, idleTimeoutMs: 10000, updatePrompt: 'continue', interStepDelayMs: 2000, sendClear: false, sendInit: true },
|
||||
respawnConfig: {
|
||||
enabled: true,
|
||||
idleTimeoutMs: 10000,
|
||||
updatePrompt: 'continue',
|
||||
interStepDelayMs: 2000,
|
||||
sendClear: false,
|
||||
sendInit: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.reconcileSessions();
|
||||
@@ -86,6 +93,34 @@ describe('TmuxManager restart recovery (test mode safety)', () => {
|
||||
expect(result.discovered).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('preserves remote SSH metadata across reconcile (mux-sessions.json round-trip source)', async () => {
|
||||
manager.registerSession({
|
||||
sessionId: 'remote-recovery-1',
|
||||
muxName: 'codeman-de51ecaf',
|
||||
pid: 1,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/home/ubuntu/work',
|
||||
mode: 'claude',
|
||||
attached: false,
|
||||
name: 'Remote Recovery',
|
||||
remote: {
|
||||
hostId: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.reconcileSessions();
|
||||
expect(result.alive).toContain('remote-recovery-1');
|
||||
|
||||
// restoreMuxSessions() reads MuxSession.remote off exactly this map to rebuild
|
||||
// the recovered Session — if it were dropped here the session would respawn LOCAL.
|
||||
const recovered = manager.getSession('remote-recovery-1');
|
||||
expect(recovered?.remote).toMatchObject({ hostId: 'gpu-box', host: '10.0.0.42', remotePath: '/home/ubuntu/work' });
|
||||
});
|
||||
|
||||
it('should not execute any tmux commands in test mode', async () => {
|
||||
manager.registerSession({
|
||||
sessionId: 'alive-session',
|
||||
@@ -101,9 +136,7 @@ describe('TmuxManager restart recovery (test mode safety)', () => {
|
||||
await manager.reconcileSessions();
|
||||
|
||||
// Verify no tmux commands were executed
|
||||
const tmuxCalls = mockedExecSync.mock.calls.filter(
|
||||
([cmd]) => typeof cmd === 'string' && cmd.includes('tmux')
|
||||
);
|
||||
const tmuxCalls = mockedExecSync.mock.calls.filter(([cmd]) => typeof cmd === 'string' && cmd.includes('tmux'));
|
||||
expect(tmuxCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -155,9 +188,7 @@ describe('TmuxManager restart recovery (test mode safety)', () => {
|
||||
expect(manager.getSession('kill-me')).toBeUndefined();
|
||||
|
||||
// Verify no real kill commands were executed
|
||||
const killCalls = mockedExecSync.mock.calls.filter(
|
||||
([cmd]) => typeof cmd === 'string' && cmd.includes('kill')
|
||||
);
|
||||
const killCalls = mockedExecSync.mock.calls.filter(([cmd]) => typeof cmd === 'string' && cmd.includes('kill'));
|
||||
expect(killCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* COD-138: shell terminal staircase / diagonal replay after reload.
|
||||
*
|
||||
* Root cause: the full-history tmux capture (`capture-pane -p -e -S -`) returns
|
||||
* scrollback lines joined by a BARE `\n` (no `\r`). The visible/tab-switch path
|
||||
* repaints each row with an absolute cursor CSI via `formatPaneSnapshot`, so it
|
||||
* never staircases — but the full-history path returns the raw buffer. The
|
||||
* browser xterm is created with the default `convertEol: false` (correct for the
|
||||
* live PTY stream, which carries real `\r\n`), so on a full page reload each
|
||||
* bare `\n` drops a row WITHOUT returning the cursor to column 0. Every replayed
|
||||
* line then starts one column further right → the diagonal staircase.
|
||||
*
|
||||
* Fix: normalize the full-history scrollback to `\r\n` line endings before it is
|
||||
* shipped to the browser, so a fresh xterm starts every replayed line at col 0.
|
||||
*
|
||||
* This exercises the pure transform (`normalizeScrollbackEol`). Under VITEST,
|
||||
* TmuxManager no-ops execSync (IS_TEST_MODE), so the real capture path can't be
|
||||
* driven end-to-end here; the transform is the load-bearing seam.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeScrollbackEol } from '../src/tmux-manager.js';
|
||||
|
||||
describe('normalizeScrollbackEol (COD-138 staircase fix)', () => {
|
||||
it('adds carriage returns so bare-LF scrollback lines start at column 0', () => {
|
||||
// tmux capture-pane joins rows with bare \n. Without a preceding \r, xterm
|
||||
// (convertEol:false) keeps the column → staircase.
|
||||
const raw = 'line one\nline two\nline three';
|
||||
expect(normalizeScrollbackEol(raw)).toBe('line one\r\nline two\r\nline three');
|
||||
});
|
||||
|
||||
it('every newline in the result is preceded by a carriage return', () => {
|
||||
const raw = 'a\nb\nc\nd';
|
||||
const out = normalizeScrollbackEol(raw);
|
||||
// The staircase invariant: no LF may appear without a CR immediately before it.
|
||||
expect(/(?<!\r)\n/.test(out)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not double up carriage returns on already-CRLF input', () => {
|
||||
const raw = 'line one\r\nline two\r\nline three';
|
||||
expect(normalizeScrollbackEol(raw)).toBe('line one\r\nline two\r\nline three');
|
||||
});
|
||||
|
||||
it('normalizes a mix of CRLF and bare LF to uniform CRLF', () => {
|
||||
const raw = 'crlf\r\nbare\ncrlf2\r\nbare2';
|
||||
expect(normalizeScrollbackEol(raw)).toBe('crlf\r\nbare\r\ncrlf2\r\nbare2');
|
||||
});
|
||||
|
||||
it('preserves a lone trailing carriage return (in-line overwrite, not an EOL)', () => {
|
||||
// A bare \r not followed by \n is a column-0 reset the TUI emitted on purpose;
|
||||
// it must survive untouched so we do not corrupt an overwrite.
|
||||
const raw = 'progress\rdone';
|
||||
expect(normalizeScrollbackEol(raw)).toBe('progress\rdone');
|
||||
});
|
||||
|
||||
it('is a no-op on content without newlines', () => {
|
||||
expect(normalizeScrollbackEol('single frame')).toBe('single frame');
|
||||
expect(normalizeScrollbackEol('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for WsConnectionRegistry (COD-137).
|
||||
*
|
||||
* The registry is the pure decision unit extracted out of ws-routes.ts so the
|
||||
* connection-limit / clientId-eviction logic is testable without driving real
|
||||
* WebSocket upgrades. Uses plain fake sockets (identity only).
|
||||
*
|
||||
* @dependency src/web/ws-connection-registry.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { WsConnectionRegistry } from '../src/web/ws-connection-registry.js';
|
||||
|
||||
/** Fake socket — registry only compares identity, so any object works. */
|
||||
const sock = (label: string) => ({ readyState: 1, label });
|
||||
|
||||
describe('WsConnectionRegistry', () => {
|
||||
it('reconnecting client (same cid) reclaims its slot instead of being rejected at the limit', () => {
|
||||
const reg = new WsConnectionRegistry(5);
|
||||
// Fill all 5 slots with distinct clients, one of which is "alice".
|
||||
for (const c of ['alice', 'b', 'c', 'd', 'e']) {
|
||||
expect(reg.register('s1', c, sock(c)).admitted).toBe(true);
|
||||
}
|
||||
expect(reg.liveCount('s1')).toBe(5);
|
||||
|
||||
// Alice's new upgrade lands BEFORE her old socket's async close fires.
|
||||
const aliceNew = sock('alice-new');
|
||||
const res = reg.register('s1', 'alice', aliceNew);
|
||||
|
||||
expect(res.admitted).toBe(true); // NOT a spurious 4008
|
||||
expect(res.evictedSocket).toBeDefined(); // old alice socket handed back to close
|
||||
expect(reg.liveCount('s1')).toBe(5); // slot reused, not double-counted
|
||||
});
|
||||
|
||||
it('still rejects a genuine (N+1)th DISTINCT client', () => {
|
||||
const reg = new WsConnectionRegistry(5);
|
||||
for (const c of ['a', 'b', 'c', 'd', 'e']) {
|
||||
expect(reg.register('s1', c, sock(c)).admitted).toBe(true);
|
||||
}
|
||||
const sixth = reg.register('s1', 'f', sock('f'));
|
||||
expect(sixth.admitted).toBe(false);
|
||||
expect(sixth.evictedSocket).toBeUndefined();
|
||||
expect(reg.liveCount('s1')).toBe(5);
|
||||
});
|
||||
|
||||
it('eager removal on terminate frees a slot immediately', () => {
|
||||
const reg = new WsConnectionRegistry(5);
|
||||
const sockets = ['a', 'b', 'c', 'd', 'e'].map((c) => {
|
||||
const s = sock(c);
|
||||
reg.register('s1', c, s);
|
||||
return [c, s] as const;
|
||||
});
|
||||
expect(reg.register('s1', 'f', sock('f')).admitted).toBe(false);
|
||||
|
||||
// Eagerly unregister one (simulating terminate/error, not async close).
|
||||
reg.unregister('s1', sockets[0][1]);
|
||||
expect(reg.liveCount('s1')).toBe(4);
|
||||
|
||||
// Now a brand-new distinct client is admitted.
|
||||
expect(reg.register('s1', 'f', sock('f')).admitted).toBe(true);
|
||||
expect(reg.liveCount('s1')).toBe(5);
|
||||
});
|
||||
|
||||
it('cid-less upgrades are admitted up to the limit and never evict a keyed client', () => {
|
||||
const reg = new WsConnectionRegistry(5);
|
||||
const keyed = sock('keyed');
|
||||
reg.register('s1', 'keyed', keyed);
|
||||
|
||||
// Four anonymous upgrades fill the rest of the cap.
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const res = reg.register('s1', null, sock(`anon${i}`));
|
||||
expect(res.admitted).toBe(true);
|
||||
expect(res.evictedSocket).toBeUndefined(); // never evicts the keyed client
|
||||
}
|
||||
expect(reg.liveCount('s1')).toBe(5);
|
||||
|
||||
// 6th anonymous is rejected — anonymous sockets count toward the cap.
|
||||
expect(reg.register('s1', null, sock('anon-extra')).admitted).toBe(false);
|
||||
|
||||
// The keyed client is untouched: a same-cid reconnect still reclaims.
|
||||
const keyedNew = sock('keyed-new');
|
||||
const res = reg.register('s1', 'keyed', keyedNew);
|
||||
expect(res.admitted).toBe(true);
|
||||
expect(res.evictedSocket).toBe(keyed);
|
||||
});
|
||||
|
||||
it('late close of a superseded socket does not evict the reconnected one', () => {
|
||||
const reg = new WsConnectionRegistry(5);
|
||||
const old = sock('old');
|
||||
reg.register('s1', 'alice', old);
|
||||
const fresh = sock('fresh');
|
||||
reg.register('s1', 'alice', fresh); // supersede
|
||||
|
||||
// The stale socket's async close arrives late — must NOT remove fresh.
|
||||
reg.unregister('s1', old);
|
||||
expect(reg.liveCount('s1')).toBe(1);
|
||||
|
||||
// Fresh is still the live entry: another reconnect evicts fresh, not old.
|
||||
const fresher = sock('fresher');
|
||||
expect(reg.register('s1', 'alice', fresher).evictedSocket).toBe(fresh);
|
||||
});
|
||||
|
||||
it('two tabs of the same browser (shared clientId, distinct tab nonce) coexist without eviction', () => {
|
||||
// The client keys the upgrade by `clientId:tabNonce`, NOT the bare
|
||||
// browser-wide clientId — otherwise two windows on one session would
|
||||
// supersede each other in a perpetual 4010/5s reconnect ping-pong.
|
||||
const reg = new WsConnectionRegistry(5);
|
||||
const tabA = sock('tab-a');
|
||||
const tabB = sock('tab-b');
|
||||
|
||||
expect(reg.register('s1', 'c-browser:tab-A', tabA).evictedSocket).toBeUndefined();
|
||||
const resB = reg.register('s1', 'c-browser:tab-B', tabB);
|
||||
expect(resB.admitted).toBe(true);
|
||||
expect(resB.evictedSocket).toBeUndefined(); // tab A keeps its socket
|
||||
expect(reg.liveCount('s1')).toBe(2);
|
||||
|
||||
// A genuine same-tab reconnect still supersedes only its own socket.
|
||||
const tabANew = sock('tab-a-new');
|
||||
const res = reg.register('s1', 'c-browser:tab-A', tabANew);
|
||||
expect(res.evictedSocket).toBe(tabA);
|
||||
expect(reg.liveCount('s1')).toBe(2);
|
||||
});
|
||||
|
||||
it('isolates counts per session', () => {
|
||||
const reg = new WsConnectionRegistry(2);
|
||||
reg.register('s1', 'a', sock('a'));
|
||||
reg.register('s1', 'b', sock('b'));
|
||||
expect(reg.register('s1', 'c', sock('c')).admitted).toBe(false);
|
||||
// s2 has its own budget.
|
||||
expect(reg.register('s2', 'a', sock('a2')).admitted).toBe(true);
|
||||
expect(reg.liveCount('s2')).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* COD-134 — Terminal WebSocket reconnect policy.
|
||||
*
|
||||
* `CodemanWsReconnect.plan(code, attempt)` is the pure decision behind the
|
||||
* client WS `onclose` handler in app.js: given a WebSocket close code and the
|
||||
* number of consecutive reconnects already attempted, it returns the action to
|
||||
* take (`reconnect` | `retry-fallback` | `give-up`) and a backoff delay. It is
|
||||
* exposed on `window.CodemanWsReconnect` and tested here in a plain node VM
|
||||
* context (no jsdom — jsdom env setup is broken on some hosts).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
type Plan = { action: 'reconnect' | 'retry-fallback' | 'give-up'; delayMs: number };
|
||||
|
||||
function loadHelper() {
|
||||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
vm.runInContext(source, context, { filename: 'constants.js' });
|
||||
return (context.window as { CodemanWsReconnect: { plan: (code: number, attempt: number) => Plan } })
|
||||
.CodemanWsReconnect;
|
||||
}
|
||||
|
||||
describe('COD-134 WS reconnect plan policy', () => {
|
||||
it('reconnects immediately on the first attempt for a transient close', () => {
|
||||
const { plan } = loadHelper();
|
||||
expect(plan(1006, 0)).toEqual({ action: 'reconnect', delayMs: 0 });
|
||||
expect(plan(1000, 0).action).toBe('reconnect');
|
||||
expect(plan(1001, 0).delayMs).toBe(0);
|
||||
expect(plan(1005, 0).delayMs).toBe(0);
|
||||
});
|
||||
|
||||
it('grows the backoff exponentially with a 10s cap for transient closes', () => {
|
||||
const { plan } = loadHelper();
|
||||
expect(plan(1006, 0).delayMs).toBe(0);
|
||||
expect(plan(1006, 1).delayMs).toBe(250);
|
||||
expect(plan(1006, 2).delayMs).toBe(500);
|
||||
expect(plan(1006, 3).delayMs).toBe(1000);
|
||||
expect(plan(1006, 4).delayMs).toBe(2000);
|
||||
expect(plan(1006, 5).delayMs).toBe(4000);
|
||||
expect(plan(1006, 6).delayMs).toBe(8000);
|
||||
expect(plan(1006, 7).delayMs).toBe(10000); // 16000 capped to 10000
|
||||
expect(plan(1006, 8).delayMs).toBe(10000);
|
||||
expect(plan(1006, 50).delayMs).toBe(10000); // stays capped no matter how many attempts
|
||||
// every transient attempt is still a reconnect
|
||||
for (let attempt = 0; attempt < 12; attempt++) {
|
||||
expect(plan(1006, attempt).action).toBe('reconnect');
|
||||
}
|
||||
});
|
||||
|
||||
it('auto-retries the fallback on a too-many-connections (4008) close', () => {
|
||||
const { plan } = loadHelper();
|
||||
expect(plan(4008, 0)).toEqual({ action: 'retry-fallback', delayMs: 5000 });
|
||||
expect(plan(4008, 3)).toEqual({ action: 'retry-fallback', delayMs: 5000 });
|
||||
});
|
||||
|
||||
it('gives up on session-not-found (4004) and session-terminated (4009)', () => {
|
||||
const { plan } = loadHelper();
|
||||
expect(plan(4004, 0)).toEqual({ action: 'give-up', delayMs: 0 });
|
||||
expect(plan(4004, 5)).toEqual({ action: 'give-up', delayMs: 0 });
|
||||
expect(plan(4009, 0)).toEqual({ action: 'give-up', delayMs: 0 });
|
||||
expect(plan(4009, 5)).toEqual({ action: 'give-up', delayMs: 0 });
|
||||
});
|
||||
|
||||
it('auto-retries the fallback for an unknown >=4004 code (e.g. 4010, 4005)', () => {
|
||||
const { plan } = loadHelper();
|
||||
expect(plan(4010, 0)).toEqual({ action: 'retry-fallback', delayMs: 5000 });
|
||||
expect(plan(4005, 2)).toEqual({ action: 'retry-fallback', delayMs: 5000 });
|
||||
expect(plan(4500, 0)).toEqual({ action: 'retry-fallback', delayMs: 5000 });
|
||||
});
|
||||
|
||||
it('treats a sub-4004 close (e.g. 4003 Forbidden) as a transient reconnect', () => {
|
||||
const { plan } = loadHelper();
|
||||
// 4003 is < 4004, so it is NOT a give-up; it follows the transient backoff.
|
||||
expect(plan(4003, 0)).toEqual({ action: 'reconnect', delayMs: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* @fileoverview Terminal WebSocket state-machine lifecycle tests
|
||||
* (`CodemanApp._connectWs` / `ws.onopen` / `ws.onclose` / `_disconnectWs`).
|
||||
*
|
||||
* Unlike test/connection-indicator.test.ts (which pins the pure descriptor per
|
||||
* pre-seeded `_wsState`), this suite drives the REAL transitions through a fake
|
||||
* `WebSocket` class so the production assignments are covered:
|
||||
*
|
||||
* 1. `_connectWs()` → 'connecting', a real `onopen` → 'connected' (the chip
|
||||
* renders "WS"), `_disconnectWs()` → 'disconnected'. Regression guard for
|
||||
* the PR-review blocker where `_wsState` was only ever written in
|
||||
* `onclose`, leaving the chip stuck on "WS…"/"HTTP" forever.
|
||||
* 2. Exponential backoff really escalates across the onclose → timer →
|
||||
* `_connectWs` cycle: `_disconnectWs()` (called first by `_connectWs`)
|
||||
* must NOT zero `_wsReconnectAttempts`, or every retry replans at
|
||||
* attempt 0 (a ~0ms tight reconnect loop during an outage). Only a
|
||||
* successful `onopen` resets the counter.
|
||||
* 3. The upgrade URL carries the per-TAB `cid` (`clientId:tabNonce`), not the
|
||||
* browser-wide clientId — two tabs of one profile must register distinct
|
||||
* registry keys so they coexist instead of 4010-evicting each other.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed context (no jsdom — see input-send-order.test.ts).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type FakeTimer = { id: number; fn: () => void; delay: number; cleared: boolean };
|
||||
|
||||
class FakeWebSocket {
|
||||
static OPEN = 1;
|
||||
url: string;
|
||||
readyState = 0;
|
||||
closed = false;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((e: unknown) => void) | null = null;
|
||||
onclose: ((e: { code: number; reason: string }) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
FakeWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
send(): void {}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.readyState = 3;
|
||||
}
|
||||
|
||||
static instances: FakeWebSocket[] = [];
|
||||
}
|
||||
|
||||
function loadHarness() {
|
||||
FakeWebSocket.instances = [];
|
||||
const timers: FakeTimer[] = [];
|
||||
let nextTimerId = 1;
|
||||
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout: (fn: () => void, delay: number) => {
|
||||
const id = nextTimerId++;
|
||||
timers.push({ id, fn, delay, cleared: false });
|
||||
return id;
|
||||
},
|
||||
clearTimeout: (id: number) => {
|
||||
const t = timers.find((x) => x.id === id);
|
||||
if (t) t.cleared = true;
|
||||
},
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
WebSocket: FakeWebSocket,
|
||||
location: { protocol: 'https:', host: 'test.local' },
|
||||
fetch: (...args: Parameters<typeof fetch>) => global.fetch(...args),
|
||||
document: { addEventListener: vi.fn() },
|
||||
localStorage: {
|
||||
length: 0,
|
||||
key: vi.fn(),
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
},
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
MobileDetection: {},
|
||||
});
|
||||
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||
const CodemanApp = (context as { __CodemanApp: new () => unknown }).__CodemanApp;
|
||||
return { CodemanApp, timers };
|
||||
}
|
||||
|
||||
function fakeElement() {
|
||||
return { style: { display: '' }, title: '', textContent: '', className: '' };
|
||||
}
|
||||
|
||||
type LifecycleApp = {
|
||||
_connectWs: (id: string) => void;
|
||||
_disconnectWs: () => void;
|
||||
_wsState: string;
|
||||
_wsReady: boolean;
|
||||
_wsReconnectAttempts: number | undefined;
|
||||
_ws: FakeWebSocket | null;
|
||||
activeSessionId: string | null;
|
||||
};
|
||||
|
||||
function makeApp(
|
||||
CodemanApp: new () => unknown,
|
||||
overrides: Record<string, unknown> = {}
|
||||
): { app: LifecycleApp; els: Record<string, ReturnType<typeof fakeElement>> } {
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as LifecycleApp & Record<string, unknown>;
|
||||
const els: Record<string, ReturnType<typeof fakeElement>> = {
|
||||
connectionIndicator: fakeElement(),
|
||||
connectionDot: fakeElement(),
|
||||
connectionText: fakeElement(),
|
||||
};
|
||||
app.$ = (id: string) => els[id];
|
||||
app._clientId = 'c-browser';
|
||||
app._wsTabNonce = 'tab-1';
|
||||
app._ws = null;
|
||||
app._wsSessionId = null;
|
||||
app._wsReady = false;
|
||||
app._wsState = 'disconnected';
|
||||
app._wsLastRecvAt = 0;
|
||||
app._lastIndicatorDescriptor = null;
|
||||
app._pendingDeliveries = new Map();
|
||||
app._connectionStatus = 'connected';
|
||||
app.activeSessionId = 's1';
|
||||
app.isOnline = true;
|
||||
app.sendResize = vi.fn();
|
||||
app._onWsReady = vi.fn();
|
||||
Object.assign(app, overrides);
|
||||
return { app: app as LifecycleApp, els };
|
||||
}
|
||||
|
||||
/** Run the oldest pending (not-cleared, not-yet-fired) reconnect timer. */
|
||||
function fireNextTimer(timers: FakeTimer[]): FakeTimer {
|
||||
const t = timers.find((x) => !x.cleared);
|
||||
if (!t) throw new Error('no pending timer');
|
||||
t.cleared = true; // mark consumed so the next fire picks the following one
|
||||
t.fn();
|
||||
return t;
|
||||
}
|
||||
|
||||
describe('WS state lifecycle — real _connectWs/onopen/onclose transitions', () => {
|
||||
it("_connectWs sets 'connecting', a real onopen sets 'connected' and renders 'WS'", () => {
|
||||
const { CodemanApp } = loadHarness();
|
||||
const { app, els } = makeApp(CodemanApp);
|
||||
|
||||
app._connectWs('s1');
|
||||
expect(app._wsState).toBe('connecting');
|
||||
expect(els.connectionText.textContent).toBe('WS…');
|
||||
|
||||
const ws = FakeWebSocket.instances[0];
|
||||
ws.readyState = 1;
|
||||
ws.onopen?.();
|
||||
|
||||
expect(app._wsState).toBe('connected');
|
||||
expect(app._wsReady).toBe(true);
|
||||
expect(app._wsReconnectAttempts).toBe(0);
|
||||
// The chip must show the healthy transport from the REAL open path — the
|
||||
// 'connected' branch was dead code when only onclose wrote _wsState.
|
||||
expect(els.connectionText.textContent).toBe('WS');
|
||||
expect(els.connectionDot.className).toBe('connection-dot connected');
|
||||
});
|
||||
|
||||
it("_disconnectWs resets the state machine to 'disconnected' and closes the socket", () => {
|
||||
const { CodemanApp } = loadHarness();
|
||||
const { app } = makeApp(CodemanApp);
|
||||
|
||||
app._connectWs('s1');
|
||||
const ws = FakeWebSocket.instances[0];
|
||||
ws.readyState = 1;
|
||||
ws.onopen?.();
|
||||
expect(app._wsState).toBe('connected');
|
||||
|
||||
app._disconnectWs();
|
||||
expect(app._wsState).toBe('disconnected');
|
||||
expect(app._wsReady).toBe(false);
|
||||
expect(app._ws).toBeNull();
|
||||
expect(ws.closed).toBe(true);
|
||||
});
|
||||
|
||||
it("a retry-fallback close (4010) shows 'HTTP', and the successful retry returns the chip to 'WS'", () => {
|
||||
const { CodemanApp, timers } = loadHarness();
|
||||
const { app, els } = makeApp(CodemanApp);
|
||||
|
||||
app._connectWs('s1');
|
||||
const ws1 = FakeWebSocket.instances[0];
|
||||
ws1.readyState = 1;
|
||||
ws1.onopen?.();
|
||||
expect(els.connectionText.textContent).toBe('WS');
|
||||
|
||||
ws1.onclose?.({ code: 4010, reason: 'Superseded by reconnect' });
|
||||
expect(app._wsState).toBe('fallback');
|
||||
expect(els.connectionText.textContent).toBe('HTTP');
|
||||
|
||||
// The bounded 5s retry succeeds → the chip must NOT stay stuck on "HTTP".
|
||||
const timer = fireNextTimer(timers);
|
||||
expect(timer.delay).toBe(5000);
|
||||
const ws2 = FakeWebSocket.instances[1];
|
||||
ws2.readyState = 1;
|
||||
ws2.onopen?.();
|
||||
expect(app._wsState).toBe('connected');
|
||||
expect(els.connectionText.textContent).toBe('WS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WS reconnect backoff — attempts survive the _connectWs → _disconnectWs call', () => {
|
||||
it('escalates the transient-close delay ladder instead of replanning at attempt 0', () => {
|
||||
const { CodemanApp, timers } = loadHarness();
|
||||
const { app } = makeApp(CodemanApp);
|
||||
|
||||
app._connectWs('s1');
|
||||
// Attempt 0: transient close plans 0ms (+ <250ms jitter).
|
||||
FakeWebSocket.instances[0].onclose?.({ code: 1006, reason: '' });
|
||||
expect(app._wsReconnectAttempts).toBe(1);
|
||||
const t1 = fireNextTimer(timers);
|
||||
expect(t1.delay).toBeLessThan(250);
|
||||
|
||||
// Attempt 1: the retry's _connectWs ran _disconnectWs first — the counter
|
||||
// must survive it, so this close plans 250ms (+ jitter), not 0ms again.
|
||||
FakeWebSocket.instances[1].onclose?.({ code: 1006, reason: '' });
|
||||
expect(app._wsReconnectAttempts).toBe(2);
|
||||
const t2 = fireNextTimer(timers);
|
||||
expect(t2.delay).toBeGreaterThanOrEqual(250);
|
||||
expect(t2.delay).toBeLessThan(500);
|
||||
|
||||
// Attempt 2 → 500ms rung.
|
||||
FakeWebSocket.instances[2].onclose?.({ code: 1006, reason: '' });
|
||||
expect(app._wsReconnectAttempts).toBe(3);
|
||||
const t3 = fireNextTimer(timers);
|
||||
expect(t3.delay).toBeGreaterThanOrEqual(500);
|
||||
expect(t3.delay).toBeLessThan(750);
|
||||
});
|
||||
|
||||
it('a successful onopen (not an intentional disconnect) is what resets the counter', () => {
|
||||
const { CodemanApp, timers } = loadHarness();
|
||||
const { app } = makeApp(CodemanApp);
|
||||
|
||||
app._connectWs('s1');
|
||||
FakeWebSocket.instances[0].onclose?.({ code: 1006, reason: '' });
|
||||
FakeWebSocket.instances[0].closed = true;
|
||||
fireNextTimer(timers);
|
||||
expect(app._wsReconnectAttempts).toBe(1);
|
||||
|
||||
const ws2 = FakeWebSocket.instances[1];
|
||||
ws2.readyState = 1;
|
||||
ws2.onopen?.();
|
||||
expect(app._wsReconnectAttempts).toBe(0);
|
||||
expect(app._wsState).toBe('connected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WS upgrade cid — per-TAB identity (clientId:tabNonce)', () => {
|
||||
it('sends the composite cid on the upgrade URL, keeping the bare clientId for input frames', () => {
|
||||
const { CodemanApp } = loadHarness();
|
||||
const { app } = makeApp(CodemanApp);
|
||||
|
||||
app._connectWs('s1');
|
||||
const url = new URL(FakeWebSocket.instances[0].url);
|
||||
expect(url.searchParams.get('cid')).toBe('c-browser:tab-1');
|
||||
});
|
||||
|
||||
it('two tabs sharing the browser clientId register DIFFERENT registry keys', () => {
|
||||
const { CodemanApp } = loadHarness();
|
||||
const { app: tabA } = makeApp(CodemanApp, { _wsTabNonce: 'tab-A' });
|
||||
const { app: tabB } = makeApp(CodemanApp, { _wsTabNonce: 'tab-B' });
|
||||
|
||||
tabA._connectWs('s1');
|
||||
tabB._connectWs('s1');
|
||||
|
||||
const cidA = new URL(FakeWebSocket.instances[0].url).searchParams.get('cid');
|
||||
const cidB = new URL(FakeWebSocket.instances[1].url).searchParams.get('cid');
|
||||
expect(cidA).toBe('c-browser:tab-A');
|
||||
expect(cidB).toBe('c-browser:tab-B');
|
||||
// Distinct keys → the server registry admits both instead of supersede-evicting.
|
||||
expect(cidA).not.toBe(cidB);
|
||||
});
|
||||
|
||||
it('omits the cid query entirely when no clientId is available', () => {
|
||||
const { CodemanApp } = loadHarness();
|
||||
const { app } = makeApp(CodemanApp, { _clientId: '' });
|
||||
|
||||
app._connectWs('s1');
|
||||
expect(FakeWebSocket.instances[0].url).not.toContain('cid=');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user