mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
chore(lint): clean up cli package warnings
This commit is contained in:
@@ -33,6 +33,12 @@ import { resolveCliVersion } from "./version.js";
|
||||
|
||||
const VERSION = resolveCliVersion();
|
||||
|
||||
function resolveHostnamesOption(hostnames: unknown, allowedHosts: unknown): string | undefined {
|
||||
if (typeof hostnames === "string") return hostnames;
|
||||
if (typeof allowedHosts === "string") return allowedHosts;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function createCli(): Command {
|
||||
const program = new Command();
|
||||
|
||||
@@ -120,12 +126,7 @@ export function createCli(): Command {
|
||||
return runDaemonRestartCommand(
|
||||
{
|
||||
...options,
|
||||
hostnames:
|
||||
typeof options.hostnames === "string"
|
||||
? options.hostnames
|
||||
: typeof options.allowedHosts === "string"
|
||||
? options.allowedHosts
|
||||
: undefined,
|
||||
hostnames: resolveHostnamesOption(options.hostnames, options.allowedHosts),
|
||||
},
|
||||
command,
|
||||
);
|
||||
|
||||
@@ -87,16 +87,27 @@ export async function runDeleteCommand(
|
||||
agents = [fetchResult.agent];
|
||||
}
|
||||
|
||||
for (const agent of agents) {
|
||||
try {
|
||||
if (agent.status === "running") {
|
||||
await client.cancelAgent(agent.id);
|
||||
const deleteResults = await Promise.all(
|
||||
agents.map(async (agent) => {
|
||||
try {
|
||||
if (agent.status === "running") {
|
||||
await client.cancelAgent(agent.id);
|
||||
}
|
||||
await client.deleteAgent(agent.id);
|
||||
return { ok: true as const, id: agent.id };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false as const, id: agent.id, message };
|
||||
}
|
||||
await client.deleteAgent(agent.id);
|
||||
deletedIds.push(agent.id);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`Warning: Failed to delete agent ${agent.id.slice(0, 7)}: ${message}`);
|
||||
}),
|
||||
);
|
||||
for (const result of deleteResults) {
|
||||
if (result.ok) {
|
||||
deletedIds.push(result.id);
|
||||
} else {
|
||||
console.error(
|
||||
`Warning: Failed to delete agent ${result.id.slice(0, 7)}: ${result.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,30 +104,28 @@ function resolveModel(snapshot: AgentSnapshotPayload): string | null {
|
||||
return normalizeModelId(snapshot.runtimeInfo?.model) ?? normalizeModelId(snapshot.model);
|
||||
}
|
||||
|
||||
function buildLastUsage(snapshot: AgentSnapshotPayload): AgentInspect["LastUsage"] {
|
||||
if (!snapshot.lastUsage) return null;
|
||||
return {
|
||||
InputTokens: snapshot.lastUsage.inputTokens ?? 0,
|
||||
OutputTokens: snapshot.lastUsage.outputTokens ?? 0,
|
||||
CachedTokens: snapshot.lastUsage.cachedInputTokens ?? 0,
|
||||
CostUsd: snapshot.lastUsage.totalCostUsd ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCapabilities(snapshot: AgentSnapshotPayload): AgentInspect["Capabilities"] {
|
||||
if (!snapshot.capabilities) return null;
|
||||
return {
|
||||
Streaming: snapshot.capabilities.supportsStreaming ?? false,
|
||||
Persistence: snapshot.capabilities.supportsSessionPersistence ?? false,
|
||||
DynamicModes: snapshot.capabilities.supportsDynamicModes ?? false,
|
||||
McpServers: snapshot.capabilities.supportsMcpServers ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert agent snapshot to inspection data */
|
||||
function toInspectData(snapshot: AgentSnapshotPayload): AgentInspect {
|
||||
const lastUsage = snapshot.lastUsage
|
||||
? {
|
||||
InputTokens: snapshot.lastUsage.inputTokens ?? 0,
|
||||
OutputTokens: snapshot.lastUsage.outputTokens ?? 0,
|
||||
CachedTokens: snapshot.lastUsage.cachedInputTokens ?? 0,
|
||||
CostUsd: snapshot.lastUsage.totalCostUsd ?? 0,
|
||||
}
|
||||
: null;
|
||||
|
||||
const capabilities = snapshot.capabilities
|
||||
? {
|
||||
Streaming: snapshot.capabilities.supportsStreaming ?? false,
|
||||
Persistence: snapshot.capabilities.supportsSessionPersistence ?? false,
|
||||
DynamicModes: snapshot.capabilities.supportsDynamicModes ?? false,
|
||||
McpServers: snapshot.capabilities.supportsMcpServers ?? false,
|
||||
}
|
||||
: null;
|
||||
|
||||
// Extract worktree and parentAgentId from labels if they exist
|
||||
const worktree = snapshot.labels?.["paseo.worktree"] ?? null;
|
||||
const parentAgentId = snapshot.labels?.["paseo.parent-agent-id"] ?? null;
|
||||
|
||||
return {
|
||||
Id: snapshot.id,
|
||||
Name: snapshot.title ?? "-",
|
||||
@@ -141,8 +139,8 @@ function toInspectData(snapshot: AgentSnapshotPayload): AgentInspect {
|
||||
Cwd: snapshot.cwd,
|
||||
CreatedAt: snapshot.createdAt,
|
||||
UpdatedAt: snapshot.updatedAt,
|
||||
LastUsage: lastUsage,
|
||||
Capabilities: capabilities,
|
||||
LastUsage: buildLastUsage(snapshot),
|
||||
Capabilities: buildCapabilities(snapshot),
|
||||
AvailableModes: snapshot.availableModes
|
||||
? snapshot.availableModes.map((m) => ({ id: m.id, label: m.label }))
|
||||
: null,
|
||||
@@ -150,8 +148,8 @@ function toInspectData(snapshot: AgentSnapshotPayload): AgentInspect {
|
||||
id: p.id,
|
||||
tool: p.name ?? "unknown",
|
||||
})),
|
||||
Worktree: worktree,
|
||||
ParentAgentId: parentAgentId,
|
||||
Worktree: snapshot.labels?.["paseo.worktree"] ?? null,
|
||||
ParentAgentId: snapshot.labels?.["paseo.parent-agent-id"] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -174,6 +174,40 @@ class StructuredRunStatusError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStructuredOutput(
|
||||
caller: (structuredPrompt: string) => Promise<string>,
|
||||
prompt: string,
|
||||
outputSchema: ReturnType<typeof loadOutputSchema>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return await getStructuredAgentResponse<Record<string, unknown>>({
|
||||
caller,
|
||||
prompt,
|
||||
schema: outputSchema,
|
||||
schemaName: "RunOutput",
|
||||
maxRetries: 2,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof StructuredRunStatusError) {
|
||||
throw {
|
||||
code: "OUTPUT_SCHEMA_FAILED",
|
||||
message: err.message,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
if (err instanceof StructuredAgentResponseError) {
|
||||
throw {
|
||||
code: "OUTPUT_SCHEMA_FAILED",
|
||||
message: "Agent response did not match the required output schema",
|
||||
details:
|
||||
err.validationErrors.length > 0
|
||||
? err.validationErrors.join("\n")
|
||||
: err.lastResponse || "No response",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
type ConnectedDaemonClient = Awaited<ReturnType<typeof connectToDaemon>>;
|
||||
|
||||
export interface StructuredResponseTimelineClient {
|
||||
@@ -219,6 +253,107 @@ function structuredRunSchema(output: Record<string, unknown>): OutputSchema<Agen
|
||||
};
|
||||
}
|
||||
|
||||
function validateRunOptions(prompt: string, options: AgentRunOptions, outputSchema: unknown): void {
|
||||
if (!prompt || prompt.trim().length === 0) {
|
||||
throw {
|
||||
code: "MISSING_PROMPT",
|
||||
message: "A prompt is required",
|
||||
details: "Usage: paseo agent run [options] <prompt>",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
if (options.base && !options.worktree) {
|
||||
throw {
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "--base can only be used with --worktree",
|
||||
details: "Usage: paseo agent run --worktree <name> --base <branch> <prompt>",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
if (outputSchema && options.detach) {
|
||||
throw {
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "--output-schema cannot be used with --detach",
|
||||
details: "Structured output requires waiting for the agent to finish",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
}
|
||||
|
||||
function parseWaitTimeoutOption(waitTimeout: string | undefined): number {
|
||||
if (!waitTimeout) return 0;
|
||||
try {
|
||||
const ms = parseDuration(waitTimeout);
|
||||
if (ms <= 0) {
|
||||
throw new Error("Timeout must be positive");
|
||||
}
|
||||
return ms;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw {
|
||||
code: "INVALID_TIMEOUT",
|
||||
message: "Invalid wait timeout value",
|
||||
details: message,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
}
|
||||
|
||||
function loadRunImages(
|
||||
imagePaths: string[] | undefined,
|
||||
): Array<{ data: string; mimeType: string }> | undefined {
|
||||
if (!imagePaths || imagePaths.length === 0) return undefined;
|
||||
return imagePaths.map((imagePath) => {
|
||||
const resolvedPath = resolve(imagePath);
|
||||
try {
|
||||
const imageData = readFileSync(resolvedPath);
|
||||
const mimeType = lookup(resolvedPath) || "application/octet-stream";
|
||||
if (!mimeType.startsWith("image/")) {
|
||||
throw new Error(`File is not an image: ${imagePath} (detected type: ${mimeType})`);
|
||||
}
|
||||
return {
|
||||
data: imageData.toString("base64"),
|
||||
mimeType,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Failed to read image ${imagePath}: ${message}`, { cause: err });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parseRunLabels(labelFlags: string[] | undefined): Record<string, string> {
|
||||
const labels: Record<string, string> = {};
|
||||
if (!labelFlags) return labels;
|
||||
for (const labelStr of labelFlags) {
|
||||
const eqIndex = labelStr.indexOf("=");
|
||||
if (eqIndex === -1) {
|
||||
throw {
|
||||
code: "INVALID_LABEL",
|
||||
message: `Invalid label format: ${labelStr}`,
|
||||
details: "Labels must be in key=value format",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
const key = labelStr.slice(0, eqIndex);
|
||||
labels[key] = labelStr.slice(eqIndex + 1);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
async function connectToDaemonOrThrow(
|
||||
hostOption: string | undefined,
|
||||
host: string,
|
||||
): Promise<ConnectedDaemonClient> {
|
||||
try {
|
||||
return await connectToDaemon({ host: hostOption });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runRunCommand(
|
||||
prompt: string,
|
||||
options: AgentRunOptions,
|
||||
@@ -226,70 +361,14 @@ export async function runRunCommand(
|
||||
): Promise<SingleResult<AgentRunResult>> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined });
|
||||
const outputSchema = options.outputSchema ? loadOutputSchema(options.outputSchema) : undefined;
|
||||
let waitTimeoutMs = 0;
|
||||
|
||||
// Validate prompt is provided
|
||||
if (!prompt || prompt.trim().length === 0) {
|
||||
const error: CommandError = {
|
||||
code: "MISSING_PROMPT",
|
||||
message: "A prompt is required",
|
||||
details: "Usage: paseo agent run [options] <prompt>",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Validate --base is only used with --worktree
|
||||
if (options.base && !options.worktree) {
|
||||
const error: CommandError = {
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "--base can only be used with --worktree",
|
||||
details: "Usage: paseo agent run --worktree <name> --base <branch> <prompt>",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
// --output-schema always runs in attached/wait mode
|
||||
if (outputSchema && options.detach) {
|
||||
const error: CommandError = {
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "--output-schema cannot be used with --detach",
|
||||
details: "Structured output requires waiting for the agent to finish",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (options.waitTimeout) {
|
||||
try {
|
||||
waitTimeoutMs = parseDuration(options.waitTimeout);
|
||||
if (waitTimeoutMs <= 0) {
|
||||
throw new Error("Timeout must be positive");
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "INVALID_TIMEOUT",
|
||||
message: "Invalid wait timeout value",
|
||||
details: message,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
validateRunOptions(prompt, options, outputSchema);
|
||||
const waitTimeoutMs = parseWaitTimeoutOption(options.waitTimeout);
|
||||
|
||||
const resolvedProviderModel = resolveProviderAndModel(options);
|
||||
const resolvedTitle = options.title ?? options.name;
|
||||
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host as string | undefined });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const client = await connectToDaemonOrThrow(options.host as string | undefined, host);
|
||||
|
||||
try {
|
||||
// Resolve working directory
|
||||
@@ -305,32 +384,8 @@ export async function runRunCommand(
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Process images if provided
|
||||
let images: Array<{ data: string; mimeType: string }> | undefined;
|
||||
if (options.image && options.image.length > 0) {
|
||||
images = options.image.map((imagePath) => {
|
||||
const resolvedPath = resolve(imagePath);
|
||||
try {
|
||||
const imageData = readFileSync(resolvedPath);
|
||||
const mimeType = lookup(resolvedPath) || "application/octet-stream";
|
||||
const images = loadRunImages(options.image);
|
||||
|
||||
// Verify it's an image MIME type
|
||||
if (!mimeType.startsWith("image/")) {
|
||||
throw new Error(`File is not an image: ${imagePath} (detected type: ${mimeType})`);
|
||||
}
|
||||
|
||||
return {
|
||||
data: imageData.toString("base64"),
|
||||
mimeType,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Failed to read image ${imagePath}: ${message}`, { cause: err });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Build git options if worktree is specified
|
||||
const git = options.worktree
|
||||
? {
|
||||
createWorktree: true,
|
||||
@@ -339,24 +394,7 @@ export async function runRunCommand(
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Build labels from --label flags
|
||||
const labels: Record<string, string> = {};
|
||||
if (options.label) {
|
||||
for (const labelStr of options.label) {
|
||||
const eqIndex = labelStr.indexOf("=");
|
||||
if (eqIndex === -1) {
|
||||
const error: CommandError = {
|
||||
code: "INVALID_LABEL",
|
||||
message: `Invalid label format: ${labelStr}`,
|
||||
details: "Labels must be in key=value format",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const key = labelStr.slice(0, eqIndex);
|
||||
const value = labelStr.slice(eqIndex + 1);
|
||||
labels[key] = value;
|
||||
}
|
||||
}
|
||||
const labels = parseRunLabels(options.label);
|
||||
|
||||
if (outputSchema) {
|
||||
let structuredAgent: AgentSnapshotPayload | null = null;
|
||||
@@ -413,36 +451,7 @@ export async function runRunCommand(
|
||||
return lastMessage;
|
||||
};
|
||||
|
||||
let output: Record<string, unknown>;
|
||||
try {
|
||||
output = await getStructuredAgentResponse<Record<string, unknown>>({
|
||||
caller: callStructuredTurn,
|
||||
prompt,
|
||||
schema: outputSchema,
|
||||
schemaName: "RunOutput",
|
||||
maxRetries: 2,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof StructuredRunStatusError) {
|
||||
const error: CommandError = {
|
||||
code: "OUTPUT_SCHEMA_FAILED",
|
||||
message: err.message,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
if (err instanceof StructuredAgentResponseError) {
|
||||
const error: CommandError = {
|
||||
code: "OUTPUT_SCHEMA_FAILED",
|
||||
message: "Agent response did not match the required output schema",
|
||||
details:
|
||||
err.validationErrors.length > 0
|
||||
? err.validationErrors.join("\n")
|
||||
: err.lastResponse || "No response",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const output = await fetchStructuredOutput(callStructuredTurn, prompt, outputSchema);
|
||||
|
||||
if (!structuredAgent) {
|
||||
const error: CommandError = {
|
||||
|
||||
@@ -51,51 +51,42 @@ export function addSendOptions(cmd: Command): Command {
|
||||
async function readImageFiles(
|
||||
imagePaths: string[],
|
||||
): Promise<Array<{ data: string; mimeType: string }>> {
|
||||
const images: Array<{ data: string; mimeType: string }> = [];
|
||||
return Promise.all(
|
||||
imagePaths.map(async (path) => {
|
||||
try {
|
||||
const buffer = await readFile(path);
|
||||
const ext = extname(path).toLowerCase();
|
||||
|
||||
for (const path of imagePaths) {
|
||||
try {
|
||||
const buffer = await readFile(path);
|
||||
const ext = extname(path).toLowerCase();
|
||||
let mimeType = "image/jpeg";
|
||||
switch (ext) {
|
||||
case ".png":
|
||||
mimeType = "image/png";
|
||||
break;
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
mimeType = "image/jpeg";
|
||||
break;
|
||||
case ".gif":
|
||||
mimeType = "image/gif";
|
||||
break;
|
||||
case ".webp":
|
||||
mimeType = "image/webp";
|
||||
break;
|
||||
default:
|
||||
mimeType = "image/jpeg";
|
||||
}
|
||||
|
||||
// Determine media type from extension
|
||||
let mimeType = "image/jpeg";
|
||||
switch (ext) {
|
||||
case ".png":
|
||||
mimeType = "image/png";
|
||||
break;
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
mimeType = "image/jpeg";
|
||||
break;
|
||||
case ".gif":
|
||||
mimeType = "image/gif";
|
||||
break;
|
||||
case ".webp":
|
||||
mimeType = "image/webp";
|
||||
break;
|
||||
default:
|
||||
// Default to jpeg for unknown types
|
||||
mimeType = "image/jpeg";
|
||||
return { data: buffer.toString("base64"), mimeType };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw {
|
||||
code: "IMAGE_READ_ERROR",
|
||||
message: `Failed to read image file: ${path}`,
|
||||
details: message,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
const data = buffer.toString("base64");
|
||||
images.push({
|
||||
data,
|
||||
mimeType,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "IMAGE_READ_ERROR",
|
||||
message: `Failed to read image file: ${path}`,
|
||||
details: message,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return images;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function resolvePromptInput(options: {
|
||||
@@ -147,6 +138,28 @@ async function resolvePromptInput(options: {
|
||||
}
|
||||
}
|
||||
|
||||
type SendWaitState = Awaited<
|
||||
ReturnType<Awaited<ReturnType<typeof connectToDaemon>>["waitForFinish"]>
|
||||
>;
|
||||
|
||||
function buildSendResult(agentIdArg: string, state: SendWaitState): AgentSendResult {
|
||||
const agentId = state.final?.id ?? agentIdArg;
|
||||
if (state.status === "timeout") {
|
||||
return { agentId, status: "timeout", message: "Timed out waiting for agent to finish" };
|
||||
}
|
||||
if (state.status === "permission") {
|
||||
return { agentId, status: "permission", message: "Agent is waiting for permission" };
|
||||
}
|
||||
if (state.status === "error") {
|
||||
return {
|
||||
agentId,
|
||||
status: "error",
|
||||
message: state.error ?? "Agent finished with error",
|
||||
};
|
||||
}
|
||||
return { agentId, status: "completed", message: "Agent completed processing the message" };
|
||||
}
|
||||
|
||||
export async function runSendCommand(
|
||||
agentIdArg: string,
|
||||
prompt: string | undefined,
|
||||
@@ -207,54 +220,12 @@ export async function runSendCommand(
|
||||
};
|
||||
}
|
||||
|
||||
// Wait for agent to finish
|
||||
const state = await client.waitForFinish(agentIdArg, 600000); // 10 minute timeout
|
||||
|
||||
await client.close();
|
||||
|
||||
if (state.status === "timeout") {
|
||||
return {
|
||||
type: "single",
|
||||
data: {
|
||||
agentId: state.final?.id ?? agentIdArg,
|
||||
status: "timeout",
|
||||
message: "Timed out waiting for agent to finish",
|
||||
},
|
||||
schema: agentSendSchema,
|
||||
};
|
||||
}
|
||||
|
||||
if (state.status === "permission") {
|
||||
return {
|
||||
type: "single",
|
||||
data: {
|
||||
agentId: state.final?.id ?? agentIdArg,
|
||||
status: "permission",
|
||||
message: "Agent is waiting for permission",
|
||||
},
|
||||
schema: agentSendSchema,
|
||||
};
|
||||
}
|
||||
|
||||
if (state.status === "error") {
|
||||
return {
|
||||
type: "single",
|
||||
data: {
|
||||
agentId: state.final?.id ?? agentIdArg,
|
||||
status: "error",
|
||||
message: state.error ?? "Agent finished with error",
|
||||
},
|
||||
schema: agentSendSchema,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "single",
|
||||
data: {
|
||||
agentId: state.final?.id ?? agentIdArg,
|
||||
status: "completed",
|
||||
message: "Agent completed processing the message",
|
||||
},
|
||||
data: buildSendResult(agentIdArg, state),
|
||||
schema: agentSendSchema,
|
||||
};
|
||||
} catch (err) {
|
||||
|
||||
@@ -95,16 +95,25 @@ export async function runStopCommand(
|
||||
}
|
||||
|
||||
// Interrupt each running agent. Idle agents are a no-op.
|
||||
for (const agent of agents) {
|
||||
try {
|
||||
if (agent.status === "running") {
|
||||
const stopResults = await Promise.all(
|
||||
agents.map(async (agent) => {
|
||||
if (agent.status !== "running") return { ok: true as const, id: agent.id, stopped: false };
|
||||
try {
|
||||
await client.cancelAgent(agent.id);
|
||||
stoppedIds.push(agent.id);
|
||||
return { ok: true as const, id: agent.id, stopped: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false as const, id: agent.id, message };
|
||||
}
|
||||
} catch (err) {
|
||||
// Continue interrupting other agents even if one fails
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`Warning: Failed to stop agent ${agent.id.slice(0, 7)}: ${message}`);
|
||||
}),
|
||||
);
|
||||
for (const result of stopResults) {
|
||||
if (!result.ok) {
|
||||
console.error(`Warning: Failed to stop agent ${result.id.slice(0, 7)}: ${result.message}`);
|
||||
continue;
|
||||
}
|
||||
if (result.stopped) {
|
||||
stoppedIds.push(result.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,80 @@ async function getRecentActivityTranscript(
|
||||
}
|
||||
}
|
||||
|
||||
function parseWaitTimeout(timeout: string | undefined): {
|
||||
timeoutMs: number;
|
||||
timeoutLabel: string | null;
|
||||
} {
|
||||
if (!timeout) return { timeoutMs: 0, timeoutLabel: null };
|
||||
try {
|
||||
const ms = parseDuration(timeout);
|
||||
if (ms <= 0) {
|
||||
throw new Error("Timeout must be positive");
|
||||
}
|
||||
const timeoutSeconds = Math.floor(ms / 1000);
|
||||
return {
|
||||
timeoutMs: ms,
|
||||
timeoutLabel: `${timeoutSeconds} second${timeoutSeconds === 1 ? "" : "s"}`,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw {
|
||||
code: "INVALID_TIMEOUT",
|
||||
message: "Invalid timeout value",
|
||||
details: message,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
}
|
||||
|
||||
type WaitFinishState = Awaited<
|
||||
ReturnType<Awaited<ReturnType<typeof connectToDaemon>>["waitForFinish"]>
|
||||
>;
|
||||
|
||||
function buildWaitResult(args: {
|
||||
state: WaitFinishState;
|
||||
resolvedAgentId: string;
|
||||
recentActivity: string | null;
|
||||
timeoutLabel: string | null;
|
||||
}): AgentWaitResult {
|
||||
const { state, resolvedAgentId, recentActivity, timeoutLabel } = args;
|
||||
|
||||
if (state.status === "timeout") {
|
||||
const timeoutMessage = timeoutLabel
|
||||
? `Agent did not finish within ${timeoutLabel}. Run \`paseo wait ${resolvedAgentId}\` again to keep waiting.`
|
||||
: `Agent wait timed out. Run \`paseo wait ${resolvedAgentId}\` again to keep waiting.`;
|
||||
return {
|
||||
agentId: resolvedAgentId,
|
||||
status: "timeout",
|
||||
message: appendRecentActivity(timeoutMessage, recentActivity),
|
||||
};
|
||||
}
|
||||
|
||||
if (state.status === "permission") {
|
||||
const permission = state.final?.pendingPermissions?.[0];
|
||||
return {
|
||||
agentId: resolvedAgentId,
|
||||
status: "permission",
|
||||
message: permission
|
||||
? `Agent is waiting for permission: ${permission.kind}`
|
||||
: "Agent is waiting for permission",
|
||||
};
|
||||
}
|
||||
|
||||
if (state.status === "error") {
|
||||
return {
|
||||
agentId: resolvedAgentId,
|
||||
status: "error",
|
||||
message: state.error ?? "Agent finished with error",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
agentId: resolvedAgentId,
|
||||
status: "idle",
|
||||
message: appendRecentActivity("Agent is idle.", recentActivity),
|
||||
};
|
||||
}
|
||||
|
||||
export function addWaitOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
.description("Wait for an agent to become idle")
|
||||
@@ -67,37 +141,15 @@ export async function runWaitCommand(
|
||||
): Promise<SingleResult<AgentWaitResult>> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined });
|
||||
|
||||
// Validate arguments
|
||||
if (!agentIdArg || agentIdArg.trim().length === 0) {
|
||||
const error: CommandError = {
|
||||
throw {
|
||||
code: "MISSING_AGENT_ID",
|
||||
message: "Agent ID is required",
|
||||
details: "Usage: paseo agent wait <id>",
|
||||
};
|
||||
throw error;
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
// Parse timeout (no limit unless explicitly provided)
|
||||
let timeoutMs = 0;
|
||||
let timeoutLabel: string | null = null;
|
||||
if (options.timeout) {
|
||||
try {
|
||||
timeoutMs = parseDuration(options.timeout);
|
||||
if (timeoutMs <= 0) {
|
||||
throw new Error("Timeout must be positive");
|
||||
}
|
||||
const timeoutSeconds = Math.floor(timeoutMs / 1000);
|
||||
timeoutLabel = `${timeoutSeconds} second${timeoutSeconds === 1 ? "" : "s"}`;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "INVALID_TIMEOUT",
|
||||
message: "Invalid timeout value",
|
||||
details: message,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const { timeoutMs, timeoutLabel } = parseWaitTimeout(options.timeout);
|
||||
|
||||
let client;
|
||||
try {
|
||||
@@ -123,56 +175,9 @@ export async function runWaitCommand(
|
||||
|
||||
await client.close();
|
||||
|
||||
if (state.status === "timeout") {
|
||||
const timeoutMessage = timeoutLabel
|
||||
? `Agent did not finish within ${timeoutLabel}. Run \`paseo wait ${resolvedAgentId}\` again to keep waiting.`
|
||||
: `Agent wait timed out. Run \`paseo wait ${resolvedAgentId}\` again to keep waiting.`;
|
||||
return {
|
||||
type: "single",
|
||||
data: {
|
||||
agentId: resolvedAgentId,
|
||||
status: "timeout",
|
||||
message: appendRecentActivity(timeoutMessage, recentActivity),
|
||||
},
|
||||
schema: agentWaitSchema,
|
||||
};
|
||||
}
|
||||
|
||||
if (state.status === "permission") {
|
||||
const permission = state.final?.pendingPermissions?.[0];
|
||||
return {
|
||||
type: "single",
|
||||
data: {
|
||||
agentId: resolvedAgentId,
|
||||
status: "permission",
|
||||
message: permission
|
||||
? `Agent is waiting for permission: ${permission.kind}`
|
||||
: "Agent is waiting for permission",
|
||||
},
|
||||
schema: agentWaitSchema,
|
||||
};
|
||||
}
|
||||
|
||||
if (state.status === "error") {
|
||||
return {
|
||||
type: "single",
|
||||
data: {
|
||||
agentId: resolvedAgentId,
|
||||
status: "error",
|
||||
message: state.error ?? "Agent finished with error",
|
||||
},
|
||||
schema: agentWaitSchema,
|
||||
};
|
||||
}
|
||||
|
||||
// Agent is idle
|
||||
return {
|
||||
type: "single",
|
||||
data: {
|
||||
agentId: resolvedAgentId,
|
||||
status: "idle",
|
||||
message: appendRecentActivity("Agent is idle.", recentActivity),
|
||||
},
|
||||
data: buildWaitResult({ state, resolvedAgentId, recentActivity, timeoutLabel }),
|
||||
schema: agentWaitSchema,
|
||||
};
|
||||
} catch (waitErr) {
|
||||
|
||||
@@ -7,6 +7,12 @@ import { pairCommand } from "./pair.js";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import { addJsonOption } from "../../utils/command-options.js";
|
||||
|
||||
function resolveHostnamesOption(hostnames: unknown, allowedHosts: unknown): string | undefined {
|
||||
if (typeof hostnames === "string") return hostnames;
|
||||
if (typeof allowedHosts === "string") return allowedHosts;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function createDaemonCommand(): Command {
|
||||
const daemon = new Command("daemon").description("Manage the Paseo daemon");
|
||||
|
||||
@@ -46,12 +52,7 @@ export function createDaemonCommand(): Command {
|
||||
return runRestartCommand(
|
||||
{
|
||||
...options,
|
||||
hostnames:
|
||||
typeof options.hostnames === "string"
|
||||
? options.hostnames
|
||||
: typeof options.allowedHosts === "string"
|
||||
? options.allowedHosts
|
||||
: undefined,
|
||||
hostnames: resolveHostnamesOption(options.hostnames, options.allowedHosts),
|
||||
},
|
||||
command,
|
||||
);
|
||||
|
||||
@@ -119,25 +119,30 @@ function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv {
|
||||
return childEnv;
|
||||
}
|
||||
|
||||
function resolveServerRunnerFromDir(currentDir: string): string | null {
|
||||
const packageJsonPath = path.join(currentDir, "package.json");
|
||||
if (!existsSync(packageJsonPath)) return null;
|
||||
try {
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { name?: string };
|
||||
if (packageJson.name !== "@getpaseo/server") return null;
|
||||
const distRunner = path.join(currentDir, "dist", "scripts", "supervisor-entrypoint.js");
|
||||
if (existsSync(distRunner)) {
|
||||
return distRunner;
|
||||
}
|
||||
return path.join(currentDir, "scripts", "supervisor-entrypoint.ts");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDaemonRunnerEntry(): string {
|
||||
const serverExportPath = require.resolve("@getpaseo/server");
|
||||
let currentDir = path.dirname(serverExportPath);
|
||||
|
||||
while (true) {
|
||||
const packageJsonPath = path.join(currentDir, "package.json");
|
||||
if (existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { name?: string };
|
||||
if (packageJson.name === "@getpaseo/server") {
|
||||
const distRunner = path.join(currentDir, "dist", "scripts", "supervisor-entrypoint.js");
|
||||
if (existsSync(distRunner)) {
|
||||
return distRunner;
|
||||
}
|
||||
return path.join(currentDir, "scripts", "supervisor-entrypoint.ts");
|
||||
}
|
||||
} catch {
|
||||
// Continue searching up if package.json exists but is invalid.
|
||||
}
|
||||
const entry = resolveServerRunnerFromDir(currentDir);
|
||||
if (entry) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
const parentDir = path.dirname(currentDir);
|
||||
@@ -154,6 +159,22 @@ function pidFilePath(paseoHome: string): string {
|
||||
return path.join(paseoHome, DAEMON_PID_FILENAME);
|
||||
}
|
||||
|
||||
function resolveListenField(listen: unknown, sockPath: unknown): string | undefined {
|
||||
if (typeof listen === "string") return listen;
|
||||
if (typeof sockPath === "string") return sockPath;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveStopMessage(
|
||||
forced: boolean,
|
||||
lifecycleRequested: boolean,
|
||||
fallbackMessage: string | null | undefined,
|
||||
): string {
|
||||
if (forced) return "Daemon owner process was force-stopped";
|
||||
if (lifecycleRequested) return "Daemon stopped gracefully";
|
||||
return fallbackMessage ?? "Daemon stopped via owner PID signal";
|
||||
}
|
||||
|
||||
function readPidFile(pidPath: string): LocalDaemonPidInfo | null {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as Record<string, unknown>;
|
||||
@@ -167,12 +188,7 @@ function readPidFile(pidPath: string): LocalDaemonPidInfo | null {
|
||||
startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : undefined,
|
||||
hostname: typeof parsed.hostname === "string" ? parsed.hostname : undefined,
|
||||
uid: typeof parsed.uid === "number" ? parsed.uid : undefined,
|
||||
listen:
|
||||
typeof parsed.listen === "string"
|
||||
? parsed.listen
|
||||
: typeof parsed.sockPath === "string"
|
||||
? parsed.sockPath
|
||||
: undefined,
|
||||
listen: resolveListenField(parsed.listen, parsed.sockPath),
|
||||
desktopManaged: parsed.desktopManaged === true ? true : undefined,
|
||||
};
|
||||
} catch {
|
||||
@@ -528,10 +544,6 @@ export async function stopLocalDaemon(
|
||||
home: state.home,
|
||||
pid,
|
||||
forced,
|
||||
message: forced
|
||||
? "Daemon owner process was force-stopped"
|
||||
: lifecycleRequested
|
||||
? "Daemon stopped gracefully"
|
||||
: (fallbackMessage ?? "Daemon stopped via owner PID signal"),
|
||||
message: resolveStopMessage(forced, lifecycleRequested, fallbackMessage),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -208,6 +208,117 @@ function resolveOwnerLabel(uid: number | undefined, hostname: string | undefined
|
||||
return `${uidPart}@${hostPart}`;
|
||||
}
|
||||
|
||||
interface DaemonProbeResult {
|
||||
connectedDaemon: DaemonStatus["connectedDaemon"];
|
||||
localDaemonOverride?: DaemonStatus["localDaemon"];
|
||||
daemonVersion?: string | null;
|
||||
runningAgents?: number;
|
||||
idleAgents?: number;
|
||||
daemonNodeOverride?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
async function probeDaemonOverWebsocket(args: {
|
||||
host: string;
|
||||
state: ReturnType<typeof resolveLocalDaemonState>;
|
||||
}): Promise<DaemonProbeResult> {
|
||||
const { host, state } = args;
|
||||
const client = await tryConnectToDaemon({ host, timeout: 1500 });
|
||||
if (!client) {
|
||||
if (state.running) {
|
||||
return {
|
||||
connectedDaemon: "unreachable",
|
||||
localDaemonOverride: "unresponsive",
|
||||
note: `Local daemon PID is running but websocket at ${host} is not reachable`,
|
||||
};
|
||||
}
|
||||
return { connectedDaemon: "unreachable" };
|
||||
}
|
||||
|
||||
const daemonVersion = client.getLastServerInfoMessage()?.version ?? null;
|
||||
try {
|
||||
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
const agents = agentsPayload.entries.map((entry) => entry.agent);
|
||||
const runningAgents = agents.filter((a) => a.status === "running").length;
|
||||
const idleAgents = agents.filter((a) => a.status === "idle").length;
|
||||
|
||||
if (!state.running) {
|
||||
return {
|
||||
connectedDaemon: "reachable",
|
||||
daemonVersion,
|
||||
runningAgents,
|
||||
idleAgents,
|
||||
daemonNodeOverride: "unknown (API reachable, PID unresolved)",
|
||||
note: state.pidInfo
|
||||
? `Connected daemon is reachable at ${host} even though local daemon PID ${state.pidInfo.pid} is stale`
|
||||
: `Connected daemon is reachable at ${host} but no local daemon PID file was found`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
connectedDaemon: "reachable",
|
||||
daemonVersion,
|
||||
runningAgents,
|
||||
idleAgents,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
connectedDaemon: "reachable",
|
||||
daemonVersion,
|
||||
localDaemonOverride: state.running ? "unresponsive" : undefined,
|
||||
note: state.running
|
||||
? `Local daemon PID is running but API requests to ${host} failed`
|
||||
: `Connected daemon websocket is reachable at ${host} but fetch_agents failed`,
|
||||
};
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
interface ProbeMergeState {
|
||||
probe: DaemonProbeResult;
|
||||
connectedDaemon: DaemonStatus["connectedDaemon"];
|
||||
localDaemon: DaemonStatus["localDaemon"];
|
||||
daemonNode: string;
|
||||
daemonVersion: string | null;
|
||||
runningAgents: number | null;
|
||||
idleAgents: number | null;
|
||||
note: string | undefined;
|
||||
}
|
||||
|
||||
function applyProbeToStatus(input: ProbeMergeState): Omit<ProbeMergeState, "probe"> {
|
||||
const { probe } = input;
|
||||
return {
|
||||
connectedDaemon: probe.connectedDaemon,
|
||||
localDaemon: probe.localDaemonOverride ?? input.localDaemon,
|
||||
daemonNode: probe.daemonNodeOverride ?? input.daemonNode,
|
||||
daemonVersion: probe.daemonVersion !== undefined ? probe.daemonVersion : input.daemonVersion,
|
||||
runningAgents: probe.runningAgents !== undefined ? probe.runningAgents : input.runningAgents,
|
||||
idleAgents: probe.idleAgents !== undefined ? probe.idleAgents : input.idleAgents,
|
||||
note: probe.note ? appendNote(input.note, probe.note) : input.note,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveServerIdSafely(home: string): { serverId: string | null; error: string | null } {
|
||||
try {
|
||||
return { serverId: getOrCreateServerId(home), error: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
serverId: null,
|
||||
error: `serverId unavailable: ${shortenMessage(normalizeError(error))}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDaemonNodeLabel(
|
||||
state: ReturnType<typeof resolveLocalDaemonState>,
|
||||
): Promise<string> {
|
||||
if (!state.running) return "-";
|
||||
if (!state.pidInfo?.pid) return "unknown (no PID available)";
|
||||
const fromPid = await resolveNodePathFromPid(state.pidInfo.pid);
|
||||
return fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`;
|
||||
}
|
||||
|
||||
export type StatusResult = ListResult<StatusRow>;
|
||||
|
||||
export async function runStatusCommand(
|
||||
@@ -219,15 +330,7 @@ export async function runStatusCommand(
|
||||
const host = resolveTcpHostFromListen(state.listen);
|
||||
|
||||
const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname);
|
||||
let daemonNode: string;
|
||||
if (!state.running) {
|
||||
daemonNode = "-";
|
||||
} else if (state.pidInfo?.pid) {
|
||||
const fromPid = await resolveNodePathFromPid(state.pidInfo.pid);
|
||||
daemonNode = fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`;
|
||||
} else {
|
||||
daemonNode = "unknown (no PID available)";
|
||||
}
|
||||
let daemonNode = await resolveDaemonNodeLabel(state);
|
||||
const cliNode = process.execPath;
|
||||
let localDaemon: DaemonStatus["localDaemon"] = state.running ? "running" : "stopped";
|
||||
let connectedDaemon: DaemonStatus["connectedDaemon"] = "not_probed";
|
||||
@@ -242,58 +345,28 @@ export async function runStatusCommand(
|
||||
}
|
||||
|
||||
if (host) {
|
||||
const client = await tryConnectToDaemon({ host, timeout: 1500 });
|
||||
if (client) {
|
||||
connectedDaemon = "reachable";
|
||||
daemonVersion = client.getLastServerInfoMessage()?.version ?? null;
|
||||
try {
|
||||
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
const agents = agentsPayload.entries.map((entry) => entry.agent);
|
||||
runningAgents = agents.filter((a) => a.status === "running").length;
|
||||
idleAgents = agents.filter((a) => a.status === "idle").length;
|
||||
if (!state.running) {
|
||||
daemonNode = "unknown (API reachable, PID unresolved)";
|
||||
note = appendNote(
|
||||
note,
|
||||
state.pidInfo
|
||||
? `Connected daemon is reachable at ${host} even though local daemon PID ${state.pidInfo.pid} is stale`
|
||||
: `Connected daemon is reachable at ${host} but no local daemon PID file was found`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
if (state.running) {
|
||||
localDaemon = "unresponsive";
|
||||
}
|
||||
note = appendNote(
|
||||
note,
|
||||
state.running
|
||||
? `Local daemon PID is running but API requests to ${host} failed`
|
||||
: `Connected daemon websocket is reachable at ${host} but fetch_agents failed`,
|
||||
);
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
} else if (state.running) {
|
||||
connectedDaemon = "unreachable";
|
||||
localDaemon = "unresponsive";
|
||||
note = appendNote(
|
||||
const probe = await probeDaemonOverWebsocket({ host, state });
|
||||
({ connectedDaemon, localDaemon, daemonNode, daemonVersion, runningAgents, idleAgents, note } =
|
||||
applyProbeToStatus({
|
||||
probe,
|
||||
connectedDaemon,
|
||||
localDaemon,
|
||||
daemonNode,
|
||||
daemonVersion,
|
||||
runningAgents,
|
||||
idleAgents,
|
||||
note,
|
||||
`Local daemon PID is running but websocket at ${host} is not reachable`,
|
||||
);
|
||||
} else {
|
||||
connectedDaemon = "unreachable";
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
note = appendNote(note, "Daemon is configured for unix socket listen; API probe skipped");
|
||||
}
|
||||
|
||||
const cliVersion = resolveCliVersion();
|
||||
|
||||
let serverId: string | null = null;
|
||||
try {
|
||||
serverId = getOrCreateServerId(state.home);
|
||||
} catch (error) {
|
||||
note = appendNote(note, `serverId unavailable: ${shortenMessage(normalizeError(error))}`);
|
||||
const serverIdResult = resolveServerIdSafely(state.home);
|
||||
const serverId = serverIdResult.serverId;
|
||||
if (serverIdResult.error) {
|
||||
note = appendNote(note, serverIdResult.error);
|
||||
}
|
||||
|
||||
const providers = await checkProviderBinaries();
|
||||
|
||||
@@ -315,6 +315,102 @@ export function onboardCommand(): Command {
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveAndPersistVoice(
|
||||
paseoHome: string,
|
||||
options: OnboardOptions,
|
||||
): Promise<boolean> {
|
||||
let persisted = loadPersistedConfig(paseoHome) as OnboardPersistedConfig;
|
||||
const persistedVoiceSelection = resolvePersistedVoiceSelection(persisted);
|
||||
const shouldPrompt = options.voice === "ask" || options.voice === undefined;
|
||||
let voiceEnabled: boolean;
|
||||
try {
|
||||
voiceEnabled =
|
||||
shouldPrompt && persistedVoiceSelection !== null
|
||||
? persistedVoiceSelection
|
||||
: await resolveVoiceSelection(options.voice);
|
||||
} catch (error) {
|
||||
if (error instanceof OnboardCancelledError) {
|
||||
cancel("Onboarding cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (shouldPrompt && persistedVoiceSelection !== null) {
|
||||
log.message(`Using saved voice setup from config (${voiceEnabled ? "enabled" : "disabled"}).`);
|
||||
}
|
||||
|
||||
persisted = applyVoiceSelection(persisted, voiceEnabled);
|
||||
savePersistedConfig(paseoHome, persisted);
|
||||
return voiceEnabled;
|
||||
}
|
||||
|
||||
async function ensureDaemonStarted(options: OnboardOptions, richUi: boolean): Promise<void> {
|
||||
const stateBeforeStart = resolveLocalDaemonState({ home: options.home });
|
||||
if (stateBeforeStart.running) {
|
||||
log.message(`Daemon already running (PID ${stateBeforeStart.pidInfo?.pid ?? "unknown"}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
const startSpinner = richUi ? spinner() : null;
|
||||
try {
|
||||
if (startSpinner) {
|
||||
startSpinner.start("Starting daemon...");
|
||||
} else {
|
||||
log.message("Starting daemon...");
|
||||
}
|
||||
const startup = await startLocalDaemonDetached(options);
|
||||
if (startSpinner) {
|
||||
startSpinner.stop(`Daemon started (PID ${startup.pid ?? "unknown"})`);
|
||||
} else {
|
||||
log.message(`Daemon started (PID ${startup.pid ?? "unknown"})`);
|
||||
}
|
||||
log.message(`Logs: ${startup.logPath}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (startSpinner) {
|
||||
startSpinner.error(message);
|
||||
} else {
|
||||
log.error(message);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDaemonReadyWithUi(args: {
|
||||
home: string;
|
||||
timeoutMs: number;
|
||||
richUi: boolean;
|
||||
}): Promise<{ listen: string; host: string | null }> {
|
||||
const readySpinner = args.richUi ? spinner() : null;
|
||||
try {
|
||||
if (readySpinner) {
|
||||
readySpinner.start("Waiting for daemon to become ready...");
|
||||
} else {
|
||||
log.message("Waiting for daemon to become ready...");
|
||||
}
|
||||
const readyState = await waitForDaemonReady({
|
||||
home: args.home,
|
||||
timeoutMs: args.timeoutMs,
|
||||
onStatus: readySpinner ? (message) => readySpinner.message(message) : undefined,
|
||||
});
|
||||
if (readySpinner) {
|
||||
readySpinner.stop(`Daemon ready on ${readyState.listen}`);
|
||||
} else {
|
||||
log.message(`Daemon ready on ${readyState.listen}`);
|
||||
}
|
||||
return readyState;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (readySpinner) {
|
||||
readySpinner.error(message);
|
||||
} else {
|
||||
log.error(message);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runOnboard(options: OnboardOptions): Promise<void> {
|
||||
const richUi = process.stdin.isTTY && process.stdout.isTTY;
|
||||
if (richUi) {
|
||||
@@ -340,96 +436,21 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
|
||||
renderNote(paseoHome, "Paseo home");
|
||||
}
|
||||
|
||||
let persisted = loadPersistedConfig(paseoHome) as OnboardPersistedConfig;
|
||||
const persistedVoiceSelection = resolvePersistedVoiceSelection(persisted);
|
||||
const shouldPrompt = options.voice === "ask" || options.voice === undefined;
|
||||
let voiceEnabled: boolean;
|
||||
try {
|
||||
voiceEnabled =
|
||||
shouldPrompt && persistedVoiceSelection !== null
|
||||
? persistedVoiceSelection
|
||||
: await resolveVoiceSelection(options.voice);
|
||||
} catch (error) {
|
||||
if (error instanceof OnboardCancelledError) {
|
||||
cancel("Onboarding cancelled.");
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (shouldPrompt && persistedVoiceSelection !== null) {
|
||||
log.message(`Using saved voice setup from config (${voiceEnabled ? "enabled" : "disabled"}).`);
|
||||
}
|
||||
|
||||
persisted = applyVoiceSelection(persisted, voiceEnabled);
|
||||
savePersistedConfig(paseoHome, persisted);
|
||||
|
||||
const voiceEnabled = await resolveAndPersistVoice(paseoHome, options);
|
||||
const config = loadConfig(paseoHome, { cli: toCliOverrides(options) });
|
||||
|
||||
const voiceStatus = voiceEnabled
|
||||
? "Voice features enabled. Local speech models will be downloaded automatically if missing."
|
||||
: "Voice features disabled. Local speech models will not be downloaded.";
|
||||
log.message(voiceStatus);
|
||||
log.message(
|
||||
voiceEnabled
|
||||
? "Voice features enabled. Local speech models will be downloaded automatically if missing."
|
||||
: "Voice features disabled. Local speech models will not be downloaded.",
|
||||
);
|
||||
|
||||
const stateBeforeStart = resolveLocalDaemonState({ home: options.home });
|
||||
const startSpinner = richUi ? spinner() : null;
|
||||
|
||||
if (!stateBeforeStart.running) {
|
||||
try {
|
||||
if (startSpinner) {
|
||||
startSpinner.start("Starting daemon...");
|
||||
} else {
|
||||
log.message("Starting daemon...");
|
||||
}
|
||||
const startup = await startLocalDaemonDetached(options);
|
||||
if (startSpinner) {
|
||||
startSpinner.stop(`Daemon started (PID ${startup.pid ?? "unknown"})`);
|
||||
} else {
|
||||
log.message(`Daemon started (PID ${startup.pid ?? "unknown"})`);
|
||||
}
|
||||
log.message(`Logs: ${startup.logPath}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (startSpinner) {
|
||||
startSpinner.error(message);
|
||||
} else {
|
||||
log.error(message);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
log.message(`Daemon already running (PID ${stateBeforeStart.pidInfo?.pid ?? "unknown"}).`);
|
||||
}
|
||||
|
||||
let readyState: { listen: string; host: string | null };
|
||||
const readySpinner = richUi ? spinner() : null;
|
||||
try {
|
||||
if (readySpinner) {
|
||||
readySpinner.start("Waiting for daemon to become ready...");
|
||||
} else {
|
||||
log.message("Waiting for daemon to become ready...");
|
||||
}
|
||||
readyState = await waitForDaemonReady({
|
||||
home: options.home ?? paseoHome,
|
||||
timeoutMs,
|
||||
onStatus: readySpinner ? (message) => readySpinner.message(message) : undefined,
|
||||
});
|
||||
if (readySpinner) {
|
||||
readySpinner.stop(`Daemon ready on ${readyState.listen}`);
|
||||
} else {
|
||||
log.message(`Daemon ready on ${readyState.listen}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (readySpinner) {
|
||||
readySpinner.error(message);
|
||||
} else {
|
||||
log.error(message);
|
||||
}
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
await ensureDaemonStarted(options, richUi);
|
||||
await waitForDaemonReadyWithUi({
|
||||
home: options.home ?? paseoHome,
|
||||
timeoutMs,
|
||||
richUi,
|
||||
});
|
||||
|
||||
if (config.relayEnabled === false) {
|
||||
log.warn("Relay is disabled; pairing offer is unavailable for this daemon.");
|
||||
|
||||
@@ -125,20 +125,21 @@ export async function runAllowCommand(
|
||||
}
|
||||
|
||||
// Allow permissions
|
||||
const results: PermissionResponseItem[] = [];
|
||||
for (const permission of permissionsToAllow) {
|
||||
await client.respondToPermission(resolvedAgentId, permission.id, {
|
||||
behavior: "allow",
|
||||
...(updatedInput ? { updatedInput } : {}),
|
||||
});
|
||||
results.push({
|
||||
requestId: permission.id.slice(0, 8),
|
||||
agentId: resolvedAgentId,
|
||||
agentShortId: resolvedAgentId.slice(0, 7),
|
||||
name: permission.name,
|
||||
result: "allowed",
|
||||
});
|
||||
}
|
||||
const results: PermissionResponseItem[] = await Promise.all(
|
||||
permissionsToAllow.map(async (permission) => {
|
||||
await client.respondToPermission(resolvedAgentId, permission.id, {
|
||||
behavior: "allow",
|
||||
...(updatedInput ? { updatedInput } : {}),
|
||||
});
|
||||
return {
|
||||
requestId: permission.id.slice(0, 8),
|
||||
agentId: resolvedAgentId,
|
||||
agentShortId: resolvedAgentId.slice(0, 7),
|
||||
name: permission.name,
|
||||
result: "allowed",
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
await client.close();
|
||||
|
||||
|
||||
@@ -89,21 +89,22 @@ export async function runDenyCommand(
|
||||
}
|
||||
|
||||
// Deny permissions
|
||||
const results: PermissionResponseItem[] = [];
|
||||
for (const permission of permissionsToDeny) {
|
||||
await client.respondToPermission(resolvedAgentId, permission.id, {
|
||||
behavior: "deny",
|
||||
...(options.message ? { message: options.message } : {}),
|
||||
...(options.interrupt ? { interrupt: true } : {}),
|
||||
});
|
||||
results.push({
|
||||
requestId: permission.id.slice(0, 8),
|
||||
agentId: resolvedAgentId,
|
||||
agentShortId: resolvedAgentId.slice(0, 7),
|
||||
name: permission.name,
|
||||
result: "denied",
|
||||
});
|
||||
}
|
||||
const results: PermissionResponseItem[] = await Promise.all(
|
||||
permissionsToDeny.map(async (permission) => {
|
||||
await client.respondToPermission(resolvedAgentId, permission.id, {
|
||||
behavior: "deny",
|
||||
...(options.message ? { message: options.message } : {}),
|
||||
...(options.interrupt ? { interrupt: true } : {}),
|
||||
});
|
||||
return {
|
||||
requestId: permission.id.slice(0, 8),
|
||||
agentId: resolvedAgentId,
|
||||
agentShortId: resolvedAgentId.slice(0, 7),
|
||||
name: permission.name,
|
||||
result: "denied",
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
await client.close();
|
||||
|
||||
|
||||
@@ -83,6 +83,46 @@ export function formatDurationMs(durationMs: number): string {
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function resolveScheduleTarget(args: {
|
||||
targetValue: string | undefined;
|
||||
hasExplicitProviderSelection: boolean;
|
||||
newAgentTarget: ScheduleTarget;
|
||||
}): ScheduleTarget {
|
||||
const { targetValue, hasExplicitProviderSelection, newAgentTarget } = args;
|
||||
const currentAgentId = process.env.PASEO_AGENT_ID?.trim();
|
||||
|
||||
if (!targetValue) {
|
||||
if (currentAgentId && !hasExplicitProviderSelection) {
|
||||
return { type: "self", agentId: currentAgentId };
|
||||
}
|
||||
return newAgentTarget;
|
||||
}
|
||||
|
||||
if (targetValue === "new-agent") {
|
||||
return newAgentTarget;
|
||||
}
|
||||
|
||||
if (hasExplicitProviderSelection) {
|
||||
throw {
|
||||
code: "INVALID_TARGET",
|
||||
message: "--provider can only be used with a new-agent target",
|
||||
details: "Use --target new-agent or omit --target to create a new agent schedule",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
if (targetValue === "self") {
|
||||
if (!currentAgentId) {
|
||||
throw {
|
||||
code: "INVALID_TARGET",
|
||||
message: "--target self requires running inside a Paseo agent",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
return { type: "self", agentId: currentAgentId };
|
||||
}
|
||||
|
||||
return { type: "agent", agentId: targetValue };
|
||||
}
|
||||
|
||||
export function parseScheduleCreateInput(options: {
|
||||
prompt: string;
|
||||
every?: string;
|
||||
@@ -127,45 +167,11 @@ export function parseScheduleCreateInput(options: {
|
||||
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
|
||||
},
|
||||
};
|
||||
let target: ScheduleTarget;
|
||||
if (!targetValue) {
|
||||
const currentAgentId = process.env.PASEO_AGENT_ID?.trim();
|
||||
if (currentAgentId && !hasExplicitProviderSelection) {
|
||||
target = { type: "self", agentId: currentAgentId };
|
||||
} else {
|
||||
target = newAgentTarget;
|
||||
}
|
||||
} else if (targetValue === "self") {
|
||||
if (hasExplicitProviderSelection) {
|
||||
throw {
|
||||
code: "INVALID_TARGET",
|
||||
message: "--provider can only be used with a new-agent target",
|
||||
details: "Use --target new-agent or omit --target to create a new agent schedule",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
const currentAgentId = process.env.PASEO_AGENT_ID?.trim();
|
||||
if (!currentAgentId) {
|
||||
throw {
|
||||
code: "INVALID_TARGET",
|
||||
message: "--target self requires running inside a Paseo agent",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
target = { type: "self", agentId: currentAgentId };
|
||||
} else if (targetValue === "new-agent") {
|
||||
target = newAgentTarget;
|
||||
} else {
|
||||
if (hasExplicitProviderSelection) {
|
||||
throw {
|
||||
code: "INVALID_TARGET",
|
||||
message: "--provider can only be used with a new-agent target",
|
||||
details: "Use --target new-agent or omit --target to create a new agent schedule",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
target = {
|
||||
type: "agent",
|
||||
agentId: targetValue,
|
||||
};
|
||||
}
|
||||
const target = resolveScheduleTarget({
|
||||
targetValue,
|
||||
hasExplicitProviderSelection,
|
||||
newAgentTarget,
|
||||
});
|
||||
|
||||
const maxRuns =
|
||||
options.maxRuns === undefined ? undefined : parsePositiveInt(options.maxRuns, "--max-runs");
|
||||
|
||||
@@ -85,11 +85,9 @@ function readPidSocketTarget(paseoHome: string): string | null {
|
||||
listen?: unknown;
|
||||
sockPath?: unknown;
|
||||
};
|
||||
return typeof parsed.listen === "string"
|
||||
? parsed.listen
|
||||
: typeof parsed.sockPath === "string"
|
||||
? parsed.sockPath
|
||||
: null;
|
||||
if (typeof parsed.listen === "string") return parsed.listen;
|
||||
if (typeof parsed.sockPath === "string") return parsed.sockPath;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -143,6 +141,12 @@ function resolveDaemonHostCandidates(options?: ConnectOptions): string[] {
|
||||
return resolveDefaultDaemonHosts();
|
||||
}
|
||||
|
||||
function stripIpcPrefix(trimmed: string): string {
|
||||
if (trimmed.startsWith("unix://")) return trimmed.slice("unix://".length).trim();
|
||||
if (trimmed.startsWith("pipe://")) return trimmed.slice("pipe://".length).trim();
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function resolveDaemonTarget(host: string): DaemonTarget {
|
||||
const trimmed = host.trim();
|
||||
if (
|
||||
@@ -150,11 +154,7 @@ export function resolveDaemonTarget(host: string): DaemonTarget {
|
||||
trimmed.startsWith("pipe://") ||
|
||||
trimmed.startsWith("\\\\.\\pipe\\")
|
||||
) {
|
||||
const socketPath = trimmed.startsWith("unix://")
|
||||
? trimmed.slice("unix://".length).trim()
|
||||
: trimmed.startsWith("pipe://")
|
||||
? trimmed.slice("pipe://".length).trim()
|
||||
: trimmed;
|
||||
const socketPath = stripIpcPrefix(trimmed);
|
||||
if (!socketPath) {
|
||||
throw new Error("Invalid IPC daemon target: missing socket path");
|
||||
}
|
||||
|
||||
@@ -147,14 +147,6 @@ async function test_verify_logs(agentId: string): Promise<void> {
|
||||
// Logs should have content from both tasks
|
||||
assert(result.stdout.length > 0, "Logs should have content");
|
||||
|
||||
// The agent was asked to say specific things, check if either appears in logs
|
||||
// This is a loose check since exact log format may vary
|
||||
const logsLower = result.stdout.toLowerCase();
|
||||
const hasInitialTask =
|
||||
logsLower.includes("initial") || logsLower.includes("hello") || logsLower.includes("task");
|
||||
const hasFollowUp =
|
||||
logsLower.includes("follow") || logsLower.includes("complete") || logsLower.includes("task");
|
||||
|
||||
// At minimum, there should be log entries (we can't guarantee exact content)
|
||||
assert(result.stdout.split("\n").length > 3, "Should have multiple log entries from both tasks");
|
||||
|
||||
|
||||
@@ -64,6 +64,29 @@ function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
interface PermitResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
}
|
||||
|
||||
function findMatchingPermission(
|
||||
result: PermitResult,
|
||||
agentId: string,
|
||||
): { agentId?: string } | undefined {
|
||||
if (result.exitCode !== 0) return undefined;
|
||||
try {
|
||||
const permissions = JSON.parse(result.stdout.trim());
|
||||
if (!Array.isArray(permissions) || permissions.length === 0) return undefined;
|
||||
return permissions.find(
|
||||
(p: { agentId?: string }) =>
|
||||
p.agentId?.startsWith(agentId.slice(0, 7)) ||
|
||||
agentId.startsWith(p.agentId?.slice(0, 7) || ""),
|
||||
);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function test_create_agent_with_permissions(): Promise<string> {
|
||||
console.log("\n--- Test: Create agent in default mode (will request permissions) ---");
|
||||
|
||||
@@ -111,28 +134,12 @@ async function test_wait_for_permission_request(agentId: string): Promise<void>
|
||||
|
||||
while (Date.now() - startTime < maxWait) {
|
||||
const result = await ctx.paseo(["permit", "ls", "--json"]);
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
try {
|
||||
const permissions = JSON.parse(result.stdout.trim());
|
||||
if (Array.isArray(permissions) && permissions.length > 0) {
|
||||
// Check if any permission is for our agent
|
||||
const ourPermission = permissions.find(
|
||||
(p: { agentId?: string }) =>
|
||||
p.agentId?.startsWith(agentId.slice(0, 7)) ||
|
||||
agentId.startsWith(p.agentId?.slice(0, 7) || ""),
|
||||
);
|
||||
if (ourPermission) {
|
||||
console.log("Permission request detected:", ourPermission);
|
||||
console.log("PASS: Agent requested permission");
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// JSON parse failed, continue polling
|
||||
}
|
||||
const ourPermission = findMatchingPermission(result, agentId);
|
||||
if (ourPermission) {
|
||||
console.log("Permission request detected:", ourPermission);
|
||||
console.log("PASS: Agent requested permission");
|
||||
return;
|
||||
}
|
||||
|
||||
await sleep(pollInterval);
|
||||
}
|
||||
|
||||
|
||||
@@ -126,24 +126,17 @@ async function terminateProcessTree(processRef: ChildProcess, timeoutMs: number)
|
||||
signalProcessTree(pid, "SIGTERM");
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
const done = () => resolve();
|
||||
const onExit = () => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve();
|
||||
done();
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
signalProcessTree(pid, "SIGKILL");
|
||||
finish();
|
||||
processRef.removeListener("exit", onExit);
|
||||
done();
|
||||
}, timeoutMs);
|
||||
|
||||
processRef.once("exit", () => {
|
||||
finish();
|
||||
});
|
||||
processRef.once("exit", onExit);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user