Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 109 additions & 3 deletions overlay/packages/opencode/src/session/agent/local.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<number | undefined> {
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<boolean> {
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<boolean> {
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<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")
}

Expand All @@ -353,6 +456,7 @@ export function registerVisibleAgent(params: {
command: string
args: string[]
pid?: number
pidFile?: string
sessionId?: string
}): LocalAgentRecord {
const record: LocalAgentRecord = {
Expand All @@ -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,
Expand Down
24 changes: 20 additions & 4 deletions overlay/packages/opencode/src/session/agent/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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,
Expand All @@ -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) }
}
Expand Down Expand Up @@ -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)
}
16 changes: 12 additions & 4 deletions overlay/packages/opencode/src/tool/agent-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,18 @@ 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.`
: `${agent} cannot pre-fill a prompt; the user must paste the task into the new window.`
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 })
Expand Down Expand Up @@ -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", {
Expand Down Expand Up @@ -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.` }
},
})
Expand Down
89 changes: 88 additions & 1 deletion test/agent-local.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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))
Expand Down Expand Up @@ -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<void>((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",
Expand Down