mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 10:42:45 +00:00
* 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>
232 lines
7.3 KiB
TypeScript
232 lines
7.3 KiB
TypeScript
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,
|
|
LIVE_HISTORY_FETCH_TIMEOUT_MS,
|
|
} from "../../utils/timeline.js";
|
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
|
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
|
|
import { curateAgentActivity } from "@getpaseo/server/agent-activity";
|
|
|
|
export function addLogsOptions(cmd: Command): Command {
|
|
return cmd
|
|
.description("View agent activity/timeline")
|
|
.argument("<id>", "Agent ID (or prefix)")
|
|
.option("-f, --follow", "Follow log output (streaming)")
|
|
.option("--tail <n>", "Show last n entries")
|
|
.option("--filter <type>", "Filter by event type (tools, text, errors, permissions)")
|
|
.option("--since <time>", "Show logs since timestamp");
|
|
}
|
|
|
|
export interface AgentLogsOptions extends CommandOptions {
|
|
follow?: boolean;
|
|
tail?: string;
|
|
filter?: string;
|
|
since?: string;
|
|
}
|
|
|
|
// Logs command returns void - it outputs directly to console
|
|
export type AgentLogsResult = void;
|
|
|
|
export const NO_ACTIVITY_MESSAGE = "No activity to display.";
|
|
|
|
export async function fetchAgentTimelineItems(
|
|
client: DaemonClient,
|
|
agentId: string,
|
|
options?: { timeoutMs?: number },
|
|
): Promise<AgentTimelineItem[]> {
|
|
return fetchProjectedTimelineItems({ client, agentId, timeoutMs: options?.timeoutMs });
|
|
}
|
|
|
|
export function formatAgentActivityTranscript(
|
|
timelineItems: AgentTimelineItem[],
|
|
tailCount?: number,
|
|
): string {
|
|
if (tailCount === 0) {
|
|
return "";
|
|
}
|
|
return curateAgentActivity(
|
|
timelineItems,
|
|
tailCount !== undefined ? { maxItems: tailCount } : undefined,
|
|
);
|
|
}
|
|
|
|
function parseTailCount(raw: string | undefined): number | undefined {
|
|
if (raw === undefined) return undefined;
|
|
const parsed = Number.parseInt(raw, 10);
|
|
if (Number.isNaN(parsed) || parsed < 0) {
|
|
return undefined;
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
/**
|
|
* Check if a timeline item matches the filter type
|
|
*/
|
|
function matchesFilter(item: AgentTimelineItem, filter?: string): boolean {
|
|
if (!filter) return true;
|
|
|
|
const filterLower = filter.toLowerCase();
|
|
const type = item.type.toLowerCase();
|
|
|
|
switch (filterLower) {
|
|
case "tools":
|
|
return type === "tool_call";
|
|
case "text":
|
|
return type === "user_message" || type === "assistant_message" || type === "reasoning";
|
|
case "errors":
|
|
return type === "error";
|
|
case "permissions":
|
|
// Permissions might be in tool_call status or a separate event type
|
|
return type.includes("permission");
|
|
default:
|
|
// If filter doesn't match predefined types, match against the actual type
|
|
return type.includes(filterLower);
|
|
}
|
|
}
|
|
|
|
export async function runLogsCommand(
|
|
id: string,
|
|
options: AgentLogsOptions,
|
|
_command: Command,
|
|
): Promise<AgentLogsResult> {
|
|
if (!id) {
|
|
console.error("Error: Agent ID required");
|
|
console.error("Usage: paseo agent logs <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;
|
|
|
|
// For follow mode, we stream events continuously
|
|
if (options.follow) {
|
|
if (options.tail !== undefined && parseTailCount(options.tail) === undefined) {
|
|
console.error(`Error: Invalid --tail value: ${options.tail}`);
|
|
console.error("Usage: --tail <n> (where n is >= 0)");
|
|
await client.close().catch(() => {});
|
|
process.exit(1);
|
|
}
|
|
await runFollowMode(client, resolvedId, options);
|
|
return;
|
|
}
|
|
|
|
// Fetch timeline directly via cursor RPC.
|
|
let timelineItems = await fetchAgentTimelineItems(client, resolvedId);
|
|
|
|
// Apply filter
|
|
if (options.filter) {
|
|
timelineItems = timelineItems.filter((item) => matchesFilter(item, options.filter));
|
|
}
|
|
|
|
const tailCount = parseTailCount(options.tail);
|
|
if (options.tail !== undefined && tailCount === undefined) {
|
|
console.error(`Error: Invalid --tail value: ${options.tail}`);
|
|
console.error("Usage: --tail <n> (where n is >= 0)");
|
|
await client.close().catch(() => {});
|
|
process.exit(1);
|
|
}
|
|
|
|
await client.close();
|
|
|
|
// Use curateAgentActivity to format the transcript
|
|
if (tailCount === 0) {
|
|
return;
|
|
}
|
|
|
|
const transcript = formatAgentActivityTranscript(timelineItems, tailCount);
|
|
console.log(transcript);
|
|
} catch (err) {
|
|
if (err && typeof err === "object" && "code" in err) throw err;
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
console.error(`Error: Failed to get logs: ${message}`);
|
|
await client.close().catch(() => {});
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Follow mode: stream logs in real-time until interrupted
|
|
*/
|
|
async function runFollowMode(
|
|
client: DaemonClient,
|
|
agentId: string,
|
|
options: AgentLogsOptions,
|
|
): Promise<void> {
|
|
const DEFAULT_FOLLOW_TAIL = 10;
|
|
const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL;
|
|
|
|
// First, get existing timeline.
|
|
let existingItems: AgentTimelineItem[] = [];
|
|
try {
|
|
existingItems = await fetchAgentTimelineItems(client, agentId, {
|
|
timeoutMs: LIVE_HISTORY_FETCH_TIMEOUT_MS,
|
|
});
|
|
} catch (error) {
|
|
console.warn("Warning: failed to fetch existing timeline", error);
|
|
}
|
|
|
|
// Apply filter to existing items
|
|
if (options.filter) {
|
|
existingItems = existingItems.filter((item) => matchesFilter(item, options.filter));
|
|
}
|
|
|
|
// Print existing transcript (tail-like behavior)
|
|
if (tailCount > 0) {
|
|
const existingTranscript = formatAgentActivityTranscript(existingItems, tailCount);
|
|
if (existingTranscript !== NO_ACTIVITY_MESSAGE) {
|
|
console.log(existingTranscript);
|
|
}
|
|
}
|
|
|
|
// Subscribe to new events
|
|
const tailLabel =
|
|
tailCount === 0 ? "no history" : `last ${tailCount} entr${tailCount === 1 ? "y" : "ies"}`;
|
|
|
|
const unsubscribe = client.subscribeAgentTimeline(agentId, (message) => {
|
|
if (message.type === "agent.timeline.replacement") {
|
|
console.log("\n[Timeline replaced; earlier output is no longer current]");
|
|
return;
|
|
}
|
|
|
|
if (message.type === "agent.timeline.error") {
|
|
console.error(`Timeline observation stopped: ${message.payload.error}`);
|
|
return;
|
|
}
|
|
if (message.type === "agent.timeline.subscription_restored") {
|
|
console.log("\n[Reconnected; live output resumed. Events may have been missed.]");
|
|
return;
|
|
}
|
|
if (message.payload.event.type === "timeline") {
|
|
const item = message.payload.event.item;
|
|
// Apply filter
|
|
if (options.filter && !matchesFilter(item, options.filter)) {
|
|
return;
|
|
}
|
|
// Print each timeline item as it arrives using the curator format
|
|
const transcript = formatAgentActivityTranscript([item]);
|
|
if (transcript !== NO_ACTIVITY_MESSAGE) {
|
|
console.log(transcript);
|
|
}
|
|
}
|
|
});
|
|
|
|
await unsubscribe.ready;
|
|
console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
|
|
|
|
await waitForStop();
|
|
unsubscribe();
|
|
await client.close();
|
|
}
|