diff --git a/CLAUDE.md b/CLAUDE.md index 394526c..5744abf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,14 +16,21 @@ codex-collab health | File | Purpose | |------|---------| -| `src/cli.ts` | CLI commands, argument parsing, output formatting | -| `src/protocol.ts` | JSON-RPC client for Codex app server (spawn, handshake, request routing) | -| `src/threads.ts` | Thread lifecycle, short ID mapping | +| `src/cli.ts` | CLI router, argument parsing, signal handlers | +| `src/client.ts` | JSON-RPC client for Codex app server (spawn, handshake, request routing) | +| `src/commands/` | CLI command handlers (run, review, threads, kill, config, approve) | +| `src/threads.ts` | Thread index, run ledger, short ID mapping | | `src/turns.ts` | Turn lifecycle (runTurn, runReview), event wiring | | `src/events.ts` | Event dispatcher (progress lines, log writer, output accumulator) | | `src/approvals.ts` | Approval handler abstraction (auto-approve, interactive IPC) | | `src/types.ts` | Protocol types (JSON-RPC, threads, turns, items, approvals) | -| `src/config.ts` | Configuration constants | +| `src/config.ts` | Configuration constants, workspace resolution | +| `src/broker.ts` | Shared app-server lifecycle (connection pooling) | +| `src/broker-client.ts` | Socket-based client for connecting to the broker server | +| `src/broker-server.ts` | Detached broker server process (multiplexes JSON-RPC between clients and app-server) | +| `src/process.ts` | Process spawn/lifecycle utilities | +| `src/git.ts` | Git operations (diff, log, status) | +| `src/reviews.ts` | Review validation, structured output parsing | | `SKILL.md` | Claude Code skill definition | ## Dependencies @@ -33,10 +40,10 @@ codex-collab health ## Architecture Notes - Communicates with Codex via `codex app-server` JSON-RPC protocol over stdio -- Threads stored in `~/.codex-collab/threads.json` as short ID → full ID mapping -- Logs stored in `~/.codex-collab/logs/` per thread +- Per-workspace state under `~/.codex-collab/workspaces/{slug}-{hash}/` (threads, logs, runs, approvals, kill signals, PIDs) - User defaults stored in `~/.codex-collab/config.json` (model, reasoning, sandbox, approval, timeout) -- Approval requests use file-based IPC in `~/.codex-collab/approvals/` +- Broker manages a shared app-server per workspace via Unix socket / named pipe; falls back to direct connection when broker is busy (parallel execution) or unavailable - Short IDs are 8-char hex, support prefix resolution +- Run ledger tracks per-invocation state (status, timing, output) under `runs/` - Bun is the TypeScript runtime — never use npm/yarn/pnpm for running - Skill installed to `~/.claude/skills/codex-collab/` via `install.sh` (build + copy; `--dev` for symlinks) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2c5facc..af75863 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,14 +41,21 @@ The codebase is organized into focused modules: | File | Purpose | |------|---------| -| `src/cli.ts` | CLI commands, argument parsing, output formatting | -| `src/protocol.ts` | JSON-RPC client for Codex app server | -| `src/threads.ts` | Thread lifecycle, short ID mapping | -| `src/turns.ts` | Turn lifecycle, event wiring | -| `src/events.ts` | Event dispatcher, log writer | +| `src/cli.ts` | CLI router, signal handlers | +| `src/client.ts` | JSON-RPC client for Codex app server (spawn, handshake, request routing) | +| `src/commands/` | CLI command handlers (run, review, threads, kill, config, approve) | +| `src/broker.ts` | Shared app-server lifecycle (connection pooling, busy fallback) | +| `src/broker-server.ts` | Detached broker process (multiplexes JSON-RPC between clients and app-server) | +| `src/broker-client.ts` | Socket-based client for connecting to the broker server | +| `src/threads.ts` | Thread index, run ledger, short ID mapping | +| `src/turns.ts` | Turn lifecycle (runTurn, runReview), event wiring | +| `src/events.ts` | Event dispatcher, log writer, output accumulator | | `src/approvals.ts` | Approval handler abstraction | -| `src/types.ts` | Protocol types | -| `src/config.ts` | Configuration constants | +| `src/types.ts` | Protocol types (JSON-RPC, threads, turns, items, approvals) | +| `src/config.ts` | Configuration constants, workspace resolution | +| `src/process.ts` | Process spawn/lifecycle utilities | +| `src/git.ts` | Git operations (diff, log, status) | +| `src/reviews.ts` | Review validation, structured output parsing | ## Pull Requests diff --git a/README.md b/README.md index b5ccb28..7ca0176 100644 --- a/README.md +++ b/README.md @@ -80,10 +80,11 @@ codex-collab run --resume "now check error handling" --content-only |---------|-------------| | `run "prompt" [opts]` | Start thread, send prompt, wait, print output | | `review [opts]` | Code review (PR, uncommitted, commit) | -| `jobs [--json] [--all]` | List threads (`--limit ` to cap) | +| `threads [--json] [--all]` | List threads (`--limit ` to cap, `--discover` to scan server) | | `kill ` | Interrupt running thread | | `output ` | Full log for thread | | `progress ` | Recent activity (tail of log) | +| `config [key] [value]` | Show or set persistent defaults | | `models` | List available models | | `health` | Check dependencies | @@ -112,6 +113,7 @@ codex-collab run --resume "now check error handling" --content-only | `--ref ` | Commit ref for `--mode commit` | | `--resume ` | Resume existing thread | | `--approval ` | Approval policy: never, on-request, on-failure, untrusted (default: never) | +| `--template ` | Prompt template for run command (user `~/.codex-collab/templates/` or built-in) | | `--content-only` | Suppress progress lines; with `output`, return only extracted content | | `--timeout ` | Turn timeout (default: 1200) | | `--base ` | Base branch for PR review (default: main) | diff --git a/README.zh-CN.md b/README.zh-CN.md index 72b9a6a..26c193b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -80,10 +80,11 @@ codex-collab run --resume "现在检查错误处理" --content-only |------|------| | `run "prompt" [opts]` | 新建会话、发送提示、等待完成并输出结果 | | `review [opts]` | 代码审查(PR、未提交更改、指定 commit) | -| `jobs [--json] [--all]` | 列出会话(`--limit ` 限制数量) | +| `threads [--json] [--all]` | 列出会话(`--limit ` 限制数量,`--discover` 扫描服务器) | | `kill ` | 中断运行中的会话 | | `output ` | 查看会话完整日志 | | `progress ` | 查看近期活动(日志尾部) | +| `config [key] [value]` | 查看或设置持久化默认值 | | `models` | 列出可用模型 | | `health` | 检查依赖项 | @@ -112,6 +113,7 @@ codex-collab run --resume "现在检查错误处理" --content-only | `--ref ` | 指定 commit 哈希(配合 `--mode commit`) | | `--resume ` | 恢复已有会话 | | `--approval ` | 审批策略: never, on-request, on-failure, untrusted(默认: never) | +| `--template ` | 提示词模板(run 命令;优先使用 `~/.codex-collab/templates/`,然后使用内置模板) | | `--content-only` | 隐藏进度输出;配合 `output` 时仅返回正文内容 | | `--timeout ` | 单轮超时时间,单位秒(默认: 1200) | | `--base ` | PR 审查的基准分支(默认: main) | diff --git a/SKILL.md b/SKILL.md index b66386e..a127d1f 100644 --- a/SKILL.md +++ b/SKILL.md @@ -11,7 +11,7 @@ codex-collab is a bridge between Claude and Codex. It communicates with Codex vi - **Run** — Single-command `run` for any prompted task (research, analysis, implementation). Starts a thread, sends prompt, waits for completion, returns output. - **Code review** — Single-command `review` for PR-style, uncommitted, or commit-level review. -- **Parallel work** — You and Codex work on different parts simultaneously. Start multiple jobs. +- **Parallel work** — You and Codex work on different parts simultaneously. Start multiple threads. - **Research** — Spin up a read-only Codex session to investigate something while you continue other work. ## Run Command (Recommended for Prompted Tasks) @@ -32,20 +32,17 @@ codex-collab run --resume "now check the error handling" --content-only codex-collab run "investigate the auth module" -d /path/to/project --content-only ``` -**IMPORTANT: Always use `dangerouslyDisableSandbox=true`** for all `codex-collab` Bash commands — the tool writes to `~/.codex-collab/` which is outside the sandbox allowlist. +**IMPORTANT — Execution rules for ALL `codex-collab` Bash commands:** +- Always use `dangerouslyDisableSandbox=true` — the tool writes to `~/.codex-collab/` which is outside the sandbox allowlist. +- For `run` and `review`: also use `run_in_background=true` — these take minutes. After launching, tell the user it's running and end your turn. **While running**: do NOT poll, block, wait, or spawn an agent to monitor — you will be notified automatically when the task finishes. If other tasks complete while Codex is running, handle them normally without checking on Codex. **When notified**: read the completed task's output (the notification includes it or use `Read` on the output file), then present the results to the user. +- For all other commands (`kill`, `threads`, `progress`, `output`, `approve`, `decline`, `clean`, `delete`, `models`, `health`): run in the **foreground** — they complete in seconds. -For **`run` and `review`** commands, also use `run_in_background=true` — these take minutes. You will be notified automatically when the command finishes. After launching, tell the user it's running and end your turn. Do NOT use TaskOutput, block, poll, wait, or spawn an agent to monitor the result — the background task notification handles this automatically. If other background tasks complete while a Codex task is still running, handle those completed tasks normally — do NOT proactively check on, wait for, or poll the still-running Codex task. It will notify you when it finishes. - -For **all other commands** (`kill`, `jobs`, `progress`, `output`, `approve`, `decline`, `clean`, `delete`, `models`, `health`), run in the **foreground** — they complete in seconds. - -If the user asks about progress mid-task, use `progress` to check the recent activity: +If the user asks about progress mid-task, use `TaskOutput(block=false)` to read the background output stream, or: ```bash codex-collab progress ``` -Or use `TaskOutput(block=false)` to check the current output stream without blocking. - ## Code Review (Recommended: Single Command) The `review` command handles the entire review workflow in one call. @@ -69,9 +66,7 @@ codex-collab review "Focus on security issues" -d /path/to/project --content-onl codex-collab review --resume -d /path/to/project --content-only ``` -Review modes: `pr` (default), `uncommitted`, `commit` - -**IMPORTANT: Use `run_in_background=true` and `dangerouslyDisableSandbox=true`** — reviews typically take 5-20 minutes. You will be notified automatically when done. After launching, tell the user it's running and end your turn. Do NOT use TaskOutput, block, poll, wait, or spawn an agent to monitor the result — the background task notification handles this automatically. If other background tasks complete while a review is still running, handle those completed tasks normally — do NOT proactively check on or wait for the review. +Review modes: `pr` (default), `uncommitted`, `commit`, `custom` ## Context Efficiency @@ -83,6 +78,12 @@ Review modes: `pr` (default), `uncommitted`, `commit` When consecutive tasks relate to the same project, resume the existing thread. Codex retains the conversation history, so follow-ups like "now fix what you found" or "check the tests too" work better when Codex already has context from the previous exchange. Start a fresh thread when the task is unrelated or targets a different project. +**Before starting a new thread for a follow-up**, run `codex-collab resume-candidate --json` first. If it returns `{ "available": true, "threadId": "...", "shortId": "...", "name": "..." }`, use `--resume ` instead of starting fresh. This finds the best resumable thread across the current session, prior sessions, and TUI-created threads. + +The `--resume` flag accepts both ID formats: +- `--resume ` — 8-char hex short ID (supports prefix matching, e.g., `a1b2`) +- `--resume ` — Full Codex thread ID (UUID, e.g., `019d680c-7b23-7f22-ab99-6584214a2bed`) + | Situation | Action | |-----------|--------| | Same project, new prompt | `codex-collab run --resume "prompt"` | @@ -90,7 +91,7 @@ When consecutive tasks relate to the same project, resume the existing thread. C | Different project | Start new thread | | Thread stuck / errored | `codex-collab kill ` then start new | -If you've lost track of the thread ID, use `codex-collab jobs` to find active threads. +If you've lost track of the thread ID, use `codex-collab threads` to find active threads. ## Checking Progress @@ -100,7 +101,7 @@ If the user asks about a running task, use `TaskOutput(block=false)` to read the codex-collab progress ``` -Note: `` is the codex-collab thread short ID (8-char hex from the output), not the Claude Code background task ID. If you don't have it, run `codex-collab jobs`. +Note: `` is the codex-collab thread short ID (8-char hex from the output), not the Claude Code background task ID. If you don't have it, run `codex-collab threads`. Progress lines stream in real-time during execution: ``` @@ -165,13 +166,18 @@ codex-collab progress # Recent activity (tail of log) ### Thread Management ```bash -codex-collab jobs # List threads -codex-collab jobs --json # List threads (JSON) +codex-collab threads # List threads (current session) +codex-collab threads --all # List all threads (no display limit) +codex-collab threads --discover # Discover threads from Codex server +codex-collab threads --json # List threads (JSON) +codex-collab resume-candidate --json # Find best resumable thread codex-collab kill # Stop a running thread codex-collab delete # Archive thread, delete local files codex-collab clean # Delete old logs and stale mappings ``` +Note: `jobs` still works as a deprecated alias for `threads`. + ### Utility ```bash @@ -190,32 +196,49 @@ codex-collab health # Check prerequisites | Flag | Description | |------|-------------| | `-m, --model ` | Model name (default: auto — latest available) | -| `-r, --reasoning ` | Reasoning effort: low, medium, high, xhigh (default: auto — highest for model) | +| `-r, --reasoning ` | Reasoning effort: none, minimal, low, medium, high, xhigh (default: auto — highest for model) | | `-s, --sandbox ` | Sandbox: read-only, workspace-write, danger-full-access (default: workspace-write; review always uses read-only) | | `-d, --dir ` | Working directory (default: cwd) | | `--resume ` | Resume existing thread (run and review) | -| `--timeout ` | Turn timeout in seconds (default: 1200) | +| `--timeout ` | Turn timeout in seconds (default: 1200). Do not lower this — Codex tasks routinely take 5-15 minutes. Increase for large reviews or complex tasks. | | `--approval ` | Approval policy: never, on-request, on-failure, untrusted (default: never) | -| `--mode ` | Review mode: pr, uncommitted, commit | +| `--mode ` | Review mode: pr, uncommitted, commit, custom | | `--ref ` | Commit ref for --mode commit | -| `--json` | JSON output (jobs command) | +| `--all` | List all threads with no display limit (threads command) | +| `--discover` | Query Codex server for threads not in local index (threads command) | +| `--json` | JSON output (threads, resume-candidate commands) | +| `--template ` | Prompt template for run command (checks `~/.codex-collab/templates/` first, then built-in) | | `--content-only` | Print only result text (no progress lines) | | `--limit ` | Limit items shown | +## Templates + +Use `--template ` with the `run` command to wrap your prompt in a structured template. + + + +Custom templates: place `.md` files with frontmatter in `~/.codex-collab/templates/`, then re-run the installer. + +## TUI Handoff + +To hand off a thread to the Codex TUI, look up the full thread ID with `codex-collab threads --json` and then run `codex resume ` in the terminal. + ## Tips - **`run --resume` requires a prompt.** `review --resume` works without one (it uses the review workflow), but `run --resume ` will error if no prompt is given. - **Omit `-d` if already in the project directory** — it defaults to cwd. Only pass `-d` when the target project differs from your current directory. -- **Multiple concurrent threads** are supported. Each gets its own Codex app-server process and thread ID. +- **Multiple concurrent threads** are supported. Threads share a per-workspace broker for efficient resource usage. - **Validate Codex's findings.** After reading Codex's review or analysis output, verify each finding against the actual source code before presenting to the user. Drop false positives, note which findings you verified. +- **Per-workspace scoping.** Threads and state are scoped per workspace (git repo root). Different repos have independent thread lists. +- **First invocation per workspace** may take slightly longer to initialize; subsequent calls in the same session reuse the connection context. ## Error Recovery | Symptom | Fix | |---------|-----| | "codex CLI not found" | Install: `npm install -g @openai/codex` | -| Turn timed out | Increase `--timeout` or check if the task is too large | -| Thread not found | Use `codex-collab jobs` to list active threads | +| Turn timed out | Increase `--timeout` (e.g., `--timeout 1800` for 30 min). Large reviews and complex tasks often need more than the 20-min default. | +| Thread not found | Use `codex-collab threads` to list active threads | | Process crashed mid-task | Resume with `--resume ` — thread state is persisted | | Approval request hanging | Run `codex-collab approve ` or `codex-collab decline ` | diff --git a/install.ps1 b/install.ps1 index 5525e8d..511bf64 100644 --- a/install.ps1 +++ b/install.ps1 @@ -54,6 +54,57 @@ try { Pop-Location } +# --------------------------------------------------------------------------- +# Generate SKILL.md with injected template table +# --------------------------------------------------------------------------- + +function Generate-SkillMd { + param([string]$OutPath) + + $rows = @() + + # Scan built-in templates + $builtinDir = Join-Path $RepoDir "src\prompts" + if (Test-Path $builtinDir) { + foreach ($tmpl in Get-ChildItem $builtinDir -Filter "*.md") { + $name = $tmpl.BaseName + $content = Get-Content $tmpl.FullName -Raw + $desc = "(no description)"; $sandbox = "" + if ($content -match "(?ms)^---\s*\n(.+?)\n---") { + $fm = $Matches[1] + if ($fm -match "description:\s*(.+)") { $desc = $Matches[1].Trim() } + if ($fm -match "sandbox:\s*(.+)") { $sandbox = " ($($Matches[1].Trim()))" } + } + $rows += "| ``$name`` | $desc$sandbox |" + } + } + + # Scan user templates + $userDir = Join-Path $env:USERPROFILE ".codex-collab\templates" + if (Test-Path $userDir) { + foreach ($tmpl in Get-ChildItem $userDir -Filter "*.md") { + $name = $tmpl.BaseName + $content = Get-Content $tmpl.FullName -Raw + $desc = "(no description)"; $sandbox = "" + if ($content -match "(?ms)^---\s*\n(.+?)\n---") { + $fm = $Matches[1] + if ($fm -match "description:\s*(.+)") { $desc = $Matches[1].Trim() } + if ($fm -match "sandbox:\s*(.+)") { $sandbox = " ($($Matches[1].Trim()))" } + } + $rows += "| ``$name`` | $desc$sandbox |" + } + } + + $skillContent = Get-Content (Join-Path $RepoDir "SKILL.md") -Raw + if ($rows.Count -gt 0) { + $table = "| Template | Description |`n|----------|-------------|`n" + ($rows -join "`n") + $skillContent = $skillContent -replace "", $table + } else { + $skillContent = $skillContent -replace "", "No templates found." + } + [System.IO.File]::WriteAllText($OutPath, $skillContent, [System.Text.UTF8Encoding]::new($false)) +} + if ($Dev) { Write-Host "Installing in dev mode (symlinks)..." Write-Host "Note: Symlinks on Windows may require Developer Mode or elevated privileges." @@ -61,10 +112,13 @@ if ($Dev) { # Create skill directory New-Item -ItemType Directory -Path (Join-Path $SkillDir "scripts") -Force | Out-Null + # Generate SKILL.md with template table (can't inject into a symlink) + Generate-SkillMd -OutPath (Join-Path $SkillDir "SKILL.md") + # Symlink skill files (requires Developer Mode or elevated privileges) $links = @( - @{ Path = (Join-Path $SkillDir "SKILL.md"); Target = (Join-Path $RepoDir "SKILL.md") } @{ Path = (Join-Path $SkillDir "scripts\codex-collab"); Target = (Join-Path $RepoDir "src\cli.ts") } + @{ Path = (Join-Path $SkillDir "scripts\broker-server"); Target = (Join-Path $RepoDir "src\broker-server.ts") } @{ Path = (Join-Path $SkillDir "LICENSE.txt"); Target = (Join-Path $RepoDir "LICENSE") } ) @@ -94,23 +148,32 @@ if ($Dev) { if (Test-Path $skillBuild) { Remove-Item $skillBuild -Recurse -Force } New-Item -ItemType Directory -Path (Join-Path $skillBuild "scripts") -Force | Out-Null - $built = Join-Path $skillBuild "scripts\codex-collab" + # Build CLI and broker server + $cliBuild = Join-Path $skillBuild "scripts\codex-collab" + $brokerBuild = Join-Path $skillBuild "scripts\broker-server" try { - bun build (Join-Path $RepoDir "src\cli.ts") --outfile $built --target bun - if ($LASTEXITCODE -ne 0) { throw "'bun build' failed with exit code $LASTEXITCODE" } + bun build (Join-Path $RepoDir "src\cli.ts") --outfile $cliBuild --target bun + if ($LASTEXITCODE -ne 0) { throw "'bun build cli' failed with exit code $LASTEXITCODE" } + bun build (Join-Path $RepoDir "src\broker-server.ts") --outfile $brokerBuild --target bun + if ($LASTEXITCODE -ne 0) { throw "'bun build broker-server' failed with exit code $LASTEXITCODE" } } catch { Write-Host "Error: $_" exit 1 } - # Prepend shebang if missing (needed for Unix execution; harmless on Windows with Bun) - $content = Get-Content $built -Raw - if (-not $content.StartsWith("#!/")) { - [System.IO.File]::WriteAllText($built, "#!/usr/bin/env bun`n" + $content, [System.Text.UTF8Encoding]::new($false)) + # Prepend shebangs if missing (needed for Unix execution; harmless on Windows with Bun) + foreach ($built in @($cliBuild, $brokerBuild)) { + $content = Get-Content $built -Raw + if (-not $content.StartsWith("#!/")) { + [System.IO.File]::WriteAllText($built, "#!/usr/bin/env bun`n" + $content, [System.Text.UTF8Encoding]::new($false)) + } } - # Copy SKILL.md and LICENSE - Copy-Item (Join-Path $RepoDir "SKILL.md") (Join-Path $skillBuild "SKILL.md") + # Copy prompts (needed at runtime for built-in templates) + Copy-Item (Join-Path $RepoDir "src\prompts") (Join-Path $skillBuild "scripts\prompts") -Recurse + + # Generate SKILL.md with injected template table, copy LICENSE + Generate-SkillMd -OutPath (Join-Path $skillBuild "SKILL.md") Copy-Item (Join-Path $RepoDir "LICENSE") (Join-Path $skillBuild "LICENSE.txt") # Install skill diff --git a/install.sh b/install.sh index bfc8fc2..fc85bfd 100755 --- a/install.sh +++ b/install.sh @@ -41,13 +41,75 @@ fi echo "Installing dependencies..." (cd "$REPO_DIR" && bun install) +# --------------------------------------------------------------------------- +# Generate SKILL.md with injected template table +# --------------------------------------------------------------------------- + +generate_skill_md() { + local out="$1" + local table_file + table_file=$(mktemp) + + # Helper: extract a frontmatter field from a template file + extract_field() { + local file="$1" field="$2" + awk -v f="$field" ' + /^---$/ { if (++c==2) exit } + c==1 && $0 ~ "^"f":" { sub("^"f":[ ]*",""); print; exit } + ' "$file" + } + + # Scan a directory for templates and append rows to table_file + scan_dir() { + local dir="$1" + [ -d "$dir" ] || return 0 + for tmpl in "$dir"/*.md; do + [ -f "$tmpl" ] || continue + local name desc sandbox sb_col + name=$(basename "$tmpl" .md) + desc=$(extract_field "$tmpl" "description") + sandbox=$(extract_field "$tmpl" "sandbox") + [ -z "$desc" ] && desc="(no description)" + sb_col=""; [ -n "$sandbox" ] && sb_col=" ($sandbox)" + printf '| `%s` | %s%s |\n' "$name" "$desc" "$sb_col" >> "$table_file" + done + } + + scan_dir "$REPO_DIR/src/prompts" + scan_dir "$HOME/.codex-collab/templates" + + # Build the output: read SKILL.md line by line, replace the placeholder. + # Write to a temp file first to avoid clobbering the source via symlinks. + local out_tmp + out_tmp=$(mktemp) + while IFS= read -r line || [ -n "$line" ]; do + if [ "$line" = "" ]; then + if [ -s "$table_file" ]; then + printf '| Template | Description |\n' + printf '|----------|-------------|\n' + cat "$table_file" + else + printf 'No templates found.\n' + fi + else + printf '%s\n' "$line" + fi + done < "$REPO_DIR/SKILL.md" > "$out_tmp" + + # Remove old file/symlink before placing generated file + rm -f "$out" + mv "$out_tmp" "$out" + rm -f "$table_file" +} + if [ "$MODE" = "dev" ]; then echo "Installing in dev mode (symlinks)..." - # Symlink skill files + # Generate SKILL.md with template table (can't inject into a symlink) mkdir -p "$SKILL_DIR/scripts" - ln -sf "$REPO_DIR/SKILL.md" "$SKILL_DIR/SKILL.md" + generate_skill_md "$SKILL_DIR/SKILL.md" ln -sf "$REPO_DIR/src/cli.ts" "$SKILL_DIR/scripts/codex-collab" + ln -sf "$REPO_DIR/src/broker-server.ts" "$SKILL_DIR/scripts/broker-server" ln -sf "$REPO_DIR/LICENSE" "$SKILL_DIR/LICENSE.txt" echo "Linked skill to $SKILL_DIR" @@ -59,24 +121,30 @@ if [ "$MODE" = "dev" ]; then else echo "Building..." - # Build bundled JS + # Build bundled JS (CLI + broker server) rm -rf "$REPO_DIR/skill" mkdir -p "$REPO_DIR/skill/codex-collab/scripts" bun build "$REPO_DIR/src/cli.ts" --outfile "$REPO_DIR/skill/codex-collab/scripts/codex-collab" --target bun - - # Prepend shebang - BUILT="$REPO_DIR/skill/codex-collab/scripts/codex-collab" - if ! head -1 "$BUILT" | grep -q '^#!/'; then - TEMP=$(mktemp) - trap 'rm -f "$TEMP"' EXIT - printf '#!/usr/bin/env bun\n' > "$TEMP" - cat "$BUILT" >> "$TEMP" - mv "$TEMP" "$BUILT" - trap - EXIT - fi - - # Copy SKILL.md and LICENSE into build - cp "$REPO_DIR/SKILL.md" "$REPO_DIR/skill/codex-collab/SKILL.md" + bun build "$REPO_DIR/src/broker-server.ts" --outfile "$REPO_DIR/skill/codex-collab/scripts/broker-server" --target bun + + # Prepend shebangs + for BUILT in "$REPO_DIR/skill/codex-collab/scripts/codex-collab" "$REPO_DIR/skill/codex-collab/scripts/broker-server"; do + if ! head -1 "$BUILT" | grep -q '^#!/'; then + TEMP=$(mktemp) + trap 'rm -f "$TEMP"' EXIT + printf '#!/usr/bin/env bun\n' > "$TEMP" + cat "$BUILT" >> "$TEMP" + mv "$TEMP" "$BUILT" + trap - EXIT + fi + chmod +x "$BUILT" + done + + # Copy prompts (needed at runtime for built-in templates) + cp -r "$REPO_DIR/src/prompts" "$REPO_DIR/skill/codex-collab/scripts/prompts" + + # Generate SKILL.md with injected template table, copy LICENSE + generate_skill_md "$REPO_DIR/skill/codex-collab/SKILL.md" cp "$REPO_DIR/LICENSE" "$REPO_DIR/skill/codex-collab/LICENSE.txt" # Install skill (copy to ~/.claude/skills/) diff --git a/package.json b/package.json index 3ea418e..77e8983 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ }, "keywords": ["claude", "codex", "claude-code", "skill", "json-rpc", "cli"], "scripts": { - "build": "bun build src/cli.ts --outfile skill/codex-collab/scripts/codex-collab --target bun", + "build": "bun build src/cli.ts --outfile skill/codex-collab/scripts/codex-collab --target bun && bun build src/broker-server.ts --outfile skill/codex-collab/scripts/broker-server --target bun && cp -r src/prompts skill/codex-collab/scripts/prompts", "test": "bun test", "typecheck": "tsc --noEmit" }, diff --git a/src/approvals.ts b/src/approvals.ts index 6835234..91e061c 100644 --- a/src/approvals.ts +++ b/src/approvals.ts @@ -35,7 +35,7 @@ export class InteractiveApprovalHandler implements ApprovalHandler { private onProgress: (line: string) => void, private pollIntervalMs = 1000, ) { - if (!existsSync(approvalsDir)) mkdirSync(approvalsDir, { recursive: true }); + if (!existsSync(approvalsDir)) mkdirSync(approvalsDir, { recursive: true, mode: 0o700 }); } async handleCommandApproval(req: CommandApprovalRequest, signal?: AbortSignal): Promise { diff --git a/src/broker-client.ts b/src/broker-client.ts new file mode 100644 index 0000000..ebe78ae --- /dev/null +++ b/src/broker-client.ts @@ -0,0 +1,309 @@ +/** + * BrokerClient — connects to a broker server via Unix socket / named pipe + * and implements the AppServerClient interface. + * + * This allows callers to use the same interface whether connected directly + * to `codex app-server` or through the broker multiplexer. + */ + +import net from "node:net"; +import { parseMessage, formatNotification, formatResponse, isResponse, isError, isRequest, isNotification } from "./client"; +import type { AppServerClient, RequestId, PendingRequest, NotificationHandler, ServerRequestHandler } from "./client"; +import { RpcError, type JsonRpcMessage } from "./types"; +import { config } from "./config"; +import { parseEndpoint } from "./broker"; + +const MAX_BUFFER_SIZE = 10 * 1024 * 1024; + +export interface BrokerClientOptions { + /** The broker endpoint (unix:/path or pipe:\path). */ + endpoint: string; + /** Request timeout in ms. Defaults to config.requestTimeout (30s). */ + requestTimeout?: number; +} + +/** + * Connect to a broker server via Unix socket / named pipe. + * Performs the initialize handshake and returns an AppServerClient. + */ +export async function connectToBroker(opts: BrokerClientOptions): Promise { + const requestTimeout = opts.requestTimeout ?? config.requestTimeout; + const target = parseEndpoint(opts.endpoint); + + const pending = new Map(); + const notificationHandlers = new Map>(); + const requestHandlers = new Map(); + let closed = false; + let nextId = 1; + + // Connect to the socket + const socket = await new Promise((resolve, reject) => { + const sock = new net.Socket(); + sock.setEncoding("utf8"); + + const timer = setTimeout(() => { + sock.destroy(); + reject(new Error(`Connection to broker timed out (${opts.endpoint})`)); + }, 5000); + + sock.on("connect", () => { + clearTimeout(timer); + resolve(sock); + }); + + sock.on("error", (err) => { + clearTimeout(timer); + reject(new Error(`Failed to connect to broker: ${err.message}`)); + }); + + sock.connect({ path: target.path }); + }); + + // Write to the socket + function write(data: string): void { + if (closed || socket.destroyed) return; + try { + socket.write(data); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error(`[broker-client] Failed to write: ${msg}`); + rejectAll("Socket write failed: " + msg); + } + } + + function rejectAll(reason: string): void { + for (const entry of pending.values()) { + clearTimeout(entry.timer); + entry.reject(new Error(reason)); + } + pending.clear(); + } + + // Dispatch incoming messages + function dispatch(msg: JsonRpcMessage): void { + if (isResponse(msg)) { + const entry = pending.get(msg.id); + if (entry) { + clearTimeout(entry.timer); + pending.delete(msg.id); + entry.resolve(msg.result); + } + return; + } + + if (isError(msg)) { + const entry = pending.get(msg.id); + if (entry) { + clearTimeout(entry.timer); + pending.delete(msg.id); + const e = msg.error; + const err = new RpcError( + `JSON-RPC error ${e.code}: ${e.message}${e.data ? ` (${JSON.stringify(e.data)})` : ""}`, + e.code, + ); + entry.reject(err); + } + return; + } + + if (isRequest(msg)) { + const handler = requestHandlers.get(msg.method); + if (handler) { + Promise.resolve() + .then(() => handler(msg.params)) + .then( + (res) => write(formatResponse(msg.id, res)), + (err) => { + const errMsg = err instanceof Error ? err.message : String(err); + console.error(`[broker-client] Error in request handler for "${msg.method}": ${errMsg}`); + write( + JSON.stringify({ + id: msg.id, + error: { code: -32603, message: `Handler error: ${errMsg}` }, + }) + "\n", + ); + }, + ); + } else { + write( + JSON.stringify({ + id: msg.id, + error: { code: -32601, message: `Method not found: ${msg.method}` }, + }) + "\n", + ); + } + return; + } + + if (isNotification(msg)) { + const handlers = notificationHandlers.get(msg.method); + if (handlers) { + for (const h of handlers) { + try { + h(msg.params); + } catch (e) { + console.error( + `[broker-client] Error in notification handler for "${msg.method}": ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + } + } + } + + // Read loop — parse newline-delimited JSON from socket + let buffer = ""; + socket.on("data", (chunk: string) => { + buffer += chunk; + if (buffer.length > MAX_BUFFER_SIZE) { + socket.destroy(new Error("Broker response buffer exceeded maximum size")); + return; + } + let newlineIdx: number; + while ((newlineIdx = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, newlineIdx).trim(); + buffer = buffer.slice(newlineIdx + 1); + if (!line) continue; + const msg = parseMessage(line); + if (msg) dispatch(msg); + } + }); + + const closeHandlers = new Set<() => void>(); + + socket.on("close", () => { + if (!closed) { + rejectAll("Broker connection closed"); + for (const handler of closeHandlers) { + try { handler(); } catch (e) { + console.error(`[codex] Warning: close handler error: ${e instanceof Error ? e.message : String(e)}`); + } + } + } + }); + + socket.on("error", (err) => { + if (!closed) { + console.error(`[broker-client] Socket error: ${err.message}`); + rejectAll("Broker socket error: " + err.message); + } + }); + + // Build the client interface + function request(method: string, params?: unknown): Promise { + return new Promise((resolve, reject) => { + if (closed) { + reject(new Error("Client is closed")); + return; + } + + const id = nextId++; + const msg: Record = { id, method }; + if (params !== undefined) msg.params = params; + const line = JSON.stringify(msg) + "\n"; + + const timer = setTimeout(() => { + pending.delete(id); + reject( + new Error( + `Request ${method} (id=${id}) timed out after ${requestTimeout}ms`, + ), + ); + }, requestTimeout); + + pending.set(id, { + resolve: resolve as (value: unknown) => void, + reject, + timer, + }); + write(line); + }); + } + + function notify(method: string, params?: unknown): void { + write(formatNotification(method, params)); + } + + function on(method: string, handler: NotificationHandler): () => void { + if (!notificationHandlers.has(method)) { + notificationHandlers.set(method, new Set()); + } + notificationHandlers.get(method)!.add(handler); + return () => { + notificationHandlers.get(method)?.delete(handler); + }; + } + + function onRequest(method: string, handler: ServerRequestHandler): () => void { + if (requestHandlers.has(method)) { + console.error( + `[broker-client] Warning: replacing existing request handler for "${method}"`, + ); + } + requestHandlers.set(method, handler); + return () => { + if (requestHandlers.get(method) === handler) { + requestHandlers.delete(method); + } + }; + } + + function respond(id: RequestId, result: unknown): void { + write(formatResponse(id, result)); + } + + function onClose(handler: () => void): () => void { + closeHandlers.add(handler); + return () => { closeHandlers.delete(handler); }; + } + + async function close(): Promise { + if (closed) return; + closed = true; + rejectAll("Client closed"); + socket.end(); + // Wait for the socket to fully close + await new Promise((resolve) => { + socket.on("close", resolve); + // If already destroyed, resolve immediately + if (socket.destroyed) resolve(); + // Safety timeout + setTimeout(resolve, 1000); + }); + } + + // Perform initialize handshake with the broker + let userAgent: string; + let brokerBusy = false; + try { + const result = await request<{ userAgent: string; busy?: boolean }>("initialize", { + clientInfo: { + name: config.clientName, + title: null, + version: config.clientVersion, + }, + capabilities: { + experimentalApi: false, + optOutNotificationMethods: ["item/reasoning/textDelta"], + }, + }); + brokerBusy = result.busy === true; + userAgent = result.userAgent; + notify("initialized"); + } catch (e) { + await close(); + throw e; + } + + return { + request, + notify, + on, + onRequest, + respond, + onClose, + close, + userAgent, + brokerBusy, + }; +} diff --git a/src/broker-server.test.ts b/src/broker-server.test.ts new file mode 100644 index 0000000..d2a620b --- /dev/null +++ b/src/broker-server.test.ts @@ -0,0 +1,1541 @@ +/** + * Tests for broker-server.ts — the detached broker process that multiplexes + * JSON-RPC messages between socket clients and a single app-server child. + * + * Strategy: Spawn broker-server.ts as a real subprocess with a mock app-server + * script on PATH. The mock app-server speaks just enough JSON-RPC to satisfy + * the initialize handshake and respond to requests. Test clients connect via + * Unix socket and exercise concurrency control, approval forwarding, idle + * timeout, and shutdown. + */ + +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import net from "node:net"; +import { mkdtempSync, rmSync, writeFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import type { Subprocess } from "bun"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +let tempDir: string; + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "broker-server-test-")); +}); + +afterEach(async () => { + // Kill any broker processes we spawned + for (const proc of spawnedProcesses) { + try { proc.kill(); } catch {} + } + spawnedProcesses.length = 0; + // Clean up temp dir + rmSync(tempDir, { recursive: true, force: true }); +}); + +const spawnedProcesses: Subprocess[] = []; + +/** + * Create a mock codex CLI script that speaks JSON-RPC when invoked as + * `codex app-server`. The mock handles initialize, thread/start, turn/start, + * turn/interrupt, thread/read, thread/list, and review/start. + * + * It also supports sending notifications (item/started, turn/completed) after + * turn/start, and server-sent approval requests when MOCK_SEND_APPROVAL=1. + */ +function createMockCodex(dir: string, opts?: { + /** Delay in ms before responding to turn/start */ + turnDelay?: number; + /** If true, send a turn/completed notification after turn/start response */ + sendTurnCompleted?: boolean; + /** If true, send an approval request after turn/start */ + sendApproval?: boolean; + /** Delay in ms before sending turn/completed (after response) */ + turnCompletedDelay?: number; +}): string { + const turnDelay = opts?.turnDelay ?? 0; + const sendTurnCompleted = opts?.sendTurnCompleted ?? true; + const sendApproval = opts?.sendApproval ?? false; + const turnCompletedDelay = opts?.turnCompletedDelay ?? 10; + + const scriptPath = join(dir, "codex"); + const script = `#!/usr/bin/env bun +// Mock codex app-server for broker-server tests +const args = process.argv.slice(2); +if (args[0] !== "app-server") { + process.stderr.write("Mock codex: expected 'app-server' subcommand\\n"); + process.exit(1); +} + +function respond(obj) { process.stdout.write(JSON.stringify(obj) + "\\n"); } + +let buffer = ""; +let approvalIdCounter = 1; +process.stdin.setEncoding("utf-8"); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let idx; + while ((idx = buffer.indexOf("\\n")) !== -1) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (!line) continue; + let msg; + try { msg = JSON.parse(line); } catch { continue; } + + // Notification — no id + if (msg.id === undefined) continue; + + switch (msg.method) { + case "initialize": + respond({ id: msg.id, result: { userAgent: "mock-codex/0.1.0" } }); + break; + + case "thread/start": + respond({ id: msg.id, result: { + thread: { + id: "thread-001", preview: "", modelProvider: "openai", + createdAt: Date.now(), updatedAt: Date.now(), + status: { type: "idle" }, path: null, cwd: "/tmp", + cliVersion: "0.1.0", source: "mock", name: null, + agentNickname: null, agentRole: null, gitInfo: null, turns: [], + }, + model: "gpt-5.3-codex", modelProvider: "openai", + cwd: "/tmp", approvalPolicy: "never", sandbox: null, + }}); + break; + + case "turn/start": { + const threadId = msg.params?.threadId || "thread-001"; + setTimeout(() => { + respond({ id: msg.id, result: { + turn: { id: "turn-001", items: [], status: "inProgress", error: null }, + }}); + + ${sendApproval ? ` + // Send approval request after turn/start response + setTimeout(() => { + const approvalId = "approval-" + (approvalIdCounter++); + respond({ + id: approvalId, + method: "item/commandExecution/requestApproval", + params: { + threadId: threadId, + turnId: "turn-001", + itemId: "item-001", + command: "echo hello", + cwd: "/tmp", + }, + }); + }, 5); + ` : ""} + + ${sendTurnCompleted ? ` + setTimeout(() => { + respond({ + method: "turn/completed", + params: { + threadId: threadId, + turn: { id: "turn-001", items: [], status: "completed", error: null }, + }, + }); + }, ${turnCompletedDelay}); + ` : ""} + }, ${turnDelay}); + break; + } + + case "review/start": { + const threadId = msg.params?.threadId || "thread-001"; + const reviewThreadId = "review-thread-001"; + respond({ id: msg.id, result: { + turn: { id: "review-turn-001", items: [], status: "inProgress", error: null }, + reviewThreadId: reviewThreadId, + }}); + ${sendTurnCompleted ? ` + setTimeout(() => { + respond({ + method: "turn/completed", + params: { + threadId: reviewThreadId, + turn: { id: "review-turn-001", items: [], status: "completed", error: null }, + }, + }); + }, ${turnCompletedDelay}); + ` : ""} + break; + } + + case "turn/interrupt": + respond({ id: msg.id, result: {} }); + break; + + case "thread/read": + respond({ id: msg.id, result: { + thread: { + id: msg.params?.threadId || "thread-001", preview: "", + modelProvider: "openai", createdAt: Date.now(), updatedAt: Date.now(), + status: { type: "idle" }, path: null, cwd: "/tmp", + cliVersion: "0.1.0", source: "mock", name: null, + agentNickname: null, agentRole: null, gitInfo: null, turns: [], + }, + }}); + break; + + case "thread/list": + respond({ id: msg.id, result: { data: [], nextCursor: null } }); + break; + + default: + respond({ id: msg.id, error: { code: -32601, message: "Method not found: " + msg.method } }); + } + } +}); + +process.stdin.on("end", () => process.exit(0)); +process.stdin.on("error", () => process.exit(1)); +`; + + writeFileSync(scriptPath, script, { mode: 0o755 }); + return dir; // The dir to prepend to PATH +} + +/** Spawn broker-server as a subprocess with the mock codex on PATH. */ +function spawnBroker( + endpoint: string, + mockCodexDir: string, + opts?: { + idleTimeout?: number; + cwd?: string; + }, +): Subprocess { + const brokerPath = join(import.meta.dir, "broker-server.ts"); + const args = [ + "run", brokerPath, "serve", + "--endpoint", endpoint, + "--idle-timeout", String(opts?.idleTimeout ?? 30000), + ]; + if (opts?.cwd) { + args.push("--cwd", opts.cwd); + } + + const proc = Bun.spawn(["bun", ...args], { + env: { + ...process.env, + PATH: `${mockCodexDir}:${process.env.PATH}`, + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + cwd: opts?.cwd ?? tempDir, + }); + + spawnedProcesses.push(proc); + return proc; +} + +/** Wait for the broker socket to become connectable. */ +async function waitForSocket( + sockPath: string, + timeoutMs = 10_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const sock = new net.Socket(); + await new Promise((resolve, reject) => { + sock.on("connect", () => { sock.destroy(); resolve(); }); + sock.on("error", reject); + sock.connect({ path: sockPath }); + }); + return; + } catch { + await new Promise((r) => setTimeout(r, 50)); + } + } + throw new Error(`Socket ${sockPath} did not become available within ${timeoutMs}ms`); +} + +/** + * A minimal JSON-RPC client for testing. Connects to a Unix socket, performs + * the initialize handshake, and provides request/notify/onMessage helpers. + */ +class TestClient { + private socket: net.Socket; + private buffer = ""; + private nextId = 1; + private pending = new Map void; + reject: (e: Error) => void; + }>(); + private notificationHandlers: Array<(msg: Record) => void> = []; + private requestHandlers: Array<(msg: Record) => void> = []; + private allMessages: Array> = []; + + private constructor(socket: net.Socket) { + this.socket = socket; + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => { + this.buffer += chunk; + let idx: number; + while ((idx = this.buffer.indexOf("\n")) !== -1) { + const line = this.buffer.slice(0, idx).trim(); + this.buffer = this.buffer.slice(idx + 1); + if (!line) continue; + try { + const msg = JSON.parse(line) as Record; + this.allMessages.push(msg); + this.dispatch(msg); + } catch {} + } + }); + } + + static async connect(sockPath: string): Promise { + const socket = await new Promise((resolve, reject) => { + const sock = new net.Socket(); + const timer = setTimeout(() => { + sock.destroy(); + reject(new Error("Connection timed out")); + }, 5000); + sock.on("connect", () => { clearTimeout(timer); resolve(sock); }); + sock.on("error", (err) => { clearTimeout(timer); reject(err); }); + sock.connect({ path: sockPath }); + }); + return new TestClient(socket); + } + + /** Connect and perform the initialize handshake. */ + static async connectAndInit(sockPath: string): Promise { + const client = await TestClient.connect(sockPath); + const result = await client.request("initialize", { + clientInfo: { name: "test", title: null, version: "0.0.1" }, + capabilities: { experimentalApi: false }, + }) as { userAgent: string }; + client.send({ method: "initialized" }); + return client; + } + + private dispatch(msg: Record): void { + // Response (has id + result or error, no method) + if (msg.id !== undefined && !("method" in msg)) { + const entry = this.pending.get(msg.id as string | number); + if (entry) { + this.pending.delete(msg.id as string | number); + if ("error" in msg) { + const err = msg.error as { code: number; message: string }; + const error = new Error(err.message) as Error & { code: number }; + error.code = err.code; + entry.reject(error); + } else { + entry.resolve(msg.result); + } + } + return; + } + + // Request from server (has id + method) + if (msg.id !== undefined && "method" in msg) { + for (const h of this.requestHandlers) h(msg); + return; + } + + // Notification (method, no id) + if ("method" in msg && msg.id === undefined) { + for (const h of this.notificationHandlers) h(msg); + } + } + + send(msg: Record): void { + this.socket.write(JSON.stringify(msg) + "\n"); + } + + async request(method: string, params?: unknown): Promise { + return new Promise((resolve, reject) => { + const id = this.nextId++; + const msg: Record = { id, method }; + if (params !== undefined) msg.params = params; + this.pending.set(id, { resolve, reject }); + this.send(msg); + // 10s timeout + setTimeout(() => { + if (this.pending.has(id)) { + this.pending.delete(id); + reject(new Error(`Request ${method} (id=${id}) timed out`)); + } + }, 10_000); + }); + } + + onNotification(handler: (msg: Record) => void): void { + this.notificationHandlers.push(handler); + } + + onRequest(handler: (msg: Record) => void): void { + this.requestHandlers.push(handler); + } + + get messages(): Array> { + return this.allMessages; + } + + async close(): Promise { + this.socket.end(); + await new Promise((resolve) => { + this.socket.on("close", resolve); + if (this.socket.destroyed) resolve(); + setTimeout(resolve, 1000); + }); + } + + get destroyed(): boolean { + return this.socket.destroyed; + } +} + +/** Collect notifications from a client into an array. Returns the array ref. */ +function collectNotifications( + client: TestClient, +): Array> { + const collected: Array> = []; + client.onNotification((msg) => collected.push(msg)); + return collected; +} + +/** Wait for a condition to become true within a timeout. */ +async function waitFor( + condFn: () => boolean, + timeoutMs = 5000, + pollMs = 20, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condFn()) return; + await new Promise((r) => setTimeout(r, pollMs)); + } + throw new Error("waitFor timed out"); +} + +// ─── Socket support detection ──────────────────────────────────────────────── + +// These integration tests spawn a real broker-server subprocess with a mock +// codex script (bash shebang) and connect via Unix socket. They require: +// 1. Unix platform (the mock script uses #!/usr/bin/env bun) +// 2. Unix socket support (not restricted by sandbox) +const IS_UNIX = process.platform !== "win32"; +const SOCKETS_AVAILABLE = IS_UNIX && await (async () => { + const checkDir = mkdtempSync(join(tmpdir(), "broker-sock-check-")); + const testSock = join(checkDir, "test.sock"); + try { + const srv = net.createServer(); + await new Promise((resolve, reject) => { + srv.on("error", reject); + srv.listen(testSock, () => { srv.close(); resolve(); }); + }); + return true; + } catch { + return false; + } finally { + try { rmSync(checkDir, { recursive: true, force: true }); } catch {} + } +})(); + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe.skipIf(!SOCKETS_AVAILABLE)("broker-server", () => { + + // ── Initialize handshake ────────────────────────────────────────────────── + + describe("initialize handshake", () => { + test("responds with userAgent locally, does not forward to app-server", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + const client = await TestClient.connect(sockPath); + const result = await client.request("initialize", { + clientInfo: { name: "test", title: null, version: "0.0.1" }, + capabilities: { experimentalApi: false }, + }) as { userAgent: string }; + + expect(result.userAgent).toBe("codex-collab-broker"); + await client.close(); + } finally { + proc.kill(); + } + }, 15_000); + + test("initialize returns busy=false when no stream is active", async () => { + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + const client = await TestClient.connect(sockPath); + const result = await client.request("initialize", { + clientInfo: { name: "test", title: null, version: "0.0.1" }, + capabilities: { experimentalApi: false }, + }) as { userAgent: string; busy: boolean }; + + expect(result.busy).toBe(false); + await client.close(); + } finally { + proc.kill(); + } + }, 15_000); + + test("initialize returns busy=true when a stream is active", async () => { + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir, { sendTurnCompleted: false }); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + // Client 1 establishes a stream + const client1 = await TestClient.connectAndInit(sockPath); + await client1.request("turn/start", { + threadId: "thread-001", + input: [{ type: "text", text: "hello" }], + }); + await new Promise((r) => setTimeout(r, 100)); + + // Client 2 connects — initialize should report busy + const client2 = await TestClient.connect(sockPath); + const result = await client2.request("initialize", { + clientInfo: { name: "test", title: null, version: "0.0.1" }, + capabilities: { experimentalApi: false }, + }) as { userAgent: string; busy: boolean }; + + expect(result.busy).toBe(true); + + await client1.close(); + await client2.close(); + } finally { + proc.kill(); + } + }, 15_000); + + test("swallows initialized notification without error", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + const client = await TestClient.connectAndInit(sockPath); + // Send another initialized notification — should be silently ignored + client.send({ method: "initialized" }); + // If the broker crashes or sends an error, the next request would fail + const result = await client.request("thread/list") as { data: unknown[] }; + expect(result.data).toBeArrayOfSize(0); + await client.close(); + } finally { + proc.kill(); + } + }, 15_000); + }); + + // ── Basic request forwarding ────────────────────────────────────────────── + + describe("request forwarding", () => { + test("forwards thread/start to app-server and returns result", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + const client = await TestClient.connectAndInit(sockPath); + const result = await client.request("thread/start", { + cwd: "/tmp", + experimentalRawEvents: false, + persistExtendedHistory: false, + }) as { thread: { id: string } }; + + expect(result.thread.id).toBe("thread-001"); + await client.close(); + } finally { + proc.kill(); + } + }, 15_000); + + test("forwards thread/read and thread/list as read-only methods", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + const client = await TestClient.connectAndInit(sockPath); + + const listResult = await client.request("thread/list") as { data: unknown[] }; + expect(listResult.data).toBeArrayOfSize(0); + + const readResult = await client.request("thread/read", { + threadId: "thread-001", + includeTurns: false, + }) as { thread: { id: string } }; + expect(readResult.thread.id).toBe("thread-001"); + + await client.close(); + } finally { + proc.kill(); + } + }, 15_000); + + test("returns JSON parse error for invalid JSON input", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + const client = await TestClient.connectAndInit(sockPath); + // Send raw invalid JSON + client.send({ bogus: true } as any); // This is valid JSON but missing id/method + // The broker ignores notifications without id, so this is just dropped. + // Now send actually invalid JSON: + (client as any).socket.write("not valid json\n"); + + // Wait for error response + await new Promise((r) => setTimeout(r, 200)); + + const errorMsg = client.messages.find( + (m) => m.id === null && (m as any).error?.code === -32700, + ); + expect(errorMsg).toBeDefined(); + expect((errorMsg as any).error.message).toContain("Invalid JSON"); + + await client.close(); + } finally { + proc.kill(); + } + }, 15_000); + + test("ignores client notifications (no id)", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + const client = await TestClient.connectAndInit(sockPath); + + // Send a notification (no id) — broker should silently ignore it + client.send({ method: "some/notification", params: {} }); + + // Verify the broker is still functional after receiving the notification. + // NOTE: This only verifies the broker didn't crash. It does not verify that + // the notification was NOT forwarded to the app-server, because the mock + // app-server silently ignores notifications (no id) and there is no + // observable side-effect to check from the client side. + const result = await client.request("thread/list") as { data: unknown[] }; + expect(result.data).toBeArrayOfSize(0); + + await client.close(); + } finally { + proc.kill(); + } + }, 15_000); + }); + + // ── Concurrency control ─────────────────────────────────────────────────── + + describe("concurrency control", () => { + test("second client gets -32001 busy error during active stream", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + // Use a long turn delay so the stream stays active + const mockDir = createMockCodex(tempDir, { + sendTurnCompleted: false, + }); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + const client1 = await TestClient.connectAndInit(sockPath); + const client2 = await TestClient.connectAndInit(sockPath); + + // Client 1 starts a turn (streaming method) + const turnResult = await client1.request("turn/start", { + threadId: "thread-001", + input: [{ type: "text", text: "hello" }], + }); + expect(turnResult).toBeDefined(); + + // Wait briefly for stream ownership to be established + await new Promise((r) => setTimeout(r, 100)); + + // Client 2 tries to start a turn — should get busy error + try { + await client2.request("turn/start", { + threadId: "thread-001", + input: [{ type: "text", text: "world" }], + }); + throw new Error("Expected busy error"); + } catch (err: any) { + expect(err.message).toContain("Shared Codex broker is busy"); + expect(err.code).toBe(-32001); + } + + await client1.close(); + await client2.close(); + } finally { + proc.kill(); + } + }, 15_000); + + test("second client can proceed after first client's turn completes", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir, { + sendTurnCompleted: true, + turnCompletedDelay: 50, + }); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + const client1 = await TestClient.connectAndInit(sockPath); + const client2 = await TestClient.connectAndInit(sockPath); + + // Client 1 starts a turn + await client1.request("turn/start", { + threadId: "thread-001", + input: [{ type: "text", text: "hello" }], + }); + + // Wait for turn/completed + await new Promise((r) => setTimeout(r, 300)); + + // Client 2 should now be able to make requests + const result = await client2.request("thread/list") as { data: unknown[] }; + expect(result.data).toBeArrayOfSize(0); + + await client1.close(); + await client2.close(); + } finally { + proc.kill(); + } + }, 15_000); + + test("turn/interrupt allowed from different socket during active stream", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir, { + sendTurnCompleted: false, + }); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + const client1 = await TestClient.connectAndInit(sockPath); + const client2 = await TestClient.connectAndInit(sockPath); + + // Client 1 starts a turn + await client1.request("turn/start", { + threadId: "thread-001", + input: [{ type: "text", text: "hello" }], + }); + + // Wait for stream ownership + await new Promise((r) => setTimeout(r, 100)); + + // Client 2 sends turn/interrupt — should succeed (not blocked) + const interruptResult = await client2.request("turn/interrupt", { + threadId: "thread-001", + turnId: "turn-001", + }); + expect(interruptResult).toEqual({}); + + await client1.close(); + await client2.close(); + } finally { + proc.kill(); + } + }, 15_000); + + test("thread/read allowed from different socket during active stream", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir, { + sendTurnCompleted: false, + }); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + const client1 = await TestClient.connectAndInit(sockPath); + const client2 = await TestClient.connectAndInit(sockPath); + + // Client 1 starts a turn + await client1.request("turn/start", { + threadId: "thread-001", + input: [{ type: "text", text: "hello" }], + }); + + await new Promise((r) => setTimeout(r, 100)); + + // Client 2 reads a thread — should succeed + const readResult = await client2.request("thread/read", { + threadId: "thread-001", + includeTurns: false, + }) as { thread: { id: string } }; + expect(readResult.thread.id).toBe("thread-001"); + + await client1.close(); + await client2.close(); + } finally { + proc.kill(); + } + }, 15_000); + + test("thread/list allowed from different socket during active stream", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir, { + sendTurnCompleted: false, + }); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + const client1 = await TestClient.connectAndInit(sockPath); + const client2 = await TestClient.connectAndInit(sockPath); + + // Client 1 starts a turn + await client1.request("turn/start", { + threadId: "thread-001", + input: [{ type: "text", text: "hello" }], + }); + + await new Promise((r) => setTimeout(r, 100)); + + // Client 2 lists threads — should succeed + const listResult = await client2.request("thread/list") as { data: unknown[] }; + expect(listResult.data).toBeArrayOfSize(0); + + await client1.close(); + await client2.close(); + } finally { + proc.kill(); + } + }, 15_000); + + test("non-streaming request from same socket is allowed", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir, { + sendTurnCompleted: false, + }); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + const client = await TestClient.connectAndInit(sockPath); + + // Start a turn (streaming) + await client.request("turn/start", { + threadId: "thread-001", + input: [{ type: "text", text: "hello" }], + }); + + await new Promise((r) => setTimeout(r, 100)); + + // Same socket can still make requests (it owns the stream) + const result = await client.request("thread/read", { + threadId: "thread-001", + includeTurns: false, + }) as { thread: { id: string } }; + expect(result.thread.id).toBe("thread-001"); + + await client.close(); + } finally { + proc.kill(); + } + }, 15_000); + }); + + // ── Notification routing ────────────────────────────────────────────────── + + describe("notification routing", () => { + test("turn/completed notification is forwarded to the stream-owning socket", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir, { + sendTurnCompleted: true, + turnCompletedDelay: 50, + }); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + const client = await TestClient.connectAndInit(sockPath); + const notifications = collectNotifications(client); + + // Start a turn + await client.request("turn/start", { + threadId: "thread-001", + input: [{ type: "text", text: "hello" }], + }); + + // Wait for turn/completed notification + await waitFor(() => notifications.some( + (n) => n.method === "turn/completed", + ), 3000); + + const turnCompleted = notifications.find((n) => n.method === "turn/completed"); + expect(turnCompleted).toBeDefined(); + expect((turnCompleted!.params as any).threadId).toBe("thread-001"); + + await client.close(); + } finally { + proc.kill(); + } + }, 15_000); + + test("notifications are not sent to non-owning sockets", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir, { + sendTurnCompleted: true, + turnCompletedDelay: 50, + }); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + const client1 = await TestClient.connectAndInit(sockPath); + const client2 = await TestClient.connectAndInit(sockPath); + const notifications1 = collectNotifications(client1); + const notifications2 = collectNotifications(client2); + + // Client 1 starts a turn + await client1.request("turn/start", { + threadId: "thread-001", + input: [{ type: "text", text: "hello" }], + }); + + // Wait for turn/completed + await waitFor(() => notifications1.some( + (n) => n.method === "turn/completed", + ), 3000); + + // Client 2 should NOT have received the notification + expect(notifications2.length).toBe(0); + + await client1.close(); + await client2.close(); + } finally { + proc.kill(); + } + }, 15_000); + }); + + // ── Approval forwarding ─────────────────────────────────────────────────── + + describe("approval forwarding", () => { + test("client receives forwarded approval request and responds — round-trip", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir, { + sendTurnCompleted: false, + sendApproval: true, + }); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + const client = await TestClient.connectAndInit(sockPath); + + // Set up approval response handler — when we receive a request + // with method "item/commandExecution/requestApproval", respond with accept + client.onRequest((msg) => { + if (msg.method === "item/commandExecution/requestApproval") { + // Respond with approval decision + client.send({ + id: msg.id, + result: { decision: "accept" }, + }); + } + }); + + // Start a turn (which triggers the mock to send an approval request) + const turnResult = await client.request("turn/start", { + threadId: "thread-001", + input: [{ type: "text", text: "hello" }], + }); + expect(turnResult).toBeDefined(); + + // Wait for the approval request to arrive and be responded to + await waitFor( + () => client.messages.some( + (m) => + m.method === "item/commandExecution/requestApproval" && + m.id !== undefined, + ), + 3000, + ); + + // Verify we received the forwarded approval request + const approvalReq = client.messages.find( + (m) => m.method === "item/commandExecution/requestApproval", + ); + expect(approvalReq).toBeDefined(); + expect((approvalReq!.params as any).command).toBe("echo hello"); + + await client.close(); + } finally { + proc.kill(); + } + }, 15_000); + + test("malformed response (missing result and error) is rejected", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir, { + sendTurnCompleted: false, + sendApproval: true, + }); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + const client = await TestClient.connectAndInit(sockPath); + + // Respond to approval with neither result nor error + client.onRequest((msg) => { + if (msg.method === "item/commandExecution/requestApproval") { + // Send malformed response — just id, no result or error + client.send({ id: msg.id }); + } + }); + + // Start a turn + await client.request("turn/start", { + threadId: "thread-001", + input: [{ type: "text", text: "hello" }], + }); + + // Wait for the approval request to arrive + await waitFor( + () => client.messages.some( + (m) => m.method === "item/commandExecution/requestApproval", + ), + 3000, + ); + + // The broker should reject the malformed response internally and log a + // warning to stderr. We cannot easily verify the stderr warning from the + // subprocess, nor can we observe the rejection sent to the app-server from + // the client side. We verify the broker remains functional, which confirms + // it handled the malformed response without crashing. + await new Promise((r) => setTimeout(r, 200)); + + // Broker should still be alive and respond to requests + // (the stream owner is still this client, so same-socket request works) + const result = await client.request("thread/read", { + threadId: "thread-001", + includeTurns: false, + }) as { thread: { id: string } }; + expect(result.thread.id).toBe("thread-001"); + + await client.close(); + } finally { + proc.kill(); + } + }, 15_000); + + test("socket disconnect during pending approval rejects only that socket's approvals", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir, { + sendTurnCompleted: false, + sendApproval: true, + }); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + const client = await TestClient.connectAndInit(sockPath); + + // Don't respond to approval — just disconnect + let approvalReceived = false; + client.onRequest((msg) => { + if (msg.method === "item/commandExecution/requestApproval") { + approvalReceived = true; + // Don't respond — just disconnect + setTimeout(() => client.close(), 50); + } + }); + + // Start a turn + await client.request("turn/start", { + threadId: "thread-001", + input: [{ type: "text", text: "hello" }], + }); + + // Wait for approval to arrive and client to disconnect + await waitFor(() => approvalReceived, 3000); + await new Promise((r) => setTimeout(r, 200)); + + // Broker should still be alive — connect a new client. + // NOTE: We cannot directly verify that the pending approval was rejected + // (sent back to the app-server as a reject response) because the mock + // app-server does not expose that information. We verify indirectly: the + // broker survives the disconnect and accepts new connections, which confirms + // it cleaned up the pending approval state without deadlocking. + const client2 = await TestClient.connectAndInit(sockPath); + expect(client2.destroyed).toBe(false); + + await client2.close(); + } finally { + proc.kill(); + } + }, 15_000); + }); + + // ── Socket permissions ──────────────────────────────────────────────────── + + describe("socket permissions", () => { + test("socket file has restrictive permissions (0o700)", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + const stats = statSync(sockPath); + // Socket permission bits — the file mode should have 0o700 + // On Linux, socket files may have 0o755 or similar, but the + // chmodSync(path, 0o700) should set the permission bits. + const permBits = stats.mode & 0o777; + expect(permBits).toBe(0o700); + } finally { + proc.kill(); + } + }, 15_000); + }); + + // ── broker/shutdown RPC ─────────────────────────────────────────────────── + + describe("broker/shutdown", () => { + test("broker exits cleanly after broker/shutdown request", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + const client = await TestClient.connectAndInit(sockPath); + + // Send broker/shutdown + const result = await client.request("broker/shutdown"); + expect(result).toEqual({}); + + // Wait for process to exit + const exitCode = await Promise.race([ + proc.exited, + new Promise((r) => setTimeout(() => r(-1), 5000)), + ]); + expect(exitCode).toBe(0); + + await client.close(); + }, 15_000); + }); + + // ── Idle timeout ────────────────────────────────────────────────────────── + + describe("idle timeout", () => { + test("broker shuts down after idle timeout with no activity", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir); + + // Use a very short idle timeout (1 second) + const proc = spawnBroker(endpoint, mockDir, { idleTimeout: 1000 }); + await waitForSocket(sockPath); + + // Don't send any requests — just wait for the broker to exit + const exitCode = await Promise.race([ + proc.exited, + new Promise((r) => setTimeout(() => r(-999), 5000)), + ]); + + // Should exit with code 0 (idle timeout) + expect(exitCode).toBe(0); + }, 10_000); + + test("activity resets the idle timer", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir); + + // Use a 2s idle timeout + const proc = spawnBroker(endpoint, mockDir, { idleTimeout: 2000 }); + await waitForSocket(sockPath); + + const client = await TestClient.connectAndInit(sockPath); + + // Send periodic requests to keep the broker alive + for (let i = 0; i < 3; i++) { + await new Promise((r) => setTimeout(r, 800)); + await client.request("thread/list"); + } + + // At this point ~2.4s have passed, but the timer was reset each time + // so the broker should still be alive + const result = await client.request("thread/list") as { data: unknown[] }; + expect(result.data).toBeArrayOfSize(0); + + await client.close(); + + // Now wait for idle timeout after closing + const exitCode = await Promise.race([ + proc.exited, + new Promise((r) => setTimeout(() => r(-999), 5000)), + ]); + expect(exitCode).toBe(0); + }, 15_000); + }); + + // ── Buffer overflow protection ──────────────────────────────────────────── + + describe("buffer overflow protection", () => { + test("broker destroys socket when client sends >10MB without newlines", async () => { + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir); + + const proc = spawnBroker(endpoint, mockDir, { idleTimeout: 30000 }); + await waitForSocket(sockPath); + + try { + // Use a raw socket (not TestClient) so we can flood data without + // the JSON-RPC framing getting in the way. + const rawSocket = new net.Socket(); + await new Promise((resolve, reject) => { + rawSocket.on("connect", resolve); + rawSocket.on("error", reject); + rawSocket.connect({ path: sockPath }); + }); + + // Complete the initialize handshake first so the broker accepts us + rawSocket.write(JSON.stringify({ id: 1, method: "initialize", params: { clientInfo: { name: "test", title: null, version: "1.0" }, capabilities: { experimentalApi: false } } }) + "\n"); + await new Promise((r) => setTimeout(r, 100)); + + // Now flood >10MB without newlines. Use a single large write to + // maximize the chance the broker receives it all in one chunk. + let destroyed = false; + rawSocket.on("close", () => { destroyed = true; }); + rawSocket.on("error", () => { destroyed = true; }); + + // Write in a loop with drain handling to ensure data actually flows + const chunkSize = 256 * 1024; // 256KB — typical kernel buffer unit + const target = 11 * 1024 * 1024; // 11MB > MAX_BUFFER_SIZE (10MB) + let written = 0; + + while (written < target && !destroyed) { + const chunk = "x".repeat(chunkSize); + const canWrite = rawSocket.write(chunk); + written += chunkSize; + if (!canWrite && !destroyed) { + // Wait for drain before writing more + await new Promise((resolve) => { + rawSocket.once("drain", resolve); + // Safety: if socket destroyed, also resolve + rawSocket.once("close", resolve); + setTimeout(resolve, 1000); + }); + } + } + + // Wait for the broker to detect overflow and destroy our socket + await waitFor(() => destroyed, 30000, 50); + expect(destroyed).toBe(true); + + rawSocket.destroy(); + } finally { + proc.kill(); + } + }, 30_000); + }); + + // ── Multiple clients ────────────────────────────────────────────────────── + + describe("multiple clients", () => { + test("multiple clients can connect and make sequential requests", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + const client1 = await TestClient.connectAndInit(sockPath); + const client2 = await TestClient.connectAndInit(sockPath); + const client3 = await TestClient.connectAndInit(sockPath); + + // Each client makes a non-streaming request sequentially + const r1 = await client1.request("thread/list") as { data: unknown[] }; + expect(r1.data).toBeArrayOfSize(0); + + const r2 = await client2.request("thread/list") as { data: unknown[] }; + expect(r2.data).toBeArrayOfSize(0); + + const r3 = await client3.request("thread/list") as { data: unknown[] }; + expect(r3.data).toBeArrayOfSize(0); + + await client1.close(); + await client2.close(); + await client3.close(); + } finally { + proc.kill(); + } + }, 15_000); + + test("client disconnect during stream preserves concurrency lock until turn completes", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir, { + sendTurnCompleted: false, + }); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + const client1 = await TestClient.connectAndInit(sockPath); + const client2 = await TestClient.connectAndInit(sockPath); + + // Client 1 starts a turn + await client1.request("turn/start", { + threadId: "thread-001", + input: [{ type: "text", text: "hello" }], + }); + + // Use a longer delay to ensure stream ownership is firmly established + await new Promise((r) => setTimeout(r, 300)); + + // Client 1 disconnects while stream is active + await client1.close(); + // Wait long enough for broker to process the disconnect and set sentinel + await new Promise((r) => setTimeout(r, 300)); + + // Client 2 tries to start a new streaming request — should be blocked + // because the orphaned stream is still a sentinel (turn never completed) + let gotBusy = false; + try { + await client2.request("turn/start", { + threadId: "thread-001", + input: [{ type: "text", text: "next" }], + }); + } catch (err: any) { + gotBusy = true; + expect(err.code).toBe(-32001); + } + expect(gotBusy).toBe(true); + + await client2.close(); + } finally { + proc.kill(); + } + }, 15_000); + }); + + // ── Streaming methods ───────────────────────────────────────────────────── + + describe("streaming methods", () => { + test("review/start establishes stream ownership with reviewThreadId", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + // Use a long turn-completed delay so stream stays active during the test + const mockDir = createMockCodex(tempDir, { + sendTurnCompleted: true, + turnCompletedDelay: 5000, + }); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + const client1 = await TestClient.connectAndInit(sockPath); + const client2 = await TestClient.connectAndInit(sockPath); + + // Client 1 starts a review (streaming method) + const reviewResult = await client1.request("review/start", { + threadId: "thread-001", + target: { type: "uncommittedChanges" }, + }) as { turn: { id: string }; reviewThreadId: string }; + expect(reviewResult.reviewThreadId).toBe("review-thread-001"); + + // Immediately try client 2 — review stream is still active (5s delay) + let gotBusy = false; + try { + await client2.request("turn/start", { + threadId: "thread-001", + input: [{ type: "text", text: "hello" }], + }); + } catch (err: any) { + gotBusy = true; + expect(err.code).toBe(-32001); + } + expect(gotBusy).toBe(true); + + await client1.close(); + await client2.close(); + } finally { + proc.kill(); + } + }, 15_000); + }); + + // ── Error forwarding ────────────────────────────────────────────────────── + + describe("error forwarding", () => { + test("app-server error responses are forwarded to the client", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + const client = await TestClient.connectAndInit(sockPath); + + // Send a method that the mock doesn't know — it returns Method not found + try { + await client.request("unknown/method"); + throw new Error("Expected error"); + } catch (err: any) { + expect(err.message).toContain("Method not found: unknown/method"); + } + + await client.close(); + } finally { + proc.kill(); + } + }, 15_000); + }); + + // ── Forwarded response from wrong socket ────────────────────────────────── + + describe("forwarded response validation", () => { + // NOTE: This test only verifies the broker doesn't crash when receiving a + // response with an unknown id. It does not verify that the response is + // actually dropped (vs. silently forwarded somewhere). The broker logs a + // warning to stderr, but we don't capture subprocess stderr in assertions. + test("response for unknown forwarded request is ignored", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + const client = await TestClient.connectAndInit(sockPath); + + // Send a response with an id that doesn't match any pending forwarded request + client.send({ id: "nonexistent-req-id", result: { ok: true } }); + + // Broker should just log a warning and continue functioning + await new Promise((r) => setTimeout(r, 200)); + const result = await client.request("thread/list") as { data: unknown[] }; + expect(result.data).toBeArrayOfSize(0); + + await client.close(); + } finally { + proc.kill(); + } + }, 15_000); + }); + + // ── Stale socket cleanup ────────────────────────────────────────────────── + + describe("stale socket cleanup", () => { + test("removes stale socket file before listening", async () => { + + const sockPath = join(tempDir, "broker.sock"); + const endpoint = `unix:${sockPath}`; + const mockDir = createMockCodex(tempDir); + + // Create a stale socket file + writeFileSync(sockPath, "stale"); + + const proc = spawnBroker(endpoint, mockDir); + await waitForSocket(sockPath); + + try { + // Should be able to connect despite the stale file + const client = await TestClient.connectAndInit(sockPath); + const result = await client.request("thread/list") as { data: unknown[] }; + expect(result.data).toBeArrayOfSize(0); + await client.close(); + } finally { + proc.kill(); + } + }, 15_000); + }); +}); diff --git a/src/broker-server.ts b/src/broker-server.ts new file mode 100644 index 0000000..602b227 --- /dev/null +++ b/src/broker-server.ts @@ -0,0 +1,653 @@ +#!/usr/bin/env bun + +/** + * Broker server — a long-running detached process that multiplexes + * JSON-RPC messages between socket clients and a single `codex app-server` child. + * + * Usage: bun run src/broker-server.ts serve --endpoint [--cwd ] [--idle-timeout ] + * + * Behavior: + * - Spawns `codex app-server` as a child and connects via stdio + * - Listens on a Unix socket (or Windows named pipe) for client connections + * - Forwards JSON-RPC messages between socket clients and the app-server + * - Exclusive lock: only one client's request streams at a time + * - Returns error code -32001 when busy + * - Idle timeout: shuts down after N ms with no activity + * - Handles SIGTERM/SIGINT gracefully + */ + +import net from "node:net"; +import fs, { chmodSync } from "node:fs"; +import path from "node:path"; +import { + connectDirect, + parseMessage, + type AppServerClient, +} from "./client"; +import { parseEndpoint, BROKER_BUSY_RPC_CODE } from "./broker"; +import { RpcError } from "./types"; +import { config } from "./config"; + +// ─── Constants ────────────────────────────────────────────────────────────── + +const MAX_BUFFER_SIZE = 10 * 1024 * 1024; + +/** Methods that start a streaming turn — the socket that initiated the stream + * owns notifications until turn/completed arrives. */ +const STREAMING_METHODS = new Set(["turn/start", "review/start", "thread/compact/start"]); + +// ─── Argument parsing ─────────────────────────────────────────────────────── + +function parseArgs(argv: string[]): { + endpoint: string; + cwd: string; + idleTimeout: number; +} { + let endpoint: string | undefined; + let cwd = process.cwd(); + let idleTimeout = config.defaultBrokerIdleTimeout; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--endpoint" && i + 1 < argv.length) { + endpoint = argv[++i]; + } else if (arg === "--cwd" && i + 1 < argv.length) { + cwd = path.resolve(argv[++i]); + } else if (arg === "--idle-timeout" && i + 1 < argv.length) { + idleTimeout = Number(argv[++i]); + if (!Number.isFinite(idleTimeout) || idleTimeout <= 0) { + throw new Error(`Invalid --idle-timeout: ${argv[i]}`); + } + } + } + + if (!endpoint) { + throw new Error("Missing required --endpoint"); + } + + return { endpoint, cwd, idleTimeout }; +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function buildJsonRpcError(code: number, message: string, data?: unknown) { + return data === undefined ? { code, message } : { code, message, data }; +} + +function send(socket: net.Socket, message: Record): void { + if (socket.destroyed) return; + socket.write(JSON.stringify(message) + "\n"); +} + +function buildStreamThreadIds( + method: string, + params: Record | undefined, + result: Record, +): Set { + const ids = new Set(); + if (params?.threadId && typeof params.threadId === "string") { + ids.add(params.threadId); + } + if (method === "review/start" && typeof result?.reviewThreadId === "string") { + ids.add(result.reviewThreadId); + } + return ids; +} + +// ─── Main ─────────────────────────────────────────────────────────────────── + +async function main() { + const [subcommand, ...argv] = process.argv.slice(2); + if (subcommand !== "serve") { + throw new Error( + "Usage: bun run src/broker-server.ts serve --endpoint [--cwd ] [--idle-timeout ]", + ); + } + + const { endpoint, cwd, idleTimeout } = parseArgs(argv); + const listenTarget = parseEndpoint(endpoint); + + // Spawn the real app-server + const appClient = await connectDirect({ cwd }); + + // If the app-server exits unexpectedly, shut down the broker immediately + // so the next ensureConnection() spawns a fresh broker + app-server. + let shutdownInitiated = false; + appClient.onClose(() => { + if (shutdownInitiated) return; + shutdownInitiated = true; + process.stderr.write("[broker-server] App-server exited unexpectedly — shutting down\n"); + shutdown(server).then(() => process.exit(1)); + }); + + // ─── State ────────────────────────────────────────────────────────────── + + /** Socket that currently owns a pending request (waiting for response). */ + let activeRequestSocket: net.Socket | null = null; + /** Socket that owns the current streaming turn (notifications routed here). */ + let activeStreamSocket: net.Socket | null = null; + /** Thread IDs for the active stream (for turn/completed matching). */ + let activeStreamThreadIds: Set | null = null; + /** All connected sockets. */ + const sockets = new Set(); + /** Thread IDs whose turns completed — prevents stale stream ownership + * when turn/completed arrives during the streaming request itself. */ + const completedStreamThreadIds = new Set(); + /** Pending forwarded requests (e.g. approval requests sent to a client socket, + * awaiting a response routed through the main data handler). */ + const pendingForwardedRequests = new Map void; + reject: (error: Error) => void; + timer: ReturnType; + target: net.Socket; + }>(); + /** Idle timer — shut down if no activity within idleTimeout. */ + let idleTimer: ReturnType | null = null; + + function resetIdleTimer(): void { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + process.stderr.write("[broker-server] Idle timeout — shutting down\n"); + shutdown(server).then(() => process.exit(0)); + }, idleTimeout); + } + + // ─── Notification routing ─────────────────────────────────────────────── + + // Wire up a raw notification forwarder. The connectDirect client uses + // `on(method, handler)` for each method. Instead of registering every + // possible method, we'll use a single forwarding approach by re-exporting + // notifications through a wrapper. + + // For each notification the app-server sends, forward to the active socket. + // We register catch-all handlers for known notification types. + const NOTIFICATION_METHODS = [ + "item/started", + "item/completed", + "item/agentMessage/delta", + "item/commandExecution/outputDelta", + "item/reasoning/textDelta", + "turn/completed", + "error", + ]; + + for (const method of NOTIFICATION_METHODS) { + appClient.on(method, (notifParams) => { + resetIdleTimer(); + const target = activeRequestSocket ?? activeStreamSocket; + + // Forward the notification to the owning socket (if still connected) + if (target) { + const message: Record = { method, params: notifParams }; + send(target, message); + } + + // If turn/completed, release the stream ownership — even if the owning + // socket has disconnected (orphaned turn completing naturally). + if (method === "turn/completed") { + const threadId = (notifParams as Record)?.threadId; + // Track completed thread IDs so that a streaming request that is + // still awaiting its response doesn't re-establish ownership after + // the turn has already finished (fast-turn race). + if (typeof threadId === "string") { + completedStreamThreadIds.add(threadId); + } + const matchesStream = + !threadId || + typeof threadId !== "string" || + !activeStreamThreadIds || + activeStreamThreadIds.has(threadId); + if (matchesStream && (activeStreamSocket === target || activeStreamSocket === null)) { + // If we're releasing actual stream ownership (activeStreamSocket was set), + // also clean up the completed tracking so it doesn't block the next turn + // on the same thread. In the fast-turn race (activeStreamSocket is null), + // keep the tracking — the pending response handler needs it. + if (activeStreamSocket !== null && typeof threadId === "string") { + completedStreamThreadIds.delete(threadId); + } + activeStreamSocket = null; + activeStreamThreadIds = null; + if (target && activeRequestSocket === target) { + activeRequestSocket = null; + } + } + } + }); + } + + // Also forward server-sent requests (like approval requests) + const SERVER_REQUEST_METHODS = [ + "item/commandExecution/requestApproval", + "item/fileChange/requestApproval", + ]; + + for (const method of SERVER_REQUEST_METHODS) { + appClient.onRequest(method, async (reqParams) => { + resetIdleTimer(); + const target = activeRequestSocket ?? activeStreamSocket; + if (!target || target.destroyed) { + throw new Error("No active client to forward approval request"); + } + + // Forward the request to the client socket and wait for the response + // via the main data handler (which checks pendingForwardedRequests). + return new Promise((resolve, reject) => { + const reqId = `broker-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + // Match client-side approval timeout (1 hour) — interactive approvals + // require human action and 60s is too short. + const timer = setTimeout(() => { + pendingForwardedRequests.delete(reqId); + reject(new Error("Approval request forwarding timed out")); + }, 3_600_000); + + pendingForwardedRequests.set(reqId, { resolve, reject, timer, target }); + + // Send the request to the client socket + send(target, { id: reqId, method, params: reqParams }); + }); + }); + } + + // ─── Shutdown ─────────────────────────────────────────────────────────── + + async function shutdown(server: net.Server): Promise { + shutdownInitiated = true; + if (idleTimer) clearTimeout(idleTimer); + // Reject all pending forwarded requests before closing sockets + for (const [reqId, entry] of pendingForwardedRequests) { + clearTimeout(entry.timer); + entry.reject(new Error("Broker shutting down")); + pendingForwardedRequests.delete(reqId); + } + for (const socket of sockets) { + socket.end(); + } + try { + await appClient.close(); + } catch (e) { + process.stderr.write(`[broker-server] Warning: app-server close failed: ${e instanceof Error ? e.message : String(e)}\n`); + } + await new Promise((resolve) => server.close(() => resolve())); + if (listenTarget.kind === "unix") { + try { + fs.unlinkSync(listenTarget.path); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") { + process.stderr.write( + `[broker-server] Warning: socket cleanup failed: ${(e as Error).message}\n`, + ); + } + } + } + } + + // ─── Approval response fast-path ───────────────────────────────────── + + // Routes approval responses synchronously, bypassing the per-socket message + // queue. This prevents deadlocks when a client's approval response is queued + // behind an RPC request that the app-server can't complete until the approval + // is received. + function tryRouteApprovalResponse(socket: net.Socket, line: string): boolean { + let parsed: Record; + try { + parsed = JSON.parse(line); + } catch { + return false; + } + if (typeof parsed !== "object" || parsed === null) return false; + // Approval responses have id but no method + if (parsed.id === undefined || "method" in parsed) return false; + const reqId = String(parsed.id); + const entry = pendingForwardedRequests.get(reqId); + if (!entry) return false; // Not a pending forwarded request — let the queue handle it + resetIdleTimer(); + if (entry.target !== socket) { + process.stderr.write( + `[broker-server] Warning: forwarded response id=${reqId} from wrong socket — ignoring\n`, + ); + return true; + } + pendingForwardedRequests.delete(reqId); + clearTimeout(entry.timer); + if ("result" in parsed) { + entry.resolve(parsed.result); + } else if ("error" in parsed) { + const errObj = parsed.error as Record | undefined; + entry.reject(new Error((errObj?.message as string) ?? "Client error")); + } else { + entry.reject(new Error("Malformed forwarded response: missing both 'result' and 'error'")); + } + return true; + } + + // ─── Per-socket message handler ──────────────────────────────────────── + + // Processes a single JSON-RPC message from a client socket. Extracted from + // the data handler so messages can be chained via a per-socket Promise + // queue, preventing async reentrancy on the shared buffer. + async function processMessage(socket: net.Socket, line: string): Promise { + resetIdleTimer(); + + let message: Record; + try { + message = JSON.parse(line); + } catch (err) { + send(socket, { + id: null, + error: buildJsonRpcError( + -32700, + `Invalid JSON: ${(err as Error).message}`, + ), + }); + return; + } + + // Handle initialize locally — don't forward to app-server + if (message.id !== undefined && message.method === "initialize") { + send(socket, { + id: message.id, + result: { + userAgent: "codex-collab-broker", + busy: activeStreamSocket !== null, + }, + }); + return; + } + + // Swallow initialized notification + if (message.method === "initialized" && message.id === undefined) { + return; + } + + // Handle broker/shutdown + if (message.id !== undefined && message.method === "broker/shutdown") { + send(socket, { id: message.id, result: {} }); + await shutdown(server); + process.exit(0); + } + + // Ignore notifications (no id) from clients + if (message.id === undefined) { + return; + } + + // Route responses (id + result/error, no method) to pending forwarded + // requests (e.g. approval request responses from the client). + if (message.id !== undefined && !("method" in message)) { + const reqId = String(message.id); + const entry = pendingForwardedRequests.get(reqId); + if (entry) { + if (entry.target !== socket) { + process.stderr.write( + `[broker-server] Warning: forwarded response id=${reqId} from wrong socket — ignoring\n`, + ); + return; + } + pendingForwardedRequests.delete(reqId); + clearTimeout(entry.timer); + if ("result" in message) { + entry.resolve(message.result); + } else if ("error" in message) { + const errObj = message.error as Record | undefined; + entry.reject(new Error((errObj?.message as string) ?? "Client error")); + } else { + entry.reject(new Error("Malformed forwarded response: missing both 'result' and 'error'")); + } + } else { + process.stderr.write( + `[broker-server] Warning: received response for unknown/expired forwarded request id=${reqId}\n`, + ); + } + return; + } + + // ─── Concurrency control ────────────────────────────────── + + const isInterrupt = + typeof message.method === "string" && + message.method === "turn/interrupt"; + const isReadOnly = + typeof message.method === "string" && + (message.method === "thread/read" || message.method === "thread/list"); + + // Allow interrupt and read-only requests through even when another + // client owns the stream — but only when there's no pending request. + // Read-only methods are needed by `kill` (reads thread to get turn ID) + // and `threads` (lists threads while a turn is running). + const allowDuringActiveStream = + (isInterrupt || isReadOnly) && + activeStreamSocket !== null && + activeStreamSocket !== socket && + activeRequestSocket === null; + + if ( + ((activeRequestSocket !== null && activeRequestSocket !== socket) || + (activeStreamSocket !== null && activeStreamSocket !== socket)) && + !allowDuringActiveStream + ) { + send(socket, { + id: message.id, + error: buildJsonRpcError( + BROKER_BUSY_RPC_CODE, + "Shared Codex broker is busy.", + ), + }); + return; + } + + // Forward interrupt/read-only during active stream (special path) + if (allowDuringActiveStream) { + try { + const result = await appClient.request( + message.method as string, + (message.params ?? {}) as Record, + ); + send(socket, { id: message.id, result }); + } catch (error) { + send(socket, { + id: message.id, + error: buildJsonRpcError( + error instanceof RpcError ? error.rpcCode : -32000, + (error as Error).message, + ), + }); + } + return; + } + + // ─── Normal request forwarding ──────────────────────────── + + const isStreaming = STREAMING_METHODS.has(message.method as string); + activeRequestSocket = socket; + + try { + const result = await appClient.request( + message.method as string, + (message.params ?? {}) as Record, + ); + + // If the requesting client disconnected while we were waiting for the + // response, the turn has started on the app-server but nobody is + // listening. Interrupt it immediately to free the stream slot. + if (socket.destroyed && isStreaming) { + const turn = (result as Record)?.turn as Record | undefined; + const turnId = turn?.id as string | undefined; + const threadId = (message.params as Record)?.threadId as string | undefined; + if (turnId && threadId) { + appClient.request("turn/interrupt", { threadId, turnId }).catch((e) => { + process.stderr.write( + `[broker-server] Warning: failed to interrupt orphaned turn ${turnId}: ${e instanceof Error ? e.message : String(e)}\n`, + ); + }); + } + if (activeRequestSocket === socket) activeRequestSocket = null; + return; + } + + send(socket, { id: message.id, result }); + + if (isStreaming) { + const streamIds = buildStreamThreadIds( + message.method as string, + message.params as Record | undefined, + result as Record, + ); + // Only claim stream ownership if the turn hasn't already completed + // during the request. turn/completed can arrive in the same read + // chunk as the response, firing the notification handler before + // this code runs. Without this check the broker stays permanently busy. + const alreadyCompleted = [...streamIds].some(id => completedStreamThreadIds.has(id)); + if (!alreadyCompleted) { + activeStreamSocket = socket; + activeStreamThreadIds = streamIds; + } + // Clean up tracked completions for these thread IDs + for (const id of streamIds) completedStreamThreadIds.delete(id); + } + + if (activeRequestSocket === socket) { + activeRequestSocket = null; + } + } catch (error) { + send(socket, { + id: message.id, + error: buildJsonRpcError( + error instanceof RpcError ? error.rpcCode : -32000, + (error as Error).message, + ), + }); + if (activeRequestSocket === socket) { + activeRequestSocket = null; + } + if (activeStreamSocket === socket && !isStreaming) { + activeStreamSocket = null; + } + } + } + + // ─── Socket server ───────────────────────────────────────────────────── + + const server = net.createServer((socket) => { + sockets.add(socket); + socket.setEncoding("utf8"); + let buffer = ""; + resetIdleTimer(); + + let messageQueue: Promise = Promise.resolve(); + + socket.on("data", (chunk: string) => { + buffer += chunk; + if (buffer.length > MAX_BUFFER_SIZE) { + process.stderr.write("[broker-server] Client buffer exceeded maximum size, disconnecting\n"); + socket.destroy(); + return; + } + // Extract complete lines synchronously to prevent async reentrancy + // on the shared buffer when multiple data events overlap. + const lines: string[] = []; + let newlineIdx: number; + while ((newlineIdx = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, newlineIdx).trim(); + buffer = buffer.slice(newlineIdx + 1); + if (line) lines.push(line); + } + for (const line of lines) { + // Approval responses bypass the queue to prevent deadlocks when + // queued behind an RPC request awaiting the same approval. + if (!tryRouteApprovalResponse(socket, line)) { + messageQueue = messageQueue.then(() => processMessage(socket, line)); + } + } + }); + + socket.on("close", () => { + sockets.delete(socket); + // Reject only pending forwarded requests targeting this socket + for (const [reqId, entry] of pendingForwardedRequests) { + if (entry.target !== socket) continue; + clearTimeout(entry.timer); + entry.reject(new Error("Client disconnected while awaiting approval response")); + pendingForwardedRequests.delete(reqId); + } + if (activeStreamSocket === socket) { + if (activeStreamThreadIds) { + // Turn is still running — keep activeStreamSocket as a sentinel so the + // concurrency check blocks new streaming requests until turn/completed + // clears the state. Nulling it would let a second client interleave. + process.stderr.write("[broker-server] Warning: stream-owning client disconnected while turn is active\n"); + } else { + activeStreamSocket = null; + } + } + if (activeRequestSocket === socket) { + activeRequestSocket = null; + } + }); + + socket.on("error", (err) => { + process.stderr.write(`[broker-server] Client socket error: ${err.message}\n`); + sockets.delete(socket); + // Reject only pending forwarded requests targeting this socket + for (const [reqId, entry] of pendingForwardedRequests) { + if (entry.target !== socket) continue; + clearTimeout(entry.timer); + entry.reject(new Error("Client socket error while awaiting approval response")); + pendingForwardedRequests.delete(reqId); + } + if (activeStreamSocket === socket) { + if (activeStreamThreadIds) { + // Turn is still running — keep activeStreamSocket as sentinel so the + // concurrency check blocks new streaming requests until turn/completed. + process.stderr.write("[broker-server] Warning: stream-owning client errored while turn is active\n"); + } else { + activeStreamSocket = null; + } + } + if (activeRequestSocket === socket) { + activeRequestSocket = null; + } + }); + }); + + // ─── Signal handlers ────────────────────────────────────────────────── + + process.on("SIGTERM", async () => { + await shutdown(server); + process.exit(0); + }); + + process.on("SIGINT", async () => { + await shutdown(server); + process.exit(0); + }); + + // ─── Start listening ────────────────────────────────────────────────── + + // Remove stale socket file before listening (Unix only) + if (listenTarget.kind === "unix") { + try { + fs.unlinkSync(listenTarget.path); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e; + } + } + + server.listen(listenTarget.path, () => { + process.stderr.write( + `[broker-server] Listening on ${endpoint} (idle timeout: ${idleTimeout}ms)\n`, + ); + if (listenTarget.kind === "unix") { + chmodSync(listenTarget.path, 0o700); + } + }); + + resetIdleTimer(); +} + +main().catch((error) => { + process.stderr.write( + `[broker-server] Fatal: ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exit(1); +}); diff --git a/src/broker.test.ts b/src/broker.test.ts new file mode 100644 index 0000000..b89adde --- /dev/null +++ b/src/broker.test.ts @@ -0,0 +1,980 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { + createEndpoint, + parseEndpoint, + saveBrokerState, + loadBrokerState, + clearBrokerState, + saveSessionState, + loadSessionState, + isBrokerAlive, + getCurrentSessionId, + acquireSpawnLock, + teardownBroker, +} from "./broker"; +import { connectToBroker } from "./broker-client"; +import net from "node:net"; +import { mkdtempSync, rmSync, writeFileSync, existsSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import type { BrokerState } from "./types"; + +let tempDir: string; + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "broker-test-")); +}); + +afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); +}); + +// ─── createEndpoint ─────────────────────────────────────────────────────── + +describe("createEndpoint", () => { + test.skipIf(process.platform === "win32")("returns unix endpoint on non-windows", () => { + const ep = createEndpoint(tempDir, "linux"); + expect(ep).toBe(`unix:${tempDir}/broker.sock`); + }); + + test.skipIf(process.platform === "win32")("returns unix endpoint on darwin", () => { + const ep = createEndpoint(tempDir, "darwin"); + expect(ep).toBe(`unix:${tempDir}/broker.sock`); + }); + + test("returns pipe endpoint on win32", () => { + const ep = createEndpoint(tempDir, "win32"); + expect(ep).toMatch(/^pipe:\\\\.\\pipe\\codex-collab-[0-9a-f]+$/); + }); + + test("defaults to current platform", () => { + const ep = createEndpoint(tempDir); + // On Linux/macOS CI, this should be unix: + if (process.platform !== "win32") { + expect(ep.startsWith("unix:")).toBe(true); + } else { + expect(ep.startsWith("pipe:")).toBe(true); + } + }); +}); + +// ─── parseEndpoint ──────────────────────────────────────────────────────── + +describe("parseEndpoint", () => { + test("parses unix endpoint", () => { + const parsed = parseEndpoint("unix:/tmp/broker.sock"); + expect(parsed).toEqual({ kind: "unix", path: "/tmp/broker.sock" }); + }); + + test("parses pipe endpoint", () => { + const parsed = parseEndpoint("pipe:\\\\.\\pipe\\codex-collab-abc123"); + expect(parsed).toEqual({ kind: "pipe", path: "\\\\.\\pipe\\codex-collab-abc123" }); + }); + + test("throws on invalid endpoint", () => { + expect(() => parseEndpoint("http://localhost:3000")).toThrow(/Invalid endpoint/); + }); + + test("throws on empty string", () => { + expect(() => parseEndpoint("")).toThrow(/Invalid endpoint/); + }); + + test("throws on prefix without path", () => { + expect(() => parseEndpoint("unix:")).toThrow(/Invalid endpoint/); + }); +}); + +// ─── broker state persistence ───────────────────────────────────────────── + +describe("broker state", () => { + test("save/load round-trip", () => { + const state: BrokerState = { + endpoint: "unix:/tmp/broker.sock", + pid: 12345, + sessionDir: "/tmp/session", + startedAt: "2026-01-01T00:00:00Z", + }; + saveBrokerState(tempDir, state); + const loaded = loadBrokerState(tempDir); + expect(loaded).toEqual(state); + }); + + test("returns null for missing file", () => { + const loaded = loadBrokerState(tempDir); + expect(loaded).toBeNull(); + }); + + test("returns null for invalid JSON", () => { + writeFileSync(join(tempDir, "broker.json"), "not-json{{{"); + const loaded = loadBrokerState(tempDir); + expect(loaded).toBeNull(); + }); + + test("clear removes broker.json", () => { + const state: BrokerState = { + endpoint: "unix:/tmp/broker.sock", + pid: 12345, + sessionDir: "/tmp/session", + startedAt: "2026-01-01T00:00:00Z", + }; + saveBrokerState(tempDir, state); + expect(loadBrokerState(tempDir)).not.toBeNull(); + + clearBrokerState(tempDir); + expect(loadBrokerState(tempDir)).toBeNull(); + expect(existsSync(join(tempDir, "broker.json"))).toBe(false); + }); +}); + +// ─── session state persistence ──────────────────────────────────────────── + +describe("session state", () => { + test("save/load round-trip", () => { + const state = { + sessionId: "abc-123", + startedAt: "2026-01-01T00:00:00Z", + }; + saveSessionState(tempDir, state); + const loaded = loadSessionState(tempDir); + expect(loaded).toEqual(state); + }); + + test("returns null for missing file", () => { + const loaded = loadSessionState(tempDir); + expect(loaded).toBeNull(); + }); +}); + +// ─── isBrokerAlive ──────────────────────────────────────────────────────── + +describe("isBrokerAlive", () => { + test("returns false for non-existent unix socket", async () => { + const alive = await isBrokerAlive("unix:/tmp/nonexistent-broker-test.sock", 100); + expect(alive).toBe(false); + }); + + test("returns false for non-existent pipe", async () => { + const alive = await isBrokerAlive("pipe:\\\\.\\pipe\\nonexistent-broker-test", 100); + expect(alive).toBe(false); + }); + + test("returns false for invalid endpoint", async () => { + const alive = await isBrokerAlive("invalid:something", 100); + expect(alive).toBe(false); + }); + + test("returns false for null endpoint", async () => { + const alive = await isBrokerAlive(null, 100); + expect(alive).toBe(false); + }); +}); + +// ─── getCurrentSessionId ────────────────────────────────────────────────── + +describe("getCurrentSessionId", () => { + test("reads from env var first", () => { + const orig = process.env.CODEX_COLLAB_SESSION_ID; + try { + process.env.CODEX_COLLAB_SESSION_ID = "env-session-123"; + const id = getCurrentSessionId(tempDir); + expect(id).toBe("env-session-123"); + } finally { + if (orig !== undefined) { + process.env.CODEX_COLLAB_SESSION_ID = orig; + } else { + delete process.env.CODEX_COLLAB_SESSION_ID; + } + } + }); + + test("reads from session.json when env var not set", () => { + const orig = process.env.CODEX_COLLAB_SESSION_ID; + try { + delete process.env.CODEX_COLLAB_SESSION_ID; + saveSessionState(tempDir, { + sessionId: "file-session-456", + startedAt: "2026-01-01T00:00:00Z", + }); + const id = getCurrentSessionId(tempDir); + expect(id).toBe("file-session-456"); + } finally { + if (orig !== undefined) { + process.env.CODEX_COLLAB_SESSION_ID = orig; + } else { + delete process.env.CODEX_COLLAB_SESSION_ID; + } + } + }); + + test("returns null when neither env var nor session.json exists", () => { + const orig = process.env.CODEX_COLLAB_SESSION_ID; + try { + delete process.env.CODEX_COLLAB_SESSION_ID; + const id = getCurrentSessionId(tempDir); + expect(id).toBeNull(); + } finally { + if (orig !== undefined) { + process.env.CODEX_COLLAB_SESSION_ID = orig; + } else { + delete process.env.CODEX_COLLAB_SESSION_ID; + } + } + }); +}); + +// ─── acquireSpawnLock ───────────────────────────────────────────────────── + +describe("acquireSpawnLock", () => { + test("acquires and releases lock", () => { + const release = acquireSpawnLock(tempDir); + expect(release).not.toBeNull(); + expect(existsSync(join(tempDir, "broker.lock"))).toBe(true); + release!(); + expect(existsSync(join(tempDir, "broker.lock"))).toBe(false); + }); + + test("second acquire succeeds after first is released", () => { + const release1 = acquireSpawnLock(tempDir); + expect(release1).not.toBeNull(); + release1!(); + + const release2 = acquireSpawnLock(tempDir); + expect(release2).not.toBeNull(); + release2!(); + }); +}); + +// ─── teardownBroker ─────────────────────────────────────────────────────── + +describe("teardownBroker", () => { + test("clears broker state file", () => { + const state: BrokerState = { + endpoint: `unix:${tempDir}/broker.sock`, + pid: null, + sessionDir: tempDir, + startedAt: "2026-01-01T00:00:00Z", + }; + saveBrokerState(tempDir, state); + teardownBroker(tempDir, state); + expect(loadBrokerState(tempDir)).toBeNull(); + }); + + test("removes socket file for unix endpoint", () => { + const sockPath = join(tempDir, "broker.sock"); + writeFileSync(sockPath, ""); // simulate socket file + const state: BrokerState = { + endpoint: `unix:${sockPath}`, + pid: null, + sessionDir: tempDir, + startedAt: "2026-01-01T00:00:00Z", + }; + saveBrokerState(tempDir, state); + teardownBroker(tempDir, state); + expect(existsSync(sockPath)).toBe(false); + }); + + test("does not throw for missing socket file", () => { + const state: BrokerState = { + endpoint: `unix:${tempDir}/nonexistent.sock`, + pid: null, + sessionDir: tempDir, + startedAt: "2026-01-01T00:00:00Z", + }; + expect(() => teardownBroker(tempDir, state)).not.toThrow(); + }); +}); + +// ─── BrokerClient ──────────────────────────────────────────────────────── + +// BrokerClient tests require Unix socket creation, which may be restricted +// in sandboxed environments. Detected at first test run. +let canCreateSockets: boolean | null = null; + +async function checkSocketSupport(): Promise { + if (canCreateSockets !== null) return canCreateSockets; + // BrokerClient tests use `unix:` endpoint strings which don't work on Windows + if (process.platform === "win32") { canCreateSockets = false; return false; } + const checkDir = mkdtempSync(join(tmpdir(), "broker-sock-check-")); + const testSock = join(checkDir, "test.sock"); + try { + const srv = net.createServer(); + await new Promise((resolve, reject) => { + srv.on("error", reject); + srv.listen(testSock, () => { srv.close(); resolve(); }); + }); + canCreateSockets = true; + } catch { + canCreateSockets = false; + } + try { rmSync(checkDir, { recursive: true, force: true }); } catch {} + return canCreateSockets; +} + +describe("BrokerClient", () => { + test("connects to a mock broker server and performs handshake", async () => { + if (!await checkSocketSupport()) return; // skip in sandboxed environments + const sockPath = join(tempDir, "mock-broker.sock"); + + // Create a mock broker that responds to initialize + const server = net.createServer((socket) => { + socket.setEncoding("utf8"); + let buffer = ""; + socket.on("data", (chunk: string) => { + buffer += chunk; + let idx: number; + while ((idx = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (!line) continue; + try { + const msg = JSON.parse(line); + if (msg.method === "initialize" && msg.id !== undefined) { + socket.write(JSON.stringify({ id: msg.id, result: { userAgent: "mock-broker" } }) + "\n"); + } else if (msg.method === "initialized") { + // Swallow + } else if (msg.method === "test/echo" && msg.id !== undefined) { + socket.write(JSON.stringify({ id: msg.id, result: { echo: msg.params } }) + "\n"); + } + } catch { + // ignore parse errors + } + } + }); + }); + + await new Promise((resolve) => server.listen(sockPath, resolve)); + + try { + const client = await connectToBroker({ endpoint: `unix:${sockPath}` }); + expect(client.userAgent).toBe("mock-broker"); + + // Test a round-trip request + const result = await client.request<{ echo: unknown }>("test/echo", { hello: "world" }); + expect(result.echo).toEqual({ hello: "world" }); + + await client.close(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + try { rmSync(sockPath); } catch {} + } + }); + + test("receives notifications from broker", async () => { + if (!await checkSocketSupport()) return; // skip in sandboxed environments + const sockPath = join(tempDir, "mock-notif.sock"); + let clientSocket: net.Socket | null = null; + + const server = net.createServer((socket) => { + clientSocket = socket; + socket.setEncoding("utf8"); + let buffer = ""; + socket.on("data", (chunk: string) => { + buffer += chunk; + let idx: number; + while ((idx = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (!line) continue; + try { + const msg = JSON.parse(line); + if (msg.method === "initialize" && msg.id !== undefined) { + socket.write(JSON.stringify({ id: msg.id, result: { userAgent: "mock-notif" } }) + "\n"); + } + } catch {} + } + }); + }); + + await new Promise((resolve) => server.listen(sockPath, resolve)); + + try { + const client = await connectToBroker({ endpoint: `unix:${sockPath}` }); + + // Register notification handler + const received: unknown[] = []; + client.on("test/event", (params) => { + received.push(params); + }); + + // Send a notification from the server + clientSocket!.write(JSON.stringify({ method: "test/event", params: { value: 42 } }) + "\n"); + + // Give it a moment to arrive + await new Promise((r) => setTimeout(r, 50)); + expect(received).toEqual([{ value: 42 }]); + + await client.close(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + try { rmSync(sockPath); } catch {} + } + }); + + test("rejects with error on connection failure", async () => { + await expect( + connectToBroker({ endpoint: `unix:${tempDir}/nonexistent.sock` }), + ).rejects.toThrow(/Failed to connect to broker/); + }); + + test("request rejects on JSON-RPC error from broker", async () => { + if (!await checkSocketSupport()) return; // skip in sandboxed environments + const sockPath = join(tempDir, "mock-err.sock"); + + const server = net.createServer((socket) => { + socket.setEncoding("utf8"); + let buffer = ""; + socket.on("data", (chunk: string) => { + buffer += chunk; + let idx: number; + while ((idx = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (!line) continue; + try { + const msg = JSON.parse(line); + if (msg.method === "initialize" && msg.id !== undefined) { + socket.write(JSON.stringify({ id: msg.id, result: { userAgent: "mock" } }) + "\n"); + } else if (msg.method === "test/fail" && msg.id !== undefined) { + socket.write(JSON.stringify({ + id: msg.id, + error: { code: -32001, message: "Broker is busy" }, + }) + "\n"); + } + } catch {} + } + }); + }); + + await new Promise((resolve) => server.listen(sockPath, resolve)); + + try { + const client = await connectToBroker({ endpoint: `unix:${sockPath}` }); + await expect(client.request("test/fail")).rejects.toThrow(/Broker is busy/); + await client.close(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + try { rmSync(sockPath); } catch {} + } + }); +}); + +// ─── BrokerClient edge cases ──────────────────────────────────────────── + +// Helper: create a mock broker server that completes the initialize handshake +// and optionally runs a per-connection callback for custom behavior. +type ConnectionHandler = ( + socket: net.Socket, + parsedMessages: { resolve: (msg: Record) => void; promise: Promise> }, +) => void; + +function createMockBroker( + sockPath: string, + onConnection?: ConnectionHandler, +): { server: net.Server; clientSockets: net.Socket[]; start: () => Promise; stop: () => Promise } { + const clientSockets: net.Socket[] = []; + const server = net.createServer((socket) => { + clientSockets.push(socket); + socket.setEncoding("utf8"); + let buffer = ""; + let handshakeDone = false; + + // Create a deferred for the first post-handshake message + let resolveNext: ((msg: Record) => void) | null = null; + const nextMessage = new Promise>((resolve) => { + resolveNext = resolve; + }); + + socket.on("data", (chunk: string) => { + buffer += chunk; + let idx: number; + while ((idx = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (!line) continue; + try { + const msg = JSON.parse(line); + if (msg.method === "initialize" && msg.id !== undefined) { + socket.write( + JSON.stringify({ id: msg.id, result: { userAgent: "test-broker" } }) + "\n", + ); + } else if (msg.method === "initialized") { + handshakeDone = true; + } else if (handshakeDone && resolveNext) { + resolveNext(msg); + } + } catch {} + } + }); + + if (onConnection) { + onConnection(socket, { resolve: resolveNext!, promise: nextMessage }); + } + }); + + return { + server, + clientSockets, + start: () => new Promise((resolve) => server.listen(sockPath, resolve)), + stop: () => + new Promise((resolve) => { + for (const s of clientSockets) { + try { s.destroy(); } catch {} + } + server.close(() => resolve()); + try { rmSync(sockPath); } catch {} + }), + }; +} + +describe("BrokerClient — request timeout", () => { + test("rejects when server never responds to a request", async () => { + if (!await checkSocketSupport()) return; + const sockPath = join(tempDir, "timeout.sock"); + + // Server completes handshake but never responds to subsequent requests + const broker = createMockBroker(sockPath); + await broker.start(); + + try { + const client = await connectToBroker({ + endpoint: `unix:${sockPath}`, + requestTimeout: 200, // 200ms for fast test + }); + + const start = Date.now(); + await expect(client.request("test/hang")).rejects.toThrow(/timed out/); + const elapsed = Date.now() - start; + expect(elapsed).toBeGreaterThanOrEqual(180); + expect(elapsed).toBeLessThan(2000); + + await client.close(); + } finally { + await broker.stop(); + } + }); +}); + +describe("BrokerClient — socket close during pending request", () => { + test("rejects all pending requests when server closes the connection", async () => { + if (!await checkSocketSupport()) return; + const sockPath = join(tempDir, "close-pending.sock"); + + const broker = createMockBroker(sockPath); + await broker.start(); + + try { + const client = await connectToBroker({ + endpoint: `unix:${sockPath}`, + requestTimeout: 5000, + }); + + // Fire a request, then immediately destroy the server socket + const reqPromise = client.request("test/pending"); + // Small delay to ensure the request is sent before destroying + await new Promise((r) => setTimeout(r, 20)); + for (const s of broker.clientSockets) s.destroy(); + + await expect(reqPromise).rejects.toThrow(/Broker connection closed/); + + await client.close(); + } finally { + await broker.stop(); + } + }); +}); + +describe("BrokerClient — socket error during pending request", () => { + test("rejects pending requests when socket emits an error", async () => { + if (!await checkSocketSupport()) return; + const sockPath = join(tempDir, "error-pending.sock"); + + const broker = createMockBroker(sockPath); + await broker.start(); + + try { + const client = await connectToBroker({ + endpoint: `unix:${sockPath}`, + requestTimeout: 5000, + }); + + const reqPromise = client.request("test/error-case"); + // Capture rejection before triggering it to prevent unhandled rejection + let rejectedWith: Error | null = null; + reqPromise.catch((e: Error) => { rejectedWith = e; }); + await new Promise((r) => setTimeout(r, 20)); + // Destroy the server-side socket to trigger client disconnection + for (const s of broker.clientSockets) { + s.destroy(); + } + // Wait for the rejection to propagate + await new Promise((r) => setTimeout(r, 50)); + // Remote destroy may surface as either "close" or "error" depending on + // platform timing — both are valid rejection paths in broker-client.ts. + expect(rejectedWith).not.toBeNull(); + expect(rejectedWith!.message).toMatch(/Broker connection closed|Broker socket error/); + } finally { + await broker.stop(); + } + }); +}); + +describe("BrokerClient — close() while requests pending", () => { + test("rejects pending requests with 'Client closed'", async () => { + if (!await checkSocketSupport()) return; + const sockPath = join(tempDir, "close-while-pending.sock"); + + // Server never responds to post-handshake requests + const broker = createMockBroker(sockPath); + await broker.start(); + + try { + const client = await connectToBroker({ + endpoint: `unix:${sockPath}`, + requestTimeout: 30000, + }); + + const reqPromise = client.request("test/slow"); + // Capture the rejection BEFORE calling close() — close() synchronously + // calls rejectAll which fires reject() before we can attach a handler. + let rejectedWith: Error | null = null; + reqPromise.catch((e: Error) => { rejectedWith = e; }); + // close() synchronously calls rejectAll("Client closed") + await client.close(); + // Give microtask queue time to process the rejection + await new Promise((r) => setTimeout(r, 10)); + expect(rejectedWith).not.toBeNull(); + expect(rejectedWith!.message).toMatch(/Client closed/); + } finally { + await broker.stop(); + } + }); +}); + +describe("BrokerClient — request after close()", () => { + test("immediately rejects with 'Client is closed'", async () => { + if (!await checkSocketSupport()) return; + const sockPath = join(tempDir, "request-after-close.sock"); + + const broker = createMockBroker(sockPath); + await broker.start(); + + try { + const client = await connectToBroker({ endpoint: `unix:${sockPath}` }); + await client.close(); + + await expect(client.request("test/anything")).rejects.toThrow(/Client is closed/); + } finally { + await broker.stop(); + } + }); +}); + +describe("BrokerClient — server-sent request (onRequest handler)", () => { + test("dispatches server-sent requests and sends back the response", async () => { + if (!await checkSocketSupport()) return; + const sockPath = join(tempDir, "server-request.sock"); + + let serverSocket: net.Socket | null = null; + const broker = createMockBroker(sockPath, (socket) => { + serverSocket = socket; + }); + await broker.start(); + + try { + const client = await connectToBroker({ endpoint: `unix:${sockPath}` }); + + // Register a handler for a method the server will call + const receivedParams: unknown[] = []; + client.onRequest("approval/request", (params) => { + receivedParams.push(params); + return { approved: true }; + }); + + // Server sends a request to the client + serverSocket!.write( + JSON.stringify({ id: 999, method: "approval/request", params: { tool: "bash", command: "ls" } }) + "\n", + ); + + // Wait for the response to come back on the server socket + const response = await new Promise>((resolve) => { + let buf = ""; + // The socket already has a data listener from createMockBroker, so + // we add another one specifically to capture the response + const onData = (chunk: string) => { + buf += chunk; + let idx: number; + while ((idx = buf.indexOf("\n")) !== -1) { + const line = buf.slice(0, idx).trim(); + buf = buf.slice(idx + 1); + if (!line) continue; + try { + const msg = JSON.parse(line); + if (msg.id === 999 && "result" in msg) { + serverSocket!.removeListener("data", onData); + resolve(msg); + } + } catch {} + } + }; + serverSocket!.on("data", onData); + }); + + expect(receivedParams).toEqual([{ tool: "bash", command: "ls" }]); + expect(response.result).toEqual({ approved: true }); + + await client.close(); + } finally { + await broker.stop(); + } + }); + + test("sends method-not-found error when no handler registered", async () => { + if (!await checkSocketSupport()) return; + const sockPath = join(tempDir, "server-request-no-handler.sock"); + + let serverSocket: net.Socket | null = null; + const broker = createMockBroker(sockPath, (socket) => { + serverSocket = socket; + }); + await broker.start(); + + try { + const client = await connectToBroker({ endpoint: `unix:${sockPath}` }); + + // Server sends a request for a method with no handler + serverSocket!.write( + JSON.stringify({ id: 888, method: "unknown/method", params: {} }) + "\n", + ); + + // Wait for the error response + const response = await new Promise>((resolve) => { + let buf = ""; + const onData = (chunk: string) => { + buf += chunk; + let idx: number; + while ((idx = buf.indexOf("\n")) !== -1) { + const line = buf.slice(0, idx).trim(); + buf = buf.slice(idx + 1); + if (!line) continue; + try { + const msg = JSON.parse(line); + if (msg.id === 888 && "error" in msg) { + serverSocket!.removeListener("data", onData); + resolve(msg); + } + } catch {} + } + }; + serverSocket!.on("data", onData); + }); + + expect((response.error as any).code).toBe(-32601); + expect((response.error as any).message).toContain("Method not found"); + + await client.close(); + } finally { + await broker.stop(); + } + }); +}); + +describe("BrokerClient — onClose callback", () => { + test("fires on unexpected server disconnect", async () => { + if (!await checkSocketSupport()) return; + const sockPath = join(tempDir, "onclose-unexpected.sock"); + + const broker = createMockBroker(sockPath); + await broker.start(); + + try { + const client = await connectToBroker({ endpoint: `unix:${sockPath}` }); + + let closeFired = false; + client.onClose(() => { + closeFired = true; + }); + + // Destroy all server sockets (simulate unexpected disconnect) + for (const s of broker.clientSockets) s.destroy(); + + // Wait for close event to propagate + await new Promise((r) => setTimeout(r, 100)); + expect(closeFired).toBe(true); + + await client.close(); + } finally { + await broker.stop(); + } + }); + + test("does NOT fire on intentional close()", async () => { + if (!await checkSocketSupport()) return; + const sockPath = join(tempDir, "onclose-intentional.sock"); + + const broker = createMockBroker(sockPath); + await broker.start(); + + try { + const client = await connectToBroker({ endpoint: `unix:${sockPath}` }); + + let closeFired = false; + client.onClose(() => { + closeFired = true; + }); + + await client.close(); + + // Give it some time to ensure the handler does not fire + await new Promise((r) => setTimeout(r, 100)); + expect(closeFired).toBe(false); + } finally { + await broker.stop(); + } + }); + + test("unsubscribe removes the onClose handler", async () => { + if (!await checkSocketSupport()) return; + const sockPath = join(tempDir, "onclose-unsub.sock"); + + const broker = createMockBroker(sockPath); + await broker.start(); + + try { + const client = await connectToBroker({ endpoint: `unix:${sockPath}` }); + + let closeFired = false; + const unsub = client.onClose(() => { + closeFired = true; + }); + unsub(); // unsubscribe before the disconnect + + for (const s of broker.clientSockets) s.destroy(); + await new Promise((r) => setTimeout(r, 100)); + expect(closeFired).toBe(false); + + await client.close(); + } finally { + await broker.stop(); + } + }); +}); + +describe("BrokerClient — buffer overflow protection", () => { + test("disconnects when buffer exceeds MAX_BUFFER_SIZE", async () => { + if (!await checkSocketSupport()) return; + const sockPath = join(tempDir, "overflow.sock"); + + let serverSocket: net.Socket | null = null; + const broker = createMockBroker(sockPath, (socket) => { + serverSocket = socket; + }); + await broker.start(); + + try { + const client = await connectToBroker({ endpoint: `unix:${sockPath}` }); + + let closeFired = false; + client.onClose(() => { + closeFired = true; + }); + + // Send a payload larger than MAX_BUFFER_SIZE (10 MB) without any newline. + // Write in chunks with async yields so the event loop can process the + // client-side buffer check between writes. + const chunkSize = 1024 * 1024; // 1 MB per chunk + const totalChunks = 11; // 11 MB total > 10 MB limit + const chunk = "x".repeat(chunkSize); + for (let i = 0; i < totalChunks; i++) { + if (serverSocket!.destroyed) break; + serverSocket!.write(chunk); + await new Promise((r) => setTimeout(r, 10)); // yield to event loop + } + + // Wait for the client to detect the overflow and disconnect + const deadline = Date.now() + 10_000; + while (!closeFired && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 50)); + } + expect(closeFired).toBe(true); + } finally { + await broker.stop(); + } + }, 30_000); // generous timeout — writing 11MB over socket can be slow +}); + +describe("BrokerClient — brokerBusy flag", () => { + test("reports brokerBusy=true when initialize returns busy=true", async () => { + if (!await checkSocketSupport()) return; + const sockPath = join(tempDir, "busy-broker.sock"); + + const server = net.createServer((socket) => { + socket.setEncoding("utf8"); + let buffer = ""; + socket.on("data", (chunk: string) => { + buffer += chunk; + let idx: number; + while ((idx = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (!line) continue; + try { + const msg = JSON.parse(line); + if (msg.method === "initialize" && msg.id !== undefined) { + socket.write(JSON.stringify({ + id: msg.id, + result: { userAgent: "test-broker", busy: true }, + }) + "\n"); + } + } catch {} + } + }); + }); + await new Promise((resolve) => server.listen(sockPath, resolve)); + + try { + const client = await connectToBroker({ endpoint: `unix:${sockPath}` }); + expect(client.brokerBusy).toBe(true); + await client.close(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + test("reports brokerBusy=false when initialize returns busy=false", async () => { + if (!await checkSocketSupport()) return; + const sockPath = join(tempDir, "idle-broker.sock"); + + const server = net.createServer((socket) => { + socket.setEncoding("utf8"); + let buffer = ""; + socket.on("data", (chunk: string) => { + buffer += chunk; + let idx: number; + while ((idx = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (!line) continue; + try { + const msg = JSON.parse(line); + if (msg.method === "initialize" && msg.id !== undefined) { + socket.write(JSON.stringify({ + id: msg.id, + result: { userAgent: "test-broker", busy: false }, + }) + "\n"); + } + } catch {} + } + }); + }); + await new Promise((resolve) => server.listen(sockPath, resolve)); + + try { + const client = await connectToBroker({ endpoint: `unix:${sockPath}` }); + expect(client.brokerBusy).toBe(false); + expect(client.userAgent).toBe("test-broker"); + await client.close(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); +}); diff --git a/src/broker.ts b/src/broker.ts new file mode 100644 index 0000000..49c08ec --- /dev/null +++ b/src/broker.ts @@ -0,0 +1,560 @@ +/** + * Per-workspace broker lifecycle: endpoint abstraction, state persistence, + * session management, socket-based liveness probing, atomic spawn lock, + * and connection logic with fallback to direct connection. + */ + +import net from "node:net"; +import fs from "node:fs"; +import path from "node:path"; +import { randomBytes } from "node:crypto"; +import type { BrokerState, SessionState, ParsedEndpoint } from "./types"; +import { connectDirect, type AppServerClient } from "./client"; +import { config, resolveStateDir } from "./config"; +import { terminateProcessTree, isProcessAlive } from "./process"; + +/** JSON-RPC error code returned when the broker is busy with another request. */ +export const BROKER_BUSY_RPC_CODE = -32001; + +// ─── Endpoint abstraction ───────────────────────────────────────────────── + +/** + * Create a broker endpoint string for the given state directory. + * - Unix/macOS: `unix:{stateDir}/broker.sock` + * - Windows: `pipe:\\.\pipe\codex-collab-{random-hex}` + */ +export function createEndpoint(stateDir: string, platform?: string): string { + const plat = platform ?? process.platform; + if (plat === "win32") { + const id = randomBytes(8).toString("hex"); + return `pipe:\\\\.\\pipe\\codex-collab-${id}`; + } + return `unix:${path.join(stateDir, "broker.sock")}`; +} + +/** + * Parse an endpoint string into its kind and path. + * Throws on invalid format. + */ +export function parseEndpoint(endpoint: string): ParsedEndpoint { + if (endpoint.startsWith("unix:")) { + const p = endpoint.slice(5); + if (!p) throw new Error(`Invalid endpoint: "${endpoint}" (empty path)`); + return { kind: "unix", path: p }; + } + if (endpoint.startsWith("pipe:")) { + const p = endpoint.slice(5); + if (!p) throw new Error(`Invalid endpoint: "${endpoint}" (empty path)`); + return { kind: "pipe", path: p }; + } + throw new Error(`Invalid endpoint: "${endpoint}" (expected unix: or pipe: prefix)`); +} + +// ─── Broker state persistence ───────────────────────────────────────────── + +const BROKER_STATE_FILE = "broker.json"; + +/** Load broker state from `{stateDir}/broker.json`. Returns null if missing or invalid. */ +export function loadBrokerState(stateDir: string): BrokerState | null { + const filePath = path.join(stateDir, BROKER_STATE_FILE); + try { + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw); + // Basic shape validation — endpoint may be null (deferred broker multiplexing) + if ( + typeof parsed === "object" && + parsed !== null && + (typeof parsed.endpoint === "string" || parsed.endpoint === null) && + (typeof parsed.pid === "number" || parsed.pid === null) && + typeof parsed.sessionDir === "string" && + typeof parsed.startedAt === "string" + ) { + return parsed as BrokerState; + } + console.error("[broker] Warning: broker state file has invalid structure — ignoring"); + return null; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") { + console.error(`[broker] Warning: failed to load broker state: ${e instanceof Error ? e.message : e}`); + } + return null; + } +} + +/** Save broker state to `{stateDir}/broker.json`. Creates the directory if needed. */ +export function saveBrokerState(stateDir: string, state: BrokerState): void { + fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + const filePath = path.join(stateDir, BROKER_STATE_FILE); + const tmp = filePath + ".tmp"; + fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 }); + fs.renameSync(tmp, filePath); +} + +/** Remove `{stateDir}/broker.json`. */ +export function clearBrokerState(stateDir: string): void { + const filePath = path.join(stateDir, BROKER_STATE_FILE); + try { + fs.unlinkSync(filePath); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e; + } +} + +// ─── Session state persistence ──────────────────────────────────────────── + +const SESSION_STATE_FILE = "session.json"; + +/** Load session state from `{stateDir}/session.json`. Returns null if missing or invalid. */ +export function loadSessionState(stateDir: string): SessionState | null { + const filePath = path.join(stateDir, SESSION_STATE_FILE); + try { + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw); + if ( + typeof parsed === "object" && + parsed !== null && + typeof parsed.sessionId === "string" && + typeof parsed.startedAt === "string" + ) { + return parsed as SessionState; + } + console.error("[broker] Warning: session state file has invalid structure — ignoring"); + return null; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") { + console.error(`[broker] Warning: failed to load session state: ${e instanceof Error ? e.message : e}`); + } + return null; + } +} + +/** Save session state to `{stateDir}/session.json`. Creates the directory if needed. */ +export function saveSessionState(stateDir: string, state: SessionState): void { + fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + const filePath = path.join(stateDir, SESSION_STATE_FILE); + const tmp = filePath + ".tmp"; + fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 }); + fs.renameSync(tmp, filePath); +} + +// ─── Broker liveness probe ──────────────────────────────────────────────── + +/** + * Probe whether a broker is alive by attempting a socket connection. + * Returns true if the connection succeeds within the timeout, false otherwise. + */ +export async function isBrokerAlive(endpoint: string | null, timeoutMs = 150): Promise { + // Null endpoint means broker multiplexing is deferred — not alive + if (!endpoint) return false; + + let target: ParsedEndpoint; + try { + target = parseEndpoint(endpoint); + } catch (e) { + console.error(`[broker] Warning: cannot parse endpoint for liveness probe: ${(e as Error).message}`); + return false; + } + + return new Promise((resolve) => { + let resolved = false; + const done = (value: boolean) => { + if (resolved) return; + resolved = true; + clearTimeout(timer); + socket.destroy(); + resolve(value); + }; + + const socket = new net.Socket(); + socket.on("connect", () => done(true)); + socket.on("error", () => done(false)); + + const timer = setTimeout(() => done(false), timeoutMs); + + socket.connect({ path: target.path }); + }); +} + +// ─── Spawn lock ─────────────────────────────────────────────────────────── + +const LOCK_FILE = "broker.lock"; +const LOCK_MAX_ATTEMPTS = 600; // ~30s at 50ms avg sleep +const LOCK_STALE_THRESHOLD_MS = 60_000; + +/** + * Acquire an atomic lock file (`broker.lock`) for broker spawning. + * Uses O_CREAT|O_EXCL, spins with 30-70ms jitter on contention, max ~30s. + * Force-breaks locks older than 60s. + * Returns a release function, or null if the lock cannot be acquired. + */ +export function acquireSpawnLock(stateDir: string): (() => void) | null { + fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + const lockPath = path.join(stateDir, LOCK_FILE); + let fd: number | undefined; + + for (let i = 0; i < LOCK_MAX_ATTEMPTS; i++) { + try { + fd = fs.openSync(lockPath, "wx"); + break; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "EEXIST") { + // Unexpected filesystem error + console.error(`[broker] Warning: spawn lock creation failed: ${(e as Error).message}`); + return null; + } + Bun.sleepSync(30 + Math.random() * 40); + } + } + + if (fd === undefined) { + // Check if lock is stale + try { + const stat = fs.statSync(lockPath); + const ageMs = Date.now() - stat.mtimeMs; + if (ageMs < LOCK_STALE_THRESHOLD_MS) { + return null; // Lock is held and not stale + } + // Lock is stale — force acquire after unlink + fs.unlinkSync(lockPath); + } catch (e) { + // statSync/unlinkSync failed (ENOENT race) — try once more + console.error(`[broker] Warning: stale lock recovery failed: ${(e as Error).message}`); + } + try { + fd = fs.openSync(lockPath, "wx"); + } catch (e) { + console.error(`[broker] Warning: lock re-acquire after stale break failed: ${(e as Error).message}`); + return null; + } + } + + const capturedFd = fd; + return () => { + try { + fs.closeSync(capturedFd); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") { + console.error(`[broker] Warning: lock fd close failed: ${(e as Error).message}`); + } + } + try { + fs.unlinkSync(lockPath); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") { + console.error(`[broker] Warning: lock cleanup failed: ${(e as Error).message}`); + } + } + }; +} + +// ─── Teardown ───────────────────────────────────────────────────────────── + +/** + * Tear down a broker: kill the process (if alive), remove the socket file + * (if Unix), and clear the broker state file. + */ +export function teardownBroker(stateDir: string, state: BrokerState): void { + // Kill process if PID is alive + if (state.pid !== null && isProcessAlive(state.pid)) { + terminateProcessTree(state.pid); + } + + // Remove socket file for unix endpoints (skip if endpoint is null — deferred multiplexing) + if (state.endpoint !== null) { + try { + const target = parseEndpoint(state.endpoint); + if (target.kind === "unix") { + try { + fs.unlinkSync(target.path); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") { + console.error(`[broker] Warning: socket cleanup failed: ${(e as Error).message}`); + } + } + } + } catch (e) { + // parseEndpoint failed — skip socket cleanup + console.error(`[broker] Warning: could not parse endpoint for socket cleanup: ${(e as Error).message}`); + } + } + + // Clear broker state + clearBrokerState(stateDir); +} + +// ─── Session ID helper ──────────────────────────────────────────────────── + +/** + * Get the current session ID. + * Checks `CODEX_COLLAB_SESSION_ID` env var first, then reads from `session.json`. + */ +export function getCurrentSessionId(stateDir: string): string | null { + const envId = process.env.CODEX_COLLAB_SESSION_ID; + if (envId) return envId; + + const session = loadSessionState(stateDir); + return session?.sessionId ?? null; +} + +// ─── Broker spawn ──────────────────────────────────────────────────────── + +/** Resolve the broker-server entry point path. */ +function resolveBrokerServerPath(): string { + // Check multiple locations: + // 1. Built bundle (same directory as the running script, no extension) + const builtNoExt = path.join(import.meta.dir, "broker-server"); + if (fs.existsSync(builtNoExt)) return builtNoExt; + // 2. Source file (relative to this file's directory) + const srcPath = path.join(import.meta.dir, "broker-server.ts"); + if (fs.existsSync(srcPath)) return srcPath; + // 3. Source file from project root (when import.meta.dir is src/) + const projectSrcPath = path.join(path.dirname(import.meta.dir), "src", "broker-server.ts"); + if (fs.existsSync(projectSrcPath)) return projectSrcPath; + // Fall back — will likely fail at spawn time with a clear error + return srcPath; +} + +/** + * Spawn the broker-server as a detached process. + * Returns the PID of the spawned process. + */ +function spawnBrokerServer( + endpoint: string, + cwd: string, + stateDir: string, +): number { + const brokerPath = resolveBrokerServerPath(); + const args = [ + "run", + brokerPath, + "serve", + "--endpoint", + endpoint, + "--cwd", + cwd, + "--idle-timeout", + String(config.defaultBrokerIdleTimeout), + ]; + + const logPath = path.join(stateDir, "broker.log"); + const logFd = fs.openSync(logPath, "a"); + + const proc = Bun.spawn(["bun", ...args], { + stdin: "ignore", + stdout: logFd, + stderr: logFd, + cwd, + }); + + // Unref so the parent process can exit without waiting for the broker + proc.unref(); + + fs.closeSync(logFd); + + if (!proc.pid) { + throw new Error("Failed to spawn broker server: no PID returned"); + } + + return proc.pid; +} + +/** + * Wait for the broker to become alive by polling the socket. + * Returns true if alive within the timeout, false otherwise. + */ +async function waitForBrokerReady( + endpoint: string, + timeoutMs = 10_000, + pollMs = 100, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await isBrokerAlive(endpoint, 200)) return true; + await new Promise((r) => setTimeout(r, pollMs)); + } + return false; +} + +// ─── Main connection entry point ────────────────────────────────────────── + +/** + * Ensure a live connection to the Codex app server for the given working directory. + * + * Flow: + * 1. Resolve state dir, ensure it exists, resolve/reuse session ID + * 2. Check if an existing broker is alive (probe the socket) + * - If yes, connect to it via BrokerClient + * - If connection fails, tear down and proceed to spawn + * 3. Acquire spawn lock (falls back to direct connection if lock unavailable) + * - Re-check for a broker after lock acquisition (race avoidance) + * 4. Spawn a new broker, wait for it to become ready + * - Falls back to direct connection if spawn or readiness check fails + * 5. Save broker state and session state before the connection attempt + * 6. Connect to the new broker (falls back to direct connection on failure) + */ +export async function ensureConnection(cwd: string, streaming = false): Promise { + const stateDir = resolveStateDir(cwd); + fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + + // Check for an existing recent session to reuse the session ID + const existingSession = loadSessionState(stateDir); + let sessionId: string; + let sessionStartedAt: string; + if (existingSession) { + const ageMs = Date.now() - new Date(existingSession.startedAt).getTime(); + if (ageMs < config.defaultBrokerIdleTimeout) { + sessionId = existingSession.sessionId; + sessionStartedAt = existingSession.startedAt; + } else { + sessionId = randomBytes(16).toString("hex"); + sessionStartedAt = new Date().toISOString(); + } + } else { + sessionId = randomBytes(16).toString("hex"); + sessionStartedAt = new Date().toISOString(); + } + + // 1. Check if an existing broker is alive + const existingState = loadBrokerState(stateDir); + if (existingState?.endpoint) { + if (await isBrokerAlive(existingState.endpoint)) { + try { + const { connectToBroker } = await import("./broker-client"); + const client = await connectToBroker({ endpoint: existingState.endpoint }); + + // If broker is busy and caller needs streaming, fall back to direct. + // Non-streaming callers (kill, threads, etc.) keep the broker connection + // so they can inspect/interrupt the active turn. + if (client.brokerBusy && streaming) { + await client.close(); + console.error("[broker] Broker is busy — using direct connection for this invocation."); + try { + saveSessionState(stateDir, { sessionId, startedAt: sessionStartedAt }); + } catch (e) { + console.error(`[broker] Warning: failed to save session state: ${(e as Error).message}`); + } + return connectDirect({ cwd }); + } + + // Update session state (non-fatal if save fails — connection is valid) + try { + saveSessionState(stateDir, { sessionId, startedAt: sessionStartedAt }); + } catch (e) { + console.error(`[broker] Warning: failed to save session state: ${(e as Error).message}`); + } + + return client; + } catch (e) { + // Connection to existing broker failed — tear it down and spawn fresh + console.error( + `[broker] Warning: failed to connect to existing broker: ${(e as Error).message}. Spawning new one.`, + ); + teardownBroker(stateDir, existingState); + } + } else { + // Broker is not alive — clean up stale state + teardownBroker(stateDir, existingState); + } + } + + // 2. Acquire spawn lock + const release = acquireSpawnLock(stateDir); + if (!release) { + // Could not acquire lock — another process may be spawning. + // Fall back to direct connection. + console.error("[broker] Warning: could not acquire spawn lock. Using direct connection."); + return connectDirect({ cwd }); + } + + try { + // Re-check after lock acquisition (another process may have spawned while we waited) + const freshState = loadBrokerState(stateDir); + if (freshState?.endpoint && await isBrokerAlive(freshState.endpoint)) { + try { + const { connectToBroker } = await import("./broker-client"); + const client = await connectToBroker({ endpoint: freshState.endpoint }); + if (client.brokerBusy && streaming) { + await client.close(); + console.error("[broker] Broker is busy — using direct connection for this invocation."); + try { + saveSessionState(stateDir, { sessionId, startedAt: sessionStartedAt }); + } catch (e) { + console.error(`[broker] Warning: failed to save session state: ${(e as Error).message}`); + } + return connectDirect({ cwd }); + } + try { + saveSessionState(stateDir, { sessionId, startedAt: sessionStartedAt }); + } catch (e) { + console.error(`[broker] Warning: failed to save session state: ${(e as Error).message}`); + } + return client; + } catch (e) { + console.error(`[broker] Warning: failed to connect to existing broker after lock: ${(e as Error).message}. Spawning new one.`); + teardownBroker(stateDir, freshState); + } + } + + // 3. Spawn a new broker + const endpoint = createEndpoint(stateDir); + let pid: number; + try { + pid = spawnBrokerServer(endpoint, cwd, stateDir); + } catch (e) { + // Broker spawn failed — fall back to direct connection + console.error( + `[broker] Warning: failed to spawn broker: ${(e as Error).message}. Using direct connection.`, + ); + const client = await connectDirect({ cwd }); + try { + const now = new Date().toISOString(); + saveBrokerState(stateDir, { endpoint: null, pid: null, sessionDir: stateDir, startedAt: now }); + saveSessionState(stateDir, { sessionId, startedAt: sessionStartedAt }); + } catch (e) { + console.error(`[broker] Warning: failed to persist broker state: ${(e as Error).message}`); + } + return client; + } + + // 4. Wait for the broker to be ready + const ready = await waitForBrokerReady(endpoint); + if (!ready) { + // Broker didn't start in time — kill the orphaned process and fall back to direct + console.error("[broker] Warning: broker did not become ready in time. Using direct connection."); + if (pid) { + try { terminateProcessTree(pid); } catch { /* best effort */ } + } + const client = await connectDirect({ cwd }); + try { + const now = new Date().toISOString(); + saveBrokerState(stateDir, { endpoint: null, pid: null, sessionDir: stateDir, startedAt: now }); + saveSessionState(stateDir, { sessionId, startedAt: sessionStartedAt }); + } catch (e) { + console.error(`[broker] Warning: failed to persist broker state: ${(e as Error).message}`); + } + return client; + } + + // 5. Connect to the new broker + try { + const now = new Date().toISOString(); + saveBrokerState(stateDir, { endpoint, pid, sessionDir: stateDir, startedAt: now }); + saveSessionState(stateDir, { sessionId, startedAt: sessionStartedAt }); + } catch (e) { + console.error(`[broker] Warning: failed to persist broker state: ${(e as Error).message}. Next invocation may not find this broker.`); + } + + try { + const { connectToBroker } = await import("./broker-client"); + return await connectToBroker({ endpoint }); + } catch (e) { + // Broker connection failed after spawn — fall back to direct + console.error( + `[broker] Warning: failed to connect to new broker: ${(e as Error).message}. Using direct connection.`, + ); + return connectDirect({ cwd }); + } + } finally { + release(); + } +} diff --git a/src/cli.ts b/src/cli.ts index 18d7de3..fef7e89 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,1308 +1,84 @@ #!/usr/bin/env bun -// src/cli.ts — codex-collab CLI (app server protocol) +// src/cli.ts — codex-collab CLI router +import { config } from "./config"; +import type { AppServerClient } from "./client"; +import { updateThreadStatus, updateRun } from "./threads"; import { - config, - validateId, - type ReasoningEffort, - type SandboxMode, - type ApprovalPolicy, -} from "./config"; -import { connect, type AppServerClient } from "./protocol"; -import { - registerThread, - resolveThreadId, - findShortId, - loadThreadMapping, - removeThread, - saveThreadMapping, - updateThreadMeta, - updateThreadStatus, - withThreadLock, -} from "./threads"; -import { runTurn, runReview } from "./turns"; -import { EventDispatcher } from "./events"; -import { - autoApproveHandler, - InteractiveApprovalHandler, - type ApprovalHandler, -} from "./approvals"; -import { - existsSync, - mkdirSync, - readFileSync, - readdirSync, - unlinkSync, - writeFileSync, -} from "fs"; -import { resolve, join } from "path"; -import type { - ReviewTarget, - ThreadStartResponse, - Model, - TurnResult, -} from "./types"; - -// --------------------------------------------------------------------------- -// User config — persistent defaults from ~/.codex-collab/config.json -// --------------------------------------------------------------------------- - -/** Fields users can set in ~/.codex-collab/config.json. */ -interface UserConfig { - model?: string; - reasoning?: string; - sandbox?: string; - approval?: string; - timeout?: number; -} - -function loadUserConfig(): UserConfig { - try { - const parsed = JSON.parse(readFileSync(config.configFile, "utf-8")); - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - console.error(`[codex] Warning: config file is not a JSON object — ignoring: ${config.configFile}`); - return {}; - } - return parsed as UserConfig; - } catch (e) { - if ((e as NodeJS.ErrnoException).code === "ENOENT") return {}; - if (e instanceof SyntaxError) { - console.error(`[codex] Warning: invalid JSON in ${config.configFile} — ignoring config`); - } else { - console.error(`[codex] Warning: could not read config: ${e instanceof Error ? e.message : String(e)}`); - } - return {}; - } -} - -function saveUserConfig(cfg: UserConfig): void { - try { - writeFileSync(config.configFile, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 }); - } catch (e) { - die(`Could not save config to ${config.configFile}: ${e instanceof Error ? e.message : String(e)}`); - } -} - -/** Apply user config to parsed options — only for fields not set via CLI flags. - * Config values are added to `configured` (not `explicit`) so they suppress - * auto-detection but are NOT forwarded as overrides on thread resume. */ -function applyUserConfig(options: Options): void { - const cfg = loadUserConfig(); - - if (!options.explicit.has("model") && typeof cfg.model === "string") { - if (/[^a-zA-Z0-9._\-\/:]/.test(cfg.model)) { - console.error(`[codex] Warning: ignoring invalid model in config: ${cfg.model}`); - } else { - options.model = cfg.model; - options.configured.add("model"); - } - } - if (!options.explicit.has("reasoning") && typeof cfg.reasoning === "string") { - if (config.reasoningEfforts.includes(cfg.reasoning as any)) { - options.reasoning = cfg.reasoning as ReasoningEffort; - options.configured.add("reasoning"); - } else { - console.error(`[codex] Warning: ignoring invalid reasoning in config: ${cfg.reasoning}`); - } - } - if (!options.explicit.has("sandbox") && typeof cfg.sandbox === "string") { - if (config.sandboxModes.includes(cfg.sandbox as any)) { - options.sandbox = cfg.sandbox as SandboxMode; - options.configured.add("sandbox"); - } else { - console.error(`[codex] Warning: ignoring invalid sandbox in config: ${cfg.sandbox}`); - } - } - if (!options.explicit.has("approval") && typeof cfg.approval === "string") { - if (config.approvalPolicies.includes(cfg.approval as any)) { - options.approval = cfg.approval as ApprovalPolicy; - options.configured.add("approval"); - } else { - console.error(`[codex] Warning: ignoring invalid approval in config: ${cfg.approval}`); - } - } - if (!options.explicit.has("timeout") && cfg.timeout !== undefined) { - if (typeof cfg.timeout === "number" && Number.isFinite(cfg.timeout) && cfg.timeout > 0) { - options.timeout = cfg.timeout; - } else { - console.error(`[codex] Warning: ignoring invalid timeout in config: ${cfg.timeout}`); - } - } -} + activeClient, + activeThreadId, + activeShortId, + activeTurnId, + activeWsPaths, + activeRunId, + shuttingDown, + setShuttingDown, + removePidFile, + VALID_REVIEW_MODES, +} from "./commands/shared"; // --------------------------------------------------------------------------- // Signal handlers — clean up spawned app-server and update thread status // --------------------------------------------------------------------------- -let activeClient: AppServerClient | undefined; -let activeThreadId: string | undefined; -let activeShortId: string | undefined; -let shuttingDown = false; - async function handleShutdownSignal(exitCode: number): Promise { if (shuttingDown) { process.exit(exitCode); } - shuttingDown = true; + setShuttingDown(true); console.error("[codex] Shutting down..."); // Update thread status and clean up PID file synchronously before async // cleanup — ensures the mapping is written even if client.close() hangs. - if (activeThreadId) { + if (activeThreadId && activeWsPaths) { try { - updateThreadStatus(config.threadsFile, activeThreadId, "interrupted"); + updateThreadStatus(activeWsPaths.threadsFile, activeThreadId, "interrupted"); } catch (e) { console.error(`[codex] Warning: could not update thread status during shutdown: ${e instanceof Error ? e.message : String(e)}`); } - } - if (activeShortId) { - removePidFile(activeShortId); - } - - try { - if (activeClient) { - await activeClient.close(); - } - } catch (e) { - console.error(`[codex] Warning: cleanup failed: ${e instanceof Error ? e.message : String(e)}`); - } - process.exit(exitCode); -} - -process.on("SIGINT", () => handleShutdownSignal(130)); -process.on("SIGTERM", () => handleShutdownSignal(143)); - -// --------------------------------------------------------------------------- -// Argument parsing -// --------------------------------------------------------------------------- - -const rawArgs = process.argv.slice(2); - -interface ParsedArgs { - command: string; - positional: string[]; - options: Options; -} - -interface Options { - reasoning: ReasoningEffort | undefined; - model: string | undefined; - sandbox: SandboxMode; - approval: ApprovalPolicy; - dir: string; - contentOnly: boolean; - json: boolean; - timeout: number; - limit: number; - reviewMode: string | null; - reviewRef: string | null; - base: string; - resumeId: string | null; - /** Flags explicitly provided on the command line (forwarded on resume). */ - explicit: Set; - /** Flags set by user config file (suppress auto-detection but NOT forwarded on resume). */ - configured: Set; -} - -function parseArgs(args: string[]): ParsedArgs { - const options: Options = { - reasoning: undefined, - model: undefined, - sandbox: config.defaultSandbox, - approval: config.defaultApprovalPolicy, - dir: process.cwd(), - contentOnly: false, - json: false, - timeout: config.defaultTimeout, - limit: config.jobsListLimit, - reviewMode: null, - reviewRef: null, - base: "main", - resumeId: null, - explicit: new Set(), - configured: new Set(), - }; - - const positional: string[] = []; - let command = ""; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - - if (arg === "-h" || arg === "--help") { - showHelp(); - process.exit(0); - } else if (arg === "-r" || arg === "--reasoning") { - if (i + 1 >= args.length) { - console.error("Error: --reasoning requires a value"); - process.exit(1); - } - const level = args[++i] as ReasoningEffort; - if (!config.reasoningEfforts.includes(level)) { - console.error(`Error: Invalid reasoning level: ${level}`); - console.error( - `Valid options: ${config.reasoningEfforts.join(", ")}` - ); - process.exit(1); - } - options.reasoning = level; - options.explicit.add("reasoning"); - } else if (arg === "-m" || arg === "--model") { - if (i + 1 >= args.length) { - console.error("Error: --model requires a value"); - process.exit(1); - } - const model = args[++i]; - if (/[^a-zA-Z0-9._\-\/:]/.test(model)) { - console.error(`Error: Invalid model name: ${model}`); - process.exit(1); - } - options.model = model; - options.explicit.add("model"); - } else if (arg === "-s" || arg === "--sandbox") { - if (i + 1 >= args.length) { - console.error("Error: --sandbox requires a value"); - process.exit(1); - } - const mode = args[++i] as SandboxMode; - if (!config.sandboxModes.includes(mode)) { - console.error(`Error: Invalid sandbox mode: ${mode}`); - console.error( - `Valid options: ${config.sandboxModes.join(", ")}` - ); - process.exit(1); - } - options.sandbox = mode; - options.explicit.add("sandbox"); - } else if (arg === "--approval") { - if (i + 1 >= args.length) { - console.error("Error: --approval requires a value"); - process.exit(1); - } - const policy = args[++i] as ApprovalPolicy; - if (!config.approvalPolicies.includes(policy)) { - console.error(`Error: Invalid approval policy: ${policy}`); - console.error( - `Valid options: ${config.approvalPolicies.join(", ")}` - ); - process.exit(1); - } - options.approval = policy; - options.explicit.add("approval"); - } else if (arg === "-d" || arg === "--dir") { - if (i + 1 >= args.length) { - console.error("Error: --dir requires a value"); - process.exit(1); - } - options.dir = resolve(args[++i]); - options.explicit.add("dir"); - } else if (arg === "--content-only") { - options.contentOnly = true; - } else if (arg === "--json") { - options.json = true; - } else if (arg === "--timeout") { - if (i + 1 >= args.length) { - console.error("Error: --timeout requires a value"); - process.exit(1); - } - const val = Number(args[++i]); - if (!Number.isFinite(val) || val <= 0) { - console.error(`Error: Invalid timeout: ${args[i]}`); - process.exit(1); - } - options.timeout = val; - options.explicit.add("timeout"); - } else if (arg === "--limit") { - if (i + 1 >= args.length) { - console.error("Error: --limit requires a value"); - process.exit(1); - } - const val = Number(args[++i]); - if (!Number.isFinite(val) || val < 1) { - console.error(`Error: Invalid limit: ${args[i]}`); - process.exit(1); - } - options.limit = Math.floor(val); - } else if (arg === "--mode") { - if (i + 1 >= args.length) { - console.error("Error: --mode requires a value"); - process.exit(1); - } - const mode = args[++i]; - if (!VALID_REVIEW_MODES.includes(mode as any)) { - console.error(`Error: Invalid review mode: ${mode}`); - console.error(`Valid options: ${VALID_REVIEW_MODES.join(", ")}`); - process.exit(1); - } - options.reviewMode = mode; - } else if (arg === "--ref") { - if (i + 1 >= args.length) { - console.error("Error: --ref requires a value"); - process.exit(1); - } - options.reviewRef = validateGitRef(args[++i], "ref"); - } else if (arg === "--base") { - if (i + 1 >= args.length) { - console.error("Error: --base requires a value"); - process.exit(1); - } - options.base = validateGitRef(args[++i], "base branch"); - } else if (arg === "--resume") { - if (i + 1 >= args.length) { - console.error("Error: --resume requires a value"); - process.exit(1); - } - options.resumeId = args[++i]; - } else if (arg === "--all") { - options.limit = Infinity; - } else if (arg === "--unset") { - options.explicit.add("unset"); - } else if (arg.startsWith("-")) { - console.error(`Error: Unknown option: ${arg}`); - console.error("Run codex-collab --help for usage"); - process.exit(1); - } else { - if (!command) { - command = arg; - } else { - positional.push(arg); - } - } - } - - return { command, positional, options }; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Valid review modes for --mode flag. */ -const VALID_REVIEW_MODES = ["pr", "uncommitted", "commit", "custom"] as const; - -/** Shell metacharacters that must not appear in git refs. */ -const UNSAFE_REF_CHARS = /[;|&`$()<>\\'"{\s]/; - -function die(msg: string): never { - console.error(`Error: ${msg}`); - process.exit(1); -} - -function validateGitRef(value: string, label: string): string { - if (UNSAFE_REF_CHARS.test(value)) die(`Invalid ${label}: ${value}`); - return value; -} - -/** Validate ID, using die() for CLI-friendly error output. */ -function validateIdOrDie(id: string): string { - try { - return validateId(id); - } catch { - die(`Invalid ID: "${id}"`); - } -} - -function progress(text: string): void { - console.log(`[codex] ${text}`); -} - -function getApprovalHandler(policy: ApprovalPolicy): ApprovalHandler { - if (policy === "never") return autoApproveHandler; - return new InteractiveApprovalHandler(config.approvalsDir, progress); -} - -/** Connect to app server, run fn, then close the client (even on error). */ -async function withClient(fn: (client: AppServerClient) => Promise): Promise { - const client = await connect(); - activeClient = client; - try { - return await fn(client); - } finally { - try { - await client.close(); - } catch (e) { - console.error(`[codex] Warning: cleanup failed: ${e instanceof Error ? e.message : String(e)}`); - } - activeClient = undefined; - } -} - -function createDispatcher(shortId: string, opts: Options): EventDispatcher { - return new EventDispatcher( - shortId, - config.logsDir, - opts.contentOnly ? () => {} : progress, - ); -} - -/** Pick the best model by following the upgrade chain from the server default, - * then preferring a -codex variant if one exists at the latest generation. */ -function pickBestModel(models: Model[]): string | undefined { - const byId = new Map(models.map(m => [m.id, m])); - - // Start from the server's default model - let current = models.find(m => m.isDefault); - if (!current) return undefined; - - // Follow the upgrade chain to the latest generation - const visited = new Set(); - while (current.upgrade && !visited.has(current.id)) { - visited.add(current.id); - const next = byId.get(current.upgrade); - if (!next) break; // upgrade target not in the list - current = next; - } - - // Prefer -codex variant if available at this generation - if (!current.id.endsWith("-codex")) { - const codexVariant = byId.get(current.id + "-codex"); - if (codexVariant && codexVariant.upgrade === null) return codexVariant.id; - } - - return current.id; -} - -/** Pick the highest reasoning effort a model supports. */ -function pickHighestEffort(supported: Array<{ reasoningEffort: string }>): ReasoningEffort | undefined { - const available = new Set(supported.map(s => s.reasoningEffort)); - for (let i = config.reasoningEfforts.length - 1; i >= 0; i--) { - if (available.has(config.reasoningEfforts[i])) return config.reasoningEfforts[i]; - } - return undefined; -} - -/** Auto-resolve model and/or reasoning effort when not set by CLI or config. */ -async function resolveDefaults(client: AppServerClient, opts: Options): Promise { - const isSet = (key: string) => opts.explicit.has(key) || opts.configured.has(key); - const needModel = !isSet("model"); - const needReasoning = !isSet("reasoning"); - if (!needModel && !needReasoning) return; - - let models: Model[]; - try { - models = await fetchAllPages(client, "model/list", { includeHidden: true }); - } catch (e) { - console.error(`[codex] Warning: could not fetch model list (${e instanceof Error ? e.message : String(e)}). Model and reasoning will be determined by the server.`); - return; - } - if (models.length === 0) { - console.error(`[codex] Warning: server returned no models. Model and reasoning will be determined by the server.`); - return; - } - - if (needModel) { - opts.model = pickBestModel(models); - } - - if (needReasoning) { - const modelData = models.find(m => m.id === opts.model); - if (modelData?.supportedReasoningEfforts?.length) { - opts.reasoning = pickHighestEffort(modelData.supportedReasoningEfforts); - } - } -} - -/** Try to archive a thread on the server. Returns status string. */ -async function tryArchive(client: AppServerClient, threadId: string): Promise<"archived" | "already_done" | "failed"> { - try { - await client.request("thread/archive", { threadId }); - return "archived"; - } catch (e) { - if (e instanceof Error && (e.message.includes("not found") || e.message.includes("archived"))) { - return "already_done"; - } - console.error(`[codex] Warning: could not archive thread: ${e instanceof Error ? e.message : String(e)}`); - return "failed"; - } -} - -function resolveReviewTarget(positional: string[], opts: Options): ReviewTarget { - const mode = opts.reviewMode ?? "pr"; - - if (positional.length > 0) { - if (opts.reviewMode !== null && opts.reviewMode !== "custom") { - die(`--mode ${opts.reviewMode} does not accept positional arguments.\nUse --mode custom "instructions" for custom reviews.`); - } - return { type: "custom", instructions: positional.join(" ") }; - } - - if (mode === "custom") { - die('Custom review mode requires instructions.\nUsage: codex-collab review "instructions"'); - } - - switch (mode) { - case "pr": - return { type: "baseBranch", branch: opts.base }; - case "uncommitted": - return { type: "uncommittedChanges" }; - case "commit": - return { type: "commit", sha: opts.reviewRef ?? "HEAD" }; - default: - die(`Unknown review mode: ${mode}. Use: ${VALID_REVIEW_MODES.join(", ")}`); - } -} - -/** Per-turn parameter overrides: all values for new threads, explicit-only for resume. */ -function turnOverrides(opts: Options) { - if (!opts.resumeId) { - const o: Record = { cwd: opts.dir, approvalPolicy: opts.approval }; - if (opts.model) o.model = opts.model; - if (opts.reasoning) o.effort = opts.reasoning; - return o; - } - const o: Record = {}; - if (opts.explicit.has("dir")) o.cwd = opts.dir; - if (opts.explicit.has("model")) o.model = opts.model; - if (opts.explicit.has("reasoning")) o.effort = opts.reasoning; - if (opts.explicit.has("approval")) o.approvalPolicy = opts.approval; - return o; -} - -function formatDuration(ms: number): string { - const sec = Math.round(ms / 1000); - if (sec < 60) return `${sec}s`; - const min = Math.floor(sec / 60); - const rem = sec % 60; - return `${min}m ${rem}s`; -} - -function formatAge(unixTimestamp: number): string { - const seconds = Math.round(Date.now() / 1000 - unixTimestamp); - if (seconds < 60) return `${seconds}s ago`; - if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`; - if (seconds < 86400) return `${Math.round(seconds / 3600)}h ago`; - return `${Math.round(seconds / 86400)}d ago`; -} - -function pluralize(n: number, word: string): string { - return `${n} ${word}${n === 1 ? "" : "s"}`; -} - -/** Write a PID file for the current process so cmdJobs can detect stale "running" status. */ -function writePidFile(shortId: string): void { - try { - writeFileSync(join(config.pidsDir, shortId), String(process.pid), { mode: 0o600 }); - } catch (e) { - console.error(`[codex] Warning: could not write PID file: ${e instanceof Error ? e.message : String(e)}`); - } -} - -/** Remove the PID file for a thread. */ -function removePidFile(shortId: string): void { - try { - unlinkSync(join(config.pidsDir, shortId)); - } catch (e) { - if ((e as NodeJS.ErrnoException).code !== "ENOENT") { - console.error(`[codex] Warning: could not remove PID file: ${e instanceof Error ? e.message : String(e)}`); - } - } -} - -/** Check if the process that owns a thread is still alive. - * Returns true (assume alive) when the PID file is missing — the thread may - * have been started before PID tracking existed, or PID file write may have - * failed. Only returns false when we have a PID and can confirm the process - * is gone (ESRCH). */ -function isProcessAlive(shortId: string): boolean { - const pidPath = join(config.pidsDir, shortId); - let pid: number; - try { - pid = Number(readFileSync(pidPath, "utf-8").trim()); - } catch (e) { - if ((e as NodeJS.ErrnoException).code === "ENOENT") return true; // no PID file → assume alive - console.error(`[codex] Warning: could not read PID file for ${shortId}: ${e instanceof Error ? e.message : String(e)}`); - return true; - } - if (!Number.isFinite(pid) || pid <= 0) { - console.error(`[codex] Warning: PID file for ${shortId} contains invalid value`); - return false; - } - try { - process.kill(pid, 0); // signal 0 = existence check - return true; - } catch (e) { - const code = (e as NodeJS.ErrnoException).code; - if (code === "ESRCH") return false; // process confirmed dead - if (code === "EPERM") return true; // process exists but we can't signal it - // Unexpected error — assume alive to avoid incorrectly marking live threads as dead - console.error(`[codex] Warning: could not check process for ${shortId}: ${e instanceof Error ? e.message : String(e)}`); - return true; - } -} - -// --------------------------------------------------------------------------- -// Commands -// --------------------------------------------------------------------------- - -/** Start or resume a thread, returning threadId, shortId, and effective config. */ -async function startOrResumeThread( - client: AppServerClient, - opts: Options, - extraStartParams?: Record, - preview?: string, -): Promise<{ threadId: string; shortId: string; effective: ThreadStartResponse }> { - if (opts.resumeId) { - const threadId = resolveThreadId(config.threadsFile, opts.resumeId); - const shortId = findShortId(config.threadsFile, threadId) ?? opts.resumeId; - const resumeParams: Record = { - threadId, - persistExtendedHistory: false, - }; - // Only forward flags that were explicitly provided on the command line - if (opts.explicit.has("model")) resumeParams.model = opts.model; - if (opts.explicit.has("dir")) resumeParams.cwd = opts.dir; - if (opts.explicit.has("approval")) resumeParams.approvalPolicy = opts.approval; - if (opts.explicit.has("sandbox")) resumeParams.sandbox = opts.sandbox; - // Forced overrides from caller (e.g., review forces sandbox to read-only) - if (extraStartParams) Object.assign(resumeParams, extraStartParams); - const effective = await client.request("thread/resume", resumeParams); - // Refresh stored metadata so `jobs` stays accurate after resume - updateThreadMeta(config.threadsFile, threadId, { - model: effective.model, - ...(opts.explicit.has("dir") ? { cwd: opts.dir } : {}), - ...(preview ? { preview } : {}), - }); - return { threadId, shortId, effective }; - } - - const startParams: Record = { - cwd: opts.dir, - approvalPolicy: opts.approval, - sandbox: opts.sandbox, - experimentalRawEvents: false, - persistExtendedHistory: false, - ...extraStartParams, - }; - if (opts.model) startParams.model = opts.model; - const effective = await client.request( - "thread/start", - startParams, - ); - const threadId = effective.thread.id; - registerThread(config.threadsFile, threadId, { - model: effective.model, - cwd: opts.dir, - preview, - }); - const shortId = findShortId(config.threadsFile, threadId); - if (!shortId) die(`Internal error: thread ${threadId.slice(0, 12)}... registered but not found in mapping`); - return { threadId, shortId, effective }; -} - -/** Print turn result and return the appropriate exit code. */ -function printResult( - result: TurnResult, - shortId: string, - label: string, - contentOnly: boolean, -): number { - if (!contentOnly) { - progress(`${label} ${result.status} (${formatDuration(result.durationMs)}${result.filesChanged.length > 0 ? `, ${pluralize(result.filesChanged.length, "file")} changed` : ""})`); - if (result.output) console.log("\n--- Result ---"); - } - - if (result.output) console.log(result.output); - if (result.error) console.error(`\nError: ${result.error}`); - if (!contentOnly) console.error(`\nThread: ${shortId}`); - - return result.status === "completed" ? 0 : 1; -} - -async function cmdRun(positional: string[], opts: Options) { - if (positional.length === 0) { - die("No prompt provided\nUsage: codex-collab run \"prompt\" [options]"); - } - - const prompt = positional.join(" "); - - const exitCode = await withClient(async (client) => { - await resolveDefaults(client, opts); - - const { threadId, shortId, effective } = await startOrResumeThread(client, opts, undefined, prompt); - - if (opts.contentOnly) { - console.error(`[codex] Running (thread ${shortId})...`); - } else { - if (opts.resumeId) { - progress(`Resumed thread ${shortId} (${effective.model})`); - } else { - progress(`Thread ${shortId} started (${effective.model}, ${opts.sandbox})`); - } - progress("Turn started"); - } - - updateThreadStatus(config.threadsFile, threadId, "running"); - activeThreadId = threadId; - activeShortId = shortId; - writePidFile(shortId); - - const dispatcher = createDispatcher(shortId, opts); - - try { - const result = await runTurn( - client, - threadId, - [{ type: "text", text: prompt }], - { - dispatcher, - approvalHandler: getApprovalHandler(effective.approvalPolicy), - timeoutMs: opts.timeout * 1000, - ...turnOverrides(opts), - }, - ); - - updateThreadStatus(config.threadsFile, threadId, result.status as "completed" | "failed" | "interrupted"); - return printResult(result, shortId, "Turn", opts.contentOnly); - } catch (e) { - updateThreadStatus(config.threadsFile, threadId, "failed"); - throw e; - } finally { - activeThreadId = undefined; - activeShortId = undefined; - removePidFile(shortId); - } - }); - - process.exit(exitCode); -} - -async function cmdReview(positional: string[], opts: Options) { - const target = resolveReviewTarget(positional, opts); - - const exitCode = await withClient(async (client) => { - await resolveDefaults(client, opts); - - let reviewPreview: string; - switch (target.type) { - case "custom": reviewPreview = target.instructions; break; - case "baseBranch": reviewPreview = `Review PR (base: ${target.branch})`; break; - case "uncommittedChanges": reviewPreview = "Review uncommitted changes"; break; - case "commit": reviewPreview = `Review commit ${target.sha}`; break; - } - const { threadId, shortId, effective } = await startOrResumeThread( - client, opts, { sandbox: "read-only" }, reviewPreview, - ); - - if (opts.contentOnly) { - console.error(`[codex] Reviewing (thread ${shortId})...`); - } else { - if (opts.resumeId) { - progress(`Resumed thread ${shortId} for review`); - } else { - progress(`Thread ${shortId} started for review (${effective.model}, read-only)`); + if (activeRunId) { + try { + updateRun(activeWsPaths.stateDir, activeRunId, { + status: "cancelled", + completedAt: new Date().toISOString(), + error: "Interrupted by signal", + }); + } catch (e) { + console.error(`[codex] Warning: could not update run record during shutdown: ${e instanceof Error ? e.message : String(e)}`); } } - - updateThreadStatus(config.threadsFile, threadId, "running"); - activeThreadId = threadId; - activeShortId = shortId; - writePidFile(shortId); - - const dispatcher = createDispatcher(shortId, opts); - - // Note: effort (reasoning level) is not forwarded to reviews — the review/start - // protocol does not accept an effort parameter (unlike turn/start). - try { - const result = await runReview(client, threadId, target, { - dispatcher, - approvalHandler: getApprovalHandler(effective.approvalPolicy), - timeoutMs: opts.timeout * 1000, - ...turnOverrides(opts), - }); - - updateThreadStatus(config.threadsFile, threadId, result.status as "completed" | "failed" | "interrupted"); - return printResult(result, shortId, "Review", opts.contentOnly); - } catch (e) { - updateThreadStatus(config.threadsFile, threadId, "failed"); - throw e; - } finally { - activeThreadId = undefined; - activeShortId = undefined; - removePidFile(shortId); - } - }); - - process.exit(exitCode); -} - -/** Fetch all pages of a paginated endpoint. */ -async function fetchAllPages( - client: AppServerClient, - method: string, - baseParams?: Record, -): Promise { - const items: T[] = []; - let cursor: string | undefined; - do { - const params: Record = { ...baseParams }; - if (cursor) params.cursor = cursor; - const page = await client.request<{ data: T[]; nextCursor: string | null }>(method, params); - items.push(...page.data); - cursor = page.nextCursor ?? undefined; - } while (cursor); - return items; -} - -async function cmdJobs(opts: Options) { - const mapping = loadThreadMapping(config.threadsFile); - - // Build entries sorted by updatedAt (most recent first), falling back to createdAt - let entries = Object.entries(mapping) - .map(([shortId, entry]) => ({ shortId, ...entry })) - .sort((a, b) => { - const ta = new Date(a.updatedAt ?? a.createdAt).getTime(); - const tb = new Date(b.updatedAt ?? b.createdAt).getTime(); - return tb - ta; - }); - - // Detect stale "running" status: if the owning process is dead, mark as interrupted. - for (const e of entries) { - if (e.lastStatus === "running" && !isProcessAlive(e.shortId)) { - updateThreadStatus(config.threadsFile, e.threadId, "interrupted"); - e.lastStatus = "interrupted"; - removePidFile(e.shortId); - } } - - if (opts.limit !== Infinity) entries = entries.slice(0, opts.limit); - - if (opts.json) { - const enriched = entries.map(e => ({ - shortId: e.shortId, - threadId: e.threadId, - status: e.lastStatus ?? "unknown", - model: e.model ?? null, - cwd: e.cwd ?? null, - preview: e.preview ?? null, - createdAt: e.createdAt, - updatedAt: e.updatedAt ?? e.createdAt, - })); - console.log(JSON.stringify(enriched, null, 2)); - } else { - if (entries.length === 0) { - console.log("No threads found."); - return; - } - for (const e of entries) { - const status = e.lastStatus ?? "idle"; - const ts = new Date(e.updatedAt ?? e.createdAt).getTime() / 1000; - const age = formatAge(ts); - const model = e.model ? ` (${e.model})` : ""; - const preview = e.preview ? ` ${e.preview.slice(0, 50)}` : ""; - console.log( - ` ${e.shortId} ${status.padEnd(12)} ${age.padEnd(8)} ${e.cwd ?? ""}${model}${preview}`, - ); - } + if (activeShortId && activeWsPaths) { + removePidFile(activeWsPaths.pidsDir, activeShortId); } -} -async function cmdKill(positional: string[]) { - const id = positional[0]; - if (!id) die("Usage: codex-collab kill "); - validateIdOrDie(id); - - const threadId = resolveThreadId(config.threadsFile, id); - const shortId = findShortId(config.threadsFile, threadId); - - // Skip kill for threads that have already reached a terminal status - if (shortId) { - const mapping = loadThreadMapping(config.threadsFile); - const localStatus = mapping[shortId]?.lastStatus; - if (localStatus && localStatus !== "running") { - progress(`Thread ${id} is already ${localStatus}`); - return; - } - } - - // Write kill signal file so the running process can detect the kill - let killSignalWritten = false; - const signalPath = join(config.killSignalsDir, threadId); - try { - writeFileSync(signalPath, "", { mode: 0o600 }); - killSignalWritten = true; - } catch (e) { - console.error( - `[codex] Warning: could not write kill signal: ${e instanceof Error ? e.message : String(e)}. ` + - `The running process may not detect the kill.`, - ); - } - - // Try to interrupt the active turn on the server (immediate effect). - // The kill signal file handles the case where the run process is polling. - let serverInterrupted = false; - await withClient(async (client) => { + // Try to interrupt the active turn before disconnecting (prevents + // orphaned turns when using the broker — closing the socket alone + // only disconnects from the broker, the turn keeps running). + if (activeClient && activeThreadId && activeTurnId) { try { - const { thread } = await client.request<{ - thread: { - id: string; - status: { type: string }; - turns: Array<{ id: string; status: string }>; - }; - }>("thread/read", { threadId, includeTurns: true }); - - if (thread.status.type === "active") { - const activeTurn = thread.turns?.find( - (t) => t.status === "inProgress", - ); - if (activeTurn) { - await client.request("turn/interrupt", { - threadId, - turnId: activeTurn.id, - }); - serverInterrupted = true; - progress(`Interrupted turn ${activeTurn.id}`); - } - } + await activeClient.request("turn/interrupt", { threadId: activeThreadId, turnId: activeTurnId }); } catch (e) { - if (e instanceof Error && !e.message.includes("not found")) { - console.error(`[codex] Warning: could not read/interrupt thread: ${e.message}`); - } - } - }); - - if (killSignalWritten || serverInterrupted) { - updateThreadStatus(config.threadsFile, threadId, "interrupted"); - if (shortId) removePidFile(shortId); - progress(`Stopped thread ${id}`); - } else { - progress(`Could not signal thread ${id} — try again.`); - } -} - -/** Resolve a positional ID arg to a log file path, or die with an error. */ -function resolveLogPath(positional: string[], usage: string): string { - const id = positional[0]; - if (!id) die(usage); - validateIdOrDie(id); - const threadId = resolveThreadId(config.threadsFile, id); - const shortId = findShortId(config.threadsFile, threadId); - if (!shortId) die(`Thread not found: ${id}`); - return join(config.logsDir, `${shortId}.log`); -} - -async function cmdOutput(positional: string[], opts: Options) { - const logPath = resolveLogPath(positional, "Usage: codex-collab output "); - if (!existsSync(logPath)) die(`No log file for thread`); - const content = readFileSync(logPath, "utf-8"); - if (opts.contentOnly) { - // Extract agent output blocks from the log. - // Log format: " agent output:\n\n<>" - // Using an explicit end marker avoids false positives when model output contains timestamps. - const tsPrefix = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z /; - const lines = content.split("\n"); - let inAgentOutput = false; - for (const line of lines) { - if (line === "<>") { - inAgentOutput = false; - continue; - } - if (tsPrefix.test(line)) { - inAgentOutput = line.includes(" agent output:"); - continue; - } - if (inAgentOutput) { - console.log(line); + // Best effort — may fail if turn already completed + if (e instanceof Error && !e.message.includes("not found") && !e.message.includes("already")) { + console.error(`[codex] Warning: could not interrupt turn: ${e.message}`); } } - } else { - console.log(content); - } -} - -async function cmdProgress(positional: string[]) { - const logPath = resolveLogPath(positional, "Usage: codex-collab progress "); - if (!existsSync(logPath)) { - console.log("No activity yet."); - return; } - // Show last 20 lines - const lines = readFileSync(logPath, "utf-8").trim().split("\n"); - console.log(lines.slice(-20).join("\n")); -} - -async function cmdModels() { - const allModels = await withClient((client) => - fetchAllPages(client, "model/list", { includeHidden: true }), - ); - - for (const m of allModels) { - const efforts = - m.supportedReasoningEfforts?.map((o) => o.reasoningEffort).join(", ") ?? ""; - console.log( - ` ${m.id.padEnd(25)} ${(m.description ?? "").slice(0, 50).padEnd(52)} ${efforts}`, - ); - } -} - -async function cmdApproveOrDecline( - decision: "accept" | "decline", - positional: string[], -) { - const approvalId = positional[0]; - const verb = decision === "accept" ? "approve" : "decline"; - if (!approvalId) die(`Usage: codex-collab ${verb} `); - validateIdOrDie(approvalId); - - const requestPath = join(config.approvalsDir, `${approvalId}.json`); - if (!existsSync(requestPath)) - die(`No pending approval: ${approvalId}`); - - const decisionPath = join(config.approvalsDir, `${approvalId}.decision`); try { - writeFileSync(decisionPath, decision, { mode: 0o600 }); - } catch (e) { - die(`Failed to write approval decision: ${e instanceof Error ? e.message : String(e)}`); - } - console.log( - `${decision === "accept" ? "Approved" : "Declined"}: ${approvalId}`, - ); -} - -/** Delete files older than maxAgeMs in the given directory. Returns count deleted. */ -function deleteOldFiles(dir: string, maxAgeMs: number): number { - if (!existsSync(dir)) return 0; - const now = Date.now(); - let deleted = 0; - for (const file of readdirSync(dir)) { - const path = join(dir, file); - try { - if (now - Bun.file(path).lastModified > maxAgeMs) { - unlinkSync(path); - deleted++; - } - } catch (e) { - if (e instanceof Error && (e as NodeJS.ErrnoException).code !== "ENOENT") { - console.error(`[codex] Warning: could not delete ${path}: ${e.message}`); - } - } - } - return deleted; -} - -async function cmdClean() { - const sevenDaysMs = 7 * 24 * 60 * 60 * 1000; - const oneDayMs = 24 * 60 * 60 * 1000; - - const logsDeleted = deleteOldFiles(config.logsDir, sevenDaysMs); - const approvalsDeleted = deleteOldFiles(config.approvalsDir, oneDayMs); - const killSignalsDeleted = deleteOldFiles(config.killSignalsDir, oneDayMs); - const pidsDeleted = deleteOldFiles(config.pidsDir, oneDayMs); - - // Clean stale thread mappings — use log file mtime as proxy for last - // activity so recently-used threads aren't pruned just because they - // were created more than 7 days ago. - let mappingsRemoved = 0; - withThreadLock(config.threadsFile, () => { - const mapping = loadThreadMapping(config.threadsFile); - const now = Date.now(); - for (const [shortId, entry] of Object.entries(mapping)) { - try { - let lastActivity = new Date(entry.createdAt).getTime(); - if (Number.isNaN(lastActivity)) lastActivity = 0; - const logPath = join(config.logsDir, `${shortId}.log`); - if (existsSync(logPath)) { - lastActivity = Math.max(lastActivity, Bun.file(logPath).lastModified); - } - if (now - lastActivity > sevenDaysMs) { - delete mapping[shortId]; - mappingsRemoved++; - } - } catch (e) { - console.error(`[codex] Warning: skipping mapping ${shortId}: ${e instanceof Error ? e.message : e}`); - } - } - if (mappingsRemoved > 0) { - saveThreadMapping(config.threadsFile, mapping); - } - }); - - const parts: string[] = []; - if (logsDeleted > 0) parts.push(`${logsDeleted} log files deleted`); - if (approvalsDeleted > 0) - parts.push(`${approvalsDeleted} approval files deleted`); - if (killSignalsDeleted > 0) - parts.push(`${killSignalsDeleted} kill signal files deleted`); - if (pidsDeleted > 0) - parts.push(`${pidsDeleted} stale PID files deleted`); - if (mappingsRemoved > 0) - parts.push(`${mappingsRemoved} stale mappings removed`); - - if (parts.length === 0) { - console.log("Nothing to clean."); - } else { - console.log(`Cleaned: ${parts.join(", ")}.`); - } -} - -async function cmdDelete(positional: string[]) { - const id = positional[0]; - if (!id) die("Usage: codex-collab delete "); - validateIdOrDie(id); - - const threadId = resolveThreadId(config.threadsFile, id); - const shortId = findShortId(config.threadsFile, threadId); - - // If the thread is currently running, stop it first before archiving - const localStatus = shortId ? loadThreadMapping(config.threadsFile)[shortId]?.lastStatus : undefined; - if (localStatus === "running") { - const signalPath = join(config.killSignalsDir, threadId); - try { - writeFileSync(signalPath, "", { mode: 0o600 }); - } catch (e) { - console.error( - `[codex] Warning: could not write kill signal: ${e instanceof Error ? e.message : String(e)}. ` + - `The running process may not detect the delete.`, - ); + if (activeClient) { + await activeClient.close(); } - } - - let archiveResult: "archived" | "already_done" | "failed" = "failed"; - try { - archiveResult = await withClient(async (client) => { - // Interrupt active turn before archiving (only if running) - if (localStatus === "running") { - try { - const { thread } = await client.request<{ - thread: { - id: string; - status: { type: string }; - turns: Array<{ id: string; status: string }>; - }; - }>("thread/read", { threadId, includeTurns: true }); - - if (thread.status.type === "active") { - const activeTurn = thread.turns?.find( - (t) => t.status === "inProgress", - ); - if (activeTurn) { - await client.request("turn/interrupt", { - threadId, - turnId: activeTurn.id, - }); - } - } - } catch (e) { - if (e instanceof Error && !e.message.includes("not found") && !e.message.includes("archived")) { - console.error(`[codex] Warning: could not read/interrupt thread during delete: ${e.message}`); - } - } - } - - return tryArchive(client, threadId); - }); } catch (e) { - if (e instanceof Error && !e.message.includes("not found")) { - console.error(`[codex] Warning: could not archive on server: ${e.message}`); - } - } - - if (shortId) { - removePidFile(shortId); - const logPath = join(config.logsDir, `${shortId}.log`); - if (existsSync(logPath)) unlinkSync(logPath); - removeThread(config.threadsFile, shortId); - } - - if (archiveResult === "failed") { - progress(`Deleted local data for thread ${id} (server archive failed)`); - } else { - progress(`Deleted thread ${id}`); - } -} - -async function cmdConfig(positional: string[], opts: Options) { - const VALID_KEYS: Record boolean; hint: string }> = { - model: { validate: v => !/[^a-zA-Z0-9._\-\/:]/.test(v), hint: "model name (e.g. gpt-5.4, gpt-5.3-codex)" }, - reasoning: { validate: v => (config.reasoningEfforts as readonly string[]).includes(v), hint: config.reasoningEfforts.join(", ") }, - sandbox: { validate: v => (config.sandboxModes as readonly string[]).includes(v), hint: config.sandboxModes.join(", ") }, - approval: { validate: v => (config.approvalPolicies as readonly string[]).includes(v), hint: config.approvalPolicies.join(", ") }, - timeout: { validate: v => { const n = Number(v); return Number.isFinite(n) && n > 0; }, hint: "seconds (e.g. 1200)" }, - }; - - const cfg = loadUserConfig(); - - // No args → show current config, or --unset to clear all - if (positional.length === 0) { - if (opts.explicit.has("unset")) { - saveUserConfig({}); - console.log("All config values cleared. Using auto-detected defaults."); - return; - } - if (Object.keys(cfg).length === 0) { - console.log("No user config set. Using auto-detected defaults."); - console.log(`\nConfig file: ${config.configFile}`); - console.log(`\nAvailable keys: ${Object.keys(VALID_KEYS).join(", ")}`); - console.log("Set a value: codex-collab config "); - console.log("Unset a value: codex-collab config --unset"); - } else { - for (const [k, v] of Object.entries(cfg)) { - console.log(` ${k}: ${v}`); - } - console.log(`\nConfig file: ${config.configFile}`); - } - return; - } - - const key = positional[0]; - if (!Object.hasOwn(VALID_KEYS, key)) { - die(`Unknown config key: ${key}\nValid keys: ${Object.keys(VALID_KEYS).join(", ")}`); - } - - // Unset - if (opts.explicit.has("unset")) { - delete (cfg as Record)[key]; - saveUserConfig(cfg); - console.log(`Unset ${key} (will use auto-detected default)`); - return; - } - - // Key only → show value - if (positional.length === 1) { - const val = (cfg as Record)[key]; - if (val !== undefined) { - console.log(`${key}: ${val}`); - } else { - console.log(`${key}: (not set — auto-detected)`); - } - return; - } - - const value = positional[1]; - - // Validate and set - const spec = VALID_KEYS[key]; - if (!spec.validate(value)) { - die(`Invalid value for ${key}: ${value}\nValid: ${spec.hint}`); + console.error(`[codex] Warning: cleanup failed: ${e instanceof Error ? e.message : String(e)}`); } - - (cfg as Record)[key] = key === "timeout" ? Number(value) : value; - saveUserConfig(cfg); - console.log(`Set ${key}: ${value}`); + process.exit(exitCode); } -async function cmdHealth() { - const findCmd = process.platform === "win32" ? "where" : "which"; - const which = Bun.spawnSync([findCmd, "codex"]); - if (which.exitCode !== 0) { - die("codex CLI not found. Install: npm install -g @openai/codex"); - } - - console.log(` bun: ${Bun.version}`); - // `where` on Windows returns multiple matches; show only the first - console.log(` codex: ${which.stdout.toString().trim().split("\n")[0].trim()}`); - - try { - const userAgent = await withClient(async (client) => client.userAgent); - console.log(` app-server: OK (${userAgent})`); - } catch (e) { - console.log(` app-server: FAILED (${e instanceof Error ? e.message : e})`); - process.exit(1); - } - - console.log("\nHealth check passed."); -} +process.on("SIGINT", () => handleShutdownSignal(130)); +process.on("SIGTERM", () => handleShutdownSignal(143)); // --------------------------------------------------------------------------- // Help text @@ -1318,16 +94,18 @@ Commands: run --resume "p" Resume existing thread with new prompt review [opts] Run code review (PR-style by default) review "instructions" Custom review with specific focus - jobs [--json] [--all] List threads (--limit to cap) + threads [--json] [--all] List threads (--limit , --discover) kill Stop a running thread output Read full log for thread progress Show recent activity for thread config [key] [value] Show or set persistent defaults models List available models + templates List available prompt templates approve Approve a pending request decline Decline a pending request clean Delete old logs and stale mappings delete Archive thread, delete local files + resume-candidate --json Find resumable thread health Check prerequisites Options: @@ -1342,6 +120,7 @@ Options: --mode Review mode: ${VALID_REVIEW_MODES.join(", ")} --ref Commit ref for --mode commit --base Base branch for PR review (default: main) + --template Prompt template (run command; checks ~/.codex-collab/templates/ first) --content-only Print only result text (no progress lines) Examples: @@ -1350,38 +129,63 @@ Examples: codex-collab review -d /path/to/project --content-only codex-collab review --mode uncommitted -d /path/to/project --content-only codex-collab review "Focus on security issues" --content-only - codex-collab jobs --json + codex-collab threads --json codex-collab kill abc123 codex-collab health `); } // --------------------------------------------------------------------------- -// Main dispatch +// Argument pre-scan: extract command name and check for --help // --------------------------------------------------------------------------- -/** Ensure data directories exist (called only for commands that need them). - * Config getters throw if the home directory cannot be determined, producing a clear error. */ -function ensureDataDirs(): void { - mkdirSync(config.logsDir, { recursive: true }); - mkdirSync(config.approvalsDir, { recursive: true }); - mkdirSync(config.killSignalsDir, { recursive: true }); - mkdirSync(config.pidsDir, { recursive: true }); +const rawArgs = process.argv.slice(2); + +function extractCommand(args: string[]): { command: string; rest: string[] } { + // Scan for --help / -h before any command + for (const arg of args) { + if (arg === "-h" || arg === "--help") { + showHelp(); + process.exit(0); + } + // Stop at first unknown flag — let command modules handle errors + if (arg.startsWith("-")) break; + // First non-flag is the command + return { command: arg, rest: args.slice(args.indexOf(arg) + 1) }; + } + // No command found — check for bare flags + for (const arg of args) { + if (arg.startsWith("-") && arg !== "-h" && arg !== "--help") { + console.error(`Error: Unknown option: ${arg}`); + console.error("Run codex-collab --help for usage"); + process.exit(1); + } + } + return { command: "", rest: [] }; } +// --------------------------------------------------------------------------- +// Main dispatch +// --------------------------------------------------------------------------- + async function main() { if (rawArgs.length === 0) { showHelp(); process.exit(0); } - const { command, positional, options } = parseArgs(rawArgs); + const { command, rest } = extractCommand(rawArgs); - // Validate command before setting up data directories. - // Keep in sync with the switch below. + if (!command) { + showHelp(); + process.exit(0); + } + + // Validate command const knownCommands = new Set([ - "run", "review", "jobs", "kill", "output", "progress", - "config", "models", "approve", "decline", "clean", "delete", "health", + "run", "review", "threads", "jobs", "kill", "output", "progress", + "config", "models", "templates", "approve", "decline", "clean", "delete", "health", + "resume-candidate", ]); if (!knownCommands.has(command)) { console.error(`Error: Unknown command: ${command}`); @@ -1389,44 +193,46 @@ async function main() { process.exit(1); } - // Create data directories for commands that need them - const noDataDirCommands = new Set(["health", "models"]); - if (!noDataDirCommands.has(command)) { - ensureDataDirs(); - } - - // Apply user config for commands that use options - if (command === "run" || command === "review") { - applyUserConfig(options); + // Handle --help after a command (e.g., "codex-collab run --help") + if (rest.includes("-h") || rest.includes("--help")) { + showHelp(); + process.exit(0); } switch (command) { case "run": - return cmdRun(positional, options); + return (await import("./commands/run")).handleRun(rest); case "review": - return cmdReview(positional, options); + return (await import("./commands/review")).handleReview(rest); + case "threads": + return (await import("./commands/threads")).handleThreads(rest); case "jobs": - return cmdJobs(options); + console.error("[codex] Warning: 'jobs' is deprecated, use 'threads'"); + return (await import("./commands/threads")).handleThreads(rest); case "kill": - return cmdKill(positional); + return (await import("./commands/kill")).handleKill(rest); case "output": - return cmdOutput(positional, options); + return (await import("./commands/threads")).handleOutput(rest); case "progress": - return cmdProgress(positional); + return (await import("./commands/threads")).handleProgress(rest); case "config": - return cmdConfig(positional, options); + return (await import("./commands/config")).handleConfig(rest); case "models": - return cmdModels(); + return (await import("./commands/config")).handleModels(rest); + case "templates": + return (await import("./commands/config")).handleTemplates(rest); case "approve": - return cmdApproveOrDecline("accept", positional); + return (await import("./commands/approve")).handleApprove(rest); case "decline": - return cmdApproveOrDecline("decline", positional); + return (await import("./commands/approve")).handleDecline(rest); case "clean": - return cmdClean(); + return (await import("./commands/threads")).handleClean(rest); case "delete": - return cmdDelete(positional); + return (await import("./commands/threads")).handleDelete(rest); case "health": - return cmdHealth(); + return (await import("./commands/config")).handleHealth(rest); + case "resume-candidate": + return (await import("./commands/threads")).handleResumeCandidate(rest); } } diff --git a/src/protocol.test.ts b/src/client.test.ts similarity index 97% rename from src/protocol.test.ts rename to src/client.test.ts index 8be65ae..f52b4d0 100644 --- a/src/protocol.test.ts +++ b/src/client.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test, beforeAll, beforeEach, afterEach } from "bun:test"; -import { parseMessage, formatNotification, formatResponse, connect, type AppServerClient } from "./protocol"; +import { parseMessage, formatNotification, formatResponse, connectDirect as connect, type AppServerClient } from "./client"; import { join } from "path"; import { tmpdir } from "os"; -// Test-local formatRequest helper with its own counter (not exported from protocol.ts +// Test-local formatRequest helper with its own counter (not exported from client.ts // to avoid ID collisions with AppServerClient's internal counter). let testNextId = 1; function formatRequest(method: string, params?: unknown): { line: string; id: number } { @@ -245,7 +245,7 @@ describe("AppServerClient", () => { test("connect performs initialize handshake and returns userAgent", async () => { const c = await connect({ command: ["bun", "run", MOCK_SERVER], - requestTimeout: 5000, + requestTimeout: 10000, }); try { expect(c.userAgent).toBe("mock-codex-server/0.1.0"); @@ -257,7 +257,7 @@ describe("AppServerClient", () => { test("close shuts down gracefully", async () => { const c = await connect({ command: ["bun", "run", MOCK_SERVER], - requestTimeout: 5000, + requestTimeout: 10000, }); await c.close(); // No error means success — process exited cleanly @@ -266,7 +266,7 @@ describe("AppServerClient", () => { test("request sends and receives response", async () => { const c = await connect({ command: ["bun", "run", MOCK_SERVER], - requestTimeout: 5000, + requestTimeout: 10000, }); try { const result = await c.request<{ thread: { id: string }; model: string }>( @@ -283,7 +283,7 @@ describe("AppServerClient", () => { test("request rejects with descriptive error on JSON-RPC error response", async () => { const c = await connect({ command: ["bun", "run", MOCK_SERVER], - requestTimeout: 5000, + requestTimeout: 10000, env: { MOCK_ERROR_RESPONSE: "1" }, }); try { @@ -301,7 +301,7 @@ describe("AppServerClient", () => { test("request rejects with error for unknown method", async () => { const c = await connect({ command: ["bun", "run", MOCK_SERVER], - requestTimeout: 5000, + requestTimeout: 10000, }); try { const error = await captureErrorMessage(c.request("unknown/method")); @@ -314,7 +314,7 @@ describe("AppServerClient", () => { test("request rejects when process exits unexpectedly", async () => { const c = await connect({ command: ["bun", "run", MOCK_SERVER], - requestTimeout: 5000, + requestTimeout: 10000, env: { MOCK_EXIT_EARLY: "1" }, }); try { @@ -330,7 +330,7 @@ describe("AppServerClient", () => { test("request rejects after client is closed", async () => { const c = await connect({ command: ["bun", "run", MOCK_SERVER], - requestTimeout: 5000, + requestTimeout: 10000, }); await c.close(); @@ -375,7 +375,7 @@ describe("AppServerClient", () => { const received: unknown[] = []; const c = await connect({ command: ["bun", "run", serverPath], - requestTimeout: 5000, + requestTimeout: 10000, }); try { @@ -442,7 +442,7 @@ describe("AppServerClient", () => { const c = await connect({ command: ["bun", "run", serverPath], - requestTimeout: 5000, + requestTimeout: 10000, }); try { @@ -469,7 +469,7 @@ describe("AppServerClient", () => { test("on returns unsubscribe function", async () => { const c = await connect({ command: ["bun", "run", MOCK_SERVER], - requestTimeout: 5000, + requestTimeout: 10000, }); try { diff --git a/src/protocol.ts b/src/client.ts similarity index 89% rename from src/protocol.ts rename to src/client.ts index 7a61aae..e3809eb 100644 --- a/src/protocol.ts +++ b/src/client.ts @@ -1,4 +1,4 @@ -// src/protocol.ts — JSON-RPC client for Codex app server +// src/client.ts — JSON-RPC client for Codex app server import { spawn } from "bun"; import { spawnSync } from "child_process"; @@ -14,6 +14,8 @@ import type { } from "./types"; import { config } from "./config"; +export type { RequestId } from "./types"; + /** Format a JSON-RPC-style notification (no id, no response). Returns newline-terminated JSON. * Note: Codex app server protocol omits the standard `jsonrpc: "2.0"` field. */ export function formatNotification(method: string, params?: unknown): string { @@ -53,19 +55,19 @@ export function parseMessage(line: string): JsonRpcMessage | null { // --------------------------------------------------------------------------- /** Pending request tracker. */ -interface PendingRequest { +export interface PendingRequest { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: ReturnType; } /** Handler for server-sent notifications. */ -type NotificationHandler = (params: unknown) => void; +export type NotificationHandler = (params: unknown) => void; /** Handler for server-sent requests (e.g. approval requests). Returns the result to send back. */ -type ServerRequestHandler = (params: unknown) => unknown | Promise; +export type ServerRequestHandler = (params: unknown) => unknown | Promise; -/** Options for connect(). */ +/** Options for connectDirect(). */ export interface ConnectOptions { /** Command to spawn. Defaults to ["codex", "app-server"]. */ command?: string[]; @@ -77,7 +79,7 @@ export interface ConnectOptions { requestTimeout?: number; } -/** The client interface returned by connect(). */ +/** The client interface returned by connectDirect(). */ export interface AppServerClient { /** Send a request and wait for a response. Rejects on timeout, error, or process exit. */ request(method: string, params?: unknown): Promise; @@ -90,6 +92,9 @@ export interface AppServerClient { onRequest(method: string, handler: ServerRequestHandler): () => void; /** Send a response to a server-sent request. */ respond(id: RequestId, result: unknown): void; + /** Register a callback invoked when the connection closes unexpectedly + * (e.g. the app-server process exits). Not called on intentional close(). */ + onClose(handler: () => void): () => void; /** Close the connection and terminate the server process. * On Unix: close stdin -> wait 5s -> SIGTERM -> wait 3s -> SIGKILL. * On Windows: close stdin, then immediately terminate the process tree @@ -97,25 +102,28 @@ export interface AppServerClient { close(): Promise; /** The user-agent string from the initialize handshake. */ userAgent: string; + /** True when the broker reported it is busy serving another client's turn. + * Always false for direct connections. */ + brokerBusy: boolean; } /** Type guard: message is a response (has id + result). */ -function isResponse(msg: JsonRpcMessage): msg is JsonRpcResponse { +export function isResponse(msg: JsonRpcMessage): msg is JsonRpcResponse { return "id" in msg && "result" in msg && !("method" in msg); } /** Type guard: message is an error response (has id + error). */ -function isError(msg: JsonRpcMessage): msg is JsonRpcError { +export function isError(msg: JsonRpcMessage): msg is JsonRpcError { return "id" in msg && "error" in msg && !("method" in msg); } /** Type guard: message is a request (has id + method). */ -function isRequest(msg: JsonRpcMessage): msg is JsonRpcRequest { +export function isRequest(msg: JsonRpcMessage): msg is JsonRpcRequest { return "id" in msg && "method" in msg && !("result" in msg) && !("error" in msg); } /** Type guard: message is a notification (has method, no id). */ -function isNotification(msg: JsonRpcMessage): msg is JsonRpcNotification { +export function isNotification(msg: JsonRpcMessage): msg is JsonRpcNotification { return "method" in msg && !("id" in msg); } @@ -123,7 +131,7 @@ function isNotification(msg: JsonRpcMessage): msg is JsonRpcNotification { * Spawn the Codex app-server process, perform the initialize handshake, * and return an AppServerClient for request/response communication. */ -export async function connect(opts?: ConnectOptions): Promise { +export async function connectDirect(opts?: ConnectOptions): Promise { const command = opts?.command ?? ["codex", "app-server"]; const requestTimeout = opts?.requestTimeout ?? config.requestTimeout; @@ -270,11 +278,17 @@ export async function connect(opts?: ConnectOptions): Promise { } })(); - // Monitor process exit: reject all pending requests + // Monitor process exit: reject all pending requests and notify close handlers + const closeHandlers = new Set<() => void>(); proc.exited.then(() => { exited = true; if (!closed) { rejectAll("App server process exited unexpectedly"); + for (const handler of closeHandlers) { + try { handler(); } catch (e) { + console.error(`[codex] Warning: close handler error: ${e instanceof Error ? e.message : String(e)}`); + } + } } }); @@ -355,6 +369,11 @@ export async function connect(opts?: ConnectOptions): Promise { write(formatResponse(id, result)); } + function onClose(handler: () => void): () => void { + closeHandlers.add(handler); + return () => { closeHandlers.delete(handler); }; + } + /** Wait for the process to exit within the given timeout. */ function waitForExit(timeoutMs: number): Promise { return Promise.race([ @@ -420,7 +439,10 @@ export async function connect(opts?: ConnectOptions): Promise { const initParams: InitializeParams = { clientInfo: { name: config.clientName, title: null, version: config.clientVersion }, - capabilities: null, + capabilities: { + experimentalApi: false, + optOutNotificationMethods: ["item/reasoning/textDelta"], + }, }; let initResult: InitializeResponse; @@ -438,7 +460,9 @@ export async function connect(opts?: ConnectOptions): Promise { on, onRequest, respond, + onClose, close, userAgent: initResult.userAgent, + brokerBusy: false, }; } diff --git a/src/commands/approve.ts b/src/commands/approve.ts new file mode 100644 index 0000000..bbd8c85 --- /dev/null +++ b/src/commands/approve.ts @@ -0,0 +1,44 @@ +// src/commands/approve.ts — approve + decline command handlers + +import { existsSync, writeFileSync } from "fs"; +import { join } from "path"; +import { + die, + parseOptions, + validateIdOrDie, + getWorkspacePaths, +} from "./shared"; + +export async function handleApprove(args: string[]): Promise { + return handleApproveOrDecline("accept", args); +} + +export async function handleDecline(args: string[]): Promise { + return handleApproveOrDecline("decline", args); +} + +async function handleApproveOrDecline( + decision: "accept" | "decline", + args: string[], +): Promise { + const { positional, options } = parseOptions(args); + const ws = getWorkspacePaths(options.dir); + const approvalId = positional[0]; + const verb = decision === "accept" ? "approve" : "decline"; + if (!approvalId) die(`Usage: codex-collab ${verb} `); + validateIdOrDie(approvalId); + + const requestPath = join(ws.approvalsDir, `${approvalId}.json`); + if (!existsSync(requestPath)) + die(`No pending approval: ${approvalId}`); + + const decisionPath = join(ws.approvalsDir, `${approvalId}.decision`); + try { + writeFileSync(decisionPath, decision, { mode: 0o600 }); + } catch (e) { + die(`Failed to write approval decision: ${e instanceof Error ? e.message : String(e)}`); + } + console.log( + `${decision === "accept" ? "Approved" : "Declined"}: ${approvalId}`, + ); +} diff --git a/src/commands/config.ts b/src/commands/config.ts new file mode 100644 index 0000000..33d0614 --- /dev/null +++ b/src/commands/config.ts @@ -0,0 +1,156 @@ +// src/commands/config.ts — config, models, health command handlers + +import { config, listTemplates } from "../config"; +import type { Model } from "../types"; +import { + die, + parseOptions, + withClient, + fetchAllPages, + loadUserConfig, + saveUserConfig, +} from "./shared"; + +// --------------------------------------------------------------------------- +// config +// --------------------------------------------------------------------------- + +export async function handleConfig(args: string[]): Promise { + const { positional, options } = parseOptions(args); + + const VALID_KEYS: Record boolean; hint: string }> = { + model: { validate: v => !/[^a-zA-Z0-9._\-\/:]/.test(v), hint: "model name (e.g. gpt-5.4, gpt-5.3-codex)" }, + reasoning: { validate: v => (config.reasoningEfforts as readonly string[]).includes(v), hint: config.reasoningEfforts.join(", ") }, + sandbox: { validate: v => (config.sandboxModes as readonly string[]).includes(v), hint: config.sandboxModes.join(", ") }, + approval: { validate: v => (config.approvalPolicies as readonly string[]).includes(v), hint: config.approvalPolicies.join(", ") }, + timeout: { validate: v => { const n = Number(v); return Number.isFinite(n) && n > 0; }, hint: "seconds (e.g. 1200)" }, + }; + + const cfg = loadUserConfig(); + + // No args -> show current config, or --unset to clear all + if (positional.length === 0) { + if (options.explicit.has("unset")) { + saveUserConfig({}); + console.log("All config values cleared. Using auto-detected defaults."); + return; + } + if (Object.keys(cfg).length === 0) { + console.log("No user config set. Using auto-detected defaults."); + console.log(`\nConfig file: ${config.configFile}`); + console.log(`\nAvailable keys: ${Object.keys(VALID_KEYS).join(", ")}`); + console.log("Set a value: codex-collab config "); + console.log("Unset a value: codex-collab config --unset"); + } else { + for (const [k, v] of Object.entries(cfg)) { + console.log(` ${k}: ${v}`); + } + console.log(`\nConfig file: ${config.configFile}`); + } + return; + } + + const key = positional[0]; + if (!Object.hasOwn(VALID_KEYS, key)) { + die(`Unknown config key: ${key}\nValid keys: ${Object.keys(VALID_KEYS).join(", ")}`); + } + + // Unset + if (options.explicit.has("unset")) { + delete (cfg as Record)[key]; + saveUserConfig(cfg); + console.log(`Unset ${key} (will use auto-detected default)`); + return; + } + + // Key only -> show value + if (positional.length === 1) { + const val = (cfg as Record)[key]; + if (val !== undefined) { + console.log(`${key}: ${val}`); + } else { + console.log(`${key}: (not set — auto-detected)`); + } + return; + } + + const value = positional[1]; + + // Validate and set + const spec = VALID_KEYS[key]; + if (!spec.validate(value)) { + die(`Invalid value for ${key}: ${value}\nValid: ${spec.hint}`); + } + + (cfg as Record)[key] = key === "timeout" ? Number(value) : value; + saveUserConfig(cfg); + console.log(`Set ${key}: ${value}`); +} + +// --------------------------------------------------------------------------- +// models +// --------------------------------------------------------------------------- + +export async function handleModels(_args: string[]): Promise { + const allModels = await withClient((client) => + fetchAllPages(client, "model/list", { includeHidden: true }), + ); + + for (const m of allModels) { + const efforts = + m.supportedReasoningEfforts?.map((o) => o.reasoningEffort).join(", ") ?? ""; + console.log( + ` ${m.id.padEnd(25)} ${(m.description ?? "").slice(0, 50).padEnd(52)} ${efforts}`, + ); + } +} + +// --------------------------------------------------------------------------- +// health +// --------------------------------------------------------------------------- + +export async function handleHealth(_args: string[]): Promise { + const findCmd = process.platform === "win32" ? "where" : "which"; + const which = Bun.spawnSync([findCmd, "codex"]); + if (which.exitCode !== 0) { + die("codex CLI not found. Install: npm install -g @openai/codex"); + } + + console.log(` bun: ${Bun.version}`); + // `where` on Windows returns multiple matches; show only the first + console.log(` codex: ${which.stdout.toString().trim().split("\n")[0].trim()}`); + + try { + const userAgent = await withClient(async (client) => client.userAgent); + console.log(` app-server: OK (${userAgent})`); + } catch (e) { + console.log(` app-server: FAILED (${e instanceof Error ? e.message : e})`); + process.exit(1); + } + + console.log("\nHealth check passed."); +} + +// --------------------------------------------------------------------------- +// templates +// --------------------------------------------------------------------------- + +export function handleTemplates(_args: string[]): void { + const templates = listTemplates(); + + if (templates.length === 0) { + console.log("No templates found."); + } else { + console.log("Available templates:\n"); + const maxName = Math.max(...templates.map(t => t.name.length)); + for (const t of templates) { + const sandbox = t.sandbox ? ` (${t.sandbox})` : ""; + console.log(` ${t.name.padEnd(maxName + 2)} ${t.description}${sandbox}`); + } + } + + console.log(`\nTemplate directories:`); + console.log(` User: ~/.codex-collab/templates/`); + console.log(` Built-in: (bundled with codex-collab)`); + console.log(`\nUsage: codex-collab run "prompt" --template `); +} diff --git a/src/commands/kill.ts b/src/commands/kill.ts new file mode 100644 index 0000000..a4bf8b6 --- /dev/null +++ b/src/commands/kill.ts @@ -0,0 +1,94 @@ +// src/commands/kill.ts — kill command handler + +import { + legacyResolveThreadId as resolveThreadId, + legacyFindShortId as findShortId, + loadThreadMapping, + updateThreadStatus, +} from "../threads"; +import { writeFileSync } from "fs"; +import { join } from "path"; +import { + die, + parseOptions, + validateIdOrDie, + progress, + withClient, + removePidFile, + getWorkspacePaths, +} from "./shared"; + +export async function handleKill(args: string[]): Promise { + const { positional, options } = parseOptions(args); + const ws = getWorkspacePaths(options.dir); + const id = positional[0]; + if (!id) die("Usage: codex-collab kill "); + validateIdOrDie(id); + + const threadId = resolveThreadId(ws.threadsFile, id); + const shortId = findShortId(ws.threadsFile, threadId); + + // Skip kill for threads that have already reached a terminal status + if (shortId) { + const mapping = loadThreadMapping(ws.threadsFile); + const localStatus = mapping[shortId]?.lastStatus; + if (localStatus && localStatus !== "running") { + progress(`Thread ${id} is already ${localStatus}`); + return; + } + } + + // Write kill signal file so the running process can detect the kill + let killSignalWritten = false; + const signalPath = join(ws.killSignalsDir, threadId); + try { + writeFileSync(signalPath, "", { mode: 0o600 }); + killSignalWritten = true; + } catch (e) { + console.error( + `[codex] Warning: could not write kill signal: ${e instanceof Error ? e.message : String(e)}. ` + + `The running process may not detect the kill.`, + ); + } + + // Try to interrupt the active turn on the server (immediate effect). + // The kill signal file handles the case where the run process is polling. + let serverInterrupted = false; + await withClient(async (client) => { + try { + const { thread } = await client.request<{ + thread: { + id: string; + status: { type: string }; + turns: Array<{ id: string; status: string }>; + }; + }>("thread/read", { threadId, includeTurns: true }); + + if (thread.status.type === "active") { + const activeTurn = thread.turns?.find( + (t) => t.status === "inProgress", + ); + if (activeTurn) { + await client.request("turn/interrupt", { + threadId, + turnId: activeTurn.id, + }); + serverInterrupted = true; + progress(`Interrupted turn ${activeTurn.id}`); + } + } + } catch (e) { + if (e instanceof Error && !e.message.includes("not found")) { + console.error(`[codex] Warning: could not read/interrupt thread: ${e.message}`); + } + } + }, options.dir); + + if (killSignalWritten || serverInterrupted) { + updateThreadStatus(ws.threadsFile, threadId, "interrupted"); + if (shortId) removePidFile(ws.pidsDir, shortId); + progress(`Stopped thread ${id}`); + } else { + progress(`Could not signal thread ${id} — try again.`); + } +} diff --git a/src/commands/review.ts b/src/commands/review.ts new file mode 100644 index 0000000..81e70b8 --- /dev/null +++ b/src/commands/review.ts @@ -0,0 +1,144 @@ +// src/commands/review.ts — review command handler + +import { updateThreadStatus, updateRun } from "../threads"; +import { runReview } from "../turns"; +import { getDefaultBranch } from "../git"; +import type { ReviewTarget } from "../types"; +import { + die, + parseOptions, + applyUserConfig, + withClient, + resolveDefaults, + startOrResumeThread, + createDispatcher, + getApprovalHandler, + getWorkspacePaths, + turnOverrides, + printResult, + progress, + formatDuration, + writePidFile, + removePidFile, + setActiveThreadId, + setActiveShortId, + setActiveTurnId, + setActiveWsPaths, + setActiveRunId, + VALID_REVIEW_MODES, + type Options, +} from "./shared"; + +function resolveReviewTarget(positional: string[], opts: Options, cwd: string): ReviewTarget { + const mode = opts.reviewMode ?? "pr"; + + if (positional.length > 0) { + if (opts.reviewMode !== null && opts.reviewMode !== "custom") { + die(`--mode ${opts.reviewMode} does not accept positional arguments.\nUse --mode custom "instructions" for custom reviews.`); + } + return { type: "custom", instructions: positional.join(" ") }; + } + + if (mode === "custom") { + die('Custom review mode requires instructions.\nUsage: codex-collab review "instructions"'); + } + + switch (mode) { + case "pr": { + // Use dynamically detected default branch unless --base was explicitly provided + const base = opts.explicit.has("base") ? opts.base : getDefaultBranch(cwd); + return { type: "baseBranch", branch: base }; + } + case "uncommitted": + return { type: "uncommittedChanges" }; + case "commit": + return { type: "commit", sha: opts.reviewRef ?? "HEAD" }; + default: + die(`Unknown review mode: ${mode}. Use: ${VALID_REVIEW_MODES.join(", ")}`); + } +} + +export async function handleReview(args: string[]): Promise { + const { positional, options } = parseOptions(args); + applyUserConfig(options); + + const target = resolveReviewTarget(positional, options, options.dir); + const ws = getWorkspacePaths(options.dir); + + const exitCode = await withClient(async (client) => { + await resolveDefaults(client, options); + + let reviewPreview: string; + switch (target.type) { + case "custom": reviewPreview = target.instructions; break; + case "baseBranch": reviewPreview = `Review PR (base: ${target.branch})`; break; + case "uncommittedChanges": reviewPreview = "Review uncommitted changes"; break; + case "commit": reviewPreview = `Review commit ${target.sha}`; break; + } + const { threadId, shortId, runId, effective } = await startOrResumeThread( + client, options, ws, { sandbox: "read-only" }, reviewPreview, true, + ); + + if (options.contentOnly) { + console.error(`[codex] Reviewing (thread ${shortId})...`); + } else { + if (options.resumeId) { + progress(`Resumed thread ${shortId} for review`); + } else { + progress(`Thread ${shortId} started for review (${effective.model}, read-only)`); + } + } + + updateThreadStatus(ws.threadsFile, threadId, "running"); + setActiveThreadId(threadId); + setActiveShortId(shortId); + setActiveWsPaths(ws); + setActiveRunId(runId); + writePidFile(ws.pidsDir, shortId); + + const dispatcher = createDispatcher(shortId, ws.logsDir, options); + + // Note: effort (reasoning level) is not forwarded to reviews — the review/start + // protocol does not accept an effort parameter (unlike turn/start). + try { + const result = await runReview(client, threadId, target, { + dispatcher, + approvalHandler: getApprovalHandler(effective.approvalPolicy, ws.approvalsDir), + timeoutMs: options.timeout * 1000, + killSignalsDir: ws.killSignalsDir, + onTurnId: (id) => setActiveTurnId(id), + ...turnOverrides(options), + }); + + updateThreadStatus(ws.threadsFile, threadId, result.status as "completed" | "failed" | "interrupted"); + updateRun(ws.stateDir, runId, { + status: result.status === "completed" ? "completed" : result.status === "interrupted" ? "cancelled" : "failed", + phase: "finalizing", + completedAt: new Date().toISOString(), + elapsed: formatDuration(result.durationMs), + output: result.output || null, + filesChanged: result.filesChanged, + commandsRun: result.commandsRun, + error: result.error ?? null, + }); + return printResult(result, "Review", options.contentOnly); + } catch (e) { + updateThreadStatus(ws.threadsFile, threadId, "failed"); + updateRun(ws.stateDir, runId, { + status: "failed", + completedAt: new Date().toISOString(), + error: e instanceof Error ? e.message : String(e), + }); + throw e; + } finally { + setActiveThreadId(undefined); + setActiveShortId(undefined); + setActiveTurnId(undefined); + setActiveWsPaths(undefined); + setActiveRunId(undefined); + removePidFile(ws.pidsDir, shortId); + } + }, options.dir, true); + + process.exit(exitCode); +} diff --git a/src/commands/run.ts b/src/commands/run.ts new file mode 100644 index 0000000..c6c07a1 --- /dev/null +++ b/src/commands/run.ts @@ -0,0 +1,127 @@ +// src/commands/run.ts — run command handler + +import { updateThreadStatus } from "../threads"; +import { updateRun } from "../threads"; +import { runTurn } from "../turns"; +import { config, loadTemplateWithMeta, interpolateTemplate, type SandboxMode } from "../config"; +import { + die, + parseOptions, + applyUserConfig, + withClient, + resolveDefaults, + startOrResumeThread, + createDispatcher, + getApprovalHandler, + getWorkspacePaths, + turnOverrides, + printResult, + progress, + formatDuration, + writePidFile, + removePidFile, + setActiveThreadId, + setActiveShortId, + setActiveTurnId, + setActiveWsPaths, + setActiveRunId, +} from "./shared"; + +export async function handleRun(args: string[]): Promise { + const { positional, options } = parseOptions(args); + applyUserConfig(options); + + if (positional.length === 0) { + die("No prompt provided\nUsage: codex-collab run \"prompt\" [options]"); + } + + let prompt = positional.join(" "); + + if (options.template) { + const { meta, body } = loadTemplateWithMeta(options.template); + prompt = interpolateTemplate(body, { PROMPT: prompt }); + // Apply template's suggested sandbox if user didn't explicitly set one. + // Mark as explicit so it's forwarded on resume too. + if (meta.sandbox && !options.explicit.has("sandbox")) { + const validSandboxes: readonly string[] = config.sandboxModes; + if (!validSandboxes.includes(meta.sandbox)) { + die(`Template "${options.template}" has invalid sandbox: ${meta.sandbox}\nValid: ${config.sandboxModes.join(", ")}`); + } + options.sandbox = meta.sandbox as SandboxMode; + options.explicit.add("sandbox"); + } + } + const ws = getWorkspacePaths(options.dir); + + const exitCode = await withClient(async (client) => { + await resolveDefaults(client, options); + + const { threadId, shortId, runId, effective } = await startOrResumeThread(client, options, ws, undefined, prompt); + + if (options.contentOnly) { + console.error(`[codex] Running (thread ${shortId})...`); + } else { + if (options.resumeId) { + progress(`Resumed thread ${shortId} (${effective.model})`); + } else { + progress(`Thread ${shortId} started (${effective.model}, ${options.sandbox})`); + } + progress("Turn started"); + } + + updateThreadStatus(ws.threadsFile, threadId, "running"); + setActiveThreadId(threadId); + setActiveShortId(shortId); + setActiveWsPaths(ws); + setActiveRunId(runId); + writePidFile(ws.pidsDir, shortId); + + const dispatcher = createDispatcher(shortId, ws.logsDir, options); + + try { + const result = await runTurn( + client, + threadId, + [{ type: "text", text: prompt }], + { + dispatcher, + approvalHandler: getApprovalHandler(effective.approvalPolicy, ws.approvalsDir), + timeoutMs: options.timeout * 1000, + killSignalsDir: ws.killSignalsDir, + onTurnId: (id) => setActiveTurnId(id), + ...turnOverrides(options), + }, + ); + + updateThreadStatus(ws.threadsFile, threadId, result.status as "completed" | "failed" | "interrupted"); + updateRun(ws.stateDir, runId, { + status: result.status === "completed" ? "completed" : result.status === "interrupted" ? "cancelled" : "failed", + phase: "finalizing", + completedAt: new Date().toISOString(), + elapsed: formatDuration(result.durationMs), + output: result.output || null, + filesChanged: result.filesChanged, + commandsRun: result.commandsRun, + error: result.error ?? null, + }); + return printResult(result, "Turn", options.contentOnly); + } catch (e) { + updateThreadStatus(ws.threadsFile, threadId, "failed"); + updateRun(ws.stateDir, runId, { + status: "failed", + completedAt: new Date().toISOString(), + error: e instanceof Error ? e.message : String(e), + }); + throw e; + } finally { + setActiveThreadId(undefined); + setActiveShortId(undefined); + setActiveTurnId(undefined); + setActiveWsPaths(undefined); + setActiveRunId(undefined); + removePidFile(ws.pidsDir, shortId); + } + }, options.dir, true); + + process.exit(exitCode); +} diff --git a/src/commands/shared.test.ts b/src/commands/shared.test.ts new file mode 100644 index 0000000..9d107b3 --- /dev/null +++ b/src/commands/shared.test.ts @@ -0,0 +1,1121 @@ +// src/commands/shared.test.ts — Tests for shared CLI utilities + +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { mkdirSync, writeFileSync, rmSync, readFileSync } from "fs"; +import { join } from "path"; +import { + parseOptions, + pickBestModel, + validateGitRef, + applyUserConfig, + turnOverrides, + formatDuration, + isProcessAlive, + defaultOptions, + VALID_REVIEW_MODES, + type Options, +} from "./shared"; +import { config } from "../config"; +import type { Model } from "../types"; + +// ─── helpers ─────────────────────────────────────────────────────────────── + +const tmpRoot = join(process.env.TMPDIR ?? "/tmp", "shared-test-" + process.pid); + +function freshTmpDir(name: string): string { + const dir = join(tmpRoot, name); + mkdirSync(dir, { recursive: true }); + return dir; +} + +beforeEach(() => { + mkdirSync(tmpRoot, { recursive: true }); +}); + +afterEach(() => { + rmSync(tmpRoot, { recursive: true, force: true }); +}); + +// ─── parseOptions ────────────────────────────────────────────────────────── + +describe("parseOptions", () => { + // -- model --- + + test("--model sets model and marks explicit", () => { + const { options } = parseOptions(["--model", "o4-mini"]); + expect(options.model).toBe("o4-mini"); + expect(options.explicit.has("model")).toBe(true); + }); + + test("-m shorthand works", () => { + const { options } = parseOptions(["-m", "gpt-5"]); + expect(options.model).toBe("gpt-5"); + expect(options.explicit.has("model")).toBe(true); + }); + + test("--model resolves aliases (e.g., spark)", () => { + const { options } = parseOptions(["--model", "spark"]); + expect(options.model).toBe("gpt-5.3-codex-spark"); + }); + + test("--model allows dots, dashes, slashes, colons", () => { + const { options } = parseOptions(["--model", "org/gpt-5.1:latest"]); + expect(options.model).toBe("org/gpt-5.1:latest"); + }); + + test("--model rejects shell chars (calls process.exit)", () => { + // Model names with shell metacharacters trigger process.exit(1). + // We verify via subprocess to avoid killing the test runner. + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { parseOptions } from "./src/commands/shared"; + parseOptions(["--model", "foo;rm -rf /"]); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("Invalid model name"); + }); + + test("--model missing value exits", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { parseOptions } from "./src/commands/shared"; + parseOptions(["--model"]); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("--model requires a value"); + }); + + // -- reasoning --- + + test("--reasoning sets level and marks explicit", () => { + for (const level of config.reasoningEfforts) { + const { options } = parseOptions(["--reasoning", level]); + expect(options.reasoning).toBe(level); + expect(options.explicit.has("reasoning")).toBe(true); + } + }); + + test("-r shorthand works", () => { + const { options } = parseOptions(["-r", "high"]); + expect(options.reasoning).toBe("high"); + }); + + test("--reasoning invalid level exits", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { parseOptions } from "./src/commands/shared"; + parseOptions(["--reasoning", "turbo"]); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("Invalid reasoning level"); + }); + + test("--reasoning missing value exits", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { parseOptions } from "./src/commands/shared"; + parseOptions(["--reasoning"]); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("--reasoning requires a value"); + }); + + // -- sandbox --- + + test("--sandbox sets all valid modes", () => { + for (const mode of config.sandboxModes) { + const { options } = parseOptions(["--sandbox", mode]); + expect(options.sandbox).toBe(mode); + expect(options.explicit.has("sandbox")).toBe(true); + } + }); + + test("-s shorthand works", () => { + const { options } = parseOptions(["-s", "read-only"]); + expect(options.sandbox).toBe("read-only"); + }); + + test("--sandbox invalid mode exits", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { parseOptions } from "./src/commands/shared"; + parseOptions(["--sandbox", "yolo"]); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("Invalid sandbox mode"); + }); + + test("--sandbox missing value exits", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { parseOptions } from "./src/commands/shared"; + parseOptions(["--sandbox"]); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("--sandbox requires a value"); + }); + + // -- approval --- + + test("--approval sets all valid policies", () => { + for (const policy of config.approvalPolicies) { + const { options } = parseOptions(["--approval", policy]); + expect(options.approval).toBe(policy); + expect(options.explicit.has("approval")).toBe(true); + } + }); + + test("--approval invalid policy exits", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { parseOptions } from "./src/commands/shared"; + parseOptions(["--approval", "always"]); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("Invalid approval policy"); + }); + + test("--approval missing value exits", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { parseOptions } from "./src/commands/shared"; + parseOptions(["--approval"]); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("--approval requires a value"); + }); + + // -- timeout --- + + test("--timeout sets valid number", () => { + const { options } = parseOptions(["--timeout", "300"]); + expect(options.timeout).toBe(300); + expect(options.explicit.has("timeout")).toBe(true); + }); + + test("--timeout rejects NaN", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { parseOptions } from "./src/commands/shared"; + parseOptions(["--timeout", "abc"]); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("Invalid timeout"); + }); + + test("--timeout rejects negative", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { parseOptions } from "./src/commands/shared"; + parseOptions(["--timeout", "-5"]); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("Invalid timeout"); + }); + + test("--timeout rejects zero", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { parseOptions } from "./src/commands/shared"; + parseOptions(["--timeout", "0"]); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("Invalid timeout"); + }); + + test("--timeout missing value exits", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { parseOptions } from "./src/commands/shared"; + parseOptions(["--timeout"]); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("--timeout requires a value"); + }); + + // -- limit --- + + test("--limit sets valid number (floors to integer)", () => { + const { options } = parseOptions(["--limit", "5"]); + expect(options.limit).toBe(5); + }); + + test("--limit floors fractional values", () => { + const { options } = parseOptions(["--limit", "7.9"]); + expect(options.limit).toBe(7); + }); + + test("--limit rejects zero", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { parseOptions } from "./src/commands/shared"; + parseOptions(["--limit", "0"]); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("Invalid limit"); + }); + + test("--limit rejects NaN", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { parseOptions } from "./src/commands/shared"; + parseOptions(["--limit", "abc"]); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("Invalid limit"); + }); + + test("--limit missing value exits", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { parseOptions } from "./src/commands/shared"; + parseOptions(["--limit"]); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("--limit requires a value"); + }); + + // -- dir --- + + test("--dir sets dir and marks explicit", () => { + const testDir = process.platform === "win32" ? "C:\\tmp\\myproject" : "/tmp/myproject"; + const { options } = parseOptions(["--dir", testDir]); + expect(options.dir).toBe(testDir); + expect(options.explicit.has("dir")).toBe(true); + }); + + test("-d shorthand works", () => { + const testDir = process.platform === "win32" ? "C:\\tmp\\other" : "/tmp/other"; + const { options } = parseOptions(["-d", testDir]); + expect(options.dir).toBe(testDir); + }); + + test("--dir missing value exits", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { parseOptions } from "./src/commands/shared"; + parseOptions(["--dir"]); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("--dir requires a value"); + }); + + // -- resume --- + + test("--resume sets resumeId", () => { + const { options } = parseOptions(["--resume", "abc12345"]); + expect(options.resumeId).toBe("abc12345"); + }); + + test("--resume missing value exits", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { parseOptions } from "./src/commands/shared"; + parseOptions(["--resume"]); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("--resume requires a value"); + }); + + // -- mode --- + + test("--mode sets valid review modes", () => { + for (const mode of VALID_REVIEW_MODES) { + const { options } = parseOptions(["--mode", mode]); + expect(options.reviewMode).toBe(mode); + } + }); + + test("--mode rejects invalid mode", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { parseOptions } from "./src/commands/shared"; + parseOptions(["--mode", "invalid"]); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("Invalid review mode"); + }); + + test("--mode missing value exits", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { parseOptions } from "./src/commands/shared"; + parseOptions(["--mode"]); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("--mode requires a value"); + }); + + // -- ref, base --- + + test("--ref sets reviewRef", () => { + const { options } = parseOptions(["--ref", "abc123"]); + expect(options.reviewRef).toBe("abc123"); + }); + + test("--ref missing value exits", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { parseOptions } from "./src/commands/shared"; + parseOptions(["--ref"]); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("--ref requires a value"); + }); + + test("--base sets base and marks explicit", () => { + const { options } = parseOptions(["--base", "develop"]); + expect(options.base).toBe("develop"); + expect(options.explicit.has("base")).toBe(true); + }); + + test("--base missing value exits", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { parseOptions } from "./src/commands/shared"; + parseOptions(["--base"]); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("--base requires a value"); + }); + + // -- boolean flags --- + + test("--discover sets discover flag", () => { + const { options } = parseOptions(["--discover"]); + expect(options.discover).toBe(true); + }); + + test("--template sets template name", () => { + const { options } = parseOptions(["--template", "plan-review"]); + expect(options.template).toBe("plan-review"); + }); + + test("--template without value exits", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "run", "src/cli.ts", "run", "--template"], + cwd: process.cwd(), + env: { ...process.env, HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }, + }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr.toString()).toContain("--template requires a name"); + }); + + test("--all sets limit to Infinity", () => { + const { options } = parseOptions(["--all"]); + expect(options.limit).toBe(Infinity); + }); + + test("--json sets json flag", () => { + const { options } = parseOptions(["--json"]); + expect(options.json).toBe(true); + }); + + test("--content-only sets contentOnly flag", () => { + const { options } = parseOptions(["--content-only"]); + expect(options.contentOnly).toBe(true); + }); + + test("--unset adds 'unset' to explicit set", () => { + const { options } = parseOptions(["--unset"]); + expect(options.explicit.has("unset")).toBe(true); + }); + + // -- positional arguments --- + + test("collects positional arguments", () => { + const { positional } = parseOptions(["run", "fix the bug", "--model", "o4-mini"]); + expect(positional).toEqual(["run", "fix the bug"]); + }); + + test("--help sets help flag", () => { + const { options } = parseOptions(["--help"]); + expect(options.help).toBe(true); + }); + + test("-h sets help flag", () => { + const { options } = parseOptions(["-h"]); + expect(options.help).toBe(true); + }); + + // -- unknown flags --- + + test("unknown flag exits", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { parseOptions } from "./src/commands/shared"; + parseOptions(["--bogus"]); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("Unknown option"); + }); + + // -- combined flags --- + + test("multiple flags combine correctly", () => { + const { positional, options } = parseOptions([ + "-m", "o4-mini", + "-r", "high", + "-s", "read-only", + "--approval", "on-failure", + "--timeout", "600", + "--json", + "do stuff", + ]); + expect(options.model).toBe("o4-mini"); + expect(options.reasoning).toBe("high"); + expect(options.sandbox).toBe("read-only"); + expect(options.approval).toBe("on-failure"); + expect(options.timeout).toBe(600); + expect(options.json).toBe(true); + expect(positional).toEqual(["do stuff"]); + expect(options.explicit.has("model")).toBe(true); + expect(options.explicit.has("reasoning")).toBe(true); + expect(options.explicit.has("sandbox")).toBe(true); + expect(options.explicit.has("approval")).toBe(true); + expect(options.explicit.has("timeout")).toBe(true); + }); + + // -- defaults --- + + test("defaults are sane when no args given", () => { + const { positional, options } = parseOptions([]); + expect(positional).toEqual([]); + expect(options.model).toBeUndefined(); // resolveModel(undefined) -> undefined + expect(options.reasoning).toBeUndefined(); + expect(options.sandbox).toBe(config.defaultSandbox); + expect(options.approval).toBe(config.defaultApprovalPolicy); + expect(options.timeout).toBe(config.defaultTimeout); + expect(options.limit).toBe(config.threadsListLimit); + expect(options.resumeId).toBeNull(); + expect(options.reviewMode).toBeNull(); + expect(options.reviewRef).toBeNull(); + expect(options.base).toBe("main"); + expect(options.discover).toBe(false); + expect(options.json).toBe(false); + expect(options.contentOnly).toBe(false); + expect(options.explicit.size).toBe(0); + }); +}); + +// ─── pickBestModel ──────────────────────────────────────────────────────── + +describe("pickBestModel", () => { + const m = (id: string, opts: { upgrade?: string; isDefault?: boolean } = {}): Model => ({ + id, + model: id, + upgrade: opts.upgrade ?? null, + isDefault: opts.isDefault ?? false, + displayName: id, + description: "", + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: "medium", + inputModalities: ["text"], + supportsPersonality: false, + }); + + test("follows upgrade chain to latest model", () => { + const models = [ + m("old", { upgrade: "mid", isDefault: true }), + m("mid", { upgrade: "new" }), + m("new"), + ]; + expect(pickBestModel(models)).toBe("new"); + }); + + test("prefers -codex variant at end of chain", () => { + const models = [ + m("gpt-5", { isDefault: true }), + m("gpt-5-codex"), + ]; + expect(pickBestModel(models)).toBe("gpt-5-codex"); + }); + + test("does not prefer -codex variant if it has an upgrade itself", () => { + const models = [ + m("gpt-5", { isDefault: true }), + m("gpt-5-codex", { upgrade: "gpt-6-codex" }), + ]; + // codexVariant.upgrade !== null, so returns current (gpt-5) + expect(pickBestModel(models)).toBe("gpt-5"); + }); + + test("returns undefined when no default model", () => { + const models = [m("gpt-5")]; + expect(pickBestModel(models)).toBeUndefined(); + }); + + test("handles circular upgrade chain via visited guard", () => { + const models = [ + m("a", { upgrade: "b", isDefault: true }), + m("b", { upgrade: "a" }), + ]; + // a -> b (visited={a}), b -> a (visited={a,b}), a: visited.has(a) -> exit loop, current = a + expect(pickBestModel(models)).toBe("a"); + }); + + test("returns default when upgrade target not in list", () => { + const models = [m("gpt-5", { upgrade: "nonexistent", isDefault: true })]; + expect(pickBestModel(models)).toBe("gpt-5"); + }); + + test("already a -codex model stays as-is", () => { + const models = [m("gpt-5-codex", { isDefault: true })]; + expect(pickBestModel(models)).toBe("gpt-5-codex"); + }); +}); + +// ─── validateGitRef ──────────────────────────────────────────────────────── + +describe("validateGitRef", () => { + test("accepts valid refs", () => { + expect(validateGitRef("main", "branch")).toBe("main"); + expect(validateGitRef("feature/my-branch", "branch")).toBe("feature/my-branch"); + expect(validateGitRef("v1.2.3", "tag")).toBe("v1.2.3"); + expect(validateGitRef("abc123", "commit")).toBe("abc123"); + expect(validateGitRef("HEAD~3", "ref")).toBe("HEAD~3"); + expect(validateGitRef("origin/main", "remote")).toBe("origin/main"); + }); + + test("rejects semicolon", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { validateGitRef } from "./src/commands/shared"; + validateGitRef("main;echo pwned", "ref"); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("Invalid ref"); + }); + + test("rejects pipe", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { validateGitRef } from "./src/commands/shared"; + validateGitRef("main|cat", "ref"); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + }); + + test("rejects backtick", () => { + // Backtick — use String.fromCharCode to avoid shell quoting issues + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { validateGitRef } from "./src/commands/shared"; + validateGitRef("main" + String.fromCharCode(96) + "id" + String.fromCharCode(96), "ref"); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + }); + + test("rejects dollar sign", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { validateGitRef } from "./src/commands/shared"; + validateGitRef("$HOME", "ref"); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + }); + + test("rejects ampersand", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { validateGitRef } from "./src/commands/shared"; + validateGitRef("main&echo", "ref"); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + }); + + test("rejects whitespace", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { validateGitRef } from "./src/commands/shared"; + validateGitRef("main branch", "ref"); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + }); + + test("rejects parentheses", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { validateGitRef } from "./src/commands/shared"; + validateGitRef("main()", "ref"); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + }); + + test("rejects angle brackets", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { validateGitRef } from "./src/commands/shared"; + validateGitRef("main { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { validateGitRef } from "./src/commands/shared"; + validateGitRef("main\\\\path", "ref"); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + }); + + test("rejects curly braces", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "-e", ` + import { validateGitRef } from "./src/commands/shared"; + validateGitRef("main{0}", "ref"); + `], + cwd: process.cwd(), + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + }); +}); + +// ─── applyUserConfig ────────────────────────────────────────────────────── + +describe("applyUserConfig", () => { + // applyUserConfig reads from config.configFile which derives from os.homedir(). + // Since the config object is frozen, we override HOME env var in subprocesses + // so homedir() returns our temp dir, making configFile = /.codex-collab/config.json. + // The script file must live in the project directory so relative imports resolve. + + const projectDir = process.cwd(); + const scriptPath = join(projectDir, `_applyconfig_test_${process.pid}.ts`); + + afterEach(() => { + try { rmSync(scriptPath); } catch {} + }); + + function runApplyConfig( + configJson: string, + explicitFlags: string[] = [], + checkExpression: string, + ): { stdout: string; exitCode: number; stderr: string } { + const fakeHome = freshTmpDir("fake-home"); + const configDir = join(fakeHome, ".codex-collab"); + mkdirSync(configDir, { recursive: true }); + writeFileSync(join(configDir, "config.json"), configJson); + + // Build explicit flag setup lines + const addExplicit = explicitFlags.map(f => `opts.explicit.add("${f}");`).join("\n"); + const setValues = explicitFlags.map(f => { + if (f === "model") return `opts.model = "cli-model";`; + if (f === "reasoning") return `opts.reasoning = "low";`; + if (f === "sandbox") return `opts.sandbox = "danger-full-access";`; + if (f === "approval") return `opts.approval = "untrusted";`; + if (f === "timeout") return `opts.timeout = 999;`; + return ""; + }).filter(Boolean).join("\n"); + + // Write script inside the project directory so relative imports resolve + writeFileSync(scriptPath, ` +import { defaultOptions, applyUserConfig } from "./src/commands/shared"; +const opts = defaultOptions(); +${addExplicit} +${setValues} +applyUserConfig(opts); +console.log(JSON.stringify(${checkExpression})); +`); + + const result = Bun.spawnSync({ + cmd: ["bun", "run", scriptPath], + cwd: projectDir, + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, HOME: fakeHome, USERPROFILE: fakeHome }, + }); + return { + stdout: result.stdout.toString().trim(), + exitCode: result.exitCode, + stderr: result.stderr.toString(), + }; + } + + test("config values populate options when no explicit flags", () => { + const { stdout, exitCode } = runApplyConfig( + JSON.stringify({ model: "gpt-5", reasoning: "high", sandbox: "read-only", approval: "on-request", timeout: 500 }), + [], + `{ model: opts.model, reasoning: opts.reasoning, sandbox: opts.sandbox, approval: opts.approval, timeout: opts.timeout }`, + ); + expect(exitCode).toBe(0); + const result = JSON.parse(stdout); + expect(result.model).toBe("gpt-5"); + expect(result.reasoning).toBe("high"); + expect(result.sandbox).toBe("read-only"); + expect(result.approval).toBe("on-request"); + expect(result.timeout).toBe(500); + }); + + test("CLI explicit flags beat config values", () => { + const { stdout, exitCode } = runApplyConfig( + JSON.stringify({ model: "gpt-5", reasoning: "high" }), + ["model", "reasoning"], + `{ model: opts.model, reasoning: opts.reasoning }`, + ); + expect(exitCode).toBe(0); + const result = JSON.parse(stdout); + expect(result.model).toBe("cli-model"); + expect(result.reasoning).toBe("low"); + }); + + test("config values go to configured set, not explicit", () => { + const { stdout, exitCode } = runApplyConfig( + JSON.stringify({ model: "gpt-5", sandbox: "read-only" }), + [], + `{ configured: [...opts.configured], explicit: [...opts.explicit] }`, + ); + expect(exitCode).toBe(0); + const result = JSON.parse(stdout); + expect(result.configured).toContain("model"); + expect(result.configured).toContain("sandbox"); + expect(result.explicit).toEqual([]); + }); + + test("invalid model in config is ignored with warning", () => { + const { stdout, stderr, exitCode } = runApplyConfig( + JSON.stringify({ model: "bad;model" }), + [], + `{ model: opts.model ?? null }`, + ); + expect(exitCode).toBe(0); + expect(stderr).toContain("ignoring invalid model"); + const result = JSON.parse(stdout); + expect(result.model).toBeNull(); + }); + + test("invalid reasoning in config is ignored with warning", () => { + const { stdout, stderr, exitCode } = runApplyConfig( + JSON.stringify({ reasoning: "turbo" }), + [], + `{ reasoning: opts.reasoning ?? null }`, + ); + expect(exitCode).toBe(0); + expect(stderr).toContain("ignoring invalid reasoning"); + const result = JSON.parse(stdout); + expect(result.reasoning).toBeNull(); + }); + + test("invalid sandbox in config is ignored with warning", () => { + const { stderr, exitCode } = runApplyConfig( + JSON.stringify({ sandbox: "yolo" }), + [], + `{ sandbox: opts.sandbox }`, + ); + expect(exitCode).toBe(0); + expect(stderr).toContain("ignoring invalid sandbox"); + }); + + test("invalid approval in config is ignored with warning", () => { + const { stderr, exitCode } = runApplyConfig( + JSON.stringify({ approval: "always" }), + [], + `{ approval: opts.approval }`, + ); + expect(exitCode).toBe(0); + expect(stderr).toContain("ignoring invalid approval"); + }); + + test("invalid timeout in config is ignored with warning", () => { + const { stderr, exitCode } = runApplyConfig( + JSON.stringify({ timeout: -5 }), + [], + `{ timeout: opts.timeout }`, + ); + expect(exitCode).toBe(0); + expect(stderr).toContain("ignoring invalid timeout"); + }); + + test("model alias is resolved from config", () => { + const { stdout, exitCode } = runApplyConfig( + JSON.stringify({ model: "spark" }), + [], + `{ model: opts.model }`, + ); + expect(exitCode).toBe(0); + const result = JSON.parse(stdout); + expect(result.model).toBe("gpt-5.3-codex-spark"); + }); + + test("missing config file is tolerated", () => { + const fakeHome = freshTmpDir("no-config-home"); + // Don't create .codex-collab/config.json — it should be missing + + writeFileSync(scriptPath, ` +import { defaultOptions, applyUserConfig } from "./src/commands/shared"; +const opts = defaultOptions(); +applyUserConfig(opts); +console.log("ok"); +`); + + const result = Bun.spawnSync({ + cmd: ["bun", "run", scriptPath], + cwd: projectDir, + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, HOME: fakeHome, USERPROFILE: fakeHome }, + }); + expect(result.stdout.toString().trim()).toBe("ok"); + expect(result.exitCode).toBe(0); + }); +}); + +// ─── turnOverrides ───────────────────────────────────────────────────────── + +describe("turnOverrides", () => { + test("new thread: returns model, sandbox, effort, cwd, approval", () => { + const opts = defaultOptions(); + opts.model = "gpt-5"; + opts.reasoning = "high"; + opts.sandbox = "read-only"; + opts.approval = "on-request"; + opts.dir = "/tmp/test"; + opts.resumeId = null; // new thread + + const overrides = turnOverrides(opts); + expect(overrides).toEqual({ + cwd: "/tmp/test", + approvalPolicy: "on-request", + model: "gpt-5", + effort: "high", + }); + }); + + test("new thread: omits model and effort when not set", () => { + const opts = defaultOptions(); + opts.resumeId = null; + // model and reasoning are undefined by default + + const overrides = turnOverrides(opts); + expect(overrides).toEqual({ + cwd: opts.dir, + approvalPolicy: opts.approval, + }); + expect("model" in overrides).toBe(false); + expect("effort" in overrides).toBe(false); + }); + + test("resumed thread: only returns explicit overrides", () => { + const opts = defaultOptions(); + opts.resumeId = "abc12345"; + opts.model = "gpt-5"; + opts.reasoning = "high"; + opts.sandbox = "read-only"; + opts.dir = "/tmp/test"; + // Only model was explicitly set via CLI + opts.explicit.add("model"); + + const overrides = turnOverrides(opts); + expect(overrides).toEqual({ model: "gpt-5" }); + }); + + test("resumed thread: empty overrides when nothing explicit", () => { + const opts = defaultOptions(); + opts.resumeId = "abc12345"; + opts.model = "gpt-5"; + opts.reasoning = "high"; + opts.configured.add("model"); + opts.configured.add("reasoning"); + // Nothing in explicit set + + const overrides = turnOverrides(opts); + expect(overrides).toEqual({}); + }); + + test("resumed thread: all explicit flags forwarded", () => { + const opts = defaultOptions(); + opts.resumeId = "abc12345"; + opts.model = "o4-mini"; + opts.reasoning = "low"; + opts.dir = "/tmp/proj"; + opts.approval = "on-failure"; + opts.explicit.add("model"); + opts.explicit.add("reasoning"); + opts.explicit.add("dir"); + opts.explicit.add("approval"); + + const overrides = turnOverrides(opts); + expect(overrides).toEqual({ + model: "o4-mini", + effort: "low", + cwd: "/tmp/proj", + approvalPolicy: "on-failure", + }); + }); +}); + +// ─── formatDuration ──────────────────────────────────────────────────────── + +describe("formatDuration", () => { + test("formats 0ms as 0s", () => { + expect(formatDuration(0)).toBe("0s"); + }); + + test("formats sub-second as 0s (rounds)", () => { + expect(formatDuration(499)).toBe("0s"); + }); + + test("formats sub-second rounding up to 1s", () => { + expect(formatDuration(500)).toBe("1s"); + }); + + test("formats exact seconds", () => { + expect(formatDuration(5000)).toBe("5s"); + expect(formatDuration(59000)).toBe("59s"); + }); + + test("formats exactly 1 minute", () => { + expect(formatDuration(60_000)).toBe("1m 0s"); + }); + + test("formats minutes and seconds", () => { + expect(formatDuration(134_000)).toBe("2m 14s"); + }); + + test("formats large durations (hours expressed as minutes)", () => { + // 1 hour = 3600s = 60m 0s + expect(formatDuration(3_600_000)).toBe("60m 0s"); + }); + + test("formats 90 seconds", () => { + expect(formatDuration(90_000)).toBe("1m 30s"); + }); +}); + +// ─── isProcessAlive ──────────────────────────────────────────────────────── + +describe("isProcessAlive", () => { + test("missing PID file returns true (safety default)", () => { + const pidsDir = freshTmpDir("pids-missing"); + expect(isProcessAlive(pidsDir, "nosuchthread")).toBe(true); + }); + + test("PID of current process returns true", () => { + const pidsDir = freshTmpDir("pids-alive"); + writeFileSync(join(pidsDir, "thread1"), String(process.pid)); + expect(isProcessAlive(pidsDir, "thread1")).toBe(true); + }); + + test("PID of dead process returns false", async () => { + const pidsDir = freshTmpDir("pids-dead"); + // Spawn a short-lived process and wait for it to exit, giving us a known-dead PID + const deadProc = Bun.spawn({ cmd: ["true"] }); + await deadProc.exited; // wait for the process to fully terminate + const deadPid = deadProc.pid; + writeFileSync(join(pidsDir, "thread2"), String(deadPid)); + expect(isProcessAlive(pidsDir, "thread2")).toBe(false); + }); + + test("invalid PID in file returns false", () => { + const pidsDir = freshTmpDir("pids-invalid"); + writeFileSync(join(pidsDir, "thread4"), "not-a-number"); + expect(isProcessAlive(pidsDir, "thread4")).toBe(false); + }); + + test("negative PID in file returns false", () => { + const pidsDir = freshTmpDir("pids-negative"); + writeFileSync(join(pidsDir, "thread5"), "-1"); + expect(isProcessAlive(pidsDir, "thread5")).toBe(false); + }); + + test("zero PID in file returns false", () => { + const pidsDir = freshTmpDir("pids-zero"); + writeFileSync(join(pidsDir, "thread6"), "0"); + expect(isProcessAlive(pidsDir, "thread6")).toBe(false); + }); +}); diff --git a/src/commands/shared.ts b/src/commands/shared.ts new file mode 100644 index 0000000..484968d --- /dev/null +++ b/src/commands/shared.ts @@ -0,0 +1,808 @@ +// src/commands/shared.ts — Shared utilities for CLI command modules + +import { + config, + resolveStateDir, + resolveModel, + validateId, + type ReasoningEffort, + type SandboxMode, + type ApprovalPolicy, +} from "../config"; +import { type AppServerClient } from "../client"; +import { ensureConnection, getCurrentSessionId } from "../broker"; +import { + legacyRegisterThread as registerThread, + legacyResolveThreadId as resolveThreadId, + legacyFindShortId as findShortId, + legacyUpdateThreadMeta as updateThreadMeta, + legacyRemoveThread as removeThread, + updateThreadStatus, + loadThreadMapping, + saveThreadMapping, + withThreadLock, + generateRunId, + createRun, + updateRun, + pruneRuns, + migrateGlobalState, +} from "../threads"; +import { EventDispatcher } from "../events"; +import { + autoApproveHandler, + InteractiveApprovalHandler, + type ApprovalHandler, +} from "../approvals"; +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + unlinkSync, + statSync, +} from "fs"; +import { resolve, join, dirname } from "path"; +import type { + ThreadStartResponse, + Model, + TurnResult, + RunRecord, +} from "../types"; + +// --------------------------------------------------------------------------- +// Per-workspace paths +// --------------------------------------------------------------------------- + +export interface WorkspacePaths { + stateDir: string; + threadsFile: string; + logsDir: string; + approvalsDir: string; + killSignalsDir: string; + pidsDir: string; + runsDir: string; +} + +export function getWorkspacePaths(cwd: string): WorkspacePaths { + const stateDir = resolveStateDir(cwd); + const paths = { + stateDir, + threadsFile: join(stateDir, "threads.json"), + logsDir: join(stateDir, "logs"), + approvalsDir: join(stateDir, "approvals"), + killSignalsDir: join(stateDir, "kill-signals"), + pidsDir: join(stateDir, "pids"), + runsDir: join(stateDir, "runs"), + }; + // Lazily ensure workspace directories exist on first access. + for (const dir of [paths.logsDir, paths.approvalsDir, paths.killSignalsDir, paths.pidsDir, paths.runsDir]) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + // Ensure global data dir exists for config.json + mkdirSync(config.dataDir, { recursive: true, mode: 0o700 }); + // Migrate legacy global state to per-workspace layout (idempotent) + migrateGlobalState(cwd); + return paths; +} + +// --------------------------------------------------------------------------- +// Options interface and argument parsing +// --------------------------------------------------------------------------- + +export interface Options { + reasoning: ReasoningEffort | undefined; + model: string | undefined; + sandbox: SandboxMode; + approval: ApprovalPolicy; + dir: string; + contentOnly: boolean; + json: boolean; + timeout: number; + limit: number; + reviewMode: string | null; + reviewRef: string | null; + base: string; + resumeId: string | null; + discover: boolean; + help: boolean; + template: string | null; + /** Flags explicitly provided on the command line (forwarded on resume). */ + explicit: Set; + /** Flags set by user config file (suppress auto-detection but NOT forwarded on resume). */ + configured: Set; +} + +/** Valid review modes for --mode flag. */ +export const VALID_REVIEW_MODES = ["pr", "uncommitted", "commit", "custom"] as const; + +/** Shell metacharacters that must not appear in git refs. */ +const UNSAFE_REF_CHARS = /[;|&`$()<>\\'"{\s]/; + +export function die(msg: string): never { + console.error(`Error: ${msg}`); + process.exit(1); +} + +export function validateGitRef(value: string, label: string): string { + if (UNSAFE_REF_CHARS.test(value)) die(`Invalid ${label}: ${value}`); + return value; +} + +/** Validate ID, using die() for CLI-friendly error output. */ +export function validateIdOrDie(id: string): string { + try { + return validateId(id); + } catch { + die(`Invalid ID: "${id}"`); + } +} + +export function progress(text: string): void { + console.log(`[codex] ${text}`); +} + +export function defaultOptions(): Options { + return { + reasoning: undefined, + model: undefined, + sandbox: config.defaultSandbox, + approval: config.defaultApprovalPolicy, + dir: process.cwd(), + contentOnly: false, + json: false, + timeout: config.defaultTimeout, + limit: config.threadsListLimit, + reviewMode: null, + reviewRef: null, + base: "main", + resumeId: null, + discover: false, + help: false, + template: null, + explicit: new Set(), + configured: new Set(), + }; +} + +export function parseOptions(args: string[]): { positional: string[]; options: Options } { + const options = defaultOptions(); + const positional: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === "-h" || arg === "--help") { + options.help = true; + } else if (arg === "-r" || arg === "--reasoning") { + if (i + 1 >= args.length) { + console.error("Error: --reasoning requires a value"); + process.exit(1); + } + const level = args[++i] as ReasoningEffort; + if (!config.reasoningEfforts.includes(level)) { + console.error(`Error: Invalid reasoning level: ${level}`); + console.error( + `Valid options: ${config.reasoningEfforts.join(", ")}` + ); + process.exit(1); + } + options.reasoning = level; + options.explicit.add("reasoning"); + } else if (arg === "-m" || arg === "--model") { + if (i + 1 >= args.length) { + console.error("Error: --model requires a value"); + process.exit(1); + } + const model = args[++i]; + if (/[^a-zA-Z0-9._\-\/:]/.test(model)) { + console.error(`Error: Invalid model name: ${model}`); + process.exit(1); + } + options.model = model; + options.explicit.add("model"); + } else if (arg === "-s" || arg === "--sandbox") { + if (i + 1 >= args.length) { + console.error("Error: --sandbox requires a value"); + process.exit(1); + } + const mode = args[++i] as SandboxMode; + if (!config.sandboxModes.includes(mode)) { + console.error(`Error: Invalid sandbox mode: ${mode}`); + console.error( + `Valid options: ${config.sandboxModes.join(", ")}` + ); + process.exit(1); + } + options.sandbox = mode; + options.explicit.add("sandbox"); + } else if (arg === "--approval") { + if (i + 1 >= args.length) { + console.error("Error: --approval requires a value"); + process.exit(1); + } + const policy = args[++i] as ApprovalPolicy; + if (!config.approvalPolicies.includes(policy)) { + console.error(`Error: Invalid approval policy: ${policy}`); + console.error( + `Valid options: ${config.approvalPolicies.join(", ")}` + ); + process.exit(1); + } + options.approval = policy; + options.explicit.add("approval"); + } else if (arg === "-d" || arg === "--dir") { + if (i + 1 >= args.length) { + console.error("Error: --dir requires a value"); + process.exit(1); + } + options.dir = resolve(args[++i]); + options.explicit.add("dir"); + } else if (arg === "--content-only") { + options.contentOnly = true; + } else if (arg === "--json") { + options.json = true; + } else if (arg === "--timeout") { + if (i + 1 >= args.length) { + console.error("Error: --timeout requires a value"); + process.exit(1); + } + const val = Number(args[++i]); + if (!Number.isFinite(val) || val <= 0) { + console.error(`Error: Invalid timeout: ${args[i]}`); + process.exit(1); + } + options.timeout = val; + options.explicit.add("timeout"); + } else if (arg === "--limit") { + if (i + 1 >= args.length) { + console.error("Error: --limit requires a value"); + process.exit(1); + } + const val = Number(args[++i]); + if (!Number.isFinite(val) || val < 1) { + console.error(`Error: Invalid limit: ${args[i]}`); + process.exit(1); + } + options.limit = Math.floor(val); + } else if (arg === "--mode") { + if (i + 1 >= args.length) { + console.error("Error: --mode requires a value"); + process.exit(1); + } + const mode = args[++i]; + if (!VALID_REVIEW_MODES.includes(mode as any)) { + console.error(`Error: Invalid review mode: ${mode}`); + console.error(`Valid options: ${VALID_REVIEW_MODES.join(", ")}`); + process.exit(1); + } + options.reviewMode = mode; + } else if (arg === "--ref") { + if (i + 1 >= args.length) { + console.error("Error: --ref requires a value"); + process.exit(1); + } + options.reviewRef = validateGitRef(args[++i], "ref"); + } else if (arg === "--base") { + if (i + 1 >= args.length) { + console.error("Error: --base requires a value"); + process.exit(1); + } + options.base = validateGitRef(args[++i], "base branch"); + options.explicit.add("base"); + } else if (arg === "--resume") { + if (i + 1 >= args.length) { + console.error("Error: --resume requires a value"); + process.exit(1); + } + options.resumeId = args[++i]; + } else if (arg === "--all") { + options.limit = Infinity; + } else if (arg === "--discover") { + options.discover = true; + } else if (arg === "--template") { + if (i + 1 >= args.length) { + console.error("Error: --template requires a name"); + process.exit(1); + } + options.template = args[++i]; + } else if (arg === "--unset") { + options.explicit.add("unset"); + } else if (arg.startsWith("-")) { + console.error(`Error: Unknown option: ${arg}`); + console.error("Run codex-collab --help for usage"); + process.exit(1); + } else { + positional.push(arg); + } + } + + // Resolve model aliases (e.g., "spark" → "gpt-5.3-codex-spark") + options.model = resolveModel(options.model); + + return { positional, options }; +} + +// --------------------------------------------------------------------------- +// User config — persistent defaults from ~/.codex-collab/config.json +// --------------------------------------------------------------------------- + +/** Fields users can set in ~/.codex-collab/config.json. */ +export interface UserConfig { + model?: string; + reasoning?: ReasoningEffort; + sandbox?: SandboxMode; + approval?: ApprovalPolicy; + timeout?: number; +} + +export function loadUserConfig(): UserConfig { + try { + const parsed = JSON.parse(readFileSync(config.configFile, "utf-8")); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + console.error(`[codex] Warning: config file is not a JSON object — ignoring: ${config.configFile}`); + return {}; + } + return parsed as UserConfig; + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "ENOENT") return {}; + if (e instanceof SyntaxError) { + console.error(`[codex] Warning: invalid JSON in ${config.configFile} — ignoring config`); + } else { + console.error(`[codex] Warning: could not read config: ${e instanceof Error ? e.message : String(e)}`); + } + return {}; + } +} + +export function saveUserConfig(cfg: UserConfig): void { + try { + mkdirSync(dirname(config.configFile), { recursive: true, mode: 0o700 }); + writeFileSync(config.configFile, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 }); + } catch (e) { + die(`Could not save config to ${config.configFile}: ${e instanceof Error ? e.message : String(e)}`); + } +} + +/** Apply user config to parsed options — only for fields not set via CLI flags. + * Config values are added to `configured` (not `explicit`) so they suppress + * auto-detection but are NOT forwarded as overrides on thread resume. */ +export function applyUserConfig(options: Options): void { + const cfg = loadUserConfig(); + + if (!options.explicit.has("model") && typeof cfg.model === "string") { + if (/[^a-zA-Z0-9._\-\/:]/.test(cfg.model)) { + console.error(`[codex] Warning: ignoring invalid model in config: ${cfg.model}`); + } else { + options.model = resolveModel(cfg.model); + options.configured.add("model"); + } + } + if (!options.explicit.has("reasoning") && typeof cfg.reasoning === "string") { + if (cfg.reasoning && config.reasoningEfforts.includes(cfg.reasoning)) { + options.reasoning = cfg.reasoning; + options.configured.add("reasoning"); + } else { + console.error(`[codex] Warning: ignoring invalid reasoning in config: ${cfg.reasoning}`); + } + } + if (!options.explicit.has("sandbox") && typeof cfg.sandbox === "string") { + if (cfg.sandbox && config.sandboxModes.includes(cfg.sandbox)) { + options.sandbox = cfg.sandbox; + options.configured.add("sandbox"); + } else { + console.error(`[codex] Warning: ignoring invalid sandbox in config: ${cfg.sandbox}`); + } + } + if (!options.explicit.has("approval") && typeof cfg.approval === "string") { + if (cfg.approval && config.approvalPolicies.includes(cfg.approval)) { + options.approval = cfg.approval; + options.configured.add("approval"); + } else { + console.error(`[codex] Warning: ignoring invalid approval in config: ${cfg.approval}`); + } + } + if (!options.explicit.has("timeout") && cfg.timeout !== undefined) { + if (typeof cfg.timeout === "number" && Number.isFinite(cfg.timeout) && cfg.timeout > 0) { + options.timeout = cfg.timeout; + } else { + console.error(`[codex] Warning: ignoring invalid timeout in config: ${cfg.timeout}`); + } + } +} + +// --------------------------------------------------------------------------- +// Client lifecycle helpers +// --------------------------------------------------------------------------- + +/** Active client/thread tracking for signal handlers. */ +export let activeClient: AppServerClient | undefined; +export let activeThreadId: string | undefined; +export let activeShortId: string | undefined; +export let activeTurnId: string | undefined; +export let activeWsPaths: WorkspacePaths | undefined; +export let activeRunId: string | undefined; +export let shuttingDown = false; + +export function setActiveClient(client: AppServerClient | undefined): void { activeClient = client; } +export function setActiveThreadId(id: string | undefined): void { activeThreadId = id; } +export function setActiveShortId(id: string | undefined): void { activeShortId = id; } +export function setActiveTurnId(id: string | undefined): void { activeTurnId = id; } +export function setActiveWsPaths(ws: WorkspacePaths | undefined): void { activeWsPaths = ws; } +export function setActiveRunId(id: string | undefined): void { activeRunId = id; } +export function setShuttingDown(val: boolean): void { shuttingDown = val; } + +export function getApprovalHandler(policy: ApprovalPolicy, approvalsDir: string): ApprovalHandler { + if (policy === "never") return autoApproveHandler; + return new InteractiveApprovalHandler(approvalsDir, progress); +} + +/** Connect to app server, run fn, then close the client (even on error). */ +export async function withClient(fn: (client: AppServerClient) => Promise, cwd?: string, streaming = false): Promise { + const client = await ensureConnection(cwd ?? process.cwd(), streaming); + activeClient = client; + try { + return await fn(client); + } finally { + try { + await client.close(); + } catch (e) { + console.error(`[codex] Warning: cleanup failed: ${e instanceof Error ? e.message : String(e)}`); + } + activeClient = undefined; + } +} + +export function createDispatcher(shortId: string, logsDir: string, opts: Options): EventDispatcher { + return new EventDispatcher( + shortId, + logsDir, + opts.contentOnly ? () => {} : progress, + ); +} + +// --------------------------------------------------------------------------- +// Model auto-selection +// --------------------------------------------------------------------------- + +/** Fetch all pages of a paginated endpoint. */ +export async function fetchAllPages( + client: AppServerClient, + method: string, + baseParams?: Record, +): Promise { + const items: T[] = []; + let cursor: string | undefined; + do { + const params: Record = { ...baseParams }; + if (cursor) params.cursor = cursor; + const page = await client.request<{ data: T[]; nextCursor: string | null }>(method, params); + items.push(...page.data); + cursor = page.nextCursor ?? undefined; + } while (cursor); + return items; +} + +/** Pick the best model by following the upgrade chain from the server default, + * then preferring a -codex variant if one exists at the latest generation. */ +export function pickBestModel(models: Model[]): string | undefined { + const byId = new Map(models.map(m => [m.id, m])); + + // Start from the server's default model + let current = models.find(m => m.isDefault); + if (!current) return undefined; + + // Follow the upgrade chain to the latest generation + const visited = new Set(); + while (current.upgrade && !visited.has(current.id)) { + visited.add(current.id); + const next = byId.get(current.upgrade); + if (!next) break; // upgrade target not in the list + current = next; + } + + // Prefer -codex variant if available at this generation + if (!current.id.endsWith("-codex")) { + const codexVariant = byId.get(current.id + "-codex"); + if (codexVariant && codexVariant.upgrade === null) return codexVariant.id; + } + + return current.id; +} + +/** Pick the highest reasoning effort a model supports. */ +function pickHighestEffort(supported: Array<{ reasoningEffort: string }>): ReasoningEffort | undefined { + const available = new Set(supported.map(s => s.reasoningEffort)); + for (let i = config.reasoningEfforts.length - 1; i >= 0; i--) { + if (available.has(config.reasoningEfforts[i])) return config.reasoningEfforts[i]; + } + return undefined; +} + +/** Auto-resolve model and/or reasoning effort when not set by CLI or config. */ +export async function resolveDefaults(client: AppServerClient, opts: Options): Promise { + const isSet = (key: string) => opts.explicit.has(key) || opts.configured.has(key); + const needModel = !isSet("model"); + const needReasoning = !isSet("reasoning"); + if (!needModel && !needReasoning) return; + + let models: Model[]; + try { + models = await fetchAllPages(client, "model/list", { includeHidden: true }); + } catch (e) { + console.error(`[codex] Warning: could not fetch model list (${e instanceof Error ? e.message : String(e)}). Model and reasoning will be determined by the server.`); + return; + } + if (models.length === 0) { + console.error(`[codex] Warning: server returned no models. Model and reasoning will be determined by the server.`); + return; + } + + if (needModel) { + opts.model = pickBestModel(models); + } + + if (needReasoning) { + const modelData = models.find(m => m.id === opts.model); + if (modelData?.supportedReasoningEfforts?.length) { + opts.reasoning = pickHighestEffort(modelData.supportedReasoningEfforts); + } + } +} + +// --------------------------------------------------------------------------- +// Thread start/resume +// --------------------------------------------------------------------------- + +/** Start or resume a thread, returning threadId, shortId, runId, and effective config. */ +export async function startOrResumeThread( + client: AppServerClient, + opts: Options, + ws: WorkspacePaths, + extraStartParams?: Record, + preview?: string, + isReview = false, +): Promise<{ threadId: string; shortId: string; runId: string; effective: ThreadStartResponse }> { + let threadId: string; + let shortId: string; + let effective: ThreadStartResponse; + let isNewThread = false; + + if (opts.resumeId) { + // Try local resolution first; if not found, treat the ID as a full thread ID + // and pass it directly to the server (handles TUI-created threads not yet discovered) + try { + threadId = resolveThreadId(ws.threadsFile, opts.resumeId); + } catch (e) { + if (e instanceof Error && e.message.includes("Ambiguous")) { + throw e; // Let user see the ambiguity error + } + // Thread not found locally — treat as raw server thread ID + validateId(opts.resumeId); + threadId = opts.resumeId; + } + shortId = findShortId(ws.threadsFile, threadId) ?? opts.resumeId; + const resumeParams: Record = { + threadId, + persistExtendedHistory: false, + }; + // Only forward flags that were explicitly provided on the command line + if (opts.explicit.has("model")) resumeParams.model = opts.model; + if (opts.explicit.has("dir")) resumeParams.cwd = opts.dir; + if (opts.explicit.has("approval")) resumeParams.approvalPolicy = opts.approval; + if (opts.explicit.has("sandbox")) resumeParams.sandbox = opts.sandbox; + // Forced overrides from caller (e.g., review forces sandbox to read-only) + if (extraStartParams) Object.assign(resumeParams, extraStartParams); + effective = await client.request("thread/resume", resumeParams); + // Ensure the thread is in our local index (may not be if it was created externally) + if (!findShortId(ws.threadsFile, threadId)) { + registerThread(ws.threadsFile, threadId, { + model: effective.model, + cwd: opts.dir, + preview, + }); + shortId = findShortId(ws.threadsFile, threadId) ?? shortId; + } else { + // Refresh stored metadata so `threads` stays accurate after resume + updateThreadMeta(ws.threadsFile, threadId, { + model: effective.model, + ...(opts.explicit.has("dir") ? { cwd: opts.dir } : {}), + ...(preview ? { preview } : {}), + }); + } + } else { + const startParams: Record = { + cwd: opts.dir, + approvalPolicy: opts.approval, + sandbox: opts.sandbox, + experimentalRawEvents: false, + persistExtendedHistory: false, + ephemeral: isReview, + serviceName: config.serviceName, + ...extraStartParams, + }; + if (opts.model) startParams.model = opts.model; + effective = await client.request( + "thread/start", + startParams, + ); + threadId = effective.thread.id; + registerThread(ws.threadsFile, threadId, { + model: effective.model, + cwd: opts.dir, + preview, + }); + const resolvedShortId = findShortId(ws.threadsFile, threadId); + if (!resolvedShortId) die(`Internal error: thread ${threadId.slice(0, 12)}... registered but not found in mapping`); + shortId = resolvedShortId; + isNewThread = true; + } + + // Name new threads (non-fatal on failure) + // Name new non-ephemeral threads (reviews are ephemeral — naming would fail) + if (isNewThread && !isReview) { + const threadName = preview?.slice(0, 100) ?? "codex-collab task"; + try { + await client.request("thread/name/set", { threadId, name: threadName }); + } catch (e) { + console.error(`[codex] Warning: could not name thread: ${e instanceof Error ? e.message : String(e)}`); + } + } + + // Create run record (Gap 1 + Gap 5 + Gap 6) + const prompt = preview ?? null; + const runId = generateRunId(); + const sessionId = getCurrentSessionId(ws.stateDir); + const logPath = join(ws.logsDir, `${shortId}.log`); + const logOffset = existsSync(logPath) ? statSync(logPath).size : 0; + + createRun(ws.stateDir, { + runId, + threadId, + shortId, + kind: isReview ? "review" : "task", + phase: "starting", + status: "running", + sessionId, + logFile: `logs/${shortId}.log`, + logOffset, + prompt, + model: effective.model, + startedAt: new Date().toISOString(), + completedAt: null, + elapsed: null, + output: null, + filesChanged: null, + commandsRun: null, + error: null, + }); + pruneRuns(ws.stateDir); + + return { threadId, shortId, runId, effective }; +} + +// --------------------------------------------------------------------------- +// Turn overrides and result printing +// --------------------------------------------------------------------------- + +/** Per-turn parameter overrides: all values for new threads, explicit-only for resume. */ +export function turnOverrides(opts: Options) { + if (!opts.resumeId) { + const o: Record = { cwd: opts.dir, approvalPolicy: opts.approval }; + if (opts.model) o.model = opts.model; + if (opts.reasoning) o.effort = opts.reasoning; + return o; + } + const o: Record = {}; + if (opts.explicit.has("dir")) o.cwd = opts.dir; + if (opts.explicit.has("model")) o.model = opts.model; + if (opts.explicit.has("reasoning")) o.effort = opts.reasoning; + if (opts.explicit.has("approval")) o.approvalPolicy = opts.approval; + return o; +} + +export function formatDuration(ms: number): string { + const sec = Math.round(ms / 1000); + if (sec < 60) return `${sec}s`; + const min = Math.floor(sec / 60); + const rem = sec % 60; + return `${min}m ${rem}s`; +} + +export function formatAge(unixTimestamp: number): string { + const seconds = Math.round(Date.now() / 1000 - unixTimestamp); + if (seconds < 60) return `${seconds}s ago`; + if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.round(seconds / 3600)}h ago`; + return `${Math.round(seconds / 86400)}d ago`; +} + +export function pluralize(n: number, word: string): string { + return `${n} ${word}${n === 1 ? "" : "s"}`; +} + +/** Print turn result and return the appropriate exit code. */ +export function printResult( + result: TurnResult, + label: string, + contentOnly: boolean, +): number { + if (!contentOnly) { + progress(`${label} ${result.status} (${formatDuration(result.durationMs)}${result.filesChanged.length > 0 ? `, ${pluralize(result.filesChanged.length, "file")} changed` : ""})`); + if (result.output) console.log("\n--- Result ---"); + } + + if (result.output) console.log(result.output); + if (result.error) console.error(`\nError: ${result.error}`); + return result.status === "completed" ? 0 : 1; +} + +// --------------------------------------------------------------------------- +// PID file management +// --------------------------------------------------------------------------- + +/** Write a PID file for the current process so threads list can detect stale "running" status. */ +export function writePidFile(pidsDir: string, shortId: string): void { + try { + writeFileSync(join(pidsDir, shortId), String(process.pid), { mode: 0o600 }); + } catch (e) { + console.error(`[codex] Warning: could not write PID file: ${e instanceof Error ? e.message : String(e)}`); + } +} + +/** Remove the PID file for a thread. */ +export function removePidFile(pidsDir: string, shortId: string): void { + try { + unlinkSync(join(pidsDir, shortId)); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") { + console.error(`[codex] Warning: could not remove PID file: ${e instanceof Error ? e.message : String(e)}`); + } + } +} + +/** Check if the process that owns a thread is still alive. + * Returns true (assume alive) when the PID file is missing — the thread may + * have been started before PID tracking existed, or PID file write may have + * failed. Only returns false when we have a PID and can confirm the process + * is gone (ESRCH). */ +export function isProcessAlive(pidsDir: string, shortId: string): boolean { + const pidPath = join(pidsDir, shortId); + let pid: number; + try { + pid = Number(readFileSync(pidPath, "utf-8").trim()); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "ENOENT") return true; // no PID file -> assume alive + console.error(`[codex] Warning: could not read PID file for ${shortId}: ${e instanceof Error ? e.message : String(e)}`); + return true; + } + if (!Number.isFinite(pid) || pid <= 0) { + console.error(`[codex] Warning: PID file for ${shortId} contains invalid value`); + return false; + } + try { + process.kill(pid, 0); // signal 0 = existence check + return true; + } catch (e) { + const code = (e as NodeJS.ErrnoException).code; + if (code === "ESRCH") return false; // process confirmed dead + if (code === "EPERM") return true; // process exists but we can't signal it + // Unexpected error — assume alive to avoid incorrectly marking live threads as dead + console.error(`[codex] Warning: could not check process for ${shortId}: ${e instanceof Error ? e.message : String(e)}`); + return true; + } +} + +/** Try to archive a thread on the server. Returns status string. */ +export async function tryArchive(client: AppServerClient, threadId: string): Promise<"archived" | "already_done" | "failed"> { + try { + await client.request("thread/archive", { threadId }); + return "archived"; + } catch (e) { + if (e instanceof Error && (e.message.includes("not found") || e.message.includes("archived"))) { + return "already_done"; + } + console.error(`[codex] Warning: could not archive thread: ${e instanceof Error ? e.message : String(e)}`); + return "failed"; + } +} + diff --git a/src/commands/threads.ts b/src/commands/threads.ts new file mode 100644 index 0000000..fc61bf7 --- /dev/null +++ b/src/commands/threads.ts @@ -0,0 +1,427 @@ +// src/commands/threads.ts — threads, output, progress, delete, clean commands + +import { validateId } from "../config"; +import { + legacyRegisterThread as registerThread, + legacyResolveThreadId as resolveThreadId, + legacyFindShortId as findShortId, + legacyRemoveThread as removeThread, + loadThreadMapping, + saveThreadMapping, + updateThreadStatus, + withThreadLock, + getResumeCandidate, +} from "../threads"; +import { resolveStateDir, resolveWorkspaceDir } from "../config"; +import { getCurrentSessionId } from "../broker"; +import type { AppServerClient } from "../client"; +import type { Thread } from "../types"; +import { + existsSync, + readFileSync, + readdirSync, + unlinkSync, + writeFileSync, +} from "fs"; +import { join } from "path"; +import { + die, + parseOptions, + validateIdOrDie, + progress, + formatAge, + isProcessAlive, + removePidFile, + withClient, + tryArchive, + getWorkspacePaths, + fetchAllPages, + type WorkspacePaths, +} from "./shared"; + +// --------------------------------------------------------------------------- +// Thread discovery from app-server +// --------------------------------------------------------------------------- + +/** + * Query the app server for threads matching the workspace cwd and register + * any that are not already in the local index. Returns the number of newly + * discovered threads. + */ +/** User-facing source kinds for thread discovery. Excludes internal subagent + * sources which are implementation details of the Codex runtime. */ +const DISCOVERY_SOURCE_KINDS = ["cli", "vscode", "exec", "appServer"]; + +async function discoverThreads(client: AppServerClient, ws: WorkspacePaths, cwd: string): Promise { + const workspaceRoot = resolveWorkspaceDir(cwd); + const serverThreads = await fetchAllPages(client, "thread/list", { + cwd: workspaceRoot, + limit: 50, + sourceKinds: DISCOVERY_SOURCE_KINDS, + }); + if (serverThreads.length === 0) return 0; + + const mapping = loadThreadMapping(ws.threadsFile); + const knownThreadIds = new Set(Object.values(mapping).map(e => e.threadId)); + let discovered = 0; + + for (const thread of serverThreads) { + if (knownThreadIds.has(thread.id)) continue; + // Server timestamps are epoch seconds (not milliseconds) + const createdAt = thread.createdAt ? new Date(thread.createdAt * 1000).toISOString() : new Date().toISOString(); + const updatedAt = thread.updatedAt ? new Date(thread.updatedAt * 1000).toISOString() : createdAt; + registerThread(ws.threadsFile, thread.id, { + model: thread.modelProvider ?? undefined, + cwd: thread.cwd ?? cwd, + preview: thread.preview ?? thread.name ?? undefined, + createdAt, + updatedAt, + }); + discovered++; + } + + return discovered; +} + +// --------------------------------------------------------------------------- +// threads (list) +// --------------------------------------------------------------------------- + +export async function handleThreads(args: string[]): Promise { + const { options } = parseOptions(args); + const ws = getWorkspacePaths(options.dir); + + // If --discover, query the app-server and merge server-side threads + if (options.discover) { + try { + await withClient(async (client) => { + const count = await discoverThreads(client, ws, options.dir); + if (count > 0 && !options.json) { + progress(`Discovered ${count} thread(s) from server`); + } + }); + } catch (e) { + console.error(`[codex] Warning: thread discovery failed: ${e instanceof Error ? e.message : String(e)}`); + console.error("[codex] Showing local threads only."); + } + } + + const mapping = loadThreadMapping(ws.threadsFile); + + // Build entries sorted by updatedAt (most recent first), falling back to createdAt + let entries = Object.entries(mapping) + .map(([shortId, entry]) => ({ shortId, ...entry })) + .sort((a, b) => { + const ta = new Date(a.updatedAt ?? a.createdAt).getTime(); + const tb = new Date(b.updatedAt ?? b.createdAt).getTime(); + return tb - ta; + }); + + // Detect stale "running" status: if the owning process is dead, mark as interrupted. + for (const e of entries) { + if (e.lastStatus === "running" && !isProcessAlive(ws.pidsDir, e.shortId)) { + updateThreadStatus(ws.threadsFile, e.threadId, "interrupted"); + e.lastStatus = "interrupted"; + removePidFile(ws.pidsDir, e.shortId); + } + } + + if (options.limit !== Infinity) entries = entries.slice(0, options.limit); + + if (options.json) { + const enriched = entries.map(e => ({ + shortId: e.shortId, + threadId: e.threadId, + status: e.lastStatus ?? "unknown", + model: e.model ?? null, + cwd: e.cwd ?? null, + preview: e.preview ?? null, + createdAt: e.createdAt, + updatedAt: e.updatedAt ?? e.createdAt, + })); + console.log(JSON.stringify(enriched, null, 2)); + } else { + if (entries.length === 0) { + console.log("No threads found."); + return; + } + for (const e of entries) { + const status = e.lastStatus ?? "idle"; + const ts = new Date(e.updatedAt ?? e.createdAt).getTime() / 1000; + const age = formatAge(ts); + const model = e.model ? ` (${e.model})` : ""; + const preview = e.preview ? ` ${e.preview.slice(0, 50)}` : ""; + console.log( + ` ${e.shortId} ${status.padEnd(12)} ${age.padEnd(8)} ${e.cwd ?? ""}${model}${preview}`, + ); + } + } +} + +// --------------------------------------------------------------------------- +// output +// --------------------------------------------------------------------------- + +/** Resolve a positional ID arg to a log file path, or die with an error. */ +function resolveLogPath(positional: string[], usage: string, ws: ReturnType): string { + const id = positional[0]; + if (!id) die(usage); + validateIdOrDie(id); + const threadId = resolveThreadId(ws.threadsFile, id); + const shortId = findShortId(ws.threadsFile, threadId); + if (!shortId) die(`Thread not found: ${id}`); + return join(ws.logsDir, `${shortId}.log`); +} + +export async function handleOutput(args: string[]): Promise { + const { positional, options } = parseOptions(args); + const ws = getWorkspacePaths(options.dir); + const logPath = resolveLogPath(positional, "Usage: codex-collab output ", ws); + if (!existsSync(logPath)) die(`No log file for thread`); + const content = readFileSync(logPath, "utf-8"); + if (options.contentOnly) { + // Extract agent output blocks from the log. + // Log format: " agent output:\n\n<>" + // Using an explicit end marker avoids false positives when model output contains timestamps. + const tsPrefix = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z /; + const lines = content.split("\n"); + let inAgentOutput = false; + for (const line of lines) { + if (line === "<>") { + inAgentOutput = false; + continue; + } + if (tsPrefix.test(line)) { + inAgentOutput = line.includes(" agent output:"); + continue; + } + if (inAgentOutput) { + console.log(line); + } + } + } else { + console.log(content); + } +} + +// --------------------------------------------------------------------------- +// progress +// --------------------------------------------------------------------------- + +export async function handleProgress(args: string[]): Promise { + const { positional, options } = parseOptions(args); + const ws = getWorkspacePaths(options.dir); + const logPath = resolveLogPath(positional, "Usage: codex-collab progress ", ws); + if (!existsSync(logPath)) { + console.log("No activity yet."); + return; + } + + // Show last 20 lines + const lines = readFileSync(logPath, "utf-8").trim().split("\n"); + console.log(lines.slice(-20).join("\n")); +} + +// --------------------------------------------------------------------------- +// delete +// --------------------------------------------------------------------------- + +export async function handleDelete(args: string[]): Promise { + const { positional, options } = parseOptions(args); + const ws = getWorkspacePaths(options.dir); + const id = positional[0]; + if (!id) die("Usage: codex-collab delete "); + validateIdOrDie(id); + + const threadId = resolveThreadId(ws.threadsFile, id); + const shortId = findShortId(ws.threadsFile, threadId); + + // If the thread is currently running, stop it first before archiving + const localStatus = shortId ? loadThreadMapping(ws.threadsFile)[shortId]?.lastStatus : undefined; + if (localStatus === "running") { + const signalPath = join(ws.killSignalsDir, threadId); + try { + writeFileSync(signalPath, "", { mode: 0o600 }); + } catch (e) { + console.error( + `[codex] Warning: could not write kill signal: ${e instanceof Error ? e.message : String(e)}. ` + + `The running process may not detect the delete.`, + ); + } + } + + let archiveResult: "archived" | "already_done" | "failed" = "failed"; + try { + archiveResult = await withClient(async (client) => { + // Interrupt active turn before archiving (only if running) + if (localStatus === "running") { + try { + const { thread } = await client.request<{ + thread: { + id: string; + status: { type: string }; + turns: Array<{ id: string; status: string }>; + }; + }>("thread/read", { threadId, includeTurns: true }); + + if (thread.status.type === "active") { + const activeTurn = thread.turns?.find( + (t) => t.status === "inProgress", + ); + if (activeTurn) { + await client.request("turn/interrupt", { + threadId, + turnId: activeTurn.id, + }); + } + } + } catch (e) { + if (e instanceof Error && !e.message.includes("not found") && !e.message.includes("archived")) { + console.error(`[codex] Warning: could not read/interrupt thread during delete: ${e.message}`); + } + } + } + + return tryArchive(client, threadId); + }); + } catch (e) { + if (e instanceof Error && !e.message.includes("not found")) { + console.error(`[codex] Warning: could not archive on server: ${e.message}`); + } + } + + if (shortId) { + removePidFile(ws.pidsDir, shortId); + const logPath = join(ws.logsDir, `${shortId}.log`); + if (existsSync(logPath)) unlinkSync(logPath); + removeThread(ws.threadsFile, shortId); + } + + if (archiveResult === "failed") { + progress(`Deleted local data for thread ${id} (server archive failed)`); + } else { + progress(`Deleted thread ${id}`); + } +} + +// --------------------------------------------------------------------------- +// clean +// --------------------------------------------------------------------------- + +/** Delete files older than maxAgeMs in the given directory. Returns count deleted. */ +function deleteOldFiles(dir: string, maxAgeMs: number): number { + if (!existsSync(dir)) return 0; + const now = Date.now(); + let deleted = 0; + for (const file of readdirSync(dir)) { + const path = join(dir, file); + try { + if (now - Bun.file(path).lastModified > maxAgeMs) { + unlinkSync(path); + deleted++; + } + } catch (e) { + if (e instanceof Error && (e as NodeJS.ErrnoException).code !== "ENOENT") { + console.error(`[codex] Warning: could not delete ${path}: ${e.message}`); + } + } + } + return deleted; +} + +export async function handleClean(args: string[]): Promise { + const { options } = parseOptions(args); + const ws = getWorkspacePaths(options.dir); + const sevenDaysMs = 7 * 24 * 60 * 60 * 1000; + const oneDayMs = 24 * 60 * 60 * 1000; + + const logsDeleted = deleteOldFiles(ws.logsDir, sevenDaysMs); + const approvalsDeleted = deleteOldFiles(ws.approvalsDir, oneDayMs); + const killSignalsDeleted = deleteOldFiles(ws.killSignalsDir, oneDayMs); + const pidsDeleted = deleteOldFiles(ws.pidsDir, oneDayMs); + + // Clean stale thread mappings — use log file mtime as proxy for last + // activity so recently-used threads aren't pruned just because they + // were created more than 7 days ago. + let mappingsRemoved = 0; + withThreadLock(ws.threadsFile, () => { + const mapping = loadThreadMapping(ws.threadsFile); + const now = Date.now(); + for (const [shortId, entry] of Object.entries(mapping)) { + try { + let lastActivity = new Date(entry.createdAt).getTime(); + if (Number.isNaN(lastActivity)) lastActivity = 0; + const logPath = join(ws.logsDir, `${shortId}.log`); + if (existsSync(logPath)) { + lastActivity = Math.max(lastActivity, Bun.file(logPath).lastModified); + } + if (now - lastActivity > sevenDaysMs) { + delete mapping[shortId]; + mappingsRemoved++; + } + } catch (e) { + console.error(`[codex] Warning: skipping mapping ${shortId}: ${e instanceof Error ? e.message : e}`); + } + } + if (mappingsRemoved > 0) { + saveThreadMapping(ws.threadsFile, mapping); + } + }); + + const parts: string[] = []; + if (logsDeleted > 0) parts.push(`${logsDeleted} log files deleted`); + if (approvalsDeleted > 0) + parts.push(`${approvalsDeleted} approval files deleted`); + if (killSignalsDeleted > 0) + parts.push(`${killSignalsDeleted} kill signal files deleted`); + if (pidsDeleted > 0) + parts.push(`${pidsDeleted} stale PID files deleted`); + if (mappingsRemoved > 0) + parts.push(`${mappingsRemoved} stale mappings removed`); + + if (parts.length === 0) { + console.log("Nothing to clean."); + } else { + console.log(`Cleaned: ${parts.join(", ")}.`); + } +} + +// --------------------------------------------------------------------------- +// resume-candidate +// --------------------------------------------------------------------------- + +export async function handleResumeCandidate(args: string[]): Promise { + const { options } = parseOptions(args); + const jsonFlag = options.json; + const cwd = options.dir; + const stateDir = resolveStateDir(cwd); + const ws = getWorkspacePaths(cwd); + const sessionId = getCurrentSessionId(stateDir); + + // Check local first + let candidate = getResumeCandidate(stateDir, sessionId); + + // If no local candidate, attempt server discovery + if (!candidate.available) { + try { + await withClient(async (client) => { + const count = await discoverThreads(client, ws, cwd); + if (count > 0) { + candidate = getResumeCandidate(stateDir, sessionId); + } + }); + } catch (e) { + console.error(`[codex] Warning: server discovery failed: ${e instanceof Error ? e.message : String(e)}`); + } + } + + if (jsonFlag) { + console.log(JSON.stringify(candidate, null, 2)); + } else if (candidate.available) { + console.log(`Resumable thread: ${candidate.shortId} (${candidate.name ?? "unnamed"})`); + console.log(` Thread ID: ${candidate.threadId}`); + console.log(` Use: codex-collab run --resume ${candidate.shortId} "prompt"`); + } else { + console.log("No resumable thread found."); + } +} diff --git a/src/config.test.ts b/src/config.test.ts index 1cd36e5..3ae22ef 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,20 +1,297 @@ -import { describe, expect, test } from "bun:test"; -import { config } from "./config"; +import { describe, expect, test, beforeAll, afterAll } from "bun:test"; +import { mkdirSync, writeFileSync, rmSync, realpathSync } from "fs"; +import { join, basename, resolve, sep } from "path"; +import { createHash } from "crypto"; +import { + config, + validateId, + resolveWorkspaceDir, + resolveStateDir, + resolveModel, + validateEffort, + loadTemplate, + loadTemplateWithMeta, + interpolateTemplate, + parseTemplateFrontmatter, + listTemplates, +} from "./config"; -describe("config", () => { - test("has app server paths", () => { +// ─── config object ────────────────────────────────────────────────────────── + +describe("config object", () => { + test("has data paths under .codex-collab", () => { + expect(config.dataDir).toContain(".codex-collab"); + expect(config.configFile).toContain("config.json"); + }); + + test("deprecated paths still work", () => { expect(config.threadsFile).toContain("threads.json"); expect(config.logsDir).toContain("logs"); expect(config.approvalsDir).toContain("approvals"); + expect(config.killSignalsDir).toContain("kill-signals"); + expect(config.pidsDir).toContain("pids"); }); - test("does not reference tmux", () => { - const json = JSON.stringify(config); - expect(json).not.toContain("tmux"); - }); - - test("has protocol timeout", () => { + test("has protocol timeouts", () => { expect(config.requestTimeout).toBeGreaterThan(0); expect(config.defaultTimeout).toBeGreaterThan(0); }); + + test("has threadsListLimit", () => { + expect(config.threadsListLimit).toBe(20); + }); + + test("has new fields", () => { + expect(config.defaultBrokerIdleTimeout).toBe(30 * 60 * 1000); + expect(config.maxRunsPerWorkspace).toBe(50); + expect(config.serviceName).toBe("codex-collab"); + }); + + test("has reasoning efforts including none and minimal", () => { + expect(config.reasoningEfforts).toContain("none"); + expect(config.reasoningEfforts).toContain("minimal"); + expect(config.reasoningEfforts).toContain("low"); + expect(config.reasoningEfforts).toContain("medium"); + expect(config.reasoningEfforts).toContain("high"); + expect(config.reasoningEfforts).toContain("xhigh"); + }); + + test("is frozen", () => { + expect(Object.isFrozen(config)).toBe(true); + }); +}); + +// ─── validateId ───────────────────────────────────────────────────────────── + +describe("validateId", () => { + test("accepts valid IDs", () => { + expect(validateId("abc-123_XYZ")).toBe("abc-123_XYZ"); + }); + + test("rejects invalid IDs", () => { + expect(() => validateId("has spaces")).toThrow("Invalid ID"); + expect(() => validateId("../escape")).toThrow("Invalid ID"); + }); +}); + +// ─── resolveWorkspaceDir ──────────────────────────────────────────────────── + +describe("resolveWorkspaceDir", () => { + test("returns git repo root for cwd inside a git repo", () => { + const result = resolveWorkspaceDir(process.cwd()); + // This test repo is a git repo; the root should contain package.json + // On Windows, git returns forward-slash paths while process.cwd() uses backslashes + expect(resolve(result)).toBe(resolve(process.cwd())); + }); + + test("returns resolved cwd when not in a git repo", () => { + // Use a platform-appropriate temp directory that is not inside a git repo + const tmpDir = process.env.TMPDIR ?? (process.platform === "win32" ? process.env.TEMP ?? "C:\\Windows\\Temp" : "/tmp"); + const result = resolveWorkspaceDir(tmpDir); + expect(resolve(result)).toBe(resolve(realpathSync(tmpDir))); + }); +}); + +// ─── resolveStateDir ──────────────────────────────────────────────────────── + +describe("resolveStateDir", () => { + test("returns path under ~/.codex-collab/workspaces/", () => { + const result = resolveStateDir(process.cwd()); + expect(result).toContain(`.codex-collab${sep}workspaces${sep}`); + }); + + test("path contains slug and hash", () => { + const result = resolveStateDir(process.cwd()); + const wsRoot = resolveWorkspaceDir(process.cwd()); + const canonical = realpathSync(wsRoot); + const slug = basename(canonical).replace(/[^a-zA-Z0-9_-]/g, "_").toLowerCase(); + const hash = createHash("sha256").update(canonical).digest("hex").slice(0, 16); + expect(result).toContain(`${slug}-${hash}`); + }); + + test("different paths produce different state dirs", () => { + const dir1 = resolveStateDir(process.cwd()); + const tmpDir = process.env.TMPDIR ?? (process.platform === "win32" ? process.env.TEMP ?? "C:\\Windows\\Temp" : "/tmp"); + const dir2 = resolveStateDir(tmpDir); + expect(dir1).not.toBe(dir2); + }); +}); + +// ─── resolveModel ─────────────────────────────────────────────────────────── + +describe("resolveModel", () => { + test("resolves spark alias", () => { + expect(resolveModel("spark")).toBe("gpt-5.3-codex-spark"); + }); + + test("passes through unknown model names", () => { + expect(resolveModel("o4-mini")).toBe("o4-mini"); + expect(resolveModel("gpt-5")).toBe("gpt-5"); + }); + + test("returns undefined for undefined input", () => { + expect(resolveModel(undefined)).toBeUndefined(); + }); +}); + +// ─── validateEffort ───────────────────────────────────────────────────────── + +describe("validateEffort", () => { + test("accepts all valid effort levels", () => { + for (const level of ["none", "minimal", "low", "medium", "high", "xhigh"] as const) { + expect(validateEffort(level)).toBe(level); + } + }); + + test("throws on invalid effort", () => { + expect(() => validateEffort("max")).toThrow(); + expect(() => validateEffort("turbo")).toThrow(); + expect(() => validateEffort("")).toThrow(); + }); + + test("returns undefined for undefined input", () => { + expect(validateEffort(undefined)).toBeUndefined(); + }); +}); + +// ─── loadTemplate ─────────────────────────────────────────────────────────── + +describe("loadTemplate", () => { + const tmpDir = join(process.env.TMPDIR ?? "/tmp", "config-test-prompts"); + + beforeAll(() => { + mkdirSync(tmpDir, { recursive: true }); + writeFileSync(join(tmpDir, "greeting.md"), "Hello, {{NAME}}!"); + }); + + afterAll(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("loads a template file by name", () => { + const content = loadTemplate("greeting", tmpDir); + expect(content).toBe("Hello, {{NAME}}!"); + }); + + test("throws for missing template", () => { + expect(() => loadTemplate("nonexistent", tmpDir)).toThrow(); + }); + + test("rejects path traversal attempts", () => { + expect(() => loadTemplate("../escape")).toThrow("Invalid template name"); + expect(() => loadTemplate("sub/path")).toThrow("Invalid template name"); + expect(() => loadTemplate("..\\escape")).toThrow("Invalid template name"); + }); + + test("loads built-in plan-review template without override", () => { + const content = loadTemplate("plan-review"); + expect(content).toContain("{{PROMPT}}"); + expect(content).toContain("implementation plan"); + // Frontmatter should be stripped + expect(content).not.toContain("---"); + expect(content).not.toContain("sandbox:"); + }); + + test("strips frontmatter from template with override dir", () => { + writeFileSync(join(tmpDir, "with-fm.md"), "---\nname: test\ndescription: A test\n---\nBody here"); + const content = loadTemplate("with-fm", tmpDir); + expect(content).toBe("Body here"); + }); + + test("loadTemplateWithMeta returns both metadata and body", () => { + writeFileSync(join(tmpDir, "meta-test.md"), "---\nname: meta-test\ndescription: Test template\nsandbox: read-only\n---\nTemplate body {{PROMPT}}"); + const { meta, body } = loadTemplateWithMeta("meta-test", tmpDir); + expect(meta.name).toBe("meta-test"); + expect(meta.description).toBe("Test template"); + expect(meta.sandbox).toBe("read-only"); + expect(body).toBe("Template body {{PROMPT}}"); + }); + + test("throws helpful message for missing template without override", () => { + expect(() => loadTemplate("nonexistent-xyz")).toThrow("Template \"nonexistent-xyz\" not found"); + }); +}); + +// ─── parseTemplateFrontmatter ─────────────────────────────────────────────── + +describe("parseTemplateFrontmatter", () => { + test("extracts frontmatter fields", () => { + const raw = "---\nname: test\ndescription: A test template\nsandbox: read-only\n---\nBody content"; + const { meta, body } = parseTemplateFrontmatter(raw); + expect(meta.name).toBe("test"); + expect(meta.description).toBe("A test template"); + expect(meta.sandbox).toBe("read-only"); + expect(body).toBe("Body content"); + }); + + test("returns empty meta and full body when no frontmatter", () => { + const raw = "Just plain content\nNo frontmatter here"; + const { meta, body } = parseTemplateFrontmatter(raw); + expect(meta.name).toBe(""); + expect(meta.description).toBe(""); + expect(meta.sandbox).toBeUndefined(); + expect(body).toBe(raw); + }); + + test("handles missing closing delimiter", () => { + const raw = "---\nname: broken\nNo closing delimiter"; + const { body } = parseTemplateFrontmatter(raw); + expect(body).toBe(raw); + }); + + test("strips leading blank lines after frontmatter", () => { + const raw = "---\nname: test\n---\n\n\nBody"; + const { body } = parseTemplateFrontmatter(raw); + expect(body).toBe("Body"); + }); + + test("handles CRLF line endings", () => { + const raw = "---\r\nname: test\r\ndescription: CRLF template\r\nsandbox: read-only\r\n---\r\nBody with CRLF"; + const { meta, body } = parseTemplateFrontmatter(raw); + expect(meta.name).toBe("test"); + expect(meta.description).toBe("CRLF template"); + expect(meta.sandbox).toBe("read-only"); + expect(body).toBe("Body with CRLF"); + }); +}); + +// ─── listTemplates ────────────────────────────────────────────────────────── + +describe("listTemplates", () => { + test("includes built-in plan-review template", () => { + const templates = listTemplates(); + const planReview = templates.find(t => t.name === "plan-review"); + expect(planReview).toBeDefined(); + expect(planReview!.description).toContain("implementation plan"); + expect(planReview!.sandbox).toBe("read-only"); + }); +}); + +// ─── interpolateTemplate ──────────────────────────────────────────────────── + +describe("interpolateTemplate", () => { + test("replaces known variables", () => { + const result = interpolateTemplate("Hello, {{NAME}}! Welcome to {{PLACE}}.", { + NAME: "Alice", + PLACE: "Wonderland", + }); + expect(result).toBe("Hello, Alice! Welcome to Wonderland."); + }); + + test("leaves unknown variables as-is", () => { + const result = interpolateTemplate("{{KNOWN}} and {{UNKNOWN}}", { + KNOWN: "replaced", + }); + expect(result).toBe("replaced and {{UNKNOWN}}"); + }); + + test("handles empty vars", () => { + const result = interpolateTemplate("no vars here", {}); + expect(result).toBe("no vars here"); + }); + + test("replaces multiple occurrences of the same variable", () => { + const result = interpolateTemplate("{{X}} and {{X}}", { X: "y" }); + expect(result).toBe("y and y"); + }); }); diff --git a/src/config.ts b/src/config.ts index d500c0c..2a41f9e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,10 @@ // src/config.ts — Configuration for codex-collab import { homedir } from "os"; -import { join } from "path"; +import { join, basename, resolve } from "path"; +import { createHash } from "crypto"; +import { realpathSync, existsSync, readFileSync, readdirSync } from "fs"; +import { spawnSync } from "child_process"; import pkg from "../package.json"; function getHome(): string { @@ -10,9 +13,21 @@ function getHome(): string { return home; } +// ─── Model aliases ────────────────────────────────────────────────────────── + +const MODEL_ALIASES: Record = { + spark: "gpt-5.3-codex-spark", +}; + +// ─── Effort levels ────────────────────────────────────────────────────────── + +const VALID_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh"] as const; + +// ─── Config object ────────────────────────────────────────────────────────── + export const config = { // Reasoning effort levels - reasoningEfforts: ["low", "medium", "high", "xhigh"] as const, + reasoningEfforts: VALID_EFFORTS, // Sandbox modes sandboxModes: ["read-only", "workspace-write", "danger-full-access"] as const, @@ -25,19 +40,33 @@ export const config = { // Timeouts defaultTimeout: 1200, // seconds — turn completion (20 min) requestTimeout: 30_000, // milliseconds — individual protocol requests (30s) + defaultBrokerIdleTimeout: 30 * 60 * 1000, // 30 min in ms + + // Limits + maxRunsPerWorkspace: 50, + + // Service identity + serviceName: "codex-collab" as const, // Data paths — lazy via getters so the home directory is validated at point of use, not import time. - // Validated by ensureDataDirs() in cli.ts before any file operations. + // Lazily created by getWorkspacePaths() on first access. get dataDir() { return join(getHome(), ".codex-collab"); }, + + /** @deprecated Will be removed when threads module is refactored to use per-workspace state. */ get threadsFile() { return join(this.dataDir, "threads.json"); }, + /** @deprecated Will be removed when events module is refactored to use per-workspace state. */ get logsDir() { return join(this.dataDir, "logs"); }, + /** @deprecated Will be removed when approvals module is refactored to use per-workspace state. */ get approvalsDir() { return join(this.dataDir, "approvals"); }, + /** @deprecated Will be removed when turns module is refactored to use per-workspace state. */ get killSignalsDir() { return join(this.dataDir, "kill-signals"); }, + /** @deprecated Will be removed when cli module is refactored to use per-workspace state. */ get pidsDir() { return join(this.dataDir, "pids"); }, + get configFile() { return join(this.dataDir, "config.json"); }, // Display - jobsListLimit: 20, + threadsListLimit: 20, // Client identity (sent during initialize handshake) clientName: "codex-collab", @@ -50,6 +79,8 @@ export type ReasoningEffort = (typeof config.reasoningEfforts)[number]; export type SandboxMode = (typeof config.sandboxModes)[number]; export type ApprovalPolicy = (typeof config.approvalPolicies)[number]; +// ─── Pure utility functions ───────────────────────────────────────────────── + /** Validate that an ID contains only safe characters for file paths. */ export function validateId(id: string): string { if (!/^[a-zA-Z0-9_-]+$/.test(id)) { @@ -57,3 +88,195 @@ export function validateId(id: string): string { } return id; } + +/** + * Find workspace root by running `git rev-parse --show-toplevel`. + * If not in a git repo, returns the resolved (realpath) cwd. + */ +export function resolveWorkspaceDir(cwd: string): string { + const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { + cwd, + encoding: "utf-8", + timeout: 5000, + }); + if (result.status === 0 && result.stdout) { + return result.stdout.trim(); + } + return resolve(cwd); +} + +/** + * Compute per-workspace state directory: + * `~/.codex-collab/workspaces/{slug}-{hash}/` + * + * - slug: sanitized lowercase basename of the workspace root + * - hash: first 16 chars of SHA-256 of the canonical (realpath) path + */ +export function resolveStateDir(cwd: string): string { + const wsRoot = resolveWorkspaceDir(cwd); + let canonical: string; + try { + canonical = realpathSync(wsRoot); + } catch { + canonical = resolve(wsRoot); + } + const slug = basename(canonical).replace(/[^a-zA-Z0-9_-]/g, "_").toLowerCase(); + const hash = createHash("sha256").update(canonical).digest("hex").slice(0, 16); + return join(getHome(), ".codex-collab", "workspaces", `${slug}-${hash}`); +} + +/** + * Resolve model aliases. Currently: `spark → gpt-5.3-codex-spark`. + * Passes through unknown names. Returns undefined for undefined input. + */ +export function resolveModel(model: string | undefined): string | undefined { + if (model === undefined) return undefined; + return MODEL_ALIASES[model] ?? model; +} + +/** + * Validate reasoning effort against known levels. + * Throws on invalid. Returns undefined for undefined input. + */ +export function validateEffort(effort: string | undefined): ReasoningEffort | undefined { + if (effort === undefined) return undefined; + if (!(VALID_EFFORTS as readonly string[]).includes(effort)) { + throw new Error( + `Invalid effort level "${effort}". Valid levels: ${VALID_EFFORTS.join(", ")}`, + ); + } + return effort as ReasoningEffort; +} + +// ─── Template metadata ───────────────────────────────────────────────────── + +export interface TemplateMeta { + name: string; + description: string; + sandbox?: string; +} + +/** + * Parse YAML frontmatter from a template string. + * Returns the metadata fields and the body (content after frontmatter). + */ +export function parseTemplateFrontmatter(raw: string): { meta: TemplateMeta; body: string } { + // Normalize CRLF to LF so Windows-edited templates parse correctly + const normalized = raw.replace(/\r\n/g, "\n"); + const lines = normalized.split("\n"); + if (lines[0]?.trim() !== "---") { + return { meta: { name: "", description: "" }, body: normalized }; + } + const endIdx = lines.indexOf("---", 1); + if (endIdx === -1) { + return { meta: { name: "", description: "" }, body: normalized }; + } + + const meta: Record = {}; + for (let i = 1; i < endIdx; i++) { + const match = lines[i].match(/^(\w+)\s*:\s*(.+)$/); + if (match) meta[match[1]] = match[2].trim(); + } + + const body = lines.slice(endIdx + 1).join("\n").replace(/^\n+/, ""); + return { + meta: { + name: meta.name ?? "", + description: meta.description ?? "", + sandbox: meta.sandbox, + }, + body, + }; +} + +/** + * Read a `.md` template file and return its body (frontmatter stripped). + * Checks user templates dir first (`~/.codex-collab/templates/`), + * then falls back to built-in templates (relative to this file). + * + * The optional `promptsDir` parameter overrides both (used in tests). + */ +export function loadTemplate(name: string, promptsDir?: string): string { + const raw = loadTemplateRaw(name, promptsDir); + return parseTemplateFrontmatter(raw).body; +} + +/** + * Load a template and return both its parsed metadata and body. + */ +export function loadTemplateWithMeta(name: string, promptsDir?: string): { meta: TemplateMeta; body: string } { + return parseTemplateFrontmatter(loadTemplateRaw(name, promptsDir)); +} + +/** Load the raw template content (including frontmatter). */ +function loadTemplateRaw(name: string, promptsDir?: string): string { + if (name.includes("/") || name.includes("\\") || name.includes("..")) { + throw new Error(`Invalid template name: "${name}"`); + } + + if (promptsDir) { + const filePath = join(promptsDir, `${name}.md`); + if (!existsSync(filePath)) { + throw new Error(`Template not found: ${filePath}`); + } + return readFileSync(filePath, "utf-8"); + } + + // Check user templates first, then built-in + const userPath = join(config.dataDir, "templates", `${name}.md`); + if (existsSync(userPath)) { + return readFileSync(userPath, "utf-8"); + } + + const builtinPath = join(import.meta.dir, "prompts", `${name}.md`); + if (existsSync(builtinPath)) { + return readFileSync(builtinPath, "utf-8"); + } + + throw new Error(`Template "${name}" not found. Place a ${name}.md file in ~/.codex-collab/templates/ or check available built-in templates.`); +} + +/** + * List all available templates from user and built-in directories. + * User templates override built-in templates with the same name. + */ +export function listTemplates(): TemplateMeta[] { + const templates = new Map(); + + // Built-in templates + const builtinDir = join(import.meta.dir, "prompts"); + if (existsSync(builtinDir)) { + for (const file of readdirSync(builtinDir).filter(f => f.endsWith(".md"))) { + const name = file.replace(/\.md$/, ""); + const raw = readFileSync(join(builtinDir, file), "utf-8"); + const { meta } = parseTemplateFrontmatter(raw); + templates.set(name, { ...meta, name }); + } + } + + // User templates (override built-in) + const userDir = join(config.dataDir, "templates"); + if (existsSync(userDir)) { + for (const file of readdirSync(userDir).filter(f => f.endsWith(".md"))) { + const name = file.replace(/\.md$/, ""); + const raw = readFileSync(join(userDir, file), "utf-8"); + const { meta } = parseTemplateFrontmatter(raw); + templates.set(name, { ...meta, name }); + } + } + + return [...templates.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Replace `{{VAR}}` placeholders in a template string. + * Unknown variables are left as-is. + */ +export function interpolateTemplate( + template: string, + vars: Record, +): string { + return template.replace(/\{\{(\w+)\}\}/g, (match, key) => { + return key in vars ? vars[key] : match; + }); +} diff --git a/src/events.test.ts b/src/events.test.ts index c77aa6d..cd0b194 100644 --- a/src/events.test.ts +++ b/src/events.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, beforeEach } from "bun:test"; -import { EventDispatcher } from "./events"; +import { EventDispatcher, inferPhaseFromLog } from "./events"; import { mkdirSync, rmSync, readFileSync, existsSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; @@ -181,3 +181,116 @@ describe("EventDispatcher", () => { expect(dispatcher.getFilesChanged()).toHaveLength(1); }); }); + +describe("phase dedup", () => { + test("emits first progress for a phase", () => { + const lines: string[] = []; + const dispatcher = new EventDispatcher("test-phase1", TEST_LOG_DIR, (line) => lines.push(line)); + + dispatcher.emitProgress("Starting thread abc", { phase: "starting", threadId: "t1" }); + + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("Starting thread abc"); + }); + + test("skips consecutive same phase for same thread", () => { + const lines: string[] = []; + const dispatcher = new EventDispatcher("test-phase2", TEST_LOG_DIR, (line) => lines.push(line)); + + dispatcher.emitProgress("Starting thread abc", { phase: "starting", threadId: "t1" }); + dispatcher.emitProgress("Starting another thing", { phase: "starting", threadId: "t1" }); + + expect(lines).toHaveLength(1); + }); + + test("emits when phase changes", () => { + const lines: string[] = []; + const dispatcher = new EventDispatcher("test-phase3", TEST_LOG_DIR, (line) => lines.push(line)); + + dispatcher.emitProgress("Starting thread", { phase: "starting", threadId: "t1" }); + dispatcher.emitProgress("Editing files", { phase: "editing", threadId: "t1" }); + + expect(lines).toHaveLength(2); + expect(lines[0]).toContain("Starting thread"); + expect(lines[1]).toContain("Editing files"); + }); + + test("different threads are tracked independently", () => { + const lines: string[] = []; + const dispatcher = new EventDispatcher("test-phase4", TEST_LOG_DIR, (line) => lines.push(line)); + + dispatcher.emitProgress("Starting thread", { phase: "starting", threadId: "t1" }); + dispatcher.emitProgress("Starting thread", { phase: "starting", threadId: "t2" }); + + expect(lines).toHaveLength(2); + }); + + test("reset clears phase dedup state", () => { + const lines: string[] = []; + const dispatcher = new EventDispatcher("test-phase-reset", TEST_LOG_DIR, (line) => lines.push(line)); + + // Emit a phase, then reset, then emit the same phase again — should NOT be suppressed + dispatcher.emitProgress("Starting thread", { phase: "starting", threadId: "t1" }); + expect(lines).toHaveLength(1); + + dispatcher.reset(); + + dispatcher.emitProgress("Starting thread again", { phase: "starting", threadId: "t1" }); + expect(lines).toHaveLength(2); + expect(lines[1]).toContain("Starting thread again"); + }); + + test("emits without dedup when no phase/threadId provided", () => { + const lines: string[] = []; + const dispatcher = new EventDispatcher("test-phase5", TEST_LOG_DIR, (line) => lines.push(line)); + + dispatcher.emitProgress("Some progress line"); + dispatcher.emitProgress("Some progress line"); + + expect(lines).toHaveLength(2); + }); +}); + +describe("inferPhaseFromLog", () => { + test("infers starting", () => { + expect(inferPhaseFromLog("[codex] Starting thread")).toBe("starting"); + expect(inferPhaseFromLog("[codex] Thread abc started")).toBe("starting"); + }); + + test("infers reviewing", () => { + expect(inferPhaseFromLog("[codex] Reviewing changes")).toBe("reviewing"); + expect(inferPhaseFromLog("[codex] Code review in progress")).toBe("reviewing"); + }); + + test("infers editing", () => { + expect(inferPhaseFromLog("[codex] Editing src/foo.ts")).toBe("editing"); + expect(inferPhaseFromLog("[codex] File edited successfully")).toBe("editing"); + }); + + test("infers verifying", () => { + expect(inferPhaseFromLog("[codex] Verifying output")).toBe("verifying"); + expect(inferPhaseFromLog("[codex] Checking results")).toBe("verifying"); + }); + + test("infers running", () => { + expect(inferPhaseFromLog("[codex] Running: npm test")).toBe("running"); + expect(inferPhaseFromLog("[codex] Executing command")).toBe("running"); + expect(inferPhaseFromLog("[codex] Execute build step")).toBe("running"); + }); + + test("infers investigating", () => { + expect(inferPhaseFromLog("[codex] Investigating error")).toBe("investigating"); + expect(inferPhaseFromLog("[codex] Investigate the root cause")).toBe("investigating"); + }); + + test("infers finalizing", () => { + expect(inferPhaseFromLog("[codex] Turn completed")).toBe("finalizing"); + expect(inferPhaseFromLog("[codex] Finalizing output")).toBe("finalizing"); + expect(inferPhaseFromLog("[codex] Task complete")).toBe("finalizing"); + }); + + test("returns null for unrecognized lines", () => { + expect(inferPhaseFromLog("[codex] some random output")).toBeNull(); + expect(inferPhaseFromLog("")).toBeNull(); + }); +}); diff --git a/src/events.ts b/src/events.ts index ed3f62f..a8f2c07 100644 --- a/src/events.ts +++ b/src/events.ts @@ -2,69 +2,103 @@ import { appendFileSync, mkdirSync, existsSync } from "fs"; import { join } from "path"; -import type { - ItemStartedParams, ItemCompletedParams, DeltaParams, - ErrorNotificationParams, - FileChange, CommandExec, - CommandExecutionItem, FileChangeItem, ExitedReviewModeItem, +import { + isKnownItem, + type ItemStartedParams, type ItemCompletedParams, type DeltaParams, + type ErrorNotificationParams, + type FileChange, type CommandExec, + type RunPhase, } from "./types"; type ProgressCallback = (line: string) => void; export class EventDispatcher { private accumulatedOutput = ""; + private finalAnswerOutput = ""; private filesChanged: FileChange[] = []; private commandsRun: CommandExec[] = []; private logBuffer: string[] = []; private logPath: string; private onProgress: ProgressCallback; + private lastPhase: Map = new Map(); + /** Item IDs that the server marked as phase "final_answer". */ + private finalAnswerItemIds: Set = new Set(); + /** The item ID currently receiving deltas. */ + private currentDeltaItemId: string | null = null; constructor( shortId: string, logsDir: string, onProgress?: ProgressCallback, ) { - if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true }); + if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true, mode: 0o700 }); this.logPath = join(logsDir, `${shortId}.log`); this.onProgress = onProgress ?? ((line) => process.stderr.write(line + "\n")); } handleItemStarted(params: ItemStartedParams): void { const { item } = params; + if (!isKnownItem(item)) return; if (item.type === "commandExecution") { - this.progress(`Running: ${(item as CommandExecutionItem).command}`); + this.progress(`Running: ${item.command}`); + } + + // Track which item is receiving deltas and separate consecutive messages + if (item.type === "agentMessage") { + this.currentDeltaItemId = item.id; + if (this.accumulatedOutput.length > 0) { + this.accumulatedOutput += "\n"; + } } } handleItemCompleted(params: ItemCompletedParams): void { const { item } = params; + if (!isKnownItem(item)) return; + + // Track agent message phases for output filtering + if (item.type === "agentMessage") { + if (item.phase === "final_answer") { + // Final answer: append text (supports multiple final_answer messages) + this.finalAnswerItemIds.add(item.id); + if (item.text) { + if (this.finalAnswerOutput.length > 0) { + this.finalAnswerOutput += "\n"; + } + this.finalAnswerOutput += item.text; + } + } else if (item.text) { + // Intermediate agent message (planning/status): show as progress + const preview = item.text.length > 120 + ? item.text.slice(0, 117) + "..." + : item.text; + this.progress(preview); + } + } - // Type assertions needed: GenericItem's `type: string` prevents discriminated union narrowing switch (item.type) { case "commandExecution": { - const cmd = item as CommandExecutionItem; - if (cmd.status !== "completed") { - this.progress(`Command ${cmd.status}: ${cmd.command}`); + if (item.status !== "completed") { + this.progress(`Command ${item.status}: ${item.command}`); break; } this.commandsRun.push({ - command: cmd.command, - exitCode: cmd.exitCode ?? null, - durationMs: cmd.durationMs ?? null, + command: item.command, + exitCode: item.exitCode ?? null, + durationMs: item.durationMs ?? null, }); - const exit = cmd.exitCode ?? "?"; - this.log(`command: ${cmd.command} (exit ${exit})`); + const exit = item.exitCode ?? "?"; + this.log(`command: ${item.command} (exit ${exit})`); break; } case "fileChange": { - const fc = item as FileChangeItem; - if (fc.status !== "completed") { - const paths = fc.changes.map(c => c.path).join(", "); - this.progress(`File change ${fc.status}: ${paths || "(no paths)"}`); + if (item.status !== "completed") { + const paths = item.changes.map(c => c.path).join(", "); + this.progress(`File change ${item.status}: ${paths || "(no paths)"}`); break; } - for (const change of fc.changes) { + for (const change of item.changes) { this.filesChanged.push({ path: change.path, kind: change.kind.type, @@ -75,9 +109,8 @@ export class EventDispatcher { break; } case "exitedReviewMode": { - const review = item as ExitedReviewModeItem; - this.accumulatedOutput = review.review; - this.log(`review output (${review.review.length} chars)`); + this.accumulatedOutput = item.review; + this.log(`review output (${item.review.length} chars)`); break; } } @@ -86,6 +119,10 @@ export class EventDispatcher { handleDelta(method: string, params: DeltaParams): void { if (method === "item/agentMessage/delta") { this.accumulatedOutput += params.delta; + // If this delta belongs to a final_answer item, also accumulate separately + if (this.currentDeltaItemId && this.finalAnswerItemIds.has(this.currentDeltaItemId)) { + this.finalAnswerOutput += params.delta; + } } // No per-character logging — accumulated text is logged at flush } @@ -100,6 +137,12 @@ export class EventDispatcher { return this.accumulatedOutput; } + /** Get only the final answer output (agentMessage items with phase "final_answer"). + * Falls back to full accumulated output if no final_answer phase was seen. */ + getFinalAnswerOutput(): string { + return this.finalAnswerOutput || this.accumulatedOutput; + } + getFilesChanged(): FileChange[] { return [...this.filesChanged]; } @@ -108,10 +151,24 @@ export class EventDispatcher { return [...this.commandsRun]; } + /** Emit progress with optional phase tracking for dedup. */ + emitProgress(line: string, opts?: { phase?: string; threadId?: string }): void { + if (opts?.phase && opts?.threadId) { + const prev = this.lastPhase.get(opts.threadId); + if (prev === opts.phase) return; // dedup: same phase for same thread + this.lastPhase.set(opts.threadId, opts.phase); + } + this.progress(line); + } + reset(): void { this.accumulatedOutput = ""; + this.finalAnswerOutput = ""; this.filesChanged = []; this.commandsRun = []; + this.lastPhase.clear(); + this.finalAnswerItemIds.clear(); + this.currentDeltaItemId = null; } /** Write accumulated agent output to the log (called before final flush). */ @@ -145,3 +202,28 @@ export class EventDispatcher { if (this.logBuffer.length >= 20) this.flush(); } } + +// --- Phase inference from log lines --- + +const PHASE_PATTERNS: Array<[RegExp, RunPhase]> = [ + [/\bStarting\b/i, "starting"], + [/\bstarted\b/i, "starting"], + [/\bReviewing\b/i, "reviewing"], + [/\breview\b/i, "reviewing"], + [/\bEdit(?:ing|ed)\b/i, "editing"], + [/\bVerify(?:ing)?\b/i, "verifying"], + [/\bcheck(?:ing)?\b/i, "verifying"], + [/\bRunning\b/i, "running"], + [/\bExecut(?:ing|e)\b/i, "running"], + [/\bInvestigat(?:ing|e)\b/i, "investigating"], + [/\bFinaliz(?:ing|e)\b/i, "finalizing"], + [/\bcompleted?\b/i, "finalizing"], +]; + +/** Infer a RunPhase from a log line by regex matching. Returns null if no match. */ +export function inferPhaseFromLog(line: string): RunPhase | null { + for (const [pattern, phase] of PHASE_PATTERNS) { + if (pattern.test(line)) return phase; + } + return null; +} diff --git a/src/git.test.ts b/src/git.test.ts new file mode 100644 index 0000000..4e4b294 --- /dev/null +++ b/src/git.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, test, beforeAll, afterAll } from "bun:test"; +import { mkdirSync, writeFileSync, rmSync } from "fs"; +import { join } from "path"; +import { + isInsideGitRepo, + getDefaultBranch, + getDiffStats, + getUntrackedFiles, + resolveReviewTarget, +} from "./git"; + +// ─── isInsideGitRepo ─────────────────────────────────────────────────────── + +describe("isInsideGitRepo", () => { + test("returns true for the current repo", () => { + expect(isInsideGitRepo(process.cwd())).toBe(true); + }); + + test("returns true for a subdirectory of the repo", () => { + expect(isInsideGitRepo(join(process.cwd(), "src"))).toBe(true); + }); + + test("returns false for a temp dir outside any git repo", () => { + const tmp = join(process.env.TMPDIR ?? "/tmp", "git-test-no-repo"); + mkdirSync(tmp, { recursive: true }); + try { + expect(isInsideGitRepo(tmp)).toBe(false); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); +}); + +// ─── getDefaultBranch ────────────────────────────────────────────────────── + +describe("getDefaultBranch", () => { + test("returns 'main' for this repo", () => { + // This project uses 'main' as its default branch + expect(getDefaultBranch(process.cwd())).toBe("main"); + }); + + test("returns a non-empty string", () => { + const branch = getDefaultBranch(process.cwd()); + expect(branch.length).toBeGreaterThan(0); + }); +}); + +// ─── getDiffStats ────────────────────────────────────────────────────────── + +describe("getDiffStats", () => { + test("returns an object with numeric fields", () => { + const stats = getDiffStats(process.cwd()); + expect(typeof stats.files).toBe("number"); + expect(typeof stats.insertions).toBe("number"); + expect(typeof stats.deletions).toBe("number"); + }); + + test("all values are non-negative", () => { + const stats = getDiffStats(process.cwd()); + expect(stats.files).toBeGreaterThanOrEqual(0); + expect(stats.insertions).toBeGreaterThanOrEqual(0); + expect(stats.deletions).toBeGreaterThanOrEqual(0); + }); + + test("accepts an optional ref argument", () => { + const stats = getDiffStats(process.cwd(), "HEAD~1"); + expect(typeof stats.files).toBe("number"); + expect(typeof stats.insertions).toBe("number"); + expect(typeof stats.deletions).toBe("number"); + }); + + test("returns zeros when there are no diffs for a ref that matches HEAD", () => { + const stats = getDiffStats(process.cwd(), "HEAD"); + expect(stats.files).toBe(0); + expect(stats.insertions).toBe(0); + expect(stats.deletions).toBe(0); + }); +}); + +// ─── getUntrackedFiles ───────────────────────────────────────────────────── + +describe("getUntrackedFiles", () => { + const tmpDir = join(process.env.TMPDIR ?? "/tmp", "git-test-untracked"); + let repoDir: string; + + beforeAll(() => { + // Create a temporary git repo with some untracked files + repoDir = join(tmpDir, "repo"); + mkdirSync(repoDir, { recursive: true }); + const { spawnSync } = require("child_process"); + spawnSync("git", ["init"], { cwd: repoDir }); + spawnSync("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); + spawnSync("git", ["config", "user.name", "Test"], { cwd: repoDir }); + // Create a committed file so we have a base + writeFileSync(join(repoDir, "committed.txt"), "committed"); + spawnSync("git", ["add", "."], { cwd: repoDir }); + spawnSync("git", ["commit", "-m", "init"], { cwd: repoDir }); + // Create untracked files + writeFileSync(join(repoDir, "small.txt"), "hello"); + writeFileSync(join(repoDir, "large.bin"), Buffer.alloc(30000, 0x41)); // 30KB > 24KB default + // Create a binary file with null bytes (< 24KB so size check passes) + const binaryContent = Buffer.alloc(100); + binaryContent[50] = 0; // null byte + binaryContent.fill(0x41, 0, 50); + binaryContent.fill(0x42, 51); + writeFileSync(join(repoDir, "binary.dat"), binaryContent); + }); + + afterAll(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("returns an array of strings", () => { + const files = getUntrackedFiles(process.cwd()); + expect(Array.isArray(files)).toBe(true); + for (const f of files) { + expect(typeof f).toBe("string"); + } + }); + + test("includes small text files", () => { + const files = getUntrackedFiles(repoDir); + expect(files).toContain("small.txt"); + }); + + test("excludes files larger than maxSize", () => { + const files = getUntrackedFiles(repoDir); + expect(files).not.toContain("large.bin"); + }); + + test("excludes binary files (files with null bytes)", () => { + const files = getUntrackedFiles(repoDir); + expect(files).not.toContain("binary.dat"); + }); + + test("respects custom maxSize", () => { + // With a very large maxSize, the large file should be included + // (it's all 0x41 bytes, no nulls, so it's not binary) + const files = getUntrackedFiles(repoDir, 100_000); + expect(files).toContain("large.bin"); + }); +}); + +// ─── resolveReviewTarget ─────────────────────────────────────────────────── + +describe("resolveReviewTarget", () => { + test("mode 'pr' returns baseBranch target", () => { + const target = resolveReviewTarget(process.cwd(), { mode: "pr" }); + expect(target.type).toBe("baseBranch"); + if (target.type === "baseBranch") { + expect(typeof target.branch).toBe("string"); + expect(target.branch.length).toBeGreaterThan(0); + } + }); + + test("undefined mode defaults to baseBranch (pr)", () => { + const target = resolveReviewTarget(process.cwd(), {}); + expect(target.type).toBe("baseBranch"); + }); + + test("mode 'uncommitted' returns uncommittedChanges target", () => { + const target = resolveReviewTarget(process.cwd(), { mode: "uncommitted" }); + expect(target).toEqual({ type: "uncommittedChanges" }); + }); + + test("mode 'commit' with no ref defaults to HEAD", () => { + const target = resolveReviewTarget(process.cwd(), { mode: "commit" }); + expect(target).toEqual({ type: "commit", sha: "HEAD" }); + }); + + test("mode 'commit' with explicit ref uses that ref", () => { + const target = resolveReviewTarget(process.cwd(), { mode: "commit", ref: "abc123" }); + expect(target).toEqual({ type: "commit", sha: "abc123" }); + }); + + test("mode 'custom' with instructions returns custom target", () => { + const target = resolveReviewTarget(process.cwd(), { + mode: "custom", + instructions: "Check for security issues", + }); + expect(target).toEqual({ type: "custom", instructions: "Check for security issues" }); + }); + + test("instructions provided without mode returns custom target", () => { + const target = resolveReviewTarget(process.cwd(), { + instructions: "Focus on performance", + }); + expect(target).toEqual({ type: "custom", instructions: "Focus on performance" }); + }); + + test("throws for unknown mode", () => { + expect(() => resolveReviewTarget(process.cwd(), { mode: "bogus" })).toThrow( + /unknown review mode/i, + ); + }); +}); diff --git a/src/git.ts b/src/git.ts new file mode 100644 index 0000000..1f9689a --- /dev/null +++ b/src/git.ts @@ -0,0 +1,142 @@ +// src/git.ts — Git operations for review scoping + +import { spawnSync } from "child_process"; +import { statSync, openSync, readSync, closeSync } from "fs"; +import { join } from "path"; +import type { ReviewTarget } from "./types"; + +const DEFAULT_MAX_SIZE = 24_576; // 24KB + +/** Run a git command synchronously with a 5-second timeout. */ +function git(args: string[], cwd: string): { stdout: string; status: number | null } { + const result = spawnSync("git", args, { cwd, encoding: "utf-8", timeout: 5000 }); + return { stdout: (result.stdout ?? "").trim(), status: result.status }; +} + +/** Check if a directory is inside a git repo. */ +export function isInsideGitRepo(cwd: string): boolean { + const { stdout, status } = git(["rev-parse", "--is-inside-work-tree"], cwd); + return status === 0 && stdout === "true"; +} + +/** Get the default branch name (main or master). */ +export function getDefaultBranch(cwd: string): string { + // Try remote HEAD first + const { stdout, status } = git(["symbolic-ref", "refs/remotes/origin/HEAD"], cwd); + if (status === 0 && stdout) { + // e.g. "refs/remotes/origin/main" → "main" + const parts = stdout.split("/"); + return parts[parts.length - 1]; + } + + // Fall back to checking local branches + const mainCheck = git(["rev-parse", "--verify", "refs/heads/main"], cwd); + if (mainCheck.status === 0) return "main"; + + const masterCheck = git(["rev-parse", "--verify", "refs/heads/master"], cwd); + if (masterCheck.status === 0) return "master"; + + // Default to main + return "main"; +} + +/** Get diff stats (files changed, insertions, deletions). */ +export function getDiffStats( + cwd: string, + ref?: string, +): { files: number; insertions: number; deletions: number } { + const args = ["diff", "--shortstat"]; + if (ref) args.push(ref); + + const { stdout, status } = git(args, cwd); + if (status !== 0 || !stdout) return { files: 0, insertions: 0, deletions: 0 }; + + // Parse lines like: "3 files changed, 10 insertions(+), 5 deletions(-)" + // Some components may be missing (e.g. no deletions, or only file renames). + const filesMatch = stdout.match(/(\d+)\s+files?\s+changed/); + const insertionsMatch = stdout.match(/(\d+)\s+insertions?\(\+\)/); + const deletionsMatch = stdout.match(/(\d+)\s+deletions?\(-\)/); + + return { + files: filesMatch ? parseInt(filesMatch[1], 10) : 0, + insertions: insertionsMatch ? parseInt(insertionsMatch[1], 10) : 0, + deletions: deletionsMatch ? parseInt(deletionsMatch[1], 10) : 0, + }; +} + +/** List untracked files, skipping those >maxSize bytes and binary files. */ +export function getUntrackedFiles(cwd: string, maxSize: number = DEFAULT_MAX_SIZE): string[] { + const { stdout } = git(["ls-files", "--others", "--exclude-standard"], cwd); + if (!stdout) return []; + + const paths = stdout.split("\n").filter(Boolean); + const result: string[] = []; + + for (const relPath of paths) { + const absPath = join(cwd, relPath); + + // Skip files larger than maxSize + try { + const stat = statSync(absPath); + if (stat.size > maxSize) continue; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") { + console.error(`[codex] Warning: could not stat ${relPath}: ${(e as Error).message}`); + } + continue; + } + + // Skip binary files (check first 8KB for null bytes) + try { + const fd = openSync(absPath, "r"); + const buf = Buffer.alloc(8192); + const bytesRead = readSync(fd, buf, 0, 8192, 0); + closeSync(fd); + if (buf.subarray(0, bytesRead).includes(0)) continue; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") { + console.error(`[codex] Warning: could not read ${relPath}: ${(e as Error).message}`); + } + continue; + } + + result.push(relPath); + } + + return result; +} + +/** + * Resolve review target from CLI options to protocol ReviewTarget. + * @deprecated Use the version in commands/review.ts which handles positional args and auto-detects the base branch. + */ +export function resolveReviewTarget( + cwd: string, + opts: { mode?: string; ref?: string; instructions?: string }, +): ReviewTarget { + const mode = opts.mode; + + // If instructions are provided with no mode or with "custom" mode, return custom target + if (opts.instructions && (!mode || mode === "custom")) { + return { type: "custom", instructions: opts.instructions }; + } + + switch (mode) { + case "pr": + case undefined: + return { type: "baseBranch", branch: getDefaultBranch(cwd) }; + case "uncommitted": + return { type: "uncommittedChanges" }; + case "commit": + return { type: "commit", sha: opts.ref ?? "HEAD" }; + case "custom": + // Reached only if no instructions were provided + throw new Error( + 'Custom review mode requires instructions.\nUsage: codex-collab review --mode custom "instructions"', + ); + default: + throw new Error( + `Unknown review mode: "${mode}". Valid modes: pr, uncommitted, commit, custom`, + ); + } +} diff --git a/src/integration.test.ts b/src/integration.test.ts index 4d59317..c14d885 100644 --- a/src/integration.test.ts +++ b/src/integration.test.ts @@ -1,14 +1,168 @@ -// src/integration.test.ts — Integration smoke tests against a real codex app-server process -// Skipped unless RUN_INTEGRATION=1 is set (requires codex CLI on PATH and valid credentials). +// src/integration.test.ts — CLI integration smoke tests +// +// These tests spawn `bun run src/cli.ts` as a subprocess and verify exit codes +// and output. They do NOT require a running codex app-server — they only test +// commands that work offline (help, threads, health prerequisites, etc.). +// +// The live-server integration tests (connect, thread/start) are gated behind +// RUN_INTEGRATION=1 and require codex CLI on PATH + valid credentials. -import { describe, expect, test } from "bun:test"; -import { connect } from "./protocol"; +import { describe, expect, test, beforeAll, afterAll } from "bun:test"; +import { mkdirSync, rmSync, existsSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const CLI = join(import.meta.dir, "cli.ts"); + +interface RunResult { + exitCode: number; + stdout: string; + stderr: string; +} + +function runCli(args: string[], env?: Record): RunResult { + const result = Bun.spawnSync(["bun", "run", CLI, ...args], { + env: { ...process.env, ...env }, + timeout: 10_000, + }); + return { + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + }; +} + +// Use an isolated data directory so tests don't pollute the user's real data +const TEST_DATA_DIR = join(tmpdir(), `codex-collab-integ-${Date.now()}`); + +beforeAll(() => { + mkdirSync(TEST_DATA_DIR, { recursive: true }); +}); + +afterAll(() => { + if (existsSync(TEST_DATA_DIR)) { + rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// Offline CLI tests (no app-server required) +// --------------------------------------------------------------------------- + +describe("CLI help", () => { + test("--help prints usage and exits 0", () => { + const r = runCli(["--help"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("codex-collab"); + expect(r.stdout).toContain("Usage:"); + }); + + test("-h prints usage and exits 0", () => { + const r = runCli(["-h"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("Usage:"); + }); + + test("no args prints usage and exits 0", () => { + const r = runCli([]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("Usage:"); + }); + + test("help text mentions 'threads' (not 'jobs') as primary command", () => { + const r = runCli(["--help"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("threads"); + // The help should use 'threads' as the command name, not 'jobs' + expect(r.stdout).not.toMatch(/^\s+jobs\b/m); + }); +}); + +describe("unknown commands", () => { + test("unknown command prints error and exits 1", () => { + const r = runCli(["nonexistent"]); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain("Unknown command: nonexistent"); + expect(r.stderr).toContain("--help"); + }); + + test("unknown flag prints error and exits 1", () => { + const r = runCli(["--bogus"]); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain("Unknown option"); + }); +}); + +describe("threads command", () => { + test("threads with no data returns empty output", () => { + // Use isolated HOME to avoid reading user's real threads + const r = runCli(["threads"], { HOME: TEST_DATA_DIR }); + // Should exit 0 (empty list is fine) + expect(r.exitCode).toBe(0); + }); + + test("threads --json returns valid JSON array", () => { + const r = runCli(["threads", "--json"], { HOME: TEST_DATA_DIR }); + expect(r.exitCode).toBe(0); + // Even with no threads, JSON output should be parseable + const trimmed = r.stdout.trim(); + if (trimmed) { + expect(() => JSON.parse(trimmed)).not.toThrow(); + } + }); +}); + +describe("jobs deprecation", () => { + test("jobs prints deprecation warning but still works", () => { + const r = runCli(["jobs"], { HOME: TEST_DATA_DIR }); + expect(r.exitCode).toBe(0); + expect(r.stderr).toContain("deprecated"); + expect(r.stderr).toContain("threads"); + }); + + test("jobs --json prints deprecation warning and returns valid output", () => { + const r = runCli(["jobs", "--json"], { HOME: TEST_DATA_DIR }); + expect(r.exitCode).toBe(0); + expect(r.stderr).toContain("deprecated"); + }); +}); + +describe("health command", () => { + test("health is a recognized command (does not print 'Unknown command')", () => { + // health spawns an app-server which may hang without credentials, + // so we only verify the command is recognized — not that it completes. + // Full end-to-end health check is in the live integration suite below. + const result = Bun.spawnSync(["bun", "run", CLI, "health"], { + env: process.env, + timeout: 3_000, + }); + const combined = result.stdout.toString() + result.stderr.toString(); + // Should NOT be "Unknown command" — that would mean the router rejected it + expect(combined).not.toContain("Unknown command"); + }); +}); + +// --------------------------------------------------------------------------- +// Live integration tests (gated behind RUN_INTEGRATION=1) +// --------------------------------------------------------------------------- const runIntegration = process.env.RUN_INTEGRATION === "1" && Bun.spawnSync([process.platform === "win32" ? "where" : "which", "codex"]).exitCode === 0; -describe.skipIf(!runIntegration)("integration", () => { +describe.skipIf(!runIntegration)("live integration", () => { + // Import connect lazily so the module isn't loaded when tests are skipped + let connect: typeof import("./client").connectDirect; + + beforeAll(async () => { + const mod = await import("./client"); + connect = mod.connectDirect; + }); + test("connect and list models", async () => { const client = await connect(); try { @@ -46,4 +200,10 @@ describe.skipIf(!runIntegration)("integration", () => { await client.close(); } }, 30_000); + + test("health command succeeds end-to-end", () => { + const r = runCli(["health"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("Health check passed"); + }, 30_000); }); diff --git a/src/process.test.ts b/src/process.test.ts new file mode 100644 index 0000000..d7a4c16 --- /dev/null +++ b/src/process.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import { terminateProcessTree, isProcessAlive } from "./process"; +import { spawn } from "child_process"; + +// ─── terminateProcessTree ────────────────────────────────────────────────── + +describe("terminateProcessTree", () => { + test("kills a spawned process", async () => { + const child = spawn("sleep", ["60"], { stdio: "ignore" }); + const pid = child.pid!; + expect(pid).toBeGreaterThan(0); + + terminateProcessTree(pid); + + // Wait for exit + await new Promise((resolve) => { + child.on("exit", () => resolve()); + setTimeout(resolve, 2000); + }); + + expect(() => process.kill(pid, 0)).toThrow(); + }); + + test("does not throw for non-existent PID", () => { + expect(() => terminateProcessTree(99999999)).not.toThrow(); + }); +}); + +// ─── isProcessAlive ──────────────────────────────────────────────────────── + +describe("isProcessAlive", () => { + test("returns true for own process", () => { + expect(isProcessAlive(process.pid)).toBe(true); + }); + + test("returns false for non-existent PID", () => { + expect(isProcessAlive(99999999)).toBe(false); + }); + + test("treats EPERM as alive (PID 1 on Linux as non-root)", () => { + // PID 1 (init/systemd) is always alive but owned by root. + // As non-root, kill(1, 0) throws EPERM — should still report alive. + if (process.platform !== "win32" && process.getuid?.() !== 0) { + expect(isProcessAlive(1)).toBe(true); + } + }); +}); diff --git a/src/process.ts b/src/process.ts new file mode 100644 index 0000000..2964ee3 --- /dev/null +++ b/src/process.ts @@ -0,0 +1,111 @@ +/** + * Platform-aware process tree termination utilities. + * + * Used by the broker for cleanup and by the kill command for interrupt fallback. + */ + +import { spawnSync } from "child_process"; + +const isWindows = process.platform === "win32"; + +/** Check whether a process with the given PID is still running. */ +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (e) { + // EPERM means the process exists but we lack permission to signal it + if ((e as NodeJS.ErrnoException).code === "EPERM") return true; + return false; + } +} + +/** + * Kill a process and its children. + * + * - Unix: sends SIGTERM first; if the process is still alive, schedules + * SIGKILL after 100 ms. + * - Windows: uses `taskkill /PID /T /F`. + * + * If the process is already dead (ESRCH), this is a no-op. + */ +export function terminateProcessTree(pid: number): void { + if (isWindows) { + terminateWindows(pid); + } else { + terminateUnix(pid); + } +} + +// ─── internal ────────────────────────────────────────────────────────────── + +function terminateUnix(pid: number): void { + // Try the process group first (negative pid), then the process itself. + // ESRCH on the group kill does NOT mean the process is dead — it just + // means the pid is not a process-group leader. + let sent = false; + try { + process.kill(-pid, "SIGTERM"); + sent = true; + } catch (e) { + const code = (e as NodeJS.ErrnoException).code; + if (code !== "ESRCH" && code !== "EPERM") { + console.error(`[codex] Warning: group kill failed: ${(e as Error).message}`); + } + } + + if (!sent) { + try { + process.kill(pid, "SIGTERM"); + } catch (err: unknown) { + if (isEsrch(err)) return; // process truly gone + throw err; + } + } + + // If still alive after a short grace period, escalate to SIGKILL. + if (isProcessAlive(pid)) { + setTimeout(() => { + try { + process.kill(-pid, "SIGKILL"); + } catch (e) { + const code = (e as NodeJS.ErrnoException).code; + if (code !== "ESRCH" && code !== "EPERM") { + console.error(`[codex] Warning: group SIGKILL failed: ${(e as Error).message}`); + } + try { + process.kill(pid, "SIGKILL"); + } catch (e2) { + const code2 = (e2 as NodeJS.ErrnoException).code; + if (code2 !== "ESRCH" && code2 !== "EPERM") { + console.error(`[codex] Warning: SIGKILL failed: ${(e2 as Error).message}`); + } + } + } + }, 100); + } +} + +function terminateWindows(pid: number): void { + try { + const r = spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { + stdio: "pipe", + timeout: 5000, + windowsHide: true, + }); + if (r.status !== 0) { + const stderr = r.stderr?.toString().trim(); + console.error(`[codex] Warning: taskkill exited with code ${r.status}${stderr ? `: ${stderr}` : ""}`); + } + } catch (e) { + console.error(`[codex] Warning: process termination failed: ${(e as Error).message}`); + } +} + +function isEsrch(err: unknown): boolean { + return ( + err instanceof Error && + "code" in err && + (err as NodeJS.ErrnoException).code === "ESRCH" + ); +} diff --git a/src/prompts/plan-review.md b/src/prompts/plan-review.md new file mode 100644 index 0000000..52bc620 --- /dev/null +++ b/src/prompts/plan-review.md @@ -0,0 +1,32 @@ +--- +name: plan-review +description: Review an implementation plan against the codebase for gaps, risks, and incorrect assumptions +sandbox: read-only +--- + +You are reviewing an implementation plan against the actual codebase. Your goal is to find gaps, risks, and incorrect assumptions before implementation begins. + +## Plan to review + +{{PROMPT}} + +## Review checklist + +Verify each of these against the repository: + +1. **File accuracy** — Do the files, functions, and types referenced in the plan actually exist? Are the line numbers and signatures current? +2. **Pattern consistency** — Does the proposed approach match existing patterns in the codebase, or does it introduce unnecessary divergence? +3. **Missing dependencies** — Are there imports, modules, or infrastructure the plan assumes but doesn't account for? +4. **Edge cases** — What failure modes, concurrency issues, or boundary conditions does the plan overlook? +5. **Scope creep** — Does the plan do more than necessary, or does it leave critical gaps that will require immediate follow-up? +6. **Test coverage** — Does the plan account for testing the new behavior? Are there existing tests that would break? + +## Output format + +For each issue found, report: +- **What**: one-line description +- **Where**: file path and relevant context +- **Risk**: what goes wrong if this isn't addressed +- **Suggestion**: concrete fix or alternative + +If the plan is sound, say so directly and explain why. Do not manufacture issues. diff --git a/src/reviews.test.ts b/src/reviews.test.ts new file mode 100644 index 0000000..1a58e76 --- /dev/null +++ b/src/reviews.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, test } from "bun:test"; +import { + validateNativeReviewTarget, + parseStructuredReviewOutput, + formatReviewOutput, +} from "./reviews"; +import type { ReviewTarget, StructuredReviewOutput } from "./types"; + +// ─── validateNativeReviewTarget ─────────────────────────────────────────── + +describe("validateNativeReviewTarget", () => { + test("accepts uncommittedChanges", () => { + expect(() => + validateNativeReviewTarget({ type: "uncommittedChanges" }), + ).not.toThrow(); + }); + + test("accepts baseBranch", () => { + expect(() => + validateNativeReviewTarget({ type: "baseBranch", branch: "main" }), + ).not.toThrow(); + }); + + test("accepts commit", () => { + expect(() => + validateNativeReviewTarget({ type: "commit", sha: "abc123" }), + ).not.toThrow(); + }); + + test("rejects custom", () => { + expect(() => + validateNativeReviewTarget({ type: "custom", instructions: "anything" }), + ).toThrow("Custom instructions are not compatible with native review mode"); + }); +}); + +// ─── parseStructuredReviewOutput ────────────────────────────────────────── + +const VALID_OUTPUT: StructuredReviewOutput = { + verdict: "needs-attention", + summary: "Found a potential race condition in the cache layer.", + findings: [ + { + severity: "high", + file: "src/cache.ts", + lineStart: 42, + lineEnd: 58, + confidence: 0.85, + description: "Cache invalidation is not atomic with the write.", + recommendation: "Wrap the read-modify-write in a mutex or use compare-and-swap.", + }, + ], + nextSteps: ["Add a lock around cache writes", "Add regression test for concurrent access"], +}; + +describe("parseStructuredReviewOutput", () => { + test("parses valid bare JSON", () => { + const raw = JSON.stringify(VALID_OUTPUT); + const result = parseStructuredReviewOutput(raw); + expect(result).toEqual(VALID_OUTPUT); + }); + + test("parses JSON in markdown code fence with language tag", () => { + const raw = `Here is my review:\n\n\`\`\`json\n${JSON.stringify(VALID_OUTPUT, null, 2)}\n\`\`\`\n\nLet me know if you have questions.`; + const result = parseStructuredReviewOutput(raw); + expect(result).toEqual(VALID_OUTPUT); + }); + + test("parses JSON in markdown code fence without language tag", () => { + const raw = `\`\`\`\n${JSON.stringify(VALID_OUTPUT)}\n\`\`\``; + const result = parseStructuredReviewOutput(raw); + expect(result).toEqual(VALID_OUTPUT); + }); + + test("parses JSON with surrounding whitespace and prose", () => { + const raw = `Some preamble text.\n\n${JSON.stringify(VALID_OUTPUT)}\n\nSome trailing text.`; + const result = parseStructuredReviewOutput(raw); + expect(result).toEqual(VALID_OUTPUT); + }); + + test("returns null for invalid JSON", () => { + expect(parseStructuredReviewOutput("not json at all")).toBeNull(); + expect(parseStructuredReviewOutput("{broken json")).toBeNull(); + expect(parseStructuredReviewOutput("```json\n{broken}\n```")).toBeNull(); + }); + + test("returns null for missing required fields", () => { + // Missing verdict + const noVerdict = { summary: "ok", findings: [], nextSteps: [] }; + expect(parseStructuredReviewOutput(JSON.stringify(noVerdict))).toBeNull(); + + // Missing summary + const noSummary = { verdict: "approve", findings: [], nextSteps: [] }; + expect(parseStructuredReviewOutput(JSON.stringify(noSummary))).toBeNull(); + + // Missing findings + const noFindings = { verdict: "approve", summary: "ok", nextSteps: [] }; + expect(parseStructuredReviewOutput(JSON.stringify(noFindings))).toBeNull(); + + // Missing nextSteps + const noNextSteps = { verdict: "approve", summary: "ok", findings: [] }; + expect(parseStructuredReviewOutput(JSON.stringify(noNextSteps))).toBeNull(); + }); + + test("returns null for invalid verdict value", () => { + const bad = { ...VALID_OUTPUT, verdict: "invalid-verdict" }; + expect(parseStructuredReviewOutput(JSON.stringify(bad))).toBeNull(); + }); + + test("returns null for empty summary", () => { + const bad = { ...VALID_OUTPUT, summary: "" }; + expect(parseStructuredReviewOutput(JSON.stringify(bad))).toBeNull(); + }); + + test("validates finding structure", () => { + // Missing severity + const noSeverity = { + ...VALID_OUTPUT, + findings: [{ file: "a.ts", confidence: 0.5, description: "d", recommendation: "r", lineStart: null, lineEnd: null }], + }; + expect(parseStructuredReviewOutput(JSON.stringify(noSeverity))).toBeNull(); + + // Missing file + const noFile = { + ...VALID_OUTPUT, + findings: [{ severity: "high", confidence: 0.5, description: "d", recommendation: "r", lineStart: null, lineEnd: null }], + }; + expect(parseStructuredReviewOutput(JSON.stringify(noFile))).toBeNull(); + + // Missing description + const noDesc = { + ...VALID_OUTPUT, + findings: [{ severity: "high", file: "a.ts", confidence: 0.5, recommendation: "r", lineStart: null, lineEnd: null }], + }; + expect(parseStructuredReviewOutput(JSON.stringify(noDesc))).toBeNull(); + + // Missing recommendation + const noRec = { + ...VALID_OUTPUT, + findings: [{ severity: "high", file: "a.ts", confidence: 0.5, description: "d", lineStart: null, lineEnd: null }], + }; + expect(parseStructuredReviewOutput(JSON.stringify(noRec))).toBeNull(); + + // Missing confidence + const noConf = { + ...VALID_OUTPUT, + findings: [{ severity: "high", file: "a.ts", description: "d", recommendation: "r", lineStart: null, lineEnd: null }], + }; + expect(parseStructuredReviewOutput(JSON.stringify(noConf))).toBeNull(); + + // Confidence out of range + const badConf = { + ...VALID_OUTPUT, + findings: [{ severity: "high", file: "a.ts", confidence: 1.5, description: "d", recommendation: "r", lineStart: null, lineEnd: null }], + }; + expect(parseStructuredReviewOutput(JSON.stringify(badConf))).toBeNull(); + }); + + test("accepts findings with null lineStart/lineEnd", () => { + const output: StructuredReviewOutput = { + ...VALID_OUTPUT, + findings: [ + { + severity: "medium", + file: "src/app.ts", + lineStart: null, + lineEnd: null, + confidence: 0.6, + description: "General concern.", + recommendation: "Investigate further.", + }, + ], + }; + const result = parseStructuredReviewOutput(JSON.stringify(output)); + expect(result).toEqual(output); + }); + + test("accepts approve verdict with no findings", () => { + const output: StructuredReviewOutput = { + verdict: "approve", + summary: "Change looks safe.", + findings: [], + nextSteps: [], + }; + const result = parseStructuredReviewOutput(JSON.stringify(output)); + expect(result).toEqual(output); + }); + + test("accepts all valid severity levels", () => { + const severities = ["critical", "high", "medium", "low", "info"] as const; + for (const severity of severities) { + const output: StructuredReviewOutput = { + ...VALID_OUTPUT, + findings: [{ ...VALID_OUTPUT.findings[0], severity }], + }; + const result = parseStructuredReviewOutput(JSON.stringify(output)); + expect(result).not.toBeNull(); + expect(result!.findings[0].severity).toBe(severity); + } + }); + + test("returns null for invalid severity value", () => { + const bad = { + ...VALID_OUTPUT, + findings: [{ ...VALID_OUTPUT.findings[0], severity: "catastrophic" }], + }; + expect(parseStructuredReviewOutput(JSON.stringify(bad))).toBeNull(); + }); +}); + +// ─── formatReviewOutput ─────────────────────────────────────────────────── + +describe("formatReviewOutput", () => { + test("formats approve verdict", () => { + const output: StructuredReviewOutput = { + verdict: "approve", + summary: "No issues found.", + findings: [], + nextSteps: [], + }; + const formatted = formatReviewOutput(output); + expect(formatted).toContain("Review: approve"); + expect(formatted).toContain("No issues found."); + expect(formatted).toContain("Findings (0)"); + }); + + test("formats findings with line numbers", () => { + const formatted = formatReviewOutput(VALID_OUTPUT); + expect(formatted).toContain("Review: needs-attention"); + expect(formatted).toContain("src/cache.ts:42-58"); + expect(formatted).toContain("[high]"); + expect(formatted).toContain("confidence: 0.85"); + expect(formatted).toContain("Cache invalidation is not atomic"); + expect(formatted).toContain("Wrap the read-modify-write"); + }); + + test("formats findings without line numbers", () => { + const output: StructuredReviewOutput = { + ...VALID_OUTPUT, + findings: [ + { + severity: "low", + file: "README.md", + lineStart: null, + lineEnd: null, + confidence: 0.4, + description: "Docs are outdated.", + recommendation: "Update the README.", + }, + ], + }; + const formatted = formatReviewOutput(output); + // Should show just the file name without line range + expect(formatted).toContain("README.md"); + expect(formatted).not.toContain("README.md:"); + }); + + test("formats findings with lineStart but null lineEnd", () => { + const output: StructuredReviewOutput = { + ...VALID_OUTPUT, + findings: [ + { + severity: "medium", + file: "src/utils.ts", + lineStart: 42, + lineEnd: null, + confidence: 0.7, + description: "Unused variable.", + recommendation: "Remove the variable.", + }, + ], + }; + const formatted = formatReviewOutput(output); + expect(formatted).toContain("src/utils.ts:42"); + // Should NOT show a range like "42-null" + expect(formatted).not.toContain("null"); + expect(formatted).not.toContain("42-"); + }); + + test("formats next steps", () => { + const formatted = formatReviewOutput(VALID_OUTPUT); + expect(formatted).toContain("Next Steps:"); + expect(formatted).toContain("- Add a lock around cache writes"); + expect(formatted).toContain("- Add regression test for concurrent access"); + }); + + test("omits next steps section when empty", () => { + const output: StructuredReviewOutput = { + ...VALID_OUTPUT, + nextSteps: [], + }; + const formatted = formatReviewOutput(output); + expect(formatted).not.toContain("Next Steps:"); + }); + + test("formats request-changes verdict", () => { + const output: StructuredReviewOutput = { + verdict: "request-changes", + summary: "Critical security flaw.", + findings: [ + { + severity: "critical", + file: "src/auth.ts", + lineStart: 10, + lineEnd: 10, + confidence: 0.95, + description: "SQL injection vulnerability.", + recommendation: "Use parameterized queries.", + }, + ], + nextSteps: ["Fix the SQL injection"], + }; + const formatted = formatReviewOutput(output); + expect(formatted).toContain("Review: request-changes"); + expect(formatted).toContain("[critical]"); + expect(formatted).toContain("src/auth.ts:10-10"); + }); +}); diff --git a/src/reviews.ts b/src/reviews.ts new file mode 100644 index 0000000..6f35f6a --- /dev/null +++ b/src/reviews.ts @@ -0,0 +1,225 @@ +// src/reviews.ts — Review target validation, structured output parsing, and formatting + +import type { + ReviewTarget, + StructuredReviewOutput, + ReviewFinding, + ReviewVerdict, + ReviewSeverity, +} from "./types"; + +const VALID_VERDICTS: ReadonlySet = new Set([ + "approve", + "needs-attention", + "request-changes", +]); + +const VALID_SEVERITIES: ReadonlySet = new Set([ + "critical", + "high", + "medium", + "low", + "info", +]); + +/** + * Validate that a review target is compatible with the native reviewer. + * Native reviewer supports: uncommittedChanges, baseBranch, commit. + * Custom instructions are NOT compatible with native review mode. + * Throws if the combination is invalid. + */ +export function validateNativeReviewTarget(target: ReviewTarget): void { + if (target.type === "custom") { + throw new Error( + "Custom instructions are not compatible with native review mode. Use a task instead.", + ); + } +} + +/** + * Parse structured review output from Codex's raw response text. + * The response may contain JSON wrapped in markdown code fences. + * Returns null if the output can't be parsed or doesn't match the schema. + */ +export function parseStructuredReviewOutput(raw: string): StructuredReviewOutput | null { + const json = extractJson(raw); + if (json === null) return null; + + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return null; + } + + return validateReviewOutput(parsed); +} + +/** + * Format a structured review output for human-readable display. + */ +export function formatReviewOutput(result: StructuredReviewOutput): string { + const lines: string[] = []; + + lines.push(`Review: ${result.verdict}`); + lines.push(""); + lines.push(result.summary); + lines.push(""); + lines.push(`Findings (${result.findings.length}):`); + + for (const f of result.findings) { + lines.push(""); + const location = formatLocation(f); + lines.push(` [${f.severity}] ${location} (confidence: ${f.confidence})`); + lines.push(` ${f.description}`); + lines.push(` \u2192 ${f.recommendation}`); + } + + if (result.nextSteps.length > 0) { + lines.push(""); + lines.push("Next Steps:"); + for (const step of result.nextSteps) { + lines.push(` - ${step}`); + } + } + + return lines.join("\n"); +} + +// ─── Internal helpers ───────────────────────────────────────────────────── + +/** Extract a JSON object string from raw text that may include markdown fences or prose. */ +function extractJson(raw: string): string | null { + // Try markdown code fence with or without language tag + const fenceMatch = raw.match(/```(?:json)?\s*\n([\s\S]*?)\n```/); + if (fenceMatch) { + return fenceMatch[1].trim(); + } + + // Try to find bare JSON object — locate the first '{' and find its matching '}' + const start = raw.indexOf("{"); + if (start === -1) return null; + + let depth = 0; + let inString = false; + let escape = false; + + for (let i = start; i < raw.length; i++) { + const ch = raw[i]; + + if (escape) { + escape = false; + continue; + } + + if (ch === "\\") { + if (inString) escape = true; + continue; + } + + if (ch === '"') { + inString = !inString; + continue; + } + + if (inString) continue; + + if (ch === "{") depth++; + else if (ch === "}") { + depth--; + if (depth === 0) { + return raw.slice(start, i + 1); + } + } + } + + return null; +} + +/** Validate that a parsed object conforms to the StructuredReviewOutput schema. */ +function validateReviewOutput(obj: unknown): StructuredReviewOutput | null { + if (typeof obj !== "object" || obj === null || Array.isArray(obj)) return null; + + const o = obj as Record; + + // verdict + if (typeof o.verdict !== "string" || !VALID_VERDICTS.has(o.verdict)) return null; + + // summary + if (typeof o.summary !== "string" || o.summary.length === 0) return null; + + // findings + if (!Array.isArray(o.findings)) return null; + const findings: ReviewFinding[] = []; + for (const f of o.findings) { + const validated = validateFinding(f); + if (validated === null) return null; + findings.push(validated); + } + + // nextSteps + if (!Array.isArray(o.nextSteps)) return null; + for (const step of o.nextSteps) { + if (typeof step !== "string") return null; + } + + return { + verdict: o.verdict as ReviewVerdict, + summary: o.summary, + findings, + nextSteps: o.nextSteps as string[], + }; +} + +/** Validate a single finding object. */ +function validateFinding(obj: unknown): ReviewFinding | null { + if (typeof obj !== "object" || obj === null || Array.isArray(obj)) return null; + + const f = obj as Record; + + if (typeof f.severity !== "string" || !VALID_SEVERITIES.has(f.severity)) return null; + if (typeof f.file !== "string" || f.file.length === 0) return null; + if (typeof f.description !== "string" || f.description.length === 0) return null; + if (typeof f.recommendation !== "string" || f.recommendation.length === 0) return null; + if (typeof f.confidence !== "number" || f.confidence < 0 || f.confidence > 1) return null; + + // lineStart and lineEnd are optional (may be null or number) + const lineStart = + f.lineStart === null || f.lineStart === undefined + ? null + : typeof f.lineStart === "number" + ? f.lineStart + : null; + const lineEnd = + f.lineEnd === null || f.lineEnd === undefined + ? null + : typeof f.lineEnd === "number" + ? f.lineEnd + : null; + + // If lineStart or lineEnd was provided but not a valid type, reject + if (f.lineStart !== null && f.lineStart !== undefined && typeof f.lineStart !== "number") + return null; + if (f.lineEnd !== null && f.lineEnd !== undefined && typeof f.lineEnd !== "number") return null; + + return { + severity: f.severity as ReviewSeverity, + file: f.file, + lineStart, + lineEnd, + confidence: f.confidence, + description: f.description, + recommendation: f.recommendation, + }; +} + +/** Format a finding's file location. */ +function formatLocation(f: ReviewFinding): string { + if (f.lineStart !== null && f.lineEnd !== null) { + return `${f.file}:${f.lineStart}-${f.lineEnd}`; + } + if (f.lineStart !== null) { + return `${f.file}:${f.lineStart}`; + } + return f.file; +} diff --git a/src/threads.test.ts b/src/threads.test.ts index ba39c82..035904c 100644 --- a/src/threads.test.ts +++ b/src/threads.test.ts @@ -1,18 +1,42 @@ -import { describe, expect, test, beforeEach } from "bun:test"; +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; import { - generateShortId, loadThreadMapping, saveThreadMapping, - resolveThreadId, registerThread, findShortId, removeThread, + generateShortId, + loadThreadIndex, + saveThreadIndex, + registerThread, + resolveThreadId, + findShortId, + updateThreadMeta, + removeThread, + generateRunId, + createRun, + loadRun, + updateRun, + listRuns, + listRunsForThread, + getLatestRun, + pruneRuns, + getResumeCandidate, + migrateGlobalState, } from "./threads"; -import { rmSync, existsSync } from "fs"; +import type { RunRecord, ThreadMapping } from "./types"; +import { rmSync, existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; -const TEST_THREADS_FILE = join(tmpdir(), "codex-collab-test-threads.json"); +let testDir: string; beforeEach(() => { - if (existsSync(TEST_THREADS_FILE)) rmSync(TEST_THREADS_FILE); + testDir = join(tmpdir(), `codex-collab-test-threads-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + mkdirSync(testDir, { recursive: true }); }); +afterEach(() => { + if (existsSync(testDir)) rmSync(testDir, { recursive: true }); +}); + +// ─── generateShortId ─────────────────────────────────────────────────────── + describe("generateShortId", () => { test("returns 8-char hex string", () => { const id = generateShortId(); @@ -25,99 +49,730 @@ describe("generateShortId", () => { }); }); -describe("thread mapping", () => { - test("save and load round-trips", () => { - const mapping = { abc12345: { threadId: "thr-long-id", createdAt: "2026-01-01T00:00:00Z" } }; - saveThreadMapping(TEST_THREADS_FILE, mapping); - const loaded = loadThreadMapping(TEST_THREADS_FILE); - expect(loaded.abc12345.threadId).toBe("thr-long-id"); - }); +// ─── Thread Index ────────────────────────────────────────────────────────── +describe("thread index", () => { test("load returns empty object for missing file", () => { - const loaded = loadThreadMapping(TEST_THREADS_FILE); - expect(loaded).toEqual({}); + const index = loadThreadIndex(testDir); + expect(index).toEqual({}); + }); + + test("save and load round-trips", () => { + const index = { + abc12345: { + threadId: "thr_long_id", + name: null, + model: "gpt-5", + cwd: "/proj", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }, + }; + saveThreadIndex(testDir, index); + const loaded = loadThreadIndex(testDir); + expect(loaded.abc12345.threadId).toBe("thr_long_id"); + expect(loaded.abc12345.model).toBe("gpt-5"); }); - test("registerThread adds to mapping", () => { - const mapping = registerThread(TEST_THREADS_FILE, "thr-new-id", { model: "gpt-5.3", cwd: "/proj" }); - expect(Object.keys(mapping).length).toBe(1); - const shortId = Object.keys(mapping)[0]; + test("registerThread adds to index and returns shortId", () => { + const shortId = registerThread(testDir, "thr_new_id", { model: "gpt-5", cwd: "/proj" }); expect(shortId).toMatch(/^[0-9a-f]{8}$/); - expect(mapping[shortId].threadId).toBe("thr-new-id"); - expect(mapping[shortId].model).toBe("gpt-5.3"); - expect(mapping[shortId].cwd).toBe("/proj"); + const index = loadThreadIndex(testDir); + expect(index[shortId].threadId).toBe("thr_new_id"); + expect(index[shortId].model).toBe("gpt-5"); + expect(index[shortId].cwd).toBe("/proj"); + expect(index[shortId].name).toBeNull(); + }); + + test("registerThread regenerates on collision", () => { + // Seed an existing entry + saveThreadIndex(testDir, { + deadbeef: { + threadId: "thr_existing", + name: null, + model: null, + cwd: "/", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }, + }); + const shortId = registerThread(testDir, "thr_new"); + expect(shortId).not.toBe("deadbeef"); + const index = loadThreadIndex(testDir); + expect(Object.keys(index).length).toBe(2); + expect(index.deadbeef.threadId).toBe("thr_existing"); + }); + + test("resolveThreadId — exact short ID match", () => { + saveThreadIndex(testDir, { + abc12345: { + threadId: "thr_long_id", + name: null, + model: null, + cwd: "/", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }, + }); + const result = resolveThreadId(testDir, "abc12345"); + expect(result).toEqual({ shortId: "abc12345", threadId: "thr_long_id" }); + }); + + test("resolveThreadId — prefix match", () => { + saveThreadIndex(testDir, { + abc12345: { + threadId: "thr_long_id", + name: null, + model: null, + cwd: "/", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }, + }); + const result = resolveThreadId(testDir, "abc1"); + expect(result).toEqual({ shortId: "abc12345", threadId: "thr_long_id" }); + }); + + test("resolveThreadId — ambiguous prefix throws", () => { + saveThreadIndex(testDir, { + abc12345: { + threadId: "thr_1", + name: null, + model: null, + cwd: "/", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }, + abc12399: { + threadId: "thr_2", + name: null, + model: null, + cwd: "/", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }, + }); + expect(() => resolveThreadId(testDir, "abc12")).toThrow(/ambiguous/i); + }); + + test("resolveThreadId — full threadId lookup", () => { + saveThreadIndex(testDir, { + abc12345: { + threadId: "thr_full_thread_id_here", + name: "my thread", + model: null, + cwd: "/", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }, + }); + const result = resolveThreadId(testDir, "thr_full_thread_id_here"); + expect(result).toEqual({ shortId: "abc12345", threadId: "thr_full_thread_id_here" }); + }); + + test("resolveThreadId — UUID-style threadId lookup", () => { + saveThreadIndex(testDir, { + abc12345: { + threadId: "019d680c-7b23-7f22-ab99-6584214a2bed", + name: "uuid thread", + model: null, + cwd: "/", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }, + }); + const result = resolveThreadId(testDir, "019d680c-7b23-7f22-ab99-6584214a2bed"); + expect(result).toEqual({ shortId: "abc12345", threadId: "019d680c-7b23-7f22-ab99-6584214a2bed" }); + }); + + test("resolveThreadId — returns null for unknown", () => { + saveThreadIndex(testDir, {}); + const result = resolveThreadId(testDir, "ffffffff"); + expect(result).toBeNull(); + }); + + test("findShortId — returns short ID for known thread", () => { + saveThreadIndex(testDir, { + abc12345: { + threadId: "thr_long_id", + name: null, + model: null, + cwd: "/", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }, + }); + expect(findShortId(testDir, "thr_long_id")).toBe("abc12345"); + }); + + test("findShortId — returns null for unknown thread", () => { + saveThreadIndex(testDir, {}); + expect(findShortId(testDir, "thr_nope")).toBeNull(); + }); + + test("updateThreadMeta patches entry", () => { + saveThreadIndex(testDir, { + abc12345: { + threadId: "thr_1", + name: null, + model: "old-model", + cwd: "/old", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }, + }); + updateThreadMeta(testDir, "abc12345", { name: "my thread", model: "new-model" }); + const index = loadThreadIndex(testDir); + expect(index.abc12345.name).toBe("my thread"); + expect(index.abc12345.model).toBe("new-model"); + expect(index.abc12345.cwd).toBe("/old"); // unchanged + expect(index.abc12345.updatedAt).not.toBe("2026-01-01T00:00:00Z"); + }); + + test("removeThread deletes from index", () => { + saveThreadIndex(testDir, { + abc12345: { + threadId: "thr_1", + name: null, + model: null, + cwd: "/", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }, + def67890: { + threadId: "thr_2", + name: null, + model: null, + cwd: "/", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }, + }); + removeThread(testDir, "abc12345"); + const index = loadThreadIndex(testDir); + expect(index.abc12345).toBeUndefined(); + expect(index.def67890).toBeDefined(); + }); +}); + +// ─── Run Ledger ──────────────────────────────────────────────────────────── + +function makeRun(overrides: Partial = {}): RunRecord { + return { + runId: overrides.runId ?? generateRunId(), + threadId: "thr_test", + shortId: "abc12345", + kind: "task", + phase: null, + status: "completed", + sessionId: null, + logFile: "/tmp/test.log", + logOffset: 0, + prompt: "test prompt", + model: "gpt-5", + startedAt: new Date().toISOString(), + completedAt: null, + elapsed: null, + output: null, + filesChanged: null, + commandsRun: null, + error: null, + ...overrides, + }; +} + +describe("generateRunId", () => { + test("matches expected format", () => { + const id = generateRunId(); + expect(id).toMatch(/^run-[0-9a-z]+-[0-9a-f]{6}$/); + }); + + test("generates unique IDs", () => { + const ids = new Set(Array.from({ length: 50 }, () => generateRunId())); + expect(ids.size).toBe(50); + }); +}); + +describe("run ledger", () => { + test("createRun and loadRun round-trip", () => { + const run = makeRun(); + createRun(testDir, run); + const loaded = loadRun(testDir, run.runId); + expect(loaded).not.toBeNull(); + expect(loaded!.runId).toBe(run.runId); + expect(loaded!.threadId).toBe("thr_test"); + }); + + test("loadRun returns null for missing run", () => { + expect(loadRun(testDir, "run-nonexistent")).toBeNull(); + }); + + test("updateRun patches fields", () => { + const run = makeRun(); + createRun(testDir, run); + updateRun(testDir, run.runId, { status: "failed", error: "boom" }); + const loaded = loadRun(testDir, run.runId); + expect(loaded!.status).toBe("failed"); + expect(loaded!.error).toBe("boom"); + expect(loaded!.threadId).toBe("thr_test"); // unchanged + }); + + test("listRuns returns all runs sorted by startedAt descending", () => { + const r1 = makeRun({ startedAt: "2026-01-01T00:00:00Z" }); + const r2 = makeRun({ startedAt: "2026-01-02T00:00:00Z" }); + const r3 = makeRun({ startedAt: "2026-01-03T00:00:00Z" }); + createRun(testDir, r1); + createRun(testDir, r2); + createRun(testDir, r3); + const runs = listRuns(testDir); + expect(runs.length).toBe(3); + expect(runs[0].runId).toBe(r3.runId); + expect(runs[2].runId).toBe(r1.runId); + }); + + test("listRuns with sessionId filter", () => { + const r1 = makeRun({ sessionId: "sess-a" }); + const r2 = makeRun({ sessionId: "sess-b" }); + const r3 = makeRun({ sessionId: "sess-a" }); + createRun(testDir, r1); + createRun(testDir, r2); + createRun(testDir, r3); + const runs = listRuns(testDir, { sessionId: "sess-a" }); + expect(runs.length).toBe(2); + expect(runs.every(r => r.sessionId === "sess-a")).toBe(true); + }); + + test("listRuns returns empty for nonexistent directory", () => { + const emptyDir = join(testDir, "nonexistent-sub"); + expect(listRuns(emptyDir)).toEqual([]); + }); + + test("listRunsForThread filters by shortId", () => { + const r1 = makeRun({ shortId: "aaa11111", startedAt: "2026-01-01T00:00:00Z" }); + const r2 = makeRun({ shortId: "bbb22222", startedAt: "2026-01-02T00:00:00Z" }); + const r3 = makeRun({ shortId: "aaa11111", startedAt: "2026-01-03T00:00:00Z" }); + createRun(testDir, r1); + createRun(testDir, r2); + createRun(testDir, r3); + const runs = listRunsForThread(testDir, "aaa11111"); + expect(runs.length).toBe(2); + expect(runs.every(r => r.shortId === "aaa11111")).toBe(true); + }); + + test("getLatestRun returns newest run for thread", () => { + const r1 = makeRun({ shortId: "aaa11111", startedAt: "2026-01-01T00:00:00Z" }); + const r2 = makeRun({ shortId: "aaa11111", startedAt: "2026-01-03T00:00:00Z" }); + createRun(testDir, r1); + createRun(testDir, r2); + const latest = getLatestRun(testDir, "aaa11111"); + expect(latest!.runId).toBe(r2.runId); + }); + + test("getLatestRun returns null for thread with no runs", () => { + expect(getLatestRun(testDir, "zzz99999")).toBeNull(); + }); + + test("pruneRuns removes oldest runs", () => { + const runs: RunRecord[] = []; + for (let i = 0; i < 10; i++) { + const r = makeRun({ + startedAt: new Date(Date.UTC(2026, 0, i + 1)).toISOString(), + }); + runs.push(r); + createRun(testDir, r); + } + pruneRuns(testDir, 3); + const remaining = listRuns(testDir); + expect(remaining.length).toBe(3); + // Should keep the 3 newest (Jan 8, 9, 10) + expect(remaining[0].startedAt).toContain("2026-01-10"); + expect(remaining[1].startedAt).toContain("2026-01-09"); + expect(remaining[2].startedAt).toContain("2026-01-08"); + }); + + test("pruneRuns is a no-op when under limit", () => { + createRun(testDir, makeRun()); + createRun(testDir, makeRun()); + pruneRuns(testDir, 5); + expect(listRuns(testDir).length).toBe(2); + }); + + test("pruneRuns handles empty directory", () => { + // Should not throw + pruneRuns(testDir, 5); + }); +}); + +// ─── Resume Candidate ────────────────────────────────────────────────────── + +describe("getResumeCandidate", () => { + test("returns { available: false } when no runs exist", () => { + const result = getResumeCandidate(testDir, null); + expect(result).toEqual({ available: false }); }); - test("resolveThreadId finds by exact short ID", () => { - saveThreadMapping(TEST_THREADS_FILE, { - abc12345: { threadId: "thr-long-id", createdAt: "2026-01-01T00:00:00Z" }, + test("returns { available: false } when no completed tasks exist", () => { + createRun(testDir, makeRun({ kind: "task", status: "failed" })); + createRun(testDir, makeRun({ kind: "review", status: "completed" })); + const result = getResumeCandidate(testDir, null); + expect(result).toEqual({ available: false }); + }); + + test("returns latest completed task", () => { + const old = makeRun({ + shortId: "old11111", + threadId: "thr_old", + kind: "task", + status: "completed", + startedAt: "2026-01-01T00:00:00Z", + }); + const recent = makeRun({ + shortId: "new22222", + threadId: "thr_new", + kind: "task", + status: "completed", + startedAt: "2026-01-05T00:00:00Z", }); - const threadId = resolveThreadId(TEST_THREADS_FILE, "abc12345"); - expect(threadId).toBe("thr-long-id"); + createRun(testDir, old); + createRun(testDir, recent); + + const result = getResumeCandidate(testDir, null); + expect(result.available).toBe(true); + expect(result.threadId).toBe("thr_new"); + expect(result.shortId).toBe("new22222"); }); - test("resolveThreadId finds by prefix", () => { - saveThreadMapping(TEST_THREADS_FILE, { - abc12345: { threadId: "thr-long-id", createdAt: "2026-01-01T00:00:00Z" }, + test("prefers current session over any session", () => { + const otherSession = makeRun({ + shortId: "aaa11111", + threadId: "thr_other", + kind: "task", + status: "completed", + sessionId: "sess-other", + startedAt: "2026-01-05T00:00:00Z", + }); + const currentSession = makeRun({ + shortId: "bbb22222", + threadId: "thr_current", + kind: "task", + status: "completed", + sessionId: "sess-me", + startedAt: "2026-01-01T00:00:00Z", }); - const threadId = resolveThreadId(TEST_THREADS_FILE, "abc1"); - expect(threadId).toBe("thr-long-id"); + createRun(testDir, otherSession); + createRun(testDir, currentSession); + + const result = getResumeCandidate(testDir, "sess-me"); + expect(result.available).toBe(true); + expect(result.threadId).toBe("thr_current"); + expect(result.shortId).toBe("bbb22222"); }); - test("resolveThreadId throws for ambiguous prefix", () => { - saveThreadMapping(TEST_THREADS_FILE, { - abc12345: { threadId: "thr-1", createdAt: "2026-01-01T00:00:00Z" }, - abc12399: { threadId: "thr-2", createdAt: "2026-01-01T00:00:00Z" }, + test("falls back to any session if no current-session match", () => { + const otherSession = makeRun({ + shortId: "aaa11111", + threadId: "thr_other", + kind: "task", + status: "completed", + sessionId: "sess-other", + startedAt: "2026-01-05T00:00:00Z", }); - expect(() => resolveThreadId(TEST_THREADS_FILE, "abc12")).toThrow(/ambiguous/i); + createRun(testDir, otherSession); + + const result = getResumeCandidate(testDir, "sess-me"); + expect(result.available).toBe(true); + expect(result.threadId).toBe("thr_other"); }); - test("resolveThreadId throws for unknown ID", () => { - saveThreadMapping(TEST_THREADS_FILE, {}); - expect(() => resolveThreadId(TEST_THREADS_FILE, "ffffffff")).toThrow(/not found/i); + test("includes thread name from index", () => { + saveThreadIndex(testDir, { + abc12345: { + threadId: "thr_named", + name: "My Named Thread", + model: null, + cwd: "/", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }, + }); + createRun(testDir, makeRun({ + shortId: "abc12345", + threadId: "thr_named", + kind: "task", + status: "completed", + })); + + const result = getResumeCandidate(testDir, null); + expect(result.available).toBe(true); + expect(result.name).toBe("My Named Thread"); }); +}); + +// ─── migrateGlobalState ─────────────────────────────────────────────────── + +/** + * Helper: compute the workspace state dir that migrateGlobalState will use. + * Mirrors workspaceDirName logic in threads.ts. + */ +function computeWsStateDir(globalDataDir: string, cwd: string): string { + const { basename, resolve } = require("path"); + const { createHash } = require("crypto"); + const { realpathSync, spawnSync } = require("child_process") ? {} as any : {}; + // Use the same logic as resolveWorkspaceDir: try git, fallback to resolve + const { spawnSync: spawn } = require("child_process"); + const result = spawn("git", ["rev-parse", "--show-toplevel"], { + cwd, + encoding: "utf-8", + timeout: 5000, + }); + const wsRoot = (result.status === 0 && result.stdout) ? result.stdout.trim() : resolve(cwd); + let canonical: string; + try { + canonical = require("fs").realpathSync(wsRoot); + } catch { + canonical = resolve(wsRoot); + } + const slug = basename(canonical).replace(/[^a-zA-Z0-9_-]/g, "_").toLowerCase(); + const hash = createHash("sha256").update(canonical).digest("hex").slice(0, 16); + return join(globalDataDir, "workspaces", `${slug}-${hash}`); +} + +function writeGlobalThreads(globalDataDir: string, mapping: ThreadMapping): void { + const file = join(globalDataDir, "threads.json"); + mkdirSync(globalDataDir, { recursive: true }); + writeFileSync(file, JSON.stringify(mapping, null, 2)); +} + +function writeGlobalLog(globalDataDir: string, shortId: string, content: string): void { + const logsDir = join(globalDataDir, "logs"); + mkdirSync(logsDir, { recursive: true }); + writeFileSync(join(logsDir, `${shortId}.log`), content); +} + +describe("migrateGlobalState", () => { + let globalDir: string; + let cwdDir: string; - test("findShortId returns short ID for known thread", () => { - saveThreadMapping(TEST_THREADS_FILE, { - abc12345: { threadId: "thr-long-id", createdAt: "2026-01-01T00:00:00Z" }, + beforeEach(() => { + globalDir = join(tmpdir(), `codex-collab-test-migrate-global-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + cwdDir = join(tmpdir(), `codex-collab-test-migrate-cwd-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + mkdirSync(globalDir, { recursive: true }); + mkdirSync(cwdDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(globalDir)) rmSync(globalDir, { recursive: true }); + if (existsSync(cwdDir)) rmSync(cwdDir, { recursive: true }); + }); + + test("migrates matching entries from global to per-workspace", () => { + const wsRoot = cwdDir; // not a git repo, so resolveWorkspaceDir returns resolve(cwd) + writeGlobalThreads(globalDir, { + aaa11111: { + threadId: "thr_alpha", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-02T00:00:00Z", + model: "gpt-5", + cwd: wsRoot, + preview: "Do the thing", + lastStatus: "completed", + }, + bbb22222: { + threadId: "thr_beta", + createdAt: "2026-01-03T00:00:00Z", + updatedAt: "2026-01-04T00:00:00Z", + model: "o3", + cwd: wsRoot, + lastStatus: "failed", + }, + }); + + migrateGlobalState(cwdDir, globalDir); + + const wsStateDir = computeWsStateDir(globalDir, cwdDir); + const index = loadThreadIndex(wsStateDir); + expect(Object.keys(index)).toHaveLength(2); + expect(index.aaa11111.threadId).toBe("thr_alpha"); + expect(index.aaa11111.model).toBe("gpt-5"); + expect(index.aaa11111.name).toBeNull(); + expect(index.bbb22222.threadId).toBe("thr_beta"); + expect(index.bbb22222.model).toBe("o3"); + + // Verify synthetic run records exist + const runs = listRuns(wsStateDir); + expect(runs).toHaveLength(2); + + const alphaRun = runs.find(r => r.shortId === "aaa11111"); + expect(alphaRun).toBeDefined(); + expect(alphaRun!.status).toBe("completed"); + expect(alphaRun!.kind).toBe("task"); + expect(alphaRun!.prompt).toBe("Do the thing"); + expect(alphaRun!.model).toBe("gpt-5"); + expect(alphaRun!.completedAt).toBe("2026-01-02T00:00:00Z"); + + const betaRun = runs.find(r => r.shortId === "bbb22222"); + expect(betaRun).toBeDefined(); + expect(betaRun!.status).toBe("failed"); + expect(betaRun!.completedAt).toBe("2026-01-04T00:00:00Z"); + }); + + test("copies log files to per-workspace logs dir", () => { + const wsRoot = cwdDir; + writeGlobalThreads(globalDir, { + aaa11111: { + threadId: "thr_alpha", + createdAt: "2026-01-01T00:00:00Z", + cwd: wsRoot, + lastStatus: "completed", + }, + }); + writeGlobalLog(globalDir, "aaa11111", "line 1\nline 2\n"); + + migrateGlobalState(cwdDir, globalDir); + + const wsStateDir = computeWsStateDir(globalDir, cwdDir); + const wsLogFile = join(wsStateDir, "logs", "aaa11111.log"); + expect(existsSync(wsLogFile)).toBe(true); + expect(readFileSync(wsLogFile, "utf-8")).toBe("line 1\nline 2\n"); + + // Verify global log file still exists (copy, not move) + expect(existsSync(join(globalDir, "logs", "aaa11111.log"))).toBe(true); + + // Verify run record references the log file + const runs = listRuns(wsStateDir); + expect(runs[0].logFile).toBe(wsLogFile); + }); + + test("no-ops if per-workspace state already exists", () => { + const wsRoot = cwdDir; + writeGlobalThreads(globalDir, { + aaa11111: { + threadId: "thr_alpha", + createdAt: "2026-01-01T00:00:00Z", + cwd: wsRoot, + lastStatus: "completed", + }, }); - const shortId = findShortId(TEST_THREADS_FILE, "thr-long-id"); - expect(shortId).toBe("abc12345"); + + // Pre-create per-workspace state with different content + const wsStateDir = computeWsStateDir(globalDir, cwdDir); + saveThreadIndex(wsStateDir, { + existing1: { + threadId: "thr_existing", + name: "Existing Thread", + model: "gpt-5", + cwd: wsRoot, + createdAt: "2025-01-01T00:00:00Z", + updatedAt: "2025-01-01T00:00:00Z", + }, + }); + + migrateGlobalState(cwdDir, globalDir); + + // Verify per-workspace state was NOT overwritten + const index = loadThreadIndex(wsStateDir); + expect(Object.keys(index)).toHaveLength(1); + expect(index.existing1.threadId).toBe("thr_existing"); + expect(index.aaa11111).toBeUndefined(); }); - test("findShortId returns null for unknown thread", () => { - saveThreadMapping(TEST_THREADS_FILE, {}); - const shortId = findShortId(TEST_THREADS_FILE, "thr-nonexistent"); - expect(shortId).toBeNull(); + test("no-ops if global state doesn't exist", () => { + // globalDir exists but has no threads.json + migrateGlobalState(cwdDir, globalDir); + + const wsStateDir = computeWsStateDir(globalDir, cwdDir); + expect(existsSync(join(wsStateDir, "threads.json"))).toBe(false); }); - test("registerThread regenerates on short ID collision", () => { - // Pre-populate with many entries so a collision is likely if we force it - const mapping: Record = {}; - // Seed a known short ID, then register a new thread — the new ID must differ - const knownId = "deadbeef"; - mapping[knownId] = { threadId: "thr-existing", createdAt: "2026-01-01T00:00:00Z" }; - saveThreadMapping(TEST_THREADS_FILE, mapping); + test("filters entries by workspace cwd", () => { + const wsRoot = cwdDir; + const otherDir = join(tmpdir(), `codex-collab-test-other-${Date.now()}`); + mkdirSync(otherDir, { recursive: true }); + + try { + writeGlobalThreads(globalDir, { + aaa11111: { + threadId: "thr_match", + createdAt: "2026-01-01T00:00:00Z", + cwd: wsRoot, + lastStatus: "completed", + }, + bbb22222: { + threadId: "thr_subdir", + createdAt: "2026-01-02T00:00:00Z", + cwd: join(wsRoot, "subdir"), + lastStatus: "completed", + }, + ccc33333: { + threadId: "thr_other", + createdAt: "2026-01-03T00:00:00Z", + cwd: otherDir, + lastStatus: "completed", + }, + ddd44444: { + threadId: "thr_nocwd", + createdAt: "2026-01-04T00:00:00Z", + lastStatus: "completed", + }, + }); + + migrateGlobalState(cwdDir, globalDir); + + const wsStateDir = computeWsStateDir(globalDir, cwdDir); + const index = loadThreadIndex(wsStateDir); - const result = registerThread(TEST_THREADS_FILE, "thr-new"); - // The new thread must not overwrite the existing entry - expect(result[knownId].threadId).toBe("thr-existing"); - // There should now be 2 entries - expect(Object.keys(result).length).toBe(2); - // The new entry's short ID must differ from the existing one - const newEntry = Object.entries(result).find(([, v]) => v.threadId === "thr-new"); - expect(newEntry).toBeDefined(); - expect(newEntry![0]).not.toBe(knownId); + // Only entries with matching cwd or subdirectory cwd should be migrated + expect(Object.keys(index)).toHaveLength(2); + expect(index.aaa11111).toBeDefined(); + expect(index.bbb22222).toBeDefined(); + expect(index.ccc33333).toBeUndefined(); + expect(index.ddd44444).toBeUndefined(); + } finally { + if (existsSync(otherDir)) rmSync(otherDir, { recursive: true }); + } }); - test("removeThread deletes from mapping", () => { - saveThreadMapping(TEST_THREADS_FILE, { - abc12345: { threadId: "thr-1", createdAt: "2026-01-01T00:00:00Z" }, - def67890: { threadId: "thr-2", createdAt: "2026-01-01T00:00:00Z" }, + test("maps legacy status values correctly", () => { + const wsRoot = cwdDir; + writeGlobalThreads(globalDir, { + aaa11111: { + threadId: "thr_completed", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-02T00:00:00Z", + cwd: wsRoot, + lastStatus: "completed", + }, + bbb22222: { + threadId: "thr_running", + createdAt: "2026-01-01T00:00:00Z", + cwd: wsRoot, + lastStatus: "running", + }, + ccc33333: { + threadId: "thr_interrupted", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-03T00:00:00Z", + cwd: wsRoot, + lastStatus: "interrupted", + }, + ddd44444: { + threadId: "thr_failed", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-04T00:00:00Z", + cwd: wsRoot, + lastStatus: "failed", + }, }); - removeThread(TEST_THREADS_FILE, "abc12345"); - const loaded = loadThreadMapping(TEST_THREADS_FILE); - expect(loaded.abc12345).toBeUndefined(); - expect(loaded.def67890).toBeDefined(); + + migrateGlobalState(cwdDir, globalDir); + + const wsStateDir = computeWsStateDir(globalDir, cwdDir); + const runs = listRuns(wsStateDir); + + const byShortId = Object.fromEntries(runs.map(r => [r.shortId, r])); + expect(byShortId.aaa11111.status).toBe("completed"); + expect(byShortId.bbb22222.status).toBe("failed"); // stale running -> failed + expect(byShortId.ccc33333.status).toBe("cancelled"); // interrupted -> cancelled + expect(byShortId.ddd44444.status).toBe("failed"); }); }); diff --git a/src/threads.ts b/src/threads.ts index eeec9bc..9f1941f 100644 --- a/src/threads.ts +++ b/src/threads.ts @@ -1,10 +1,20 @@ -// src/threads.ts — Thread lifecycle and short ID mapping +// src/threads.ts — Thread index, run ledger, and resume candidate +// +// Two-layer model: +// 1. Thread Index — maps short IDs to thread metadata ({stateDir}/threads.json) +// 2. Run Ledger — per-execution records ({stateDir}/runs/{runId}.json) -import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, openSync, closeSync, unlinkSync, statSync } from "fs"; -import { randomBytes } from "crypto"; -import { dirname } from "path"; -import { validateId } from "./config"; -import type { ThreadMapping } from "./types"; +import { + readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, + openSync, closeSync, unlinkSync, statSync, readdirSync, rmSync, + copyFileSync, realpathSync, +} from "fs"; +import { randomBytes, createHash } from "crypto"; +import { basename, dirname, join, resolve, sep } from "path"; +import { config, validateId, resolveWorkspaceDir } from "./config"; +import type { ThreadIndex, ThreadIndexEntry, RunRecord, RunStatus, ThreadMapping, ThreadMappingEntry } from "./types"; + +// ─── Advisory file lock ──────────────────────────────────────────────────── /** * Acquire an advisory file lock using O_CREAT|O_EXCL on a .lock file. @@ -49,8 +59,8 @@ function acquireLock(filePath: string): () => void { } try { fd = openSync(lockPath, "wx"); - } catch { - throw new Error(`Cannot acquire lock on ${filePath} after ${maxAttempts} attempts`); + } catch (e) { + throw new Error(`Cannot acquire lock on ${filePath}: ${(e as Error).message}`); } } @@ -69,8 +79,8 @@ function acquireLock(filePath: string): () => void { } /** Acquire the thread file lock, run fn, then release. */ -export function withThreadLock(threadsFile: string, fn: () => T): T { - const release = acquireLock(threadsFile); +export function withThreadLock(filePath: string, fn: () => T): T { + const release = acquireLock(filePath); try { return fn(); } finally { @@ -78,24 +88,33 @@ export function withThreadLock(threadsFile: string, fn: () => T): T { } } +// ─── Short ID generation ─────────────────────────────────────────────────── + export function generateShortId(): string { return randomBytes(4).toString("hex"); } -export function loadThreadMapping(threadsFile: string): ThreadMapping { - if (!existsSync(threadsFile)) return {}; +// ─── Thread Index ────────────────────────────────────────────────────────── + +function threadsFilePath(stateDir: string): string { + return join(stateDir, "threads.json"); +} + +export function loadThreadIndex(stateDir: string): ThreadIndex { + const filePath = threadsFilePath(stateDir); + if (!existsSync(filePath)) return {}; let content: string; try { - content = readFileSync(threadsFile, "utf-8"); + content = readFileSync(filePath, "utf-8"); } catch (e) { - throw new Error(`Cannot read threads file ${threadsFile}: ${e instanceof Error ? e.message : e}`); + throw new Error(`Cannot read threads file ${filePath}: ${e instanceof Error ? e.message : e}`); } try { const parsed = JSON.parse(content); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { console.error("[codex] Warning: threads file has invalid structure. Starting fresh."); try { - renameSync(threadsFile, `${threadsFile}.corrupt.${Date.now()}`); + renameSync(filePath, `${filePath}.corrupt.${Date.now()}`); } catch (backupErr) { console.error(`[codex] Warning: could not back up invalid threads file: ${backupErr instanceof Error ? backupErr.message : backupErr}`); } @@ -107,7 +126,7 @@ export function loadThreadMapping(threadsFile: string): ThreadMapping { `[codex] Warning: threads file is corrupted (${e instanceof Error ? e.message : e}). Thread history may be incomplete.`, ); try { - renameSync(threadsFile, `${threadsFile}.corrupt.${Date.now()}`); + renameSync(filePath, `${filePath}.corrupt.${Date.now()}`); } catch (backupErr) { console.error(`[codex] Warning: could not back up corrupt threads file: ${backupErr instanceof Error ? backupErr.message : backupErr}`); } @@ -115,62 +134,521 @@ export function loadThreadMapping(threadsFile: string): ThreadMapping { } } -export function saveThreadMapping(threadsFile: string, mapping: ThreadMapping): void { - const dir = dirname(threadsFile); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const tmpPath = threadsFile + ".tmp"; - writeFileSync(tmpPath, JSON.stringify(mapping, null, 2), { mode: 0o600 }); - renameSync(tmpPath, threadsFile); +export function saveThreadIndex(stateDir: string, index: ThreadIndex): void { + const filePath = threadsFilePath(stateDir); + const dir = dirname(filePath); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); + const tmpPath = filePath + ".tmp"; + writeFileSync(tmpPath, JSON.stringify(index, null, 2), { mode: 0o600 }); + renameSync(tmpPath, filePath); } export function registerThread( - threadsFile: string, + stateDir: string, threadId: string, - meta?: { model?: string; cwd?: string; preview?: string }, -): ThreadMapping { - validateId(threadId); // ensure safe for use as filename (kill signals, etc.) - return withThreadLock(threadsFile, () => { - const mapping = loadThreadMapping(threadsFile); + meta?: Partial, +): string { + validateId(threadId); + const filePath = threadsFilePath(stateDir); + return withThreadLock(filePath, () => { + const index = loadThreadIndex(stateDir); let shortId = generateShortId(); - while (shortId in mapping) shortId = generateShortId(); - mapping[shortId] = { + while (shortId in index) shortId = generateShortId(); + const now = new Date().toISOString(); + index[shortId] = { threadId, - createdAt: new Date().toISOString(), - model: meta?.model, - cwd: meta?.cwd, - preview: meta?.preview, + name: meta?.name ?? null, + model: meta?.model ?? null, + cwd: meta?.cwd ?? process.cwd(), + createdAt: meta?.createdAt ?? now, + updatedAt: meta?.updatedAt ?? now, }; - saveThreadMapping(threadsFile, mapping); - return mapping; + saveThreadIndex(stateDir, index); + return shortId; }); } -export function resolveThreadId(threadsFile: string, idOrPrefix: string): string { - const mapping = loadThreadMapping(threadsFile); +/** + * Resolve a user-provided ID to { shortId, threadId }. + * + * Resolution order: + * 1. Exact short ID match + * 2. Prefix match on short IDs (error if ambiguous) + * 3. If starts with "thr_", search index values for matching threadId + * 4. Otherwise, return null + */ +export function resolveThreadId( + stateDir: string, + id: string, +): { shortId: string; threadId: string } | null { + const index = loadThreadIndex(stateDir); - // Exact match - if (mapping[idOrPrefix]) return mapping[idOrPrefix].threadId; + // 1. Exact short ID match + if (index[id]) return { shortId: id, threadId: index[id].threadId }; - // Prefix match - const matches = Object.entries(mapping).filter(([k]) => k.startsWith(idOrPrefix)); - if (matches.length === 1) return matches[0][1].threadId; - if (matches.length > 1) { + // 2. Prefix match + const prefixMatches = Object.entries(index).filter(([k]) => k.startsWith(id)); + if (prefixMatches.length === 1) { + return { shortId: prefixMatches[0][0], threadId: prefixMatches[0][1].threadId }; + } + if (prefixMatches.length > 1) { throw new Error( - `Ambiguous ID prefix "${idOrPrefix}" — matches: ${matches.map(([k]) => k).join(", ")}`, + `Ambiguous ID prefix "${id}" — matches: ${prefixMatches.map(([k]) => k).join(", ")}`, ); } - throw new Error(`Thread not found: "${idOrPrefix}"`); + // 3. Full thread ID lookup (any format — thr_, UUID, etc.) + for (const [shortId, entry] of Object.entries(index)) { + if (entry.threadId === id) return { shortId, threadId: entry.threadId }; + } + + // 4. Not found + return null; } -export function findShortId(threadsFile: string, threadId: string): string | null { - const mapping = loadThreadMapping(threadsFile); - for (const [shortId, entry] of Object.entries(mapping)) { +export function findShortId(stateDir: string, threadId: string): string | null { + const index = loadThreadIndex(stateDir); + for (const [shortId, entry] of Object.entries(index)) { if (entry.threadId === threadId) return shortId; } return null; } +type ThreadMetaPatch = Partial>; + +export function updateThreadMeta( + stateDir: string, + shortId: string, + patch: ThreadMetaPatch, +): void { + const filePath = threadsFilePath(stateDir); + withThreadLock(filePath, () => { + const index = loadThreadIndex(stateDir); + if (!index[shortId]) { + console.error(`[codex] Warning: cannot update metadata for unknown short ID ${shortId}`); + return; + } + const entry = index[shortId]; + if (patch.name !== undefined) entry.name = patch.name; + if (patch.model !== undefined) entry.model = patch.model; + if (patch.cwd !== undefined) entry.cwd = patch.cwd; + entry.updatedAt = new Date().toISOString(); + saveThreadIndex(stateDir, index); + }); +} + +export function removeThread(stateDir: string, shortId: string): void { + const filePath = threadsFilePath(stateDir); + withThreadLock(filePath, () => { + const index = loadThreadIndex(stateDir); + delete index[shortId]; + saveThreadIndex(stateDir, index); + }); +} + +// ─── Run Ledger ──────────────────────────────────────────────────────────── + +function runsDir(stateDir: string): string { + return join(stateDir, "runs"); +} + +function runFilePath(stateDir: string, runId: string): string { + return join(runsDir(stateDir), `${runId}.json`); +} + +export function generateRunId(): string { + return `run-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`; +} + +export function createRun(stateDir: string, record: RunRecord): void { + const dir = runsDir(stateDir); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); + const filePath = runFilePath(stateDir, record.runId); + const tmpPath = filePath + ".tmp"; + writeFileSync(tmpPath, JSON.stringify(record, null, 2), { mode: 0o600 }); + renameSync(tmpPath, filePath); +} + +export function loadRun(stateDir: string, runId: string): RunRecord | null { + const filePath = runFilePath(stateDir, runId); + if (!existsSync(filePath)) return null; + let content: string; + try { + content = readFileSync(filePath, "utf-8"); + } catch (e) { + console.error(`[codex] Warning: failed to read run file ${runId}: ${e instanceof Error ? e.message : e}`); + return null; + } + try { + const parsed = JSON.parse(content); + // Basic shape validation + if ( + typeof parsed !== "object" || + parsed === null || + typeof parsed.runId !== "string" || + typeof parsed.threadId !== "string" || + typeof parsed.shortId !== "string" + ) { + console.error(`[codex] Warning: run file ${runId} has invalid structure`); + return null; + } + return parsed; + } catch (e) { + console.error(`[codex] Warning: failed to parse run file ${runId}: ${e instanceof Error ? e.message : e}`); + return null; + } +} + +type RunPatch = Partial>; + +export function updateRun(stateDir: string, runId: string, patch: RunPatch): void { + const filePath = runFilePath(stateDir, runId); + if (!existsSync(filePath)) { + console.error(`[codex] Warning: cannot update unknown run ${runId}`); + return; + } + let record: RunRecord; + try { + record = JSON.parse(readFileSync(filePath, "utf-8")); + } catch (e) { + console.error(`[codex] Warning: failed to read run ${runId}: ${e instanceof Error ? e.message : e}`); + return; + } + Object.assign(record, patch); + try { + const tmpPath = filePath + ".tmp"; + writeFileSync(tmpPath, JSON.stringify(record, null, 2), { mode: 0o600 }); + renameSync(tmpPath, filePath); + } catch (e) { + console.error(`[codex] Warning: failed to write run ${runId}: ${e instanceof Error ? e.message : e}`); + } +} + +export function listRuns(stateDir: string, opts?: { sessionId?: string }): RunRecord[] { + const dir = runsDir(stateDir); + if (!existsSync(dir)) return []; + const files = readdirSync(dir).filter(f => f.endsWith(".json")); + const records: RunRecord[] = []; + for (const file of files) { + try { + const record: RunRecord = JSON.parse(readFileSync(join(dir, file), "utf-8")); + if (opts?.sessionId && record.sessionId !== opts.sessionId) continue; + records.push(record); + } catch (e) { + console.error(`[codex] Warning: skipping corrupt/unreadable run file ${file}: ${e instanceof Error ? e.message : e}`); + } + } + // Sort by startedAt descending (newest first) + records.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()); + return records; +} + +export function listRunsForThread(stateDir: string, shortId: string): RunRecord[] { + return listRuns(stateDir).filter(r => r.shortId === shortId); +} + +export function getLatestRun(stateDir: string, shortId: string): RunRecord | null { + const runs = listRunsForThread(stateDir, shortId); + return runs.length > 0 ? runs[0] : null; +} + +export function pruneRuns(stateDir: string, maxRuns?: number): void { + const limit = maxRuns ?? config.maxRunsPerWorkspace; + const dir = runsDir(stateDir); + if (!existsSync(dir)) return; + const files = readdirSync(dir).filter(f => f.endsWith(".json")); + if (files.length <= limit) return; + + // Load all records with their filenames + const entries: { file: string; startedAt: string }[] = []; + for (const file of files) { + try { + const record: RunRecord = JSON.parse(readFileSync(join(dir, file), "utf-8")); + entries.push({ file, startedAt: record.startedAt }); + } catch (e) { + // Corrupt files count toward the total; delete them first + console.error(`[codex] Warning: cannot read run file ${file} during prune: ${e instanceof Error ? e.message : e}`); + entries.push({ file, startedAt: "1970-01-01T00:00:00Z" }); + } + } + + // Sort ascending by startedAt (oldest first) + entries.sort((a, b) => new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime()); + + // Delete oldest until count <= limit + const toDelete = entries.length - limit; + for (let i = 0; i < toDelete; i++) { + try { + rmSync(join(dir, entries[i].file)); + } catch (e) { + console.error(`[codex] Warning: failed to delete run file ${entries[i].file} during prune: ${e instanceof Error ? e.message : e}`); + } + } +} + +// ─── Resume Candidate ────────────────────────────────────────────────────── + +export function getResumeCandidate( + stateDir: string, + sessionId: string | null, +): { available: boolean; threadId?: string; shortId?: string; name?: string } { + // 1. Check run ledger: find latest completed task run + const allRuns = listRuns(stateDir); + const completed = allRuns.filter(r => r.kind === "task" && r.status === "completed"); + + if (completed.length > 0) { + // Prefer runs from the current session + let candidate: RunRecord | undefined; + if (sessionId) { + candidate = completed.find(r => r.sessionId === sessionId); + } + if (!candidate) { + candidate = completed[0]; // listRuns returns newest first + } + + const index = loadThreadIndex(stateDir); + const entry = index[candidate.shortId]; + return { + available: true, + threadId: candidate.threadId, + shortId: candidate.shortId, + name: entry?.name ?? undefined, + }; + } + + // 2. Check thread index for entries with no local runs (e.g., TUI-created + // threads discovered via thread/list). These exist server-side and are + // resumable even though we never ran them locally. + const index = loadThreadIndex(stateDir); + const indexEntries = Object.entries(index) + .sort((a, b) => new Date(b[1].updatedAt).getTime() - new Date(a[1].updatedAt).getTime()); + + for (const [shortId, entry] of indexEntries) { + const runs = listRunsForThread(stateDir, shortId); + if (runs.length > 0) continue; // already checked in run ledger above + return { + available: true, + threadId: entry.threadId, + shortId, + name: entry.name ?? undefined, + }; + } + + return { available: false }; +} + +// ─── Migration ──────────────────────────────────────────────────────────── + +/** + * Map old thread status values to the new RunStatus type. + * "running" is mapped to "failed" since stale running entries are dead. + */ +function mapLegacyStatus(lastStatus?: string): RunStatus { + switch (lastStatus) { + case "completed": return "completed"; + case "failed": return "failed"; + case "interrupted": return "cancelled"; + case "running": return "failed"; // stale — process is gone + default: return "failed"; + } +} + +/** + * Compute the workspace-specific slug-hash suffix for a given cwd. + * Mirrors the logic in resolveStateDir but returns only the directory name. + */ +function workspaceDirName(cwd: string): string { + const wsRoot = resolveWorkspaceDir(cwd); + let canonical: string; + try { + canonical = realpathSync(wsRoot); + } catch { + canonical = resolve(wsRoot); + } + const slug = basename(canonical).replace(/[^a-zA-Z0-9_-]/g, "_").toLowerCase(); + const hash = createHash("sha256").update(canonical).digest("hex").slice(0, 16); + return `${slug}-${hash}`; +} + +/** + * Migrate thread entries and logs from the old global layout to per-workspace layout. + * Idempotent — no-ops if per-workspace state already exists or global state doesn't exist. + * + * @param cwd - The current working directory to migrate state for + * @param globalDataDir - Override for the global data directory (for testing). Defaults to config.dataDir. + */ +export function migrateGlobalState(cwd: string, globalDataDir?: string): void { + const dataDir = globalDataDir ?? config.dataDir; + const globalThreadsFile = join(dataDir, "threads.json"); + + // 1. Check if global threads.json exists + if (!existsSync(globalThreadsFile)) return; + + // 2. Compute per-workspace state dir and check if already migrated + const stateDir = join(dataDir, "workspaces", workspaceDirName(cwd)); + const wsThreadsFile = join(stateDir, "threads.json"); + if (existsSync(wsThreadsFile)) return; + + // 3. Load the global thread mapping + const globalMapping = loadThreadMapping(globalThreadsFile); + if (Object.keys(globalMapping).length === 0) return; + + // 4. Filter entries where cwd matches or is within the workspace root + const wsRoot = resolveWorkspaceDir(cwd); + const matchingEntries: [string, ThreadMappingEntry][] = []; + for (const [shortId, entry] of Object.entries(globalMapping)) { + if (entry.cwd && (entry.cwd === wsRoot || entry.cwd.startsWith(wsRoot + sep))) { + matchingEntries.push([shortId, entry]); + } + } + + if (matchingEntries.length === 0) return; + + // 5. Build per-workspace thread index and run records + const index: ThreadIndex = {}; + const globalLogsDir = join(dataDir, "logs"); + const wsLogsDir = join(stateDir, "logs"); + + for (const [shortId, entry] of matchingEntries) { + // Create ThreadIndexEntry + index[shortId] = { + threadId: entry.threadId, + name: null, + model: entry.model ?? null, + cwd: entry.cwd ?? cwd, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt ?? entry.createdAt, + }; + + // Copy log file if it exists + const globalLogFile = join(globalLogsDir, `${shortId}.log`); + const wsLogFile = join(wsLogsDir, `${shortId}.log`); + let logFile = ""; + if (existsSync(globalLogFile)) { + if (!existsSync(wsLogsDir)) mkdirSync(wsLogsDir, { recursive: true, mode: 0o700 }); + try { + copyFileSync(globalLogFile, wsLogFile); + logFile = wsLogFile; + } catch (e) { + console.error(`[codex] Warning: could not copy log file ${globalLogFile}: ${(e as Error).message}`); + logFile = globalLogFile; // fall back to original path + } + } + + // Determine terminal status + const status = mapLegacyStatus(entry.lastStatus); + const isTerminal = status === "completed" || status === "failed" || status === "cancelled"; + + // Create synthetic RunRecord + const record: RunRecord = { + runId: generateRunId(), + threadId: entry.threadId, + shortId, + kind: "task", + phase: null, + status, + sessionId: null, + logFile, + logOffset: 0, + prompt: entry.preview ?? null, + model: entry.model ?? null, + startedAt: entry.createdAt, + completedAt: isTerminal && entry.updatedAt ? entry.updatedAt : null, + elapsed: null, + output: null, + filesChanged: null, + commandsRun: null, + error: null, + }; + createRun(stateDir, record); + } + + // 6. Save the per-workspace thread index + saveThreadIndex(stateDir, index); + + // 7. Log migration result + console.error(`[codex] Migrated ${matchingEntries.length} thread(s) from global state to workspace ${wsRoot}`); +} + +// ─── Legacy API (backward-compatible) ────────────────────────────────────── +// These functions preserve the old signatures used by cli.ts, turns.ts, etc. +// They delegate to the new thread index functions using the parent directory +// of the threadsFile as the stateDir. + +/** @deprecated Use loadThreadIndex instead. */ +export function loadThreadMapping(threadsFile: string): ThreadMapping { + // The old API expected threadsFile = {dir}/threads.json + // We read the file directly to maintain exact backward compat + if (!existsSync(threadsFile)) return {}; + let content: string; + try { + content = readFileSync(threadsFile, "utf-8"); + } catch (e) { + throw new Error(`Cannot read threads file ${threadsFile}: ${e instanceof Error ? e.message : e}`); + } + try { + const parsed = JSON.parse(content); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + console.error("[codex] Warning: threads file has invalid structure. Starting fresh."); + try { + renameSync(threadsFile, `${threadsFile}.corrupt.${Date.now()}`); + } catch (backupErr) { + console.error(`[codex] Warning: could not back up invalid threads file: ${backupErr instanceof Error ? backupErr.message : backupErr}`); + } + return {}; + } + return parsed; + } catch (e) { + console.error( + `[codex] Warning: threads file is corrupted (${e instanceof Error ? e.message : e}). Thread history may be incomplete.`, + ); + try { + renameSync(threadsFile, `${threadsFile}.corrupt.${Date.now()}`); + } catch (backupErr) { + console.error(`[codex] Warning: could not back up corrupt threads file: ${backupErr instanceof Error ? backupErr.message : backupErr}`); + } + return {}; + } +} + +/** @deprecated Use saveThreadIndex instead. */ +export function saveThreadMapping(threadsFile: string, mapping: ThreadMapping): void { + const dir = dirname(threadsFile); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); + const tmpPath = threadsFile + ".tmp"; + writeFileSync(tmpPath, JSON.stringify(mapping, null, 2), { mode: 0o600 }); + renameSync(tmpPath, threadsFile); +} + +/** + * @deprecated Use updateThreadMeta (new signature) instead. + * Old signature: updateThreadMeta(threadsFile, threadId, meta) where threadId is the full ID. + */ +export function legacyUpdateThreadMeta( + threadsFile: string, + threadId: string, + meta: Partial>, +): void { + withThreadLock(threadsFile, () => { + const mapping = loadThreadMapping(threadsFile); + for (const entry of Object.values(mapping)) { + if (entry.threadId === threadId) { + if (meta.model !== undefined) entry.model = meta.model; + if (meta.cwd !== undefined) entry.cwd = meta.cwd; + if (meta.preview !== undefined) entry.preview = meta.preview; + entry.updatedAt = new Date().toISOString(); + saveThreadMapping(threadsFile, mapping); + return; + } + } + console.error(`[codex] Warning: cannot update metadata for unknown thread ${threadId.slice(0, 12)}...`); + }); +} + +/** @deprecated Use run ledger status tracking instead. */ export function updateThreadStatus( threadsFile: string, threadId: string, @@ -195,28 +673,77 @@ export function updateThreadStatus( }); } -export function updateThreadMeta( +/** + * @deprecated Legacy registerThread that returns the full mapping. + * New code should use the new registerThread (returns shortId string). + */ +export function legacyRegisterThread( threadsFile: string, threadId: string, - meta: { model?: string; cwd?: string; preview?: string }, -): void { - withThreadLock(threadsFile, () => { + meta?: { model?: string; cwd?: string; preview?: string; createdAt?: string; updatedAt?: string }, +): ThreadMapping { + validateId(threadId); + return withThreadLock(threadsFile, () => { const mapping = loadThreadMapping(threadsFile); - for (const entry of Object.values(mapping)) { - if (entry.threadId === threadId) { - if (meta.model !== undefined) entry.model = meta.model; - if (meta.cwd !== undefined) entry.cwd = meta.cwd; - if (meta.preview !== undefined) entry.preview = meta.preview; - entry.updatedAt = new Date().toISOString(); - saveThreadMapping(threadsFile, mapping); - return; - } - } - console.error(`[codex] Warning: cannot update metadata for unknown thread ${threadId.slice(0, 12)}...`); + let shortId = generateShortId(); + while (shortId in mapping) shortId = generateShortId(); + const now = new Date().toISOString(); + mapping[shortId] = { + threadId, + createdAt: meta?.createdAt ?? now, + updatedAt: meta?.updatedAt ?? now, + model: meta?.model, + cwd: meta?.cwd, + preview: meta?.preview, + }; + saveThreadMapping(threadsFile, mapping); + return mapping; }); } -export function removeThread(threadsFile: string, shortId: string): void { +/** + * @deprecated Legacy resolveThreadId that returns threadId string or throws. + * New code should use the new resolveThreadId (returns object or null). + */ +export function legacyResolveThreadId(threadsFile: string, idOrPrefix: string): string { + const mapping = loadThreadMapping(threadsFile); + + // Exact short ID match + if (mapping[idOrPrefix]) return mapping[idOrPrefix].threadId; + + // Short ID prefix match + const matches = Object.entries(mapping).filter(([k]) => k.startsWith(idOrPrefix)); + if (matches.length === 1) return matches[0][1].threadId; + if (matches.length > 1) { + throw new Error( + `Ambiguous ID prefix "${idOrPrefix}" — matches: ${matches.map(([k]) => k).join(", ")}`, + ); + } + + // Full thread ID match (e.g., UUID from Codex TUI handoff) + const byThreadId = Object.values(mapping).find(e => e.threadId === idOrPrefix); + if (byThreadId) return byThreadId.threadId; + + throw new Error(`Thread not found: "${idOrPrefix}"`); +} + +/** + * @deprecated Legacy findShortId that takes threadsFile. + * New code should use the new findShortId (takes stateDir). + */ +export function legacyFindShortId(threadsFile: string, threadId: string): string | null { + const mapping = loadThreadMapping(threadsFile); + for (const [shortId, entry] of Object.entries(mapping)) { + if (entry.threadId === threadId) return shortId; + } + return null; +} + +/** + * @deprecated Legacy removeThread that takes threadsFile. + * New code should use the new removeThread (takes stateDir). + */ +export function legacyRemoveThread(threadsFile: string, shortId: string): void { withThreadLock(threadsFile, () => { const mapping = loadThreadMapping(threadsFile); delete mapping[shortId]; diff --git a/src/turns.test.ts b/src/turns.test.ts index 5da7933..400a619 100644 --- a/src/turns.test.ts +++ b/src/turns.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test, beforeEach } from "bun:test"; -import { runTurn, runReview } from "./turns"; +import { runTurn, runReview, belongsToTurn, extractReasoning } from "./turns"; import { EventDispatcher } from "./events"; import { autoApproveHandler } from "./approvals"; import type { ApprovalHandler } from "./approvals"; -import type { AppServerClient } from "./protocol"; +import type { AppServerClient } from "./client"; import type { TurnCompletedParams, TurnStartResponse, - ReviewStartResponse, + ReviewStartResponse, ReasoningItem, } from "./types"; import { mkdirSync, rmSync, existsSync, writeFileSync, utimesSync } from "fs"; import { join } from "path"; @@ -77,8 +77,10 @@ function buildMockClient( return () => { requestHandlers.delete(method); }; }, respond() {}, + onClose() { return () => {}; }, async close() {}, userAgent: "mock/1.0", + brokerBusy: false, }; return { client, emit, requestHandlers }; @@ -741,3 +743,510 @@ describe("approval wiring", () => { expect(approvalCalls).toContain("file:/etc"); }); }); + +// --------------------------------------------------------------------------- +// belongsToTurn +// --------------------------------------------------------------------------- + +describe("belongsToTurn", () => { + test("matches when threadId and turnId match", () => { + expect(belongsToTurn( + { threadId: "thr-1", turnId: "turn-1" }, + "thr-1", + "turn-1", + )).toBe(true); + }); + + test("rejects when threadId differs", () => { + expect(belongsToTurn( + { threadId: "thr-2", turnId: "turn-1" }, + "thr-1", + "turn-1", + )).toBe(false); + }); + + test("rejects when turnId differs", () => { + expect(belongsToTurn( + { threadId: "thr-1", turnId: "turn-2" }, + "thr-1", + "turn-1", + )).toBe(false); + }); + + test("rejects when both differ", () => { + expect(belongsToTurn( + { threadId: "thr-2", turnId: "turn-2" }, + "thr-1", + "turn-1", + )).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Reasoning extraction +// --------------------------------------------------------------------------- + +describe("reasoning extraction", () => { + test("extracts reasoning from completed reasoning item", () => { + const item: ReasoningItem = { + type: "reasoning", + id: "r-1", + summary: ["The user wants to refactor the code"], + content: ["I should start by reading the file", "Then apply changes"], + }; + const result = extractReasoning(item); + expect(result).toBe("The user wants to refactor the code\nI should start by reading the file\nThen apply changes"); + }); + + test("deduplicates identical reasoning sections", () => { + const item: ReasoningItem = { + type: "reasoning", + id: "r-2", + summary: ["Think about the problem", "Plan the approach"], + content: ["Think about the problem", "Execute the plan"], + }; + const result = extractReasoning(item); + expect(result).toBe("Think about the problem\nPlan the approach\nExecute the plan"); + }); + + test("returns null when no reasoning content", () => { + const item: ReasoningItem = { + type: "reasoning", + id: "r-3", + summary: [], + content: [], + }; + expect(extractReasoning(item)).toBeNull(); + }); + + test("handles summary-only reasoning", () => { + const item: ReasoningItem = { + type: "reasoning", + id: "r-4", + summary: ["Just a summary"], + content: [], + }; + expect(extractReasoning(item)).toBe("Just a summary"); + }); + + test("handles content-only reasoning", () => { + const item: ReasoningItem = { + type: "reasoning", + id: "r-5", + summary: [], + content: ["Just content"], + }; + expect(extractReasoning(item)).toBe("Just content"); + }); +}); + +// --------------------------------------------------------------------------- +// Reasoning in turn result (integration) +// --------------------------------------------------------------------------- + +describe("reasoning in turn result", () => { + test("captures reasoning from item/completed during turn", async () => { + const { client, emit } = buildMockClient((method) => { + if (method === "turn/start") { + setTimeout(() => { + emit("item/completed", { + item: { + type: "reasoning", id: "r-1", + summary: ["Analyzing the request"], + content: ["Need to check the files first"], + }, + threadId: "thr-1", + turnId: "turn-1", + }); + emit("item/agentMessage/delta", { + threadId: "thr-1", turnId: "turn-1", itemId: "msg-1", + delta: "Here is the answer", + }); + }, 20); + setTimeout(() => emit("turn/completed", completedTurn("turn-1")), 80); + return inProgressTurn("turn-1"); + } + throw new Error(`Unexpected method: ${method}`); + }); + + const dispatcher = new EventDispatcher("test-reasoning-capture", TEST_LOG_DIR, () => {}); + + const result = await runTurn(client, "thr-1", [{ type: "text", text: "think hard" }], { + dispatcher, + approvalHandler: autoApproveHandler, + timeoutMs: 5000, + killSignalsDir: TEST_KILL_DIR, + }); + + expect(result.status).toBe("completed"); + expect(result.reasoning).toBe("Analyzing the request\nNeed to check the files first"); + expect(result.output).toBe("Here is the answer"); + }); + + test("merges multiple reasoning items without duplicates", async () => { + const { client, emit } = buildMockClient((method) => { + if (method === "turn/start") { + setTimeout(() => { + emit("item/completed", { + item: { + type: "reasoning", id: "r-1", + summary: ["Step one"], + content: ["Detail A"], + }, + threadId: "thr-1", + turnId: "turn-1", + }); + emit("item/completed", { + item: { + type: "reasoning", id: "r-2", + summary: ["Step one"], + content: ["Detail B"], + }, + threadId: "thr-1", + turnId: "turn-1", + }); + }, 20); + setTimeout(() => emit("turn/completed", completedTurn("turn-1")), 80); + return inProgressTurn("turn-1"); + } + throw new Error(`Unexpected method: ${method}`); + }); + + const dispatcher = new EventDispatcher("test-reasoning-merge", TEST_LOG_DIR, () => {}); + + const result = await runTurn(client, "thr-1", [{ type: "text", text: "think" }], { + dispatcher, + approvalHandler: autoApproveHandler, + timeoutMs: 5000, + killSignalsDir: TEST_KILL_DIR, + }); + + expect(result.reasoning).toBe("Step one\nDetail A\nDetail B"); + }); + + test("reasoning is null when no reasoning items", async () => { + const { client, emit } = buildMockClient((method) => { + if (method === "turn/start") { + setTimeout(() => { + emit("item/agentMessage/delta", { + threadId: "thr-1", turnId: "turn-1", itemId: "msg-1", + delta: "No reasoning here", + }); + }, 20); + setTimeout(() => emit("turn/completed", completedTurn("turn-1")), 50); + return inProgressTurn("turn-1"); + } + throw new Error(`Unexpected method: ${method}`); + }); + + const dispatcher = new EventDispatcher("test-no-reasoning", TEST_LOG_DIR, () => {}); + + const result = await runTurn(client, "thr-1", [{ type: "text", text: "hello" }], { + dispatcher, + approvalHandler: autoApproveHandler, + timeoutMs: 5000, + killSignalsDir: TEST_KILL_DIR, + }); + + expect(result.reasoning).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Notification buffering +// --------------------------------------------------------------------------- + +describe("notification buffering", () => { + test("replays buffered item/completed after turnId is known", async () => { + // Simulate: item/completed arrives BEFORE the turn/start response resolves. + // The mock fires item/completed synchronously during the request handler, + // which means it arrives before the turn/start response promise resolves. + const { client, emit } = buildMockClient((method) => { + if (method === "turn/start") { + // Fire item/completed synchronously before returning the response + emit("item/completed", { + item: { + type: "reasoning", id: "r-early", + summary: ["Early reasoning"], + content: ["Buffered content"], + }, + threadId: "thr-1", + turnId: "turn-1", + }); + setTimeout(() => emit("turn/completed", completedTurn("turn-1")), 50); + return inProgressTurn("turn-1"); + } + throw new Error(`Unexpected method: ${method}`); + }); + + const dispatcher = new EventDispatcher("test-buffer-replay", TEST_LOG_DIR, () => {}); + + const result = await runTurn(client, "thr-1", [{ type: "text", text: "hello" }], { + dispatcher, + approvalHandler: autoApproveHandler, + timeoutMs: 5000, + killSignalsDir: TEST_KILL_DIR, + }); + + expect(result.status).toBe("completed"); + expect(result.reasoning).toBe("Early reasoning\nBuffered content"); + }); + + test("buffered notifications for different thread are ignored", async () => { + const { client, emit } = buildMockClient((method) => { + if (method === "turn/start") { + // Fire item/completed for a different thread + emit("item/completed", { + item: { + type: "reasoning", id: "r-other", + summary: ["Other thread reasoning"], + content: [], + }, + threadId: "thr-OTHER", + turnId: "turn-1", + }); + setTimeout(() => emit("turn/completed", completedTurn("turn-1")), 50); + return inProgressTurn("turn-1"); + } + throw new Error(`Unexpected method: ${method}`); + }); + + const dispatcher = new EventDispatcher("test-buffer-other-thread", TEST_LOG_DIR, () => {}); + + const result = await runTurn(client, "thr-1", [{ type: "text", text: "hello" }], { + dispatcher, + approvalHandler: autoApproveHandler, + timeoutMs: 5000, + killSignalsDir: TEST_KILL_DIR, + }); + + expect(result.reasoning).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Completion inference +// --------------------------------------------------------------------------- + +describe("completion inference", () => { + test("infers completion when turn/completed is lost after agentMessage completes", async () => { + const { client, emit } = buildMockClient((method) => { + if (method === "turn/start") { + setTimeout(() => { + emit("item/agentMessage/delta", { + threadId: "thr-1", turnId: "turn-1", itemId: "msg-1", + delta: "Inferred output", + }); + // Fire agentMessage item/completed with final_answer phase — triggers inference timer + emit("item/completed", { + item: { type: "agentMessage", id: "msg-1", text: "Inferred output", phase: "final_answer" }, + threadId: "thr-1", + turnId: "turn-1", + }); + // Never fire turn/completed — inference should kick in after 250ms + }, 20); + return inProgressTurn("turn-1"); + } + throw new Error(`Unexpected method: ${method}`); + }); + + const dispatcher = new EventDispatcher("test-infer-completion", TEST_LOG_DIR, () => {}); + + const result = await runTurn(client, "thr-1", [{ type: "text", text: "hello" }], { + dispatcher, + approvalHandler: autoApproveHandler, + timeoutMs: 5000, + killSignalsDir: TEST_KILL_DIR, + }); + + expect(result.status).toBe("completed"); + expect(result.output).toBe("Inferred output"); + }); + + test("normal turn/completed cancels inference timer", async () => { + const { client, emit } = buildMockClient((method) => { + if (method === "turn/start") { + setTimeout(() => { + emit("item/agentMessage/delta", { + threadId: "thr-1", turnId: "turn-1", itemId: "msg-1", + delta: "Normal output", + }); + emit("item/completed", { + item: { type: "agentMessage", id: "msg-1", text: "Normal output" }, + threadId: "thr-1", + turnId: "turn-1", + }); + }, 20); + // turn/completed arrives well within the 250ms inference window + setTimeout(() => emit("turn/completed", completedTurn("turn-1")), 50); + return inProgressTurn("turn-1"); + } + throw new Error(`Unexpected method: ${method}`); + }); + + const dispatcher = new EventDispatcher("test-normal-beats-inference", TEST_LOG_DIR, () => {}); + + const result = await runTurn(client, "thr-1", [{ type: "text", text: "hello" }], { + dispatcher, + approvalHandler: autoApproveHandler, + timeoutMs: 5000, + killSignalsDir: TEST_KILL_DIR, + }); + + expect(result.status).toBe("completed"); + expect(result.output).toBe("Normal output"); + }); + + test("new item activity resets inference timer", async () => { + // agentMessage completes, then a command starts and completes. + // The inference timer should be reset by the command activity. + const startMs = Date.now(); + const { client, emit } = buildMockClient((method) => { + if (method === "turn/start") { + setTimeout(() => { + emit("item/completed", { + item: { type: "agentMessage", id: "msg-1", text: "early" }, + threadId: "thr-1", + turnId: "turn-1", + }); + }, 20); + // Command completes 200ms later (resets the 250ms timer) + setTimeout(() => { + emit("item/completed", { + item: { + type: "commandExecution", id: "cmd-1", + command: "echo hi", cwd: "/", status: "completed", + exitCode: 0, durationMs: 50, processId: null, commandActions: [], + }, + threadId: "thr-1", + turnId: "turn-1", + }); + // Now fire agentMessage with final_answer to trigger inference + emit("item/completed", { + item: { type: "agentMessage", id: "msg-2", text: "final", phase: "final_answer" }, + threadId: "thr-1", + turnId: "turn-1", + }); + }, 200); + // No turn/completed — inference should resolve ~450ms from start + return inProgressTurn("turn-1"); + } + throw new Error(`Unexpected method: ${method}`); + }); + + const dispatcher = new EventDispatcher("test-inference-reset", TEST_LOG_DIR, () => {}); + + const result = await runTurn(client, "thr-1", [{ type: "text", text: "hello" }], { + dispatcher, + approvalHandler: autoApproveHandler, + timeoutMs: 5000, + killSignalsDir: TEST_KILL_DIR, + }); + + expect(result.status).toBe("completed"); + // Should have taken at least ~400ms (200ms delay + 250ms inference timer) + expect(result.durationMs).toBeGreaterThanOrEqual(400); + // Command should be captured + expect(result.commandsRun.length).toBeGreaterThanOrEqual(1); + expect(result.commandsRun[0].command).toBe("echo hi"); + }); +}); + +// --------------------------------------------------------------------------- +// Structured file/command capture (supplementary) +// --------------------------------------------------------------------------- + +describe("structured capture from item/completed", () => { + test("captures files and commands from item/completed notifications", async () => { + const { client, emit } = buildMockClient((method) => { + if (method === "turn/start") { + setTimeout(() => { + emit("item/completed", { + item: { + type: "commandExecution", id: "cmd-1", + command: "bun test", cwd: "/proj", + status: "completed", exitCode: 0, durationMs: 500, + processId: null, commandActions: [], + }, + threadId: "thr-1", + turnId: "turn-1", + }); + emit("item/completed", { + item: { + type: "fileChange", id: "fc-1", + changes: [{ path: "src/main.ts", kind: { type: "add", move_path: null }, diff: "+10" }], + status: "completed", + }, + threadId: "thr-1", + turnId: "turn-1", + }); + }, 20); + setTimeout(() => emit("turn/completed", completedTurn("turn-1")), 80); + return inProgressTurn("turn-1"); + } + throw new Error(`Unexpected method: ${method}`); + }); + + const dispatcher = new EventDispatcher("test-structured-capture", TEST_LOG_DIR, () => {}); + + const result = await runTurn(client, "thr-1", [{ type: "text", text: "build" }], { + dispatcher, + approvalHandler: autoApproveHandler, + timeoutMs: 5000, + killSignalsDir: TEST_KILL_DIR, + }); + + expect(result.commandsRun).toHaveLength(1); + expect(result.commandsRun[0].command).toBe("bun test"); + expect(result.commandsRun[0].exitCode).toBe(0); + expect(result.filesChanged).toHaveLength(1); + expect(result.filesChanged[0].path).toBe("src/main.ts"); + expect(result.filesChanged[0].kind).toBe("add"); + }); + + test("deduplicates between dispatcher and turn-level capture", async () => { + // Both dispatcher and turn-level capture will see the same item/completed, + // so result should have exactly 1 command and 1 file (not 2). + const { client, emit } = buildMockClient((method) => { + if (method === "turn/start") { + setTimeout(() => { + emit("item/completed", { + item: { + type: "commandExecution", id: "cmd-1", + command: "npm test", cwd: "/proj", + status: "completed", exitCode: 0, durationMs: 1200, + processId: null, commandActions: [], + }, + threadId: "thr-1", + turnId: "turn-1", + }); + emit("item/completed", { + item: { + type: "fileChange", id: "fc-1", + changes: [{ path: "src/foo.ts", kind: { type: "update", move_path: null }, diff: "+1,-1" }], + status: "completed", + }, + threadId: "thr-1", + turnId: "turn-1", + }); + }, 20); + setTimeout(() => emit("turn/completed", completedTurn("turn-1")), 80); + return inProgressTurn("turn-1"); + } + throw new Error(`Unexpected method: ${method}`); + }); + + const dispatcher = new EventDispatcher("test-dedup-capture", TEST_LOG_DIR, () => {}); + + const result = await runTurn(client, "thr-1", [{ type: "text", text: "run tests" }], { + dispatcher, + approvalHandler: autoApproveHandler, + timeoutMs: 5000, + killSignalsDir: TEST_KILL_DIR, + }); + + // Should be exactly 1 of each, not duplicated + expect(result.commandsRun).toHaveLength(1); + expect(result.filesChanged).toHaveLength(1); + }); +}); diff --git a/src/turns.ts b/src/turns.ts index ff0ae11..16985ea 100644 --- a/src/turns.ts +++ b/src/turns.ts @@ -2,19 +2,73 @@ import { existsSync, statSync, unlinkSync } from "fs"; import { join } from "path"; -import type { AppServerClient } from "./protocol"; -import type { - UserInput, TurnStartParams, TurnStartResponse, TurnCompletedParams, - ReviewTarget, ReviewStartParams, ReviewDelivery, - TurnResult, ItemStartedParams, ItemCompletedParams, DeltaParams, - ErrorNotificationParams, - CommandApprovalRequest, FileChangeApprovalRequest, - ApprovalPolicy, ReasoningEffort, +import type { AppServerClient } from "./client"; +import { + isKnownItem, + type UserInput, type TurnStartParams, type TurnStartResponse, type TurnCompletedParams, + type ReviewTarget, type ReviewStartParams, type ReviewDelivery, + type TurnResult, type ItemStartedParams, type ItemCompletedParams, type DeltaParams, + type ErrorNotificationParams, + type CommandApprovalRequest, type FileChangeApprovalRequest, + type ApprovalPolicy, type ReasoningEffort, + type ReasoningItem, } from "./types"; import type { EventDispatcher } from "./events"; import type { ApprovalHandler } from "./approvals"; import { config } from "./config"; +// --------------------------------------------------------------------------- +// Pure helpers (exported for testing) +// --------------------------------------------------------------------------- + +/** + * Check whether a notification belongs to the current turn. + * Both threadId and turnId must match. + */ +export function belongsToTurn( + params: { threadId: string; turnId: string }, + expectedThreadId: string, + expectedTurnId: string, +): boolean { + return params.threadId === expectedThreadId && params.turnId === expectedTurnId; +} + +/** + * Extract a single reasoning string from a completed reasoning item. + * Joins summary and content arrays with newlines, deduplicates identical sections. + */ +export function extractReasoning(item: ReasoningItem): string | null { + const parts: string[] = []; + if (item.summary?.length) parts.push(...item.summary); + if (item.content?.length) parts.push(...item.content); + if (parts.length === 0) return null; + // Deduplicate identical sections (preserve order) + const seen = new Set(); + const unique: string[] = []; + for (const p of parts) { + if (!seen.has(p)) { + seen.add(p); + unique.push(p); + } + } + return unique.join("\n"); +} + +/** Merge multiple reasoning strings, deduplicating identical sections. */ +function mergeReasoningStrings(existing: string | null, addition: string): string { + if (!existing) return addition; + const allParts = [...existing.split("\n"), ...addition.split("\n")]; + const seen = new Set(); + const unique: string[] = []; + for (const p of allParts) { + if (!seen.has(p)) { + seen.add(p); + unique.push(p); + } + } + return unique.join("\n"); +} + export interface TurnOptions { dispatcher: EventDispatcher; approvalHandler: ApprovalHandler; @@ -25,6 +79,9 @@ export interface TurnOptions { approvalPolicy?: ApprovalPolicy; /** Directory for kill signal files. Defaults to config.killSignalsDir. */ killSignalsDir?: string; + /** Called with the turn ID once the turn/start (or review/start) response arrives. + * Used by the CLI signal handler to send turn/interrupt on Ctrl-C. */ + onTurnId?: (turnId: string) => void; } export interface ReviewOptions extends TurnOptions { @@ -83,6 +140,12 @@ class KillSignalError extends Error { /** * Shared turn lifecycle: register handlers, send the start request, * wait for completion, collect results, and clean up. + * + * Notification buffering: notifications may arrive before turn/start returns + * the turnId. We buffer them and replay once the turnId is known. + * + * Completion inference: if turn/completed is lost, we infer completion 250ms + * after the last agentMessage item completes (debounced). */ async function executeTurn( client: AppServerClient, @@ -97,13 +160,98 @@ async function executeTurn( const threadId = params.threadId; const signalPath = join(signalsDir, threadId); + // --- Notification buffering --- + // Before turnId is known, queue notifications. Once turn/start responds + // with the turnId, replay buffered notifications through handlers. + type BufferedNotification = { method: string; params: unknown }; + const notificationBuffer: BufferedNotification[] = []; + let turnId: string | null = null; + + // --- Turn-level structured capture --- + let turnReasoning: string | null = null; + + // --- Completion inference --- + let inferenceTimer: ReturnType | undefined; + let inferenceResolver: (() => void) | null = null; + + function clearInferenceTimer(): void { + if (inferenceTimer !== undefined) { + clearTimeout(inferenceTimer); + inferenceTimer = undefined; + } + } + + function resetInferenceTimer(): void { + clearInferenceTimer(); + if (inferenceResolver) { + inferenceTimer = setTimeout(() => { + if (inferenceResolver) inferenceResolver(); + }, 250); + } + } + + // Process an item/completed notification for reasoning extraction & completion inference + function processItemCompleted(itemParams: ItemCompletedParams): void { + const { item } = itemParams; + if (!isKnownItem(item)) return; + + // Reasoning extraction + if (item.type === "reasoning") { + const extracted = extractReasoning(item); + if (extracted) { + turnReasoning = mergeReasoningStrings(turnReasoning, extracted); + } + } + // Completion inference: agentMessage with phase "final_answer" (normal turns) + // or exitedReviewMode (reviews) starts the debounce timer. Other item types + // clear the timer to prevent premature inference while the agent is still working. + if (inferenceResolver) { + if ( + (item.type === "agentMessage" && item.phase === "final_answer") || + item.type === "exitedReviewMode" + ) { + resetInferenceTimer(); + } else { + clearInferenceTimer(); + } + } + } + // AbortController for cancelling in-flight approval polls on turn completion/timeout const abortController = new AbortController(); const unsubs = registerEventHandlers(client, opts, abortController.signal); + // Wire up item/started interception for completion inference — if new work + // starts after a final_answer, cancel the inference timer to avoid premature + // completion synthesis. + unsubs.push( + client.on("item/started", (params) => { + const p = params as ItemStartedParams; + if (turnId !== null && belongsToTurn(p, threadId, turnId) && inferenceResolver) { + clearInferenceTimer(); + } + }), + ); + + // Wire up item/completed interception for reasoning & structured capture. + // This runs alongside the dispatcher's handler (registered in registerEventHandlers). + unsubs.push( + client.on("item/completed", (params) => { + const p = params as ItemCompletedParams; + if (turnId !== null) { + if (belongsToTurn(p, threadId, turnId)) { + processItemCompleted(p); + } + } else { + // Buffer — will be replayed once turnId is known + notificationBuffer.push({ method: "item/completed", params }); + } + }), + ); + // Subscribe to turn/completed BEFORE sending the request to prevent // a race where fast turns complete before we call waitFor(). In the - // read loop (protocol.ts), a single read() chunk may contain both + // read loop (client.ts), a single read() chunk may contain both // the response and turn/completed. The while-loop dispatches them // synchronously, so the notification handler fires during dispatch — // before the response promise resolves (promise continuations are @@ -145,8 +293,42 @@ async function executeTurn( killSignal, ]); + // turnId is now known — notify caller and replay buffered notifications + turnId = turn.id; + opts.onTurnId?.(turnId); + + // Set up completion inference BEFORE replaying buffered items — if a fast + // turn delivered its final_answer item/completed before turn/start resolved, + // the replay below needs inferenceResolver to be armed so the debounce + // timer starts. Otherwise the turn waits for the full timeout. + const inferencePromise = new Promise((resolve) => { + inferenceResolver = resolve; + }); + + for (const buffered of notificationBuffer) { + if (buffered.method === "item/completed") { + const p = buffered.params as ItemCompletedParams; + if (belongsToTurn(p, threadId, turnId)) { + processItemCompleted(p); + } + } + } + notificationBuffer.length = 0; + const completedTurn = await Promise.race([ - completion.waitFor(turn.id), + completion.waitFor(turn.id).then((p) => { + // Normal path: turn/completed arrived — cancel inference timer + clearInferenceTimer(); + inferenceResolver = null; + return p; + }), + inferencePromise.then(() => { + // Inference path: turn/completed was lost — synthesize result + return { + threadId, + turn: { id: turn.id, items: [], status: "completed" as const, error: null }, + } as TurnCompletedParams; + }), killSignal, ]); @@ -157,11 +339,14 @@ async function executeTurn( // (for normal turns) or from exitedReviewMode item/completed notification // (for reviews). Note: turn/completed Turn.items is always [] per protocol // spec — items are only populated on thread/resume or thread/fork. - const output = opts.dispatcher.getAccumulatedOutput(); + // Use final answer output (excludes intermediate planning/status messages). + // Falls back to full accumulated output if no final_answer phase was seen. + const output = opts.dispatcher.getFinalAnswerOutput(); return { status: completedTurn.turn.status as TurnResult["status"], output, + reasoning: turnReasoning, filesChanged: opts.dispatcher.getFilesChanged(), commandsRun: opts.dispatcher.getCommandsRun(), error: completedTurn.turn.error?.message, @@ -173,7 +358,8 @@ async function executeTurn( opts.dispatcher.flush(); return { status: "interrupted", - output: opts.dispatcher.getAccumulatedOutput(), + output: opts.dispatcher.getFinalAnswerOutput(), + reasoning: turnReasoning, filesChanged: opts.dispatcher.getFilesChanged(), commandsRun: opts.dispatcher.getCommandsRun(), error: "Thread killed by user", @@ -182,6 +368,8 @@ async function executeTurn( } throw e; } finally { + clearInferenceTimer(); + inferenceResolver = null; killAbort.abort(); abortController.abort(); for (const unsub of unsubs) unsub(); diff --git a/src/types.ts b/src/types.ts index bdd5f79..2a2dcbb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -63,6 +63,8 @@ export interface ThreadStartParams { config?: Record; experimentalRawEvents: boolean; persistExtendedHistory: boolean; + ephemeral?: boolean; + serviceName?: string; } export interface Thread { @@ -170,6 +172,14 @@ export type CodexErrorInfo = | { responseTooManyFailedAttempts: { httpStatusCode: number | null } } | "other"; +/** Error carrying a JSON-RPC error code for protocol-level error forwarding. */ +export class RpcError extends Error { + constructor(message: string, public readonly rpcCode: number) { + super(message); + this.name = "RpcError"; + } +} + export interface TurnError { message: string; codexErrorInfo?: CodexErrorInfo | null; @@ -187,7 +197,8 @@ export interface TurnInterruptParams { // --- Items --- -export type ThreadItem = +/** Known item types with proper discriminants. */ +export type KnownThreadItem = | UserMessageItem | AgentMessageItem | PlanItem @@ -199,8 +210,21 @@ export type ThreadItem = | ImageViewItem | EnteredReviewModeItem | ExitedReviewModeItem - | ContextCompactionItem - | GenericItem; + | ContextCompactionItem; + +/** Any item from the server — known types narrow via `type` discriminant. */ +export type ThreadItem = KnownThreadItem | GenericItem; + +const KNOWN_ITEM_TYPES = new Set([ + "userMessage", "agentMessage", "plan", "reasoning", + "commandExecution", "fileChange", "mcpToolCall", "webSearch", + "imageView", "enteredReviewMode", "exitedReviewMode", "contextCompaction", +]); + +/** Narrow a ThreadItem to a known type, enabling discriminated union switches. */ +export function isKnownItem(item: ThreadItem): item is KnownThreadItem { + return KNOWN_ITEM_TYPES.has(item.type); +} export interface UserMessageItem { type: "userMessage"; @@ -431,13 +455,116 @@ export interface CommandExec { export interface TurnResult { status: "completed" | "interrupted" | "failed"; output: string; + reasoning: string | null; filesChanged: FileChange[]; commandsRun: CommandExec[]; error?: string; durationMs: number; } -// --- Short ID mapping --- +// --- Thread index (local, per-workspace) --- + +export interface ThreadIndexEntry { + threadId: string; + name: string | null; + model: string | null; + cwd: string; + createdAt: string; + updatedAt: string; +} + +export interface ThreadIndex { + [shortId: string]: ThreadIndexEntry; +} + +// --- Run ledger (local, per-workspace) --- + +export type RunKind = "task" | "review"; + +export type RunPhase = + | "starting" | "reviewing" | "editing" | "verifying" + | "running" | "investigating" | "finalizing"; + +export type RunStatus = "queued" | "running" | "completed" | "failed" | "cancelled"; + +export interface RunRecord { + runId: string; + threadId: string; + shortId: string; + kind: RunKind; + phase: RunPhase | null; + status: RunStatus; + sessionId: string | null; + logFile: string; + logOffset: number; + prompt: string | null; + model: string | null; + startedAt: string; + completedAt: string | null; + elapsed: string | null; + output: string | null; + filesChanged: FileChange[] | null; + commandsRun: CommandExec[] | null; + error: string | null; +} + +// --- Broker state (per-workspace) --- + +export interface BrokerState { + endpoint: string | null; + pid: number | null; + sessionDir: string; + startedAt: string; +} + +export interface SessionState { + sessionId: string; + startedAt: string; +} + +export type BrokerEndpointKind = "unix" | "pipe"; + +export interface ParsedEndpoint { + kind: BrokerEndpointKind; + path: string; +} + +// --- Structured review output --- + +export type ReviewSeverity = "critical" | "high" | "medium" | "low" | "info"; + +export interface ReviewFinding { + severity: ReviewSeverity; + file: string; + lineStart: number | null; + lineEnd: number | null; + confidence: number; + description: string; + recommendation: string; +} + +export type ReviewVerdict = "approve" | "needs-attention" | "request-changes"; + +export interface StructuredReviewOutput { + verdict: ReviewVerdict; + summary: string; + findings: ReviewFinding[]; + nextSteps: string[]; +} + +// --- Thread naming --- + +export interface ThreadSetNameParams { + threadId: string; + name: string; +} + +export interface ThreadSetNameResponse { + threadId: string; + name: string; +} + +// --- Short ID mapping (legacy, pending migration) --- export interface ThreadMappingEntry { threadId: string;