Files
paseo/packages/cli/src/commands/agent/logs.ts
T
Mohamed BoudraandGitHub b613bea9f6 Default client RPC waits to 60 seconds (#1789)
* fix(client): wait longer for session responses

* fix(client): default RPC waits to sixty seconds

* fix(app): preserve detached stream scroll on delayed history

Code drift: longer client RPC waits let delayed timeline responses arrive after a user scroll-away, so web stream anchoring must not reattach on transient scroll-top resets.

Restore the 15s connect deadline and leave app initialization slack above the default 60s session RPC wait.

* fix(client): keep helper waits within caller deadlines

Review fix: the 60s default session RPC wait leaked into wait previews and waitForAgentUpsert helper fetches. Bound those helper RPCs to the caller deadline or a short best-effort preview timeout, and allow small scroll ranges to reattach at bottom.

* fix(client): respect caller timeout budgets

* fix(cli): keep diagnostic probes responsive

* Refactor daemon client request options

* Preserve daemon client legacy overloads
2026-06-29 17:23:18 +02:00

239 lines
7.3 KiB
TypeScript

import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.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 type { AgentStreamMessage } from "@getpaseo/protocol/messages";
import { curateAgentActivity } from "@getpaseo/server";
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> {
const host = getDaemonHost({ host: options.host });
if (!id) {
console.error("Error: Agent ID required");
console.error("Usage: paseo agent logs <id>");
process.exit(1);
}
let client: DaemonClient;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`);
console.error("Start the daemon with: paseo daemon start");
process.exit(1);
}
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) {
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"}`;
console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
const unsubscribe = client.on("agent_stream", (msg: unknown) => {
const message = msg as AgentStreamMessage;
if (message.type !== "agent_stream") return;
if (message.payload.agentId !== agentId) 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);
}
}
});
// Wait for interrupt
await new Promise<void>((resolve) => {
const cleanup = () => {
unsubscribe();
resolve();
};
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
});
await client.close();
}