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
35 changed files with 949 additions and 436 deletions
+41
View File
@@ -1,5 +1,46 @@
# 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
+7 -7
View File
@@ -56,7 +56,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 0.6.11 (must match `package.json`)
**Version**: 0.7.1 (must match `package.json`)
## Project Overview
@@ -95,9 +95,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` env vars** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift
- **Dual-CLI prefix discipline** — Codeman supports both Claude Code and OpenCode (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*`) and the allowlist in `schemas.ts` enforces this. When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward both prefixes
- **Dual-CLI prefix discipline** — Codeman supports both Claude Code and OpenCode (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*`) and the allowlist in `schemas.ts` enforces this. When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward both prefixes. See `docs/opencode-integration.md` for the OpenCode resolver design
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. Real bugs caused: 0.6.4 (`durationMinutes` for ∞ respawn), and the same shape pattern hit `opusContext1mEnabled` in 0.6.3
- **`xterm-zerolag-input` is duplicated** — the local-echo overlay lives in BOTH `packages/xterm-zerolag-input/src/` (published to npm as a standalone library for external consumers — see README "Published Packages") AND inline inside `src/web/public/app.js` (runtime copy the web UI actually loads, since the page ships as plain JS without a bundler). Any change to overlay behavior MUST be applied to both, or dev and prod diverge — and a public API break in the package warrants a separate version bump for `xterm-zerolag-input` in the changeset. Always test on mobile after touching it.
- **`xterm-zerolag-input` is duplicated** — the local-echo overlay lives in BOTH `packages/xterm-zerolag-input/src/` (published to npm as a standalone library for external consumers — see README "Published Packages") AND inline inside `src/web/public/app.js` (runtime copy the web UI actually loads, since the page ships as plain JS without a bundler). Any change to overlay behavior MUST be applied to both, or dev and prod diverge — and a public API break in the package warrants a separate version bump for `xterm-zerolag-input` in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`.
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
@@ -109,7 +109,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|--------|-----------|-------|
| **Entry** | `src/index.ts`, `src/cli.ts` | |
| **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts`, `src/session-lifecycle-log.ts`, `src/session-task-cache.ts` | |
| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` | |
| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` ★ | |
| **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first |
| **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Read `docs/ralph-wiggum-guide.md` first |
| **Orchestrator** | `src/orchestrator-loop.ts`, `src/orchestrator-planner.ts`, `src/orchestrator-verifier.ts` | Read `docs/orchestrator-loop-architecture.md` first |
@@ -120,7 +120,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (15 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
| **Frontend** | `src/web/public/app.js` (~2.9K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 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 |
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
@@ -181,7 +181,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### API Routes
~128 handlers across 15 route files in `src/web/routes/`: system (36), sessions (27), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (5), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
~130 handlers across 15 route files in `src/web/routes/`: system (36), sessions (28), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (5), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
## Adding Features
@@ -216,7 +216,7 @@ Raw `npx vitest` skips `config/vitest.config.ts`; always use `npm test --` or pa
**Ports**: Pick unique ports manually. Search `const PORT =` before adding new tests.
**Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject()` in `test/routes/`. **Mobile tests**: Playwright suite in `test/mobile/` (135 device profiles).
**Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject({ method, url, payload })` in `test/routes/` — no live port needed. **Mobile tests**: Playwright suite in `test/mobile/` (135 device profiles).
## Debugging
@@ -1,3 +1,9 @@
> **⚠️ ARCHIVED 2026-05-21 — superseded, kept for history.**
> The headline items here were verified resolved: the P0 `{WORKING_DIR}` placeholder
> is now replaced (`plan-orchestrator.ts:431`), and the "~66 dead functions in app.js"
> are gone (app.js was modularized 15K→3K LOC). A fresh `npm run knip` sweep on
> 2026-05-21 found only a handful of unused test helpers. Do not treat this as a live TODO.
# Codebase Cleanup Findings
Compiled from parallel analysis of the entire Codeman codebase by 3 research agents (2026-02-19).
@@ -1,3 +1,9 @@
> **⚠️ ARCHIVED 2026-05-21 — superseded, kept for history.**
> The "Critical" structural items here are done: `server.ts` 6,736→2,065 LOC,
> `app.js` 15,196→3,083 LOC, `types.ts` 1,443→12 LOC (now a barrel → `src/types/`).
> The phase plans that executed this work are in `docs/archive/phase*-plan.md`.
> Do not treat this as a live TODO; see CLAUDE.md for current architecture.
# Code Structure & Quality Findings
**Date**: 2026-02-28
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "0.6.11",
"version": "0.7.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "0.6.11",
"version": "0.7.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "0.6.11",
"version": "0.7.1",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+10 -3
View File
@@ -32,6 +32,13 @@ set -e
CODEMAN_STATE="$HOME/.codeman/state.json"
CODEMAN_SESSIONS="$HOME/.codeman/mux-sessions.json"
# Dedicated tmux socket all Codeman sessions live on. MUST match
# DEFAULT_CODEMAN_TMUX_SOCKET / CODEMAN_TMUX_SOCKET in src/tmux-manager.ts —
# otherwise list-sessions would enumerate the user's default tmux server
# (missing the real Codeman sessions, surfacing unrelated ones).
CODEMAN_TMUX_SOCKET="${CODEMAN_TMUX_SOCKET:-codeman}"
TMUX_CMD=(tmux -L "$CODEMAN_TMUX_SOCKET")
# iPhone 17 Pro portrait width (conservative)
MAX_WIDTH=44
@@ -286,7 +293,7 @@ parse_sessions() {
# Get PID from tmux
local pid
pid=$(tmux display-message -t "$session_name" -p '#{pane_pid}' 2>/dev/null || echo "0")
pid=$("${TMUX_CMD[@]}" display-message -t "$session_name" -p '#{pane_pid}' 2>/dev/null || echo "0")
SESSION_PIDS+=("$pid")
MUX_NAMES+=("$session_name")
@@ -314,7 +321,7 @@ parse_sessions() {
fi
i=$((i + 1))
done < <(tmux list-sessions 2>/dev/null || true)
done < <("${TMUX_CMD[@]}" list-sessions 2>/dev/null || true)
}
# ============================================================================
@@ -462,7 +469,7 @@ attach_session() {
echo -e "${D}(Ctrl+B D to detach)${R}"
sleep 0.3
tmux attach-session -t "$mux_name"
"${TMUX_CMD[@]}" attach-session -t "$mux_name"
return 0
}
+17 -8
View File
@@ -20,6 +20,13 @@ REVERSE='\033[7m'
# Use the same path as codeman (src/tmux-manager.ts)
SESSIONS_FILE="${HOME}/.codeman/mux-sessions.json"
# Dedicated tmux socket all Codeman sessions live on. MUST match
# DEFAULT_CODEMAN_TMUX_SOCKET / CODEMAN_TMUX_SOCKET in src/tmux-manager.ts —
# otherwise this script would talk to the user's default tmux server and never
# see (or could mis-target) Codeman's sessions.
CODEMAN_TMUX_SOCKET="${CODEMAN_TMUX_SOCKET:-codeman}"
TMUX_CMD=(tmux -L "$CODEMAN_TMUX_SOCKET")
# Cached data
CACHED_JSON=""
@@ -92,7 +99,7 @@ declare -A ALIVE_CACHE
check_alive() {
local mux_name=$1
if [[ -z "${ALIVE_CACHE[$mux_name]+x}" ]]; then
if tmux has-session -t "$mux_name" 2>/dev/null; then
if "${TMUX_CMD[@]}" has-session -t "$mux_name" 2>/dev/null; then
ALIVE_CACHE[$mux_name]=1
else
ALIVE_CACHE[$mux_name]=0
@@ -111,8 +118,10 @@ kill_session() {
local mux_name=$(get_session_field $idx "muxName")
local pid=$(get_session_field $idx "pid")
# SAFETY: Never kill own tmux session
local current_session=$(tmux display-message -p '#{session_name}' 2>/dev/null || echo "")
# SAFETY: Never kill own tmux session. Queried on the Codeman socket; if run
# from a session on a different socket this returns empty (no match), which
# is fine — you can't be "inside" a Codeman-socket session you didn't attach to.
local current_session=$("${TMUX_CMD[@]}" display-message -p '#{session_name}' 2>/dev/null || echo "")
if [[ -n "$current_session" && "$mux_name" == "$current_session" ]]; then
echo -e "${RED}BLOCKED: Cannot kill own tmux session: $mux_name${NC}"
return 1
@@ -120,7 +129,7 @@ kill_session() {
pkill -TERM -P $pid 2>/dev/null
kill -TERM -$pid 2>/dev/null
tmux kill-session -t "$mux_name" 2>/dev/null
"${TMUX_CMD[@]}" kill-session -t "$mux_name" 2>/dev/null
kill -KILL $pid 2>/dev/null
# Remove from JSON
@@ -345,7 +354,7 @@ interactive_menu() {
clear
echo -e "${CYAN}Attaching... (Ctrl+B D to detach)${NC}"
sleep 0.3
tmux attach-session -t "$mux_name"
"${TMUX_CMD[@]}" attach-session -t "$mux_name"
tput civis
need_full_redraw=1
force_refresh
@@ -474,7 +483,7 @@ main() {
[[ -z "${2:-}" ]] && { echo "Usage: $0 attach <N>"; exit 1; }
force_refresh
local mux_name=$(get_session_field $(($2-1)) "muxName")
check_alive "$mux_name" && tmux attach-session -t "$mux_name" || echo "Session dead or not found"
check_alive "$mux_name" && "${TMUX_CMD[@]}" attach-session -t "$mux_name" || echo "Session dead or not found"
;;
kill)
[[ -z "${2:-}" ]] && { echo "Usage: $0 kill <N|N,M|N-M>"; exit 1; }
@@ -492,8 +501,8 @@ main() {
;;
kill-all)
force_refresh
# SAFETY: Never kill own tmux session
local current_session=$(tmux display-message -p '#{session_name}' 2>/dev/null || echo "")
# SAFETY: Never kill own tmux session (queried on the Codeman socket)
local current_session=$("${TMUX_CMD[@]}" display-message -p '#{session_name}' 2>/dev/null || echo "")
local killed=0
for ((i=CACHED_COUNT-1; i>=0; i--)); do
local mux_name=$(get_session_field $i "muxName")
+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;
/** 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 selection indicator arrow (❯ or >) pointing to one of the options
- Text asking for approval like "Would you like to proceed?" or "Ready to implement?"
- The prompt appears at the BOTTOM of the output (most recent content)
- A selection indicator arrow (❯ or >) pointing to one of the options (the default)
- The menu 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)
- A completed response with no selection menu
- An AskUserQuestion/elicitation dialog (different format, free-text input)
- A completed response with no selection menu visible
- 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
- 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):
---
+3
View File
@@ -98,6 +98,9 @@ export interface TerminalMultiplexer extends EventEmitter {
/** Which backend this instance uses */
readonly backend: 'tmux';
/** The dedicated tmux socket name all sessions live on (e.g. "codeman"). */
readonly muxSocket: string;
// ========== Lifecycle ==========
/**
+33 -25
View File
@@ -513,7 +513,7 @@ const DEFAULT_CONFIG: RespawnConfig = {
sendInit: true, // send /init after /clear
completionConfirmMs: 10000, // 10 seconds of silence after completion message
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
aiIdleCheckEnabled: true, // use AI to confirm idle state
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 */
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) ==========
/** Whether a Stop hook was received (definitive idle signal from Claude Code) */
@@ -1369,7 +1366,12 @@ export class RespawnController extends EventEmitter {
this.clearWorkingPatternWindow();
this.workingDetected = false;
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)}..."`);
// In watching state, start completion confirmation timer
@@ -1417,7 +1419,6 @@ export class RespawnController extends EventEmitter {
this.workingDetected = true;
this.promptDetected = false;
this.elicitationDetected = false; // Clear on new work cycle
this.resetHookState(); // Clear hook signals on new work
this.lastWorkingPatternTime = now;
@@ -2222,11 +2223,11 @@ export class RespawnController extends EventEmitter {
* @returns True if auto-accept should proceed to the AI confirmation stage
*/
private canAutoAccept(): boolean {
// Only auto-accept in watching state (not during a respawn cycle)
if (this._state !== 'watching') return false;
// Don't auto-accept if a completion message was detected (normal idle handles it)
if (this.completionMessageTime !== null) return false;
// Allow auto-accept from 'watching' AND 'confirming_idle'. The latter is reached
// 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
// 'watching' before sending Enter. Reject any other state (respawn cycle, etc.).
if (this._state !== 'watching' && this._state !== 'confirming_idle') return false;
// Don't auto-accept if disabled
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)
if (!this.hasReceivedOutput) return false;
// Don't auto-accept if an elicitation dialog (AskUserQuestion) was detected
if (this.elicitationDetected) {
this.log('Skipping auto-accept: elicitation dialog detected (AskUserQuestion)');
return false;
}
// Note: completionMessageTime and elicitationDetected used to block here, but both
// legitimately co-occur with selection menus (Claude Code emits "Worked for X"
// before plan approvals, and AskUserQuestion fires the elicitation hook). The
// 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)) {
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;
}
@@ -2308,8 +2309,10 @@ export class RespawnController extends EventEmitter {
}
if (result.verdict === 'PLAN_MODE') {
// Don't send Enter if state changed (e.g., AI idle check started or respawn cycle began)
if (this._state !== 'watching') {
// Don't send Enter if state moved into a respawn cycle while the check ran.
// '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`);
return;
}
@@ -2368,13 +2371,18 @@ export class RespawnController extends EventEmitter {
/**
* Signal that an elicitation dialog (AskUserQuestion) was detected via hook.
* This prevents auto-accept from firing, since the user needs to make a selection.
* The flag is cleared when working patterns are detected (new turn starts).
* Used as a positive hint that a numbered selection menu is about to render —
* 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 {
this.elicitationDetected = true;
this.cancelAutoAcceptTimer();
this.log('Elicitation dialog signaled - auto-accept blocked until next work cycle');
this.log('Elicitation dialog signaled - auto-accept will trigger if pre-filter matches');
if (this.config.autoAcceptPrompts && (this._state === 'watching' || this._state === 'confirming_idle')) {
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
* differentiate "tmux unreachable" from "size 120x40".
*
* `socket` MUST be the same dedicated socket the session lives on (`mux.muxSocket`);
* querying the default server would never find the session and silently fall back.
*
* Argv form (execFileSync, not execSync) keeps `muxName` out of any shell so
* a hostile session name can't inject options.
*/
export function queryTmuxWindowSize(muxName: string): { cols: number; rows: number } {
export function queryTmuxWindowSize(muxName: string, socket: string): { cols: number; rows: number } {
try {
const sizeStr = execFileSync('tmux', ['display', '-t', muxName, '-p', '#{window_width} #{window_height}'], {
timeout: TMUX_DISPLAY_TIMEOUT_MS,
encoding: 'utf8',
}).trim();
const sizeStr = execFileSync(
'tmux',
['-L', socket, 'display', '-t', muxName, '-p', '#{window_width} #{window_height}'],
{
timeout: TMUX_DISPLAY_TIMEOUT_MS,
encoding: 'utf8',
}
).trim();
const [w, h] = sizeStr.split(' ').map(Number);
if (w > 0 && h > 0) {
return { cols: w, rows: h };
@@ -978,7 +985,7 @@ export class Session extends EventEmitter {
// Attach to the mux session via PTY
// Query existing tmux window size so re-attach matches (avoids flicker from 120x40 default)
const { cols: ptyCols, rows: ptyRows } = queryTmuxWindowSize(this._muxSession!.muxName);
const { cols: ptyCols, rows: ptyRows } = queryTmuxWindowSize(this._muxSession!.muxName, mux.muxSocket);
try {
this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), {
name: 'xterm-256color',
+143 -47
View File
@@ -71,6 +71,9 @@ const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
/** Default stats collection interval (2 seconds) */
const DEFAULT_STATS_INTERVAL_MS = 2000;
/** Claude Code native macOS recommendation for avoiding low nofile startup failures. */
export const CLAUDE_CODE_NOFILE_LIMIT = 2147483646;
/**
* SAFETY: Test mode detection.
* When running under vitest (VITEST env var is set automatically),
@@ -98,6 +101,12 @@ const LEGACY_MUX_NAME_PATTERN = /^claudeman-[a-f0-9-]+$/;
/** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */
const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/;
/** Dedicated tmux socket for new Codeman-owned sessions. */
const DEFAULT_CODEMAN_TMUX_SOCKET = 'codeman';
/** Regex to validate tmux socket names passed to `tmux -L`. */
const SAFE_TMUX_SOCKET_PATTERN = /^[a-zA-Z0-9_.-]+$/;
/**
* Separator used in `tmux list-panes -F` output between session name and pid.
*
@@ -114,6 +123,19 @@ const PANE_LIST_SEP = '|';
/** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneList}. */
const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}`;
/**
* 构建 pane 启动前的 nofile 修复命令。
*
* macOS launchd/tmux 组合有时会让 pane 继承 256 的 soft nofile;
* 新版 Claude Code 会在这种环境下直接退出。这里避免使用 $变量
* 或命令替换,因为 fullCmd 目前经由双引号 bash -c 传递,外层
* shell 会提前展开它们。
*/
export function buildNofileLimitCommand(targetLimit = CLAUDE_CODE_NOFILE_LIMIT): string {
const safeLimit = Number.isSafeInteger(targetLimit) && targetLimit > 0 ? targetLimit : CLAUDE_CODE_NOFILE_LIMIT;
return `ulimit -Sn ${safeLimit} 2>/dev/null || ulimit -n ${safeLimit} 2>/dev/null || true`;
}
/**
* Parse the output of `tmux list-panes -a -F '#{session_name}|#{pane_pid}'`
* into a Map of session-name → pane pid. Exported for unit testing.
@@ -161,6 +183,31 @@ function isValidPath(path: string): boolean {
return SAFE_PATH_PATTERN.test(path);
}
// ===========================================================================
// Single-socket architecture: ALL Codeman sessions live on one dedicated tmux
// socket (`tmux -L codeman`), isolated from the user's default tmux server.
// The socket name is a process-wide constant (env-overridable for test/multi-
// instance isolation) — it is never stored per-session, so it cannot drift.
// ===========================================================================
/**
* Resolve the process-wide Codeman tmux socket name. Always returns a valid
* name: `CODEMAN_TMUX_SOCKET` env override if safe, else the built-in default.
*/
function resolveConfiguredTmuxSocket(): string {
const raw = process.env.CODEMAN_TMUX_SOCKET ?? DEFAULT_CODEMAN_TMUX_SOCKET;
if (!SAFE_TMUX_SOCKET_PATTERN.test(raw)) {
console.warn(`[TmuxManager] Ignoring invalid CODEMAN_TMUX_SOCKET: ${JSON.stringify(raw)}`);
return DEFAULT_CODEMAN_TMUX_SOCKET;
}
return raw;
}
/** Build the `tmux -L <socket>` command prefix. Socket name is shell-escaped. */
function tmuxCommand(socket: string): string {
return `tmux -L ${shellescape(socket)}`;
}
/**
* Build Claude CLI permission flags for the tmux command string.
* Validates allowedTools to prevent command injection.
@@ -248,7 +295,7 @@ function buildSpawnCommand(options: {
* Set sensitive environment variables on a tmux session via setenv.
* These are inherited by panes but not visible in ps output or tmux history.
*/
function setOpenCodeEnvVars(muxName: string): void {
function setOpenCodeEnvVars(tmuxCmd: string, muxName: string): void {
const sensitiveVars = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY'];
for (const key of sensitiveVars) {
const val = process.env[key];
@@ -256,7 +303,7 @@ function setOpenCodeEnvVars(muxName: string): void {
// Shell-escape: wrap in single quotes, escape any inner single quotes
const escaped = val.replace(/'/g, "'\\''");
try {
execSync(`tmux setenv -t '${muxName}' ${key} '${escaped}'`, {
execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
@@ -272,7 +319,7 @@ function setOpenCodeEnvVars(muxName: string): void {
* Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv.
* Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON.
*/
function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): void {
function setOpenCodeConfigContent(tmuxCmd: string, muxName: string, config?: OpenCodeConfig): void {
if (!config) return;
let jsonContent: string | undefined;
@@ -303,7 +350,7 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi
if (jsonContent) {
const escaped = jsonContent.replace(/'/g, "'\\''");
try {
execSync(`tmux setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, {
execSync(`${tmuxCmd} setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, {
encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
@@ -336,6 +383,7 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi
export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
readonly backend = 'tmux' as const;
private sessions: Map<string, MuxSession> = new Map();
private readonly tmuxSocket = resolveConfiguredTmuxSocket();
private statsInterval: NodeJS.Timeout | null = null;
private mouseSyncInterval: NodeJS.Timeout | null = null;
/** Track last-known pane count per session to avoid unnecessary tmux set-option calls */
@@ -351,6 +399,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
/** The dedicated tmux socket all Codeman sessions live on (see {@link TerminalMultiplexer.muxSocket}). */
get muxSocket(): string {
return this.tmuxSocket;
}
private tmux(): string {
return tmuxCommand(this.tmuxSocket);
}
// Load saved sessions from disk (NEVER called in test mode)
private loadSessions(): void {
if (IS_TEST_MODE) return;
@@ -360,8 +417,40 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const content = readFileSync(MUX_SESSIONS_FILE, 'utf-8');
const data = JSON.parse(content);
if (Array.isArray(data)) {
// Dedup by muxName: one live tmux session must map to exactly one
// tracked entry. A per-session socket-tag mismatch could historically
// let the same session be tracked twice — once under its real UUID and
// once under a "restored-<id>" placeholder — surfacing as duplicate tabs.
// Single-socket unification removed that failure mode; this pass stays
// to clean any stale duplicates already on disk. Keep the real (UUID)
// entry and drop placeholder twins.
let dropped = 0;
const keptByMuxName = new Map<string, string>(); // muxName -> kept sessionId
for (const session of data) {
// Strip the obsolete per-session tmuxSocket tag (now a process-wide
// constant). Left in place it would be written back by saveSessions()
// and linger on disk as a zombie field forever.
delete (session as { tmuxSocket?: unknown }).tmuxSocket;
const muxName: string | undefined = session.muxName;
const priorId = muxName ? keptByMuxName.get(muxName) : undefined;
if (priorId) {
const incomingIsPlaceholder = String(session.sessionId).startsWith('restored-');
const priorIsPlaceholder = priorId.startsWith('restored-');
// Drop the incoming unless it's the real twin of a placeholder we kept.
if (incomingIsPlaceholder || !priorIsPlaceholder) {
dropped++;
continue;
}
this.sessions.delete(priorId);
dropped++;
}
this.sessions.set(session.sessionId, session);
if (muxName) keptByMuxName.set(muxName, session.sessionId);
}
// Persist the cleaned list so the stale duplicates don't reload.
if (dropped > 0) {
console.log(`[TmuxManager] Dropped ${dropped} duplicate mux session record(s) on load`);
this.saveSessions();
}
}
}
@@ -438,7 +527,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
continue;
}
try {
execSync(`tmux setenv -t ${shellescape(muxName)} ${key} ${shellescape(value)}`, {
execSync(`${this.tmux()} setenv -t ${shellescape(muxName)} ${key} ${shellescape(value)}`, {
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
});
@@ -471,8 +560,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* (not visible in ps output or tmux history, inherited by panes).
*/
private _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig): void {
setOpenCodeEnvVars(muxName);
setOpenCodeConfigContent(muxName, openCodeConfig);
const tmuxCmd = this.tmux();
setOpenCodeEnvVars(tmuxCmd, muxName);
setOpenCodeConfigContent(tmuxCmd, muxName, openCodeConfig);
}
/**
@@ -545,7 +635,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
try {
// Build the full command to run inside tmux
const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`;
const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
// Create tmux session in three steps to handle cold-start (no server running)
// and avoid the race where the command exits before remain-on-exit is set:
@@ -556,7 +646,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// (Production uses systemd which has a clean env, but dev/test may be nested.)
const cleanEnv = { ...process.env };
delete cleanEnv.TMUX;
execSync(`tmux new-session -ds "${muxName}" -c "${workingDir}"`, {
execSync(`${this.tmux()} new-session -ds "${muxName}" -c "${workingDir}"`, {
cwd: workingDir,
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
@@ -565,7 +655,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Set remain-on-exit now that the server is running — must be before respawn-pane
try {
execSync(`tmux set-option -t "${muxName}" remain-on-exit on`, {
execSync(`${this.tmux()} set-option -t "${muxName}" remain-on-exit on`, {
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
});
@@ -584,7 +674,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
this.applyEnvOverrides(muxName, envOverrides);
// Replace the shell with the actual command (no echo in terminal)
execSync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
execSync(`${this.tmux()} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
});
@@ -598,20 +688,20 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// It gets enabled dynamically when panes are split (agent teams).
const configPromises: Promise<void>[] = [
// Disable tmux status bar — Codeman's web UI provides session info
execAsync(`tmux set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS })
execAsync(`${this.tmux()} set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS })
.then(() => {})
.catch(() => {
/* Non-critical — session still works with status bar */
}),
// Override global remain-on-exit with session-level setting
execAsync(`tmux set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS })
execAsync(`${this.tmux()} set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS })
.then(() => {})
.catch(() => {
/* Already set globally as fallback */
}),
// Raise tmux scrollback from its 2000-line default so re-attach preserves
// more context. Matches the xterm-side default in constants.js.
execAsync(`tmux set-option -t "${muxName}" history-limit 50000`, { timeout: EXEC_TIMEOUT_MS })
execAsync(`${this.tmux()} set-option -t "${muxName}" history-limit 50000`, { timeout: EXEC_TIMEOUT_MS })
.then(() => {})
.catch(() => {
/* Non-critical — falls back to tmux default */
@@ -621,7 +711,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Enable 24-bit true color passthrough — server-wide, set once per lifetime
if (!this.trueColorConfigured) {
configPromises.push(
execAsync(`tmux set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS })
execAsync(`${this.tmux()} set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS })
.then(() => {
this.trueColorConfigured = true;
})
@@ -678,7 +768,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
try {
const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_pid}'`, {
const output = execSync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_pid}'`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
@@ -704,7 +794,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (IS_TEST_MODE) return false;
if (!isValidMuxName(muxName)) return false;
try {
const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_dead}'`, {
const output = execSync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_dead}'`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
@@ -754,7 +844,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
});
const config = niceConfig || DEFAULT_NICE_CONFIG;
const cmd = wrapWithNice(baseCmd, config);
const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`;
const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
try {
// For OpenCode: set sensitive env vars via tmux setenv before respawn
@@ -765,7 +855,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Re-apply user env overrides before respawn so the new shell inherits them.
this.applyEnvOverrides(muxName, envOverrides);
await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
await execAsync(`${this.tmux()} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
timeout: EXEC_TIMEOUT_MS,
});
// Wait for the respawned process to start
@@ -783,7 +873,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (IS_TEST_MODE) return false;
try {
execSync(`tmux has-session -t "${muxName}" 2>/dev/null`, {
execSync(`${this.tmux()} has-session -t "${muxName}" 2>/dev/null`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
@@ -923,7 +1013,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Strategy 3: Kill tmux session by name
try {
execSync(`tmux kill-session -t "${session.muxName}" 2>/dev/null`, {
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
timeout: EXEC_TIMEOUT_MS,
});
} catch {
@@ -988,26 +1078,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const dead: string[] = [];
const discovered: string[] = [];
// Batch: single tmux call to get all session names + pane PIDs (replaces N per-session subprocess calls)
let activeSessions = new Map<string, number>();
// Single batched query against the one socket Codeman owns. With a single
// socket a session's location is a constant, so there is no per-session
// socket tag to reconcile and no cross-socket ambiguity that could mark a
// live session dead (the root cause of vanished/duplicate tabs).
let active: Map<string, number>;
try {
const output = execSync(`tmux list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, {
const output = execSync(`${this.tmux()} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
activeSessions = parsePaneList(output);
active = parsePaneList(output);
} catch (err) {
console.error('[TmuxManager] Failed to list tmux panes:', err);
active = new Map();
}
// Check known sessions against the batch result (O(1) map lookup instead of subprocess per session)
// Check tracked sessions against the live pane list.
for (const [sessionId, session] of this.sessions) {
const pid = activeSessions.get(session.muxName);
const pid = active.get(session.muxName);
if (pid !== undefined) {
alive.push(sessionId);
if (pid !== session.pid) {
session.pid = pid;
}
if (pid !== session.pid) session.pid = pid;
} else {
dead.push(sessionId);
this.sessions.delete(sessionId);
@@ -1015,13 +1107,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
// Discover unknown codeman/claudeman sessions from the same batch result
// Discover untracked codeman/claudeman sessions on our socket. Dedup by
// muxName (globally unique) so a name we already track never spawns a
// second "Restored:" entry.
const knownMuxNames = new Set<string>();
for (const session of this.sessions.values()) {
knownMuxNames.add(session.muxName);
}
for (const [sessionName, pid] of activeSessions) {
for (const [sessionName, pid] of active) {
if (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-')) continue;
if (knownMuxNames.has(sessionName)) continue;
@@ -1038,6 +1132,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
name: `Restored: ${sessionName}`,
};
this.sessions.set(sessionId, session);
knownMuxNames.add(sessionName);
discovered.push(sessionId);
console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`);
}
@@ -1345,21 +1440,21 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Ink (Claude CLI's terminal framework) needs them split — sending both in a
// single tmux invocation (via \;) causes Ink to interpret Enter as a newline
// character in the input buffer rather than as form submission.
await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
timeout: EXEC_TIMEOUT_MS,
});
await new Promise((resolve) => setTimeout(resolve, 50));
await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, {
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
timeout: EXEC_TIMEOUT_MS,
});
} else if (textPart) {
// Text only, no Enter
await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
timeout: EXEC_TIMEOUT_MS,
});
} else if (hasCarriageReturn) {
// Enter only
await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, {
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
timeout: EXEC_TIMEOUT_MS,
});
}
@@ -1386,7 +1481,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
try {
execSync(`tmux set-option -t "${muxName}" mouse on`, {
execSync(`${this.tmux()} set-option -t "${muxName}" mouse on`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
@@ -1410,7 +1505,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
try {
execSync(`tmux set-option -t "${muxName}" mouse off`, {
execSync(`${this.tmux()} set-option -t "${muxName}" mouse off`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
@@ -1450,7 +1545,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
try {
const output = execSync(
`tmux list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`,
`${this.tmux()} list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
).trim();
@@ -1489,27 +1584,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Build target: sessionName.paneId (e.g., "codeman-abc12345.%1")
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
const tmux = this.tmux();
try {
const hasCarriageReturn = input.includes('\r');
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
if (textPart && hasCarriageReturn) {
execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
execSync(`tmux send-keys -t ${shellescape(target)} Enter`, {
execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
} else if (textPart) {
execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
} else if (hasCarriageReturn) {
execSync(`tmux send-keys -t ${shellescape(target)} Enter`, {
execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
@@ -1540,7 +1636,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try {
return execSync(`tmux capture-pane -p -e -t ${shellescape(target)} -S -5000`, {
return execSync(`${this.tmux()} capture-pane -p -e -t ${shellescape(target)} -S -5000`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
@@ -1572,7 +1668,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try {
execSync(`tmux pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, {
execSync(`${this.tmux()} pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
@@ -1600,7 +1696,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try {
execSync(`tmux pipe-pane -t ${shellescape(target)}`, {
execSync(`${this.tmux()} pipe-pane -t ${shellescape(target)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
@@ -1616,7 +1712,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
getAttachArgs(muxName: string): string[] {
return ['attach-session', '-t', muxName];
return ['-L', this.tmuxSocket, 'attach-session', '-t', muxName];
}
isAvailable(): boolean {
+81 -23
View File
@@ -1128,22 +1128,40 @@ class CodemanApp {
const DIAGRAM_CHAR = /[─-╿▀-▟]/;
const tmpl = document.createElement('template');
tmpl.innerHTML = html;
// Every fenced code block gets a positioned wrapper with an action
// toolbar pinned to its top-right corner. The toolbar lives OUTSIDE the
// <pre> scroll container so its buttons stay put during horizontal
// scroll. All blocks get a one-click copy button; ASCII diagrams keep
// the additional line-wrap toggle.
tmpl.content.querySelectorAll('pre > code').forEach((code) => {
if (!DIAGRAM_CHAR.test(code.textContent || '')) return;
const pre = code.parentElement;
pre.classList.add('rv-diagram');
const isDiagram = DIAGRAM_CHAR.test(code.textContent || '');
const wrap = document.createElement('div');
wrap.className = 'rv-diagram-wrap';
wrap.className = isDiagram ? 'rv-code-wrap rv-diagram-wrap' : 'rv-code-wrap';
const btn = document.createElement('button');
btn.className = 'rv-wrap-toggle';
btn.type = 'button';
btn.setAttribute('aria-label', 'Toggle line wrapping');
btn.setAttribute('title', 'Toggle line wrapping');
const actions = document.createElement('div');
actions.className = 'rv-code-actions';
const copyBtn = document.createElement('button');
copyBtn.className = 'rv-copy-btn';
copyBtn.type = 'button';
copyBtn.setAttribute('aria-label', 'Copy code');
copyBtn.setAttribute('title', 'Copy code');
actions.appendChild(copyBtn);
if (isDiagram) {
pre.classList.add('rv-diagram');
const toggle = document.createElement('button');
toggle.className = 'rv-wrap-toggle';
toggle.type = 'button';
toggle.setAttribute('aria-label', 'Toggle line wrapping');
toggle.setAttribute('title', 'Toggle line wrapping');
actions.appendChild(toggle);
}
pre.parentNode.insertBefore(wrap, pre);
wrap.appendChild(btn);
wrap.appendChild(actions);
wrap.appendChild(pre);
});
return tmpl.innerHTML;
@@ -1162,7 +1180,23 @@ class CodemanApp {
_bindResponseViewerInteractions(body) {
if (!body || body.dataset.rvBound === '1') return;
body.dataset.rvBound = '1';
body.addEventListener('click', (ev) => {
body.addEventListener('click', async (ev) => {
// One-click copy: lift the raw source from the sibling <pre><code>.
const copyBtn = ev.target.closest('.rv-copy-btn');
if (copyBtn) {
ev.preventDefault();
ev.stopPropagation();
const code = copyBtn.closest('.rv-code-wrap')?.querySelector('pre code');
const ok = code ? await this._copyText(code.textContent || '') : false;
copyBtn.classList.remove('rv-copied', 'rv-copy-failed');
copyBtn.classList.add(ok ? 'rv-copied' : 'rv-copy-failed');
clearTimeout(copyBtn._resetTimer);
copyBtn._resetTimer = setTimeout(() => {
copyBtn.classList.remove('rv-copied', 'rv-copy-failed');
}, 1500);
return;
}
const btn = ev.target.closest('.rv-wrap-toggle');
if (!btn) return;
ev.preventDefault();
@@ -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() {
const viewer = document.getElementById('responseViewer');
const backdrop = document.getElementById('responseViewerBackdrop');
@@ -2643,19 +2705,15 @@ class CodemanApp {
});
}
// Fire-and-forget resize + Ctrl+L to force Ink redraw.
// Tailed buffers accumulate stale CUP-positioned Ink frames that overlap
// in the viewport (e.g. duplicate "bypass permissions" bars). Ctrl+L
// triggers a full Ink redraw which overwrites all stale frame content.
// sendResize may be a no-op if dimensions match, so Ctrl+L is essential.
this.sendResize(sessionId).then(() => {
if (selectGen !== this._selectGeneration) return;
fetch(`/api/sessions/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: '\x0c' })
}).catch(() => {});
});
// Fire-and-forget resize to nudge Ink via SIGWINCH on real size changes.
// Previously we also sent Ctrl+L (\x0c) here to force a full Ink redraw,
// but Claude Code 2.x treats Ctrl+L as a two-step "clear conversation"
// command — if a page refresh or SSE reconnect ran selectSession twice
// within Claude's confirmation window, the second \x0c silently wiped the
// conversation. Stale Ink frames in the tailed buffer are a cosmetic
// annoyance that disappear on the user's next keypress; data loss is not
// acceptable. Do NOT re-introduce Ctrl+L here.
this.sendResize(sessionId);
// Defer secondary panel updates so they don't block the main thread
// after terminal content is already visible.
+68
View File
@@ -8223,6 +8223,74 @@ kbd {
content: '⤢';
}
/* ── Code block one-click copy ──────────────────────────────────────────────
Every fenced code block is wrapped in .rv-code-wrap with an action toolbar
pinned to its top-right. Regular blocks get the relative positioning here;
ASCII diagrams already get it from .rv-diagram-wrap (don't clobber its
centering margins). */
.rv-text .rv-code-wrap:not(.rv-diagram-wrap),
.response-viewer-body .rv-code-wrap:not(.rv-diagram-wrap) {
position: relative;
margin: 1em 0;
}
.rv-text .rv-code-wrap:not(.rv-diagram-wrap) > pre,
.response-viewer-body .rv-code-wrap:not(.rv-diagram-wrap) > pre {
margin: 0;
padding-right: 44px; /* reserve room for the copy button */
}
/* Diagrams carry two buttons (copy + wrap toggle) — widen the reserve. */
.rv-text .rv-code-wrap.rv-diagram-wrap > pre.rv-diagram,
.response-viewer-body .rv-code-wrap.rv-diagram-wrap > pre.rv-diagram {
padding-right: 76px;
}
.rv-code-actions {
position: absolute;
top: 6px;
right: 6px;
display: inline-flex;
gap: 4px;
z-index: 2;
}
/* Inside the flex toolbar the wrap toggle flows normally — drop its own pin. */
.rv-code-actions .rv-wrap-toggle {
position: static;
top: auto;
right: auto;
}
.rv-copy-btn {
width: 28px;
height: 24px;
padding: 0;
border: 1px solid #2f2f45;
border-radius: 5px;
background: rgba(20, 20, 32, 0.92);
color: #8b8b97;
font-size: 13px;
line-height: 1;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: color 0.15s, border-color 0.15s;
}
.rv-copy-btn:hover,
.rv-copy-btn:active {
color: #e0e0ec;
border-color: #4a4a65;
}
.rv-copy-btn::before { content: '\2398'; } /* ⎘ — matches file-preview copy */
.rv-copy-btn.rv-copied { color: #9ece6a; border-color: #3a5a3a; }
.rv-copy-btn.rv-copied::before { content: '\2713'; } /* ✓ */
.rv-copy-btn.rv-copy-failed { color: #f7768e; border-color: #5a3a3a; }
.rv-copy-btn.rv-copy-failed::before { content: '\2715'; } /* ✕ */
.rv-text ul, .rv-text ol,
.response-viewer-body > ul, .response-viewer-body > ol {
margin: 0.6em 0;
+18 -29
View File
@@ -1704,7 +1704,8 @@ Object.assign(CodemanApp.prototype, {
/**
* Restore terminal size to match web UI dimensions.
* Use this after mobile screen attachment has squeezed the terminal.
* Sends resize to PTY and Ctrl+L to trigger Claude to redraw.
* Sends only resize — SIGWINCH triggers Ink redraw on real dimension changes.
* Ctrl+L is NOT sent here (Claude Code 2.x treats it as "clear conversation").
*/
async restoreTerminalSize() {
if (!this.activeSessionId) {
@@ -1719,16 +1720,10 @@ Object.assign(CodemanApp.prototype, {
}
try {
// Send resize to restore proper dimensions (with minimum enforcement)
// Send resize to restore proper dimensions (with minimum enforcement).
// The PTY's SIGWINCH on real dim change is enough for Ink to redraw.
await this.sendResize(this.activeSessionId);
// Send Ctrl+L to trigger Claude to redraw at new size
await fetch(`/api/sessions/${this.activeSessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: '\x0c' }),
});
this.showToast(`Terminal restored to ${dims.cols}x${dims.rows}`, 'success');
} catch (err) {
console.error('Failed to restore terminal size:', err);
@@ -1736,26 +1731,20 @@ Object.assign(CodemanApp.prototype, {
}
},
// Send Ctrl+L to fix display for newly created sessions once Claude is running
sendPendingCtrlL(sessionId) {
if (!this.pendingCtrlL || !this.pendingCtrlL.has(sessionId)) {
return;
}
this.pendingCtrlL.delete(sessionId);
// Only send if this is the active session
if (sessionId !== this.activeSessionId) {
return;
}
// Send resize + Ctrl+L to fix the display (with minimum dimension enforcement)
this.sendResize(sessionId).then(() => {
fetch(`/api/sessions/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: '\x0c' }),
});
});
// Vestigial no-op: this method has no callers today. It's kept (not deleted)
// as a documented guard so the Ctrl+L behavior below isn't reintroduced.
//
// Originally this sent Ctrl+L (\x0c) when a flagged session first reached
// idle/working to scrub mux-init junk from the screen. Two problems:
// 1. `pendingCtrlL` was never actually populated anywhere (dead path).
// 2. Claude Code 2.x interprets Ctrl+L as a two-step "clear conversation"
// command — sending it from background flows risked nuking the user's
// conversation if it coincided with another Ctrl+L (e.g. from
// selectSession on page reload).
// If a per-session display-fix is ever needed again, do it via sendResize
// or an Ink-safe control sequence, NOT \x0c.
sendPendingCtrlL(_sessionId) {
// intentionally empty
},
async copyTerminal() {
+29
View File
@@ -0,0 +1,29 @@
/**
* @fileoverview Shared Fastify error handler for Codeman's HTTP routes.
*
* Route helpers (`findSessionOrFail`, `parseBody` in route-helpers.ts) throw
* structured errors carrying `{ statusCode, body }`. This handler renders them
* into the proper HTTP response. It is installed by BOTH the production server
* and the route test harness so test behavior matches production exactly —
* without it, thrown errors fall through to Fastify's default handler and the
* response body is `{statusCode,error,message}` instead of `{success:false,...}`.
*/
import type { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../types.js';
/**
* Install the global error handler that renders structured route errors.
* Errors thrown with a `statusCode`/`body` (see route-helpers.ts) are sent
* verbatim at that status; anything else becomes a 500 OPERATION_FAILED response.
*/
export function installRouteErrorHandler(app: FastifyInstance): void {
app.setErrorHandler((error, _req, reply) => {
const statusCode = (error as { statusCode?: number }).statusCode ?? 500;
const body = (error as { body?: unknown }).body;
if (body) {
reply.code(statusCode).send(body);
} else {
reply.code(statusCode).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(error)));
}
});
}
+11 -4
View File
@@ -652,11 +652,18 @@ export function registerSessionRoutes(
}
try {
// Route through the dedicated Codeman socket — bare `tmux` would target the
// user's default server and never find this session (same #80 regression class).
await new Promise<void>((resolve, reject) => {
execFile('tmux', ['send-keys', '-H', '-t', muxName, ...hex], { timeout: 5000 }, (err) => {
if (err) reject(err);
else resolve();
});
execFile(
'tmux',
['-L', ctx.mux.muxSocket, 'send-keys', '-H', '-t', muxName, ...hex],
{ timeout: 5000 },
(err) => {
if (err) reject(err);
else resolve();
}
);
});
} catch (err) {
console.error('[Server] send-key failed:', err);
+4 -12
View File
@@ -89,8 +89,6 @@ const require = createRequire(import.meta.url);
const { version: APP_VERSION } = require('../../package.json');
import {
getErrorMessage,
ApiErrorCode,
createErrorResponse,
type PersistedRespawnConfig,
type NiceConfig,
type ImageDetectedEvent,
@@ -101,6 +99,7 @@ import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.j
import { SseEvent } from './sse-events.js';
import type { ScheduledRun } from './ports/index.js';
import { registerAuthMiddleware, registerSecurityHeaders } from './middleware/auth.js';
import { installRouteErrorHandler } from './route-error-handler.js';
import {
registerPushRoutes,
registerTeamRoutes,
@@ -657,16 +656,9 @@ export class WebServer extends EventEmitter {
reply.code(updated ? 204 : 404).send();
});
// Global error handler for structured errors thrown by findSessionOrFail
this.app.setErrorHandler((error, _req, reply) => {
const statusCode = (error as { statusCode?: number }).statusCode ?? 500;
const body = (error as { body?: unknown }).body;
if (body) {
reply.code(statusCode).send(body);
} else {
reply.code(statusCode).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(error)));
}
});
// Global error handler for structured errors thrown by findSessionOrFail /
// parseBody. Shared with the route test harness so test behavior matches prod.
installRouteErrorHandler(this.app);
// Crash diagnostics beacon — frontend POSTs breadcrumbs, GET to read them
let _crashBreadcrumbs = '';
+1
View File
@@ -90,6 +90,7 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
// -- InfraPort --
mux: {
muxSocket: 'codeman',
createSession: vi.fn(),
killSession: vi.fn(),
listSessions: vi.fn(() => []),
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 fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
export interface RouteTestHarness {
app: FastifyInstance;
@@ -24,7 +25,7 @@ export interface RouteTestHarness {
export async function createRouteTestHarness(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
registerFn: (app: FastifyInstance, ctx: any) => void,
ctxOptions?: { sessionId?: string },
ctxOptions?: { sessionId?: string }
): Promise<RouteTestHarness> {
const app = Fastify({ logger: false });
@@ -34,6 +35,9 @@ export async function createRouteTestHarness(
const ctx = createMockRouteContext(ctxOptions);
registerFn(app, ctx);
// Mirror production: structured errors thrown by route helpers (findSessionOrFail,
// parseBody) are rendered to {success:false} bodies at the right status.
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
+11 -12
View File
@@ -104,9 +104,7 @@ describe('case-routes', () => {
});
it('includes hasClaudeMd flag', async () => {
mockedReaddir.mockResolvedValue([
{ name: 'case-with-md', isDirectory: () => true },
] as never);
mockedReaddir.mockResolvedValue([{ name: 'case-with-md', isDirectory: () => true }] as never);
mockedExistsSync.mockReturnValue(true);
const res = await harness.app.inject({
@@ -120,9 +118,7 @@ describe('case-routes', () => {
it('includes linked cases from linked-cases.json', async () => {
// CASES_DIR readdir returns one case
mockedReaddir.mockResolvedValue([
{ name: 'regular-case', isDirectory: () => true },
] as never);
mockedReaddir.mockResolvedValue([{ name: 'regular-case', isDirectory: () => true }] as never);
// linked-cases.json is read second (after CASES_DIR readdir)
let readCallCount = 0;
mockedReadFile.mockImplementation(async () => {
@@ -158,7 +154,7 @@ describe('case-routes', () => {
url: '/api/cases',
payload: { name: 'invalid case name!!' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -169,7 +165,7 @@ describe('case-routes', () => {
url: '/api/cases',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -180,7 +176,7 @@ describe('case-routes', () => {
url: '/api/cases',
payload: { name: '../etc' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -230,7 +226,7 @@ describe('case-routes', () => {
url: '/api/cases/link',
payload: { name: 'bad name!' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -241,7 +237,7 @@ describe('case-routes', () => {
url: '/api/cases/link',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -294,7 +290,10 @@ describe('case-routes', () => {
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.case.name).toBe('linked-project');
expect(harness.ctx.broadcast).toHaveBeenCalledWith('case:linked', expect.objectContaining({ name: 'linked-project' }));
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'case:linked',
expect.objectContaining({ name: 'linked-project' })
);
});
});
+2 -1
View File
@@ -312,7 +312,8 @@ describe('file-routes', () => {
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=../../etc/shadow`,
});
expect(res.statusCode).toBe(400);
// Path traversal returns 404 ("File not found") to avoid revealing the target exists.
expect(res.statusCode).toBe(404);
});
it('rejects overly large raw files', async () => {
+6 -9
View File
@@ -38,7 +38,7 @@ describe('hook-event-routes', () => {
expect(body.success).toBe(true);
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'hook:stop',
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
expect.objectContaining({ sessionId: harness.ctx._sessionId })
);
});
@@ -55,7 +55,7 @@ describe('hook-event-routes', () => {
expect(res.statusCode).toBe(200);
expect(harness.ctx.sendPushNotifications).toHaveBeenCalledWith(
'hook:idle_prompt',
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
expect.objectContaining({ sessionId: harness.ctx._sessionId })
);
});
@@ -85,7 +85,7 @@ describe('hook-event-routes', () => {
data: null,
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -98,7 +98,7 @@ describe('hook-event-routes', () => {
event: 'stop',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -180,10 +180,7 @@ describe('hook-event-routes', () => {
},
});
expect(res.statusCode).toBe(200);
expect(mockTracker.recordHookEvent).toHaveBeenCalledWith(
'stop',
expect.any(Object),
);
expect(mockTracker.recordHookEvent).toHaveBeenCalledWith('stop', expect.any(Object));
});
it('starts transcript watcher when transcript_path is provided', async () => {
@@ -199,7 +196,7 @@ describe('hook-event-routes', () => {
expect(res.statusCode).toBe(200);
expect(harness.ctx.startTranscriptWatcher).toHaveBeenCalledWith(
harness.ctx._sessionId,
'/home/user/.claude/transcript.jsonl',
'/home/user/.claude/transcript.jsonl'
);
});
+3 -3
View File
@@ -103,7 +103,7 @@ describe('orchestrator-routes', () => {
url: '/api/orchestrator/start',
payload: { goal: '' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -114,7 +114,7 @@ describe('orchestrator-routes', () => {
url: '/api/orchestrator/start',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -228,7 +228,7 @@ describe('orchestrator-routes', () => {
url: '/api/orchestrator/reject',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+8 -10
View File
@@ -80,7 +80,7 @@ describe('plan-routes', () => {
url: '/api/cancel-plan-generation',
payload: { orchestratorId: 12345 }, // should be string
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -129,7 +129,7 @@ describe('plan-routes', () => {
expect(body.data.status).toBe('completed');
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'session:planTaskUpdate',
expect.objectContaining({ sessionId: harness.ctx._sessionId, taskId: 'task-1' }),
expect.objectContaining({ sessionId: harness.ctx._sessionId, taskId: 'task-1' })
);
});
@@ -159,7 +159,7 @@ describe('plan-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/plan/task/task-1`,
payload: { status: 'invalid_status' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -208,7 +208,7 @@ describe('plan-routes', () => {
expect(body.data.completedCount).toBe(5);
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'session:planCheckpoint',
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
expect.objectContaining({ sessionId: harness.ctx._sessionId })
);
});
});
@@ -279,9 +279,7 @@ describe('plan-routes', () => {
});
it('rolls back to a previous version', async () => {
const mockPlan = [
{ id: 'task-1', content: 'Step 1', status: 'pending' },
];
const mockPlan = [{ id: 'task-1', content: 'Step 1', status: 'pending' }];
harness.ctx._session.ralphTracker = {
rollbackToVersion: vi.fn(() => ({ success: true, plan: mockPlan })),
} as never;
@@ -296,7 +294,7 @@ describe('plan-routes', () => {
expect(body.data).toHaveLength(1);
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'session:planRollback',
expect.objectContaining({ sessionId: harness.ctx._sessionId, version: 1 }),
expect.objectContaining({ sessionId: harness.ctx._sessionId, version: 1 })
);
});
@@ -358,7 +356,7 @@ describe('plan-routes', () => {
expect(body.data.content).toBe('New task');
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'session:planTaskAdded',
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
expect.objectContaining({ sessionId: harness.ctx._sessionId })
);
});
@@ -372,7 +370,7 @@ describe('plan-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/plan/task`,
payload: { priority: 'P1' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+5 -5
View File
@@ -65,7 +65,7 @@ describe('push-routes', () => {
expect.objectContaining({
endpoint: 'https://push.example.com/send/abc123',
keys: { p256dh: 'test-p256dh-key', auth: 'test-auth-key' },
}),
})
);
});
@@ -87,7 +87,7 @@ describe('push-routes', () => {
expect.objectContaining({
userAgent: 'TestBrowser/1.0',
pushPreferences: { 'session:idle': true, 'session:error': false },
}),
})
);
});
@@ -99,7 +99,7 @@ describe('push-routes', () => {
keys: { p256dh: 'test-p256dh', auth: 'test-auth' },
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -112,7 +112,7 @@ describe('push-routes', () => {
endpoint: 'https://push.example.com/send/abc123',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -160,7 +160,7 @@ describe('push-routes', () => {
url: '/api/push/subscribe/sub-123',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+35 -21
View File
@@ -66,7 +66,9 @@ describe('ralph-routes', () => {
});
it('enables ralph tracker', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
const res = await harness.app.inject({
method: 'POST',
@@ -79,7 +81,9 @@ describe('ralph-routes', () => {
});
it('disables ralph tracker', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
const res = await harness.app.inject({
method: 'POST',
@@ -99,7 +103,7 @@ describe('ralph-routes', () => {
});
expect((harness.ctx.mux as Record<string, unknown>).updateRalphEnabled).toHaveBeenCalledWith(
harness.ctx._sessionId,
true,
true
);
});
@@ -116,7 +120,9 @@ describe('ralph-routes', () => {
});
it('handles reset option', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
await harness.app.inject({
method: 'POST',
@@ -127,7 +133,9 @@ describe('ralph-routes', () => {
});
it('configures completion phrase and max iterations', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
await harness.app.inject({
method: 'POST',
@@ -138,7 +146,9 @@ describe('ralph-routes', () => {
});
it('sets max iterations independently', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
await harness.app.inject({
method: 'POST',
@@ -154,7 +164,7 @@ describe('ralph-routes', () => {
url: '/api/sessions/nonexistent/ralph-config',
payload: { enabled: true },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -178,13 +188,15 @@ describe('ralph-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`,
payload: { enabled: 'not-boolean' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('handles disableAutoEnable flag', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
await harness.app.inject({
method: 'POST',
@@ -208,7 +220,9 @@ describe('ralph-routes', () => {
describe('POST /api/sessions/:id/ralph-circuit-breaker/reset', () => {
it('resets circuit breaker for valid session', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
const res = await harness.app.inject({
method: 'POST',
@@ -225,7 +239,7 @@ describe('ralph-routes', () => {
method: 'POST',
url: '/api/sessions/nonexistent/ralph-circuit-breaker/reset',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -253,7 +267,7 @@ describe('ralph-routes', () => {
method: 'GET',
url: '/api/sessions/nonexistent/ralph-status',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -279,7 +293,7 @@ describe('ralph-routes', () => {
method: 'GET',
url: '/api/sessions/nonexistent/fix-plan',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -315,7 +329,7 @@ describe('ralph-routes', () => {
url: '/api/sessions/nonexistent/fix-plan/import',
payload: { content: 'test' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -326,7 +340,7 @@ describe('ralph-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/fix-plan/import`,
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -341,7 +355,7 @@ describe('ralph-routes', () => {
url: '/api/sessions/nonexistent/ralph-prompt/write',
payload: { content: 'test prompt' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -365,7 +379,7 @@ describe('ralph-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/ralph-prompt/write`,
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -379,7 +393,7 @@ describe('ralph-routes', () => {
method: 'POST',
url: '/api/sessions/nonexistent/fix-plan/write',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -405,7 +419,7 @@ describe('ralph-routes', () => {
method: 'POST',
url: '/api/sessions/nonexistent/fix-plan/read',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -432,7 +446,7 @@ describe('ralph-routes', () => {
url: '/api/ralph-loop/start',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -466,7 +480,7 @@ describe('ralph-routes', () => {
caseName: '../escape-path',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+2 -2
View File
@@ -172,7 +172,7 @@ describe('respawn-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/respawn/config`,
payload: { idleTimeoutMs: 'not-a-number' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -196,7 +196,7 @@ describe('respawn-routes', () => {
expect(mockController.updateConfig).toHaveBeenCalled();
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'respawn:configUpdated',
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
expect.objectContaining({ sessionId: harness.ctx._sessionId })
);
});
+4 -8
View File
@@ -114,7 +114,7 @@ describe('scheduled-routes', () => {
prompt: '',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -125,7 +125,7 @@ describe('scheduled-routes', () => {
url: '/api/scheduled',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -139,7 +139,7 @@ describe('scheduled-routes', () => {
workingDir: '/tmp/test;rm -rf /',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -171,11 +171,7 @@ describe('scheduled-routes', () => {
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
// Should default to 60 minutes
expect(harness.ctx.startScheduledRun).toHaveBeenCalledWith(
'test',
expect.any(String),
60,
);
expect(harness.ctx.startScheduledRun).toHaveBeenCalledWith('test', expect.any(String), 60);
});
});
+59 -13
View File
@@ -7,6 +7,14 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
// Mock execFile so the send-key route's `tmux` invocation is observable (not run for real).
const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() }));
vi.mock('node:child_process', async (orig) => {
const actual = await orig<typeof import('node:child_process')>();
return { ...actual, execFile };
});
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
describe('session-routes', () => {
@@ -20,6 +28,44 @@ describe('session-routes', () => {
await harness.app.close();
});
// ========== POST /api/sessions/:id/send-key ==========
describe('POST /api/sessions/:id/send-key', () => {
it('routes tmux send-keys through the dedicated Codeman socket (-L)', async () => {
// Regression guard: bare `tmux` would hit the user's default server and never
// find a session that lives only on the Codeman socket (#80 regression class).
execFile.mockReset();
execFile.mockImplementation((_bin: string, _argv: string[], _opts: unknown, cb: (e: Error | null) => void) =>
cb(null)
);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/send-key',
payload: { key: 'S-Enter' },
});
expect(res.statusCode).toBe(200);
expect(execFile).toHaveBeenCalledTimes(1);
const [bin, argv] = execFile.mock.calls[0];
expect(bin).toBe('tmux');
expect((argv as string[]).slice(0, 2)).toEqual(['-L', 'codeman']);
expect(argv).toContain('send-keys');
expect(argv).toContain('-H');
});
it('rejects keys outside the hex allowlist without invoking tmux', async () => {
execFile.mockReset();
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/send-key',
payload: { key: 'rm -rf' },
});
expect(JSON.parse(res.body).success).toBe(false);
expect(execFile).not.toHaveBeenCalled();
});
});
// ========== GET /api/sessions ==========
describe('GET /api/sessions', () => {
@@ -57,7 +103,7 @@ describe('session-routes', () => {
method: 'GET',
url: '/api/sessions/nonexistent',
});
expect(res.statusCode).toBe(200); // returns error in body, not HTTP 404
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.error).toBeDefined();
@@ -128,7 +174,7 @@ describe('session-routes', () => {
url: '/api/sessions/nonexistent/name',
payload: { name: 'test' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -181,7 +227,7 @@ describe('session-routes', () => {
url: '/api/sessions/nonexistent/input',
payload: { input: 'hello' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -192,7 +238,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/input`,
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -219,7 +265,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
payload: { cols: 501, rows: 24 },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -230,7 +276,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
payload: { cols: 80, rows: 201 },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -241,7 +287,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
payload: { cols: 0, rows: 24 },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -266,7 +312,7 @@ describe('session-routes', () => {
method: 'GET',
url: '/api/sessions/nonexistent/terminal',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -292,7 +338,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/run`,
payload: { prompt: '' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -303,7 +349,7 @@ describe('session-routes', () => {
url: '/api/sessions/nonexistent/run',
payload: { prompt: 'test' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -340,7 +386,7 @@ describe('session-routes', () => {
method: 'POST',
url: '/api/sessions/nonexistent/interactive',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -404,7 +450,7 @@ describe('session-routes', () => {
method: 'GET',
url: '/api/sessions/nonexistent/output',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -540,7 +586,7 @@ describe('session-routes', () => {
resumeSessionId: 'not-a-uuid',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+4 -4
View File
@@ -166,7 +166,7 @@ describe('system-routes', () => {
url: '/api/config',
payload: { unknownField: 'invalid' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -361,7 +361,7 @@ describe('system-routes', () => {
url: '/api/settings',
payload: { unknownField: 'bad' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -459,7 +459,7 @@ describe('system-routes', () => {
url: '/api/subagent-window-states',
payload: { minimized: { 'agent-1': 'not-a-boolean' } },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -516,7 +516,7 @@ describe('system-routes', () => {
url: '/api/subagent-parents',
payload: { 'agent-1': 123 },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+17 -2
View File
@@ -77,9 +77,24 @@ describe('TmuxManager (unit)', () => {
});
describe('getAttachArgs', () => {
it('should return attach-session args', () => {
it('should attach every session through the dedicated Codeman socket', () => {
const args = manager.getAttachArgs('codeman-abc12345');
expect(args).toEqual(['attach-session', '-t', 'codeman-abc12345']);
expect(args).toEqual(['-L', 'codeman', 'attach-session', '-t', 'codeman-abc12345']);
});
it('should attach registered sessions on the same dedicated socket (no per-session socket)', () => {
manager.registerSession({
sessionId: 'some-session',
muxName: 'codeman-abc12345',
pid: 12345,
createdAt: Date.now(),
workingDir: '/tmp',
mode: 'claude',
attached: false,
});
const args = manager.getAttachArgs('codeman-abc12345');
expect(args).toEqual(['-L', 'codeman', 'attach-session', '-t', 'codeman-abc12345']);
});
});
+26 -23
View File
@@ -8,7 +8,8 @@
* the next frame — visible flicker and one lost repaint of scrollback.
*
* The fix queries tmux for the actual window geometry first via
* `tmux display -t <name> -p '#{window_width} #{window_height}'`. We cover:
* `tmux -L <socket> display -t <name> -p '#{window_width} #{window_height}'`
* (the `-L <socket>` targets the isolated Codeman socket). We cover:
* - Happy path: tmux reports valid geometry → those numbers are used.
* - Browser-resize-between-attaches: tmux reports a non-default size
* (because a prior client resized it) → the helper picks that up.
@@ -49,7 +50,7 @@ beforeEach(() => {
describe('queryTmuxWindowSize — happy path', () => {
it('returns the geometry tmux reports', () => {
execFileSync.mockReturnValue('200 50\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual({ cols: 200, rows: 50 });
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual({ cols: 200, rows: 50 });
});
it('picks up a non-default size left behind by a prior client (browser-resize-between-attaches)', () => {
@@ -57,12 +58,12 @@ describe('queryTmuxWindowSize — happy path', () => {
// tmux keeps the last-attached geometry. Client B re-attaches and should spawn
// its PTY at 220x60, not 120x40 — that's the whole point of #80.
execFileSync.mockReturnValue('220 60');
expect(queryTmuxWindowSize('codeman-abc')).toEqual({ cols: 220, rows: 60 });
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual({ cols: 220, rows: 60 });
});
it('tolerates trailing whitespace and newlines in tmux output', () => {
execFileSync.mockReturnValue(' 180 45 \n\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual({ cols: 180, rows: 45 });
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual({ cols: 180, rows: 45 });
});
});
@@ -75,7 +76,7 @@ describe('queryTmuxWindowSize — fallback paths', () => {
err.status = 1;
throw err;
});
expect(queryTmuxWindowSize('bogus')).toEqual(DEFAULT);
expect(queryTmuxWindowSize('bogus', 'codeman')).toEqual(DEFAULT);
});
it('falls back when tmux dies between query and parse (ETIMEDOUT / ENOENT)', () => {
@@ -85,63 +86,65 @@ describe('queryTmuxWindowSize — fallback paths', () => {
err.code = 'ETIMEDOUT';
throw err;
});
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
});
it('falls back when tmux returns empty output', () => {
execFileSync.mockReturnValue('');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
});
it('falls back when tmux returns whitespace-only output', () => {
execFileSync.mockReturnValue(' \n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
});
it('falls back when tmux returns non-numeric output', () => {
execFileSync.mockReturnValue('not a size\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
});
it('falls back when only one dimension is present', () => {
execFileSync.mockReturnValue('200\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
});
it('falls back when a dimension is zero (degenerate geometry)', () => {
// tmux reporting `0` would crash node-pty downstream — must not propagate.
execFileSync.mockReturnValue('0 40\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
execFileSync.mockReturnValue('120 0\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
});
it('falls back when a dimension is negative', () => {
execFileSync.mockReturnValue('-200 -50\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
});
it('falls back when tmux returns NaN-producing tokens', () => {
execFileSync.mockReturnValue('abc def\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
});
});
describe('queryTmuxWindowSize — call shape', () => {
it('invokes tmux with display -t <name> -p ... via argv (not a shell)', () => {
it('invokes tmux on the dedicated socket via argv (not a shell)', () => {
execFileSync.mockReturnValue('120 40\n');
queryTmuxWindowSize('codeman-abc');
queryTmuxWindowSize('codeman-abc', 'codeman');
expect(execFileSync).toHaveBeenCalledTimes(1);
const [bin, argv, opts] = execFileSync.mock.calls[0];
expect(bin).toBe('tmux');
expect(argv).toEqual(['display', '-t', 'codeman-abc', '-p', '#{window_width} #{window_height}']);
// `-L <socket>` MUST lead: querying the default server would never find a
// session that lives on the isolated Codeman socket (the #80 regression).
expect(argv).toEqual(['-L', 'codeman', 'display', '-t', 'codeman-abc', '-p', '#{window_width} #{window_height}']);
// execFileSync — not execSync — so muxName is never substituted into a shell string.
expect(opts).toMatchObject({ encoding: 'utf8' });
});
it('uses a bounded timeout so a hung tmux server cannot block startup forever', () => {
execFileSync.mockReturnValue('120 40\n');
queryTmuxWindowSize('codeman-abc');
queryTmuxWindowSize('codeman-abc', 'codeman');
const [, , opts] = execFileSync.mock.calls[0];
// Whatever the exact constant, the contract is: ≤5s so the user-visible
// attach path can't hang on a stuck tmux server.
@@ -152,12 +155,12 @@ describe('queryTmuxWindowSize — call shape', () => {
it('passes a muxName that looks like a tmux flag as an argv element (no option injection)', () => {
execFileSync.mockReturnValue('120 40\n');
queryTmuxWindowSize('-x 1 -y 1; rm -rf');
queryTmuxWindowSize('-x 1 -y 1; rm -rf', 'codeman');
const [, argv] = execFileSync.mock.calls[0];
// The whole "name" lives in a single argv slot, so tmux interprets it as a
// target session name, not as additional flags. The `-t` flag preceding it
// pins it as the target argument.
expect(argv?.[2]).toBe('-x 1 -y 1; rm -rf');
// The whole "name" lives in a single argv slot (index 4, after `-L codeman
// display -t`), so tmux interprets it as a target session name, not as
// additional flags. The `-t` flag preceding it pins it as the target.
expect(argv?.[4]).toBe('-x 1 -y 1; rm -rf');
expect((argv as string[]).indexOf('-x')).toBe(-1);
});
});