diff --git a/packages/cli/src/commands/agent/attach.ts b/packages/cli/src/commands/agent/attach.ts index 522905c033..a881f2d7f8 100644 --- a/packages/cli/src/commands/agent/attach.ts +++ b/packages/cli/src/commands/agent/attach.ts @@ -6,6 +6,7 @@ export function addAttachOptions(cmd: Command): Command { .argument("", "Agent ID (or prefix)"); } import { connectToDaemon } from "../../utils/client.js"; +import { waitForStop } from "../../utils/wait-for-stop.js"; import { fetchProjectedTimelineItems, LIVE_HISTORY_FETCH_TIMEOUT_MS, @@ -157,31 +158,10 @@ export async function runAttachCommand( await unsubscribe.ready; console.log(`Attached to agent ${resolvedId.substring(0, 7)}.`); - // Handle Ctrl+C to detach gracefully - let detached = false; - const detach = () => { - if (detached) return; - detached = true; - - console.log("\n\nDetaching from agent..."); - unsubscribe(); - client - .close() - .then(() => { - process.exit(0); - }) - .catch(() => { - process.exit(1); - }); - }; - - process.on("SIGINT", detach); - process.on("SIGTERM", detach); - - // Keep the process alive - await new Promise(() => { - // Wait indefinitely until interrupted - }); + await waitForStop(); + console.log("\n\nDetaching from agent..."); + unsubscribe(); + await client.close(); } catch (err) { await client.close().catch(() => {}); const message = err instanceof Error ? err.message : String(err); diff --git a/packages/cli/src/commands/agent/logs.ts b/packages/cli/src/commands/agent/logs.ts index 6e32690b39..f555e27b80 100644 --- a/packages/cli/src/commands/agent/logs.ts +++ b/packages/cli/src/commands/agent/logs.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import { connectToDaemon } from "../../utils/client.js"; +import { waitForStop } from "../../utils/wait-for-stop.js"; import type { CommandOptions } from "../../output/index.js"; import { fetchProjectedTimelineItems, @@ -224,16 +225,7 @@ async function runFollowMode( await unsubscribe.ready; console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`); - // Wait for interrupt - await new Promise((resolve) => { - const cleanup = () => { - unsubscribe(); - resolve(); - }; - - process.on("SIGINT", cleanup); - process.on("SIGTERM", cleanup); - }); - + await waitForStop(); + unsubscribe(); await client.close(); } diff --git a/packages/cli/src/run.test.ts b/packages/cli/src/run.test.ts index 66f4c9b613..651719e806 100644 --- a/packages/cli/src/run.test.ts +++ b/packages/cli/src/run.test.ts @@ -1,10 +1,39 @@ +import { spawn } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { createCli } from "./cli"; import { createCliParseArgv } from "./run"; +const runModuleUrl = new URL("./run.ts", import.meta.url).href; + +// Runs the CLI in a child process whose stdout pipe has no reader, as when the +// program that launched it has already closed its end. +async function runCliWithClosedStdout( + argv: string[], +): Promise<{ code: number | null; stderr: string }> { + const script = ` + const { runCli } = await import(${JSON.stringify(runModuleUrl)}); + process.exitCode = await runCli(${JSON.stringify(argv)}); + await new Promise((resolve) => setTimeout(resolve, 100)); + process.stderr.write("still running\\n"); + `; + const child = spawn(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], { + cwd: path.dirname(fileURLToPath(import.meta.url)), + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout.destroy(); + let stderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + const code = await new Promise((resolve) => child.on("close", resolve)); + return { code, stderr }; +} + describe("runCli", () => { it("lets plugin update own --version while preserving global output options", async () => { const cli = createCli() @@ -72,6 +101,19 @@ describe("runCli", () => { ).toEqual(["node", "paseo", "hooks", "claude", "UserPromptSubmit"]); }); + it("finishes the command quietly when stdout is closed before it writes its output", async () => { + const home = mkdtempSync(path.join(tmpdir(), "paseo-cli-closed-stdout-")); + + try { + const result = await runCliWithClosedStdout(["daemon", "status", "--json", "--home", home]); + + expect(result.stderr).toBe("still running\n"); + expect(result.code).toBe(0); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }, 30_000); + it("classifies existing unknown directories as open-project invocations", () => { const root = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-")); const project = path.join(root, "repository"); diff --git a/packages/cli/src/run.ts b/packages/cli/src/run.ts index b95dd9b419..ac67e80745 100644 --- a/packages/cli/src/run.ts +++ b/packages/cli/src/run.ts @@ -36,7 +36,21 @@ export function createCliParseArgv(input: { return [...nodeArgv, ...cliArgv]; } +// The program that started the CLI can close its end of stdout before the output +// is written, as `paseo ls | head -1` or a launcher that discards output does. +// Drop the output nobody reads and let the command finish, instead of surfacing +// the failed write as an uncaught error. +function ignoreClosedStdout(error: NodeJS.ErrnoException): void { + if (error.code !== "EPIPE") { + throw error; + } +} + export async function runCli(argv: string[], options: RunCliOptions = {}): Promise { + if (!process.stdout.listeners("error").includes(ignoreClosedStdout)) { + process.stdout.on("error", ignoreClosedStdout); + } + const parseArgv = createCliParseArgv({ argv, cwd: options.cwd ?? process.cwd(), diff --git a/packages/cli/src/utils/wait-for-stop.ts b/packages/cli/src/utils/wait-for-stop.ts new file mode 100644 index 0000000000..8430c39726 --- /dev/null +++ b/packages/cli/src/utils/wait-for-stop.ts @@ -0,0 +1,24 @@ +// Commands that stream until stopped (`paseo agent logs --follow`, `paseo agent +// attach`) stop on Ctrl+C, a termination signal, or when the program reading +// their output closes its end, as `| head` does. A closed reader shows up as +// EPIPE on the next write to stdout, so the command stops at its next line of +// output. +export function waitForStop(): Promise { + return new Promise((resolve) => { + const stop = () => { + process.off("SIGINT", stop); + process.off("SIGTERM", stop); + process.stdout.off("error", stopOnClosedStdout); + resolve(); + }; + const stopOnClosedStdout = (error: NodeJS.ErrnoException) => { + if (error.code === "EPIPE") { + stop(); + } + }; + + process.on("SIGINT", stop); + process.on("SIGTERM", stop); + process.stdout.on("error", stopOnClosedStdout); + }); +} diff --git a/packages/cli/tests/43-agent-follow-closed-stdout.test.ts b/packages/cli/tests/43-agent-follow-closed-stdout.test.ts new file mode 100644 index 0000000000..0191867438 --- /dev/null +++ b/packages/cli/tests/43-agent-follow-closed-stdout.test.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env npx tsx + +/** + * Follow commands stop when nobody reads their output + * + * `paseo agent logs --follow` and `paseo agent attach` stream until they are + * stopped. A program that closes its end of their stdout, as `| head` or a + * launcher that discards output does, stops them the same way Ctrl+C does. + */ + +import assert from "node:assert"; +import { connectToDaemon } from "../src/utils/client.ts"; +import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts"; + +console.log("=== Follow Commands With Closed Stdout Tests ===\n"); + +const daemon = await startTestDaemon(); +const host = `127.0.0.1:${daemon.port}`; + +try { + // An agent created without a prompt stays idle, so no provider is called. + const client = await connectToDaemon({ target: { kind: "endpoint", host } }); + const agent = await client.createAgent({ + provider: "claude", + cwd: daemon.workDir, + title: "closed stdout", + }); + await client.close(); + + for (const args of [ + ["agent", "logs", agent.id, "--follow"], + ["agent", "attach", agent.id], + ]) { + const label = `paseo ${args.slice(0, 2).join(" ")}`; + console.log(`Test: ${label} exits when its stdout reader is gone`); + const result = await runPaseoCli(daemon, [...args, "--host", host], { + closedStdout: true, + timeout: 20_000, + }); + + assert.strictEqual(result.stderr, "", `${label} should exit quietly`); + assert.strictEqual(result.exitCode, 0, `${label} should exit with code 0`); + console.log(`✓ ${label} exits when its stdout reader is gone\n`); + } +} finally { + await daemon.stop(); +} + +console.log("=== All follow commands with closed stdout tests passed ==="); diff --git a/packages/cli/tests/helpers/test-daemon.ts b/packages/cli/tests/helpers/test-daemon.ts index f8be7e6ead..5b5513d1a7 100644 --- a/packages/cli/tests/helpers/test-daemon.ts +++ b/packages/cli/tests/helpers/test-daemon.ts @@ -352,6 +352,8 @@ export async function runPaseoCli( timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv; + /** Close the reading end of the CLI's stdout, as a launcher that discards output does. */ + closedStdout?: boolean; }, ): Promise<{ exitCode: number; stdout: string; stderr: string }> { const timeout = options?.timeout ?? 60000; @@ -380,6 +382,10 @@ export async function runPaseoCli( const stdout = createOutputCapture(); const stderr = createOutputCapture(); + if (options?.closedStdout) { + proc.stdout?.destroy(); + } + proc.stdout?.on("data", (data) => { appendOutputCapture(stdout, data); });