Compare commits

..
Author SHA1 Message Date
arkonandClaude Opus 4.7 257695ff8e chore: version packages
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 05:17:07 +02:00
arkonandClaude Opus 4.7 2cfccc745f docs: correct sendPendingCtrlL comment (it has no callers)
The prior wording claimed the no-op stub was kept so SSE idle/working
handlers could call it without guards, but there are no callers anywhere.
Reword to reflect that it's a vestigial, intentionally-retained guard
documenting why Ctrl+L must not be auto-sent. Comment-only; minified
build output is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 01:52:15 +02:00
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
35 changed files with 1306 additions and 575 deletions
+50
View File
@@ -1,5 +1,55 @@
# aicodeman # aicodeman
## 0.7.1
### Patch Changes
- **fix(respawn): auto-accept now fires on plan approvals after `Worked for X` line, and on AskUserQuestion menus**
Two related blockers in the respawn controller's auto-accept path:
- Modern Claude Code emits `✻ Worked for Xm Ys` immediately before a plan-approval menu. `_detectCompletionMessage()` cancelled the auto-accept timer and `canAutoAccept()` then rejected on `completionMessageTime !== null`, so plan approvals **never** auto-accepted — the 10 s completion-confirm timer instead started a respawn cycle while the menu sat unanswered.
- The same logic in `signalElicitation()` set a hard flag that blocked auto-accept whenever Claude Code fired the `elicitation_dialog` hook, contradicting the in-UI hint ("Auto-accept presses Enter for plan approvals **and default question options**"). AskUserQuestion menus were therefore never auto-accepted either.
Fix:
- `_detectCompletionMessage()` no longer cancels the auto-accept timer; the auto-accept pre-filter is now the authoritative "is there a numbered selection menu?" gate.
- `canAutoAccept()` and the AI-plan-check callback both accept `'watching'` AND `'confirming_idle'` states (covers the single-PTY-burst case where `Worked for` and the menu arrive together — `_detectCompletionMessage` returns early before the substantial-output check can demote state back to watching). `sendAutoAcceptEnter()` self-transitions back to `'watching'` before sending Enter.
- `signalElicitation()` is now an affirmative hint that primes the auto-accept timer instead of blocking. Still gated on `config.autoAcceptPrompts` AND state ∈ {`watching`, `confirming_idle`} — never fires Enter when respawn is off or auto-accept is disabled.
- AI plan-check prompt broadened to recognize AskUserQuestion / elicitation menus as valid for auto-accept (the verdict name `PLAN_MODE` is preserved for compatibility but now means "auto-accept this selection menu").
- Removed the now-unused `elicitationDetected` field and its assignments.
Two new regression tests cover both the separate-PTY-chunk and single-PTY-chunk cases; the previously misleading "should NOT send Enter when completion message was detected" test was renamed and re-scoped to clarify it tests the **no-menu** path (which still correctly rejects via the pre-filter).
**docs(web): correct `sendPendingCtrlL` comment** — removed the stale "called by foo/bar" note from the dead-call-graph helper after #99.
## 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 ## 0.6.10
### Patch Changes ### 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. 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.10 (must match `package.json`) **Version**: 0.7.1 (must match `package.json`)
## Project Overview ## 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 - **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) - **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` env vars** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift - **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` 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 - **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. **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` | | | **Entry** | `src/index.ts`, `src/cli.ts` | |
| **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts`, `src/session-lifecycle-log.ts`, `src/session-task-cache.ts` | | | **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts`, `src/session-lifecycle-log.ts`, `src/session-task-cache.ts` | |
| **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 | | **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 | | **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 | | **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` | | | **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` | | | **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` | | | **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`) + 5 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`, `image-input.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 | | **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`. ★ = 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 ### 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 ## 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. **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 ## 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 # Codebase Cleanup Findings
Compiled from parallel analysis of the entire Codeman codebase by 3 research agents (2026-02-19). 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 # Code Structure & Quality Findings
**Date**: 2026-02-28 **Date**: 2026-02-28
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "aicodeman", "name": "aicodeman",
"version": "0.6.10", "version": "0.7.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "aicodeman", "name": "aicodeman",
"version": "0.6.10", "version": "0.7.1",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aicodeman", "name": "aicodeman",
"version": "0.6.10", "version": "0.7.1",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
+10 -3
View File
@@ -32,6 +32,13 @@ set -e
CODEMAN_STATE="$HOME/.codeman/state.json" CODEMAN_STATE="$HOME/.codeman/state.json"
CODEMAN_SESSIONS="$HOME/.codeman/mux-sessions.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) # iPhone 17 Pro portrait width (conservative)
MAX_WIDTH=44 MAX_WIDTH=44
@@ -286,7 +293,7 @@ parse_sessions() {
# Get PID from tmux # Get PID from tmux
local pid 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") SESSION_PIDS+=("$pid")
MUX_NAMES+=("$session_name") MUX_NAMES+=("$session_name")
@@ -314,7 +321,7 @@ parse_sessions() {
fi fi
i=$((i + 1)) 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}" echo -e "${D}(Ctrl+B D to detach)${R}"
sleep 0.3 sleep 0.3
tmux attach-session -t "$mux_name" "${TMUX_CMD[@]}" attach-session -t "$mux_name"
return 0 return 0
} }
+17 -8
View File
@@ -20,6 +20,13 @@ REVERSE='\033[7m'
# Use the same path as codeman (src/tmux-manager.ts) # Use the same path as codeman (src/tmux-manager.ts)
SESSIONS_FILE="${HOME}/.codeman/mux-sessions.json" 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 data
CACHED_JSON="" CACHED_JSON=""
@@ -92,7 +99,7 @@ declare -A ALIVE_CACHE
check_alive() { check_alive() {
local mux_name=$1 local mux_name=$1
if [[ -z "${ALIVE_CACHE[$mux_name]+x}" ]]; then 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 ALIVE_CACHE[$mux_name]=1
else else
ALIVE_CACHE[$mux_name]=0 ALIVE_CACHE[$mux_name]=0
@@ -111,8 +118,10 @@ kill_session() {
local mux_name=$(get_session_field $idx "muxName") local mux_name=$(get_session_field $idx "muxName")
local pid=$(get_session_field $idx "pid") local pid=$(get_session_field $idx "pid")
# SAFETY: Never kill own tmux session # SAFETY: Never kill own tmux session. Queried on the Codeman socket; if run
local current_session=$(tmux display-message -p '#{session_name}' 2>/dev/null || echo "") # 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 if [[ -n "$current_session" && "$mux_name" == "$current_session" ]]; then
echo -e "${RED}BLOCKED: Cannot kill own tmux session: $mux_name${NC}" echo -e "${RED}BLOCKED: Cannot kill own tmux session: $mux_name${NC}"
return 1 return 1
@@ -120,7 +129,7 @@ kill_session() {
pkill -TERM -P $pid 2>/dev/null pkill -TERM -P $pid 2>/dev/null
kill -TERM -$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 kill -KILL $pid 2>/dev/null
# Remove from JSON # Remove from JSON
@@ -345,7 +354,7 @@ interactive_menu() {
clear clear
echo -e "${CYAN}Attaching... (Ctrl+B D to detach)${NC}" echo -e "${CYAN}Attaching... (Ctrl+B D to detach)${NC}"
sleep 0.3 sleep 0.3
tmux attach-session -t "$mux_name" "${TMUX_CMD[@]}" attach-session -t "$mux_name"
tput civis tput civis
need_full_redraw=1 need_full_redraw=1
force_refresh force_refresh
@@ -474,7 +483,7 @@ main() {
[[ -z "${2:-}" ]] && { echo "Usage: $0 attach <N>"; exit 1; } [[ -z "${2:-}" ]] && { echo "Usage: $0 attach <N>"; exit 1; }
force_refresh force_refresh
local mux_name=$(get_session_field $(($2-1)) "muxName") 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) kill)
[[ -z "${2:-}" ]] && { echo "Usage: $0 kill <N|N,M|N-M>"; exit 1; } [[ -z "${2:-}" ]] && { echo "Usage: $0 kill <N|N,M|N-M>"; exit 1; }
@@ -492,8 +501,8 @@ main() {
;; ;;
kill-all) kill-all)
force_refresh force_refresh
# SAFETY: Never kill own tmux session # SAFETY: Never kill own tmux session (queried on the Codeman socket)
local current_session=$(tmux display-message -p '#{session_name}' 2>/dev/null || echo "") local current_session=$("${TMUX_CMD[@]}" display-message -p '#{session_name}' 2>/dev/null || echo "")
local killed=0 local killed=0
for ((i=CACHED_COUNT-1; i>=0; i--)); do for ((i=CACHED_COUNT-1; i>=0; i--)); do
local mux_name=$(get_session_field $i "muxName") local mux_name=$(get_session_field $i "muxName")
+16 -9
View File
@@ -64,20 +64,27 @@ const DEFAULT_PLAN_CHECK_CONFIG: AiPlanCheckConfig = {
const VERDICT_PATTERN = /^\s*(PLAN_MODE|NOT_PLAN_MODE)\b/i; const VERDICT_PATTERN = /^\s*(PLAN_MODE|NOT_PLAN_MODE)\b/i;
/** The prompt sent to the AI plan checker */ /** The prompt sent to the AI plan checker */
const AI_PLAN_CHECK_PROMPT = `Analyze this terminal output from a running Claude Code session. Determine if the terminal is currently showing a PLAN MODE APPROVAL PROMPT or not. const AI_PLAN_CHECK_PROMPT = `Analyze this terminal output from a running Claude Code session. Determine if the terminal is currently showing a NUMBERED SELECTION MENU that is waiting for the user to press Enter on the highlighted default option.
A plan mode approval prompt is a numbered selection menu that Claude Code shows when it wants the user to approve a plan before proceeding. It typically has these characteristics: A qualifying menu has all of these characteristics:
- A numbered list of options (e.g., "1. Yes", "2. No", "3. Type your own") - A numbered list of options (e.g., "1. Yes", "2. No", "3. Type your own")
- A selection indicator arrow (❯ or >) pointing to one of the options - A selection indicator arrow (❯ or >) pointing to one of the options (the default)
- Text asking for approval like "Would you like to proceed?" or "Ready to implement?" - The menu appears at the BOTTOM of the output (most recent content)
- The prompt appears at the BOTTOM of the output (most recent content) - It is asking the user to choose, not just displaying numbered information
NOT a plan mode prompt: This includes BOTH:
- Plan-mode approval prompts ("Would you like to proceed?" / "Ready to implement?")
- AskUserQuestion / elicitation dialogs (Claude Code's numbered question menus)
NOT a qualifying menu:
- Claude actively working (spinners, "Thinking", tool execution) - Claude actively working (spinners, "Thinking", tool execution)
- A completed response with no selection menu - A completed response with no selection menu visible
- An AskUserQuestion/elicitation dialog (different format, free-text input) - A free-text input field with no numbered options
- A numbered LIST in the assistant's prose with no selection arrow
- Network lag or mid-output pause - Network lag or mid-output pause
- Any state without a visible numbered selection menu - Any state without a visible selector arrow on a numbered option
The verdict name PLAN_MODE is historical — it now means "auto-accept this selection menu by pressing Enter on the default".
Terminal output (most recent at bottom): Terminal output (most recent at bottom):
--- ---
+3
View File
@@ -98,6 +98,9 @@ export interface TerminalMultiplexer extends EventEmitter {
/** Which backend this instance uses */ /** Which backend this instance uses */
readonly backend: 'tmux'; readonly backend: 'tmux';
/** The dedicated tmux socket name all sessions live on (e.g. "codeman"). */
readonly muxSocket: string;
// ========== Lifecycle ========== // ========== Lifecycle ==========
/** /**
+33 -25
View File
@@ -513,7 +513,7 @@ const DEFAULT_CONFIG: RespawnConfig = {
sendInit: true, // send /init after /clear sendInit: true, // send /init after /clear
completionConfirmMs: 10000, // 10 seconds of silence after completion message completionConfirmMs: 10000, // 10 seconds of silence after completion message
noOutputTimeoutMs: 30000, // 30 seconds fallback if no output at all noOutputTimeoutMs: 30000, // 30 seconds fallback if no output at all
autoAcceptPrompts: true, // auto-accept plan mode prompts (not questions) autoAcceptPrompts: true, // auto-accept numbered selection menus (plan approvals + question dialogs)
autoAcceptDelayMs: 8000, // 8 seconds before auto-accepting autoAcceptDelayMs: 8000, // 8 seconds before auto-accepting
aiIdleCheckEnabled: true, // use AI to confirm idle state aiIdleCheckEnabled: true, // use AI to confirm idle state
aiIdleCheckModel: AI_CHECK_MODEL, aiIdleCheckModel: AI_CHECK_MODEL,
@@ -623,9 +623,6 @@ export class RespawnController extends EventEmitter {
/** Whether any terminal output has been received since start/last-auto-accept */ /** Whether any terminal output has been received since start/last-auto-accept */
private hasReceivedOutput: boolean = false; private hasReceivedOutput: boolean = false;
/** Whether an elicitation dialog (AskUserQuestion) was detected via hook signal */
private elicitationDetected: boolean = false;
// ========== Hook-Based Detection State (Layer 0 - Highest Priority) ========== // ========== Hook-Based Detection State (Layer 0 - Highest Priority) ==========
/** Whether a Stop hook was received (definitive idle signal from Claude Code) */ /** Whether a Stop hook was received (definitive idle signal from Claude Code) */
@@ -1369,7 +1366,12 @@ export class RespawnController extends EventEmitter {
this.clearWorkingPatternWindow(); this.clearWorkingPatternWindow();
this.workingDetected = false; this.workingDetected = false;
this.completionMessageTime = now; this.completionMessageTime = now;
this.cancelAutoAcceptTimer(); // Normal idle flow handles this // Don't cancel the auto-accept timer here — modern Claude Code emits "Worked for X"
// immediately before a plan-approval menu, and the auto-accept pre-filter is
// responsible for distinguishing menu-present from menu-absent. Cancelling here
// would silently block auto-accept for every plan approval and AskUserQuestion
// dialog. If no menu is in the buffer, the pre-filter rejects and the
// completion-confirm timer (started below) drives the normal idle flow.
this.log(`Completion message detected: "${data.trim().substring(0, 50)}..."`); this.log(`Completion message detected: "${data.trim().substring(0, 50)}..."`);
// In watching state, start completion confirmation timer // In watching state, start completion confirmation timer
@@ -1417,7 +1419,6 @@ export class RespawnController extends EventEmitter {
this.workingDetected = true; this.workingDetected = true;
this.promptDetected = false; this.promptDetected = false;
this.elicitationDetected = false; // Clear on new work cycle
this.resetHookState(); // Clear hook signals on new work this.resetHookState(); // Clear hook signals on new work
this.lastWorkingPatternTime = now; this.lastWorkingPatternTime = now;
@@ -2222,11 +2223,11 @@ export class RespawnController extends EventEmitter {
* @returns True if auto-accept should proceed to the AI confirmation stage * @returns True if auto-accept should proceed to the AI confirmation stage
*/ */
private canAutoAccept(): boolean { private canAutoAccept(): boolean {
// Only auto-accept in watching state (not during a respawn cycle) // Allow auto-accept from 'watching' AND 'confirming_idle'. The latter is reached
if (this._state !== 'watching') return false; // when "Worked for X" was detected — which Claude Code now emits in the same PTY
// burst as a plan-approval menu. `sendAutoAcceptEnter()` self-transitions back to
// Don't auto-accept if a completion message was detected (normal idle handles it) // 'watching' before sending Enter. Reject any other state (respawn cycle, etc.).
if (this.completionMessageTime !== null) return false; if (this._state !== 'watching' && this._state !== 'confirming_idle') return false;
// Don't auto-accept if disabled // Don't auto-accept if disabled
if (!this.config.autoAcceptPrompts) return false; if (!this.config.autoAcceptPrompts) return false;
@@ -2234,15 +2235,15 @@ export class RespawnController extends EventEmitter {
// Don't auto-accept if we haven't received any output yet (prevents spurious Enter on fresh start) // Don't auto-accept if we haven't received any output yet (prevents spurious Enter on fresh start)
if (!this.hasReceivedOutput) return false; if (!this.hasReceivedOutput) return false;
// Don't auto-accept if an elicitation dialog (AskUserQuestion) was detected // Note: completionMessageTime and elicitationDetected used to block here, but both
if (this.elicitationDetected) { // legitimately co-occur with selection menus (Claude Code emits "Worked for X"
this.log('Skipping auto-accept: elicitation dialog detected (AskUserQuestion)'); // before plan approvals, and AskUserQuestion fires the elicitation hook). The
return false; // pre-filter below is the authoritative gate for "is there a numbered menu?".
}
// Stage 1: Pre-filter — check if buffer looks like plan mode // Stage 1: Pre-filter — check if buffer looks like a numbered selection menu
// (covers both plan-mode approvals and AskUserQuestion dialogs)
if (!this.isPlanModePreFilterMatch(this.terminalBuffer.value)) { if (!this.isPlanModePreFilterMatch(this.terminalBuffer.value)) {
this.log('Skipping auto-accept: pre-filter did not match plan mode patterns'); this.log('Skipping auto-accept: pre-filter did not match selection-menu patterns');
return false; return false;
} }
@@ -2308,8 +2309,10 @@ export class RespawnController extends EventEmitter {
} }
if (result.verdict === 'PLAN_MODE') { if (result.verdict === 'PLAN_MODE') {
// Don't send Enter if state changed (e.g., AI idle check started or respawn cycle began) // Don't send Enter if state moved into a respawn cycle while the check ran.
if (this._state !== 'watching') { // 'watching' and 'confirming_idle' are both valid — sendAutoAcceptEnter()
// self-transitions to 'watching' before sending.
if (this._state !== 'watching' && this._state !== 'confirming_idle') {
this.logAction('plan-check', `Verdict: PLAN_MODE but state is ${this._state}, not sending Enter`); this.logAction('plan-check', `Verdict: PLAN_MODE but state is ${this._state}, not sending Enter`);
return; return;
} }
@@ -2368,13 +2371,18 @@ export class RespawnController extends EventEmitter {
/** /**
* Signal that an elicitation dialog (AskUserQuestion) was detected via hook. * Signal that an elicitation dialog (AskUserQuestion) was detected via hook.
* This prevents auto-accept from firing, since the user needs to make a selection. * Used as a positive hint that a numbered selection menu is about to render —
* The flag is cleared when working patterns are detected (new turn starts). * we restart the auto-accept timer so the pre-filter gets a fresh shot at it
* once the menu finishes drawing. The actual gate is `isPlanModePreFilterMatch()`
* plus (optionally) the AI plan check; this hook just primes the timer.
* No-op if respawn isn't `'watching'`/`'confirming_idle'` or `autoAcceptPrompts`
* is off, so this can never fire Enter when the user has disabled auto-accept.
*/ */
signalElicitation(): void { signalElicitation(): void {
this.elicitationDetected = true; this.log('Elicitation dialog signaled - auto-accept will trigger if pre-filter matches');
this.cancelAutoAcceptTimer(); if (this.config.autoAcceptPrompts && (this._state === 'watching' || this._state === 'confirming_idle')) {
this.log('Elicitation dialog signaled - auto-accept blocked until next work cycle'); this.startAutoAcceptTimer();
}
} }
/** /**
+13 -6
View File
@@ -133,15 +133,22 @@ const TMUX_DISPLAY_TIMEOUT_MS = 2000;
* (tmux dead, muxName unknown, malformed output) — caller never has to * (tmux dead, muxName unknown, malformed output) — caller never has to
* differentiate "tmux unreachable" from "size 120x40". * 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 * Argv form (execFileSync, not execSync) keeps `muxName` out of any shell so
* a hostile session name can't inject options. * 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 { try {
const sizeStr = execFileSync('tmux', ['display', '-t', muxName, '-p', '#{window_width} #{window_height}'], { const sizeStr = execFileSync(
timeout: TMUX_DISPLAY_TIMEOUT_MS, 'tmux',
encoding: 'utf8', ['-L', socket, 'display', '-t', muxName, '-p', '#{window_width} #{window_height}'],
}).trim(); {
timeout: TMUX_DISPLAY_TIMEOUT_MS,
encoding: 'utf8',
}
).trim();
const [w, h] = sizeStr.split(' ').map(Number); const [w, h] = sizeStr.split(' ').map(Number);
if (w > 0 && h > 0) { if (w > 0 && h > 0) {
return { cols: w, rows: h }; return { cols: w, rows: h };
@@ -978,7 +985,7 @@ export class Session extends EventEmitter {
// Attach to the mux session via PTY // Attach to the mux session via PTY
// Query existing tmux window size so re-attach matches (avoids flicker from 120x40 default) // 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 { try {
this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), { this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), {
name: 'xterm-256color', name: 'xterm-256color',
+143 -47
View File
@@ -71,6 +71,9 @@ const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
/** Default stats collection interval (2 seconds) */ /** Default stats collection interval (2 seconds) */
const DEFAULT_STATS_INTERVAL_MS = 2000; 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. * SAFETY: Test mode detection.
* When running under vitest (VITEST env var is set automatically), * 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") */ /** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */
const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/; 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. * 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}. */ /** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneList}. */
const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}`; 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}'` * 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. * 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); 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. * Build Claude CLI permission flags for the tmux command string.
* Validates allowedTools to prevent command injection. * Validates allowedTools to prevent command injection.
@@ -248,7 +295,7 @@ function buildSpawnCommand(options: {
* Set sensitive environment variables on a tmux session via setenv. * Set sensitive environment variables on a tmux session via setenv.
* These are inherited by panes but not visible in ps output or tmux history. * 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']; const sensitiveVars = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY'];
for (const key of sensitiveVars) { for (const key of sensitiveVars) {
const val = process.env[key]; 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 // Shell-escape: wrap in single quotes, escape any inner single quotes
const escaped = val.replace(/'/g, "'\\''"); const escaped = val.replace(/'/g, "'\\''");
try { try {
execSync(`tmux setenv -t '${muxName}' ${key} '${escaped}'`, { execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
encoding: 'utf8', encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
@@ -272,7 +319,7 @@ function setOpenCodeEnvVars(muxName: string): void {
* Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv. * Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv.
* Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON. * 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; if (!config) return;
let jsonContent: string | undefined; let jsonContent: string | undefined;
@@ -303,7 +350,7 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi
if (jsonContent) { if (jsonContent) {
const escaped = jsonContent.replace(/'/g, "'\\''"); const escaped = jsonContent.replace(/'/g, "'\\''");
try { try {
execSync(`tmux setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, { execSync(`${tmuxCmd} setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, {
encoding: 'utf8', encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
@@ -336,6 +383,7 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi
export class TmuxManager extends EventEmitter implements TerminalMultiplexer { export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
readonly backend = 'tmux' as const; readonly backend = 'tmux' as const;
private sessions: Map<string, MuxSession> = new Map(); private sessions: Map<string, MuxSession> = new Map();
private readonly tmuxSocket = resolveConfiguredTmuxSocket();
private statsInterval: NodeJS.Timeout | null = null; private statsInterval: NodeJS.Timeout | null = null;
private mouseSyncInterval: NodeJS.Timeout | null = null; private mouseSyncInterval: NodeJS.Timeout | null = null;
/** Track last-known pane count per session to avoid unnecessary tmux set-option calls */ /** 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) // Load saved sessions from disk (NEVER called in test mode)
private loadSessions(): void { private loadSessions(): void {
if (IS_TEST_MODE) return; 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 content = readFileSync(MUX_SESSIONS_FILE, 'utf-8');
const data = JSON.parse(content); const data = JSON.parse(content);
if (Array.isArray(data)) { 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) { 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); 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; continue;
} }
try { try {
execSync(`tmux setenv -t ${shellescape(muxName)} ${key} ${shellescape(value)}`, { execSync(`${this.tmux()} setenv -t ${shellescape(muxName)} ${key} ${shellescape(value)}`, {
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'], 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). * (not visible in ps output or tmux history, inherited by panes).
*/ */
private _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig): void { private _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig): void {
setOpenCodeEnvVars(muxName); const tmuxCmd = this.tmux();
setOpenCodeConfigContent(muxName, openCodeConfig); setOpenCodeEnvVars(tmuxCmd, muxName);
setOpenCodeConfigContent(tmuxCmd, muxName, openCodeConfig);
} }
/** /**
@@ -545,7 +635,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
try { try {
// Build the full command to run inside tmux // 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) // 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: // 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.) // (Production uses systemd which has a clean env, but dev/test may be nested.)
const cleanEnv = { ...process.env }; const cleanEnv = { ...process.env };
delete cleanEnv.TMUX; delete cleanEnv.TMUX;
execSync(`tmux new-session -ds "${muxName}" -c "${workingDir}"`, { execSync(`${this.tmux()} new-session -ds "${muxName}" -c "${workingDir}"`, {
cwd: workingDir, cwd: workingDir,
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore', 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 // Set remain-on-exit now that the server is running — must be before respawn-pane
try { 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, timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore', stdio: 'ignore',
}); });
@@ -584,7 +674,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
this.applyEnvOverrides(muxName, envOverrides); this.applyEnvOverrides(muxName, envOverrides);
// Replace the shell with the actual command (no echo in terminal) // 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, timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore', stdio: 'ignore',
}); });
@@ -598,20 +688,20 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// It gets enabled dynamically when panes are split (agent teams). // It gets enabled dynamically when panes are split (agent teams).
const configPromises: Promise<void>[] = [ const configPromises: Promise<void>[] = [
// Disable tmux status bar — Codeman's web UI provides session info // 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(() => {}) .then(() => {})
.catch(() => { .catch(() => {
/* Non-critical — session still works with status bar */ /* Non-critical — session still works with status bar */
}), }),
// Override global remain-on-exit with session-level setting // 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(() => {}) .then(() => {})
.catch(() => { .catch(() => {
/* Already set globally as fallback */ /* Already set globally as fallback */
}), }),
// Raise tmux scrollback from its 2000-line default so re-attach preserves // Raise tmux scrollback from its 2000-line default so re-attach preserves
// more context. Matches the xterm-side default in constants.js. // 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(() => {}) .then(() => {})
.catch(() => { .catch(() => {
/* Non-critical — falls back to tmux default */ /* 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 // Enable 24-bit true color passthrough — server-wide, set once per lifetime
if (!this.trueColorConfigured) { if (!this.trueColorConfigured) {
configPromises.push( 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(() => { .then(() => {
this.trueColorConfigured = true; this.trueColorConfigured = true;
}) })
@@ -678,7 +768,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
try { 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', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}).trim(); }).trim();
@@ -704,7 +794,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (IS_TEST_MODE) return false; if (IS_TEST_MODE) return false;
if (!isValidMuxName(muxName)) return false; if (!isValidMuxName(muxName)) return false;
try { 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', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}).trim(); }).trim();
@@ -754,7 +844,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}); });
const config = niceConfig || DEFAULT_NICE_CONFIG; const config = niceConfig || DEFAULT_NICE_CONFIG;
const cmd = wrapWithNice(baseCmd, config); const cmd = wrapWithNice(baseCmd, config);
const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`; const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
try { try {
// For OpenCode: set sensitive env vars via tmux setenv before respawn // 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. // Re-apply user env overrides before respawn so the new shell inherits them.
this.applyEnvOverrides(muxName, envOverrides); 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, timeout: EXEC_TIMEOUT_MS,
}); });
// Wait for the respawned process to start // Wait for the respawned process to start
@@ -783,7 +873,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (IS_TEST_MODE) return false; if (IS_TEST_MODE) return false;
try { try {
execSync(`tmux has-session -t "${muxName}" 2>/dev/null`, { execSync(`${this.tmux()} has-session -t "${muxName}" 2>/dev/null`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
@@ -923,7 +1013,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Strategy 3: Kill tmux session by name // Strategy 3: Kill tmux session by name
try { 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, timeout: EXEC_TIMEOUT_MS,
}); });
} catch { } catch {
@@ -988,26 +1078,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const dead: string[] = []; const dead: string[] = [];
const discovered: string[] = []; const discovered: string[] = [];
// Batch: single tmux call to get all session names + pane PIDs (replaces N per-session subprocess calls) // Single batched query against the one socket Codeman owns. With a single
let activeSessions = new Map<string, number>(); // 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 { 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', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}).trim(); }).trim();
activeSessions = parsePaneList(output); active = parsePaneList(output);
} catch (err) { } catch (err) {
console.error('[TmuxManager] Failed to list tmux panes:', 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) { for (const [sessionId, session] of this.sessions) {
const pid = activeSessions.get(session.muxName); const pid = active.get(session.muxName);
if (pid !== undefined) { if (pid !== undefined) {
alive.push(sessionId); alive.push(sessionId);
if (pid !== session.pid) { if (pid !== session.pid) session.pid = pid;
session.pid = pid;
}
} else { } else {
dead.push(sessionId); dead.push(sessionId);
this.sessions.delete(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>(); const knownMuxNames = new Set<string>();
for (const session of this.sessions.values()) { for (const session of this.sessions.values()) {
knownMuxNames.add(session.muxName); 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 (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-')) continue;
if (knownMuxNames.has(sessionName)) continue; if (knownMuxNames.has(sessionName)) continue;
@@ -1038,6 +1132,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
name: `Restored: ${sessionName}`, name: `Restored: ${sessionName}`,
}; };
this.sessions.set(sessionId, session); this.sessions.set(sessionId, session);
knownMuxNames.add(sessionName);
discovered.push(sessionId); discovered.push(sessionId);
console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`); 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 // 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 // single tmux invocation (via \;) causes Ink to interpret Enter as a newline
// character in the input buffer rather than as form submission. // 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, timeout: EXEC_TIMEOUT_MS,
}); });
await new Promise((resolve) => setTimeout(resolve, 50)); 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, timeout: EXEC_TIMEOUT_MS,
}); });
} else if (textPart) { } else if (textPart) {
// Text only, no Enter // 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, timeout: EXEC_TIMEOUT_MS,
}); });
} else if (hasCarriageReturn) { } else if (hasCarriageReturn) {
// Enter only // 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, timeout: EXEC_TIMEOUT_MS,
}); });
} }
@@ -1386,7 +1481,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
try { try {
execSync(`tmux set-option -t "${muxName}" mouse on`, { execSync(`${this.tmux()} set-option -t "${muxName}" mouse on`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
@@ -1410,7 +1505,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
try { try {
execSync(`tmux set-option -t "${muxName}" mouse off`, { execSync(`${this.tmux()} set-option -t "${muxName}" mouse off`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
@@ -1450,7 +1545,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
try { try {
const output = execSync( 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 } { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
).trim(); ).trim();
@@ -1489,27 +1584,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Build target: sessionName.paneId (e.g., "codeman-abc12345.%1") // Build target: sessionName.paneId (e.g., "codeman-abc12345.%1")
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
const tmux = this.tmux();
try { try {
const hasCarriageReturn = input.includes('\r'); const hasCarriageReturn = input.includes('\r');
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd(); const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
if (textPart && hasCarriageReturn) { 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', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
execSync(`tmux send-keys -t ${shellescape(target)} Enter`, { execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
} else if (textPart) { } 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', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
} else if (hasCarriageReturn) { } else if (hasCarriageReturn) {
execSync(`tmux send-keys -t ${shellescape(target)} Enter`, { execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
@@ -1540,7 +1636,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try { 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', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
@@ -1572,7 +1668,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try { 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', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
@@ -1600,7 +1696,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try { try {
execSync(`tmux pipe-pane -t ${shellescape(target)}`, { execSync(`${this.tmux()} pipe-pane -t ${shellescape(target)}`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
@@ -1616,7 +1712,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
getAttachArgs(muxName: string): string[] { getAttachArgs(muxName: string): string[] {
return ['attach-session', '-t', muxName]; return ['-L', this.tmuxSocket, 'attach-session', '-t', muxName];
} }
isAvailable(): boolean { isAvailable(): boolean {
+87 -65
View File
@@ -1128,22 +1128,40 @@ class CodemanApp {
const DIAGRAM_CHAR = /[─-╿▀-▟]/; const DIAGRAM_CHAR = /[─-╿▀-▟]/;
const tmpl = document.createElement('template'); const tmpl = document.createElement('template');
tmpl.innerHTML = html; 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) => { tmpl.content.querySelectorAll('pre > code').forEach((code) => {
if (!DIAGRAM_CHAR.test(code.textContent || '')) return;
const pre = code.parentElement; const pre = code.parentElement;
pre.classList.add('rv-diagram'); const isDiagram = DIAGRAM_CHAR.test(code.textContent || '');
const wrap = document.createElement('div'); 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'); const actions = document.createElement('div');
btn.className = 'rv-wrap-toggle'; actions.className = 'rv-code-actions';
btn.type = 'button';
btn.setAttribute('aria-label', 'Toggle line wrapping'); const copyBtn = document.createElement('button');
btn.setAttribute('title', 'Toggle line wrapping'); 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); pre.parentNode.insertBefore(wrap, pre);
wrap.appendChild(btn); wrap.appendChild(actions);
wrap.appendChild(pre); wrap.appendChild(pre);
}); });
return tmpl.innerHTML; return tmpl.innerHTML;
@@ -1162,7 +1180,23 @@ class CodemanApp {
_bindResponseViewerInteractions(body) { _bindResponseViewerInteractions(body) {
if (!body || body.dataset.rvBound === '1') return; if (!body || body.dataset.rvBound === '1') return;
body.dataset.rvBound = '1'; 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'); const btn = ev.target.closest('.rv-wrap-toggle');
if (!btn) return; if (!btn) return;
ev.preventDefault(); ev.preventDefault();
@@ -1175,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() { async toggleResponseViewer() {
const viewer = document.getElementById('responseViewer'); const viewer = document.getElementById('responseViewer');
const backdrop = document.getElementById('responseViewerBackdrop'); const backdrop = document.getElementById('responseViewerBackdrop');
@@ -1794,8 +1856,10 @@ class CodemanApp {
} }
}); });
// Restore tabs that were open before refresh but are no longer on the server // Server is source of truth for open sessions — don't resurrect stale tabs
this._restoreEndedTabs(); // 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) // Sync sessionOrder with current sessions (preserve order, add new, remove stale)
this.syncSessionOrder(); this.syncSessionOrder();
@@ -2117,8 +2181,7 @@ class CodemanApp {
const tallTabsEnabled = this._tallTabsEnabled ?? false; const tallTabsEnabled = this._tallTabsEnabled ?? false;
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name; 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}" 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}"${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)}"` : ''}>
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''} ${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
<span class="tab-status ${status}" aria-hidden="true"></span> <span class="tab-status ${status}" aria-hidden="true"></span>
<span class="tab-info"> <span class="tab-info">
@@ -2138,9 +2201,6 @@ class CodemanApp {
container.innerHTML = parts.join(''); container.innerHTML = parts.join('');
// Persist tab metadata for refresh recovery
this._saveTabMetadata();
// Set up drag-and-drop handlers for tab reordering // Set up drag-and-drop handlers for tab reordering
this.setupTabDragHandlers(); this.setupTabDragHandlers();
@@ -2240,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 // Set up drag-and-drop handlers on tab elements
setupTabDragHandlers() { setupTabDragHandlers() {
const container = this.$('sessionTabs'); const container = this.$('sessionTabs');
@@ -2546,16 +2579,9 @@ class CodemanApp {
// Check if this is a restored session that needs to be attached // Check if this is a restored session that needs to be attached
const session = this.sessions.get(sessionId); 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 // Track working directory for path normalization in Project Insights
this.currentSessionWorkingDir = session?.workingDir || null; 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 // Session has no PTY attached — either restored after server restart
// or detached for some other reason. Re-attach regardless of status. // or detached for some other reason. Re-attach regardless of status.
try { try {
@@ -2679,19 +2705,15 @@ class CodemanApp {
}); });
} }
// Fire-and-forget resize + Ctrl+L to force Ink redraw. // Fire-and-forget resize to nudge Ink via SIGWINCH on real size changes.
// Tailed buffers accumulate stale CUP-positioned Ink frames that overlap // Previously we also sent Ctrl+L (\x0c) here to force a full Ink redraw,
// in the viewport (e.g. duplicate "bypass permissions" bars). Ctrl+L // but Claude Code 2.x treats Ctrl+L as a two-step "clear conversation"
// triggers a full Ink redraw which overwrites all stale frame content. // command — if a page refresh or SSE reconnect ran selectSession twice
// sendResize may be a no-op if dimensions match, so Ctrl+L is essential. // within Claude's confirmation window, the second \x0c silently wiped the
this.sendResize(sessionId).then(() => { // conversation. Stale Ink frames in the tailed buffer are a cosmetic
if (selectGen !== this._selectGeneration) return; // annoyance that disappear on the user's next keypress; data loss is not
fetch(`/api/sessions/${sessionId}/input`, { // acceptable. Do NOT re-introduce Ctrl+L here.
method: 'POST', this.sendResize(sessionId);
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: '\x0c' })
}).catch(() => {});
});
// Defer secondary panel updates so they don't block the main thread // Defer secondary panel updates so they don't block the main thread
// after terminal content is already visible. // after terminal content is already visible.
+118 -1
View File
@@ -293,7 +293,6 @@ body {
} }
.session-tab .tab-status.error { background: var(--red); } .session-tab .tab-status.error { background: var(--red); }
.session-tab .tab-status.ended { background: var(--text-muted); opacity: 0.5; } .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 color coding - left border indicator */
.session-tab[data-color="red"] { border-left: 3px solid var(--session-red); } .session-tab[data-color="red"] { border-left: 3px solid var(--session-red); }
@@ -2370,6 +2369,56 @@ body {
color: var(--text); 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 { .welcome-hint {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.8rem; font-size: 0.8rem;
@@ -8174,6 +8223,74 @@ kbd {
content: '⤢'; 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, .rv-text ul, .rv-text ol,
.response-viewer-body > ul, .response-viewer-body > ol { .response-viewer-body > ul, .response-viewer-body > ol {
margin: 0.6em 0; margin: 0.6em 0;
+177 -32
View File
@@ -444,7 +444,6 @@ Object.assign(CodemanApp.prototype, {
if ( if (
activeResizeSession && activeResizeSession &&
activeResizeSession.mode !== 'shell' && activeResizeSession.mode !== 'shell' &&
!activeResizeSession._ended &&
this.terminal && this.terminal &&
this.isTerminalAtBottom() this.isTerminalAtBottom()
) { ) {
@@ -918,8 +917,15 @@ Object.assign(CodemanApp.prototype, {
.replace(/^\/Users\/[^/]+\//, '~/'); .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 = const size =
s.sizeBytes < 1024 s.sizeBytes < 1024
? `${s.sizeBytes}B` ? `${s.sizeBytes}B`
@@ -1001,6 +1007,21 @@ Object.assign(CodemanApp.prototype, {
detail.append(promptRow, pathRow, metaRow); 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) => { expandBtn.addEventListener('click', (ev) => {
ev.stopPropagation(); ev.stopPropagation();
const expanded = item.classList.toggle('expanded'); const expanded = item.classList.toggle('expanded');
@@ -1064,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) { async resumeHistorySession(sessionId, workingDir) {
// Close the run mode menu if open // Close the run mode menu if open
document.getElementById('runModeMenu')?.classList.remove('active'); document.getElementById('runModeMenu')?.classList.remove('active');
// Close folder history modal if open
this._closeFolderHistoryModal();
try { try {
this.terminal.clear(); this.terminal.clear();
this.terminal.writeln(`\x1b[1;32m Resuming conversation ${sessionId.slice(0, 8)}...\x1b[0m`); this.terminal.writeln(`\x1b[1;32m Resuming conversation ${sessionId.slice(0, 8)}...\x1b[0m`);
@@ -1548,7 +1704,8 @@ Object.assign(CodemanApp.prototype, {
/** /**
* Restore terminal size to match web UI dimensions. * Restore terminal size to match web UI dimensions.
* Use this after mobile screen attachment has squeezed the terminal. * 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() { async restoreTerminalSize() {
if (!this.activeSessionId) { if (!this.activeSessionId) {
@@ -1563,16 +1720,10 @@ Object.assign(CodemanApp.prototype, {
} }
try { 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); 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'); this.showToast(`Terminal restored to ${dims.cols}x${dims.rows}`, 'success');
} catch (err) { } catch (err) {
console.error('Failed to restore terminal size:', err); console.error('Failed to restore terminal size:', err);
@@ -1580,26 +1731,20 @@ Object.assign(CodemanApp.prototype, {
} }
}, },
// Send Ctrl+L to fix display for newly created sessions once Claude is running // Vestigial no-op: this method has no callers today. It's kept (not deleted)
sendPendingCtrlL(sessionId) { // as a documented guard so the Ctrl+L behavior below isn't reintroduced.
if (!this.pendingCtrlL || !this.pendingCtrlL.has(sessionId)) { //
return; // Originally this sent Ctrl+L (\x0c) when a flagged session first reached
} // idle/working to scrub mux-init junk from the screen. Two problems:
this.pendingCtrlL.delete(sessionId); // 1. `pendingCtrlL` was never actually populated anywhere (dead path).
// 2. Claude Code 2.x interprets Ctrl+L as a two-step "clear conversation"
// Only send if this is the active session // command — sending it from background flows risked nuking the user's
if (sessionId !== this.activeSessionId) { // conversation if it coincided with another Ctrl+L (e.g. from
return; // 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.
// Send resize + Ctrl+L to fix the display (with minimum dimension enforcement) sendPendingCtrlL(_sessionId) {
this.sendResize(sessionId).then(() => { // intentionally empty
fetch(`/api/sessions/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: '\x0c' }),
});
});
}, },
async copyTerminal() { 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)));
}
});
}
+144 -97
View File
@@ -652,11 +652,18 @@ export function registerSessionRoutes(
} }
try { 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) => { await new Promise<void>((resolve, reject) => {
execFile('tmux', ['send-keys', '-H', '-t', muxName, ...hex], { timeout: 5000 }, (err) => { execFile(
if (err) reject(err); 'tmux',
else resolve(); ['-L', ctx.mux.muxSocket, 'send-keys', '-H', '-t', muxName, ...hex],
}); { timeout: 5000 },
(err) => {
if (err) reject(err);
else resolve();
}
);
}); });
} catch (err) { } catch (err) {
console.error('[Server] send-key failed:', err); console.error('[Server] send-key failed:', err);
@@ -1324,42 +1331,76 @@ export function registerSessionRoutes(
* Claude CLI encodes both '/' and '_' as '-', so each '-' in the key could be * Claude CLI encodes both '/' and '_' as '-', so each '-' in the key could be
* any of: '/' (path separator), '_' (underscore), or '-' (literal dash). * any of: '/' (path separator), '_' (underscore), or '-' (literal dash).
* *
* Strategy: look-ahead matching. At each '-', try consuming multiple segments * Strategy: recursive backtracking with longest-match-first preference.
* joined by '_' or '-' to find an existing child directory, then recurse. * At each segment boundary, try joining as many segments as possible (with '_'
* E.g. for segments [AI, project, Mirror] inside /Workspace: * or '-') into a single existing directory name. If a shorter match leads to a
* try /Workspace/AI (no) -> /Workspace/AI_project (yes!) -> continue with [Mirror] * 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> { async function decodeProjectKey(projKey: string): Promise<string> {
const encoded = projKey.startsWith('-') ? projKey.slice(1) : projKey; const encoded = projKey.startsWith('-') ? projKey.slice(1) : projKey;
const segments = encoded.split('-'); const segments = encoded.split('-');
const isDir = async (p: string): Promise<boolean> => const isDirCache = new Map<string, boolean>();
fs const isDir = async (p: string): Promise<boolean> => {
const cached = isDirCache.get(p);
if (cached !== undefined) return cached;
const result = await fs
.stat(p) .stat(p)
.then((s) => s.isDirectory()) .then((s) => s.isDirectory())
.catch(() => false); .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 current = '';
let i = 0; let i = 0;
while (i < segments.length) { while (i < segments.length) {
// Try progressively longer child names by joining segments with '_' or '-'
let matched = false; let matched = false;
// Limit look-ahead to avoid excessive fs checks (max 4 segments per component)
const maxLook = Math.min(i + 4, segments.length); const maxLook = Math.min(i + 4, segments.length);
for (let end = i; end < maxLook; end++) { 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[] = []; const candidates: string[] = [];
if (end === i) { if (end === i) {
candidates.push(segments[i]); candidates.push(segments[i]);
} else { } else {
// Build with underscores between joined segments
candidates.push(segments.slice(i, end + 1).join('_')); candidates.push(segments.slice(i, end + 1).join('_'));
// Build with dashes (literal)
candidates.push(segments.slice(i, end + 1).join('-')); candidates.push(segments.slice(i, end + 1).join('-'));
} }
for (const child of candidates) { for (const child of candidates) {
const candidate = current + '/' + child; const candidate = current + '/' + child;
if (await isDir(candidate)) { if (await isDir(candidate)) {
@@ -1372,12 +1413,10 @@ export function registerSessionRoutes(
if (matched) break; if (matched) break;
} }
if (!matched) { if (!matched) {
// No directory match found — append as-is and move on
current = current + '/' + segments[i]; current = current + '/' + segments[i];
i++; i++;
} }
} }
const finalExists = await fs const finalExists = await fs
.access(current) .access(current)
.then(() => true) .then(() => true)
@@ -1416,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 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); 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 { try {
const projectDirs = await fs.readdir(projectsDir); const projectDirs = await fs.readdir(projectsDir);
for (const projDir of projectDirs) { for (const projDir of projectDirs) {
const projPath = join(projectsDir, projDir); const projPath = join(projectsDir, projDir);
const stat = await fs.stat(projPath).catch(() => null); const list = await scanProjectDir(projPath, projDir, headBuf);
if (!stat?.isDirectory()) continue; results.push(...list);
// 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,
});
}
} }
} catch { } catch {
// Projects dir may not exist // Projects dir may not exist
} }
// Sort by lastModified descending
results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()); results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
return { sessions: results.slice(0, 50) }; return { sessions: results.slice(0, 50) };
}); });
+4 -12
View File
@@ -89,8 +89,6 @@ const require = createRequire(import.meta.url);
const { version: APP_VERSION } = require('../../package.json'); const { version: APP_VERSION } = require('../../package.json');
import { import {
getErrorMessage, getErrorMessage,
ApiErrorCode,
createErrorResponse,
type PersistedRespawnConfig, type PersistedRespawnConfig,
type NiceConfig, type NiceConfig,
type ImageDetectedEvent, type ImageDetectedEvent,
@@ -101,6 +99,7 @@ import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.j
import { SseEvent } from './sse-events.js'; import { SseEvent } from './sse-events.js';
import type { ScheduledRun } from './ports/index.js'; import type { ScheduledRun } from './ports/index.js';
import { registerAuthMiddleware, registerSecurityHeaders } from './middleware/auth.js'; import { registerAuthMiddleware, registerSecurityHeaders } from './middleware/auth.js';
import { installRouteErrorHandler } from './route-error-handler.js';
import { import {
registerPushRoutes, registerPushRoutes,
registerTeamRoutes, registerTeamRoutes,
@@ -657,16 +656,9 @@ export class WebServer extends EventEmitter {
reply.code(updated ? 204 : 404).send(); reply.code(updated ? 204 : 404).send();
}); });
// Global error handler for structured errors thrown by findSessionOrFail // Global error handler for structured errors thrown by findSessionOrFail /
this.app.setErrorHandler((error, _req, reply) => { // parseBody. Shared with the route test harness so test behavior matches prod.
const statusCode = (error as { statusCode?: number }).statusCode ?? 500; installRouteErrorHandler(this.app);
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)));
}
});
// Crash diagnostics beacon — frontend POSTs breadcrumbs, GET to read them // Crash diagnostics beacon — frontend POSTs breadcrumbs, GET to read them
let _crashBreadcrumbs = ''; let _crashBreadcrumbs = '';
+1
View File
@@ -90,6 +90,7 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
// -- InfraPort -- // -- InfraPort --
mux: { mux: {
muxSocket: 'codeman',
createSession: vi.fn(), createSession: vi.fn(),
killSession: vi.fn(), killSession: vi.fn(),
listSessions: vi.fn(() => []), listSessions: vi.fn(() => []),
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -7,6 +7,7 @@
import Fastify, { type FastifyInstance } from 'fastify'; import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie'; import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
export interface RouteTestHarness { export interface RouteTestHarness {
app: FastifyInstance; app: FastifyInstance;
@@ -24,7 +25,7 @@ export interface RouteTestHarness {
export async function createRouteTestHarness( export async function createRouteTestHarness(
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
registerFn: (app: FastifyInstance, ctx: any) => void, registerFn: (app: FastifyInstance, ctx: any) => void,
ctxOptions?: { sessionId?: string }, ctxOptions?: { sessionId?: string }
): Promise<RouteTestHarness> { ): Promise<RouteTestHarness> {
const app = Fastify({ logger: false }); const app = Fastify({ logger: false });
@@ -34,6 +35,9 @@ export async function createRouteTestHarness(
const ctx = createMockRouteContext(ctxOptions); const ctx = createMockRouteContext(ctxOptions);
registerFn(app, ctx); 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(); await app.ready();
return { app, ctx }; return { app, ctx };
+11 -12
View File
@@ -104,9 +104,7 @@ describe('case-routes', () => {
}); });
it('includes hasClaudeMd flag', async () => { it('includes hasClaudeMd flag', async () => {
mockedReaddir.mockResolvedValue([ mockedReaddir.mockResolvedValue([{ name: 'case-with-md', isDirectory: () => true }] as never);
{ name: 'case-with-md', isDirectory: () => true },
] as never);
mockedExistsSync.mockReturnValue(true); mockedExistsSync.mockReturnValue(true);
const res = await harness.app.inject({ const res = await harness.app.inject({
@@ -120,9 +118,7 @@ describe('case-routes', () => {
it('includes linked cases from linked-cases.json', async () => { it('includes linked cases from linked-cases.json', async () => {
// CASES_DIR readdir returns one case // CASES_DIR readdir returns one case
mockedReaddir.mockResolvedValue([ mockedReaddir.mockResolvedValue([{ name: 'regular-case', isDirectory: () => true }] as never);
{ name: 'regular-case', isDirectory: () => true },
] as never);
// linked-cases.json is read second (after CASES_DIR readdir) // linked-cases.json is read second (after CASES_DIR readdir)
let readCallCount = 0; let readCallCount = 0;
mockedReadFile.mockImplementation(async () => { mockedReadFile.mockImplementation(async () => {
@@ -158,7 +154,7 @@ describe('case-routes', () => {
url: '/api/cases', url: '/api/cases',
payload: { name: 'invalid case name!!' }, payload: { name: 'invalid case name!!' },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -169,7 +165,7 @@ describe('case-routes', () => {
url: '/api/cases', url: '/api/cases',
payload: {}, payload: {},
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -180,7 +176,7 @@ describe('case-routes', () => {
url: '/api/cases', url: '/api/cases',
payload: { name: '../etc' }, payload: { name: '../etc' },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -230,7 +226,7 @@ describe('case-routes', () => {
url: '/api/cases/link', url: '/api/cases/link',
payload: { name: 'bad name!' }, payload: { name: 'bad name!' },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -241,7 +237,7 @@ describe('case-routes', () => {
url: '/api/cases/link', url: '/api/cases/link',
payload: {}, payload: {},
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -294,7 +290,10 @@ describe('case-routes', () => {
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(true); expect(body.success).toBe(true);
expect(body.data.case.name).toBe('linked-project'); 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', method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=../../etc/shadow`, 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 () => { 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(body.success).toBe(true);
expect(harness.ctx.broadcast).toHaveBeenCalledWith( expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'hook:stop', '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(res.statusCode).toBe(200);
expect(harness.ctx.sendPushNotifications).toHaveBeenCalledWith( expect(harness.ctx.sendPushNotifications).toHaveBeenCalledWith(
'hook:idle_prompt', '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, data: null,
}, },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -98,7 +98,7 @@ describe('hook-event-routes', () => {
event: 'stop', event: 'stop',
}, },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -180,10 +180,7 @@ describe('hook-event-routes', () => {
}, },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(mockTracker.recordHookEvent).toHaveBeenCalledWith( expect(mockTracker.recordHookEvent).toHaveBeenCalledWith('stop', expect.any(Object));
'stop',
expect.any(Object),
);
}); });
it('starts transcript watcher when transcript_path is provided', async () => { it('starts transcript watcher when transcript_path is provided', async () => {
@@ -199,7 +196,7 @@ describe('hook-event-routes', () => {
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(harness.ctx.startTranscriptWatcher).toHaveBeenCalledWith( expect(harness.ctx.startTranscriptWatcher).toHaveBeenCalledWith(
harness.ctx._sessionId, 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', url: '/api/orchestrator/start',
payload: { goal: '' }, payload: { goal: '' },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -114,7 +114,7 @@ describe('orchestrator-routes', () => {
url: '/api/orchestrator/start', url: '/api/orchestrator/start',
payload: {}, payload: {},
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -228,7 +228,7 @@ describe('orchestrator-routes', () => {
url: '/api/orchestrator/reject', url: '/api/orchestrator/reject',
payload: {}, payload: {},
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
+8 -10
View File
@@ -80,7 +80,7 @@ describe('plan-routes', () => {
url: '/api/cancel-plan-generation', url: '/api/cancel-plan-generation',
payload: { orchestratorId: 12345 }, // should be string payload: { orchestratorId: 12345 }, // should be string
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -129,7 +129,7 @@ describe('plan-routes', () => {
expect(body.data.status).toBe('completed'); expect(body.data.status).toBe('completed');
expect(harness.ctx.broadcast).toHaveBeenCalledWith( expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'session:planTaskUpdate', '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`, url: `/api/sessions/${harness.ctx._sessionId}/plan/task/task-1`,
payload: { status: 'invalid_status' }, payload: { status: 'invalid_status' },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -208,7 +208,7 @@ describe('plan-routes', () => {
expect(body.data.completedCount).toBe(5); expect(body.data.completedCount).toBe(5);
expect(harness.ctx.broadcast).toHaveBeenCalledWith( expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'session:planCheckpoint', '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 () => { it('rolls back to a previous version', async () => {
const mockPlan = [ const mockPlan = [{ id: 'task-1', content: 'Step 1', status: 'pending' }];
{ id: 'task-1', content: 'Step 1', status: 'pending' },
];
harness.ctx._session.ralphTracker = { harness.ctx._session.ralphTracker = {
rollbackToVersion: vi.fn(() => ({ success: true, plan: mockPlan })), rollbackToVersion: vi.fn(() => ({ success: true, plan: mockPlan })),
} as never; } as never;
@@ -296,7 +294,7 @@ describe('plan-routes', () => {
expect(body.data).toHaveLength(1); expect(body.data).toHaveLength(1);
expect(harness.ctx.broadcast).toHaveBeenCalledWith( expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'session:planRollback', '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(body.data.content).toBe('New task');
expect(harness.ctx.broadcast).toHaveBeenCalledWith( expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'session:planTaskAdded', '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`, url: `/api/sessions/${harness.ctx._sessionId}/plan/task`,
payload: { priority: 'P1' }, payload: { priority: 'P1' },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
+5 -5
View File
@@ -65,7 +65,7 @@ describe('push-routes', () => {
expect.objectContaining({ expect.objectContaining({
endpoint: 'https://push.example.com/send/abc123', endpoint: 'https://push.example.com/send/abc123',
keys: { p256dh: 'test-p256dh-key', auth: 'test-auth-key' }, keys: { p256dh: 'test-p256dh-key', auth: 'test-auth-key' },
}), })
); );
}); });
@@ -87,7 +87,7 @@ describe('push-routes', () => {
expect.objectContaining({ expect.objectContaining({
userAgent: 'TestBrowser/1.0', userAgent: 'TestBrowser/1.0',
pushPreferences: { 'session:idle': true, 'session:error': false }, pushPreferences: { 'session:idle': true, 'session:error': false },
}), })
); );
}); });
@@ -99,7 +99,7 @@ describe('push-routes', () => {
keys: { p256dh: 'test-p256dh', auth: 'test-auth' }, keys: { p256dh: 'test-p256dh', auth: 'test-auth' },
}, },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -112,7 +112,7 @@ describe('push-routes', () => {
endpoint: 'https://push.example.com/send/abc123', endpoint: 'https://push.example.com/send/abc123',
}, },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -160,7 +160,7 @@ describe('push-routes', () => {
url: '/api/push/subscribe/sub-123', url: '/api/push/subscribe/sub-123',
payload: {}, payload: {},
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
+35 -21
View File
@@ -66,7 +66,9 @@ describe('ralph-routes', () => {
}); });
it('enables ralph tracker', async () => { 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({ const res = await harness.app.inject({
method: 'POST', method: 'POST',
@@ -79,7 +81,9 @@ describe('ralph-routes', () => {
}); });
it('disables ralph tracker', async () => { 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({ const res = await harness.app.inject({
method: 'POST', method: 'POST',
@@ -99,7 +103,7 @@ describe('ralph-routes', () => {
}); });
expect((harness.ctx.mux as Record<string, unknown>).updateRalphEnabled).toHaveBeenCalledWith( expect((harness.ctx.mux as Record<string, unknown>).updateRalphEnabled).toHaveBeenCalledWith(
harness.ctx._sessionId, harness.ctx._sessionId,
true, true
); );
}); });
@@ -116,7 +120,9 @@ describe('ralph-routes', () => {
}); });
it('handles reset option', async () => { 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({ await harness.app.inject({
method: 'POST', method: 'POST',
@@ -127,7 +133,9 @@ describe('ralph-routes', () => {
}); });
it('configures completion phrase and max iterations', async () => { 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({ await harness.app.inject({
method: 'POST', method: 'POST',
@@ -138,7 +146,9 @@ describe('ralph-routes', () => {
}); });
it('sets max iterations independently', async () => { 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({ await harness.app.inject({
method: 'POST', method: 'POST',
@@ -154,7 +164,7 @@ describe('ralph-routes', () => {
url: '/api/sessions/nonexistent/ralph-config', url: '/api/sessions/nonexistent/ralph-config',
payload: { enabled: true }, payload: { enabled: true },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -178,13 +188,15 @@ describe('ralph-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`, url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`,
payload: { enabled: 'not-boolean' }, payload: { enabled: 'not-boolean' },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
it('handles disableAutoEnable flag', async () => { 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({ await harness.app.inject({
method: 'POST', method: 'POST',
@@ -208,7 +220,9 @@ describe('ralph-routes', () => {
describe('POST /api/sessions/:id/ralph-circuit-breaker/reset', () => { describe('POST /api/sessions/:id/ralph-circuit-breaker/reset', () => {
it('resets circuit breaker for valid session', async () => { 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({ const res = await harness.app.inject({
method: 'POST', method: 'POST',
@@ -225,7 +239,7 @@ describe('ralph-routes', () => {
method: 'POST', method: 'POST',
url: '/api/sessions/nonexistent/ralph-circuit-breaker/reset', url: '/api/sessions/nonexistent/ralph-circuit-breaker/reset',
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -253,7 +267,7 @@ describe('ralph-routes', () => {
method: 'GET', method: 'GET',
url: '/api/sessions/nonexistent/ralph-status', url: '/api/sessions/nonexistent/ralph-status',
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -279,7 +293,7 @@ describe('ralph-routes', () => {
method: 'GET', method: 'GET',
url: '/api/sessions/nonexistent/fix-plan', url: '/api/sessions/nonexistent/fix-plan',
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -315,7 +329,7 @@ describe('ralph-routes', () => {
url: '/api/sessions/nonexistent/fix-plan/import', url: '/api/sessions/nonexistent/fix-plan/import',
payload: { content: 'test' }, payload: { content: 'test' },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -326,7 +340,7 @@ describe('ralph-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/fix-plan/import`, url: `/api/sessions/${harness.ctx._sessionId}/fix-plan/import`,
payload: {}, payload: {},
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -341,7 +355,7 @@ describe('ralph-routes', () => {
url: '/api/sessions/nonexistent/ralph-prompt/write', url: '/api/sessions/nonexistent/ralph-prompt/write',
payload: { content: 'test prompt' }, payload: { content: 'test prompt' },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -365,7 +379,7 @@ describe('ralph-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/ralph-prompt/write`, url: `/api/sessions/${harness.ctx._sessionId}/ralph-prompt/write`,
payload: {}, payload: {},
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -379,7 +393,7 @@ describe('ralph-routes', () => {
method: 'POST', method: 'POST',
url: '/api/sessions/nonexistent/fix-plan/write', url: '/api/sessions/nonexistent/fix-plan/write',
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -405,7 +419,7 @@ describe('ralph-routes', () => {
method: 'POST', method: 'POST',
url: '/api/sessions/nonexistent/fix-plan/read', url: '/api/sessions/nonexistent/fix-plan/read',
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -432,7 +446,7 @@ describe('ralph-routes', () => {
url: '/api/ralph-loop/start', url: '/api/ralph-loop/start',
payload: {}, payload: {},
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -466,7 +480,7 @@ describe('ralph-routes', () => {
caseName: '../escape-path', caseName: '../escape-path',
}, },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
+2 -2
View File
@@ -172,7 +172,7 @@ describe('respawn-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/respawn/config`, url: `/api/sessions/${harness.ctx._sessionId}/respawn/config`,
payload: { idleTimeoutMs: 'not-a-number' }, payload: { idleTimeoutMs: 'not-a-number' },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -196,7 +196,7 @@ describe('respawn-routes', () => {
expect(mockController.updateConfig).toHaveBeenCalled(); expect(mockController.updateConfig).toHaveBeenCalled();
expect(harness.ctx.broadcast).toHaveBeenCalledWith( expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'respawn:configUpdated', '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: '', prompt: '',
}, },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -125,7 +125,7 @@ describe('scheduled-routes', () => {
url: '/api/scheduled', url: '/api/scheduled',
payload: {}, payload: {},
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -139,7 +139,7 @@ describe('scheduled-routes', () => {
workingDir: '/tmp/test;rm -rf /', workingDir: '/tmp/test;rm -rf /',
}, },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -171,11 +171,7 @@ describe('scheduled-routes', () => {
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(true); expect(body.success).toBe(true);
// Should default to 60 minutes // Should default to 60 minutes
expect(harness.ctx.startScheduledRun).toHaveBeenCalledWith( expect(harness.ctx.startScheduledRun).toHaveBeenCalledWith('test', expect.any(String), 60);
'test',
expect.any(String),
60,
);
}); });
}); });
+59 -13
View File
@@ -7,6 +7,14 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; 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'; import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
describe('session-routes', () => { describe('session-routes', () => {
@@ -20,6 +28,44 @@ describe('session-routes', () => {
await harness.app.close(); 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 ========== // ========== GET /api/sessions ==========
describe('GET /api/sessions', () => { describe('GET /api/sessions', () => {
@@ -57,7 +103,7 @@ describe('session-routes', () => {
method: 'GET', method: 'GET',
url: '/api/sessions/nonexistent', 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); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
expect(body.error).toBeDefined(); expect(body.error).toBeDefined();
@@ -128,7 +174,7 @@ describe('session-routes', () => {
url: '/api/sessions/nonexistent/name', url: '/api/sessions/nonexistent/name',
payload: { name: 'test' }, payload: { name: 'test' },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -181,7 +227,7 @@ describe('session-routes', () => {
url: '/api/sessions/nonexistent/input', url: '/api/sessions/nonexistent/input',
payload: { input: 'hello' }, payload: { input: 'hello' },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -192,7 +238,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/input`, url: `/api/sessions/${harness.ctx._sessionId}/input`,
payload: {}, payload: {},
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -219,7 +265,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/resize`, url: `/api/sessions/${harness.ctx._sessionId}/resize`,
payload: { cols: 501, rows: 24 }, payload: { cols: 501, rows: 24 },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -230,7 +276,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/resize`, url: `/api/sessions/${harness.ctx._sessionId}/resize`,
payload: { cols: 80, rows: 201 }, payload: { cols: 80, rows: 201 },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -241,7 +287,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/resize`, url: `/api/sessions/${harness.ctx._sessionId}/resize`,
payload: { cols: 0, rows: 24 }, payload: { cols: 0, rows: 24 },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -266,7 +312,7 @@ describe('session-routes', () => {
method: 'GET', method: 'GET',
url: '/api/sessions/nonexistent/terminal', url: '/api/sessions/nonexistent/terminal',
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -292,7 +338,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/run`, url: `/api/sessions/${harness.ctx._sessionId}/run`,
payload: { prompt: '' }, payload: { prompt: '' },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -303,7 +349,7 @@ describe('session-routes', () => {
url: '/api/sessions/nonexistent/run', url: '/api/sessions/nonexistent/run',
payload: { prompt: 'test' }, payload: { prompt: 'test' },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -340,7 +386,7 @@ describe('session-routes', () => {
method: 'POST', method: 'POST',
url: '/api/sessions/nonexistent/interactive', url: '/api/sessions/nonexistent/interactive',
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -404,7 +450,7 @@ describe('session-routes', () => {
method: 'GET', method: 'GET',
url: '/api/sessions/nonexistent/output', url: '/api/sessions/nonexistent/output',
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -540,7 +586,7 @@ describe('session-routes', () => {
resumeSessionId: 'not-a-uuid', resumeSessionId: 'not-a-uuid',
}, },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
+4 -4
View File
@@ -166,7 +166,7 @@ describe('system-routes', () => {
url: '/api/config', url: '/api/config',
payload: { unknownField: 'invalid' }, payload: { unknownField: 'invalid' },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -361,7 +361,7 @@ describe('system-routes', () => {
url: '/api/settings', url: '/api/settings',
payload: { unknownField: 'bad' }, payload: { unknownField: 'bad' },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -459,7 +459,7 @@ describe('system-routes', () => {
url: '/api/subagent-window-states', url: '/api/subagent-window-states',
payload: { minimized: { 'agent-1': 'not-a-boolean' } }, payload: { minimized: { 'agent-1': 'not-a-boolean' } },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -516,7 +516,7 @@ describe('system-routes', () => {
url: '/api/subagent-parents', url: '/api/subagent-parents',
payload: { 'agent-1': 123 }, payload: { 'agent-1': 123 },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
+17 -2
View File
@@ -77,9 +77,24 @@ describe('TmuxManager (unit)', () => {
}); });
describe('getAttachArgs', () => { describe('getAttachArgs', () => {
it('should return attach-session args', () => { it('should attach every session through the dedicated Codeman socket', () => {
const args = manager.getAttachArgs('codeman-abc12345'); 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 next frame — visible flicker and one lost repaint of scrollback.
* *
* The fix queries tmux for the actual window geometry first via * 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. * - Happy path: tmux reports valid geometry → those numbers are used.
* - Browser-resize-between-attaches: tmux reports a non-default size * - Browser-resize-between-attaches: tmux reports a non-default size
* (because a prior client resized it) → the helper picks that up. * (because a prior client resized it) → the helper picks that up.
@@ -49,7 +50,7 @@ beforeEach(() => {
describe('queryTmuxWindowSize — happy path', () => { describe('queryTmuxWindowSize — happy path', () => {
it('returns the geometry tmux reports', () => { it('returns the geometry tmux reports', () => {
execFileSync.mockReturnValue('200 50\n'); 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)', () => { 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 // 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. // its PTY at 220x60, not 120x40 — that's the whole point of #80.
execFileSync.mockReturnValue('220 60'); 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', () => { it('tolerates trailing whitespace and newlines in tmux output', () => {
execFileSync.mockReturnValue(' 180 45 \n\n'); 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; err.status = 1;
throw err; 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)', () => { it('falls back when tmux dies between query and parse (ETIMEDOUT / ENOENT)', () => {
@@ -85,63 +86,65 @@ describe('queryTmuxWindowSize — fallback paths', () => {
err.code = 'ETIMEDOUT'; err.code = 'ETIMEDOUT';
throw err; throw err;
}); });
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
}); });
it('falls back when tmux returns empty output', () => { it('falls back when tmux returns empty output', () => {
execFileSync.mockReturnValue(''); execFileSync.mockReturnValue('');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
}); });
it('falls back when tmux returns whitespace-only output', () => { it('falls back when tmux returns whitespace-only output', () => {
execFileSync.mockReturnValue(' \n'); 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', () => { it('falls back when tmux returns non-numeric output', () => {
execFileSync.mockReturnValue('not a size\n'); 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', () => { it('falls back when only one dimension is present', () => {
execFileSync.mockReturnValue('200\n'); 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)', () => { it('falls back when a dimension is zero (degenerate geometry)', () => {
// tmux reporting `0` would crash node-pty downstream — must not propagate. // tmux reporting `0` would crash node-pty downstream — must not propagate.
execFileSync.mockReturnValue('0 40\n'); execFileSync.mockReturnValue('0 40\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
execFileSync.mockReturnValue('120 0\n'); 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', () => { it('falls back when a dimension is negative', () => {
execFileSync.mockReturnValue('-200 -50\n'); 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', () => { it('falls back when tmux returns NaN-producing tokens', () => {
execFileSync.mockReturnValue('abc def\n'); execFileSync.mockReturnValue('abc def\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
}); });
}); });
describe('queryTmuxWindowSize — call shape', () => { 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'); execFileSync.mockReturnValue('120 40\n');
queryTmuxWindowSize('codeman-abc'); queryTmuxWindowSize('codeman-abc', 'codeman');
expect(execFileSync).toHaveBeenCalledTimes(1); expect(execFileSync).toHaveBeenCalledTimes(1);
const [bin, argv, opts] = execFileSync.mock.calls[0]; const [bin, argv, opts] = execFileSync.mock.calls[0];
expect(bin).toBe('tmux'); 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. // execFileSync — not execSync — so muxName is never substituted into a shell string.
expect(opts).toMatchObject({ encoding: 'utf8' }); expect(opts).toMatchObject({ encoding: 'utf8' });
}); });
it('uses a bounded timeout so a hung tmux server cannot block startup forever', () => { it('uses a bounded timeout so a hung tmux server cannot block startup forever', () => {
execFileSync.mockReturnValue('120 40\n'); execFileSync.mockReturnValue('120 40\n');
queryTmuxWindowSize('codeman-abc'); queryTmuxWindowSize('codeman-abc', 'codeman');
const [, , opts] = execFileSync.mock.calls[0]; const [, , opts] = execFileSync.mock.calls[0];
// Whatever the exact constant, the contract is: ≤5s so the user-visible // Whatever the exact constant, the contract is: ≤5s so the user-visible
// attach path can't hang on a stuck tmux server. // 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)', () => { it('passes a muxName that looks like a tmux flag as an argv element (no option injection)', () => {
execFileSync.mockReturnValue('120 40\n'); 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]; const [, argv] = execFileSync.mock.calls[0];
// The whole "name" lives in a single argv slot, so tmux interprets it as a // The whole "name" lives in a single argv slot (index 4, after `-L codeman
// target session name, not as additional flags. The `-t` flag preceding it // display -t`), so tmux interprets it as a target session name, not as
// pins it as the target argument. // additional flags. The `-t` flag preceding it pins it as the target.
expect(argv?.[2]).toBe('-x 1 -y 1; rm -rf'); expect(argv?.[4]).toBe('-x 1 -y 1; rm -rf');
expect((argv as string[]).indexOf('-x')).toBe(-1); expect((argv as string[]).indexOf('-x')).toBe(-1);
}); });
}); });