Files
paseo/packages/cli/src/commands/agent/attach.ts
T
paseo-bot[bot] f24bb523e8 Exit CLI commands quietly when their output is no longer read (#6097)
* fix(cli): exit quietly when stdout's reader is gone

When the program that launched the CLI closed its end of stdout, the
output write failed with EPIPE and nothing handled the error. Node
printed an unhandled 'error' event and exited 1, and the desktop app's
CLI passthrough could raise Electron's main-process error dialog.

runCli now exits with code 0 on EPIPE from stdout. Other stdout errors
still surface as before.

Closes #5982

* fix(cli): keep running when stdout's reader is gone

Exiting on EPIPE ended a command's own lifecycle: `paseo daemon run`
writes its ready line and then supervises the daemon child, so exiting
there left the child running without its parent. Drop output nobody
reads instead, and let every command finish with its own exit code.

* fix(cli): stop following when stdout's reader is gone

With EPIPE ignored, `paseo agent logs --follow` and `paseo agent attach`
kept running after the program reading their output closed it, until
interrupted. Both now wait through one helper that stops on SIGINT,
SIGTERM, or EPIPE on stdout, so a closed reader stops them at their next
line of output the same way Ctrl+C does.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 09:18:15 +02:00

172 lines
4.9 KiB
TypeScript

import type { Command } from "commander";
export function addAttachOptions(cmd: Command): Command {
return cmd
.description("Attach to a running agent's output stream")
.argument("<id>", "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,
} from "../../utils/timeline.js";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
export interface AgentAttachOptions {
host?: string;
daemonTarget: import("../../utils/daemon-target.js").DaemonTarget;
[key: string]: unknown;
}
/**
* Format and print a timeline item to the terminal
*/
function printTimelineItem(item: AgentTimelineItem): void {
switch (item.type) {
case "assistant_message":
// Print assistant text directly
process.stdout.write(item.text);
break;
case "reasoning":
// Print reasoning in a muted color if available
console.log(`\n[Reasoning] ${item.text}`);
break;
case "tool_call": {
const toolName = item.name;
const status = item.status ?? "started";
console.log(`\n[Tool: ${toolName}] ${status}`);
break;
}
case "todo": {
const completed = item.items.filter((i) => i.completed).length;
const total = item.items.length;
console.log(`\n[Todo] ${completed}/${total} completed`);
break;
}
case "error":
console.error(`\n[Error] ${item.message}`);
break;
case "user_message":
console.log(`\n[User] ${item.text}`);
break;
default:
// Unknown item type, skip
break;
}
}
/**
* Format and print a stream event to the terminal
*/
function printStreamEvent(event: AgentStreamEventPayload): void {
switch (event.type) {
case "timeline":
// Print the timeline item
printTimelineItem(event.item);
break;
case "permission_requested":
console.log(`\n[Permission Required] ${event.request.name}`);
if (event.request.description) {
console.log(` ${event.request.description}`);
}
break;
case "permission_resolved":
console.log(`\n[Permission ${event.resolution.behavior}]`);
break;
case "turn_failed":
console.error(`\n[Turn Failed] ${event.error}`);
break;
case "attention_required":
console.log(`\n[Attention Required: ${event.reason}]`);
break;
default:
// Other event types are internal
break;
}
}
/**
* Attach to a running agent's output stream
*/
export async function runAttachCommand(
id: string,
options: AgentAttachOptions,
_command: Command,
): Promise<void> {
if (!id) {
console.error("Error: Agent ID required");
console.error("Usage: paseo attach <id>");
process.exit(1);
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const fetchResult = await client.fetchAgent({ agentId: id });
if (!fetchResult) {
console.error(`Error: No agent found matching: ${id}`);
console.error("Use `paseo ls` to list available agents");
await client.close();
process.exit(1);
}
const resolvedId = fetchResult.agent.id;
// Print header
console.log(`Fetching history for agent ${resolvedId.substring(0, 7)}...`);
console.log(`(Press Ctrl+C to detach)\n`);
// Print existing output from timeline fetch.
try {
const timelineItems = await fetchProjectedTimelineItems({
client,
agentId: resolvedId,
timeoutMs: LIVE_HISTORY_FETCH_TIMEOUT_MS,
});
for (const item of timelineItems) {
printTimelineItem(item);
}
} catch (error) {
console.warn("Warning: failed to fetch existing timeline", error);
}
// Subscribe to new events
const unsubscribe = client.subscribeAgentTimeline(resolvedId, (message) => {
if (message.type === "agent.timeline.replacement") {
console.log("\n[Timeline replaced; earlier output is no longer current]");
} else if (message.type === "agent.timeline.subscription_restored") {
console.log("\n[Reconnected; live output resumed. Events may have been missed.]");
} else if (message.type === "agent.timeline.error") {
console.error(`Timeline observation stopped: ${message.payload.error}`);
} else {
printStreamEvent(message.payload.event);
}
});
await unsubscribe.ready;
console.log(`Attached to agent ${resolvedId.substring(0, 7)}.`);
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);
console.error(`Error: Failed to attach to agent: ${message}`);
process.exit(1);
}
}