mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-01 04:59:41 +02:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8abf349cfc | ||
|
|
ae5bcf9330 | ||
|
|
78d5fcf70c | ||
|
|
1ff315a1e6 | ||
|
|
08de6667ab | ||
|
|
d27f8e77f7 | ||
|
|
e248cd8bcf | ||
|
|
73d81afd4d |
@@ -1,5 +1,25 @@
|
||||
# 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
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Resume Conversation: fixes and folder drill-down.
|
||||
- **fix(history)**: `decodeProjectKey()` now uses longest-join-first backtracking with on-disk validation, so sibling directories sharing a prefix (e.g. `diary/` vs `diary-app/`) resolve to the correct path. Previously the greedy shortest-match decoder picked the shorter name and bailed, surfacing `$HOME` in the Resume Conversation list and resuming into the wrong folder. Greedy decode is kept as a fallback so history for deleted projects still resolves. (#92)
|
||||
- **fix(tabs)**: Drop the client-side resurrection of ended-session tabs. The old code cached open tabs in `localStorage` and rebuilt them as grayed-out stubs whenever the server no longer knew them, which left phantom tabs after closing a session on another device. The server is now the single source of truth; legacy `localStorage` keys are purged on init. Net -44 / +6 lines. (#93)
|
||||
- **feat(history)**: New "View all in this folder" drill-down on Resume Conversation. `GET /api/history/sessions` accepts `projectKey` (validated against `^[A-Za-z0-9_-]+$` before any filesystem access), `offset`, and `limit`; single-folder mode bypasses the 50-cap and returns `{ sessions, total }`. Frontend adds a modal listing 20 sessions per page with a "Show more" pagination button. Modal items omit their own "View all" button to prevent recursive entry points. (#94)
|
||||
|
||||
## 0.6.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -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.10 (must match `package.json`)
|
||||
**Version**: 0.6.12 (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`.
|
||||
@@ -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
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.6.10",
|
||||
"version": "0.6.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "0.6.10",
|
||||
"version": "0.6.12",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.6.10",
|
||||
"version": "0.6.12",
|
||||
"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
@@ -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
@@ -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")
|
||||
|
||||
@@ -98,6 +98,9 @@ export interface TerminalMultiplexer extends EventEmitter {
|
||||
/** Which backend this instance uses */
|
||||
readonly backend: 'tmux';
|
||||
|
||||
/** The dedicated tmux socket name all sessions live on (e.g. "codeman"). */
|
||||
readonly muxSocket: string;
|
||||
|
||||
// ========== Lifecycle ==========
|
||||
|
||||
/**
|
||||
|
||||
+13
-6
@@ -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
@@ -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 {
|
||||
|
||||
+6
-42
@@ -1794,8 +1794,10 @@ class CodemanApp {
|
||||
}
|
||||
});
|
||||
|
||||
// Restore tabs that were open before refresh but are no longer on the server
|
||||
this._restoreEndedTabs();
|
||||
// Server is source of truth for open sessions — don't resurrect stale tabs
|
||||
// from localStorage (would show phantom "ended" tabs when a session was closed
|
||||
// on another device).
|
||||
try { localStorage.removeItem('codeman-tab-meta'); } catch {}
|
||||
|
||||
// Sync sessionOrder with current sessions (preserve order, add new, remove stale)
|
||||
this.syncSessionOrder();
|
||||
@@ -2117,8 +2119,7 @@ class CodemanApp {
|
||||
const tallTabsEnabled = this._tallTabsEnabled ?? false;
|
||||
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
|
||||
|
||||
const endedAttr = session._ended ? ' data-ended="1"' : '';
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}" data-id="${id}" data-color="${color}"${endedAttr} onclick="app.selectSession('${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}" data-id="${id}" data-color="${color}" onclick="app.selectSession('${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
<span class="tab-info">
|
||||
@@ -2138,9 +2139,6 @@ class CodemanApp {
|
||||
|
||||
container.innerHTML = parts.join('');
|
||||
|
||||
// Persist tab metadata for refresh recovery
|
||||
this._saveTabMetadata();
|
||||
|
||||
// Set up drag-and-drop handlers for tab reordering
|
||||
this.setupTabDragHandlers();
|
||||
|
||||
@@ -2240,33 +2238,6 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Save tab metadata to localStorage so ended sessions can be restored after refresh
|
||||
_saveTabMetadata() {
|
||||
try {
|
||||
const meta = {};
|
||||
for (const [id, s] of this.sessions) {
|
||||
if (s._ended) continue; // Don't persist ended stubs back
|
||||
meta[id] = { id, name: s.name || '', workingDir: s.workingDir || '', mode: s.mode || 'claude', color: s.color || 'default' };
|
||||
}
|
||||
localStorage.setItem('codeman-tab-meta', JSON.stringify(meta));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Restore tabs that were open before refresh but are no longer on the server
|
||||
_restoreEndedTabs() {
|
||||
try {
|
||||
const saved = localStorage.getItem('codeman-tab-meta');
|
||||
if (!saved) return;
|
||||
const meta = JSON.parse(saved);
|
||||
for (const [id, info] of Object.entries(meta)) {
|
||||
if (!this.sessions.has(id)) {
|
||||
// Add a stub session so the tab renders
|
||||
this.sessions.set(id, { id, name: info.name, workingDir: info.workingDir, mode: info.mode, color: info.color, status: 'ended', _ended: true });
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Set up drag-and-drop handlers on tab elements
|
||||
setupTabDragHandlers() {
|
||||
const container = this.$('sessionTabs');
|
||||
@@ -2546,16 +2517,9 @@ class CodemanApp {
|
||||
// Check if this is a restored session that needs to be attached
|
||||
const session = this.sessions.get(sessionId);
|
||||
|
||||
// Ended tabs (restored from localStorage, no longer on server) — show message, skip buffer load
|
||||
if (session?._ended) {
|
||||
this.terminal.clear();
|
||||
this.terminal.write('\r\n \x1b[2mSession ended. Close tab or click to reopen.\x1b[0m\r\n');
|
||||
return;
|
||||
}
|
||||
|
||||
// Track working directory for path normalization in Project Insights
|
||||
this.currentSessionWorkingDir = session?.workingDir || null;
|
||||
if (session && session.pid === null && !session._ended) {
|
||||
if (session && session.pid === null) {
|
||||
// Session has no PTY attached — either restored after server restart
|
||||
// or detached for some other reason. Re-attach regardless of status.
|
||||
try {
|
||||
|
||||
@@ -293,7 +293,6 @@ body {
|
||||
}
|
||||
.session-tab .tab-status.error { background: var(--red); }
|
||||
.session-tab .tab-status.ended { background: var(--text-muted); opacity: 0.5; }
|
||||
.session-tab[data-ended] { opacity: 0.55; }
|
||||
|
||||
/* Session color coding - left border indicator */
|
||||
.session-tab[data-color="red"] { border-left: 3px solid var(--session-red); }
|
||||
@@ -2370,6 +2369,56 @@ body {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.history-detail-actions {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.history-view-all-btn {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.75rem;
|
||||
background: rgba(99, 179, 237, 0.08);
|
||||
border: 1px solid rgba(99, 179, 237, 0.25);
|
||||
border-radius: 6px;
|
||||
color: rgba(99, 179, 237, 0.95);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-smooth), border-color var(--transition-smooth);
|
||||
}
|
||||
|
||||
.history-view-all-btn:hover {
|
||||
background: rgba(99, 179, 237, 0.15);
|
||||
border-color: rgba(99, 179, 237, 0.5);
|
||||
}
|
||||
|
||||
/* Folder history modal */
|
||||
.folder-history-modal .modal-body {
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.folder-history-subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-family: 'SF Mono', Menlo, Consolas, monospace;
|
||||
word-break: break-all;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.folder-history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.folder-history-empty {
|
||||
padding: 2rem 0.5rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.welcome-hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
|
||||
@@ -444,7 +444,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (
|
||||
activeResizeSession &&
|
||||
activeResizeSession.mode !== 'shell' &&
|
||||
!activeResizeSession._ended &&
|
||||
this.terminal &&
|
||||
this.isTerminalAtBottom()
|
||||
) {
|
||||
@@ -918,8 +917,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
.replace(/^\/Users\/[^/]+\//, '~/');
|
||||
},
|
||||
|
||||
/** Build a single history item DOM element */
|
||||
_buildHistoryItem(s, cases) {
|
||||
/**
|
||||
* Build a single history item DOM element.
|
||||
* @param {object} s session record
|
||||
* @param {Array} cases linked cases (for #caseName label)
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.showViewAll=true] show "View all in folder" button in detail panel
|
||||
*/
|
||||
_buildHistoryItem(s, cases, options) {
|
||||
const showViewAll = options?.showViewAll !== false;
|
||||
const size =
|
||||
s.sizeBytes < 1024
|
||||
? `${s.sizeBytes}B`
|
||||
@@ -1001,6 +1007,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
detail.append(promptRow, pathRow, metaRow);
|
||||
|
||||
if (showViewAll && s.projectKey) {
|
||||
const actionRow = document.createElement('div');
|
||||
actionRow.className = 'history-detail-row history-detail-actions';
|
||||
const viewAllBtn = document.createElement('button');
|
||||
viewAllBtn.type = 'button';
|
||||
viewAllBtn.className = 'history-view-all-btn';
|
||||
viewAllBtn.textContent = 'View all in this folder';
|
||||
viewAllBtn.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
this.openFolderHistoryModal(s.projectKey, s.workingDir, cases);
|
||||
});
|
||||
actionRow.appendChild(viewAllBtn);
|
||||
detail.appendChild(actionRow);
|
||||
}
|
||||
|
||||
expandBtn.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
const expanded = item.classList.toggle('expanded');
|
||||
@@ -1064,9 +1085,144 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/** Page size for the folder history modal */
|
||||
_FOLDER_HISTORY_PAGE_SIZE: 20,
|
||||
|
||||
/**
|
||||
* Open a modal showing all history sessions in a single folder.
|
||||
* Paginated by FOLDER_HISTORY_PAGE_SIZE; "Show more" loads next page.
|
||||
*/
|
||||
openFolderHistoryModal(projectKey, workingDir, cases) {
|
||||
// Close any existing instance first
|
||||
this._closeFolderHistoryModal();
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal active folder-history-modal';
|
||||
modal.id = 'folderHistoryModal';
|
||||
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop';
|
||||
backdrop.addEventListener('click', () => this._closeFolderHistoryModal());
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'modal-content modal-lg';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'modal-header';
|
||||
const title = document.createElement('h3');
|
||||
title.textContent = 'Folder History';
|
||||
const subtitle = document.createElement('div');
|
||||
subtitle.className = 'folder-history-subtitle';
|
||||
subtitle.textContent = this._shortenHomePath(workingDir);
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'modal-close';
|
||||
closeBtn.setAttribute('aria-label', 'Close');
|
||||
closeBtn.innerHTML = '×';
|
||||
closeBtn.addEventListener('click', () => this._closeFolderHistoryModal());
|
||||
header.append(title, closeBtn);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'modal-body';
|
||||
const list = document.createElement('div');
|
||||
list.className = 'folder-history-list';
|
||||
list.setAttribute('data-loading', 'true');
|
||||
list.textContent = 'Loading...';
|
||||
body.append(subtitle, list);
|
||||
|
||||
content.append(header, body);
|
||||
modal.append(backdrop, content);
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Track state for pagination
|
||||
this._folderHistoryState = {
|
||||
projectKey,
|
||||
workingDir,
|
||||
cases: cases || [],
|
||||
offset: 0,
|
||||
total: null,
|
||||
list,
|
||||
};
|
||||
|
||||
// ESC to close
|
||||
this._folderHistoryEscHandler = (ev) => {
|
||||
if (ev.key === 'Escape') this._closeFolderHistoryModal();
|
||||
};
|
||||
document.addEventListener('keydown', this._folderHistoryEscHandler);
|
||||
|
||||
this._loadFolderHistoryPage();
|
||||
},
|
||||
|
||||
async _loadFolderHistoryPage() {
|
||||
const state = this._folderHistoryState;
|
||||
if (!state) return;
|
||||
const { projectKey, cases, list } = state;
|
||||
const limit = this._FOLDER_HISTORY_PAGE_SIZE;
|
||||
const offset = state.offset;
|
||||
|
||||
// Remove existing "Show more" button while loading
|
||||
const existingMore = list.querySelector('.folder-history-more');
|
||||
if (existingMore) existingMore.remove();
|
||||
|
||||
// First page: clear loading placeholder
|
||||
if (offset === 0) {
|
||||
list.replaceChildren();
|
||||
list.removeAttribute('data-loading');
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `/api/history/sessions?projectKey=${encodeURIComponent(projectKey)}&offset=${offset}&limit=${limit}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
const sessions = data.sessions || [];
|
||||
state.total = typeof data.total === 'number' ? data.total : sessions.length + offset;
|
||||
|
||||
if (offset === 0 && sessions.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'folder-history-empty';
|
||||
empty.textContent = 'No conversations found in this folder.';
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const s of sessions) {
|
||||
list.appendChild(this._buildHistoryItem(s, cases, { showViewAll: false }));
|
||||
}
|
||||
|
||||
state.offset = offset + sessions.length;
|
||||
|
||||
// Add "Show more" if there are more sessions
|
||||
if (state.offset < state.total) {
|
||||
const remaining = state.total - state.offset;
|
||||
const moreBtn = document.createElement('button');
|
||||
moreBtn.className = 'history-show-more folder-history-more';
|
||||
moreBtn.textContent = `Show ${Math.min(limit, remaining)} more (${remaining} remaining)`;
|
||||
moreBtn.addEventListener('click', () => this._loadFolderHistoryPage());
|
||||
list.appendChild(moreBtn);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[loadFolderHistoryPage]', err);
|
||||
const errorEl = document.createElement('div');
|
||||
errorEl.className = 'folder-history-empty';
|
||||
errorEl.textContent = 'Failed to load folder history.';
|
||||
list.appendChild(errorEl);
|
||||
}
|
||||
},
|
||||
|
||||
_closeFolderHistoryModal() {
|
||||
const modal = document.getElementById('folderHistoryModal');
|
||||
if (modal) modal.remove();
|
||||
if (this._folderHistoryEscHandler) {
|
||||
document.removeEventListener('keydown', this._folderHistoryEscHandler);
|
||||
this._folderHistoryEscHandler = null;
|
||||
}
|
||||
this._folderHistoryState = null;
|
||||
},
|
||||
|
||||
async resumeHistorySession(sessionId, workingDir) {
|
||||
// Close the run mode menu if open
|
||||
document.getElementById('runModeMenu')?.classList.remove('active');
|
||||
// Close folder history modal if open
|
||||
this._closeFolderHistoryModal();
|
||||
try {
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Resuming conversation ${sessionId.slice(0, 8)}...\x1b[0m`);
|
||||
|
||||
@@ -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);
|
||||
@@ -1324,42 +1331,76 @@ export function registerSessionRoutes(
|
||||
* Claude CLI encodes both '/' and '_' as '-', so each '-' in the key could be
|
||||
* any of: '/' (path separator), '_' (underscore), or '-' (literal dash).
|
||||
*
|
||||
* Strategy: look-ahead matching. At each '-', try consuming multiple segments
|
||||
* joined by '_' or '-' to find an existing child directory, then recurse.
|
||||
* E.g. for segments [AI, project, Mirror] inside /Workspace:
|
||||
* try /Workspace/AI (no) -> /Workspace/AI_project (yes!) -> continue with [Mirror]
|
||||
* Strategy: recursive backtracking with longest-match-first preference.
|
||||
* At each segment boundary, try joining as many segments as possible (with '_'
|
||||
* or '-') into a single existing directory name. If a shorter match leads to a
|
||||
* dead end, backtrack and try the next-shorter candidate.
|
||||
*
|
||||
* Why backtracking: when both `diary/` and `diary-app/` exist as siblings, the
|
||||
* naive shortest-match would pick `diary` and then fail to find `app` inside,
|
||||
* leaving the rest of the key unresolved. Longest-first picks `diary-app`.
|
||||
*/
|
||||
async function decodeProjectKey(projKey: string): Promise<string> {
|
||||
const encoded = projKey.startsWith('-') ? projKey.slice(1) : projKey;
|
||||
const segments = encoded.split('-');
|
||||
|
||||
const isDir = async (p: string): Promise<boolean> =>
|
||||
fs
|
||||
const isDirCache = new Map<string, boolean>();
|
||||
const isDir = async (p: string): Promise<boolean> => {
|
||||
const cached = isDirCache.get(p);
|
||||
if (cached !== undefined) return cached;
|
||||
const result = await fs
|
||||
.stat(p)
|
||||
.then((s) => s.isDirectory())
|
||||
.catch(() => false);
|
||||
isDirCache.set(p, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Recursive backtracking: returns the deepest valid path that consumes all
|
||||
// segments. Tries the longest segment-join first at each step so that
|
||||
// dash-containing directory names win over shorter same-prefix siblings.
|
||||
async function tryDecode(idx: number, current: string): Promise<string | null> {
|
||||
if (idx >= segments.length) return current;
|
||||
const maxLook = Math.min(idx + 4, segments.length);
|
||||
// Longest first: end = maxLook-1 down to idx
|
||||
for (let end = maxLook - 1; end >= idx; end--) {
|
||||
const candidates: string[] = [];
|
||||
if (end === idx) {
|
||||
candidates.push(segments[idx]);
|
||||
} else {
|
||||
candidates.push(segments.slice(idx, end + 1).join('-'));
|
||||
candidates.push(segments.slice(idx, end + 1).join('_'));
|
||||
}
|
||||
for (const child of candidates) {
|
||||
const candidate = current + '/' + child;
|
||||
if (await isDir(candidate)) {
|
||||
const result = await tryDecode(end + 1, candidate);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const decoded = await tryDecode(0, '');
|
||||
if (decoded) return decoded;
|
||||
|
||||
// Fallback: greedy shortest-match (original behavior) — best effort when
|
||||
// no fully-valid path exists (e.g. directory was deleted after the
|
||||
// conversation was recorded).
|
||||
let current = '';
|
||||
let i = 0;
|
||||
|
||||
while (i < segments.length) {
|
||||
// Try progressively longer child names by joining segments with '_' or '-'
|
||||
let matched = false;
|
||||
// Limit look-ahead to avoid excessive fs checks (max 4 segments per component)
|
||||
const maxLook = Math.min(i + 4, segments.length);
|
||||
for (let end = i; end < maxLook; end++) {
|
||||
// Build candidate child name from segments[i..end]
|
||||
// Try all separator combinations: for 2+ segments, try '_' first then '-'
|
||||
const candidates: string[] = [];
|
||||
if (end === i) {
|
||||
candidates.push(segments[i]);
|
||||
} else {
|
||||
// Build with underscores between joined segments
|
||||
candidates.push(segments.slice(i, end + 1).join('_'));
|
||||
// Build with dashes (literal)
|
||||
candidates.push(segments.slice(i, end + 1).join('-'));
|
||||
}
|
||||
|
||||
for (const child of candidates) {
|
||||
const candidate = current + '/' + child;
|
||||
if (await isDir(candidate)) {
|
||||
@@ -1372,12 +1413,10 @@ export function registerSessionRoutes(
|
||||
if (matched) break;
|
||||
}
|
||||
if (!matched) {
|
||||
// No directory match found — append as-is and move on
|
||||
current = current + '/' + segments[i];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
const finalExists = await fs
|
||||
.access(current)
|
||||
.then(() => true)
|
||||
@@ -1416,96 +1455,104 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/api/history/sessions', async () => {
|
||||
type HistorySession = {
|
||||
sessionId: string;
|
||||
workingDir: string;
|
||||
projectKey: string;
|
||||
sizeBytes: number;
|
||||
lastModified: string;
|
||||
firstPrompt?: string;
|
||||
};
|
||||
|
||||
// Scan a single project directory and return all valid history sessions in it.
|
||||
// Reused by both the global overview and the single-folder drill-down.
|
||||
async function scanProjectDir(projPath: string, projDir: string, headBuf: Buffer): Promise<HistorySession[]> {
|
||||
const out: HistorySession[] = [];
|
||||
const stat = await fs.stat(projPath).catch(() => null);
|
||||
if (!stat?.isDirectory()) return out;
|
||||
|
||||
const workingDir = await decodeProjectKey(projDir);
|
||||
const entries = await fs.readdir(projPath).catch(() => [] as string[]);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith('.jsonl')) continue;
|
||||
const sessionId = entry.replace('.jsonl', '');
|
||||
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(sessionId)) continue;
|
||||
|
||||
const filePath = join(projPath, entry);
|
||||
const fileStat = await fs.stat(filePath).catch(() => null);
|
||||
if (!fileStat) continue;
|
||||
if (fileStat.size < 4000) continue;
|
||||
|
||||
let firstPrompt: string | undefined;
|
||||
const head = await readFileHead(filePath, headBuf);
|
||||
const hasConversation = (text: string) =>
|
||||
text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"');
|
||||
|
||||
let foundContent = head ? hasConversation(head) : false;
|
||||
let tail: string | null = null;
|
||||
if (!foundContent && fileStat.size > 16384) {
|
||||
const tailBuf = Buffer.alloc(32768);
|
||||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||
if (tail) foundContent = hasConversation(tail);
|
||||
}
|
||||
if (!foundContent) continue;
|
||||
|
||||
if (head) firstPrompt = extractFirstUserPrompt(head);
|
||||
if (!firstPrompt && fileStat.size > 65536) {
|
||||
if (!tail) {
|
||||
const tailBuf = Buffer.alloc(32768);
|
||||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||
}
|
||||
if (tail) firstPrompt = extractFirstUserPrompt(tail);
|
||||
}
|
||||
|
||||
out.push({
|
||||
sessionId,
|
||||
workingDir,
|
||||
projectKey: projDir,
|
||||
sizeBytes: fileStat.size,
|
||||
lastModified: fileStat.mtime.toISOString(),
|
||||
firstPrompt,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
app.get('/api/history/sessions', async (req) => {
|
||||
const query = req.query as { projectKey?: string; offset?: string; limit?: string };
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
const results: Array<{
|
||||
sessionId: string;
|
||||
workingDir: string;
|
||||
projectKey: string;
|
||||
sizeBytes: number;
|
||||
lastModified: string;
|
||||
firstPrompt?: string;
|
||||
}> = [];
|
||||
const headBuf = Buffer.alloc(16384);
|
||||
|
||||
// Single-folder drill-down: when projectKey is provided, scan only that
|
||||
// directory, bypass the 50-cap, and honor offset/limit pagination.
|
||||
if (query.projectKey) {
|
||||
// Validate projectKey format to prevent path traversal
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(query.projectKey)) {
|
||||
return { sessions: [], total: 0 };
|
||||
}
|
||||
const offset = Math.max(0, parseInt(query.offset || '0', 10) || 0);
|
||||
const limit = Math.min(100, Math.max(1, parseInt(query.limit || '20', 10) || 20));
|
||||
const projPath = join(projectsDir, query.projectKey);
|
||||
const all = await scanProjectDir(projPath, query.projectKey, headBuf);
|
||||
all.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
||||
return { sessions: all.slice(offset, offset + limit), total: all.length };
|
||||
}
|
||||
|
||||
// Global overview: scan all projects, return up to 50 most-recent sessions.
|
||||
const results: HistorySession[] = [];
|
||||
try {
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
for (const projDir of projectDirs) {
|
||||
const projPath = join(projectsDir, projDir);
|
||||
const stat = await fs.stat(projPath).catch(() => null);
|
||||
if (!stat?.isDirectory()) continue;
|
||||
|
||||
// Decode project key to working dir. Claude CLI encodes '/' as '-',
|
||||
// but path components may also contain '-' (e.g. "AI_project" vs "AI-project").
|
||||
// Use recursive backtracking: try each '-' as either '/' or literal '-',
|
||||
// verify which decoded path actually exists on disk.
|
||||
const workingDir = await decodeProjectKey(projDir);
|
||||
|
||||
const entries = await fs.readdir(projPath);
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith('.jsonl')) continue;
|
||||
const sessionId = entry.replace('.jsonl', '');
|
||||
// Only valid UUIDs
|
||||
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(sessionId)) continue;
|
||||
|
||||
const filePath = join(projPath, entry);
|
||||
const fileStat = await fs.stat(filePath).catch(() => null);
|
||||
if (!fileStat) continue;
|
||||
// Skip files too small to contain real conversation (metadata-only sessions
|
||||
// like file-history-snapshot entries are typically < 4KB)
|
||||
if (fileStat.size < 4000) continue;
|
||||
|
||||
// Quick content check: verify actual conversation data exists.
|
||||
// Sessions with only file-history-snapshot or hook_progress entries have
|
||||
// no "user"/"assistant" messages and will fail claude --resume.
|
||||
// Read first 16KB to check content and extract first user prompt.
|
||||
let firstPrompt: string | undefined;
|
||||
const head = await readFileHead(filePath, headBuf);
|
||||
const hasConversation = (text: string) =>
|
||||
text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"');
|
||||
|
||||
let foundContent = head ? hasConversation(head) : false;
|
||||
|
||||
// For large files, head may not contain user messages (e.g. /init followed
|
||||
// by large system entries). Check the tail as well.
|
||||
let tail: string | null = null;
|
||||
if (!foundContent && fileStat.size > 16384) {
|
||||
const tailBuf = Buffer.alloc(32768);
|
||||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||
if (tail) foundContent = hasConversation(tail);
|
||||
}
|
||||
|
||||
if (!foundContent) continue; // No conversation content — skip
|
||||
|
||||
if (head) firstPrompt = extractFirstUserPrompt(head);
|
||||
|
||||
// If head scan found no usable prompt (e.g. session started with /init),
|
||||
// try reading the tail for a recent user message.
|
||||
if (!firstPrompt && fileStat.size > 65536) {
|
||||
if (!tail) {
|
||||
const tailBuf = Buffer.alloc(32768);
|
||||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||
}
|
||||
if (tail) firstPrompt = extractFirstUserPrompt(tail);
|
||||
}
|
||||
|
||||
results.push({
|
||||
sessionId,
|
||||
workingDir,
|
||||
projectKey: projDir,
|
||||
sizeBytes: fileStat.size,
|
||||
lastModified: fileStat.mtime.toISOString(),
|
||||
firstPrompt,
|
||||
});
|
||||
}
|
||||
const list = await scanProjectDir(projPath, projDir, headBuf);
|
||||
results.push(...list);
|
||||
}
|
||||
} catch {
|
||||
// Projects dir may not exist
|
||||
}
|
||||
|
||||
// Sort by lastModified descending
|
||||
results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
||||
|
||||
return { sessions: results.slice(0, 50) };
|
||||
});
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
|
||||
|
||||
// -- InfraPort --
|
||||
mux: {
|
||||
muxSocket: 'codeman',
|
||||
createSession: vi.fn(),
|
||||
killSession: vi.fn(),
|
||||
listSessions: vi.fn(() => []),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user