diff --git a/overlay/packages/opencode/src/session/agent/local.ts b/overlay/packages/opencode/src/session/agent/local.ts index 50414c5..7038c61 100644 --- a/overlay/packages/opencode/src/session/agent/local.ts +++ b/overlay/packages/opencode/src/session/agent/local.ts @@ -1,8 +1,9 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process" import { randomUUID } from "node:crypto" -import { existsSync } from "node:fs" +import { existsSync, readFileSync, unlinkSync } from "node:fs" import { homedir } from "node:os" import { join } from "node:path" +import { setTimeout as sleep } from "node:timers/promises" export type LocalAgentKind = "claude" | "codex" | "opencode" | "hermes" @@ -28,6 +29,12 @@ export type LocalAgentRecord = { // True when the agent runs interactively in its own terminal window: no // stdout/stderr is captured and stdin cannot be messaged. visible?: boolean + // PID of the command running inside a visible terminal, when known. Killing + // this process/group lets the terminal close once the command exits. + terminalPid?: number + // Linux visible-terminal launches write the command PID here from inside the + // terminal because many emulator launcher processes exit immediately. + terminalPidFile?: string stdout: string stderr: string // Characters trimmed from the front of each buffer once it exceeds @@ -264,6 +271,8 @@ export function respawnLocalAgent( record.command = spec.command record.args = spec.args record.visible = false + record.terminalPid = undefined + discardTerminalPidFile(record) const child = spawn(spec.command, spec.args, { cwd: record.workspace, env: process.env, @@ -336,9 +345,103 @@ export function readLocalAgentOutput( } } -export function stopLocalAgent(handle: string): boolean { +function markVisibleExited(record: LocalAgentRecord, signal: NodeJS.Signals | null): void { + record.visible = false + record.exitedAt = new Date().toISOString() + record.exitCode = null + record.signal = signal +} + +// Remove the terminal PID file from tmp and forget it. Safe to call when no +// file is tracked; swallows unlink errors (already gone, or never written). +function discardTerminalPidFile(record: LocalAgentRecord): void { + if (!record.terminalPidFile) return + try { + unlinkSync(record.terminalPidFile) + } catch {} + record.terminalPidFile = undefined +} + +function readTerminalPidFile(record: LocalAgentRecord): number | undefined { + if (!record.terminalPidFile) return undefined + try { + const pid = Number.parseInt(readFileSync(record.terminalPidFile, "utf8").trim(), 10) + if (!Number.isSafeInteger(pid) || pid <= 0) return undefined + record.pid = pid + record.terminalPid = pid + discardTerminalPidFile(record) + return pid + } catch { + return undefined + } +} + +async function resolveVisibleTerminalPid(record: LocalAgentRecord, timeoutMs: number): Promise { + if (record.terminalPid != null) return record.terminalPid + const deadline = Date.now() + timeoutMs + do { + const pid = readTerminalPidFile(record) + if (pid != null) return pid + if (!record.terminalPidFile) return undefined + await sleep(50) + } while (Date.now() < deadline) + return readTerminalPidFile(record) +} + +function processExists(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return error instanceof Error && "code" in error && error.code !== "ESRCH" + } +} + +async function waitForProcessExit(pid: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + do { + if (!processExists(pid)) return true + await sleep(50) + } while (Date.now() < deadline) + return !processExists(pid) +} + +function signalVisiblePid(pid: number): boolean { + try { + process.kill(-pid, "SIGTERM") + return true + } catch { + try { + process.kill(pid, "SIGTERM") + return true + } catch { + return false + } + } +} + +// Close a visible terminal by terminating the foreground command running inside +// it. Linux launches track that command via a PID file so server-style terminal +// emulators don't leave us holding a short-lived launcher PID. The record only +// becomes reusable after the tracked process is observed gone. +async function closeVisibleTerminal(record: LocalAgentRecord): Promise { + const pid = await resolveVisibleTerminalPid(record, 1000) + if (pid == null) return false + if (!processExists(pid)) { + markVisibleExited(record, null) + return true + } + if (!signalVisiblePid(pid)) return false + if (!(await waitForProcessExit(pid, 1500))) return false + markVisibleExited(record, "SIGTERM") + return true +} + +export async function stopLocalAgent(handle: string): Promise { const record = agents.get(handle) - if (!record?.process) return false + if (!record) return false + if (record.visible) return closeVisibleTerminal(record) + if (!record.process) return false return record.process.kill("SIGTERM") } @@ -353,6 +456,7 @@ export function registerVisibleAgent(params: { command: string args: string[] pid?: number + pidFile?: string sessionId?: string }): LocalAgentRecord { const record: LocalAgentRecord = { @@ -366,6 +470,8 @@ export function registerVisibleAgent(params: { startedAt: new Date().toISOString(), sessionId: params.sessionId, visible: true, + terminalPid: params.pid, + terminalPidFile: params.pidFile, stdout: "", stderr: "", stdoutDropped: 0, diff --git a/overlay/packages/opencode/src/session/agent/terminal.ts b/overlay/packages/opencode/src/session/agent/terminal.ts index 3c591ea..2e51b77 100644 --- a/overlay/packages/opencode/src/session/agent/terminal.ts +++ b/overlay/packages/opencode/src/session/agent/terminal.ts @@ -4,12 +4,23 @@ // known emulator found on PATH per platform. import { spawn, spawnSync } from "node:child_process" -import { basename, delimiter, join } from "node:path" +import { randomUUID } from "node:crypto" import { existsSync } from "node:fs" +import { tmpdir } from "node:os" +import { basename, delimiter, join } from "node:path" export type TerminalLaunch = { ok: boolean terminal?: string + // PID of the command running inside the visible terminal, when known at + // launch time. This is intentionally not the emulator launcher PID because + // common terminals hand work to a server process and then exit. + pid?: number + // On Linux the visible command writes its PID here from inside the terminal; + // callers can read it later if it was not available before this function + // returned. Undefined on macOS/Windows where closing the window is not yet + // supported reliably. + pidFile?: string error?: string } @@ -70,7 +81,11 @@ function shellQuote(token: string): string { return `'${token.replace(/'/g, `'\\''`)}'` } -function launchDetached(argv: string[], cwd: string): TerminalLaunch { +function withPidCapture(argv: string[], pidFile: string): string[] { + return ["sh", "-c", 'pidfile=$1; shift; printf "%s\\n" "$$" > "$pidfile"; exec "$@"', "athena-terminal", pidFile, ...argv] +} + +function launchDetached(argv: string[], cwd: string, pidFile?: string): TerminalLaunch { try { const child = spawn(argv[0]!, argv.slice(1), { cwd, @@ -80,7 +95,7 @@ function launchDetached(argv: string[], cwd: string): TerminalLaunch { }) child.on("error", () => {}) child.unref() - return { ok: true, terminal: basename(argv[0]!) } + return { ok: true, terminal: basename(argv[0]!), pidFile } } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) } } @@ -115,5 +130,6 @@ export function openVisibleTerminal(argv: string[], cwd: string): TerminalLaunch error: "No terminal emulator found on PATH (set ATHENA_TERMINAL to your terminal command).", } } - return launchDetached(emulatorArgv(candidates[0]!, argv, cwd), cwd) + const pidFile = join(tmpdir(), `athena-terminal-${randomUUID()}.pid`) + return launchDetached(emulatorArgv(candidates[0]!, withPidCapture(argv, pidFile), cwd), cwd, pidFile) } diff --git a/overlay/packages/opencode/src/tool/agent-local.ts b/overlay/packages/opencode/src/tool/agent-local.ts index df833ce..05e0598 100644 --- a/overlay/packages/opencode/src/tool/agent-local.ts +++ b/overlay/packages/opencode/src/tool/agent-local.ts @@ -80,6 +80,8 @@ export const AgentSpawnTool = tool({ command: spec.command, args: spec.args, sessionId: spec.sessionId, + pid: launch.pid, + pidFile: launch.pidFile, }) const note = spec.promptInjected ? `The task prompt was pre-submitted.` @@ -87,7 +89,9 @@ export const AgentSpawnTool = tool({ return { title: "Agent opened in terminal", metadata: { handle: record.handle, terminal: launch.terminal }, - output: `${render(record)}\nOpened in a new ${launch.terminal} window. ${note} Output is not captured for visible agents.`, + output: `${render(record)}\nOpened in a new ${launch.terminal} window. ${note} Output is not captured for visible agents.${ + launch.pid || launch.pidFile ? ` Close the window with agent_stop ${record.handle}.` : "" + }`, } } const record = spawnLocalAgent({ agent, task: args.task, workspace }) @@ -146,10 +150,14 @@ export const AgentTakeoverTool = tool({ return { title: "Terminal launch failed", metadata: { error: true }, output: launch.error ?? "Could not open a terminal window." } } record.visible = true + record.terminalPid = launch.pid + record.terminalPidFile = launch.pidFile return { title: "Session opened in terminal", metadata: { handle: record.handle, sessionId, terminal: launch.terminal }, - output: `Resumed ${record.agent} session ${sessionId} in a new ${launch.terminal} window.`, + output: `Resumed ${record.agent} session ${sessionId} in a new ${launch.terminal} window.${ + launch.pid || launch.pidFile ? ` Close it later with agent_stop ${record.handle}.` : "" + }`, } } GlobalBus.emit("event", { @@ -224,13 +232,13 @@ export const AgentMessageTool = tool({ }) export const AgentStopTool = tool({ - description: "Stop a running local agent spawned by Athena Code. Use when the user asks to stop, kill, or terminate a specific spawned agent handle.", + description: "Stop a local agent spawned by Athena Code. Kills a headless agent process, or closes the window of an agent opened in a visible terminal (on Linux/BSD). Use when the user asks to stop, kill, terminate, or close a specific spawned agent handle.", args: { handle: tool.schema.string().describe('Agent handle, for example "claude#1" or "codex#1".'), }, async execute(args) { const record = getLocalAgent(args.handle) - const ok = stopLocalAgent(args.handle) + const ok = await stopLocalAgent(args.handle) return { title: ok ? "Agent stopped" : "Agent not running", metadata: { handle: args.handle, ok }, output: record ? render(record) : `${args.handle} does not exist.` } }, }) diff --git a/test/agent-local.test.ts b/test/agent-local.test.ts index d695223..4cb6c0e 100644 --- a/test/agent-local.test.ts +++ b/test/agent-local.test.ts @@ -1,5 +1,6 @@ import { test, expect } from "bun:test" -import { mkdtempSync } from "node:fs" +import { spawn } from "node:child_process" +import { mkdtempSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { @@ -15,8 +16,10 @@ import { resolveLocalAgentSessionId, respawnLocalAgent, spawnLocalAgentCommand, + stopLocalAgent, waitLocalAgent, } from "../overlay/packages/opencode/src/session/agent/local" +import { openVisibleTerminal } from "../overlay/packages/opencode/src/session/agent/terminal" function workspace(prefix: string): string { return mkdtempSync(join(tmpdir(), prefix)) @@ -251,6 +254,90 @@ test("visible agents are listed but not resumed headless", async () => { expect((await continueLocalAgent(record.handle, "hello")).status).toBe("terminal") }) +test("stopLocalAgent keeps untracked visible terminals blocked", async () => { + const record = registerVisibleAgent({ + agent: "claude", + task: "visible run", + workspace: workspace("athagent-"), + command: "claude", + args: ["visible run"], + sessionId: "uuid-2", + }) + + expect(await stopLocalAgent(record.handle)).toBe(false) + expect(record.visible).toBe(true) + expect(record.exitedAt).toBeUndefined() + expect(localAgentTakeoverBlockReason(record)).toBe("terminal") +}) + +test("stopLocalAgent closes visible agents with a tracked pid", async () => { + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + detached: true, + stdio: "ignore", + }) + const pid = child.pid! + const closed = new Promise((resolve) => child.once("close", () => resolve())) + const record = registerVisibleAgent({ + agent: "claude", + task: "visible run", + workspace: workspace("athagent-"), + command: process.execPath, + args: [], + pid, + sessionId: "uuid-3", + }) + + try { + expect(await stopLocalAgent(record.handle)).toBe(true) + await closed + expect(record.visible).toBe(false) + expect(record.exitedAt).toBeDefined() + expect(record.signal).toBe("SIGTERM") + } finally { + try { + process.kill(-pid, "SIGKILL") + } catch {} + } +}) + +test("openVisibleTerminal tracks Linux command pid through a pid file", async () => { + if (process.platform !== "linux") return + const dir = workspace("athagent-terminal-") + const terminal = join(dir, "fake-terminal") + writeFileSync(terminal, '#!/bin/sh\nif [ "$1" = "-e" ]; then shift; fi\nexec "$@"\n', { mode: 0o755 }) + + const previousAthenaTerminal = process.env.ATHENA_TERMINAL + const previousTerminal = process.env.TERMINAL + process.env.ATHENA_TERMINAL = terminal + delete process.env.TERMINAL + try { + const launch = openVisibleTerminal([process.execPath, "-e", "setInterval(() => {}, 1000)"], dir) + expect(launch.ok).toBe(true) + expect(launch.pid).toBeUndefined() + expect(launch.pidFile).toBeDefined() + + const record = registerVisibleAgent({ + agent: "claude", + task: "visible run", + workspace: dir, + command: process.execPath, + args: [], + pid: launch.pid, + pidFile: launch.pidFile, + sessionId: "uuid-4", + }) + + expect(await stopLocalAgent(record.handle)).toBe(true) + expect(record.terminalPid).toBeGreaterThan(0) + expect(record.visible).toBe(false) + } finally { + if (previousAthenaTerminal === undefined) delete process.env.ATHENA_TERMINAL + else process.env.ATHENA_TERMINAL = previousAthenaTerminal + if (previousTerminal === undefined) delete process.env.TERMINAL + else process.env.TERMINAL = previousTerminal + } +}) + test("one-shot agents get stdin closed so stdin-draining CLIs exit", async () => { const record = spawnLocalAgentCommand({ agent: "codex",