From 885f5636b7a939785649b692bc2478f9789e052f Mon Sep 17 00:00:00 2001 From: luckeyfaraday Date: Tue, 16 Jun 2026 00:06:52 +0200 Subject: [PATCH 1/2] Let agent_stop close visible terminal windows it spawned Visible agent terminals were launched detached with their PID discarded, so nothing held a handle to the window and agent_stop only ever killed headless processes. Capture the emulator PID through openVisibleTerminal, store it on the visible agent record, and route stopLocalAgent through a process-group kill (SIGTERM to -pid) that tears down the window and the agent CLI inside it on Linux/BSD. macOS (Terminal.app via osascript) and Windows (start) don't yield a killable window PID, so launch.pid is undefined there and close degrades gracefully without offering the option. Co-Authored-By: Claude Opus 4.8 --- .../opencode/src/session/agent/local.ts | 35 ++++++++++++++++++- .../opencode/src/session/agent/terminal.ts | 8 ++++- .../packages/opencode/src/tool/agent-local.ts | 12 +++++-- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/overlay/packages/opencode/src/session/agent/local.ts b/overlay/packages/opencode/src/session/agent/local.ts index 50414c5..24072d8 100644 --- a/overlay/packages/opencode/src/session/agent/local.ts +++ b/overlay/packages/opencode/src/session/agent/local.ts @@ -28,6 +28,11 @@ 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 visible terminal emulator (process-group leader on POSIX), set + // when the window was launched with a killable handle. Lets stopLocalAgent + // close the window. Undefined on macOS/Windows where the window isn't a + // process we own. + terminalPid?: number stdout: string stderr: string // Characters trimmed from the front of each buffer once it exceeds @@ -336,9 +341,36 @@ export function readLocalAgentOutput( } } +// Close a visible terminal window we launched. The emulator was spawned +// detached (a process-group leader on POSIX), so signalling the negated PID +// tears down the whole group — the window and the agent CLI running inside it. +// Falls back to a plain single-PID kill if the group signal is rejected. +function closeVisibleTerminal(record: LocalAgentRecord): boolean { + if (record.terminalPid == null) return false + let closed = false + try { + process.kill(-record.terminalPid, "SIGTERM") + closed = true + } catch { + try { + process.kill(record.terminalPid, "SIGTERM") + closed = true + } catch { + closed = false + } + } + if (closed) { + record.visible = false + record.exitedAt = new Date().toISOString() + } + return closed +} + export function stopLocalAgent(handle: string): boolean { 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") } @@ -366,6 +398,7 @@ export function registerVisibleAgent(params: { startedAt: new Date().toISOString(), sessionId: params.sessionId, visible: true, + terminalPid: params.pid, 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..279d602 100644 --- a/overlay/packages/opencode/src/session/agent/terminal.ts +++ b/overlay/packages/opencode/src/session/agent/terminal.ts @@ -10,6 +10,12 @@ import { existsSync } from "node:fs" export type TerminalLaunch = { ok: boolean terminal?: string + // PID of the spawned emulator process. On Linux/BSD it is a process-group + // leader (detached spawn calls setsid), so killing -pid closes the window + // and the agent inside it. Undefined on platforms where we can't get a + // killable handle to the window (macOS Terminal.app via osascript, Windows + // `start`), in which case the window can't be closed programmatically yet. + pid?: number error?: string } @@ -80,7 +86,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]!), pid: child.pid } } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) } } diff --git a/overlay/packages/opencode/src/tool/agent-local.ts b/overlay/packages/opencode/src/tool/agent-local.ts index df833ce..891c585 100644 --- a/overlay/packages/opencode/src/tool/agent-local.ts +++ b/overlay/packages/opencode/src/tool/agent-local.ts @@ -80,6 +80,7 @@ export const AgentSpawnTool = tool({ command: spec.command, args: spec.args, sessionId: spec.sessionId, + pid: launch.pid, }) const note = spec.promptInjected ? `The task prompt was pre-submitted.` @@ -87,7 +88,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 ? ` Close the window with agent_stop ${record.handle}.` : "" + }`, } } const record = spawnLocalAgent({ agent, task: args.task, workspace }) @@ -146,10 +149,13 @@ 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 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 ? ` Close it later with agent_stop ${record.handle}.` : "" + }`, } } GlobalBus.emit("event", { @@ -224,7 +230,7 @@ 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".'), }, From 95d8b7c3a62a17b246f7c581285e2c083c6b31c0 Mon Sep 17 00:00:00 2001 From: luckeyfaraday Date: Tue, 16 Jun 2026 17:31:38 +0200 Subject: [PATCH 2/2] Track visible-terminal PID via a pid file, not the launcher PID The emulator launcher PID is useless for closing the window: server-style terminals (gnome-terminal, konsole, kitty) hand work to a daemon and exit, so child.pid dies immediately and signalling it does nothing. Instead, wrap the visible command in `sh -c 'printf $$ > pidfile; exec "$@"'` so the PID written is the agent CLI itself (exec preserves the PID). The record tracks the pid file; closeVisibleTerminal resolves the PID from it, SIGTERMs the process group (falling back to the bare PID), waits for exit, then marks the record reusable. stopLocalAgent is now async. Also clean up the tmp pid file on respawn and after a successful read so it doesn't linger in tmpdir. macOS/Windows still yield no PID and degrade gracefully. Adds tests for tracked-pid close, untracked-stays-blocked, and the Linux pid-file round-trip through a fake terminal. Co-Authored-By: Claude Opus 4.8 --- .../opencode/src/session/agent/local.ts | 117 ++++++++++++++---- .../opencode/src/session/agent/terminal.ts | 28 +++-- .../packages/opencode/src/tool/agent-local.ts | 8 +- test/agent-local.test.ts | 89 ++++++++++++- 4 files changed, 207 insertions(+), 35 deletions(-) diff --git a/overlay/packages/opencode/src/session/agent/local.ts b/overlay/packages/opencode/src/session/agent/local.ts index 24072d8..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,11 +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 visible terminal emulator (process-group leader on POSIX), set - // when the window was launched with a killable handle. Lets stopLocalAgent - // close the window. Undefined on macOS/Windows where the window isn't a - // process we own. + // 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 @@ -269,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, @@ -341,32 +345,99 @@ export function readLocalAgentOutput( } } -// Close a visible terminal window we launched. The emulator was spawned -// detached (a process-group leader on POSIX), so signalling the negated PID -// tears down the whole group — the window and the agent CLI running inside it. -// Falls back to a plain single-PID kill if the group signal is rejected. -function closeVisibleTerminal(record: LocalAgentRecord): boolean { - if (record.terminalPid == null) return false - let closed = false +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(-record.terminalPid, "SIGTERM") - closed = true + process.kill(-pid, "SIGTERM") + return true } catch { try { - process.kill(record.terminalPid, "SIGTERM") - closed = true + process.kill(pid, "SIGTERM") + return true } catch { - closed = false + return false } } - if (closed) { - record.visible = false - record.exitedAt = new Date().toISOString() +} + +// 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 } - return closed + if (!signalVisiblePid(pid)) return false + if (!(await waitForProcessExit(pid, 1500))) return false + markVisibleExited(record, "SIGTERM") + return true } -export function stopLocalAgent(handle: string): boolean { +export async function stopLocalAgent(handle: string): Promise { const record = agents.get(handle) if (!record) return false if (record.visible) return closeVisibleTerminal(record) @@ -385,6 +456,7 @@ export function registerVisibleAgent(params: { command: string args: string[] pid?: number + pidFile?: string sessionId?: string }): LocalAgentRecord { const record: LocalAgentRecord = { @@ -399,6 +471,7 @@ export function registerVisibleAgent(params: { 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 279d602..2e51b77 100644 --- a/overlay/packages/opencode/src/session/agent/terminal.ts +++ b/overlay/packages/opencode/src/session/agent/terminal.ts @@ -4,18 +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 spawned emulator process. On Linux/BSD it is a process-group - // leader (detached spawn calls setsid), so killing -pid closes the window - // and the agent inside it. Undefined on platforms where we can't get a - // killable handle to the window (macOS Terminal.app via osascript, Windows - // `start`), in which case the window can't be closed programmatically yet. + // 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 } @@ -76,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, @@ -86,7 +95,7 @@ function launchDetached(argv: string[], cwd: string): TerminalLaunch { }) child.on("error", () => {}) child.unref() - return { ok: true, terminal: basename(argv[0]!), pid: child.pid } + return { ok: true, terminal: basename(argv[0]!), pidFile } } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) } } @@ -121,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 891c585..05e0598 100644 --- a/overlay/packages/opencode/src/tool/agent-local.ts +++ b/overlay/packages/opencode/src/tool/agent-local.ts @@ -81,6 +81,7 @@ export const AgentSpawnTool = tool({ args: spec.args, sessionId: spec.sessionId, pid: launch.pid, + pidFile: launch.pidFile, }) const note = spec.promptInjected ? `The task prompt was pre-submitted.` @@ -89,7 +90,7 @@ export const AgentSpawnTool = tool({ 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.${ - launch.pid ? ` Close the window with agent_stop ${record.handle}.` : "" + launch.pid || launch.pidFile ? ` Close the window with agent_stop ${record.handle}.` : "" }`, } } @@ -150,11 +151,12 @@ export const AgentTakeoverTool = tool({ } 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.${ - launch.pid ? ` Close it later with agent_stop ${record.handle}.` : "" + launch.pid || launch.pidFile ? ` Close it later with agent_stop ${record.handle}.` : "" }`, } } @@ -236,7 +238,7 @@ export const AgentStopTool = tool({ }, 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",