Compare commits

...
Author SHA1 Message Date
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
16 changed files with 319 additions and 102 deletions

No files matched your search

+11
View File
@@ -1,5 +1,16 @@
# aicodeman # aicodeman
## 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 ## 0.6.11
### Patch Changes ### Patch Changes
+6 -6
View File
@@ -56,7 +56,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed. CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 0.6.11 (must match `package.json`) **Version**: 0.6.12 (must match `package.json`)
## Project Overview ## Project Overview
@@ -95,9 +95,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly - **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically) - **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` env vars** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift - **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` env vars** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift
- **Dual-CLI prefix discipline** — Codeman supports both Claude Code and OpenCode (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*`) and the allowlist in `schemas.ts` enforces this. When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward both prefixes - **Dual-CLI prefix discipline** — Codeman supports both Claude Code and OpenCode (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*`) and the allowlist in `schemas.ts` enforces this. When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward both prefixes. See `docs/opencode-integration.md` for the OpenCode resolver design
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. Real bugs caused: 0.6.4 (`durationMinutes` for ∞ respawn), and the same shape pattern hit `opusContext1mEnabled` in 0.6.3 - **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. Real bugs caused: 0.6.4 (`durationMinutes` for ∞ respawn), and the same shape pattern hit `opusContext1mEnabled` in 0.6.3
- **`xterm-zerolag-input` is duplicated** — the local-echo overlay lives in BOTH `packages/xterm-zerolag-input/src/` (published to npm as a standalone library for external consumers — see README "Published Packages") AND inline inside `src/web/public/app.js` (runtime copy the web UI actually loads, since the page ships as plain JS without a bundler). Any change to overlay behavior MUST be applied to both, or dev and prod diverge — and a public API break in the package warrants a separate version bump for `xterm-zerolag-input` in the changeset. Always test on mobile after touching it. - **`xterm-zerolag-input` is duplicated** — the local-echo overlay lives in BOTH `packages/xterm-zerolag-input/src/` (published to npm as a standalone library for external consumers — see README "Published Packages") AND inline inside `src/web/public/app.js` (runtime copy the web UI actually loads, since the page ships as plain JS without a bundler). Any change to overlay behavior MUST be applied to both, or dev and prod diverge — and a public API break in the package warrants a separate version bump for `xterm-zerolag-input` in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`.
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files. **Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
@@ -109,7 +109,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|--------|-----------|-------| |--------|-----------|-------|
| **Entry** | `src/index.ts`, `src/cli.ts` | | | **Entry** | `src/index.ts`, `src/cli.ts` | |
| **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts`, `src/session-lifecycle-log.ts`, `src/session-task-cache.ts` | | | **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts`, `src/session-lifecycle-log.ts`, `src/session-task-cache.ts` | |
| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` | | | **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` ★ | |
| **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first | | **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first |
| **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Read `docs/ralph-wiggum-guide.md` first | | **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Read `docs/ralph-wiggum-guide.md` first |
| **Orchestrator** | `src/orchestrator-loop.ts`, `src/orchestrator-planner.ts`, `src/orchestrator-verifier.ts` | Read `docs/orchestrator-loop-architecture.md` first | | **Orchestrator** | `src/orchestrator-loop.ts`, `src/orchestrator-planner.ts`, `src/orchestrator-verifier.ts` | Read `docs/orchestrator-loop-architecture.md` first |
@@ -120,7 +120,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | | | **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | | | **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (15 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | | | **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (15 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
| **Frontend** | `src/web/public/app.js` (~2.9K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 5 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | | | **Frontend** | `src/web/public/app.js` (~3.1K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 5 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | |
| **Types** | `src/types/index.ts` (barrel) → 14 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts | | **Types** | `src/types/index.ts` (barrel) → 14 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`. ★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
@@ -216,7 +216,7 @@ Raw `npx vitest` skips `config/vitest.config.ts`; always use `npm test --` or pa
**Ports**: Pick unique ports manually. Search `const PORT =` before adding new tests. **Ports**: Pick unique ports manually. Search `const PORT =` before adding new tests.
**Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject()` in `test/routes/`. **Mobile tests**: Playwright suite in `test/mobile/` (135 device profiles). **Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject({ method, url, payload })` in `test/routes/` — no live port needed. **Mobile tests**: Playwright suite in `test/mobile/` (135 device profiles).
## Debugging ## Debugging
@@ -1,3 +1,9 @@
> **⚠️ ARCHIVED 2026-05-21 — superseded, kept for history.**
> The headline items here were verified resolved: the P0 `{WORKING_DIR}` placeholder
> is now replaced (`plan-orchestrator.ts:431`), and the "~66 dead functions in app.js"
> are gone (app.js was modularized 15K→3K LOC). A fresh `npm run knip` sweep on
> 2026-05-21 found only a handful of unused test helpers. Do not treat this as a live TODO.
# Codebase Cleanup Findings # Codebase Cleanup Findings
Compiled from parallel analysis of the entire Codeman codebase by 3 research agents (2026-02-19). Compiled from parallel analysis of the entire Codeman codebase by 3 research agents (2026-02-19).
@@ -1,3 +1,9 @@
> **⚠️ ARCHIVED 2026-05-21 — superseded, kept for history.**
> The "Critical" structural items here are done: `server.ts` 6,736→2,065 LOC,
> `app.js` 15,196→3,083 LOC, `types.ts` 1,443→12 LOC (now a barrel → `src/types/`).
> The phase plans that executed this work are in `docs/archive/phase*-plan.md`.
> Do not treat this as a live TODO; see CLAUDE.md for current architecture.
# Code Structure & Quality Findings # Code Structure & Quality Findings
**Date**: 2026-02-28 **Date**: 2026-02-28
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "aicodeman", "name": "aicodeman",
"version": "0.6.11", "version": "0.6.12",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "aicodeman", "name": "aicodeman",
"version": "0.6.11", "version": "0.6.12",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aicodeman", "name": "aicodeman",
"version": "0.6.11", "version": "0.6.12",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
+10 -3
View File
@@ -32,6 +32,13 @@ set -e
CODEMAN_STATE="$HOME/.codeman/state.json" CODEMAN_STATE="$HOME/.codeman/state.json"
CODEMAN_SESSIONS="$HOME/.codeman/mux-sessions.json" CODEMAN_SESSIONS="$HOME/.codeman/mux-sessions.json"
# Dedicated tmux socket all Codeman sessions live on. MUST match
# DEFAULT_CODEMAN_TMUX_SOCKET / CODEMAN_TMUX_SOCKET in src/tmux-manager.ts —
# otherwise list-sessions would enumerate the user's default tmux server
# (missing the real Codeman sessions, surfacing unrelated ones).
CODEMAN_TMUX_SOCKET="${CODEMAN_TMUX_SOCKET:-codeman}"
TMUX_CMD=(tmux -L "$CODEMAN_TMUX_SOCKET")
# iPhone 17 Pro portrait width (conservative) # iPhone 17 Pro portrait width (conservative)
MAX_WIDTH=44 MAX_WIDTH=44
@@ -286,7 +293,7 @@ parse_sessions() {
# Get PID from tmux # Get PID from tmux
local pid local pid
pid=$(tmux display-message -t "$session_name" -p '#{pane_pid}' 2>/dev/null || echo "0") pid=$("${TMUX_CMD[@]}" display-message -t "$session_name" -p '#{pane_pid}' 2>/dev/null || echo "0")
SESSION_PIDS+=("$pid") SESSION_PIDS+=("$pid")
MUX_NAMES+=("$session_name") MUX_NAMES+=("$session_name")
@@ -314,7 +321,7 @@ parse_sessions() {
fi fi
i=$((i + 1)) i=$((i + 1))
done < <(tmux list-sessions 2>/dev/null || true) done < <("${TMUX_CMD[@]}" list-sessions 2>/dev/null || true)
} }
# ============================================================================ # ============================================================================
@@ -462,7 +469,7 @@ attach_session() {
echo -e "${D}(Ctrl+B D to detach)${R}" echo -e "${D}(Ctrl+B D to detach)${R}"
sleep 0.3 sleep 0.3
tmux attach-session -t "$mux_name" "${TMUX_CMD[@]}" attach-session -t "$mux_name"
return 0 return 0
} }
+17 -8
View File
@@ -20,6 +20,13 @@ REVERSE='\033[7m'
# Use the same path as codeman (src/tmux-manager.ts) # Use the same path as codeman (src/tmux-manager.ts)
SESSIONS_FILE="${HOME}/.codeman/mux-sessions.json" SESSIONS_FILE="${HOME}/.codeman/mux-sessions.json"
# Dedicated tmux socket all Codeman sessions live on. MUST match
# DEFAULT_CODEMAN_TMUX_SOCKET / CODEMAN_TMUX_SOCKET in src/tmux-manager.ts —
# otherwise this script would talk to the user's default tmux server and never
# see (or could mis-target) Codeman's sessions.
CODEMAN_TMUX_SOCKET="${CODEMAN_TMUX_SOCKET:-codeman}"
TMUX_CMD=(tmux -L "$CODEMAN_TMUX_SOCKET")
# Cached data # Cached data
CACHED_JSON="" CACHED_JSON=""
@@ -92,7 +99,7 @@ declare -A ALIVE_CACHE
check_alive() { check_alive() {
local mux_name=$1 local mux_name=$1
if [[ -z "${ALIVE_CACHE[$mux_name]+x}" ]]; then if [[ -z "${ALIVE_CACHE[$mux_name]+x}" ]]; then
if tmux has-session -t "$mux_name" 2>/dev/null; then if "${TMUX_CMD[@]}" has-session -t "$mux_name" 2>/dev/null; then
ALIVE_CACHE[$mux_name]=1 ALIVE_CACHE[$mux_name]=1
else else
ALIVE_CACHE[$mux_name]=0 ALIVE_CACHE[$mux_name]=0
@@ -111,8 +118,10 @@ kill_session() {
local mux_name=$(get_session_field $idx "muxName") local mux_name=$(get_session_field $idx "muxName")
local pid=$(get_session_field $idx "pid") local pid=$(get_session_field $idx "pid")
# SAFETY: Never kill own tmux session # SAFETY: Never kill own tmux session. Queried on the Codeman socket; if run
local current_session=$(tmux display-message -p '#{session_name}' 2>/dev/null || echo "") # from a session on a different socket this returns empty (no match), which
# is fine — you can't be "inside" a Codeman-socket session you didn't attach to.
local current_session=$("${TMUX_CMD[@]}" display-message -p '#{session_name}' 2>/dev/null || echo "")
if [[ -n "$current_session" && "$mux_name" == "$current_session" ]]; then if [[ -n "$current_session" && "$mux_name" == "$current_session" ]]; then
echo -e "${RED}BLOCKED: Cannot kill own tmux session: $mux_name${NC}" echo -e "${RED}BLOCKED: Cannot kill own tmux session: $mux_name${NC}"
return 1 return 1
@@ -120,7 +129,7 @@ kill_session() {
pkill -TERM -P $pid 2>/dev/null pkill -TERM -P $pid 2>/dev/null
kill -TERM -$pid 2>/dev/null kill -TERM -$pid 2>/dev/null
tmux kill-session -t "$mux_name" 2>/dev/null "${TMUX_CMD[@]}" kill-session -t "$mux_name" 2>/dev/null
kill -KILL $pid 2>/dev/null kill -KILL $pid 2>/dev/null
# Remove from JSON # Remove from JSON
@@ -345,7 +354,7 @@ interactive_menu() {
clear clear
echo -e "${CYAN}Attaching... (Ctrl+B D to detach)${NC}" echo -e "${CYAN}Attaching... (Ctrl+B D to detach)${NC}"
sleep 0.3 sleep 0.3
tmux attach-session -t "$mux_name" "${TMUX_CMD[@]}" attach-session -t "$mux_name"
tput civis tput civis
need_full_redraw=1 need_full_redraw=1
force_refresh force_refresh
@@ -474,7 +483,7 @@ main() {
[[ -z "${2:-}" ]] && { echo "Usage: $0 attach <N>"; exit 1; } [[ -z "${2:-}" ]] && { echo "Usage: $0 attach <N>"; exit 1; }
force_refresh force_refresh
local mux_name=$(get_session_field $(($2-1)) "muxName") local mux_name=$(get_session_field $(($2-1)) "muxName")
check_alive "$mux_name" && tmux attach-session -t "$mux_name" || echo "Session dead or not found" check_alive "$mux_name" && "${TMUX_CMD[@]}" attach-session -t "$mux_name" || echo "Session dead or not found"
;; ;;
kill) kill)
[[ -z "${2:-}" ]] && { echo "Usage: $0 kill <N|N,M|N-M>"; exit 1; } [[ -z "${2:-}" ]] && { echo "Usage: $0 kill <N|N,M|N-M>"; exit 1; }
@@ -492,8 +501,8 @@ main() {
;; ;;
kill-all) kill-all)
force_refresh force_refresh
# SAFETY: Never kill own tmux session # SAFETY: Never kill own tmux session (queried on the Codeman socket)
local current_session=$(tmux display-message -p '#{session_name}' 2>/dev/null || echo "") local current_session=$("${TMUX_CMD[@]}" display-message -p '#{session_name}' 2>/dev/null || echo "")
local killed=0 local killed=0
for ((i=CACHED_COUNT-1; i>=0; i--)); do for ((i=CACHED_COUNT-1; i>=0; i--)); do
local mux_name=$(get_session_field $i "muxName") local mux_name=$(get_session_field $i "muxName")
+3
View File
@@ -98,6 +98,9 @@ export interface TerminalMultiplexer extends EventEmitter {
/** Which backend this instance uses */ /** Which backend this instance uses */
readonly backend: 'tmux'; readonly backend: 'tmux';
/** The dedicated tmux socket name all sessions live on (e.g. "codeman"). */
readonly muxSocket: string;
// ========== Lifecycle ========== // ========== Lifecycle ==========
/** /**
+13 -6
View File
@@ -133,15 +133,22 @@ const TMUX_DISPLAY_TIMEOUT_MS = 2000;
* (tmux dead, muxName unknown, malformed output) — caller never has to * (tmux dead, muxName unknown, malformed output) — caller never has to
* differentiate "tmux unreachable" from "size 120x40". * differentiate "tmux unreachable" from "size 120x40".
* *
* `socket` MUST be the same dedicated socket the session lives on (`mux.muxSocket`);
* querying the default server would never find the session and silently fall back.
*
* Argv form (execFileSync, not execSync) keeps `muxName` out of any shell so * Argv form (execFileSync, not execSync) keeps `muxName` out of any shell so
* a hostile session name can't inject options. * a hostile session name can't inject options.
*/ */
export function queryTmuxWindowSize(muxName: string): { cols: number; rows: number } { export function queryTmuxWindowSize(muxName: string, socket: string): { cols: number; rows: number } {
try { try {
const sizeStr = execFileSync('tmux', ['display', '-t', muxName, '-p', '#{window_width} #{window_height}'], { const sizeStr = execFileSync(
timeout: TMUX_DISPLAY_TIMEOUT_MS, 'tmux',
encoding: 'utf8', ['-L', socket, 'display', '-t', muxName, '-p', '#{window_width} #{window_height}'],
}).trim(); {
timeout: TMUX_DISPLAY_TIMEOUT_MS,
encoding: 'utf8',
}
).trim();
const [w, h] = sizeStr.split(' ').map(Number); const [w, h] = sizeStr.split(' ').map(Number);
if (w > 0 && h > 0) { if (w > 0 && h > 0) {
return { cols: w, rows: h }; return { cols: w, rows: h };
@@ -978,7 +985,7 @@ export class Session extends EventEmitter {
// Attach to the mux session via PTY // Attach to the mux session via PTY
// Query existing tmux window size so re-attach matches (avoids flicker from 120x40 default) // Query existing tmux window size so re-attach matches (avoids flicker from 120x40 default)
const { cols: ptyCols, rows: ptyRows } = queryTmuxWindowSize(this._muxSession!.muxName); const { cols: ptyCols, rows: ptyRows } = queryTmuxWindowSize(this._muxSession!.muxName, mux.muxSocket);
try { try {
this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), { this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), {
name: 'xterm-256color', name: 'xterm-256color',
+143 -47
View File
@@ -71,6 +71,9 @@ const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
/** Default stats collection interval (2 seconds) */ /** Default stats collection interval (2 seconds) */
const DEFAULT_STATS_INTERVAL_MS = 2000; const DEFAULT_STATS_INTERVAL_MS = 2000;
/** Claude Code native macOS recommendation for avoiding low nofile startup failures. */
export const CLAUDE_CODE_NOFILE_LIMIT = 2147483646;
/** /**
* SAFETY: Test mode detection. * SAFETY: Test mode detection.
* When running under vitest (VITEST env var is set automatically), * When running under vitest (VITEST env var is set automatically),
@@ -98,6 +101,12 @@ const LEGACY_MUX_NAME_PATTERN = /^claudeman-[a-f0-9-]+$/;
/** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */ /** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */
const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/; const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/;
/** Dedicated tmux socket for new Codeman-owned sessions. */
const DEFAULT_CODEMAN_TMUX_SOCKET = 'codeman';
/** Regex to validate tmux socket names passed to `tmux -L`. */
const SAFE_TMUX_SOCKET_PATTERN = /^[a-zA-Z0-9_.-]+$/;
/** /**
* Separator used in `tmux list-panes -F` output between session name and pid. * Separator used in `tmux list-panes -F` output between session name and pid.
* *
@@ -114,6 +123,19 @@ const PANE_LIST_SEP = '|';
/** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneList}. */ /** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneList}. */
const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}`; const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}`;
/**
* 构建 pane 启动前的 nofile 修复命令。
*
* macOS launchd/tmux 组合有时会让 pane 继承 256 的 soft nofile;
* 新版 Claude Code 会在这种环境下直接退出。这里避免使用 $变量
* 或命令替换,因为 fullCmd 目前经由双引号 bash -c 传递,外层
* shell 会提前展开它们。
*/
export function buildNofileLimitCommand(targetLimit = CLAUDE_CODE_NOFILE_LIMIT): string {
const safeLimit = Number.isSafeInteger(targetLimit) && targetLimit > 0 ? targetLimit : CLAUDE_CODE_NOFILE_LIMIT;
return `ulimit -Sn ${safeLimit} 2>/dev/null || ulimit -n ${safeLimit} 2>/dev/null || true`;
}
/** /**
* Parse the output of `tmux list-panes -a -F '#{session_name}|#{pane_pid}'` * Parse the output of `tmux list-panes -a -F '#{session_name}|#{pane_pid}'`
* into a Map of session-name → pane pid. Exported for unit testing. * into a Map of session-name → pane pid. Exported for unit testing.
@@ -161,6 +183,31 @@ function isValidPath(path: string): boolean {
return SAFE_PATH_PATTERN.test(path); return SAFE_PATH_PATTERN.test(path);
} }
// ===========================================================================
// Single-socket architecture: ALL Codeman sessions live on one dedicated tmux
// socket (`tmux -L codeman`), isolated from the user's default tmux server.
// The socket name is a process-wide constant (env-overridable for test/multi-
// instance isolation) — it is never stored per-session, so it cannot drift.
// ===========================================================================
/**
* Resolve the process-wide Codeman tmux socket name. Always returns a valid
* name: `CODEMAN_TMUX_SOCKET` env override if safe, else the built-in default.
*/
function resolveConfiguredTmuxSocket(): string {
const raw = process.env.CODEMAN_TMUX_SOCKET ?? DEFAULT_CODEMAN_TMUX_SOCKET;
if (!SAFE_TMUX_SOCKET_PATTERN.test(raw)) {
console.warn(`[TmuxManager] Ignoring invalid CODEMAN_TMUX_SOCKET: ${JSON.stringify(raw)}`);
return DEFAULT_CODEMAN_TMUX_SOCKET;
}
return raw;
}
/** Build the `tmux -L <socket>` command prefix. Socket name is shell-escaped. */
function tmuxCommand(socket: string): string {
return `tmux -L ${shellescape(socket)}`;
}
/** /**
* Build Claude CLI permission flags for the tmux command string. * Build Claude CLI permission flags for the tmux command string.
* Validates allowedTools to prevent command injection. * Validates allowedTools to prevent command injection.
@@ -248,7 +295,7 @@ function buildSpawnCommand(options: {
* Set sensitive environment variables on a tmux session via setenv. * Set sensitive environment variables on a tmux session via setenv.
* These are inherited by panes but not visible in ps output or tmux history. * These are inherited by panes but not visible in ps output or tmux history.
*/ */
function setOpenCodeEnvVars(muxName: string): void { function setOpenCodeEnvVars(tmuxCmd: string, muxName: string): void {
const sensitiveVars = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY']; const sensitiveVars = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY'];
for (const key of sensitiveVars) { for (const key of sensitiveVars) {
const val = process.env[key]; const val = process.env[key];
@@ -256,7 +303,7 @@ function setOpenCodeEnvVars(muxName: string): void {
// Shell-escape: wrap in single quotes, escape any inner single quotes // Shell-escape: wrap in single quotes, escape any inner single quotes
const escaped = val.replace(/'/g, "'\\''"); const escaped = val.replace(/'/g, "'\\''");
try { try {
execSync(`tmux setenv -t '${muxName}' ${key} '${escaped}'`, { execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
encoding: 'utf8', encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
@@ -272,7 +319,7 @@ function setOpenCodeEnvVars(muxName: string): void {
* Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv. * Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv.
* Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON. * Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON.
*/ */
function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): void { function setOpenCodeConfigContent(tmuxCmd: string, muxName: string, config?: OpenCodeConfig): void {
if (!config) return; if (!config) return;
let jsonContent: string | undefined; let jsonContent: string | undefined;
@@ -303,7 +350,7 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi
if (jsonContent) { if (jsonContent) {
const escaped = jsonContent.replace(/'/g, "'\\''"); const escaped = jsonContent.replace(/'/g, "'\\''");
try { try {
execSync(`tmux setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, { execSync(`${tmuxCmd} setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, {
encoding: 'utf8', encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
@@ -336,6 +383,7 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi
export class TmuxManager extends EventEmitter implements TerminalMultiplexer { export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
readonly backend = 'tmux' as const; readonly backend = 'tmux' as const;
private sessions: Map<string, MuxSession> = new Map(); private sessions: Map<string, MuxSession> = new Map();
private readonly tmuxSocket = resolveConfiguredTmuxSocket();
private statsInterval: NodeJS.Timeout | null = null; private statsInterval: NodeJS.Timeout | null = null;
private mouseSyncInterval: NodeJS.Timeout | null = null; private mouseSyncInterval: NodeJS.Timeout | null = null;
/** Track last-known pane count per session to avoid unnecessary tmux set-option calls */ /** Track last-known pane count per session to avoid unnecessary tmux set-option calls */
@@ -351,6 +399,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
} }
/** The dedicated tmux socket all Codeman sessions live on (see {@link TerminalMultiplexer.muxSocket}). */
get muxSocket(): string {
return this.tmuxSocket;
}
private tmux(): string {
return tmuxCommand(this.tmuxSocket);
}
// Load saved sessions from disk (NEVER called in test mode) // Load saved sessions from disk (NEVER called in test mode)
private loadSessions(): void { private loadSessions(): void {
if (IS_TEST_MODE) return; if (IS_TEST_MODE) return;
@@ -360,8 +417,40 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const content = readFileSync(MUX_SESSIONS_FILE, 'utf-8'); const content = readFileSync(MUX_SESSIONS_FILE, 'utf-8');
const data = JSON.parse(content); const data = JSON.parse(content);
if (Array.isArray(data)) { if (Array.isArray(data)) {
// Dedup by muxName: one live tmux session must map to exactly one
// tracked entry. A per-session socket-tag mismatch could historically
// let the same session be tracked twice — once under its real UUID and
// once under a "restored-<id>" placeholder — surfacing as duplicate tabs.
// Single-socket unification removed that failure mode; this pass stays
// to clean any stale duplicates already on disk. Keep the real (UUID)
// entry and drop placeholder twins.
let dropped = 0;
const keptByMuxName = new Map<string, string>(); // muxName -> kept sessionId
for (const session of data) { for (const session of data) {
// Strip the obsolete per-session tmuxSocket tag (now a process-wide
// constant). Left in place it would be written back by saveSessions()
// and linger on disk as a zombie field forever.
delete (session as { tmuxSocket?: unknown }).tmuxSocket;
const muxName: string | undefined = session.muxName;
const priorId = muxName ? keptByMuxName.get(muxName) : undefined;
if (priorId) {
const incomingIsPlaceholder = String(session.sessionId).startsWith('restored-');
const priorIsPlaceholder = priorId.startsWith('restored-');
// Drop the incoming unless it's the real twin of a placeholder we kept.
if (incomingIsPlaceholder || !priorIsPlaceholder) {
dropped++;
continue;
}
this.sessions.delete(priorId);
dropped++;
}
this.sessions.set(session.sessionId, session); this.sessions.set(session.sessionId, session);
if (muxName) keptByMuxName.set(muxName, session.sessionId);
}
// Persist the cleaned list so the stale duplicates don't reload.
if (dropped > 0) {
console.log(`[TmuxManager] Dropped ${dropped} duplicate mux session record(s) on load`);
this.saveSessions();
} }
} }
} }
@@ -438,7 +527,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
continue; continue;
} }
try { try {
execSync(`tmux setenv -t ${shellescape(muxName)} ${key} ${shellescape(value)}`, { execSync(`${this.tmux()} setenv -t ${shellescape(muxName)} ${key} ${shellescape(value)}`, {
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
}); });
@@ -471,8 +560,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* (not visible in ps output or tmux history, inherited by panes). * (not visible in ps output or tmux history, inherited by panes).
*/ */
private _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig): void { private _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig): void {
setOpenCodeEnvVars(muxName); const tmuxCmd = this.tmux();
setOpenCodeConfigContent(muxName, openCodeConfig); setOpenCodeEnvVars(tmuxCmd, muxName);
setOpenCodeConfigContent(tmuxCmd, muxName, openCodeConfig);
} }
/** /**
@@ -545,7 +635,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
try { try {
// Build the full command to run inside tmux // Build the full command to run inside tmux
const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`; const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
// Create tmux session in three steps to handle cold-start (no server running) // Create tmux session in three steps to handle cold-start (no server running)
// and avoid the race where the command exits before remain-on-exit is set: // and avoid the race where the command exits before remain-on-exit is set:
@@ -556,7 +646,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// (Production uses systemd which has a clean env, but dev/test may be nested.) // (Production uses systemd which has a clean env, but dev/test may be nested.)
const cleanEnv = { ...process.env }; const cleanEnv = { ...process.env };
delete cleanEnv.TMUX; delete cleanEnv.TMUX;
execSync(`tmux new-session -ds "${muxName}" -c "${workingDir}"`, { execSync(`${this.tmux()} new-session -ds "${muxName}" -c "${workingDir}"`, {
cwd: workingDir, cwd: workingDir,
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore', stdio: 'ignore',
@@ -565,7 +655,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Set remain-on-exit now that the server is running — must be before respawn-pane // Set remain-on-exit now that the server is running — must be before respawn-pane
try { try {
execSync(`tmux set-option -t "${muxName}" remain-on-exit on`, { execSync(`${this.tmux()} set-option -t "${muxName}" remain-on-exit on`, {
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore', stdio: 'ignore',
}); });
@@ -584,7 +674,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
this.applyEnvOverrides(muxName, envOverrides); this.applyEnvOverrides(muxName, envOverrides);
// Replace the shell with the actual command (no echo in terminal) // Replace the shell with the actual command (no echo in terminal)
execSync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, { execSync(`${this.tmux()} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore', stdio: 'ignore',
}); });
@@ -598,20 +688,20 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// It gets enabled dynamically when panes are split (agent teams). // It gets enabled dynamically when panes are split (agent teams).
const configPromises: Promise<void>[] = [ const configPromises: Promise<void>[] = [
// Disable tmux status bar — Codeman's web UI provides session info // Disable tmux status bar — Codeman's web UI provides session info
execAsync(`tmux set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS }) execAsync(`${this.tmux()} set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS })
.then(() => {}) .then(() => {})
.catch(() => { .catch(() => {
/* Non-critical — session still works with status bar */ /* Non-critical — session still works with status bar */
}), }),
// Override global remain-on-exit with session-level setting // Override global remain-on-exit with session-level setting
execAsync(`tmux set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS }) execAsync(`${this.tmux()} set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS })
.then(() => {}) .then(() => {})
.catch(() => { .catch(() => {
/* Already set globally as fallback */ /* Already set globally as fallback */
}), }),
// Raise tmux scrollback from its 2000-line default so re-attach preserves // Raise tmux scrollback from its 2000-line default so re-attach preserves
// more context. Matches the xterm-side default in constants.js. // more context. Matches the xterm-side default in constants.js.
execAsync(`tmux set-option -t "${muxName}" history-limit 50000`, { timeout: EXEC_TIMEOUT_MS }) execAsync(`${this.tmux()} set-option -t "${muxName}" history-limit 50000`, { timeout: EXEC_TIMEOUT_MS })
.then(() => {}) .then(() => {})
.catch(() => { .catch(() => {
/* Non-critical — falls back to tmux default */ /* Non-critical — falls back to tmux default */
@@ -621,7 +711,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Enable 24-bit true color passthrough — server-wide, set once per lifetime // Enable 24-bit true color passthrough — server-wide, set once per lifetime
if (!this.trueColorConfigured) { if (!this.trueColorConfigured) {
configPromises.push( configPromises.push(
execAsync(`tmux set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS }) execAsync(`${this.tmux()} set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS })
.then(() => { .then(() => {
this.trueColorConfigured = true; this.trueColorConfigured = true;
}) })
@@ -678,7 +768,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
try { try {
const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_pid}'`, { const output = execSync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_pid}'`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}).trim(); }).trim();
@@ -704,7 +794,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (IS_TEST_MODE) return false; if (IS_TEST_MODE) return false;
if (!isValidMuxName(muxName)) return false; if (!isValidMuxName(muxName)) return false;
try { try {
const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_dead}'`, { const output = execSync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_dead}'`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}).trim(); }).trim();
@@ -754,7 +844,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}); });
const config = niceConfig || DEFAULT_NICE_CONFIG; const config = niceConfig || DEFAULT_NICE_CONFIG;
const cmd = wrapWithNice(baseCmd, config); const cmd = wrapWithNice(baseCmd, config);
const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`; const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
try { try {
// For OpenCode: set sensitive env vars via tmux setenv before respawn // For OpenCode: set sensitive env vars via tmux setenv before respawn
@@ -765,7 +855,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Re-apply user env overrides before respawn so the new shell inherits them. // Re-apply user env overrides before respawn so the new shell inherits them.
this.applyEnvOverrides(muxName, envOverrides); this.applyEnvOverrides(muxName, envOverrides);
await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, { await execAsync(`${this.tmux()} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
// Wait for the respawned process to start // Wait for the respawned process to start
@@ -783,7 +873,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (IS_TEST_MODE) return false; if (IS_TEST_MODE) return false;
try { try {
execSync(`tmux has-session -t "${muxName}" 2>/dev/null`, { execSync(`${this.tmux()} has-session -t "${muxName}" 2>/dev/null`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
@@ -923,7 +1013,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Strategy 3: Kill tmux session by name // Strategy 3: Kill tmux session by name
try { try {
execSync(`tmux kill-session -t "${session.muxName}" 2>/dev/null`, { execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
} catch { } catch {
@@ -988,26 +1078,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const dead: string[] = []; const dead: string[] = [];
const discovered: string[] = []; const discovered: string[] = [];
// Batch: single tmux call to get all session names + pane PIDs (replaces N per-session subprocess calls) // Single batched query against the one socket Codeman owns. With a single
let activeSessions = new Map<string, number>(); // socket a session's location is a constant, so there is no per-session
// socket tag to reconcile and no cross-socket ambiguity that could mark a
// live session dead (the root cause of vanished/duplicate tabs).
let active: Map<string, number>;
try { try {
const output = execSync(`tmux list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, { const output = execSync(`${this.tmux()} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}).trim(); }).trim();
activeSessions = parsePaneList(output); active = parsePaneList(output);
} catch (err) { } catch (err) {
console.error('[TmuxManager] Failed to list tmux panes:', err); console.error('[TmuxManager] Failed to list tmux panes:', err);
active = new Map();
} }
// Check known sessions against the batch result (O(1) map lookup instead of subprocess per session) // Check tracked sessions against the live pane list.
for (const [sessionId, session] of this.sessions) { for (const [sessionId, session] of this.sessions) {
const pid = activeSessions.get(session.muxName); const pid = active.get(session.muxName);
if (pid !== undefined) { if (pid !== undefined) {
alive.push(sessionId); alive.push(sessionId);
if (pid !== session.pid) { if (pid !== session.pid) session.pid = pid;
session.pid = pid;
}
} else { } else {
dead.push(sessionId); dead.push(sessionId);
this.sessions.delete(sessionId); this.sessions.delete(sessionId);
@@ -1015,13 +1107,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
} }
// Discover unknown codeman/claudeman sessions from the same batch result // Discover untracked codeman/claudeman sessions on our socket. Dedup by
// muxName (globally unique) so a name we already track never spawns a
// second "Restored:" entry.
const knownMuxNames = new Set<string>(); const knownMuxNames = new Set<string>();
for (const session of this.sessions.values()) { for (const session of this.sessions.values()) {
knownMuxNames.add(session.muxName); knownMuxNames.add(session.muxName);
} }
for (const [sessionName, pid] of activeSessions) { for (const [sessionName, pid] of active) {
if (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-')) continue; if (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-')) continue;
if (knownMuxNames.has(sessionName)) continue; if (knownMuxNames.has(sessionName)) continue;
@@ -1038,6 +1132,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
name: `Restored: ${sessionName}`, name: `Restored: ${sessionName}`,
}; };
this.sessions.set(sessionId, session); this.sessions.set(sessionId, session);
knownMuxNames.add(sessionName);
discovered.push(sessionId); discovered.push(sessionId);
console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`); console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`);
} }
@@ -1345,21 +1440,21 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Ink (Claude CLI's terminal framework) needs them split — sending both in a // Ink (Claude CLI's terminal framework) needs them split — sending both in a
// single tmux invocation (via \;) causes Ink to interpret Enter as a newline // single tmux invocation (via \;) causes Ink to interpret Enter as a newline
// character in the input buffer rather than as form submission. // character in the input buffer rather than as form submission.
await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, { await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, { await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
} else if (textPart) { } else if (textPart) {
// Text only, no Enter // Text only, no Enter
await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, { await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
} else if (hasCarriageReturn) { } else if (hasCarriageReturn) {
// Enter only // Enter only
await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, { await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
} }
@@ -1386,7 +1481,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
try { try {
execSync(`tmux set-option -t "${muxName}" mouse on`, { execSync(`${this.tmux()} set-option -t "${muxName}" mouse on`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
@@ -1410,7 +1505,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
try { try {
execSync(`tmux set-option -t "${muxName}" mouse off`, { execSync(`${this.tmux()} set-option -t "${muxName}" mouse off`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
@@ -1450,7 +1545,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
try { try {
const output = execSync( const output = execSync(
`tmux list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`, `${this.tmux()} list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
).trim(); ).trim();
@@ -1489,27 +1584,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Build target: sessionName.paneId (e.g., "codeman-abc12345.%1") // Build target: sessionName.paneId (e.g., "codeman-abc12345.%1")
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
const tmux = this.tmux();
try { try {
const hasCarriageReturn = input.includes('\r'); const hasCarriageReturn = input.includes('\r');
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd(); const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
if (textPart && hasCarriageReturn) { if (textPart && hasCarriageReturn) {
execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, { execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
execSync(`tmux send-keys -t ${shellescape(target)} Enter`, { execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
} else if (textPart) { } else if (textPart) {
execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, { execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
} else if (hasCarriageReturn) { } else if (hasCarriageReturn) {
execSync(`tmux send-keys -t ${shellescape(target)} Enter`, { execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
@@ -1540,7 +1636,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try { try {
return execSync(`tmux capture-pane -p -e -t ${shellescape(target)} -S -5000`, { return execSync(`${this.tmux()} capture-pane -p -e -t ${shellescape(target)} -S -5000`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
@@ -1572,7 +1668,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try { try {
execSync(`tmux pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, { execSync(`${this.tmux()} pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
@@ -1600,7 +1696,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try { try {
execSync(`tmux pipe-pane -t ${shellescape(target)}`, { execSync(`${this.tmux()} pipe-pane -t ${shellescape(target)}`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
@@ -1616,7 +1712,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
getAttachArgs(muxName: string): string[] { getAttachArgs(muxName: string): string[] {
return ['attach-session', '-t', muxName]; return ['-L', this.tmuxSocket, 'attach-session', '-t', muxName];
} }
isAvailable(): boolean { isAvailable(): boolean {
+11 -4
View File
@@ -652,11 +652,18 @@ export function registerSessionRoutes(
} }
try { try {
// Route through the dedicated Codeman socket — bare `tmux` would target the
// user's default server and never find this session (same #80 regression class).
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
execFile('tmux', ['send-keys', '-H', '-t', muxName, ...hex], { timeout: 5000 }, (err) => { execFile(
if (err) reject(err); 'tmux',
else resolve(); ['-L', ctx.mux.muxSocket, 'send-keys', '-H', '-t', muxName, ...hex],
}); { timeout: 5000 },
(err) => {
if (err) reject(err);
else resolve();
}
);
}); });
} catch (err) { } catch (err) {
console.error('[Server] send-key failed:', err); console.error('[Server] send-key failed:', err);
+1
View File
@@ -90,6 +90,7 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
// -- InfraPort -- // -- InfraPort --
mux: { mux: {
muxSocket: 'codeman',
createSession: vi.fn(), createSession: vi.fn(),
killSession: vi.fn(), killSession: vi.fn(),
listSessions: vi.fn(() => []), listSessions: vi.fn(() => []),
+46
View File
@@ -7,6 +7,14 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
// Mock execFile so the send-key route's `tmux` invocation is observable (not run for real).
const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() }));
vi.mock('node:child_process', async (orig) => {
const actual = await orig<typeof import('node:child_process')>();
return { ...actual, execFile };
});
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
describe('session-routes', () => { describe('session-routes', () => {
@@ -20,6 +28,44 @@ describe('session-routes', () => {
await harness.app.close(); await harness.app.close();
}); });
// ========== POST /api/sessions/:id/send-key ==========
describe('POST /api/sessions/:id/send-key', () => {
it('routes tmux send-keys through the dedicated Codeman socket (-L)', async () => {
// Regression guard: bare `tmux` would hit the user's default server and never
// find a session that lives only on the Codeman socket (#80 regression class).
execFile.mockReset();
execFile.mockImplementation((_bin: string, _argv: string[], _opts: unknown, cb: (e: Error | null) => void) =>
cb(null)
);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/send-key',
payload: { key: 'S-Enter' },
});
expect(res.statusCode).toBe(200);
expect(execFile).toHaveBeenCalledTimes(1);
const [bin, argv] = execFile.mock.calls[0];
expect(bin).toBe('tmux');
expect((argv as string[]).slice(0, 2)).toEqual(['-L', 'codeman']);
expect(argv).toContain('send-keys');
expect(argv).toContain('-H');
});
it('rejects keys outside the hex allowlist without invoking tmux', async () => {
execFile.mockReset();
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/send-key',
payload: { key: 'rm -rf' },
});
expect(JSON.parse(res.body).success).toBe(false);
expect(execFile).not.toHaveBeenCalled();
});
});
// ========== GET /api/sessions ========== // ========== GET /api/sessions ==========
describe('GET /api/sessions', () => { describe('GET /api/sessions', () => {
+17 -2
View File
@@ -77,9 +77,24 @@ describe('TmuxManager (unit)', () => {
}); });
describe('getAttachArgs', () => { describe('getAttachArgs', () => {
it('should return attach-session args', () => { it('should attach every session through the dedicated Codeman socket', () => {
const args = manager.getAttachArgs('codeman-abc12345'); const args = manager.getAttachArgs('codeman-abc12345');
expect(args).toEqual(['attach-session', '-t', 'codeman-abc12345']); expect(args).toEqual(['-L', 'codeman', 'attach-session', '-t', 'codeman-abc12345']);
});
it('should attach registered sessions on the same dedicated socket (no per-session socket)', () => {
manager.registerSession({
sessionId: 'some-session',
muxName: 'codeman-abc12345',
pid: 12345,
createdAt: Date.now(),
workingDir: '/tmp',
mode: 'claude',
attached: false,
});
const args = manager.getAttachArgs('codeman-abc12345');
expect(args).toEqual(['-L', 'codeman', 'attach-session', '-t', 'codeman-abc12345']);
}); });
}); });
+26 -23
View File
@@ -8,7 +8,8 @@
* the next frame — visible flicker and one lost repaint of scrollback. * the next frame — visible flicker and one lost repaint of scrollback.
* *
* The fix queries tmux for the actual window geometry first via * The fix queries tmux for the actual window geometry first via
* `tmux display -t <name> -p '#{window_width} #{window_height}'`. We cover: * `tmux -L <socket> display -t <name> -p '#{window_width} #{window_height}'`
* (the `-L <socket>` targets the isolated Codeman socket). We cover:
* - Happy path: tmux reports valid geometry → those numbers are used. * - Happy path: tmux reports valid geometry → those numbers are used.
* - Browser-resize-between-attaches: tmux reports a non-default size * - Browser-resize-between-attaches: tmux reports a non-default size
* (because a prior client resized it) → the helper picks that up. * (because a prior client resized it) → the helper picks that up.
@@ -49,7 +50,7 @@ beforeEach(() => {
describe('queryTmuxWindowSize — happy path', () => { describe('queryTmuxWindowSize — happy path', () => {
it('returns the geometry tmux reports', () => { it('returns the geometry tmux reports', () => {
execFileSync.mockReturnValue('200 50\n'); execFileSync.mockReturnValue('200 50\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual({ cols: 200, rows: 50 }); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual({ cols: 200, rows: 50 });
}); });
it('picks up a non-default size left behind by a prior client (browser-resize-between-attaches)', () => { it('picks up a non-default size left behind by a prior client (browser-resize-between-attaches)', () => {
@@ -57,12 +58,12 @@ describe('queryTmuxWindowSize — happy path', () => {
// tmux keeps the last-attached geometry. Client B re-attaches and should spawn // tmux keeps the last-attached geometry. Client B re-attaches and should spawn
// its PTY at 220x60, not 120x40 — that's the whole point of #80. // its PTY at 220x60, not 120x40 — that's the whole point of #80.
execFileSync.mockReturnValue('220 60'); execFileSync.mockReturnValue('220 60');
expect(queryTmuxWindowSize('codeman-abc')).toEqual({ cols: 220, rows: 60 }); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual({ cols: 220, rows: 60 });
}); });
it('tolerates trailing whitespace and newlines in tmux output', () => { it('tolerates trailing whitespace and newlines in tmux output', () => {
execFileSync.mockReturnValue(' 180 45 \n\n'); execFileSync.mockReturnValue(' 180 45 \n\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual({ cols: 180, rows: 45 }); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual({ cols: 180, rows: 45 });
}); });
}); });
@@ -75,7 +76,7 @@ describe('queryTmuxWindowSize — fallback paths', () => {
err.status = 1; err.status = 1;
throw err; throw err;
}); });
expect(queryTmuxWindowSize('bogus')).toEqual(DEFAULT); expect(queryTmuxWindowSize('bogus', 'codeman')).toEqual(DEFAULT);
}); });
it('falls back when tmux dies between query and parse (ETIMEDOUT / ENOENT)', () => { it('falls back when tmux dies between query and parse (ETIMEDOUT / ENOENT)', () => {
@@ -85,63 +86,65 @@ describe('queryTmuxWindowSize — fallback paths', () => {
err.code = 'ETIMEDOUT'; err.code = 'ETIMEDOUT';
throw err; throw err;
}); });
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
}); });
it('falls back when tmux returns empty output', () => { it('falls back when tmux returns empty output', () => {
execFileSync.mockReturnValue(''); execFileSync.mockReturnValue('');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
}); });
it('falls back when tmux returns whitespace-only output', () => { it('falls back when tmux returns whitespace-only output', () => {
execFileSync.mockReturnValue(' \n'); execFileSync.mockReturnValue(' \n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
}); });
it('falls back when tmux returns non-numeric output', () => { it('falls back when tmux returns non-numeric output', () => {
execFileSync.mockReturnValue('not a size\n'); execFileSync.mockReturnValue('not a size\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
}); });
it('falls back when only one dimension is present', () => { it('falls back when only one dimension is present', () => {
execFileSync.mockReturnValue('200\n'); execFileSync.mockReturnValue('200\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
}); });
it('falls back when a dimension is zero (degenerate geometry)', () => { it('falls back when a dimension is zero (degenerate geometry)', () => {
// tmux reporting `0` would crash node-pty downstream — must not propagate. // tmux reporting `0` would crash node-pty downstream — must not propagate.
execFileSync.mockReturnValue('0 40\n'); execFileSync.mockReturnValue('0 40\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
execFileSync.mockReturnValue('120 0\n'); execFileSync.mockReturnValue('120 0\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
}); });
it('falls back when a dimension is negative', () => { it('falls back when a dimension is negative', () => {
execFileSync.mockReturnValue('-200 -50\n'); execFileSync.mockReturnValue('-200 -50\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
}); });
it('falls back when tmux returns NaN-producing tokens', () => { it('falls back when tmux returns NaN-producing tokens', () => {
execFileSync.mockReturnValue('abc def\n'); execFileSync.mockReturnValue('abc def\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
}); });
}); });
describe('queryTmuxWindowSize — call shape', () => { describe('queryTmuxWindowSize — call shape', () => {
it('invokes tmux with display -t <name> -p ... via argv (not a shell)', () => { it('invokes tmux on the dedicated socket via argv (not a shell)', () => {
execFileSync.mockReturnValue('120 40\n'); execFileSync.mockReturnValue('120 40\n');
queryTmuxWindowSize('codeman-abc'); queryTmuxWindowSize('codeman-abc', 'codeman');
expect(execFileSync).toHaveBeenCalledTimes(1); expect(execFileSync).toHaveBeenCalledTimes(1);
const [bin, argv, opts] = execFileSync.mock.calls[0]; const [bin, argv, opts] = execFileSync.mock.calls[0];
expect(bin).toBe('tmux'); expect(bin).toBe('tmux');
expect(argv).toEqual(['display', '-t', 'codeman-abc', '-p', '#{window_width} #{window_height}']); // `-L <socket>` MUST lead: querying the default server would never find a
// session that lives on the isolated Codeman socket (the #80 regression).
expect(argv).toEqual(['-L', 'codeman', 'display', '-t', 'codeman-abc', '-p', '#{window_width} #{window_height}']);
// execFileSync — not execSync — so muxName is never substituted into a shell string. // execFileSync — not execSync — so muxName is never substituted into a shell string.
expect(opts).toMatchObject({ encoding: 'utf8' }); expect(opts).toMatchObject({ encoding: 'utf8' });
}); });
it('uses a bounded timeout so a hung tmux server cannot block startup forever', () => { it('uses a bounded timeout so a hung tmux server cannot block startup forever', () => {
execFileSync.mockReturnValue('120 40\n'); execFileSync.mockReturnValue('120 40\n');
queryTmuxWindowSize('codeman-abc'); queryTmuxWindowSize('codeman-abc', 'codeman');
const [, , opts] = execFileSync.mock.calls[0]; const [, , opts] = execFileSync.mock.calls[0];
// Whatever the exact constant, the contract is: ≤5s so the user-visible // Whatever the exact constant, the contract is: ≤5s so the user-visible
// attach path can't hang on a stuck tmux server. // attach path can't hang on a stuck tmux server.
@@ -152,12 +155,12 @@ describe('queryTmuxWindowSize — call shape', () => {
it('passes a muxName that looks like a tmux flag as an argv element (no option injection)', () => { it('passes a muxName that looks like a tmux flag as an argv element (no option injection)', () => {
execFileSync.mockReturnValue('120 40\n'); execFileSync.mockReturnValue('120 40\n');
queryTmuxWindowSize('-x 1 -y 1; rm -rf'); queryTmuxWindowSize('-x 1 -y 1; rm -rf', 'codeman');
const [, argv] = execFileSync.mock.calls[0]; const [, argv] = execFileSync.mock.calls[0];
// The whole "name" lives in a single argv slot, so tmux interprets it as a // The whole "name" lives in a single argv slot (index 4, after `-L codeman
// target session name, not as additional flags. The `-t` flag preceding it // display -t`), so tmux interprets it as a target session name, not as
// pins it as the target argument. // additional flags. The `-t` flag preceding it pins it as the target.
expect(argv?.[2]).toBe('-x 1 -y 1; rm -rf'); expect(argv?.[4]).toBe('-x 1 -y 1; rm -rf');
expect((argv as string[]).indexOf('-x')).toBe(-1); expect((argv as string[]).indexOf('-x')).toBe(-1);
}); });
}); });