diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a43d5ba --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Normalize all text files to LF in the repo +* text=auto eol=lf + +# Windows scripts use CRLF (required by CMD/BAT; conventional for .ps1) +*.ps1 text eol=crlf +*.cmd text eol=crlf +*.bat text eol=crlf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bdeac4..8769c71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,11 @@ on: jobs: test: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -23,3 +27,32 @@ jobs: - name: Run tests run: bun test + + - name: Install codex CLI + run: npm install -g @openai/codex + + - name: Smoke-test installer (Linux/macOS) + if: runner.os != 'Windows' + run: | + ./install.sh + codex-collab --help + codex-collab health + + - name: Smoke-test installer (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + Set-ExecutionPolicy Bypass -Scope Process -Force + .\install.ps1 + codex-collab --help + codex-collab health + + - name: Smoke-test .cmd shim from CMD (Windows) + if: runner.os == 'Windows' + shell: cmd + run: | + REM Simulate new terminal: reload PATH from registry, keep session PATH for bun/node + for /f "delims=" %%i in ('powershell -Command "[Environment]::GetEnvironmentVariable('Path','User') + ';' + [Environment]::GetEnvironmentVariable('Path','Machine')"') do set "PATH=%%i;%PATH%" + codex-collab --help + codex-collab health + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1fe4407..2c5facc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,12 @@ bun install ./install.sh --dev # symlink for live iteration ``` +On Windows (PowerShell): + +```powershell +powershell -ExecutionPolicy Bypass -File install.ps1 -Dev +``` + ## Running Tests ```bash diff --git a/README.md b/README.md index ab556ec..1446f6a 100644 --- a/README.md +++ b/README.md @@ -21,29 +21,46 @@ codex-collab is a [Claude Code skill](https://docs.anthropic.com/en/docs/claude- - **Thread reuse** — Resume existing threads to send follow-up prompts, build on previous responses, or steer the work in a new direction. - **Approval control** — Configurable approval policies for tool calls: auto-approve, interactive, or deny. -## Prerequisites - -Tested on Linux (Ubuntu 22.04) and macOS. Both must be installed and on your PATH. - -- [Bun](https://bun.sh/) >= 1.0 — runs the CLI -- [Codex CLI](https://github.com/openai/codex) — must support `codex app-server` (tested with 0.106.0; `npm install -g @openai/codex`) - ## Installation +Requires [Bun](https://bun.sh/) >= 1.0 and [Codex CLI](https://github.com/openai/codex) (`npm install -g @openai/codex`) on your PATH. Tested on Linux (Ubuntu 22.04), macOS, and Windows 10. + ```bash git clone https://github.com/Kevin7Qi/codex-collab.git cd codex-collab +``` + +### Linux / macOS + +```bash ./install.sh ``` -The install script builds a self-contained bundle, copies it to `~/.claude/skills/codex-collab/`, and symlinks the binary. Once installed, Claude discovers the skill automatically and can invoke it without explicit prompting. +### Windows -For development (live-reloading source changes): +```powershell +powershell -ExecutionPolicy Bypass -File install.ps1 +``` + +After installation, **reopen your terminal** so the updated PATH takes effect, then run `codex-collab health` to verify. + +The installer builds a self-contained bundle, deploys it to your home directory (`~/.claude/skills/codex-collab/` on Linux/macOS, `%USERPROFILE%\.claude\skills\codex-collab\` on Windows), and adds a binary shim to your PATH. Once installed, Claude discovers the skill automatically. + +
+Development mode + +Use `--dev` to symlink source files for live-reloading instead of building a bundle: ```bash +# Linux / macOS ./install.sh --dev + +# Windows (may require Developer Mode or an elevated terminal for symlinks) +powershell -ExecutionPolicy Bypass -File install.ps1 -Dev ``` +
+ ## Quick Start ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 96a6873..609a0b9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -21,29 +21,46 @@ codex-collab 是一个 [Claude Code 技能](https://docs.anthropic.com/en/docs/c - **会话复用** — 接续先前的会话继续对话,在既有上下文基础上推进,不必从头开始。 - **审批控制** — 按需配置工具调用的审批策略:自动批准、交互确认或拒绝。 -## 环境要求 - -在 Linux (Ubuntu 22.04) 与 macOS 上测试通过。请确保以下工具已安装并加入 PATH: - -- [Bun](https://bun.sh/) >= 1.0 — 用于运行 CLI -- [Codex CLI](https://github.com/openai/codex) — 须支持 `codex app-server` 子命令(已测试 0.106.0;`npm install -g @openai/codex`) - ## 安装 +需要 [Bun](https://bun.sh/) >= 1.0 和 [Codex CLI](https://github.com/openai/codex)(`npm install -g @openai/codex`)并加入 PATH。已在 Linux (Ubuntu 22.04)、macOS 与 Windows 10 上测试通过。 + ```bash git clone https://github.com/Kevin7Qi/codex-collab.git cd codex-collab +``` + +### Linux / macOS + +```bash ./install.sh ``` -运行后会自动构建独立 bundle 并部署到 `~/.claude/skills/codex-collab/`。完成后 Claude 即可自动发现该技能,无需额外配置。 +### Windows -开发模式(源码变更实时生效): +```powershell +powershell -ExecutionPolicy Bypass -File install.ps1 +``` + +安装完成后,**重新打开终端**以使 PATH 生效,然后运行 `codex-collab health` 验证安装。 + +安装脚本会自动构建独立 bundle,部署到主目录下(Linux/macOS 为 `~/.claude/skills/codex-collab/`,Windows 为 `%USERPROFILE%\.claude\skills\codex-collab\`),并添加可执行文件到 PATH。完成后 Claude 即可自动发现该技能。 + +
+开发模式 + +使用 `--dev` 以符号链接方式安装,源码变更实时生效: ```bash +# Linux / macOS ./install.sh --dev + +# Windows(可能需要启用开发者模式或使用管理员终端以创建符号链接) +powershell -ExecutionPolicy Bypass -File install.ps1 -Dev ``` +
+ ## 快速开始 ```bash diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..5525e8d --- /dev/null +++ b/install.ps1 @@ -0,0 +1,154 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Install codex-collab on Windows. +.PARAMETER Dev + Symlink source files for live development instead of building. +#> +param( + [switch]$Dev, + [switch]$Help +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$RepoDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$SkillDir = Join-Path $env:USERPROFILE ".claude\skills\codex-collab" +$BinDir = Join-Path $env:USERPROFILE ".local\bin" + +function Show-Usage { + Write-Host "Usage: powershell -File install.ps1 [-Dev]" + Write-Host "" + Write-Host " (default) Build and copy a self-contained skill directory" + Write-Host " -Dev Symlink source files for live development" +} + +if ($Help) { + Show-Usage + exit 0 +} + +# Check prerequisites +$missing = @() +if (-not (Get-Command bun -ErrorAction SilentlyContinue)) { $missing += "bun" } +if (-not (Get-Command codex -ErrorAction SilentlyContinue)) { $missing += "codex" } + +if ($missing.Count -gt 0) { + Write-Host "Missing prerequisites: $($missing -join ', ')" + Write-Host " bun: https://bun.sh/" + Write-Host " codex: npm install -g @openai/codex" + exit 1 +} + +# Install dependencies +Write-Host "Installing dependencies..." +Push-Location $RepoDir +try { + bun install + if ($LASTEXITCODE -ne 0) { throw "'bun install' failed with exit code $LASTEXITCODE" } +} catch { + Write-Host "Error: $_" + exit 1 +} finally { + Pop-Location +} + +if ($Dev) { + Write-Host "Installing in dev mode (symlinks)..." + Write-Host "Note: Symlinks on Windows may require Developer Mode or elevated privileges." + + # Create skill directory + New-Item -ItemType Directory -Path (Join-Path $SkillDir "scripts") -Force | Out-Null + + # 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 "LICENSE.txt"); Target = (Join-Path $RepoDir "LICENSE") } + ) + + foreach ($link in $links) { + if (Test-Path $link.Path) { Remove-Item $link.Path -Force } + try { + New-Item -ItemType SymbolicLink -Path $link.Path -Target $link.Target -Force | Out-Null + } catch { + Write-Host "" + Write-Host "Error: Cannot create symlinks. Dev mode requires one of:" + Write-Host " 1. Enable Developer Mode: Settings > Update & Security > For developers" + Write-Host " 2. Run this script in an elevated (Administrator) terminal" + Write-Host "" + Write-Host "Alternatively, use build mode (without -Dev) which does not need symlinks." + exit 1 + } + } + Write-Host "Linked skill to $SkillDir" + + $shimTarget = Join-Path $RepoDir "src\cli.ts" + +} else { + Write-Host "Building..." + + # Build bundled JS + $skillBuild = Join-Path $RepoDir "skill\codex-collab" + 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" + 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" } + } 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)) + } + + # Copy SKILL.md and LICENSE + Copy-Item (Join-Path $RepoDir "SKILL.md") (Join-Path $skillBuild "SKILL.md") + Copy-Item (Join-Path $RepoDir "LICENSE") (Join-Path $skillBuild "LICENSE.txt") + + # Install skill + if (Test-Path $SkillDir) { Remove-Item $SkillDir -Recurse -Force } + New-Item -ItemType Directory -Path (Split-Path $SkillDir) -Force | Out-Null + Copy-Item $skillBuild $SkillDir -Recurse + Write-Host "Installed skill to $SkillDir" + + $shimTarget = Join-Path $SkillDir "scripts\codex-collab" +} + +# Create .cmd shim (CMD/PowerShell) and extensionless bash wrapper (Git Bash/MSYS2) +New-Item -ItemType Directory -Path $BinDir -Force | Out-Null +$cmdShim = Join-Path $BinDir "codex-collab.cmd" +[System.IO.File]::WriteAllText($cmdShim, "@bun `"$shimTarget`" %*`r`n", [System.Text.UTF8Encoding]::new($false)) +$bashShim = Join-Path $BinDir "codex-collab" +[System.IO.File]::WriteAllText($bashShim, "#!/usr/bin/env bash`nexec bun `"$shimTarget`" `"`$@`"", [System.Text.UTF8Encoding]::new($false)) +Write-Host "Created binary shims at $BinDir" + +# Add bin dir to user PATH if not already present +$userPath = [Environment]::GetEnvironmentVariable("Path", "User") +if ($userPath -notlike "*$BinDir*") { + [Environment]::SetEnvironmentVariable("Path", "$BinDir;$userPath", "User") + Write-Host "Added $BinDir to user PATH (permanent)." +} +# Update current session PATH +if ($env:Path -notlike "*$BinDir*") { + $env:Path = "$BinDir;$env:Path" +} + +# Verify and health check +Write-Host "" +codex-collab health +if ($LASTEXITCODE -ne 0) { + Write-Host "Error: 'codex-collab health' failed. The installation may be broken." + exit 1 +} + +$mode = if ($Dev) { "dev" } else { "build" } +Write-Host "" +Write-Host "Done ($mode mode). Open a new terminal, then run 'codex-collab --help' to get started." diff --git a/src/approvals.test.ts b/src/approvals.test.ts index 5506058..27b286c 100644 --- a/src/approvals.test.ts +++ b/src/approvals.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test, beforeEach } from "bun:test"; import { autoApproveHandler, InteractiveApprovalHandler } from "./approvals"; import { mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; import type { CommandApprovalRequest, FileChangeApprovalRequest } from "./types"; -const TEST_APPROVALS_DIR = `${process.env.TMPDIR || "/tmp/claude-1000"}/codex-collab-test-approvals`; +const TEST_APPROVALS_DIR = join(tmpdir(), "codex-collab-test-approvals"); beforeEach(() => { if (existsSync(TEST_APPROVALS_DIR)) rmSync(TEST_APPROVALS_DIR, { recursive: true }); @@ -51,23 +53,23 @@ describe("InteractiveApprovalHandler", () => { // Write decision file after a short delay setTimeout(() => { - writeFileSync(`${TEST_APPROVALS_DIR}/appr-001.decision`, "accept"); + writeFileSync(join(TEST_APPROVALS_DIR, "appr-001.decision"), "accept"); }, 200); const decision = await handler.handleCommandApproval(mockCommandRequest); expect(decision).toBe("accept"); expect(lines.some((l) => l.includes("APPROVAL NEEDED"))).toBe(true); // Request file should be cleaned up - expect(existsSync(`${TEST_APPROVALS_DIR}/appr-001.json`)).toBe(false); + expect(existsSync(join(TEST_APPROVALS_DIR, "appr-001.json"))).toBe(false); // Decision file should be cleaned up - expect(existsSync(`${TEST_APPROVALS_DIR}/appr-001.decision`)).toBe(false); + expect(existsSync(join(TEST_APPROVALS_DIR, "appr-001.decision"))).toBe(false); }); test("returns decline when decision file says decline", async () => { const handler = new InteractiveApprovalHandler(TEST_APPROVALS_DIR, () => {}, 100); setTimeout(() => { - writeFileSync(`${TEST_APPROVALS_DIR}/appr-001.decision`, "decline"); + writeFileSync(join(TEST_APPROVALS_DIR, "appr-001.decision"), "decline"); }, 200); const decision = await handler.handleCommandApproval(mockCommandRequest); @@ -83,14 +85,14 @@ describe("InteractiveApprovalHandler", () => { ); setTimeout(() => { - writeFileSync(`${TEST_APPROVALS_DIR}/item2.decision`, "accept"); + writeFileSync(join(TEST_APPROVALS_DIR, "item2.decision"), "accept"); }, 200); const decision = await handler.handleFileChangeApproval(mockFileChangeRequest); expect(decision).toBe("accept"); expect(lines.some((l) => l.includes("file change"))).toBe(true); // Cleanup happened - expect(existsSync(`${TEST_APPROVALS_DIR}/item2.json`)).toBe(false); + expect(existsSync(join(TEST_APPROVALS_DIR, "item2.json"))).toBe(false); }); test("uses itemId as fallback when approvalId is null", async () => { @@ -102,7 +104,7 @@ describe("InteractiveApprovalHandler", () => { }; setTimeout(() => { - writeFileSync(`${TEST_APPROVALS_DIR}/item1.decision`, "accept"); + writeFileSync(join(TEST_APPROVALS_DIR, "item1.decision"), "accept"); }, 200); const decision = await handler.handleCommandApproval(reqWithNullApprovalId); @@ -114,7 +116,7 @@ describe("InteractiveApprovalHandler", () => { // Start the approval but write decision after verifying request file setTimeout(() => { - const requestPath = `${TEST_APPROVALS_DIR}/appr-001.json`; + const requestPath = join(TEST_APPROVALS_DIR, "appr-001.json"); expect(existsSync(requestPath)).toBe(true); const content = JSON.parse(readFileSync(requestPath, "utf-8")); expect(content.type).toBe("commandExecution"); @@ -123,14 +125,14 @@ describe("InteractiveApprovalHandler", () => { expect(content.reason).toBe("network access"); expect(content.threadId).toBe("t1"); expect(content.turnId).toBe("turn1"); - writeFileSync(`${TEST_APPROVALS_DIR}/appr-001.decision`, "accept"); + writeFileSync(join(TEST_APPROVALS_DIR, "appr-001.decision"), "accept"); }, 150); await handler.handleCommandApproval(mockCommandRequest); }); test("creates approvalsDir if it does not exist", async () => { - const nestedDir = `${TEST_APPROVALS_DIR}/nested/deep`; + const nestedDir = join(TEST_APPROVALS_DIR, "nested", "deep"); const handler = new InteractiveApprovalHandler(nestedDir, () => {}, 100); expect(existsSync(nestedDir)).toBe(true); @@ -145,7 +147,7 @@ describe("InteractiveApprovalHandler", () => { ); setTimeout(() => { - writeFileSync(`${TEST_APPROVALS_DIR}/appr-001.decision`, "accept"); + writeFileSync(join(TEST_APPROVALS_DIR, "appr-001.decision"), "accept"); }, 200); await handler.handleCommandApproval(mockCommandRequest); @@ -164,14 +166,14 @@ describe("InteractiveApprovalHandler", () => { handler.handleCommandApproval(mockCommandRequest, controller.signal), ).rejects.toThrow("cancelled"); - expect(existsSync(`${TEST_APPROVALS_DIR}/appr-001.json`)).toBe(false); + expect(existsSync(join(TEST_APPROVALS_DIR, "appr-001.json"))).toBe(false); }); test("treats unknown decision text as decline", async () => { const handler = new InteractiveApprovalHandler(TEST_APPROVALS_DIR, () => {}, 100); setTimeout(() => { - writeFileSync(`${TEST_APPROVALS_DIR}/appr-001.decision`, "garbage"); + writeFileSync(join(TEST_APPROVALS_DIR, "appr-001.decision"), "garbage"); }, 200); const decision = await handler.handleCommandApproval(mockCommandRequest); diff --git a/src/approvals.ts b/src/approvals.ts index 710e1a5..6835234 100644 --- a/src/approvals.ts +++ b/src/approvals.ts @@ -1,6 +1,7 @@ // src/approvals.ts — Approval handler abstraction import { writeFileSync, readFileSync, unlinkSync, existsSync, mkdirSync } from "fs"; +import { join } from "path"; import type { ApprovalDecision, CommandApprovalRequest, @@ -77,7 +78,7 @@ export class InteractiveApprovalHandler implements ApprovalHandler { private writeRequestFile(id: string, data: unknown): void { try { - writeFileSync(`${this.approvalsDir}/${id}.json`, JSON.stringify(data, null, 2), { mode: 0o600 }); + writeFileSync(join(this.approvalsDir, `${id}.json`), JSON.stringify(data, null, 2), { mode: 0o600 }); } catch (e) { console.error(`[codex] Failed to write approval request: ${e instanceof Error ? e.message : e}`); throw e; @@ -85,8 +86,8 @@ export class InteractiveApprovalHandler implements ApprovalHandler { } private async pollForDecision(id: string, timeoutMs: number, signal?: AbortSignal): Promise { - const decisionPath = `${this.approvalsDir}/${id}.decision`; - const requestPath = `${this.approvalsDir}/${id}.json`; + const decisionPath = join(this.approvalsDir, `${id}.decision`); + const requestPath = join(this.approvalsDir, `${id}.json`); const deadline = Date.now() + timeoutMs; const cleanup = () => { diff --git a/src/cli.test.ts b/src/cli.test.ts index c2d3bfd..d9a3738 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -12,6 +12,7 @@ function run(...args: string[]): { stdout: string; stderr: string; exitCode: num encoding: "utf-8", cwd: import.meta.dir + "/..", stdio: ["pipe", "pipe", "pipe"], + timeout: 5000, }); return { stdout: (result.stdout ?? "") as string, @@ -51,15 +52,14 @@ describe("CLI valid commands", () => { describe("CLI flag parsing", () => { it("--all does not error", () => { - // --all should be accepted by the parser (jobs command needs server, so we just check parse doesn't error) - // We can't test jobs without a server, but we can verify --all isn't "Unknown option" - const { stderr, exitCode } = run("run", "test", "--all"); - // Should fail because codex isn't available, not because --all is unknown + // Use 'health' instead of 'run' to avoid starting app server (hangs if codex installed) + const { stderr } = run("health", "--all"); expect(stderr).not.toContain("Unknown option"); }); it("--content-only does not error", () => { - const { stderr } = run("run", "test", "--content-only"); + // Use 'health' instead of 'run' to avoid starting app server (hangs if codex installed) + const { stderr } = run("health", "--content-only"); expect(stderr).not.toContain("Unknown option"); }); }); @@ -87,9 +87,10 @@ describe("CLI invalid inputs", () => { expect(stderr).toContain("Invalid sandbox mode"); }); - it("run without prompt exits 1", () => { - const { exitCode } = run("run"); + it("run without prompt exits with error message", () => { + const { stderr, exitCode } = run("run"); expect(exitCode).toBe(1); + expect(stderr).toContain("No prompt provided"); }); it("unknown option exits 1", () => { diff --git a/src/cli.ts b/src/cli.ts index b34fabb..bf2f246 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -35,7 +35,7 @@ import { unlinkSync, writeFileSync, } from "fs"; -import { resolve } from "path"; +import { resolve, join } from "path"; import type { ReviewTarget, Thread, @@ -635,6 +635,16 @@ async function cmdKill(positional: string[]) { const threadId = resolveThreadId(config.threadsFile, id); + // Check local status — skip server operations if already killed + const mapping = loadThreadMapping(config.threadsFile); + const shortId = findShortId(config.threadsFile, threadId); + const localStatus = shortId ? mapping[shortId]?.lastStatus : undefined; + + if (localStatus === "killed") { + progress(`Thread ${id} is already killed`); + return; + } + const archived = await withClient(async (client) => { // Try to read thread status first and interrupt active turn if any try { @@ -683,7 +693,7 @@ function resolveLogPath(positional: string[], usage: string): string { const threadId = resolveThreadId(config.threadsFile, id); const shortId = findShortId(config.threadsFile, threadId); if (!shortId) die(`Thread not found: ${id}`); - return `${config.logsDir}/${shortId}.log`; + return join(config.logsDir, `${shortId}.log`); } async function cmdOutput(positional: string[], opts: Options) { @@ -750,11 +760,11 @@ async function cmdApproveOrDecline( if (!approvalId) die(`Usage: codex-collab ${verb} `); validateIdOrDie(approvalId); - const requestPath = `${config.approvalsDir}/${approvalId}.json`; + const requestPath = join(config.approvalsDir, `${approvalId}.json`); if (!existsSync(requestPath)) die(`No pending approval: ${approvalId}`); - const decisionPath = `${config.approvalsDir}/${approvalId}.decision`; + const decisionPath = join(config.approvalsDir, `${approvalId}.decision`); try { writeFileSync(decisionPath, decision, { mode: 0o600 }); } catch (e) { @@ -771,7 +781,7 @@ function deleteOldFiles(dir: string, maxAgeMs: number): number { const now = Date.now(); let deleted = 0; for (const file of readdirSync(dir)) { - const path = `${dir}/${file}`; + const path = join(dir, file); try { if (now - Bun.file(path).lastModified > maxAgeMs) { unlinkSync(path); @@ -804,7 +814,7 @@ async function cmdClean() { try { let lastActivity = new Date(entry.createdAt).getTime(); if (Number.isNaN(lastActivity)) lastActivity = 0; - const logPath = `${config.logsDir}/${shortId}.log`; + const logPath = join(config.logsDir, `${shortId}.log`); if (existsSync(logPath)) { lastActivity = Math.max(lastActivity, Bun.file(logPath).lastModified); } @@ -853,7 +863,7 @@ async function cmdDelete(positional: string[]) { } if (shortId) { - const logPath = `${config.logsDir}/${shortId}.log`; + const logPath = join(config.logsDir, `${shortId}.log`); if (existsSync(logPath)) unlinkSync(logPath); removeThread(config.threadsFile, shortId); } @@ -866,13 +876,15 @@ async function cmdDelete(positional: string[]) { } async function cmdHealth() { - const which = Bun.spawnSync(["which", "codex"]); + 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}`); - console.log(` codex: ${which.stdout.toString().trim()}`); + // `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); @@ -941,7 +953,7 @@ Examples: // --------------------------------------------------------------------------- /** Ensure data directories exist (called only for commands that need them). - * Config getters throw if HOME is unset, producing a clear error. */ + * 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 }); diff --git a/src/config.ts b/src/config.ts index eda2278..3d22ac0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,10 +1,12 @@ // src/config.ts — Configuration for codex-collab +import { homedir } from "os"; +import { join } from "path"; import pkg from "../package.json"; function getHome(): string { - const home = process.env.HOME; - if (!home) throw new Error("HOME environment variable is not set"); + const home = homedir(); + if (!home) throw new Error("Cannot determine home directory"); return home; } @@ -28,12 +30,12 @@ export const config = { defaultTimeout: 1200, // seconds — turn completion (20 min) requestTimeout: 30_000, // milliseconds — individual protocol requests (30s) - // Data paths — lazy via getters so HOME is validated at point of use, not import time. + // 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. - get dataDir() { return `${getHome()}/.codex-collab`; }, - get threadsFile() { return `${this.dataDir}/threads.json`; }, - get logsDir() { return `${this.dataDir}/logs`; }, - get approvalsDir() { return `${this.dataDir}/approvals`; }, + get dataDir() { return join(getHome(), ".codex-collab"); }, + get threadsFile() { return join(this.dataDir, "threads.json"); }, + get logsDir() { return join(this.dataDir, "logs"); }, + get approvalsDir() { return join(this.dataDir, "approvals"); }, // Display jobsListLimit: 20, diff --git a/src/events.test.ts b/src/events.test.ts index b7e19a4..c77aa6d 100644 --- a/src/events.test.ts +++ b/src/events.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test, beforeEach } from "bun:test"; import { EventDispatcher } from "./events"; import { mkdirSync, rmSync, readFileSync, existsSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; -const TEST_LOG_DIR = `${process.env.TMPDIR || "/tmp/claude-1000"}/codex-collab-test-logs`; +const TEST_LOG_DIR = join(tmpdir(), "codex-collab-test-logs"); beforeEach(() => { if (existsSync(TEST_LOG_DIR)) rmSync(TEST_LOG_DIR, { recursive: true }); @@ -66,7 +68,7 @@ describe("EventDispatcher", () => { }); dispatcher.flush(); - const logPath = `${TEST_LOG_DIR}/test4.log`; + const logPath = join(TEST_LOG_DIR, "test4.log"); expect(existsSync(logPath)).toBe(true); const content = readFileSync(logPath, "utf-8"); expect(content).toContain("echo hello"); @@ -138,7 +140,7 @@ describe("EventDispatcher", () => { test("progress events auto-flush to log file", () => { const dispatcher = new EventDispatcher("test-autoflush", TEST_LOG_DIR); - const logPath = `${TEST_LOG_DIR}/test-autoflush.log`; + const logPath = join(TEST_LOG_DIR, "test-autoflush.log"); // Trigger a progress event (command started) — should auto-flush without explicit flush() call dispatcher.handleItemStarted({ diff --git a/src/events.ts b/src/events.ts index 2a4c7a8..ed3f62f 100644 --- a/src/events.ts +++ b/src/events.ts @@ -1,6 +1,7 @@ // src/events.ts — Event dispatcher for app server notifications import { appendFileSync, mkdirSync, existsSync } from "fs"; +import { join } from "path"; import type { ItemStartedParams, ItemCompletedParams, DeltaParams, ErrorNotificationParams, @@ -24,7 +25,7 @@ export class EventDispatcher { onProgress?: ProgressCallback, ) { if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true }); - this.logPath = `${logsDir}/${shortId}.log`; + this.logPath = join(logsDir, `${shortId}.log`); this.onProgress = onProgress ?? ((line) => process.stderr.write(line + "\n")); } diff --git a/src/integration.test.ts b/src/integration.test.ts index 0db74cb..4d59317 100644 --- a/src/integration.test.ts +++ b/src/integration.test.ts @@ -6,7 +6,7 @@ import { connect } from "./protocol"; const runIntegration = process.env.RUN_INTEGRATION === "1" && - Bun.spawnSync(["which", "codex"]).exitCode === 0; + Bun.spawnSync([process.platform === "win32" ? "where" : "which", "codex"]).exitCode === 0; describe.skipIf(!runIntegration)("integration", () => { test("connect and list models", async () => { diff --git a/src/protocol.test.ts b/src/protocol.test.ts index 0d136e7..2d580fc 100644 --- a/src/protocol.test.ts +++ b/src/protocol.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test, beforeAll, beforeEach, afterEach } from "bun:test"; import { parseMessage, formatNotification, formatResponse, connect, type AppServerClient } from "./protocol"; +import { join } from "path"; +import { tmpdir } from "os"; // Test-local formatRequest helper with its own counter (not exported from protocol.ts // to avoid ID collisions with AppServerClient's internal counter). @@ -11,58 +13,66 @@ function formatRequest(method: string, params?: unknown): { line: string; id: nu return { line: JSON.stringify(msg) + "\n", id }; } -const TEST_DIR = `${process.env.TMPDIR || "/tmp/claude-1000"}/codex-collab-test-protocol`; -const MOCK_SERVER = `${TEST_DIR}/mock-app-server.ts`; +async function captureErrorMessage(promise: Promise): Promise { + // Workaround: bun test on Windows doesn't flush .rejects properly, so we + // capture the rejection message manually instead of using .rejects.toThrow(). + let resolved = false; + try { + await promise; + resolved = true; + } catch (e) { + return e instanceof Error ? e.message : String(e); + } + if (resolved) throw new Error("Expected promise to reject, but it resolved"); + return ""; // unreachable +} + +const TEST_DIR = join(tmpdir(), "codex-collab-test-protocol"); +const MOCK_SERVER = join(TEST_DIR, "mock-app-server.ts"); const MOCK_SERVER_SOURCE = `#!/usr/bin/env bun -const decoder = new TextDecoder(); function respond(obj) { process.stdout.write(JSON.stringify(obj) + "\\n"); } const exitEarly = process.env.MOCK_EXIT_EARLY === "1"; const errorResponse = process.env.MOCK_ERROR_RESPONSE === "1"; -async function main() { - const reader = Bun.stdin.stream().getReader(); - let buffer = ""; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - 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; } - if (msg.id !== undefined && msg.method) { - switch (msg.method) { - case "initialize": - respond({ id: msg.id, result: { userAgent: "mock-codex-server/0.1.0" } }); - if (exitEarly) setTimeout(() => process.exit(0), 50); - break; - case "thread/start": - if (errorResponse) { - respond({ id: msg.id, error: { code: -32603, message: "Internal error: model not available" } }); - } else { - respond({ id: msg.id, result: { - thread: { id: "thread-mock-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: msg.params?.model || "gpt-5.3-codex", modelProvider: "openai", - cwd: "/tmp", approvalPolicy: "never", sandbox: null, - }}); - } - break; - default: - respond({ id: msg.id, error: { code: -32601, message: "Method not found: " + msg.method } }); +let buffer = ""; +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; } + if (msg.id !== undefined && msg.method) { + switch (msg.method) { + case "initialize": + respond({ id: msg.id, result: { userAgent: "mock-codex-server/0.1.0" } }); + if (exitEarly) setTimeout(() => process.exit(0), 50); + break; + case "thread/start": + if (errorResponse) { + respond({ id: msg.id, error: { code: -32603, message: "Internal error: model not available" } }); + } else { + respond({ id: msg.id, result: { + thread: { id: "thread-mock-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: msg.params?.model || "gpt-5.3-codex", modelProvider: "openai", + cwd: "/tmp", approvalPolicy: "never", sandbox: null, + }}); } - } + break; + default: + respond({ id: msg.id, error: { code: -32601, message: "Method not found: " + msg.method } }); } } - } catch {} -} -main(); + } +}); +process.stdin.on("end", () => process.exit(0)); +process.stdin.on("error", () => process.exit(1)); `; beforeAll(async () => { @@ -226,260 +236,262 @@ describe("parseMessage", () => { // AppServerClient integration tests (using mock server) // --------------------------------------------------------------------------- +// Each test manages its own client lifecycle to avoid dangling-process races +// when bun runs tests concurrently within a describe block. describe("AppServerClient", () => { - let client: AppServerClient | null = null; - + // On Windows, bun's test runner doesn't fully await async finally blocks before moving + // to the next test. close() takes ~400ms on Windows (dominated by taskkill process tree + // cleanup). This delay ensures close() completes before the next test spawns a mock server. afterEach(async () => { - if (client) { - await client.close(); - client = null; + if (process.platform === "win32") { + await new Promise((r) => setTimeout(r, 1000)); } }); test("connect performs initialize handshake and returns userAgent", async () => { - client = await connect({ + const c = await connect({ command: ["bun", "run", MOCK_SERVER], requestTimeout: 5000, }); - expect(client.userAgent).toBe("mock-codex-server/0.1.0"); + try { + expect(c.userAgent).toBe("mock-codex-server/0.1.0"); + } finally { + await c.close(); + } }); test("close shuts down gracefully", async () => { - client = await connect({ + const c = await connect({ command: ["bun", "run", MOCK_SERVER], requestTimeout: 5000, }); - await client.close(); - client = null; + await c.close(); // No error means success — process exited cleanly }); test("request sends and receives response", async () => { - client = await connect({ + const c = await connect({ command: ["bun", "run", MOCK_SERVER], requestTimeout: 5000, }); - - const result = await client.request<{ thread: { id: string }; model: string }>( - "thread/start", - { model: "gpt-5.3-codex" }, - ); - - expect(result.thread.id).toBe("thread-mock-001"); - expect(result.model).toBe("gpt-5.3-codex"); + try { + const result = await c.request<{ thread: { id: string }; model: string }>( + "thread/start", + { model: "gpt-5.3-codex" }, + ); + expect(result.thread.id).toBe("thread-mock-001"); + expect(result.model).toBe("gpt-5.3-codex"); + } finally { + await c.close(); + } }); test("request rejects with descriptive error on JSON-RPC error response", async () => { - client = await connect({ + const c = await connect({ command: ["bun", "run", MOCK_SERVER], requestTimeout: 5000, env: { MOCK_ERROR_RESPONSE: "1" }, }); - - await expect( - client.request("thread/start", { model: "bad-model" }), - ).rejects.toThrow("JSON-RPC error -32603: Internal error: model not available"); + try { + const error = await captureErrorMessage( + c.request("thread/start", { model: "bad-model" }), + ); + expect(error).toContain( + "JSON-RPC error -32603: Internal error: model not available", + ); + } finally { + await c.close(); + } }); test("request rejects with error for unknown method", async () => { - client = await connect({ + const c = await connect({ command: ["bun", "run", MOCK_SERVER], requestTimeout: 5000, }); - - await expect( - client.request("unknown/method"), - ).rejects.toThrow("Method not found: unknown/method"); + try { + const error = await captureErrorMessage(c.request("unknown/method")); + expect(error).toContain("Method not found: unknown/method"); + } finally { + await c.close(); + } }); test("request rejects when process exits unexpectedly", async () => { - client = await connect({ + const c = await connect({ command: ["bun", "run", MOCK_SERVER], requestTimeout: 5000, env: { MOCK_EXIT_EARLY: "1" }, }); - - // The mock server exits after initialize, so the next request should fail - // Give a tiny delay for the process to actually exit - await new Promise((r) => setTimeout(r, 100)); - - await expect( - client.request("thread/start"), - ).rejects.toThrow(); + try { + // The mock server exits after initialize, so the next request should fail + await new Promise((r) => setTimeout(r, 100)); + const error = await captureErrorMessage(c.request("thread/start")); + expect(error.length).toBeGreaterThan(0); + } finally { + await c.close(); + } }); test("request rejects after client is closed", async () => { - client = await connect({ - command: ["bun", "run", MOCK_SERVER], - requestTimeout: 5000, - }); - - await client.close(); - client = null; - - // We need a fresh reference but close has been called, so we reconnect then close then try const c = await connect({ command: ["bun", "run", MOCK_SERVER], requestTimeout: 5000, }); await c.close(); - await expect( - c.request("thread/start"), - ).rejects.toThrow("Client is closed"); + const error = await captureErrorMessage(c.request("thread/start")); + expect(error).toContain("Client is closed"); }); test("notification handlers receive server notifications", async () => { // For this test we use a custom inline mock that sends a notification const notifyServer = ` - const decoder = new TextDecoder(); - async function main() { - const reader = Bun.stdin.stream().getReader(); - let buffer = ""; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - let idx; - while ((idx = buffer.indexOf("\\n")) !== -1) { - const line = buffer.slice(0, idx).trim(); - buffer = buffer.slice(idx + 1); - if (!line) continue; - const msg = JSON.parse(line); - if (msg.id !== undefined && msg.method === "initialize") { - process.stdout.write(JSON.stringify({ - id: msg.id, - result: { userAgent: "notify-server/0.1.0" }, - }) + "\\n"); - } - // After receiving "initialized" notification, send a server notification - if (!msg.id && msg.method === "initialized") { - process.stdout.write(JSON.stringify({ - method: "item/started", - params: { item: { type: "agentMessage", id: "item-1", text: "" }, threadId: "t1", turnId: "turn-1" }, - }) + "\\n"); - } - } + let buffer = ""; + 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; + const msg = JSON.parse(line); + if (msg.id !== undefined && msg.method === "initialize") { + process.stdout.write(JSON.stringify({ + id: msg.id, + result: { userAgent: "notify-server/0.1.0" }, + }) + "\\n"); } - } catch {} - } - main(); + if (!msg.id && msg.method === "initialized") { + process.stdout.write(JSON.stringify({ + method: "item/started", + params: { item: { type: "agentMessage", id: "item-1", text: "" }, threadId: "t1", turnId: "turn-1" }, + }) + "\\n"); + } + } + }); + process.stdin.on("end", () => process.exit(0)); + process.stdin.on("error", () => process.exit(1)); `; - const serverPath = `${TEST_DIR}/mock-notify-server.ts`; + const serverPath = join(TEST_DIR, "mock-notify-server.ts"); await Bun.write(serverPath, notifyServer); const received: unknown[] = []; - client = await connect({ + const c = await connect({ command: ["bun", "run", serverPath], requestTimeout: 5000, }); - client.on("item/started", (params) => { - received.push(params); - }); - - // Give time for the notification to arrive - await new Promise((r) => setTimeout(r, 200)); - - expect(received.length).toBe(1); - expect(received[0]).toEqual({ - item: { type: "agentMessage", id: "item-1", text: "" }, - threadId: "t1", - turnId: "turn-1", - }); + try { + c.on("item/started", (params) => { + received.push(params); + }); + + // Give time for the notification to arrive + await new Promise((r) => setTimeout(r, 200)); + + expect(received.length).toBe(1); + expect(received[0]).toEqual({ + item: { type: "agentMessage", id: "item-1", text: "" }, + threadId: "t1", + turnId: "turn-1", + }); + } finally { + await c.close(); + } }); test("onRequest handler responds to server requests", async () => { // Mock server that sends a server request after initialize const approvalServer = ` - const decoder = new TextDecoder(); let sentApproval = false; - async function main() { - const reader = Bun.stdin.stream().getReader(); - let buffer = ""; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - let idx; - while ((idx = buffer.indexOf("\\n")) !== -1) { - const line = buffer.slice(0, idx).trim(); - buffer = buffer.slice(idx + 1); - if (!line) continue; - const msg = JSON.parse(line); - if (msg.id !== undefined && msg.method === "initialize") { - process.stdout.write(JSON.stringify({ - id: msg.id, - result: { userAgent: "approval-server/0.1.0" }, - }) + "\\n"); - } - // After initialized notification, send a server request - if (!msg.id && msg.method === "initialized" && !sentApproval) { - sentApproval = true; - process.stdout.write(JSON.stringify({ - id: "srv-1", - method: "item/commandExecution/requestApproval", - params: { command: "rm -rf /", threadId: "t1", turnId: "turn-1", itemId: "item-1" }, - }) + "\\n"); - } - // When we get back our response, send a verification notification - if (msg.id === "srv-1" && msg.result) { - process.stdout.write(JSON.stringify({ - method: "test/approvalReceived", - params: { decision: msg.result.decision }, - }) + "\\n"); - } - } + let buffer = ""; + 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; + const msg = JSON.parse(line); + if (msg.id !== undefined && msg.method === "initialize") { + process.stdout.write(JSON.stringify({ + id: msg.id, + result: { userAgent: "approval-server/0.1.0" }, + }) + "\\n"); } - } catch {} - } - main(); + if (!msg.id && msg.method === "initialized" && !sentApproval) { + sentApproval = true; + process.stdout.write(JSON.stringify({ + id: "srv-1", + method: "item/commandExecution/requestApproval", + params: { command: "rm -rf /", threadId: "t1", turnId: "turn-1", itemId: "item-1" }, + }) + "\\n"); + } + if (msg.id === "srv-1" && msg.result) { + process.stdout.write(JSON.stringify({ + method: "test/approvalReceived", + params: { decision: msg.result.decision }, + }) + "\\n"); + } + } + }); + process.stdin.on("end", () => process.exit(0)); + process.stdin.on("error", () => process.exit(1)); `; - const serverPath = `${TEST_DIR}/mock-approval-server.ts`; + const serverPath = join(TEST_DIR, "mock-approval-server.ts"); await Bun.write(serverPath, approvalServer); - client = await connect({ + const c = await connect({ command: ["bun", "run", serverPath], requestTimeout: 5000, }); - // Register handler for approval requests - client.onRequest("item/commandExecution/requestApproval", (params: any) => { - return { decision: "accept" }; - }); + try { + // Register handler for approval requests + c.onRequest("item/commandExecution/requestApproval", (params: any) => { + return { decision: "accept" }; + }); - // Wait for the round-trip - const received: unknown[] = []; - client.on("test/approvalReceived", (params) => { - received.push(params); - }); + // Wait for the round-trip + const received: unknown[] = []; + c.on("test/approvalReceived", (params) => { + received.push(params); + }); - await new Promise((r) => setTimeout(r, 300)); + await new Promise((r) => setTimeout(r, 300)); - expect(received.length).toBe(1); - expect(received[0]).toEqual({ decision: "accept" }); + expect(received.length).toBe(1); + expect(received[0]).toEqual({ decision: "accept" }); + } finally { + await c.close(); + } }); test("on returns unsubscribe function", async () => { - client = await connect({ + const c = await connect({ command: ["bun", "run", MOCK_SERVER], requestTimeout: 5000, }); - const received: unknown[] = []; - const unsub = client.on("test/event", (params) => { - received.push(params); - }); + try { + const received: unknown[] = []; + const unsub = c.on("test/event", (params) => { + received.push(params); + }); - // Unsubscribe immediately - unsub(); + // Unsubscribe immediately + unsub(); - // Even if a notification arrived, handler should not fire - // (no notification is sent by the basic mock, but this verifies the unsub mechanism) - expect(received.length).toBe(0); + // Even if a notification arrived, handler should not fire + // (no notification is sent by the basic mock, but this verifies the unsub mechanism) + expect(received.length).toBe(0); + } finally { + await c.close(); + } }); }); diff --git a/src/protocol.ts b/src/protocol.ts index 7dcaf82..4f9d510 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -1,6 +1,7 @@ // src/protocol.ts — JSON-RPC client for Codex app server import { spawn } from "bun"; +import { spawnSync } from "child_process"; import type { JsonRpcMessage, JsonRpcRequest, @@ -89,7 +90,10 @@ export interface AppServerClient { onRequest(method: string, handler: ServerRequestHandler): () => void; /** Send a response to a server-sent request. */ respond(id: RequestId, result: unknown): void; - /** Gracefully close: close stdin -> wait 5s -> SIGTERM -> wait 3s -> SIGKILL. */ + /** 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 + * (no timed grace period, unlike Unix). */ close(): Promise; /** The user-agent string from the initialize handshake. */ userAgent: string; @@ -364,7 +368,7 @@ export async function connect(opts?: ConnectOptions): Promise { closed = true; rejectAll("Client closed"); - // Step 1: Close stdin to signal the server to exit + // Close stdin to signal the server to exit try { proc.stdin.end(); } catch (e) { @@ -373,14 +377,40 @@ export async function connect(opts?: ConnectOptions): Promise { } } - // Step 2: Wait up to 5s for graceful exit - if (await waitForExit(5000)) { await readLoop; return; } + if (process.platform === "win32") { + // Windows: no SIGTERM equivalent — process termination is immediate. + // Kill the process tree first via taskkill /T /F, then fall back to + // proc.kill(). This order matters: if codex is a .cmd wrapper, killing + // the direct child first removes the PID that taskkill needs to traverse + // the tree, potentially leaving the real app-server alive. + // Note: we intentionally do NOT await readLoop/proc.exited here because + // Bun's test runner on Windows doesn't fully await async finally blocks, + // and the extra latency causes inter-test process races. The process is + // already dead after taskkill+kill, so the dangling promise is benign. + if (proc.pid) { + try { + const r = spawnSync("taskkill", ["/PID", String(proc.pid), "/T", "/F"], { stdio: "pipe", timeout: 5000 }); + // status 128: process already exited; null: spawnSync timed out + if (r.status !== 0 && r.status !== null && r.status !== 128) { + const msg = r.stderr?.toString().trim(); + console.error(`[codex] Warning: taskkill exited ${r.status}${msg ? ": " + msg : ""}`); + } + } catch (e) { + console.error(`[codex] Warning: process tree cleanup failed: ${e instanceof Error ? e.message : String(e)}`); + } + } + try { proc.kill(); } catch (e) { + if (!exited) { + console.error(`[codex] Warning: proc.kill() failed: ${e instanceof Error ? e.message : String(e)}`); + } + } + return; + } - // Step 3: SIGTERM, wait 3s + // Unix: wait for graceful exit, then escalate + if (await waitForExit(5000)) { await readLoop; return; } proc.kill("SIGTERM"); if (await waitForExit(3000)) { await readLoop; return; } - - // Step 4: SIGKILL proc.kill("SIGKILL"); await proc.exited; await readLoop; diff --git a/src/threads.test.ts b/src/threads.test.ts index f94c463..ba39c82 100644 --- a/src/threads.test.ts +++ b/src/threads.test.ts @@ -4,8 +4,10 @@ import { resolveThreadId, registerThread, findShortId, removeThread, } from "./threads"; import { rmSync, existsSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; -const TEST_THREADS_FILE = `${process.env.TMPDIR || "/tmp/claude-1000"}/codex-collab-test-threads.json`; +const TEST_THREADS_FILE = join(tmpdir(), "codex-collab-test-threads.json"); beforeEach(() => { if (existsSync(TEST_THREADS_FILE)) rmSync(TEST_THREADS_FILE); diff --git a/src/turns.test.ts b/src/turns.test.ts index dbd4a5c..17ff893 100644 --- a/src/turns.test.ts +++ b/src/turns.test.ts @@ -9,8 +9,10 @@ import type { ReviewStartResponse, } from "./types"; import { mkdirSync, rmSync, existsSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; -const TEST_LOG_DIR = `${process.env.TMPDIR || "/tmp/claude-1000"}/codex-collab-test-turns`; +const TEST_LOG_DIR = join(tmpdir(), "codex-collab-test-turns"); beforeEach(() => { if (existsSync(TEST_LOG_DIR)) rmSync(TEST_LOG_DIR, { recursive: true });