Compare commits

...
Author SHA1 Message Date
arkonandClaude Opus 4.7 016c23934f chore: version packages
Release 0.7.0. Also syncs CLAUDE.md version line and corrects the
route-handler counts (~130 handlers, sessions 28).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:19:15 +02:00
Tenggan ZhangandTeigen 896dc5b177 fix(web): stop auto-sending Ctrl+L from session selection paths (#99)
Claude Code 2.x treats Ctrl+L (\x0c) as a two-step "clear conversation"
command (first press shows the confirmation prompt, second press
clears). The frontend previously fired \x0c from three places to force
Ink to redraw stale CUP-positioned frames in the tailed buffer; if a
page refresh or SSE reconnect ran the same path twice within Claude's
confirmation window the second \x0c silently nuked the user's
conversation.

Removed the \x0c sends from:
- selectSession() — main offender, runs on every tab switch & page reload
- restoreTerminalSize() — manual "restore size" button
- sendPendingCtrlL() — dead code path (pendingCtrlL was never populated)

Trade-off: occasional stale Ink frames immediately after refresh; the
user's first keypress causes Ink to redraw and the artifact vanishes.
Losing the conversation silently is far worse than a brief cosmetic
glitch.

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-05-26 00:12:43 +02:00
Tenggan ZhangandTeigen 196646a7ff feat(web): one-click copy button on response-viewer code blocks (#98)
Wrap every fenced code block in the response viewer with a positioned
.rv-code-wrap toolbar (outside the <pre> scroll container so buttons stay put
during horizontal scroll). All blocks get a copy button; ASCII diagrams keep
their existing line-wrap toggle alongside it.

_copyText() prefers the async Clipboard API and falls back to a hidden-textarea
+ execCommand path, so copy works over plain HTTP too. The button shows a 1.5s
✓ / ✕ feedback state after each attempt.

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-05-26 00:00:37 +02:00
Tenggan ZhangandTeigen 1b652ceb87 test: repair route harness error rendering + stop AI-checker spawning real processes in tests (#97)
* fix(test): share route error handler with test harness + fix stale assertions

The route test harness built a bare Fastify instance without the production
global error handler (server.ts), so structured errors thrown by route helpers
(findSessionOrFail → 404, parseBody → 400) fell through to Fastify's default
handler — yielding a `{statusCode,error,message}` body instead of the
`{success:false,...}` shape, and the tests asserted the old implicit-200
behavior. 51 route tests across 7 files were red.

- Extract the handler into src/web/route-error-handler.ts; server.ts and the
  test harness now install the identical handler (single source of truth).
- Correct stale assertions across route test files: throw-based error paths
  now assert 404 (unknown session) / 400 (invalid body); genuine in-handler
  `return createErrorResponse(...)` paths (200 + success:false) left untouched.
- Reformat a few test files prettier flagged (pre-existing non-compliance).

Route suite: 307/307 passing (was 256/307). No production behavior change.

* test(respawn): mock child_process so AI checker never spawns real processes

respawn-controller.test.ts drives the AI idle checker (ai-checker-base), whose
runCheck() spawns a real `tmux new-session` running `claude -p`. The AI-enabled
tests only assert the ai_checking state transition (then cancel/stop), so the
spawn produced stray real tmux sessions and claude processes on every run — the
reason `npm test` (full suite) was unsafe to run inside a managed session.

Mock node:child_process here (mirroring ai-idle-checker.test.ts), spreading the
real module so `exec` stays intact for transitively-imported modules
(tmux-manager calls promisify(exec) at load). With this, the full non-mobile
suite runs without spawning any real tmux/claude.

---------

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-05-25 23:55:57 +02:00
arkonandClaude Opus 4.7 8abf349cfc chore: version packages
Also add docs/opencode-integration.md pointer to the dual-CLI gotcha in CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:32:44 +02:00
arkonandClaude Opus 4.7 ae5bcf9330 docs: archive stale findings docs (work completed)
Both findings docs described a codebase that no longer exists — their
headline 'Critical'/'P0' items (server.ts/app.js/types.ts splits, the
{WORKING_DIR} placeholder bug) are all resolved. Moved to docs/archive/
with dated banners so they read as history, not a live TODO.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:31:50 +02:00
arkonandClaude Opus 4.7 78d5fcf70c docs: mark tmux-manager.ts as large file, refine CLAUDE.md accuracy
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:31:50 +02:00
Tenggan ZhangandTeigen 1ff315a1e6 fix(tmux): isolate sessions on a dedicated socket + raise pane nofile limit (fixes new-session crash after tmux upgrade) (#96)
* fix: isolate codeman tmux sessions

* fix(tmux): unify all sessions onto a single dedicated socket

Remove the per-session `tmuxSocket` field that recorded which tmux server
each session lived on (default vs the `codeman` socket). That field was a
persisted cache of physical reality and could drift — causing live sessions
to be wrongly marked dead ("tab shows no session found") and spawning
duplicate "Restored:" tabs.

All Codeman sessions now live on one process-wide socket (`tmux -L codeman`,
overridable via CODEMAN_TMUX_SOCKET), exposed via TmuxManager.muxSocket on
the TerminalMultiplexer interface. reconcileSessions() collapses from a
multi-socket scan (locate / re-pin / cross-socket dedup) to a single
`list-panes` query. loadSessions() strips the obsolete field from on-disk
records so it stops being written back.

Also fix two sibling bare-`tmux` call sites the unification would otherwise
leave broken (same #80 regression class — bare tmux hits the user's default
server and never finds a session on the codeman socket):
- session.ts queryTmuxWindowSize(): add `-L <socket>` (was silently falling
  back to 120x40 on re-attach, losing scrollback)
- session-routes.ts send-key (Shift+Enter / Ctrl+Enter newline): route
  through ctx.mux.muxSocket

SSH chooser scripts (tmux-manager.sh, tmux-chooser.sh) route every tmux call
through `tmux -L $CODEMAN_TMUX_SOCKET`, matching the TS default.

---------

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-05-25 23:28:58 +02:00
arkonandClaude Opus 4.7 08de6667ab chore: version packages
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:43:23 +02:00
Tenggan ZhangandTeigen d27f8e77f7 feat: add View all in folder modal for Resume Conversation (#94)
Drill into a single project's complete history when the homepage's
3-per-project dedup hides older conversations.

- Backend: /api/history/sessions accepts projectKey/offset/limit;
  single-folder mode bypasses the 50-cap and returns { sessions, total }.
  projectKey is validated against ^[A-Za-z0-9_-]+$ to prevent traversal.
- Frontend: detail panel adds "View all in this folder" button that
  opens a modal listing 20 sessions per page with Show more pagination.
- Modal items reuse _buildHistoryItem with showViewAll:false to avoid
  recursive entry points.

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-05-19 16:27:42 +02:00
Tenggan ZhangandTeigen e248cd8bcf fix: drop phantom ended-tab stubs, trust server as source of truth (#93)
Previously the client cached open session tabs in localStorage and resurrected
any that the server no longer knew about as grayed-out "ended" stubs. On
multi-device use (close tab on mobile, open desktop) this left stale phantom
tabs the user had to manually dismiss.

Remove _restoreEndedTabs / _saveTabMetadata, the session._ended branch in
selectSession, the data-ended render attribute, and the matching CSS rule.
Clear the legacy localStorage key on init to purge stale entries.

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-05-19 16:19:07 +02:00
Tenggan ZhangandTeigen 73d81afd4d fix: decode project keys with longest-match backtracking (#92)
When two sibling directories share a prefix (e.g. `diary/` and
`diary-app/`), the greedy shortest-match decoder picked the shorter
name and then failed to resolve the remainder, so the homepage Resume
Conversation list showed those workingDirs as $HOME and resume targeted
the wrong folder. Switch to recursive backtracking with longest-join-first
at each segment boundary; require every step to be a real directory.
Keep the greedy path as a fallback for deleted dirs.

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-05-19 16:16:46 +02:00
arkon 7884a37c55 chore: version packages 2026-05-19 12:11:44 +02:00
Ark0N ad89a97106 fix(renderer): WebGL longtask fallback hardening (#91)
Closes #89.

- _disposeWebGLObserver() called from both trip path and onContextLoss (fixes the leak)
- Thresholds (200ms/3/30s/5s/7d) hoisted to WEBGL_FALLBACK in constants.js
- Pure evaluateWebGLLongTaskTrip() helper + 9 Playwright tests (test/webgl-fallback.test.ts, port 3166)
2026-05-19 11:43:36 +02:00
arkonandClaude Opus 4.7 0600b7843e docs: add image-input.js to frontend module list in CLAUDE.md
PR #84 added `src/web/public/image-input.js` (clipboard paste + drag-drop)
but the CLAUDE.md frontend module table wasn't updated. Bumps the feature
modules count from 4 to 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:40:35 +02:00
arkonandClaude Opus 4.7 6d896c781e ci: add server boot smoke test
CI was running typecheck + lint + format only, which let plugin
registration regressions reach master — the @fastify/multipart conflict
in #90 crashed the server at startup but passed CI. The boot smoke
spawns the web server on a non-default port, polls /api/status for up
to 30s, and dumps the log on failure (either early exit or no-ready).

Catches: plugin registration conflicts, route registration errors,
import cycles, and any other failure between process start and
app.listen() resolving.

Auto-installs tmux on the runner since createMultiplexer() throws
without it (mux-factory.ts:17). ubuntu-latest already ships tmux, so
the install branch is normally a no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:41:42 +02:00
arkonandClaude Opus 4.7 930492058b fix(server): remove duplicate multipart parser conflicting with @fastify/multipart
#90 added @fastify/multipart, which registers its own multipart/form-data
content-type parser. Combined with the existing manual no-op parser in
setupRoutes() (originally there so /api/screenshots could read req.raw
directly), this raises "Content type parser 'multipart/form-data' already
present" at server boot and the process exits. CI did not catch it
because ci.yml runs typecheck + lint only.

@fastify/multipart's parser is a no-op marker (sets req[kMultipart] =
true and returns) and leaves the body on req.raw, so the legacy
/api/screenshots handler that reads req.raw directly keeps working
unchanged. The manual parser was redundant the moment the plugin was
registered.

Smoke-tested locally: server boots, /api/sessions/:id/paste-image
returns 200 / 403-CSRF / 415-magic-mismatch / 413-oversize / 429-rate
as designed; /api/screenshots upload still returns 200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:37:26 +02:00
aakhter 101cee0cec security(paste-image): harden against 7 findings from PR #84 review (#90)
Hardens `/api/sessions/:id/paste-image` against the seven findings flagged in the dismissed security review on #84. Each commit addresses one finding.

- LOW: Collision-free filenames (`paste-${ts}-${rand4}${ext}`)
- MED: Symlink check on image dir (`lstat` + non-recursive mkdir + `O_EXCL|O_NOFOLLOW`)
- MED: Magic-byte validation (PNG/JPEG/GIF/WebP/BMP)
- HIGH: CSRF protection (Origin/Referer match req.host; non-browser clients send `X-Codeman-CSRF`)
- MED: Swap hand-rolled multipart parser to @fastify/multipart with `limits: { fileSize: 10MB, files: 1, fields: 4 }`
- MED: Rate limit (30/min per IP+session) + hourly GC of `paste-*` files older than 7d
- LOW: Use `terminal.paste(text)` instead of `sendInput(text)` so bracketed-paste markers survive

Co-authored-by: Aamer Akhter <aakhter@gmail.com>
2026-05-19 10:36:05 +02:00
38 changed files with 3804 additions and 616 deletions
+26
View File
@@ -34,6 +34,32 @@ jobs:
- name: Format check
run: npm run format:check
- name: Server boot smoke test
run: |
set -u
if ! command -v tmux >/dev/null; then
sudo apt-get update -qq
sudo apt-get install -y tmux
fi
npx tsx src/index.ts web --port 3151 > /tmp/boot.log 2>&1 &
SERVER_PID=$!
trap "kill $SERVER_PID 2>/dev/null || true" EXIT
for i in $(seq 1 30); do
if curl -fsS http://localhost:3151/api/status -o /dev/null; then
echo "Server booted in ${i}s"
exit 0
fi
if ! kill -0 $SERVER_PID 2>/dev/null; then
echo "Server exited before becoming ready. Logs:"
cat /tmp/boot.log
exit 1
fi
sleep 1
done
echo "Server did not respond on /api/status within 30s. Logs:"
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
+63
View File
@@ -1,5 +1,68 @@
# aicodeman
## 0.7.0
### Minor Changes
- Response viewer & terminal-stability improvements, plus test/error-handling hardening.
- **Copy button on code blocks (#98):** Every fenced code block in the response viewer now has a one-click copy button pinned to its top-right, outside the `<pre>` scroll container so it stays put during horizontal scroll. ASCII diagrams keep their line-wrap toggle alongside it. Copy prefers the async Clipboard API and falls back to a hidden-textarea + `execCommand` path, so it works over plain HTTP (tunnel) too, with a brief ✓/✕ feedback state.
- **Fix: stop auto-sending Ctrl+L from session-selection paths (#99):** A fast page refresh or SSE reconnect could fire two programmatic Ctrl+L (`\x0c`) sends within Claude Code 2.x's "clear conversation" confirmation window, silently wiping the active conversation. Removed the automatic Ctrl+L sends from `selectSession()`, `restoreTerminalSize()`, and the dead `sendPendingCtrlL()` path; redraws now rely on resize/SIGWINCH. User-initiated Ctrl+L still works. Trade-off: an occasional transient stale Ink frame right after refresh that self-heals on the next keypress — far preferable to silent data loss.
- **Test & error-handling hardening (#97):** Repaired route-test harness error rendering via a dedicated `route-error-handler.ts`, and stopped the AI idle/plan checkers from spawning real processes during tests.
## 0.6.12
### Patch Changes
- Fix new-session crash after a tmux upgrade and isolate Codeman sessions on a dedicated tmux socket.
- **Pane file-descriptor limit**: raise `ulimit -Sn` before launching the CLI (in both the spawn and respawn paths) so the newer tmux + macOS launchd combination — which hands panes a low soft `nofile` limit (256) that recent Claude Code refuses to start under — no longer kills every freshly spawned session on startup.
- **Single-socket isolation**: all Codeman-owned tmux sessions now live on a dedicated socket (`tmux -L codeman`, overridable via `CODEMAN_TMUX_SOCKET`), fully separated from the user's default tmux server. The socket name is validated and shell-escaped at every call site.
- **Drop the drift-prone per-session `tmuxSocket` field**: session reconciliation collapses to a single `list-panes` query against the one socket, eliminating live sessions being wrongly marked dead ("session not found") and duplicate "Restored:" tabs. Stale per-session socket tags and duplicate records are cleaned from disk on load (dedup by `muxName`, keeping the real entry over `restored-` placeholders).
- **Route remaining bare-`tmux` call sites through the socket**: the window-size query on re-attach (previously fell back to 120×40 and lost scrollback) and the send-key route (Shift+Enter / Ctrl+Enter newline).
- **SSH chooser scripts** (`tmux-manager.sh`, `tmux-chooser.sh`) route every tmux call through the dedicated socket.
## 0.6.11
### Patch Changes
- Resume Conversation: fixes and folder drill-down.
- **fix(history)**: `decodeProjectKey()` now uses longest-join-first backtracking with on-disk validation, so sibling directories sharing a prefix (e.g. `diary/` vs `diary-app/`) resolve to the correct path. Previously the greedy shortest-match decoder picked the shorter name and bailed, surfacing `$HOME` in the Resume Conversation list and resuming into the wrong folder. Greedy decode is kept as a fallback so history for deleted projects still resolves. (#92)
- **fix(tabs)**: Drop the client-side resurrection of ended-session tabs. The old code cached open tabs in `localStorage` and rebuilt them as grayed-out stubs whenever the server no longer knew them, which left phantom tabs after closing a session on another device. The server is now the single source of truth; legacy `localStorage` keys are purged on init. Net -44 / +6 lines. (#93)
- **feat(history)**: New "View all in this folder" drill-down on Resume Conversation. `GET /api/history/sessions` accepts `projectKey` (validated against `^[A-Za-z0-9_-]+$` before any filesystem access), `offset`, and `limit`; single-folder mode bypasses the 50-cap and returns `{ sessions, total }`. Frontend adds a modal listing 20 sessions per page with a "Show more" pagination button. Modal items omit their own "View all" button to prevent recursive entry points. (#94)
## 0.6.10
### Patch Changes
- ## Security: paste-image endpoint hardening (#90)
Addresses seven findings from the dismissed review of #84. Most exposed in tunneled deployments where `CODEMAN_PASSWORD` is set but the server is reachable beyond localhost.
- **CSRF protection** on `POST /api/sessions/:id/paste-image`. Requires `Origin`/`Referer` to match `req.host`; non-browser clients (no `Origin` and no `Referer`) must send `X-Codeman-CSRF`. Defeats cross-origin `<form enctype="multipart/form-data">` submits that would otherwise plant arbitrary bytes into the victim's `.claude-images/` while their session cookie is live.
- **Magic-byte validation** on uploaded images. Sniffs the first 12 bytes against PNG/JPEG/GIF/WebP/BMP signatures and rejects 415 on mismatch. Polyglot HTML-or-SVG-with-image-MIME no longer round-trips through the endpoint.
- **Symlink-safe writes** on `.claude-images/`. `lstat` before the write, non-recursive `mkdir`, `O_EXCL|O_NOFOLLOW` on file open. A `node_modules` postinstall (or the agent itself) planting `.claude-images -> ~/.ssh/` no longer redirects pastes outside `workingDir`.
- **Multipart parser swap** to `@fastify/multipart` with `limits: { fileSize: 10MB, files: 1, fields: 4 }`. Replaces a hand-rolled boundary scanner that matched the literal boundary anywhere in the body, hard-coded `\r\n` (silently corrupting LF-only clients), and had no part-count cap.
- **Rate limit + GC**: token-bucket (30/min per IP+session) and hourly GC of `paste-*` files older than 7 days from each live session's `.claude-images/`. New `paste-image-gc.ts` started/stopped from `WebServer.start/stop`.
- **Collision-free filenames**: `paste-${Date.now()}-${randomBytes(4)}${ext}`. Two tabs pasting in the same millisecond no longer silently last-write-wins.
- **Bracketed-paste preservation**: text-only paste in `image-input.js` now goes through `terminal.paste(text)` instead of `sendInput(text)`, so xterm preserves `CSI 200~ ... CSI 201~` markers — Claude Code uses them as part of its prompt-injection defenses.
## Fix: duplicate multipart parser conflict
Removed a duplicate multipart content-type parser left behind after the swap above. The duplicate registration conflicted with `@fastify/multipart`'s own parser; uploads now flow through the plugin exclusively.
## WebGL renderer auto-fallback hardening (#91)
Follow-ups on the longtask auto-fallback shipped in #83.
- `PerformanceObserver` is now disconnected on `onContextLoss` as well as on the trip path. Previously the observer outlived its disposed addon after a context loss, holding a closure reference over every longtask the page emitted.
- Thresholds (`200ms / 3 longtasks / 30s window / 5s grace / 7d sticky-disable`) are hoisted to `WEBGL_FALLBACK` in `constants.js`. No more inline literals.
- New `evaluateWebGLLongTaskTrip()` pure helper splits the rolling-window arithmetic from the `PerformanceObserver` callback so the trip math is unit-testable. New `test/webgl-fallback.test.ts` (9 tests, port 3166): trip inside window, no-trip when spread, sub-threshold filtering, stale-entry pruning, cumulative counting across batches, observer-dispose idempotency.
## CI: server boot smoke test
GitHub Actions now boots the server as a final step after typecheck/lint/format. Catches production-only ESM/CJS regressions that `tsx` masks in dev.
## Docs
`CLAUDE.md` frontend-module table updated to include `image-input.js` (overlooked when #84 landed).
## 0.6.9
### Patch Changes
+7 -7
View File
@@ -56,7 +56,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 0.6.9 (must match `package.json`)
**Version**: 0.7.0 (must match `package.json`)
## Project Overview
@@ -95,9 +95,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly
- **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
- **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
- **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
- **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.
- **`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`.
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
@@ -109,7 +109,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|--------|-----------|-------|
| **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` | |
| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.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 |
| **Orchestrator** | `src/orchestrator-loop.ts`, `src/orchestrator-planner.ts`, `src/orchestrator-verifier.ts` | Read `docs/orchestrator-loop-architecture.md` first |
@@ -120,7 +120,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **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` (~2.9K 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`) + 4 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`) + `sw.js` | |
| **Frontend** | `src/web/public/app.js` (~3.1K 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 |
★ = 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`.
@@ -181,7 +181,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### API Routes
~128 handlers across 15 route files in `src/web/routes/`: system (36), sessions (27), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (5), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
~130 handlers across 15 route files in `src/web/routes/`: system (36), sessions (28), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (5), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
## Adding Features
@@ -216,7 +216,7 @@ Raw `npx vitest` skips `config/vitest.config.ts`; always use `npm test --` or pa
**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()` in `test/routes/`. **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).
## Debugging
@@ -1,3 +1,9 @@
> **⚠️ ARCHIVED 2026-05-21 — superseded, kept for history.**
> The headline items here were verified resolved: the P0 `{WORKING_DIR}` placeholder
> is now replaced (`plan-orchestrator.ts:431`), and the "~66 dead functions in app.js"
> are gone (app.js was modularized 15K→3K LOC). A fresh `npm run knip` sweep on
> 2026-05-21 found only a handful of unused test helpers. Do not treat this as a live TODO.
# Codebase Cleanup Findings
Compiled from parallel analysis of the entire Codeman codebase by 3 research agents (2026-02-19).
@@ -1,3 +1,9 @@
> **⚠️ ARCHIVED 2026-05-21 — superseded, kept for history.**
> The "Critical" structural items here are done: `server.ts` 6,736→2,065 LOC,
> `app.js` 15,196→3,083 LOC, `types.ts` 1,443→12 LOC (now a barrel → `src/types/`).
> The phase plans that executed this work are in `docs/archive/phase*-plan.md`.
> Do not treat this as a live TODO; see CLAUDE.md for current architecture.
# Code Structure & Quality Findings
**Date**: 2026-02-28
+2033 -2
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "0.6.9",
"version": "0.7.0",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
@@ -52,6 +52,7 @@
"dependencies": {
"@fastify/compress": "^8.3.1",
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^8.0.0",
"@fastify/websocket": "^11.2.0",
"@xterm/addon-fit": "^0.11.0",
+10 -3
View File
@@ -32,6 +32,13 @@ set -e
CODEMAN_STATE="$HOME/.codeman/state.json"
CODEMAN_SESSIONS="$HOME/.codeman/mux-sessions.json"
# Dedicated tmux socket all Codeman sessions live on. MUST match
# DEFAULT_CODEMAN_TMUX_SOCKET / CODEMAN_TMUX_SOCKET in src/tmux-manager.ts —
# otherwise list-sessions would enumerate the user's default tmux server
# (missing the real Codeman sessions, surfacing unrelated ones).
CODEMAN_TMUX_SOCKET="${CODEMAN_TMUX_SOCKET:-codeman}"
TMUX_CMD=(tmux -L "$CODEMAN_TMUX_SOCKET")
# iPhone 17 Pro portrait width (conservative)
MAX_WIDTH=44
@@ -286,7 +293,7 @@ parse_sessions() {
# Get PID from tmux
local pid
pid=$(tmux display-message -t "$session_name" -p '#{pane_pid}' 2>/dev/null || echo "0")
pid=$("${TMUX_CMD[@]}" display-message -t "$session_name" -p '#{pane_pid}' 2>/dev/null || echo "0")
SESSION_PIDS+=("$pid")
MUX_NAMES+=("$session_name")
@@ -314,7 +321,7 @@ parse_sessions() {
fi
i=$((i + 1))
done < <(tmux list-sessions 2>/dev/null || true)
done < <("${TMUX_CMD[@]}" list-sessions 2>/dev/null || true)
}
# ============================================================================
@@ -462,7 +469,7 @@ attach_session() {
echo -e "${D}(Ctrl+B D to detach)${R}"
sleep 0.3
tmux attach-session -t "$mux_name"
"${TMUX_CMD[@]}" attach-session -t "$mux_name"
return 0
}
+17 -8
View File
@@ -20,6 +20,13 @@ REVERSE='\033[7m'
# Use the same path as codeman (src/tmux-manager.ts)
SESSIONS_FILE="${HOME}/.codeman/mux-sessions.json"
# Dedicated tmux socket all Codeman sessions live on. MUST match
# DEFAULT_CODEMAN_TMUX_SOCKET / CODEMAN_TMUX_SOCKET in src/tmux-manager.ts —
# otherwise this script would talk to the user's default tmux server and never
# see (or could mis-target) Codeman's sessions.
CODEMAN_TMUX_SOCKET="${CODEMAN_TMUX_SOCKET:-codeman}"
TMUX_CMD=(tmux -L "$CODEMAN_TMUX_SOCKET")
# Cached data
CACHED_JSON=""
@@ -92,7 +99,7 @@ declare -A ALIVE_CACHE
check_alive() {
local mux_name=$1
if [[ -z "${ALIVE_CACHE[$mux_name]+x}" ]]; then
if tmux has-session -t "$mux_name" 2>/dev/null; then
if "${TMUX_CMD[@]}" has-session -t "$mux_name" 2>/dev/null; then
ALIVE_CACHE[$mux_name]=1
else
ALIVE_CACHE[$mux_name]=0
@@ -111,8 +118,10 @@ kill_session() {
local mux_name=$(get_session_field $idx "muxName")
local pid=$(get_session_field $idx "pid")
# SAFETY: Never kill own tmux session
local current_session=$(tmux display-message -p '#{session_name}' 2>/dev/null || echo "")
# SAFETY: Never kill own tmux session. Queried on the Codeman socket; if run
# from a session on a different socket this returns empty (no match), which
# is fine — you can't be "inside" a Codeman-socket session you didn't attach to.
local current_session=$("${TMUX_CMD[@]}" display-message -p '#{session_name}' 2>/dev/null || echo "")
if [[ -n "$current_session" && "$mux_name" == "$current_session" ]]; then
echo -e "${RED}BLOCKED: Cannot kill own tmux session: $mux_name${NC}"
return 1
@@ -120,7 +129,7 @@ kill_session() {
pkill -TERM -P $pid 2>/dev/null
kill -TERM -$pid 2>/dev/null
tmux kill-session -t "$mux_name" 2>/dev/null
"${TMUX_CMD[@]}" kill-session -t "$mux_name" 2>/dev/null
kill -KILL $pid 2>/dev/null
# Remove from JSON
@@ -345,7 +354,7 @@ interactive_menu() {
clear
echo -e "${CYAN}Attaching... (Ctrl+B D to detach)${NC}"
sleep 0.3
tmux attach-session -t "$mux_name"
"${TMUX_CMD[@]}" attach-session -t "$mux_name"
tput civis
need_full_redraw=1
force_refresh
@@ -474,7 +483,7 @@ main() {
[[ -z "${2:-}" ]] && { echo "Usage: $0 attach <N>"; exit 1; }
force_refresh
local mux_name=$(get_session_field $(($2-1)) "muxName")
check_alive "$mux_name" && tmux attach-session -t "$mux_name" || echo "Session dead or not found"
check_alive "$mux_name" && "${TMUX_CMD[@]}" attach-session -t "$mux_name" || echo "Session dead or not found"
;;
kill)
[[ -z "${2:-}" ]] && { echo "Usage: $0 kill <N|N,M|N-M>"; exit 1; }
@@ -492,8 +501,8 @@ main() {
;;
kill-all)
force_refresh
# SAFETY: Never kill own tmux session
local current_session=$(tmux display-message -p '#{session_name}' 2>/dev/null || echo "")
# SAFETY: Never kill own tmux session (queried on the Codeman socket)
local current_session=$("${TMUX_CMD[@]}" display-message -p '#{session_name}' 2>/dev/null || echo "")
local killed=0
for ((i=CACHED_COUNT-1; i>=0; i--)); do
local mux_name=$(get_session_field $i "muxName")
+3
View File
@@ -98,6 +98,9 @@ export interface TerminalMultiplexer extends EventEmitter {
/** Which backend this instance uses */
readonly backend: 'tmux';
/** The dedicated tmux socket name all sessions live on (e.g. "codeman"). */
readonly muxSocket: string;
// ========== Lifecycle ==========
/**
+13 -6
View File
@@ -133,15 +133,22 @@ const TMUX_DISPLAY_TIMEOUT_MS = 2000;
* (tmux dead, muxName unknown, malformed output) — caller never has to
* differentiate "tmux unreachable" from "size 120x40".
*
* `socket` MUST be the same dedicated socket the session lives on (`mux.muxSocket`);
* querying the default server would never find the session and silently fall back.
*
* Argv form (execFileSync, not execSync) keeps `muxName` out of any shell so
* a hostile session name can't inject options.
*/
export function queryTmuxWindowSize(muxName: string): { cols: number; rows: number } {
export function queryTmuxWindowSize(muxName: string, socket: string): { cols: number; rows: number } {
try {
const sizeStr = execFileSync('tmux', ['display', '-t', muxName, '-p', '#{window_width} #{window_height}'], {
timeout: TMUX_DISPLAY_TIMEOUT_MS,
encoding: 'utf8',
}).trim();
const sizeStr = execFileSync(
'tmux',
['-L', socket, 'display', '-t', muxName, '-p', '#{window_width} #{window_height}'],
{
timeout: TMUX_DISPLAY_TIMEOUT_MS,
encoding: 'utf8',
}
).trim();
const [w, h] = sizeStr.split(' ').map(Number);
if (w > 0 && h > 0) {
return { cols: w, rows: h };
@@ -978,7 +985,7 @@ 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)
const { cols: ptyCols, rows: ptyRows } = queryTmuxWindowSize(this._muxSession!.muxName);
const { cols: ptyCols, rows: ptyRows } = queryTmuxWindowSize(this._muxSession!.muxName, mux.muxSocket);
try {
this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), {
name: 'xterm-256color',
+143 -47
View File
@@ -71,6 +71,9 @@ const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
/** Default stats collection interval (2 seconds) */
const DEFAULT_STATS_INTERVAL_MS = 2000;
/** Claude Code native macOS recommendation for avoiding low nofile startup failures. */
export const CLAUDE_CODE_NOFILE_LIMIT = 2147483646;
/**
* SAFETY: Test mode detection.
* When running under vitest (VITEST env var is set automatically),
@@ -98,6 +101,12 @@ const LEGACY_MUX_NAME_PATTERN = /^claudeman-[a-f0-9-]+$/;
/** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */
const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/;
/** Dedicated tmux socket for new Codeman-owned sessions. */
const DEFAULT_CODEMAN_TMUX_SOCKET = 'codeman';
/** Regex to validate tmux socket names passed to `tmux -L`. */
const SAFE_TMUX_SOCKET_PATTERN = /^[a-zA-Z0-9_.-]+$/;
/**
* Separator used in `tmux list-panes -F` output between session name and pid.
*
@@ -114,6 +123,19 @@ const PANE_LIST_SEP = '|';
/** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneList}. */
const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}`;
/**
* 构建 pane 启动前的 nofile 修复命令。
*
* macOS launchd/tmux 组合有时会让 pane 继承 256 的 soft nofile;
* 新版 Claude Code 会在这种环境下直接退出。这里避免使用 $变量
* 或命令替换,因为 fullCmd 目前经由双引号 bash -c 传递,外层
* shell 会提前展开它们。
*/
export function buildNofileLimitCommand(targetLimit = CLAUDE_CODE_NOFILE_LIMIT): string {
const safeLimit = Number.isSafeInteger(targetLimit) && targetLimit > 0 ? targetLimit : CLAUDE_CODE_NOFILE_LIMIT;
return `ulimit -Sn ${safeLimit} 2>/dev/null || ulimit -n ${safeLimit} 2>/dev/null || true`;
}
/**
* Parse the output of `tmux list-panes -a -F '#{session_name}|#{pane_pid}'`
* into a Map of session-name → pane pid. Exported for unit testing.
@@ -161,6 +183,31 @@ function isValidPath(path: string): boolean {
return SAFE_PATH_PATTERN.test(path);
}
// ===========================================================================
// Single-socket architecture: ALL Codeman sessions live on one dedicated tmux
// socket (`tmux -L codeman`), isolated from the user's default tmux server.
// The socket name is a process-wide constant (env-overridable for test/multi-
// instance isolation) — it is never stored per-session, so it cannot drift.
// ===========================================================================
/**
* Resolve the process-wide Codeman tmux socket name. Always returns a valid
* name: `CODEMAN_TMUX_SOCKET` env override if safe, else the built-in default.
*/
function resolveConfiguredTmuxSocket(): string {
const raw = process.env.CODEMAN_TMUX_SOCKET ?? DEFAULT_CODEMAN_TMUX_SOCKET;
if (!SAFE_TMUX_SOCKET_PATTERN.test(raw)) {
console.warn(`[TmuxManager] Ignoring invalid CODEMAN_TMUX_SOCKET: ${JSON.stringify(raw)}`);
return DEFAULT_CODEMAN_TMUX_SOCKET;
}
return raw;
}
/** Build the `tmux -L <socket>` command prefix. Socket name is shell-escaped. */
function tmuxCommand(socket: string): string {
return `tmux -L ${shellescape(socket)}`;
}
/**
* Build Claude CLI permission flags for the tmux command string.
* Validates allowedTools to prevent command injection.
@@ -248,7 +295,7 @@ function buildSpawnCommand(options: {
* Set sensitive environment variables on a tmux session via setenv.
* These are inherited by panes but not visible in ps output or tmux history.
*/
function setOpenCodeEnvVars(muxName: string): void {
function setOpenCodeEnvVars(tmuxCmd: string, muxName: string): void {
const sensitiveVars = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY'];
for (const key of sensitiveVars) {
const val = process.env[key];
@@ -256,7 +303,7 @@ function setOpenCodeEnvVars(muxName: string): void {
// Shell-escape: wrap in single quotes, escape any inner single quotes
const escaped = val.replace(/'/g, "'\\''");
try {
execSync(`tmux setenv -t '${muxName}' ${key} '${escaped}'`, {
execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
@@ -272,7 +319,7 @@ function setOpenCodeEnvVars(muxName: string): void {
* Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv.
* Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON.
*/
function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): void {
function setOpenCodeConfigContent(tmuxCmd: string, muxName: string, config?: OpenCodeConfig): void {
if (!config) return;
let jsonContent: string | undefined;
@@ -303,7 +350,7 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi
if (jsonContent) {
const escaped = jsonContent.replace(/'/g, "'\\''");
try {
execSync(`tmux setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, {
execSync(`${tmuxCmd} setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, {
encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
@@ -336,6 +383,7 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi
export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
readonly backend = 'tmux' as const;
private sessions: Map<string, MuxSession> = new Map();
private readonly tmuxSocket = resolveConfiguredTmuxSocket();
private statsInterval: NodeJS.Timeout | null = null;
private mouseSyncInterval: NodeJS.Timeout | null = null;
/** Track last-known pane count per session to avoid unnecessary tmux set-option calls */
@@ -351,6 +399,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
/** The dedicated tmux socket all Codeman sessions live on (see {@link TerminalMultiplexer.muxSocket}). */
get muxSocket(): string {
return this.tmuxSocket;
}
private tmux(): string {
return tmuxCommand(this.tmuxSocket);
}
// Load saved sessions from disk (NEVER called in test mode)
private loadSessions(): void {
if (IS_TEST_MODE) return;
@@ -360,8 +417,40 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const content = readFileSync(MUX_SESSIONS_FILE, 'utf-8');
const data = JSON.parse(content);
if (Array.isArray(data)) {
// Dedup by muxName: one live tmux session must map to exactly one
// tracked entry. A per-session socket-tag mismatch could historically
// let the same session be tracked twice — once under its real UUID and
// once under a "restored-<id>" placeholder — surfacing as duplicate tabs.
// Single-socket unification removed that failure mode; this pass stays
// to clean any stale duplicates already on disk. Keep the real (UUID)
// entry and drop placeholder twins.
let dropped = 0;
const keptByMuxName = new Map<string, string>(); // muxName -> kept sessionId
for (const session of data) {
// Strip the obsolete per-session tmuxSocket tag (now a process-wide
// constant). Left in place it would be written back by saveSessions()
// and linger on disk as a zombie field forever.
delete (session as { tmuxSocket?: unknown }).tmuxSocket;
const muxName: string | undefined = session.muxName;
const priorId = muxName ? keptByMuxName.get(muxName) : undefined;
if (priorId) {
const incomingIsPlaceholder = String(session.sessionId).startsWith('restored-');
const priorIsPlaceholder = priorId.startsWith('restored-');
// Drop the incoming unless it's the real twin of a placeholder we kept.
if (incomingIsPlaceholder || !priorIsPlaceholder) {
dropped++;
continue;
}
this.sessions.delete(priorId);
dropped++;
}
this.sessions.set(session.sessionId, session);
if (muxName) keptByMuxName.set(muxName, session.sessionId);
}
// Persist the cleaned list so the stale duplicates don't reload.
if (dropped > 0) {
console.log(`[TmuxManager] Dropped ${dropped} duplicate mux session record(s) on load`);
this.saveSessions();
}
}
}
@@ -438,7 +527,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
continue;
}
try {
execSync(`tmux setenv -t ${shellescape(muxName)} ${key} ${shellescape(value)}`, {
execSync(`${this.tmux()} setenv -t ${shellescape(muxName)} ${key} ${shellescape(value)}`, {
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
});
@@ -471,8 +560,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* (not visible in ps output or tmux history, inherited by panes).
*/
private _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig): void {
setOpenCodeEnvVars(muxName);
setOpenCodeConfigContent(muxName, openCodeConfig);
const tmuxCmd = this.tmux();
setOpenCodeEnvVars(tmuxCmd, muxName);
setOpenCodeConfigContent(tmuxCmd, muxName, openCodeConfig);
}
/**
@@ -545,7 +635,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
try {
// Build the full command to run inside tmux
const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`;
const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
// Create tmux session in three steps to handle cold-start (no server running)
// and avoid the race where the command exits before remain-on-exit is set:
@@ -556,7 +646,7 @@ 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;
execSync(`tmux new-session -ds "${muxName}" -c "${workingDir}"`, {
execSync(`${this.tmux()} new-session -ds "${muxName}" -c "${workingDir}"`, {
cwd: workingDir,
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
@@ -565,7 +655,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Set remain-on-exit now that the server is running — must be before respawn-pane
try {
execSync(`tmux set-option -t "${muxName}" remain-on-exit on`, {
execSync(`${this.tmux()} set-option -t "${muxName}" remain-on-exit on`, {
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
});
@@ -584,7 +674,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
this.applyEnvOverrides(muxName, envOverrides);
// Replace the shell with the actual command (no echo in terminal)
execSync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
execSync(`${this.tmux()} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
});
@@ -598,20 +688,20 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// It gets enabled dynamically when panes are split (agent teams).
const configPromises: Promise<void>[] = [
// Disable tmux status bar — Codeman's web UI provides session info
execAsync(`tmux set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS })
execAsync(`${this.tmux()} set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS })
.then(() => {})
.catch(() => {
/* Non-critical — session still works with status bar */
}),
// Override global remain-on-exit with session-level setting
execAsync(`tmux set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS })
execAsync(`${this.tmux()} set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS })
.then(() => {})
.catch(() => {
/* Already set globally as fallback */
}),
// Raise tmux scrollback from its 2000-line default so re-attach preserves
// more context. Matches the xterm-side default in constants.js.
execAsync(`tmux set-option -t "${muxName}" history-limit 50000`, { timeout: EXEC_TIMEOUT_MS })
execAsync(`${this.tmux()} set-option -t "${muxName}" history-limit 50000`, { timeout: EXEC_TIMEOUT_MS })
.then(() => {})
.catch(() => {
/* Non-critical — falls back to tmux default */
@@ -621,7 +711,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Enable 24-bit true color passthrough — server-wide, set once per lifetime
if (!this.trueColorConfigured) {
configPromises.push(
execAsync(`tmux set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS })
execAsync(`${this.tmux()} set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS })
.then(() => {
this.trueColorConfigured = true;
})
@@ -678,7 +768,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
try {
const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_pid}'`, {
const output = execSync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_pid}'`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
@@ -704,7 +794,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (IS_TEST_MODE) return false;
if (!isValidMuxName(muxName)) return false;
try {
const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_dead}'`, {
const output = execSync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_dead}'`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
@@ -754,7 +844,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
});
const config = niceConfig || DEFAULT_NICE_CONFIG;
const cmd = wrapWithNice(baseCmd, config);
const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`;
const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
try {
// For OpenCode: set sensitive env vars via tmux setenv before respawn
@@ -765,7 +855,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Re-apply user env overrides before respawn so the new shell inherits them.
this.applyEnvOverrides(muxName, envOverrides);
await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
await execAsync(`${this.tmux()} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
timeout: EXEC_TIMEOUT_MS,
});
// Wait for the respawned process to start
@@ -783,7 +873,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (IS_TEST_MODE) return false;
try {
execSync(`tmux has-session -t "${muxName}" 2>/dev/null`, {
execSync(`${this.tmux()} has-session -t "${muxName}" 2>/dev/null`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
@@ -923,7 +1013,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Strategy 3: Kill tmux session by name
try {
execSync(`tmux kill-session -t "${session.muxName}" 2>/dev/null`, {
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
timeout: EXEC_TIMEOUT_MS,
});
} catch {
@@ -988,26 +1078,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const dead: string[] = [];
const discovered: string[] = [];
// Batch: single tmux call to get all session names + pane PIDs (replaces N per-session subprocess calls)
let activeSessions = new Map<string, number>();
// Single batched query against the one socket Codeman owns. With a single
// socket a session's location is a constant, so there is no per-session
// socket tag to reconcile and no cross-socket ambiguity that could mark a
// live session dead (the root cause of vanished/duplicate tabs).
let active: Map<string, number>;
try {
const output = execSync(`tmux list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, {
const output = execSync(`${this.tmux()} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
activeSessions = parsePaneList(output);
active = parsePaneList(output);
} catch (err) {
console.error('[TmuxManager] Failed to list tmux panes:', err);
active = new Map();
}
// Check known sessions against the batch result (O(1) map lookup instead of subprocess per session)
// Check tracked sessions against the live pane list.
for (const [sessionId, session] of this.sessions) {
const pid = activeSessions.get(session.muxName);
const pid = active.get(session.muxName);
if (pid !== undefined) {
alive.push(sessionId);
if (pid !== session.pid) {
session.pid = pid;
}
if (pid !== session.pid) session.pid = pid;
} else {
dead.push(sessionId);
this.sessions.delete(sessionId);
@@ -1015,13 +1107,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
// Discover unknown codeman/claudeman sessions from the same batch result
// Discover untracked codeman/claudeman sessions on our socket. Dedup by
// muxName (globally unique) so a name we already track never spawns a
// second "Restored:" entry.
const knownMuxNames = new Set<string>();
for (const session of this.sessions.values()) {
knownMuxNames.add(session.muxName);
}
for (const [sessionName, pid] of activeSessions) {
for (const [sessionName, pid] of active) {
if (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-')) continue;
if (knownMuxNames.has(sessionName)) continue;
@@ -1038,6 +1132,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
name: `Restored: ${sessionName}`,
};
this.sessions.set(sessionId, session);
knownMuxNames.add(sessionName);
discovered.push(sessionId);
console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`);
}
@@ -1345,21 +1440,21 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Ink (Claude CLI's terminal framework) needs them split — sending both in a
// single tmux invocation (via \;) causes Ink to interpret Enter as a newline
// character in the input buffer rather than as form submission.
await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
timeout: EXEC_TIMEOUT_MS,
});
await new Promise((resolve) => setTimeout(resolve, 50));
await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, {
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
timeout: EXEC_TIMEOUT_MS,
});
} else if (textPart) {
// Text only, no Enter
await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
timeout: EXEC_TIMEOUT_MS,
});
} else if (hasCarriageReturn) {
// Enter only
await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, {
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
timeout: EXEC_TIMEOUT_MS,
});
}
@@ -1386,7 +1481,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
try {
execSync(`tmux set-option -t "${muxName}" mouse on`, {
execSync(`${this.tmux()} set-option -t "${muxName}" mouse on`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
@@ -1410,7 +1505,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
try {
execSync(`tmux set-option -t "${muxName}" mouse off`, {
execSync(`${this.tmux()} set-option -t "${muxName}" mouse off`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
@@ -1450,7 +1545,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
try {
const output = execSync(
`tmux list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`,
`${this.tmux()} list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
).trim();
@@ -1489,27 +1584,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Build target: sessionName.paneId (e.g., "codeman-abc12345.%1")
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
const tmux = this.tmux();
try {
const hasCarriageReturn = input.includes('\r');
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
if (textPart && hasCarriageReturn) {
execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
execSync(`tmux send-keys -t ${shellescape(target)} Enter`, {
execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
} else if (textPart) {
execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
} else if (hasCarriageReturn) {
execSync(`tmux send-keys -t ${shellescape(target)} Enter`, {
execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
@@ -1540,7 +1636,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try {
return execSync(`tmux capture-pane -p -e -t ${shellescape(target)} -S -5000`, {
return execSync(`${this.tmux()} capture-pane -p -e -t ${shellescape(target)} -S -5000`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
@@ -1572,7 +1668,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try {
execSync(`tmux pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, {
execSync(`${this.tmux()} pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
@@ -1600,7 +1696,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try {
execSync(`tmux pipe-pane -t ${shellescape(target)}`, {
execSync(`${this.tmux()} pipe-pane -t ${shellescape(target)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
@@ -1616,7 +1712,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
getAttachArgs(muxName: string): string[] {
return ['attach-session', '-t', muxName];
return ['-L', this.tmuxSocket, 'attach-session', '-t', muxName];
}
isAvailable(): boolean {
+69
View File
@@ -0,0 +1,69 @@
/**
* @fileoverview Periodic GC for paste-image files.
*
* Without cleanup, /api/sessions/:id/paste-image accumulates files indefinitely
* under {workingDir}/.claude-images/. The route only triggers cleanup on
* killMux=true session deletion, so long-lived sessions can fill disk under
* heavy pasting. This sweeper bounds disk use by deleting `paste-*` files
* older than MAX_AGE_MS from each live session's image dir on an interval.
*
* Conservative defaults — only files matching the `paste-` prefix are
* considered, and we lstat (not stat) so a planted symlink cannot escape the
* image dir.
*/
import fs from 'node:fs/promises';
import { join } from 'node:path';
import type { SessionPort } from './ports/index.js';
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const SWEEP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
const INITIAL_DELAY_MS = 30 * 1000; // 30s after startup
export async function sweepPasteImagesOnce(
ctx: Pick<SessionPort, 'sessions'>,
now: number = Date.now()
): Promise<{ scanned: number; deleted: number }> {
const cutoff = now - MAX_AGE_MS;
let scanned = 0;
let deleted = 0;
for (const session of ctx.sessions.values()) {
const dir = join(session.workingDir, '.claude-images');
let entries: string[];
try {
entries = await fs.readdir(dir);
} catch {
continue; // dir absent — nothing to do
}
for (const name of entries) {
if (!name.startsWith('paste-')) continue;
const p = join(dir, name);
scanned += 1;
try {
const st = await fs.lstat(p);
if (!st.isFile()) continue;
if (st.mtimeMs < cutoff) {
await fs.unlink(p);
deleted += 1;
}
} catch {
// best-effort: skip permission/race errors silently
}
}
}
return { scanned, deleted };
}
export function startPasteImageGc(ctx: Pick<SessionPort, 'sessions'>): () => void {
const initial = setTimeout(() => {
void sweepPasteImagesOnce(ctx);
}, INITIAL_DELAY_MS);
const interval = setInterval(() => {
void sweepPasteImagesOnce(ctx);
}, SWEEP_INTERVAL_MS);
if (typeof initial.unref === 'function') initial.unref();
if (typeof interval.unref === 'function') interval.unref();
return (): void => {
clearTimeout(initial);
clearInterval(interval);
};
}
+108 -77
View File
@@ -625,6 +625,7 @@ class CodemanApp {
console.error('[CRASH-DIAG] WebGL context LOST — falling back to canvas renderer');
_crashDiag.log('WEBGL_LOST');
this._disableWebGLSticky('context-lost');
this._disposeWebGLObserver();
this._webglAddon?.dispose();
this._webglAddon = null;
});
@@ -636,9 +637,10 @@ class CodemanApp {
/**
* Watch for sustained main-thread stalls that indicate WebGL/GPU trouble.
* After 3 long tasks (>=200ms each) within 30s, dispose the WebGL addon and
* persist a sticky disable so subsequent reloads also use the DOM renderer.
* 5s grace period skips initial-load stalls. Force-re-enable: ?webgl=force.
* After WEBGL_FALLBACK.LONGTASK_COUNT long tasks (>=LONGTASK_MS each) within
* WINDOW_MS, dispose the WebGL addon and persist a sticky disable so
* subsequent reloads also use the DOM renderer. GRACE_MS skips initial-load
* stalls. Force-re-enable: ?webgl=force.
*/
_installWebGLLongTaskGuard() {
if (typeof PerformanceObserver === 'undefined' || this._webglLongTaskObserver) return;
@@ -648,19 +650,14 @@ class CodemanApp {
this._webglLongTaskObserver = new PerformanceObserver((list) => {
if (!this._webglAddon) return;
const now = performance.now();
if (now - installedAt < 5000) return;
for (const entry of list.getEntries()) {
if (entry.duration >= 200) recent.push(entry.startTime);
}
while (recent.length && now - recent[0] > 30000) recent.shift();
if (recent.length >= 3) {
console.warn(`[CRASH-DIAG] WebGL long-task threshold (${recent.length} stalls/30s) — falling back to canvas renderer`);
if (now - installedAt < WEBGL_FALLBACK.GRACE_MS) return;
if (evaluateWebGLLongTaskTrip(recent, list.getEntries(), now)) {
console.warn(`[CRASH-DIAG] WebGL long-task threshold (${recent.length} stalls/${WEBGL_FALLBACK.WINDOW_MS}ms) — falling back to canvas renderer`);
_crashDiag.log(`WEBGL_FALLBACK: ${recent.length}`);
this._disableWebGLSticky('long-tasks');
this._disposeWebGLObserver();
this._webglAddon?.dispose();
this._webglAddon = null;
try { this._webglLongTaskObserver.disconnect(); } catch {}
this._webglLongTaskObserver = null;
try { this.terminal.refresh(0, this.terminal.rows - 1); } catch {}
}
});
@@ -668,6 +665,18 @@ class CodemanApp {
} catch { /* longtask not supported */ }
}
/**
* Disconnect the WebGL longtask observer. Idempotent. Called from the trip
* path, the onContextLoss handler, and any future terminal-teardown path —
* the observer outlives its addon otherwise, holding a closure reference
* over `this` for every long task the page emits.
*/
_disposeWebGLObserver() {
if (!this._webglLongTaskObserver) return;
try { this._webglLongTaskObserver.disconnect(); } catch {}
this._webglLongTaskObserver = null;
}
_disableWebGLSticky(reason) {
try {
localStorage.setItem('codeman-webgl-disabled', JSON.stringify({ reason, at: Date.now() }));
@@ -1119,22 +1128,40 @@ class CodemanApp {
const DIAGRAM_CHAR = /[─-╿▀-▟]/;
const tmpl = document.createElement('template');
tmpl.innerHTML = html;
// Every fenced code block gets a positioned wrapper with an action
// toolbar pinned to its top-right corner. The toolbar lives OUTSIDE the
// <pre> scroll container so its buttons stay put during horizontal
// scroll. All blocks get a one-click copy button; ASCII diagrams keep
// the additional line-wrap toggle.
tmpl.content.querySelectorAll('pre > code').forEach((code) => {
if (!DIAGRAM_CHAR.test(code.textContent || '')) return;
const pre = code.parentElement;
pre.classList.add('rv-diagram');
const isDiagram = DIAGRAM_CHAR.test(code.textContent || '');
const wrap = document.createElement('div');
wrap.className = 'rv-diagram-wrap';
wrap.className = isDiagram ? 'rv-code-wrap rv-diagram-wrap' : 'rv-code-wrap';
const btn = document.createElement('button');
btn.className = 'rv-wrap-toggle';
btn.type = 'button';
btn.setAttribute('aria-label', 'Toggle line wrapping');
btn.setAttribute('title', 'Toggle line wrapping');
const actions = document.createElement('div');
actions.className = 'rv-code-actions';
const copyBtn = document.createElement('button');
copyBtn.className = 'rv-copy-btn';
copyBtn.type = 'button';
copyBtn.setAttribute('aria-label', 'Copy code');
copyBtn.setAttribute('title', 'Copy code');
actions.appendChild(copyBtn);
if (isDiagram) {
pre.classList.add('rv-diagram');
const toggle = document.createElement('button');
toggle.className = 'rv-wrap-toggle';
toggle.type = 'button';
toggle.setAttribute('aria-label', 'Toggle line wrapping');
toggle.setAttribute('title', 'Toggle line wrapping');
actions.appendChild(toggle);
}
pre.parentNode.insertBefore(wrap, pre);
wrap.appendChild(btn);
wrap.appendChild(actions);
wrap.appendChild(pre);
});
return tmpl.innerHTML;
@@ -1153,7 +1180,23 @@ class CodemanApp {
_bindResponseViewerInteractions(body) {
if (!body || body.dataset.rvBound === '1') return;
body.dataset.rvBound = '1';
body.addEventListener('click', (ev) => {
body.addEventListener('click', async (ev) => {
// One-click copy: lift the raw source from the sibling <pre><code>.
const copyBtn = ev.target.closest('.rv-copy-btn');
if (copyBtn) {
ev.preventDefault();
ev.stopPropagation();
const code = copyBtn.closest('.rv-code-wrap')?.querySelector('pre code');
const ok = code ? await this._copyText(code.textContent || '') : false;
copyBtn.classList.remove('rv-copied', 'rv-copy-failed');
copyBtn.classList.add(ok ? 'rv-copied' : 'rv-copy-failed');
clearTimeout(copyBtn._resetTimer);
copyBtn._resetTimer = setTimeout(() => {
copyBtn.classList.remove('rv-copied', 'rv-copy-failed');
}, 1500);
return;
}
const btn = ev.target.closest('.rv-wrap-toggle');
if (!btn) return;
ev.preventDefault();
@@ -1166,6 +1209,34 @@ class CodemanApp {
});
}
/**
* Copy text to the clipboard. Prefers the async Clipboard API (secure
* contexts); falls back to a hidden-textarea + execCommand path so copy
* still works over plain HTTP. Returns true on success.
*/
async _copyText(text) {
if (!text) return false;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch { /* secure-context write failed — try the legacy path */ }
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
}
async toggleResponseViewer() {
const viewer = document.getElementById('responseViewer');
const backdrop = document.getElementById('responseViewerBackdrop');
@@ -1785,8 +1856,10 @@ class CodemanApp {
}
});
// Restore tabs that were open before refresh but are no longer on the server
this._restoreEndedTabs();
// Server is source of truth for open sessions — don't resurrect stale tabs
// from localStorage (would show phantom "ended" tabs when a session was closed
// on another device).
try { localStorage.removeItem('codeman-tab-meta'); } catch {}
// Sync sessionOrder with current sessions (preserve order, add new, remove stale)
this.syncSessionOrder();
@@ -2108,8 +2181,7 @@ class CodemanApp {
const tallTabsEnabled = this._tallTabsEnabled ?? false;
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
const endedAttr = session._ended ? ' data-ended="1"' : '';
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}" data-id="${id}" data-color="${color}"${endedAttr} 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}" 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)}"` : ''}>
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
<span class="tab-status ${status}" aria-hidden="true"></span>
<span class="tab-info">
@@ -2129,9 +2201,6 @@ class CodemanApp {
container.innerHTML = parts.join('');
// Persist tab metadata for refresh recovery
this._saveTabMetadata();
// Set up drag-and-drop handlers for tab reordering
this.setupTabDragHandlers();
@@ -2231,33 +2300,6 @@ class CodemanApp {
}
}
// Save tab metadata to localStorage so ended sessions can be restored after refresh
_saveTabMetadata() {
try {
const meta = {};
for (const [id, s] of this.sessions) {
if (s._ended) continue; // Don't persist ended stubs back
meta[id] = { id, name: s.name || '', workingDir: s.workingDir || '', mode: s.mode || 'claude', color: s.color || 'default' };
}
localStorage.setItem('codeman-tab-meta', JSON.stringify(meta));
} catch { /* ignore */ }
}
// Restore tabs that were open before refresh but are no longer on the server
_restoreEndedTabs() {
try {
const saved = localStorage.getItem('codeman-tab-meta');
if (!saved) return;
const meta = JSON.parse(saved);
for (const [id, info] of Object.entries(meta)) {
if (!this.sessions.has(id)) {
// Add a stub session so the tab renders
this.sessions.set(id, { id, name: info.name, workingDir: info.workingDir, mode: info.mode, color: info.color, status: 'ended', _ended: true });
}
}
} catch { /* ignore */ }
}
// Set up drag-and-drop handlers on tab elements
setupTabDragHandlers() {
const container = this.$('sessionTabs');
@@ -2537,16 +2579,9 @@ class CodemanApp {
// Check if this is a restored session that needs to be attached
const session = this.sessions.get(sessionId);
// Ended tabs (restored from localStorage, no longer on server) — show message, skip buffer load
if (session?._ended) {
this.terminal.clear();
this.terminal.write('\r\n \x1b[2mSession ended. Close tab or click to reopen.\x1b[0m\r\n');
return;
}
// Track working directory for path normalization in Project Insights
this.currentSessionWorkingDir = session?.workingDir || null;
if (session && session.pid === null && !session._ended) {
if (session && session.pid === null) {
// Session has no PTY attached — either restored after server restart
// or detached for some other reason. Re-attach regardless of status.
try {
@@ -2670,19 +2705,15 @@ class CodemanApp {
});
}
// Fire-and-forget resize + Ctrl+L to force Ink redraw.
// Tailed buffers accumulate stale CUP-positioned Ink frames that overlap
// in the viewport (e.g. duplicate "bypass permissions" bars). Ctrl+L
// triggers a full Ink redraw which overwrites all stale frame content.
// sendResize may be a no-op if dimensions match, so Ctrl+L is essential.
this.sendResize(sessionId).then(() => {
if (selectGen !== this._selectGeneration) return;
fetch(`/api/sessions/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: '\x0c' })
}).catch(() => {});
});
// Fire-and-forget resize to nudge Ink via SIGWINCH on real size changes.
// Previously we also sent Ctrl+L (\x0c) here to force a full Ink redraw,
// but Claude Code 2.x treats Ctrl+L as a two-step "clear conversation"
// command — if a page refresh or SSE reconnect ran selectSession twice
// within Claude's confirmation window, the second \x0c silently wiped the
// conversation. Stale Ink frames in the tailed buffer are a cosmetic
// annoyance that disappear on the user's next keypress; data loss is not
// acceptable. Do NOT re-introduce Ctrl+L here.
this.sendResize(sessionId);
// Defer secondary panel updates so they don't block the main thread
// after terminal content is already visible.
+46
View File
@@ -71,6 +71,52 @@ const WINDOW_MIN_WIDTH_PX = 200;
const WINDOW_MIN_HEIGHT_PX = 200;
const WINDOW_DEFAULT_WIDTH_PX = 300;
// WebGL renderer auto-fallback thresholds.
// _installWebGLLongTaskGuard() observes longtask entries and disables WebGL
// after LONGTASK_COUNT stalls of >= LONGTASK_MS within WINDOW_MS. GRACE_MS
// suppresses the noisy initial-load stalls. STICKY_EXPIRY_MS is how long
// localStorage's webgl-disabled marker survives before we retry WebGL on a
// fresh load (driver/Chrome may have been updated).
const WEBGL_FALLBACK = {
LONGTASK_MS: 200,
LONGTASK_COUNT: 3,
WINDOW_MS: 30000,
GRACE_MS: 5000,
STICKY_EXPIRY_MS: 7 * 24 * 60 * 60 * 1000,
};
/**
* Pure rolling-window trip evaluator for the WebGL longtask guard.
* Mutates `recent` in place (prunes entries older than `now - WINDOW_MS`)
* and appends each new duration's startTime that meets the threshold.
* Returns true when the count inside the window reaches `LONGTASK_COUNT`.
*
* Exposed on `window` for unit testing — the production guard in app.js
* inlines this same logic in its PerformanceObserver callback. Splitting it
* out keeps the threshold math testable without a real PerformanceObserver.
*
* @param {number[]} recent - mutable array of startTimes inside the window
* @param {{startTime: number, duration: number}[]} entries - new longtask entries
* @param {number} now - performance.now() at evaluation time
* @param {typeof WEBGL_FALLBACK} [config=WEBGL_FALLBACK] - thresholds
* @returns {boolean} true if the rolling window has reached the trip count
*/
function evaluateWebGLLongTaskTrip(recent, entries, now, config = WEBGL_FALLBACK) {
for (const entry of entries) {
if (entry.duration >= config.LONGTASK_MS) recent.push(entry.startTime);
}
while (recent.length && now - recent[0] > config.WINDOW_MS) recent.shift();
return recent.length >= config.LONGTASK_COUNT;
}
// Expose for tests. `const` declarations at the top of a non-module script
// are global lexical bindings but not `window` properties, so explicit
// assignment is the test-visible API surface.
if (typeof window !== 'undefined') {
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
}
// Scheduler API — prioritize terminal writes over background UI updates.
// scheduler.postTask('background') defers non-critical work (connection lines, panel renders)
// so the main thread stays free for terminal rendering at 60fps.
+7 -2
View File
@@ -87,10 +87,15 @@ Object.assign(CodemanApp.prototype, {
e.preventDefault();
self._uploadAndInsertImages(imageFiles);
} else {
// No image -- extract text and send to terminal
// No image -- route text through xterm's paste() so bracketed-paste
// markers (CSI 200~ ... CSI 201~) survive when the inner application
// has enabled bracketed-paste mode (Claude Code does). Sending text
// via raw sendInput() strips those markers and makes pasted input
// indistinguishable from typed input, weakening the CLI's
// prompt-injection defenses.
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
e.preventDefault();
if (text) self.sendInput(text);
if (text && self.terminal) self.terminal.paste(text);
}
});
+118 -1
View File
@@ -293,7 +293,6 @@ body {
}
.session-tab .tab-status.error { background: var(--red); }
.session-tab .tab-status.ended { background: var(--text-muted); opacity: 0.5; }
.session-tab[data-ended] { opacity: 0.55; }
/* Session color coding - left border indicator */
.session-tab[data-color="red"] { border-left: 3px solid var(--session-red); }
@@ -2370,6 +2369,56 @@ body {
color: var(--text);
}
.history-detail-actions {
margin-top: 0.5rem;
}
.history-view-all-btn {
width: 100%;
padding: 0.45rem 0.75rem;
background: rgba(99, 179, 237, 0.08);
border: 1px solid rgba(99, 179, 237, 0.25);
border-radius: 6px;
color: rgba(99, 179, 237, 0.95);
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
transition: background var(--transition-smooth), border-color var(--transition-smooth);
}
.history-view-all-btn:hover {
background: rgba(99, 179, 237, 0.15);
border-color: rgba(99, 179, 237, 0.5);
}
/* Folder history modal */
.folder-history-modal .modal-body {
padding: 0.75rem 1rem 1rem;
}
.folder-history-subtitle {
color: var(--text-muted);
font-size: 0.78rem;
font-family: 'SF Mono', Menlo, Consolas, monospace;
word-break: break-all;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.folder-history-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.folder-history-empty {
padding: 2rem 0.5rem;
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
}
.welcome-hint {
color: var(--text-muted);
font-size: 0.8rem;
@@ -8174,6 +8223,74 @@ kbd {
content: '⤢';
}
/* ── Code block one-click copy ──────────────────────────────────────────────
Every fenced code block is wrapped in .rv-code-wrap with an action toolbar
pinned to its top-right. Regular blocks get the relative positioning here;
ASCII diagrams already get it from .rv-diagram-wrap (don't clobber its
centering margins). */
.rv-text .rv-code-wrap:not(.rv-diagram-wrap),
.response-viewer-body .rv-code-wrap:not(.rv-diagram-wrap) {
position: relative;
margin: 1em 0;
}
.rv-text .rv-code-wrap:not(.rv-diagram-wrap) > pre,
.response-viewer-body .rv-code-wrap:not(.rv-diagram-wrap) > pre {
margin: 0;
padding-right: 44px; /* reserve room for the copy button */
}
/* Diagrams carry two buttons (copy + wrap toggle) — widen the reserve. */
.rv-text .rv-code-wrap.rv-diagram-wrap > pre.rv-diagram,
.response-viewer-body .rv-code-wrap.rv-diagram-wrap > pre.rv-diagram {
padding-right: 76px;
}
.rv-code-actions {
position: absolute;
top: 6px;
right: 6px;
display: inline-flex;
gap: 4px;
z-index: 2;
}
/* Inside the flex toolbar the wrap toggle flows normally — drop its own pin. */
.rv-code-actions .rv-wrap-toggle {
position: static;
top: auto;
right: auto;
}
.rv-copy-btn {
width: 28px;
height: 24px;
padding: 0;
border: 1px solid #2f2f45;
border-radius: 5px;
background: rgba(20, 20, 32, 0.92);
color: #8b8b97;
font-size: 13px;
line-height: 1;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: color 0.15s, border-color 0.15s;
}
.rv-copy-btn:hover,
.rv-copy-btn:active {
color: #e0e0ec;
border-color: #4a4a65;
}
.rv-copy-btn::before { content: '\2398'; } /* ⎘ — matches file-preview copy */
.rv-copy-btn.rv-copied { color: #9ece6a; border-color: #3a5a3a; }
.rv-copy-btn.rv-copied::before { content: '\2713'; } /* ✓ */
.rv-copy-btn.rv-copy-failed { color: #f7768e; border-color: #5a3a3a; }
.rv-copy-btn.rv-copy-failed::before { content: '\2715'; } /* ✕ */
.rv-text ul, .rv-text ol,
.response-viewer-body > ul, .response-viewer-body > ol {
margin: 0.6em 0;
+180 -34
View File
@@ -197,8 +197,9 @@ Object.assign(CodemanApp.prototype, {
const raw = localStorage.getItem('codeman-webgl-disabled');
if (!raw) return false;
const { at } = JSON.parse(raw);
// Auto-expire after 7 days so we retry (driver may have been fixed)
if (Date.now() - at > 7 * 24 * 60 * 60 * 1000) {
// Auto-expire after WEBGL_FALLBACK.STICKY_EXPIRY_MS so we retry
// (driver/Chrome may have been updated).
if (Date.now() - at > WEBGL_FALLBACK.STICKY_EXPIRY_MS) {
localStorage.removeItem('codeman-webgl-disabled');
return false;
}
@@ -443,7 +444,6 @@ Object.assign(CodemanApp.prototype, {
if (
activeResizeSession &&
activeResizeSession.mode !== 'shell' &&
!activeResizeSession._ended &&
this.terminal &&
this.isTerminalAtBottom()
) {
@@ -917,8 +917,15 @@ Object.assign(CodemanApp.prototype, {
.replace(/^\/Users\/[^/]+\//, '~/');
},
/** Build a single history item DOM element */
_buildHistoryItem(s, cases) {
/**
* Build a single history item DOM element.
* @param {object} s session record
* @param {Array} cases linked cases (for #caseName label)
* @param {object} [options]
* @param {boolean} [options.showViewAll=true] show "View all in folder" button in detail panel
*/
_buildHistoryItem(s, cases, options) {
const showViewAll = options?.showViewAll !== false;
const size =
s.sizeBytes < 1024
? `${s.sizeBytes}B`
@@ -1000,6 +1007,21 @@ Object.assign(CodemanApp.prototype, {
detail.append(promptRow, pathRow, metaRow);
if (showViewAll && s.projectKey) {
const actionRow = document.createElement('div');
actionRow.className = 'history-detail-row history-detail-actions';
const viewAllBtn = document.createElement('button');
viewAllBtn.type = 'button';
viewAllBtn.className = 'history-view-all-btn';
viewAllBtn.textContent = 'View all in this folder';
viewAllBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
this.openFolderHistoryModal(s.projectKey, s.workingDir, cases);
});
actionRow.appendChild(viewAllBtn);
detail.appendChild(actionRow);
}
expandBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
const expanded = item.classList.toggle('expanded');
@@ -1063,9 +1085,144 @@ Object.assign(CodemanApp.prototype, {
}
},
/** Page size for the folder history modal */
_FOLDER_HISTORY_PAGE_SIZE: 20,
/**
* Open a modal showing all history sessions in a single folder.
* Paginated by FOLDER_HISTORY_PAGE_SIZE; "Show more" loads next page.
*/
openFolderHistoryModal(projectKey, workingDir, cases) {
// Close any existing instance first
this._closeFolderHistoryModal();
const modal = document.createElement('div');
modal.className = 'modal active folder-history-modal';
modal.id = 'folderHistoryModal';
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
backdrop.addEventListener('click', () => this._closeFolderHistoryModal());
const content = document.createElement('div');
content.className = 'modal-content modal-lg';
const header = document.createElement('div');
header.className = 'modal-header';
const title = document.createElement('h3');
title.textContent = 'Folder History';
const subtitle = document.createElement('div');
subtitle.className = 'folder-history-subtitle';
subtitle.textContent = this._shortenHomePath(workingDir);
const closeBtn = document.createElement('button');
closeBtn.className = 'modal-close';
closeBtn.setAttribute('aria-label', 'Close');
closeBtn.innerHTML = '&times;';
closeBtn.addEventListener('click', () => this._closeFolderHistoryModal());
header.append(title, closeBtn);
const body = document.createElement('div');
body.className = 'modal-body';
const list = document.createElement('div');
list.className = 'folder-history-list';
list.setAttribute('data-loading', 'true');
list.textContent = 'Loading...';
body.append(subtitle, list);
content.append(header, body);
modal.append(backdrop, content);
document.body.appendChild(modal);
// Track state for pagination
this._folderHistoryState = {
projectKey,
workingDir,
cases: cases || [],
offset: 0,
total: null,
list,
};
// ESC to close
this._folderHistoryEscHandler = (ev) => {
if (ev.key === 'Escape') this._closeFolderHistoryModal();
};
document.addEventListener('keydown', this._folderHistoryEscHandler);
this._loadFolderHistoryPage();
},
async _loadFolderHistoryPage() {
const state = this._folderHistoryState;
if (!state) return;
const { projectKey, cases, list } = state;
const limit = this._FOLDER_HISTORY_PAGE_SIZE;
const offset = state.offset;
// Remove existing "Show more" button while loading
const existingMore = list.querySelector('.folder-history-more');
if (existingMore) existingMore.remove();
// First page: clear loading placeholder
if (offset === 0) {
list.replaceChildren();
list.removeAttribute('data-loading');
}
try {
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;
if (offset === 0 && sessions.length === 0) {
const empty = document.createElement('div');
empty.className = 'folder-history-empty';
empty.textContent = 'No conversations found in this folder.';
list.appendChild(empty);
return;
}
for (const s of sessions) {
list.appendChild(this._buildHistoryItem(s, cases, { showViewAll: false }));
}
state.offset = offset + sessions.length;
// Add "Show more" if there are more sessions
if (state.offset < state.total) {
const remaining = state.total - state.offset;
const moreBtn = document.createElement('button');
moreBtn.className = 'history-show-more folder-history-more';
moreBtn.textContent = `Show ${Math.min(limit, remaining)} more (${remaining} remaining)`;
moreBtn.addEventListener('click', () => this._loadFolderHistoryPage());
list.appendChild(moreBtn);
}
} catch (err) {
console.error('[loadFolderHistoryPage]', err);
const errorEl = document.createElement('div');
errorEl.className = 'folder-history-empty';
errorEl.textContent = 'Failed to load folder history.';
list.appendChild(errorEl);
}
},
_closeFolderHistoryModal() {
const modal = document.getElementById('folderHistoryModal');
if (modal) modal.remove();
if (this._folderHistoryEscHandler) {
document.removeEventListener('keydown', this._folderHistoryEscHandler);
this._folderHistoryEscHandler = null;
}
this._folderHistoryState = null;
},
async resumeHistorySession(sessionId, workingDir) {
// Close the run mode menu if open
document.getElementById('runModeMenu')?.classList.remove('active');
// Close folder history modal if open
this._closeFolderHistoryModal();
try {
this.terminal.clear();
this.terminal.writeln(`\x1b[1;32m Resuming conversation ${sessionId.slice(0, 8)}...\x1b[0m`);
@@ -1547,7 +1704,8 @@ Object.assign(CodemanApp.prototype, {
/**
* Restore terminal size to match web UI dimensions.
* Use this after mobile screen attachment has squeezed the terminal.
* Sends resize to PTY and Ctrl+L to trigger Claude to redraw.
* Sends only resize — SIGWINCH triggers Ink redraw on real dimension changes.
* Ctrl+L is NOT sent here (Claude Code 2.x treats it as "clear conversation").
*/
async restoreTerminalSize() {
if (!this.activeSessionId) {
@@ -1562,16 +1720,10 @@ Object.assign(CodemanApp.prototype, {
}
try {
// Send resize to restore proper dimensions (with minimum enforcement)
// Send resize to restore proper dimensions (with minimum enforcement).
// The PTY's SIGWINCH on real dim change is enough for Ink to redraw.
await this.sendResize(this.activeSessionId);
// Send Ctrl+L to trigger Claude to redraw at new size
await fetch(`/api/sessions/${this.activeSessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: '\x0c' }),
});
this.showToast(`Terminal restored to ${dims.cols}x${dims.rows}`, 'success');
} catch (err) {
console.error('Failed to restore terminal size:', err);
@@ -1579,26 +1731,20 @@ Object.assign(CodemanApp.prototype, {
}
},
// Send Ctrl+L to fix display for newly created sessions once Claude is running
sendPendingCtrlL(sessionId) {
if (!this.pendingCtrlL || !this.pendingCtrlL.has(sessionId)) {
return;
}
this.pendingCtrlL.delete(sessionId);
// Only send if this is the active session
if (sessionId !== this.activeSessionId) {
return;
}
// Send resize + Ctrl+L to fix the display (with minimum dimension enforcement)
this.sendResize(sessionId).then(() => {
fetch(`/api/sessions/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: '\x0c' }),
});
});
// Legacy hook for newly-created sessions; kept as a no-op so the SSE
// idle/working handlers can still call it without conditional guards.
//
// Originally this sent Ctrl+L (\x0c) when a flagged session first reached
// idle/working to scrub mux-init junk from the screen. Two problems:
// 1. `pendingCtrlL` was never actually populated anywhere (dead path).
// 2. Claude Code 2.x interprets Ctrl+L as a two-step "clear conversation"
// command — sending it from background flows risked nuking the user's
// conversation if it coincided with another Ctrl+L (e.g. from
// selectSession on page reload).
// If a per-session display-fix is ever needed again, do it via sendResize
// or an Ink-safe control sequence, NOT \x0c.
sendPendingCtrlL(_sessionId) {
// intentionally empty
},
async copyTerminal() {
+29
View File
@@ -0,0 +1,29 @@
/**
* @fileoverview Shared Fastify error handler for Codeman's HTTP routes.
*
* Route helpers (`findSessionOrFail`, `parseBody` in route-helpers.ts) throw
* structured errors carrying `{ statusCode, body }`. This handler renders them
* into the proper HTTP response. It is installed by BOTH the production server
* and the route test harness so test behavior matches production exactly —
* without it, thrown errors fall through to Fastify's default handler and the
* response body is `{statusCode,error,message}` instead of `{success:false,...}`.
*/
import type { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../types.js';
/**
* Install the global error handler that renders structured route errors.
* Errors thrown with a `statusCode`/`body` (see route-helpers.ts) are sent
* verbatim at that status; anything else becomes a 500 OPERATION_FAILED response.
*/
export function installRouteErrorHandler(app: FastifyInstance): void {
app.setErrorHandler((error, _req, reply) => {
const statusCode = (error as { statusCode?: number }).statusCode ?? 500;
const body = (error as { body?: unknown }).body;
if (body) {
reply.code(statusCode).send(body);
} else {
reply.code(statusCode).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(error)));
}
});
}
+329 -156
View File
@@ -10,6 +10,7 @@ import { homedir } from 'node:os';
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import {
ApiErrorCode,
createErrorResponse,
@@ -122,6 +123,76 @@ export function stripInkRedrawBloat(buffer: string): string {
return parts.join('');
}
/**
* Validate image bytes against a declared extension. Sniffs the first ~12 bytes
* for a known magic-number signature. Defends against polyglots (e.g. HTML or
* SVG disguised under a `Content-Type: image/png` header) and against simple
* extension-only spoofing — both the multipart filename and the Content-Type
* are attacker-controlled, the raw bytes are not.
*
* Signatures: https://en.wikipedia.org/wiki/List_of_file_signatures
*/
export function imageMagicMatchesExt(data: Buffer, ext: string): boolean {
if (data.length < 12) return false;
const u32be = (off: number): number => data.readUInt32BE(off);
switch (ext) {
case '.png':
return u32be(0) === 0x89504e47 && u32be(4) === 0x0d0a1a0a;
case '.jpg':
case '.jpeg':
return data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff;
case '.gif':
return (
data[0] === 0x47 &&
data[1] === 0x49 &&
data[2] === 0x46 &&
data[3] === 0x38 &&
(data[4] === 0x37 || data[4] === 0x39) &&
data[5] === 0x61
);
case '.webp':
// RIFF....WEBP
return u32be(0) === 0x52494646 && u32be(8) === 0x57454250;
case '.bmp':
return data[0] === 0x42 && data[1] === 0x4d;
default:
return false;
}
}
// Per-(IP, sessionId) token bucket for paste-image. 30 requests/minute.
// Bucket map entries are pruned when they drift > 1h stale to bound memory
// against a flood of unique IP keys.
const PASTE_RATE_TOKENS = 30;
const PASTE_RATE_REFILL_PER_MS = PASTE_RATE_TOKENS / 60_000;
const PASTE_BUCKET_TTL_MS = 60 * 60 * 1000;
const PASTE_BUCKET_GC_THRESHOLD = 1000;
const pasteRateBuckets = new Map<string, { tokens: number; lastRefill: number }>();
export function consumePasteToken(key: string, now: number = Date.now()): boolean {
if (pasteRateBuckets.size > PASTE_BUCKET_GC_THRESHOLD) {
for (const [k, b] of pasteRateBuckets) {
if (now - b.lastRefill > PASTE_BUCKET_TTL_MS) pasteRateBuckets.delete(k);
}
}
let b = pasteRateBuckets.get(key);
if (!b) {
b = { tokens: PASTE_RATE_TOKENS, lastRefill: now };
pasteRateBuckets.set(key, b);
}
const delta = (now - b.lastRefill) * PASTE_RATE_REFILL_PER_MS;
b.tokens = Math.min(PASTE_RATE_TOKENS, b.tokens + delta);
b.lastRefill = now;
if (b.tokens < 1) return false;
b.tokens -= 1;
return true;
}
// Test hook: reset between runs.
export function _resetPasteRateBuckets(): void {
pasteRateBuckets.clear();
}
export function registerSessionRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
@@ -581,11 +652,18 @@ export function registerSessionRoutes(
}
try {
// Route through the dedicated Codeman socket — bare `tmux` would target the
// user's default server and never find this session (same #80 regression class).
await new Promise<void>((resolve, reject) => {
execFile('tmux', ['send-keys', '-H', '-t', muxName, ...hex], { timeout: 5000 }, (err) => {
if (err) reject(err);
else resolve();
});
execFile(
'tmux',
['-L', ctx.mux.muxSocket, 'send-keys', '-H', '-t', muxName, ...hex],
{ timeout: 5000 },
(err) => {
if (err) reject(err);
else resolve();
}
);
});
} catch (err) {
console.error('[Server] send-key failed:', err);
@@ -1253,42 +1331,76 @@ export function registerSessionRoutes(
* Claude CLI encodes both '/' and '_' as '-', so each '-' in the key could be
* any of: '/' (path separator), '_' (underscore), or '-' (literal dash).
*
* Strategy: look-ahead matching. At each '-', try consuming multiple segments
* joined by '_' or '-' to find an existing child directory, then recurse.
* E.g. for segments [AI, project, Mirror] inside /Workspace:
* try /Workspace/AI (no) -> /Workspace/AI_project (yes!) -> continue with [Mirror]
* Strategy: recursive backtracking with longest-match-first preference.
* At each segment boundary, try joining as many segments as possible (with '_'
* or '-') into a single existing directory name. If a shorter match leads to a
* dead end, backtrack and try the next-shorter candidate.
*
* Why backtracking: when both `diary/` and `diary-app/` exist as siblings, the
* naive shortest-match would pick `diary` and then fail to find `app` inside,
* leaving the rest of the key unresolved. Longest-first picks `diary-app`.
*/
async function decodeProjectKey(projKey: string): Promise<string> {
const encoded = projKey.startsWith('-') ? projKey.slice(1) : projKey;
const segments = encoded.split('-');
const isDir = async (p: string): Promise<boolean> =>
fs
const isDirCache = new Map<string, boolean>();
const isDir = async (p: string): Promise<boolean> => {
const cached = isDirCache.get(p);
if (cached !== undefined) return cached;
const result = await fs
.stat(p)
.then((s) => s.isDirectory())
.catch(() => false);
isDirCache.set(p, result);
return result;
};
// Recursive backtracking: returns the deepest valid path that consumes all
// segments. Tries the longest segment-join first at each step so that
// dash-containing directory names win over shorter same-prefix siblings.
async function tryDecode(idx: number, current: string): Promise<string | null> {
if (idx >= segments.length) return current;
const maxLook = Math.min(idx + 4, segments.length);
// Longest first: end = maxLook-1 down to idx
for (let end = maxLook - 1; end >= idx; end--) {
const candidates: string[] = [];
if (end === idx) {
candidates.push(segments[idx]);
} else {
candidates.push(segments.slice(idx, end + 1).join('-'));
candidates.push(segments.slice(idx, end + 1).join('_'));
}
for (const child of candidates) {
const candidate = current + '/' + child;
if (await isDir(candidate)) {
const result = await tryDecode(end + 1, candidate);
if (result) return result;
}
}
}
return null;
}
const decoded = await tryDecode(0, '');
if (decoded) return decoded;
// Fallback: greedy shortest-match (original behavior) — best effort when
// no fully-valid path exists (e.g. directory was deleted after the
// conversation was recorded).
let current = '';
let i = 0;
while (i < segments.length) {
// Try progressively longer child names by joining segments with '_' or '-'
let matched = false;
// Limit look-ahead to avoid excessive fs checks (max 4 segments per component)
const maxLook = Math.min(i + 4, segments.length);
for (let end = i; end < maxLook; end++) {
// Build candidate child name from segments[i..end]
// Try all separator combinations: for 2+ segments, try '_' first then '-'
const candidates: string[] = [];
if (end === i) {
candidates.push(segments[i]);
} else {
// Build with underscores between joined segments
candidates.push(segments.slice(i, end + 1).join('_'));
// Build with dashes (literal)
candidates.push(segments.slice(i, end + 1).join('-'));
}
for (const child of candidates) {
const candidate = current + '/' + child;
if (await isDir(candidate)) {
@@ -1301,12 +1413,10 @@ export function registerSessionRoutes(
if (matched) break;
}
if (!matched) {
// No directory match found — append as-is and move on
current = current + '/' + segments[i];
i++;
}
}
const finalExists = await fs
.access(current)
.then(() => true)
@@ -1345,96 +1455,104 @@ export function registerSessionRoutes(
}
}
app.get('/api/history/sessions', async () => {
type HistorySession = {
sessionId: string;
workingDir: string;
projectKey: string;
sizeBytes: number;
lastModified: string;
firstPrompt?: string;
};
// Scan a single project directory and return all valid history sessions in it.
// Reused by both the global overview and the single-folder drill-down.
async function scanProjectDir(projPath: string, projDir: string, headBuf: Buffer): Promise<HistorySession[]> {
const out: HistorySession[] = [];
const stat = await fs.stat(projPath).catch(() => null);
if (!stat?.isDirectory()) return out;
const workingDir = await decodeProjectKey(projDir);
const entries = await fs.readdir(projPath).catch(() => [] as string[]);
for (const entry of entries) {
if (!entry.endsWith('.jsonl')) continue;
const sessionId = entry.replace('.jsonl', '');
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(sessionId)) continue;
const filePath = join(projPath, entry);
const fileStat = await fs.stat(filePath).catch(() => null);
if (!fileStat) continue;
if (fileStat.size < 4000) continue;
let firstPrompt: string | undefined;
const head = await readFileHead(filePath, headBuf);
const hasConversation = (text: string) =>
text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"');
let foundContent = head ? hasConversation(head) : false;
let tail: string | null = null;
if (!foundContent && fileStat.size > 16384) {
const tailBuf = Buffer.alloc(32768);
tail = await readFileTail(filePath, tailBuf, fileStat.size);
if (tail) foundContent = hasConversation(tail);
}
if (!foundContent) continue;
if (head) firstPrompt = extractFirstUserPrompt(head);
if (!firstPrompt && fileStat.size > 65536) {
if (!tail) {
const tailBuf = Buffer.alloc(32768);
tail = await readFileTail(filePath, tailBuf, fileStat.size);
}
if (tail) firstPrompt = extractFirstUserPrompt(tail);
}
out.push({
sessionId,
workingDir,
projectKey: projDir,
sizeBytes: fileStat.size,
lastModified: fileStat.mtime.toISOString(),
firstPrompt,
});
}
return out;
}
app.get('/api/history/sessions', async (req) => {
const query = req.query as { projectKey?: string; offset?: string; limit?: string };
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
const results: Array<{
sessionId: string;
workingDir: string;
projectKey: string;
sizeBytes: number;
lastModified: string;
firstPrompt?: string;
}> = [];
const headBuf = Buffer.alloc(16384);
// Single-folder drill-down: when projectKey is provided, scan only that
// directory, bypass the 50-cap, and honor offset/limit pagination.
if (query.projectKey) {
// Validate projectKey format to prevent path traversal
if (!/^[A-Za-z0-9_-]+$/.test(query.projectKey)) {
return { sessions: [], total: 0 };
}
const offset = Math.max(0, parseInt(query.offset || '0', 10) || 0);
const limit = Math.min(100, Math.max(1, parseInt(query.limit || '20', 10) || 20));
const projPath = join(projectsDir, query.projectKey);
const all = await scanProjectDir(projPath, query.projectKey, headBuf);
all.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
return { sessions: all.slice(offset, offset + limit), total: all.length };
}
// Global overview: scan all projects, return up to 50 most-recent sessions.
const results: HistorySession[] = [];
try {
const projectDirs = await fs.readdir(projectsDir);
for (const projDir of projectDirs) {
const projPath = join(projectsDir, projDir);
const stat = await fs.stat(projPath).catch(() => null);
if (!stat?.isDirectory()) continue;
// Decode project key to working dir. Claude CLI encodes '/' as '-',
// but path components may also contain '-' (e.g. "AI_project" vs "AI-project").
// Use recursive backtracking: try each '-' as either '/' or literal '-',
// verify which decoded path actually exists on disk.
const workingDir = await decodeProjectKey(projDir);
const entries = await fs.readdir(projPath);
for (const entry of entries) {
if (!entry.endsWith('.jsonl')) continue;
const sessionId = entry.replace('.jsonl', '');
// Only valid UUIDs
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(sessionId)) continue;
const filePath = join(projPath, entry);
const fileStat = await fs.stat(filePath).catch(() => null);
if (!fileStat) continue;
// Skip files too small to contain real conversation (metadata-only sessions
// like file-history-snapshot entries are typically < 4KB)
if (fileStat.size < 4000) continue;
// Quick content check: verify actual conversation data exists.
// Sessions with only file-history-snapshot or hook_progress entries have
// no "user"/"assistant" messages and will fail claude --resume.
// Read first 16KB to check content and extract first user prompt.
let firstPrompt: string | undefined;
const head = await readFileHead(filePath, headBuf);
const hasConversation = (text: string) =>
text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"');
let foundContent = head ? hasConversation(head) : false;
// For large files, head may not contain user messages (e.g. /init followed
// by large system entries). Check the tail as well.
let tail: string | null = null;
if (!foundContent && fileStat.size > 16384) {
const tailBuf = Buffer.alloc(32768);
tail = await readFileTail(filePath, tailBuf, fileStat.size);
if (tail) foundContent = hasConversation(tail);
}
if (!foundContent) continue; // No conversation content — skip
if (head) firstPrompt = extractFirstUserPrompt(head);
// If head scan found no usable prompt (e.g. session started with /init),
// try reading the tail for a recent user message.
if (!firstPrompt && fileStat.size > 65536) {
if (!tail) {
const tailBuf = Buffer.alloc(32768);
tail = await readFileTail(filePath, tailBuf, fileStat.size);
}
if (tail) firstPrompt = extractFirstUserPrompt(tail);
}
results.push({
sessionId,
workingDir,
projectKey: projDir,
sizeBytes: fileStat.size,
lastModified: fileStat.mtime.toISOString(),
firstPrompt,
});
}
const list = await scanProjectDir(projPath, projDir, headBuf);
results.push(...list);
}
} catch {
// Projects dir may not exist
}
// Sort by lastModified descending
results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
return { sessions: results.slice(0, 50) };
});
@@ -1442,76 +1560,98 @@ export function registerSessionRoutes(
// Paste Image (clipboard / drag-drop upload)
// ═══════════════════════════════════════════════════════════════
const MAX_PASTE_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB
const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']);
// The 10MB size cap is enforced by @fastify/multipart (registered in server.ts).
app.post('/api/sessions/:id/paste-image', async (req, reply) => {
// CSRF defense: state-changing routes must come from same origin.
// Cookies are SameSite=lax, multipart/form-data is a "simple" CORS request
// (no preflight), so a cross-origin <form enctype="multipart/form-data">
// submit attaches the session cookie unimpeded. Reject unless Origin/Referer
// matches req.host. Non-browser clients (no Origin AND no Referer) must
// supply X-Codeman-CSRF — a header browsers cannot add cross-origin without
// a preflight, which our CORS config does not allow from other origins.
const reqHost = req.headers.host;
const origin = req.headers.origin;
const referer = req.headers.referer;
let csrfOk = false;
if (origin) {
try {
csrfOk = new URL(origin).host === reqHost;
} catch {
/* invalid Origin → not ok */
}
} else if (referer) {
try {
csrfOk = new URL(referer).host === reqHost;
} catch {
/* invalid Referer → not ok */
}
} else {
csrfOk = !!req.headers['x-codeman-csrf'];
}
if (!csrfOk) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'CSRF check failed');
}
const { id } = req.params as { id: string };
// Rate limit per (IP, sessionId): 30/min. Defends against disk-fill DoS
// — even an authenticated attacker can otherwise loop 10MB POSTs.
if (!consumePasteToken(`${req.ip}:${id}`)) {
reply.code(429);
reply.header('Retry-After', '60');
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Rate limit exceeded (30 uploads/min per session)');
}
const session = findSessionOrFail(ctx, id);
const contentType = req.headers['content-type'] ?? '';
if (!contentType.includes('multipart/form-data')) {
if (!req.isMultipart()) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
}
// Parse multipart boundary
const boundaryMatch = contentType.match(/boundary=(.+?)(?:;|$)/);
if (!boundaryMatch) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing boundary');
// Read the single file part. @fastify/multipart enforces the 10MB size cap
// and the 1-file/4-field count limits (server.ts), replacing a hand-rolled
// boundary scanner with several bugs: literal boundary matches anywhere in
// body, LF-only clients silently corrupted the last byte (hard-coded \r\n
// offsets), no part-count cap.
let part: import('@fastify/multipart').MultipartFile | undefined;
try {
part = await req.file();
} catch (err: unknown) {
reply.code(413);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err) || 'Invalid multipart payload');
}
// Collect raw body with size limit
const chunks: Buffer[] = [];
let totalSize = 0;
for await (const chunk of req.raw) {
totalSize += chunk.length;
if (totalSize > MAX_PASTE_IMAGE_SIZE) {
reply.code(413);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'File too large (max 10MB)');
}
chunks.push(chunk as Buffer);
}
const body = Buffer.concat(chunks);
// Extract image from multipart body
const boundary = '--' + boundaryMatch[1];
const boundaryBuf = Buffer.from(boundary);
const parts: { headers: string; data: Buffer }[] = [];
let pos = 0;
while (pos < body.length) {
const start = body.indexOf(boundaryBuf, pos);
if (start === -1) break;
const afterBoundary = start + boundaryBuf.length;
if (body[afterBoundary] === 0x2d && body[afterBoundary + 1] === 0x2d) break;
const headerStart = afterBoundary + 2;
const headerEnd = body.indexOf(Buffer.from('\r\n\r\n'), headerStart);
if (headerEnd === -1) break;
const headers = body.subarray(headerStart, headerEnd).toString();
const dataStart = headerEnd + 4;
const nextBoundary = body.indexOf(boundaryBuf, dataStart);
const dataEnd = nextBoundary === -1 ? body.length : nextBoundary - 2;
parts.push({ headers, data: body.subarray(dataStart, dataEnd) });
pos = nextBoundary === -1 ? body.length : nextBoundary;
}
const imagePart = parts.find((p) => p.headers.includes('name="image"'));
if (!imagePart || imagePart.data.length === 0) {
if (!part) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No image uploaded');
}
if (part.fieldname !== 'image') {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unexpected field "${part.fieldname}", expected "image"`);
}
let imageBytes: Buffer;
try {
imageBytes = await part.toBuffer();
} catch (err: unknown) {
reply.code(413);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err) || 'File too large (max 10MB)');
}
if (imageBytes.length === 0) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Empty file');
}
// Determine extension from filename or Content-Type
// Determine extension from filename or Content-Type.
let ext = '.png';
const filenameMatch = imagePart.headers.match(/filename="(.+?)"/);
if (filenameMatch) {
const origExt = extname(filenameMatch[1]).toLowerCase();
if (part.filename) {
const origExt = extname(part.filename).toLowerCase();
if (ALLOWED_IMAGE_EXTS.has(origExt)) ext = origExt;
}
const ctMatch = imagePart.headers.match(/Content-Type:\s*image\/(png|jpeg|jpg|webp|gif|bmp)/i);
if (ctMatch) {
const mimeMatch = (part.mimetype || '').toLowerCase().match(/^image\/(png|jpeg|jpg|webp|gif|bmp)$/);
if (mimeMatch) {
const map: Record<string, string> = {
png: '.png',
jpeg: '.jpg',
@@ -1520,7 +1660,7 @@ export function registerSessionRoutes(
gif: '.gif',
bmp: '.bmp',
};
ext = map[ctMatch[1].toLowerCase()] ?? ext;
ext = map[mimeMatch[1]] ?? ext;
}
if (!ALLOWED_IMAGE_EXTS.has(ext)) {
@@ -1531,14 +1671,47 @@ export function registerSessionRoutes(
);
}
// Save to {workingDir}/.claude-images/
const imageDir = join(session.workingDir, '.claude-images');
if (!existsSync(imageDir)) {
mkdirSync(imageDir, { recursive: true });
// Sniff actual bytes — filename and Content-Type are both attacker-supplied.
// Polyglot HTML/PNG would otherwise pass and serve back with image/png MIME.
if (!imageMagicMatchesExt(imageBytes, ext)) {
reply.code(415);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
}
const filename = `paste-${Date.now()}${ext}`;
// Save to {workingDir}/.claude-images/
// Refuse symlinks at imageDir — an agent or postinstall script could plant
// `.claude-images -> ~/.ssh/` and redirect future writes outside workingDir.
// We lstat (not stat) so we see the symlink itself. Use mkdir without
// `recursive` so the leaf creation does not follow a symlink either, and
// O_EXCL|O_NOFOLLOW on the file open so the write itself is symlink-safe.
const imageDir = join(session.workingDir, '.claude-images');
try {
const dirStat = await fs.lstat(imageDir);
if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '.claude-images is not a regular directory');
}
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
// Non-recursive mkdir: errors on EEXIST and does not follow symlinks for
// the leaf. session.workingDir is guaranteed to exist (live session).
await fs.mkdir(imageDir);
}
// Date.now() collides on same-ms uploads from two tabs (last-write wins
// silently). Append 8 hex chars so concurrent pastes get distinct names.
const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
const filepath = join(imageDir, filename);
await fs.writeFile(filepath, imagePart.data);
// O_EXCL: refuse to overwrite (collision is impossible with random suffix,
// but defends against TOCTOU). O_NOFOLLOW: refuse if filepath is a symlink.
const fh = await fs.open(
filepath,
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW
);
try {
await fh.writeFile(imageBytes);
} finally {
await fh.close();
}
return { success: true, path: filepath, filename };
});
+34 -17
View File
@@ -32,6 +32,8 @@ import fastifyCompress from '@fastify/compress';
import fastifyCookie from '@fastify/cookie';
import fastifyStatic from '@fastify/static';
import fastifyWebsocket from '@fastify/websocket';
import fastifyMultipart from '@fastify/multipart';
import { startPasteImageGc } from './paste-image-gc.js';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync } from 'node:fs';
@@ -87,8 +89,6 @@ const require = createRequire(import.meta.url);
const { version: APP_VERSION } = require('../../package.json');
import {
getErrorMessage,
ApiErrorCode,
createErrorResponse,
type PersistedRespawnConfig,
type NiceConfig,
type ImageDetectedEvent,
@@ -99,6 +99,7 @@ import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.j
import { SseEvent } from './sse-events.js';
import type { ScheduledRun } from './ports/index.js';
import { registerAuthMiddleware, registerSecurityHeaders } from './middleware/auth.js';
import { installRouteErrorHandler } from './route-error-handler.js';
import {
registerPushRoutes,
registerTeamRoutes,
@@ -229,6 +230,7 @@ export class WebServer extends EventEmitter {
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
private teamWatcher: TeamWatcher = new TeamWatcher();
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
private _pasteImageGcStop: (() => void) | null = null;
private teamWatcherHandlers: {
teamCreated: (config: unknown) => void;
teamUpdated: (config: unknown) => void;
@@ -512,11 +514,10 @@ export class WebServer extends EventEmitter {
}
private async setupRoutes(): Promise<void> {
// Allow multipart/form-data for screenshot uploads — skip Fastify's body parser
// so the route handler can read the raw stream directly.
this.app.addContentTypeParser('multipart/form-data', (_req, _payload, done) => {
done(null);
});
// 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
// legacy routes that read the raw stream directly (e.g. /api/screenshots)
// continue to work alongside routes that use req.file() (e.g. paste-image).
// Enable gzip/brotli compression for all responses.
// Massive win: 793KB uncompressed → ~120KB compressed for static assets.
@@ -539,6 +540,18 @@ export class WebServer extends EventEmitter {
// WebSocket support (terminal I/O — low-latency bidirectional channel)
await this.app.register(fastifyWebsocket);
// Multipart parsing (used by paste-image). Replaces a hand-rolled
// boundary scanner that had several edge-case bugs: literal boundary
// anywhere in body was a match, LF-only clients silently corrupted the
// last byte (hard-coded \r\n offsets), and there was no part-count cap.
await this.app.register(fastifyMultipart, {
limits: {
fileSize: 10 * 1024 * 1024, // 10MB per file
files: 1, // paste-image only ever sends one file
fields: 4, // small headroom for accompanying form fields
},
});
// Security headers + CORS
registerSecurityHeaders(this.app, this.https);
this.app.get('/', async (_req, reply) => {
@@ -643,16 +656,9 @@ export class WebServer extends EventEmitter {
reply.code(updated ? 204 : 404).send();
});
// Global error handler for structured errors thrown by findSessionOrFail
this.app.setErrorHandler((error, _req, reply) => {
const statusCode = (error as { statusCode?: number }).statusCode ?? 500;
const body = (error as { body?: unknown }).body;
if (body) {
reply.code(statusCode).send(body);
} else {
reply.code(statusCode).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(error)));
}
});
// Global error handler for structured errors thrown by findSessionOrFail /
// 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
let _crashBreadcrumbs = '';
@@ -1541,6 +1547,12 @@ export class WebServer extends EventEmitter {
// Clean up stale sessions from state file that don't have active mux sessions
this.cleanupStaleSessions();
// Bound disk use under heavy paste-image traffic: delete `paste-*` files
// older than 7 days from each live session's .claude-images/ hourly.
if (!this.testMode) {
this._pasteImageGcStop = startPasteImageGc({ sessions: this.sessions });
}
await this.app.listen({ port: this.port, host: '0.0.0.0' });
const protocol = this.https ? 'https' : 'http';
console.log(`Codeman web interface running at ${protocol}://localhost:${this.port}`);
@@ -1894,6 +1906,11 @@ export class WebServer extends EventEmitter {
// Set stopping flag to prevent new timer creation during shutdown
this.sse.setStopping();
if (this._pasteImageGcStop) {
this._pasteImageGcStop();
this._pasteImageGcStop = null;
}
// Dispose all managed timers (intervals + resettable timeouts)
this.cleanup.dispose();
+1
View File
@@ -90,6 +90,7 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
// -- InfraPort --
mux: {
muxSocket: 'codeman',
createSession: vi.fn(),
killSession: vi.fn(),
listSessions: vi.fn(() => []),
+149 -141
View File
@@ -1,4 +1,18 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
// The respawn controller drives the AI idle checker, which spawns real `tmux`/`claude`
// via child_process. Neutralize those spawns here (mirrors ai-idle-checker.test.ts) so
// tests never launch real processes. Spread the real module to keep `exec` etc. intact —
// transitively-imported modules (e.g. tmux-manager) call `promisify(exec)` at load time.
vi.mock('node:child_process', async (orig) => {
const actual = await orig<typeof import('node:child_process')>();
return {
...actual,
execSync: vi.fn(),
spawn: vi.fn(() => ({ unref: vi.fn(), pid: 12345, on: vi.fn() })),
};
});
import { RespawnController, RespawnState, RespawnConfig } from '../src/respawn-controller.js';
import { Session } from '../src/session.js';
import { MockSession } from './mocks/index.js';
@@ -38,7 +52,9 @@ describe('RespawnController', () => {
it('should have default configuration', () => {
const config = controller.getConfig();
expect(config.enabled).toBe(true);
expect(config.updatePrompt).toBe('write a brief progress summary to CLAUDE.md noting what you accomplished, then continue working.');
expect(config.updatePrompt).toBe(
'write a brief progress summary to CLAUDE.md noting what you accomplished, then continue working.'
);
});
it('should allow custom configuration', () => {
@@ -95,9 +111,9 @@ describe('RespawnController', () => {
session.simulateCompletionMessage();
// Wait for log
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
const hasCompletionLog = logMessages.some(msg => msg.includes('Completion message detected'));
const hasCompletionLog = logMessages.some((msg) => msg.includes('Completion message detected'));
expect(hasCompletionLog).toBe(true);
});
@@ -139,7 +155,7 @@ describe('RespawnController', () => {
session.simulateCompletionMessage();
// Wait for completion confirmation (completionConfirmMs=50) + processing
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(cycleStarted).toBe(true);
expect(controller.currentCycle).toBe(1);
@@ -155,7 +171,7 @@ describe('RespawnController', () => {
session.simulateCompletionMessage();
// Wait for completion confirmation + step delay
await new Promise(resolve => setTimeout(resolve, 300));
await new Promise((resolve) => setTimeout(resolve, 300));
expect(stepSent).toBe('update');
expect(session.writeBuffer.length).toBeGreaterThan(0);
@@ -170,7 +186,7 @@ describe('RespawnController', () => {
session.simulateCompletionMessage();
// Wait for state transitions
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Should have transitioned through multiple states (watching -> confirming_idle -> sending_update)
expect(states).toContain('watching');
@@ -239,7 +255,7 @@ describe('RespawnController', () => {
controller.start();
// Wait a bit
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const status = controller.getStatus();
expect(status.timeSinceActivity).toBeGreaterThan(0);
@@ -299,7 +315,7 @@ describe('RespawnController', () => {
});
controller.start();
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(events.length).toBeGreaterThan(0);
expect(events[0].state).toBe('watching');
@@ -313,7 +329,7 @@ describe('RespawnController', () => {
controller.start();
expect(logs.length).toBeGreaterThan(0);
expect(logs.some(l => l.includes('Starting'))).toBe(true);
expect(logs.some((l) => l.includes('Starting'))).toBe(true);
});
});
});
@@ -346,13 +362,13 @@ describe('RespawnController Integration', () => {
// Alternate between working and idle
session.simulatePrompt();
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
session.simulateWorking();
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
session.simulatePrompt();
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
// Should handle transitions gracefully
expect(controller.isRunning).toBe(true);
@@ -467,7 +483,7 @@ describe('RespawnController Integration', () => {
controller.start();
session.simulatePrompt();
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(cycleStarted).toBe(false);
controller.stop();
@@ -560,7 +576,7 @@ describe('RespawnController Configuration', () => {
it('should clamp completionConfirmMs to noOutputTimeoutMs', () => {
const controller = new RespawnController(session as unknown as Session, {
completionConfirmMs: 60000, // Greater than noOutputTimeoutMs
completionConfirmMs: 60000, // Greater than noOutputTimeoutMs
noOutputTimeoutMs: 30000,
});
// completionConfirmMs should be clamped to noOutputTimeoutMs
@@ -646,7 +662,7 @@ describe('RespawnController State Transitions', () => {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(stateHistory).toContain('watching');
expect(stateHistory.length).toBeGreaterThan(1);
@@ -657,7 +673,7 @@ describe('RespawnController State Transitions', () => {
session.simulateCompletionMessage();
// Wait a bit then stop during potential transition
await new Promise(resolve => setTimeout(resolve, 30));
await new Promise((resolve) => setTimeout(resolve, 30));
controller.stop();
expect(controller.state).toBe('stopped');
@@ -672,7 +688,7 @@ describe('RespawnController State Transitions', () => {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 500));
await new Promise((resolve) => setTimeout(resolve, 500));
// May or may not complete depending on timing
expect(controller.isRunning).toBe(true);
@@ -692,7 +708,7 @@ describe('RespawnController State Transitions', () => {
session.simulateCompletionMessage();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Should detect completion messages
expect(completionCount).toBeGreaterThan(0);
@@ -708,10 +724,10 @@ describe('RespawnController State Transitions', () => {
session.simulateCompletionMessage();
// Before confirmation timer fires, start working
await new Promise(resolve => setTimeout(resolve, 20));
await new Promise((resolve) => setTimeout(resolve, 20));
session.simulateWorking();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Cycle should not have started due to working state canceling confirmation
expect(controller.getStatus().workingDetected).toBe(true);
@@ -820,7 +836,7 @@ describe('RespawnController Edge Cases', () => {
// Update config mid-run
controller.updateConfig({ updatePrompt: 'updated' });
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(controller.getConfig().updatePrompt).toBe('updated');
controller.stop();
@@ -840,7 +856,7 @@ describe('RespawnController Edge Cases', () => {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(controller.currentCycle).toBeGreaterThan(0);
controller.stop();
@@ -853,7 +869,7 @@ describe('RespawnController Edge Cases', () => {
controller.start();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const status = controller.getStatus();
// Allow for slight timing variance (timers may fire 1-2ms early)
@@ -868,7 +884,7 @@ describe('RespawnController Edge Cases', () => {
controller.start();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
session.simulateTerminalOutput('new data');
@@ -908,7 +924,7 @@ describe('RespawnController Edge Cases', () => {
session.simulateTerminalOutput('Would you like to proceed?\n❯ 1. Yes\n 2. No\n');
// Wait for autoAcceptDelayMs to expire
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptFired).toBe(true);
expect(session.writeBuffer).toContain('\r');
@@ -935,7 +951,7 @@ describe('RespawnController Edge Cases', () => {
session.simulateCompletionMessage();
// Wait for autoAcceptDelayMs
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptFired).toBe(false);
autoAcceptController.stop();
@@ -958,7 +974,7 @@ describe('RespawnController Edge Cases', () => {
autoAcceptController.start();
session.simulateTerminalOutput('Plan: Waiting for approval...');
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptFired).toBe(false);
autoAcceptController.stop();
@@ -981,7 +997,7 @@ describe('RespawnController Edge Cases', () => {
autoAcceptController.start();
// Don't simulate any output - just wait
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptFired).toBe(false);
autoAcceptController.stop();
@@ -1006,15 +1022,15 @@ describe('RespawnController Edge Cases', () => {
session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n');
// Wait 100ms (less than 150ms delay), then send more output
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
session.simulateTerminalOutput('More output');
// Wait another 100ms - total 200ms from start but only 100ms from last output
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
expect(autoAcceptFired).toBe(false);
// Wait the remaining time
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
expect(autoAcceptFired).toBe(true);
autoAcceptController.stop();
});
@@ -1038,16 +1054,16 @@ describe('RespawnController Edge Cases', () => {
session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n');
// Wait for first auto-accept
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptCount).toBe(1);
// Wait more - should NOT fire again (hasReceivedOutput is false)
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptCount).toBe(1);
// New output comes in (plan mode again), then silence again - should fire again
session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n');
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptCount).toBe(2);
autoAcceptController.stop();
@@ -1072,14 +1088,14 @@ describe('RespawnController Edge Cases', () => {
// Trigger a respawn cycle via completion message
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 150));
await new Promise((resolve) => setTimeout(resolve, 150));
// Now in sending_update or waiting_update state
expect(autoAcceptController.state).not.toBe('watching');
// Simulate output in the waiting state, then silence
session.simulateTerminalOutput('Processing update...');
await new Promise(resolve => setTimeout(resolve, 150));
await new Promise((resolve) => setTimeout(resolve, 150));
// Auto-accept should NOT fire because we're not in watching state
expect(autoAcceptFired).toBe(false);
@@ -1107,7 +1123,7 @@ describe('RespawnController Edge Cases', () => {
autoAcceptController.signalElicitation();
// Wait for autoAcceptDelayMs to expire
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Auto-accept should NOT fire because elicitation was signaled
expect(autoAcceptFired).toBe(false);
@@ -1141,7 +1157,7 @@ describe('RespawnController Edge Cases', () => {
// New silence after work - plan mode approval with plan mode UI
session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n');
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Auto-accept should fire now (elicitation cleared by working pattern)
expect(autoAcceptFired).toBe(true);
@@ -1210,7 +1226,7 @@ describe('RespawnController AI Idle Check', () => {
session.simulateCompletionMessage();
// Wait for completion confirm timer to fire and AI check to start
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Should have transitioned to ai_checking
expect(states).toContain('ai_checking');
@@ -1230,13 +1246,13 @@ describe('RespawnController AI Idle Check', () => {
session.simulateCompletionMessage();
// Wait for AI check to start
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Simulate working patterns during AI check
session.simulateWorking();
// Should be back to watching
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(controller.state).toBe('watching');
controller.stop();
@@ -1254,13 +1270,13 @@ describe('RespawnController AI Idle Check', () => {
session.simulateCompletionMessage();
// Wait for AI check to start
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Simulate substantial output during AI check
session.simulateTerminalOutput('Some meaningful output that is more than 2 chars');
// Should be back to watching
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(controller.state).toBe('watching');
controller.stop();
@@ -1282,7 +1298,7 @@ describe('RespawnController AI Idle Check', () => {
session.simulateCompletionMessage();
// Wait for completion confirm and direct idle
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(cycleStarted).toBe(true);
controller.stop();
@@ -1304,7 +1320,7 @@ describe('RespawnController AI Idle Check', () => {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 150));
await new Promise((resolve) => setTimeout(resolve, 150));
expect(aiCheckStarted).toBe(true);
controller.stop();
@@ -1347,7 +1363,7 @@ describe('RespawnController AI Idle Check', () => {
session.simulateCompletionMessage();
// Wait for completion confirm timer + AI check start
await new Promise(resolve => setTimeout(resolve, 150));
await new Promise((resolve) => setTimeout(resolve, 150));
// Should have triggered ai_checking via completion path
expect(states).toContain('ai_checking');
@@ -1370,7 +1386,7 @@ describe('RespawnController AI Idle Check', () => {
session.simulateCompletionMessage();
// Wait for AI check to start and timeout
await new Promise(resolve => setTimeout(resolve, 300));
await new Promise((resolve) => setTimeout(resolve, 300));
// Should return to watching after timeout (with cooldown)
expect(controller.state).toBe('watching');
@@ -1416,7 +1432,7 @@ describe('RespawnController AI Plan Mode Check', () => {
// Output without plan mode patterns (no numbered list, no selector)
session.simulateTerminalOutput('Claude is just thinking about something...\nSome regular output here.');
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Pre-filter should block - no plan mode patterns found
expect(autoAcceptFired).toBe(false);
@@ -1442,13 +1458,10 @@ describe('RespawnController AI Plan Mode Check', () => {
// Output WITH plan mode patterns
session.simulateTerminalOutput(
'Would you like to proceed with this plan?\n' +
'❯ 1. Yes\n' +
' 2. No\n' +
' 3. Type your own\n'
'Would you like to proceed with this plan?\n' + '❯ 1. Yes\n' + ' 2. No\n' + ' 3. Type your own\n'
);
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Pre-filter should pass and send Enter (AI disabled)
expect(autoAcceptFired).toBe(true);
@@ -1473,15 +1486,11 @@ describe('RespawnController AI Plan Mode Check', () => {
controller.start();
// Plan mode patterns BUT also has working patterns (spinner) in the tail
session.simulateTerminalOutput(
'❯ 1. Yes\n' +
' 2. No\n' +
'Thinking ⠋\n'
);
session.simulateTerminalOutput('❯ 1. Yes\n' + ' 2. No\n' + 'Thinking ⠋\n');
// Wait for autoAcceptDelay - but working pattern resets the timer
// so we need to wait longer and check after working pattern was consumed
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Should NOT fire because working patterns detected resets timer
// (the working pattern in handleTerminalData clears timers)
@@ -1508,13 +1517,9 @@ describe('RespawnController AI Plan Mode Check', () => {
controller.start();
// Output with plan mode patterns to pass pre-filter
session.simulateTerminalOutput(
'Would you like to proceed?\n' +
'❯ 1. Yes\n' +
' 2. No\n'
);
session.simulateTerminalOutput('Would you like to proceed?\n' + '❯ 1. Yes\n' + ' 2. No\n');
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Plan check should have been started (pre-filter passed, AI enabled)
expect(planCheckStarted).toBe(true);
@@ -1544,17 +1549,14 @@ describe('RespawnController AI Plan Mode Check', () => {
controller.start();
// Trigger plan check
session.simulateTerminalOutput(
'❯ 1. Yes\n' +
' 2. No\n'
);
await new Promise(resolve => setTimeout(resolve, 150));
session.simulateTerminalOutput('❯ 1. Yes\n' + ' 2. No\n');
await new Promise((resolve) => setTimeout(resolve, 150));
expect(planCheckStarted).toBe(true);
// New output arrives - should cancel plan check (stale)
session.simulateTerminalOutput('New output from Claude...');
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Auto-accept should NOT have fired (check was cancelled)
expect(autoAcceptFired).toBe(false);
@@ -1580,16 +1582,14 @@ describe('RespawnController AI Plan Mode Check', () => {
controller.start();
// Plan mode patterns to trigger check
session.simulateTerminalOutput(
'❯ 1. Yes\n 2. No\n'
);
await new Promise(resolve => setTimeout(resolve, 150));
session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n');
await new Promise((resolve) => setTimeout(resolve, 150));
// Output arrives during check - result should be discarded
session.simulateTerminalOutput('Claude started working again');
// Wait for any pending check to complete
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptFired).toBe(false);
controller.stop();
@@ -1617,11 +1617,9 @@ describe('RespawnController AI Plan Mode Check', () => {
controller.start();
// Plan mode patterns
session.simulateTerminalOutput(
'❯ 1. Yes\n 2. No\n'
);
session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n');
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Should send Enter directly (no AI check)
expect(planCheckStarted).toBe(false);
@@ -1663,7 +1661,7 @@ describe('RespawnController AI Plan Mode Check', () => {
controller.start();
// Don't send any output - hasReceivedOutput should guard
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptFired).toBe(false);
controller.stop();
@@ -1861,7 +1859,7 @@ describe('RespawnController Resume Behavior', () => {
session.simulateCompletionMessage();
// Wait for completion confirm timer to fire
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Should start cycle based on completion message alone
expect(cycleStarted).toBe(true);
@@ -1886,7 +1884,7 @@ describe('RespawnController Resume Behavior', () => {
session.simulateTerminalOutput('Some text output');
// Wait for noOutput fallback
await new Promise(resolve => setTimeout(resolve, 300));
await new Promise((resolve) => setTimeout(resolve, 300));
// Should eventually trigger via fallback
expect(cycleStarted).toBe(true);
@@ -1915,7 +1913,7 @@ describe('RespawnController Resume Behavior', () => {
// Verify cycle can still start after resume
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(states).toContain('confirming_idle');
controller.stop();
@@ -1933,7 +1931,7 @@ describe('RespawnController Resume Behavior', () => {
session.simulateCompletionMessage();
// Wait for cycle to start
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Pause during cycle
controller.pause();
@@ -1975,7 +1973,7 @@ describe('RespawnController Step Confirmation', () => {
session.simulateCompletionMessage();
// Wait for full cycle
await new Promise(resolve => setTimeout(resolve, 300));
await new Promise((resolve) => setTimeout(resolve, 300));
// Should have gone through: watching -> confirming_idle -> sending_update -> waiting_update -> watching
expect(states).toContain('watching');
@@ -1996,12 +1994,12 @@ describe('RespawnController Step Confirmation', () => {
session.simulateCompletionMessage();
// Wait for update to be sent
await new Promise(resolve => setTimeout(resolve, 150));
await new Promise((resolve) => setTimeout(resolve, 150));
// Simulate continuous output while waiting for update completion
for (let i = 0; i < 5; i++) {
session.simulateTerminalOutput(`Processing step ${i}...`);
await new Promise(resolve => setTimeout(resolve, 20));
await new Promise((resolve) => setTimeout(resolve, 20));
}
// Controller should still be functional
@@ -2021,7 +2019,7 @@ describe('RespawnController Step Confirmation', () => {
session.simulateCompletionMessage();
// Wait for cycle to start
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Continuously emit output to prevent completion detection
const outputInterval = setInterval(() => {
@@ -2029,7 +2027,7 @@ describe('RespawnController Step Confirmation', () => {
}, 50);
// Wait a reasonable time
await new Promise(resolve => setTimeout(resolve, 500));
await new Promise((resolve) => setTimeout(resolve, 500));
clearInterval(outputInterval);
@@ -2057,7 +2055,7 @@ describe('RespawnController Step Confirmation', () => {
session.simulateCompletionMessage();
// Wait for step to complete
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// After update step completes (via timeout), should emit stepCompleted
// Note: with sendClear/sendInit false, cycle completes after update
@@ -2086,7 +2084,7 @@ describe('RespawnController AI Check Cooldown Behavior', () => {
session.simulateCompletionMessage();
// Wait for AI check to start
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const detection = controller.getDetectionStatus();
// AI check should have started or be in progress
@@ -2130,7 +2128,7 @@ describe('RespawnController AI Check Cooldown Behavior', () => {
session.simulateCompletionMessage();
// Wait for AI check to timeout
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Should have gone through ai_checking and back
expect(states).toContain('ai_checking');
@@ -2151,18 +2149,18 @@ describe('RespawnController AI Check Cooldown Behavior', () => {
// First cycle - should start AI check
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const detection1 = controller.getDetectionStatus();
// AI check was attempted
// Reset by simulating working
session.simulateWorking();
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
// Second cycle - might be on cooldown
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Controller should still be functional
expect(controller.isRunning).toBe(true);
@@ -2240,7 +2238,18 @@ describe('RespawnController Working Pattern Detection', () => {
controller.start();
// All spinner characters should indicate working
const spinnerChars = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f'];
const spinnerChars = [
'\u280b',
'\u2819',
'\u2839',
'\u2838',
'\u283c',
'\u2834',
'\u2826',
'\u2827',
'\u2807',
'\u280f',
];
for (const char of spinnerChars) {
session.simulateTerminalOutput(char);
}
@@ -2266,7 +2275,7 @@ describe('RespawnController Working Pattern Detection', () => {
// Then completion
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
// Working should be cleared after a bit of silence
status = controller.getStatus();
@@ -2301,7 +2310,7 @@ describe('RespawnController Cycle Count Tracking', () => {
expect(controller.currentCycle).toBe(0);
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 150));
await new Promise((resolve) => setTimeout(resolve, 150));
expect(controller.currentCycle).toBeGreaterThan(0);
controller.stop();
@@ -2321,7 +2330,7 @@ describe('RespawnController Cycle Count Tracking', () => {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 150));
await new Promise((resolve) => setTimeout(resolve, 150));
expect(cycleNumber).toBe(1);
controller.stop();
@@ -2425,13 +2434,13 @@ describe('RespawnController Timer Cleanup', () => {
session.simulateCompletionMessage();
// Start a timer-based operation
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
// Stop should clean up all timers
controller.stop();
// Wait to ensure no timer fires after stop
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(controller.state).toBe('stopped');
expect(controller.isRunning).toBe(false);
@@ -2448,13 +2457,13 @@ describe('RespawnController Timer Cleanup', () => {
session.simulateCompletionMessage();
// Wait for confirming_idle
await new Promise(resolve => setTimeout(resolve, 30));
await new Promise((resolve) => setTimeout(resolve, 30));
// Interrupt with working pattern
session.simulateWorking();
// Should cancel completion confirm timer and return to watching
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(controller.state).toBe('watching');
controller.stop();
@@ -2469,14 +2478,13 @@ describe('RespawnController Timer Cleanup', () => {
for (let i = 0; i < 10; i++) {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 10));
await new Promise((resolve) => setTimeout(resolve, 10));
controller.stop();
}
// Should end in stopped state without errors
expect(controller.state).toBe('stopped');
});
});
// ========== Hook-Based Detection Tests (Phase 1) ==========
@@ -2542,7 +2550,7 @@ describe('RespawnController Hook-Based Idle Detection', () => {
testController.signalStopHook();
// Wait for hook confirmation timer (3s default)
await new Promise(resolve => setTimeout(resolve, 3100));
await new Promise((resolve) => setTimeout(resolve, 3100));
expect(cycleStarted).toHaveBeenCalled();
testController.stop();
@@ -2562,7 +2570,7 @@ describe('RespawnController Hook-Based Idle Detection', () => {
testController.signalIdlePrompt();
// idle_prompt skips confirmation and goes directly to idle
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
expect(cycleStarted).toHaveBeenCalled();
testController.stop();
@@ -2582,11 +2590,11 @@ describe('RespawnController Hook-Based Idle Detection', () => {
testController.signalStopHook();
// Simulate working patterns IMMEDIATELY after hook (before confirmation)
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
session.simulateWorking();
// Wait longer than hook confirmation delay (3s)
await new Promise(resolve => setTimeout(resolve, 3500));
await new Promise((resolve) => setTimeout(resolve, 3500));
// Cycle should NOT have started because working was detected
expect(cycleStarted).not.toHaveBeenCalled();
@@ -2608,7 +2616,7 @@ describe('RespawnController Hook-Based Idle Detection', () => {
testController.start();
testController.signalIdlePrompt(); // Start a cycle
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Now in sending_update state - Stop hook should be ignored
testController.signalStopHook();
@@ -2658,7 +2666,7 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
session.simulateCompletionMessage();
// Wait for completion detection to trigger timer
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Should have started at least one timer (completion-confirm or no-output-fallback)
expect(timerEvents.length).toBeGreaterThan(0);
@@ -2690,9 +2698,9 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
// Send output to trigger no-output timer reset
session.simulateTerminalOutput('some output');
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const noOutputTimer = timerEvents.find(e => e.name === 'no-output-fallback');
const noOutputTimer = timerEvents.find((e) => e.name === 'no-output-fallback');
if (noOutputTimer) {
expect(noOutputTimer.durationMs).toBe(noOutputTimeoutMs);
}
@@ -2716,7 +2724,7 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
session.simulateCompletionMessage();
// Wait for completion confirm timer to fire (50ms + processing)
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// At least one timer should have completed (completion-confirm)
expect(completedTimers.length).toBeGreaterThan(0);
@@ -2741,11 +2749,11 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
session.simulateCompletionMessage();
// Wait for timer to start
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Simulate working to cancel the completion confirm timer
session.simulateWorking();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Working patterns should have cancelled at least one timer
const hasCancel = cancelledTimers.length > 0;
@@ -2770,13 +2778,13 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
// Trigger Stop hook to start hook-confirm timer
controller.signalStopHook();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Then working patterns should cancel it with a reason
session.simulateWorking();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const hookCancel = cancelledTimers.find(e => e.name === 'hook-confirm');
const hookCancel = cancelledTimers.find((e) => e.name === 'hook-confirm');
if (hookCancel) {
expect(hookCancel.reason).toBeTruthy();
}
@@ -2797,7 +2805,7 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
session.simulateCompletionMessage();
// Wait for timers to start
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Should have active timers
const timersBefore = controller.getActiveTimers();
@@ -2822,7 +2830,7 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
session.simulateCompletionMessage();
// Wait for timers to start
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
controller.stop();
@@ -2835,7 +2843,7 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
});
// Wait longer than any timer duration
await new Promise(resolve => setTimeout(resolve, 400));
await new Promise((resolve) => setTimeout(resolve, 400));
expect(timerFiredAfterStop).toBe(false);
});
@@ -2852,11 +2860,11 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
// First cycle: start, trigger completion-related timers, stop
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Should have completion-confirm timer active
const firstCycleTimers = controller.getActiveTimers();
const hasCompletionConfirm = firstCycleTimers.some(t => t.name === 'completion-confirm');
const hasCompletionConfirm = firstCycleTimers.some((t) => t.name === 'completion-confirm');
expect(hasCompletionConfirm).toBe(true);
controller.stop();
@@ -2866,14 +2874,14 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
controller.start();
expect(controller.state).toBe('watching');
const restartTimers = controller.getActiveTimers();
const staleCompletionConfirm = restartTimers.some(t => t.name === 'completion-confirm');
const staleCompletionConfirm = restartTimers.some((t) => t.name === 'completion-confirm');
expect(staleCompletionConfirm).toBe(false);
// Can still trigger new timers
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const newTimers = controller.getActiveTimers();
const hasNewCompletionConfirm = newTimers.some(t => t.name === 'completion-confirm');
const hasNewCompletionConfirm = newTimers.some((t) => t.name === 'completion-confirm');
expect(hasNewCompletionConfirm).toBe(true);
controller.stop();
@@ -2895,13 +2903,13 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
for (let i = 0; i < 5; i++) {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 20));
await new Promise((resolve) => setTimeout(resolve, 20));
controller.stop();
}
// Wait to check no stale timers fire
const countBefore = completedTimers.length;
await new Promise(resolve => setTimeout(resolve, 300));
await new Promise((resolve) => setTimeout(resolve, 300));
const countAfter = completedTimers.length;
// No new timer completions should happen after final stop
@@ -2920,7 +2928,7 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const activeTimers = controller.getActiveTimers();
expect(activeTimers.length).toBeGreaterThan(0);
@@ -2946,7 +2954,7 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const detectionStatus = controller.getDetectionStatus();
expect(Array.isArray(detectionStatus.activeTimers)).toBe(true);
@@ -2966,14 +2974,14 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
session.simulateCompletionMessage();
// Wait for completion confirm timer to be set up
await new Promise(resolve => setTimeout(resolve, 20));
await new Promise((resolve) => setTimeout(resolve, 20));
const timersBefore = controller.getActiveTimers();
const hasCompletionConfirm = timersBefore.some(t => t.name === 'completion-confirm');
const hasCompletionConfirm = timersBefore.some((t) => t.name === 'completion-confirm');
// Wait for the timer to fire
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
const timersAfter = controller.getActiveTimers();
const stillHasCompletionConfirm = timersAfter.some(t => t.name === 'completion-confirm');
const stillHasCompletionConfirm = timersAfter.some((t) => t.name === 'completion-confirm');
// If we caught the timer before it fired, it should be gone now
if (hasCompletionConfirm) {
@@ -2992,7 +3000,7 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Should have timers
const timersBefore = controller.getActiveTimers();
@@ -3000,11 +3008,11 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
// Cancel via working
session.simulateWorking();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// completion-confirm should be removed
const timersAfter = controller.getActiveTimers();
const hasCompletionConfirm = timersAfter.some(t => t.name === 'completion-confirm');
const hasCompletionConfirm = timersAfter.some((t) => t.name === 'completion-confirm');
expect(hasCompletionConfirm).toBe(false);
controller.stop();
@@ -3019,10 +3027,10 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
controller.start();
controller.signalStopHook();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const activeTimers = controller.getActiveTimers();
const hookTimer = activeTimers.find(t => t.name === 'hook-confirm');
const hookTimer = activeTimers.find((t) => t.name === 'hook-confirm');
expect(hookTimer).toBeDefined();
if (hookTimer) {
expect(hookTimer.totalMs).toBeGreaterThan(0);
+5 -1
View File
@@ -7,6 +7,7 @@
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';
export interface RouteTestHarness {
app: FastifyInstance;
@@ -24,7 +25,7 @@ export interface RouteTestHarness {
export async function createRouteTestHarness(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
registerFn: (app: FastifyInstance, ctx: any) => void,
ctxOptions?: { sessionId?: string },
ctxOptions?: { sessionId?: string }
): Promise<RouteTestHarness> {
const app = Fastify({ logger: false });
@@ -34,6 +35,9 @@ export async function createRouteTestHarness(
const ctx = createMockRouteContext(ctxOptions);
registerFn(app, ctx);
// Mirror production: structured errors thrown by route helpers (findSessionOrFail,
// parseBody) are rendered to {success:false} bodies at the right status.
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
+11 -12
View File
@@ -104,9 +104,7 @@ describe('case-routes', () => {
});
it('includes hasClaudeMd flag', async () => {
mockedReaddir.mockResolvedValue([
{ name: 'case-with-md', isDirectory: () => true },
] as never);
mockedReaddir.mockResolvedValue([{ name: 'case-with-md', isDirectory: () => true }] as never);
mockedExistsSync.mockReturnValue(true);
const res = await harness.app.inject({
@@ -120,9 +118,7 @@ describe('case-routes', () => {
it('includes linked cases from linked-cases.json', async () => {
// CASES_DIR readdir returns one case
mockedReaddir.mockResolvedValue([
{ name: 'regular-case', isDirectory: () => true },
] as never);
mockedReaddir.mockResolvedValue([{ name: 'regular-case', isDirectory: () => true }] as never);
// linked-cases.json is read second (after CASES_DIR readdir)
let readCallCount = 0;
mockedReadFile.mockImplementation(async () => {
@@ -158,7 +154,7 @@ describe('case-routes', () => {
url: '/api/cases',
payload: { name: 'invalid case name!!' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -169,7 +165,7 @@ describe('case-routes', () => {
url: '/api/cases',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -180,7 +176,7 @@ describe('case-routes', () => {
url: '/api/cases',
payload: { name: '../etc' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -230,7 +226,7 @@ describe('case-routes', () => {
url: '/api/cases/link',
payload: { name: 'bad name!' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -241,7 +237,7 @@ describe('case-routes', () => {
url: '/api/cases/link',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -294,7 +290,10 @@ describe('case-routes', () => {
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.case.name).toBe('linked-project');
expect(harness.ctx.broadcast).toHaveBeenCalledWith('case:linked', expect.objectContaining({ name: 'linked-project' }));
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'case:linked',
expect.objectContaining({ name: 'linked-project' })
);
});
});
+2 -1
View File
@@ -312,7 +312,8 @@ describe('file-routes', () => {
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=../../etc/shadow`,
});
expect(res.statusCode).toBe(400);
// Path traversal returns 404 ("File not found") to avoid revealing the target exists.
expect(res.statusCode).toBe(404);
});
it('rejects overly large raw files', async () => {
+6 -9
View File
@@ -38,7 +38,7 @@ describe('hook-event-routes', () => {
expect(body.success).toBe(true);
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'hook:stop',
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
expect.objectContaining({ sessionId: harness.ctx._sessionId })
);
});
@@ -55,7 +55,7 @@ describe('hook-event-routes', () => {
expect(res.statusCode).toBe(200);
expect(harness.ctx.sendPushNotifications).toHaveBeenCalledWith(
'hook:idle_prompt',
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
expect.objectContaining({ sessionId: harness.ctx._sessionId })
);
});
@@ -85,7 +85,7 @@ describe('hook-event-routes', () => {
data: null,
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -98,7 +98,7 @@ describe('hook-event-routes', () => {
event: 'stop',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -180,10 +180,7 @@ describe('hook-event-routes', () => {
},
});
expect(res.statusCode).toBe(200);
expect(mockTracker.recordHookEvent).toHaveBeenCalledWith(
'stop',
expect.any(Object),
);
expect(mockTracker.recordHookEvent).toHaveBeenCalledWith('stop', expect.any(Object));
});
it('starts transcript watcher when transcript_path is provided', async () => {
@@ -199,7 +196,7 @@ describe('hook-event-routes', () => {
expect(res.statusCode).toBe(200);
expect(harness.ctx.startTranscriptWatcher).toHaveBeenCalledWith(
harness.ctx._sessionId,
'/home/user/.claude/transcript.jsonl',
'/home/user/.claude/transcript.jsonl'
);
});
+3 -3
View File
@@ -103,7 +103,7 @@ describe('orchestrator-routes', () => {
url: '/api/orchestrator/start',
payload: { goal: '' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -114,7 +114,7 @@ describe('orchestrator-routes', () => {
url: '/api/orchestrator/start',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -228,7 +228,7 @@ describe('orchestrator-routes', () => {
url: '/api/orchestrator/reject',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+8 -10
View File
@@ -80,7 +80,7 @@ describe('plan-routes', () => {
url: '/api/cancel-plan-generation',
payload: { orchestratorId: 12345 }, // should be string
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -129,7 +129,7 @@ describe('plan-routes', () => {
expect(body.data.status).toBe('completed');
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'session:planTaskUpdate',
expect.objectContaining({ sessionId: harness.ctx._sessionId, taskId: 'task-1' }),
expect.objectContaining({ sessionId: harness.ctx._sessionId, taskId: 'task-1' })
);
});
@@ -159,7 +159,7 @@ describe('plan-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/plan/task/task-1`,
payload: { status: 'invalid_status' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -208,7 +208,7 @@ describe('plan-routes', () => {
expect(body.data.completedCount).toBe(5);
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'session:planCheckpoint',
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
expect.objectContaining({ sessionId: harness.ctx._sessionId })
);
});
});
@@ -279,9 +279,7 @@ describe('plan-routes', () => {
});
it('rolls back to a previous version', async () => {
const mockPlan = [
{ id: 'task-1', content: 'Step 1', status: 'pending' },
];
const mockPlan = [{ id: 'task-1', content: 'Step 1', status: 'pending' }];
harness.ctx._session.ralphTracker = {
rollbackToVersion: vi.fn(() => ({ success: true, plan: mockPlan })),
} as never;
@@ -296,7 +294,7 @@ describe('plan-routes', () => {
expect(body.data).toHaveLength(1);
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'session:planRollback',
expect.objectContaining({ sessionId: harness.ctx._sessionId, version: 1 }),
expect.objectContaining({ sessionId: harness.ctx._sessionId, version: 1 })
);
});
@@ -358,7 +356,7 @@ describe('plan-routes', () => {
expect(body.data.content).toBe('New task');
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'session:planTaskAdded',
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
expect.objectContaining({ sessionId: harness.ctx._sessionId })
);
});
@@ -372,7 +370,7 @@ describe('plan-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/plan/task`,
payload: { priority: 'P1' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+5 -5
View File
@@ -65,7 +65,7 @@ describe('push-routes', () => {
expect.objectContaining({
endpoint: 'https://push.example.com/send/abc123',
keys: { p256dh: 'test-p256dh-key', auth: 'test-auth-key' },
}),
})
);
});
@@ -87,7 +87,7 @@ describe('push-routes', () => {
expect.objectContaining({
userAgent: 'TestBrowser/1.0',
pushPreferences: { 'session:idle': true, 'session:error': false },
}),
})
);
});
@@ -99,7 +99,7 @@ describe('push-routes', () => {
keys: { p256dh: 'test-p256dh', auth: 'test-auth' },
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -112,7 +112,7 @@ describe('push-routes', () => {
endpoint: 'https://push.example.com/send/abc123',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -160,7 +160,7 @@ describe('push-routes', () => {
url: '/api/push/subscribe/sub-123',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+35 -21
View File
@@ -66,7 +66,9 @@ describe('ralph-routes', () => {
});
it('enables ralph tracker', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
const res = await harness.app.inject({
method: 'POST',
@@ -79,7 +81,9 @@ describe('ralph-routes', () => {
});
it('disables ralph tracker', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
const res = await harness.app.inject({
method: 'POST',
@@ -99,7 +103,7 @@ describe('ralph-routes', () => {
});
expect((harness.ctx.mux as Record<string, unknown>).updateRalphEnabled).toHaveBeenCalledWith(
harness.ctx._sessionId,
true,
true
);
});
@@ -116,7 +120,9 @@ describe('ralph-routes', () => {
});
it('handles reset option', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
await harness.app.inject({
method: 'POST',
@@ -127,7 +133,9 @@ describe('ralph-routes', () => {
});
it('configures completion phrase and max iterations', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
await harness.app.inject({
method: 'POST',
@@ -138,7 +146,9 @@ describe('ralph-routes', () => {
});
it('sets max iterations independently', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
await harness.app.inject({
method: 'POST',
@@ -154,7 +164,7 @@ describe('ralph-routes', () => {
url: '/api/sessions/nonexistent/ralph-config',
payload: { enabled: true },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -178,13 +188,15 @@ describe('ralph-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`,
payload: { enabled: 'not-boolean' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('handles disableAutoEnable flag', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
await harness.app.inject({
method: 'POST',
@@ -208,7 +220,9 @@ describe('ralph-routes', () => {
describe('POST /api/sessions/:id/ralph-circuit-breaker/reset', () => {
it('resets circuit breaker for valid session', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
const res = await harness.app.inject({
method: 'POST',
@@ -225,7 +239,7 @@ describe('ralph-routes', () => {
method: 'POST',
url: '/api/sessions/nonexistent/ralph-circuit-breaker/reset',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -253,7 +267,7 @@ describe('ralph-routes', () => {
method: 'GET',
url: '/api/sessions/nonexistent/ralph-status',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -279,7 +293,7 @@ describe('ralph-routes', () => {
method: 'GET',
url: '/api/sessions/nonexistent/fix-plan',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -315,7 +329,7 @@ describe('ralph-routes', () => {
url: '/api/sessions/nonexistent/fix-plan/import',
payload: { content: 'test' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -326,7 +340,7 @@ describe('ralph-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/fix-plan/import`,
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -341,7 +355,7 @@ describe('ralph-routes', () => {
url: '/api/sessions/nonexistent/ralph-prompt/write',
payload: { content: 'test prompt' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -365,7 +379,7 @@ describe('ralph-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/ralph-prompt/write`,
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -379,7 +393,7 @@ describe('ralph-routes', () => {
method: 'POST',
url: '/api/sessions/nonexistent/fix-plan/write',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -405,7 +419,7 @@ describe('ralph-routes', () => {
method: 'POST',
url: '/api/sessions/nonexistent/fix-plan/read',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -432,7 +446,7 @@ describe('ralph-routes', () => {
url: '/api/ralph-loop/start',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -466,7 +480,7 @@ describe('ralph-routes', () => {
caseName: '../escape-path',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+2 -2
View File
@@ -172,7 +172,7 @@ describe('respawn-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/respawn/config`,
payload: { idleTimeoutMs: 'not-a-number' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -196,7 +196,7 @@ describe('respawn-routes', () => {
expect(mockController.updateConfig).toHaveBeenCalled();
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'respawn:configUpdated',
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
expect.objectContaining({ sessionId: harness.ctx._sessionId })
);
});
+4 -8
View File
@@ -114,7 +114,7 @@ describe('scheduled-routes', () => {
prompt: '',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -125,7 +125,7 @@ describe('scheduled-routes', () => {
url: '/api/scheduled',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -139,7 +139,7 @@ describe('scheduled-routes', () => {
workingDir: '/tmp/test;rm -rf /',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -171,11 +171,7 @@ describe('scheduled-routes', () => {
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
// Should default to 60 minutes
expect(harness.ctx.startScheduledRun).toHaveBeenCalledWith(
'test',
expect.any(String),
60,
);
expect(harness.ctx.startScheduledRun).toHaveBeenCalledWith('test', expect.any(String), 60);
});
});
+59 -13
View File
@@ -7,6 +7,14 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
// Mock execFile so the send-key route's `tmux` invocation is observable (not run for real).
const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() }));
vi.mock('node:child_process', async (orig) => {
const actual = await orig<typeof import('node:child_process')>();
return { ...actual, execFile };
});
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
describe('session-routes', () => {
@@ -20,6 +28,44 @@ describe('session-routes', () => {
await harness.app.close();
});
// ========== POST /api/sessions/:id/send-key ==========
describe('POST /api/sessions/:id/send-key', () => {
it('routes tmux send-keys through the dedicated Codeman socket (-L)', async () => {
// Regression guard: bare `tmux` would hit the user's default server and never
// find a session that lives only on the Codeman socket (#80 regression class).
execFile.mockReset();
execFile.mockImplementation((_bin: string, _argv: string[], _opts: unknown, cb: (e: Error | null) => void) =>
cb(null)
);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/send-key',
payload: { key: 'S-Enter' },
});
expect(res.statusCode).toBe(200);
expect(execFile).toHaveBeenCalledTimes(1);
const [bin, argv] = execFile.mock.calls[0];
expect(bin).toBe('tmux');
expect((argv as string[]).slice(0, 2)).toEqual(['-L', 'codeman']);
expect(argv).toContain('send-keys');
expect(argv).toContain('-H');
});
it('rejects keys outside the hex allowlist without invoking tmux', async () => {
execFile.mockReset();
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/send-key',
payload: { key: 'rm -rf' },
});
expect(JSON.parse(res.body).success).toBe(false);
expect(execFile).not.toHaveBeenCalled();
});
});
// ========== GET /api/sessions ==========
describe('GET /api/sessions', () => {
@@ -57,7 +103,7 @@ describe('session-routes', () => {
method: 'GET',
url: '/api/sessions/nonexistent',
});
expect(res.statusCode).toBe(200); // returns error in body, not HTTP 404
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.error).toBeDefined();
@@ -128,7 +174,7 @@ describe('session-routes', () => {
url: '/api/sessions/nonexistent/name',
payload: { name: 'test' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -181,7 +227,7 @@ describe('session-routes', () => {
url: '/api/sessions/nonexistent/input',
payload: { input: 'hello' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -192,7 +238,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/input`,
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -219,7 +265,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
payload: { cols: 501, rows: 24 },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -230,7 +276,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
payload: { cols: 80, rows: 201 },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -241,7 +287,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
payload: { cols: 0, rows: 24 },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -266,7 +312,7 @@ describe('session-routes', () => {
method: 'GET',
url: '/api/sessions/nonexistent/terminal',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -292,7 +338,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/run`,
payload: { prompt: '' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -303,7 +349,7 @@ describe('session-routes', () => {
url: '/api/sessions/nonexistent/run',
payload: { prompt: 'test' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -340,7 +386,7 @@ describe('session-routes', () => {
method: 'POST',
url: '/api/sessions/nonexistent/interactive',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -404,7 +450,7 @@ describe('session-routes', () => {
method: 'GET',
url: '/api/sessions/nonexistent/output',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -540,7 +586,7 @@ describe('session-routes', () => {
resumeSessionId: 'not-a-uuid',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+4 -4
View File
@@ -166,7 +166,7 @@ describe('system-routes', () => {
url: '/api/config',
payload: { unknownField: 'invalid' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -361,7 +361,7 @@ describe('system-routes', () => {
url: '/api/settings',
payload: { unknownField: 'bad' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -459,7 +459,7 @@ describe('system-routes', () => {
url: '/api/subagent-window-states',
payload: { minimized: { 'agent-1': 'not-a-boolean' } },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -516,7 +516,7 @@ describe('system-routes', () => {
url: '/api/subagent-parents',
payload: { 'agent-1': 123 },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+17 -2
View File
@@ -77,9 +77,24 @@ describe('TmuxManager (unit)', () => {
});
describe('getAttachArgs', () => {
it('should return attach-session args', () => {
it('should attach every session through the dedicated Codeman socket', () => {
const args = manager.getAttachArgs('codeman-abc12345');
expect(args).toEqual(['attach-session', '-t', 'codeman-abc12345']);
expect(args).toEqual(['-L', 'codeman', 'attach-session', '-t', 'codeman-abc12345']);
});
it('should attach registered sessions on the same dedicated socket (no per-session socket)', () => {
manager.registerSession({
sessionId: 'some-session',
muxName: 'codeman-abc12345',
pid: 12345,
createdAt: Date.now(),
workingDir: '/tmp',
mode: 'claude',
attached: false,
});
const args = manager.getAttachArgs('codeman-abc12345');
expect(args).toEqual(['-L', 'codeman', 'attach-session', '-t', 'codeman-abc12345']);
});
});
+26 -23
View File
@@ -8,7 +8,8 @@
* the next frame — visible flicker and one lost repaint of scrollback.
*
* The fix queries tmux for the actual window geometry first via
* `tmux display -t <name> -p '#{window_width} #{window_height}'`. We cover:
* `tmux -L <socket> display -t <name> -p '#{window_width} #{window_height}'`
* (the `-L <socket>` targets the isolated Codeman socket). We cover:
* - Happy path: tmux reports valid geometry → those numbers are used.
* - Browser-resize-between-attaches: tmux reports a non-default size
* (because a prior client resized it) → the helper picks that up.
@@ -49,7 +50,7 @@ beforeEach(() => {
describe('queryTmuxWindowSize — happy path', () => {
it('returns the geometry tmux reports', () => {
execFileSync.mockReturnValue('200 50\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual({ cols: 200, rows: 50 });
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual({ cols: 200, rows: 50 });
});
it('picks up a non-default size left behind by a prior client (browser-resize-between-attaches)', () => {
@@ -57,12 +58,12 @@ describe('queryTmuxWindowSize — happy path', () => {
// tmux keeps the last-attached geometry. Client B re-attaches and should spawn
// its PTY at 220x60, not 120x40 — that's the whole point of #80.
execFileSync.mockReturnValue('220 60');
expect(queryTmuxWindowSize('codeman-abc')).toEqual({ cols: 220, rows: 60 });
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual({ cols: 220, rows: 60 });
});
it('tolerates trailing whitespace and newlines in tmux output', () => {
execFileSync.mockReturnValue(' 180 45 \n\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual({ cols: 180, rows: 45 });
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual({ cols: 180, rows: 45 });
});
});
@@ -75,7 +76,7 @@ describe('queryTmuxWindowSize — fallback paths', () => {
err.status = 1;
throw err;
});
expect(queryTmuxWindowSize('bogus')).toEqual(DEFAULT);
expect(queryTmuxWindowSize('bogus', 'codeman')).toEqual(DEFAULT);
});
it('falls back when tmux dies between query and parse (ETIMEDOUT / ENOENT)', () => {
@@ -85,63 +86,65 @@ describe('queryTmuxWindowSize — fallback paths', () => {
err.code = 'ETIMEDOUT';
throw err;
});
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
});
it('falls back when tmux returns empty output', () => {
execFileSync.mockReturnValue('');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
});
it('falls back when tmux returns whitespace-only output', () => {
execFileSync.mockReturnValue(' \n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
});
it('falls back when tmux returns non-numeric output', () => {
execFileSync.mockReturnValue('not a size\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
});
it('falls back when only one dimension is present', () => {
execFileSync.mockReturnValue('200\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
});
it('falls back when a dimension is zero (degenerate geometry)', () => {
// tmux reporting `0` would crash node-pty downstream — must not propagate.
execFileSync.mockReturnValue('0 40\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
execFileSync.mockReturnValue('120 0\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
});
it('falls back when a dimension is negative', () => {
execFileSync.mockReturnValue('-200 -50\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
});
it('falls back when tmux returns NaN-producing tokens', () => {
execFileSync.mockReturnValue('abc def\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
});
});
describe('queryTmuxWindowSize — call shape', () => {
it('invokes tmux with display -t <name> -p ... via argv (not a shell)', () => {
it('invokes tmux on the dedicated socket via argv (not a shell)', () => {
execFileSync.mockReturnValue('120 40\n');
queryTmuxWindowSize('codeman-abc');
queryTmuxWindowSize('codeman-abc', 'codeman');
expect(execFileSync).toHaveBeenCalledTimes(1);
const [bin, argv, opts] = execFileSync.mock.calls[0];
expect(bin).toBe('tmux');
expect(argv).toEqual(['display', '-t', 'codeman-abc', '-p', '#{window_width} #{window_height}']);
// `-L <socket>` MUST lead: querying the default server would never find a
// session that lives on the isolated Codeman socket (the #80 regression).
expect(argv).toEqual(['-L', 'codeman', 'display', '-t', 'codeman-abc', '-p', '#{window_width} #{window_height}']);
// execFileSync — not execSync — so muxName is never substituted into a shell string.
expect(opts).toMatchObject({ encoding: 'utf8' });
});
it('uses a bounded timeout so a hung tmux server cannot block startup forever', () => {
execFileSync.mockReturnValue('120 40\n');
queryTmuxWindowSize('codeman-abc');
queryTmuxWindowSize('codeman-abc', 'codeman');
const [, , opts] = execFileSync.mock.calls[0];
// Whatever the exact constant, the contract is: ≤5s so the user-visible
// attach path can't hang on a stuck tmux server.
@@ -152,12 +155,12 @@ describe('queryTmuxWindowSize — call shape', () => {
it('passes a muxName that looks like a tmux flag as an argv element (no option injection)', () => {
execFileSync.mockReturnValue('120 40\n');
queryTmuxWindowSize('-x 1 -y 1; rm -rf');
queryTmuxWindowSize('-x 1 -y 1; rm -rf', 'codeman');
const [, argv] = execFileSync.mock.calls[0];
// The whole "name" lives in a single argv slot, so tmux interprets it as a
// target session name, not as additional flags. The `-t` flag preceding it
// pins it as the target argument.
expect(argv?.[2]).toBe('-x 1 -y 1; rm -rf');
// The whole "name" lives in a single argv slot (index 4, after `-L codeman
// display -t`), so tmux interprets it as a target session name, not as
// additional flags. The `-t` flag preceding it pins it as the target.
expect(argv?.[4]).toBe('-x 1 -y 1; rm -rf');
expect((argv as string[]).indexOf('-x')).toBe(-1);
});
});
+218
View File
@@ -0,0 +1,218 @@
/**
* WebGL longtask auto-fallback tests.
*
* Covers the three follow-ups from #89:
* 1. Pure trip-detection helper — rolling-window arithmetic for the
* "N longtasks of >=Xms within Yms" trip condition. Unit-tested
* independently of PerformanceObserver, which can't be driven
* deterministically from JS (entries arrive from the platform).
* 2. Constants are hoisted from inline literals to `WEBGL_FALLBACK`
* in constants.js — assert they exist with the documented values.
* 3. Observer disconnect — _disposeWebGLObserver() is idempotent and
* can be called from the onContextLoss path without the addon
* having been initialised. Mirrors the leak case in the issue:
* "observer outlives its addon" when teardown precedes a trip.
*
* Strategy: load the static app shell in a headless browser and drive
* the helper through page.evaluate(). No real PTY/tmux/WebGL needed —
* the trip math is pure and the dispose path is a couple of property
* mutations, both of which run on any page where app.js loaded.
*
* Port: 3166 (per MEMORY.md, ports 3150+ for tests)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3166;
const BASE_URL = `http://localhost:${PORT}`;
describe('WebGL longtask auto-fallback', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
beforeAll(async () => {
server = new WebServer(PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
// Wait for constants.js + app.js to have loaded — both expose globals.
await page.waitForFunction(
() =>
typeof (window as { WEBGL_FALLBACK?: unknown }).WEBGL_FALLBACK !== 'undefined' &&
typeof (window as { evaluateWebGLLongTaskTrip?: unknown }).evaluateWebGLLongTaskTrip === 'function' &&
typeof (window as { app?: unknown }).app !== 'undefined'
);
}, 60000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
describe('constants are hoisted', () => {
it('WEBGL_FALLBACK exposes documented thresholds', async () => {
const cfg = await page.evaluate(
() => (window as unknown as { WEBGL_FALLBACK: Record<string, number> }).WEBGL_FALLBACK
);
expect(cfg).toEqual({
LONGTASK_MS: 200,
LONGTASK_COUNT: 3,
WINDOW_MS: 30000,
GRACE_MS: 5000,
STICKY_EXPIRY_MS: 7 * 24 * 60 * 60 * 1000,
});
});
});
describe('evaluateWebGLLongTaskTrip — rolling window arithmetic', () => {
type EvalFn = (
recent: number[],
entries: { startTime: number; duration: number }[],
now: number
) => { tripped: boolean; recent: number[] };
/** Run the pure helper in the page and return the post-call state. */
const run: EvalFn = async (recent, entries, now) =>
page.evaluate(
({ r, e, n }) => {
const fn = (
window as unknown as {
evaluateWebGLLongTaskTrip: (
rec: number[],
ents: { startTime: number; duration: number }[],
now: number
) => boolean;
}
).evaluateWebGLLongTaskTrip;
const recent = [...r];
const tripped = fn(recent, e, n);
return { tripped, recent };
},
{ r: recent, e: entries, n: now }
) as unknown as { tripped: boolean; recent: number[] };
it('trips when 3 longtasks fall inside the 30s window', async () => {
const entries = [
{ startTime: 1000, duration: 250 },
{ startTime: 5000, duration: 300 },
{ startTime: 10000, duration: 220 },
];
const result = await run([], entries, 12000);
expect(result.tripped).toBe(true);
expect(result.recent).toEqual([1000, 5000, 10000]);
});
it('does not trip when 3 longtasks are spread across 60s', async () => {
// 3 longtasks 25s apart — only the most recent two stay inside 30s.
const entries = [
{ startTime: 1000, duration: 250 },
{ startTime: 26000, duration: 250 },
{ startTime: 51000, duration: 250 },
];
const result = await run([], entries, 51100);
expect(result.tripped).toBe(false);
// First entry pruned (1000 is >30s before now=51100); 26000 and 51000 stay.
expect(result.recent).toEqual([26000, 51000]);
});
it('ignores entries shorter than 200ms', async () => {
const entries = [
{ startTime: 1000, duration: 199 },
{ startTime: 2000, duration: 100 },
{ startTime: 3000, duration: 50 },
];
const result = await run([], entries, 3500);
expect(result.tripped).toBe(false);
expect(result.recent).toEqual([]);
});
it('prunes stale entries even when no new ones arrive', async () => {
// Existing window has 2 stale + 1 fresh; an empty batch should still
// age out the stale ones so the next real batch evaluates correctly.
const recent = [1000, 5000, 40000];
const result = await run(recent, [], 41000);
expect(result.tripped).toBe(false);
expect(result.recent).toEqual([40000]);
});
it('counts entries cumulatively across batches', async () => {
// Two batches of 2 entries each, all inside the window — second
// batch should push the cumulative count to 4 and trip.
const recent: number[] = [];
const first = await run(
recent,
[
{ startTime: 1000, duration: 250 },
{ startTime: 2000, duration: 250 },
],
3000
);
expect(first.tripped).toBe(false);
expect(first.recent).toEqual([1000, 2000]);
const second = await run(
first.recent,
[
{ startTime: 4000, duration: 250 },
{ startTime: 5000, duration: 250 },
],
6000
);
expect(second.tripped).toBe(true);
expect(second.recent).toEqual([1000, 2000, 4000, 5000]);
});
});
describe('_disposeWebGLObserver', () => {
it('is idempotent — safe to call when no observer was installed', async () => {
const ok = await page.evaluate(() => {
const app = (
window as unknown as { app: { _disposeWebGLObserver: () => void; _webglLongTaskObserver: unknown } }
).app;
app._webglLongTaskObserver = null;
app._disposeWebGLObserver();
app._disposeWebGLObserver();
return app._webglLongTaskObserver === null;
});
expect(ok).toBe(true);
});
it('disconnects a stub observer and nulls the reference', async () => {
const result = await page.evaluate(() => {
const app = (
window as unknown as { app: { _disposeWebGLObserver: () => void; _webglLongTaskObserver: unknown } }
).app;
let disconnectCalls = 0;
app._webglLongTaskObserver = {
disconnect() {
disconnectCalls++;
},
} as unknown;
app._disposeWebGLObserver();
return { disconnectCalls, ref: app._webglLongTaskObserver };
});
expect(result.disconnectCalls).toBe(1);
expect(result.ref).toBeNull();
});
it('swallows a throwing disconnect — guards against driver quirks', async () => {
const result = await page.evaluate(() => {
const app = (
window as unknown as { app: { _disposeWebGLObserver: () => void; _webglLongTaskObserver: unknown } }
).app;
app._webglLongTaskObserver = {
disconnect() {
throw new Error('synthetic');
},
} as unknown;
app._disposeWebGLObserver();
return app._webglLongTaskObserver;
});
expect(result).toBeNull();
});
});
});