chore(merge): integrate main into workspace runtimes

This commit is contained in:
Mohamed Boudra
2026-08-14 13:28:32 +02:00
603 changed files with 33782 additions and 21333 deletions
-6
View File
@@ -1,8 +1,6 @@
import { Command, Option } from "commander";
import { createAgentCommand } from "./commands/agent/index.js";
import { createDaemonCommand } from "./commands/daemon/index.js";
import { createChatCommand } from "./commands/chat/index.js";
import { createLoopCommand } from "./commands/loop/index.js";
import { createPermitCommand } from "./commands/permit/index.js";
import { createProviderCommand } from "./commands/provider/index.js";
import { createScheduleCommand } from "./commands/schedule/index.js";
@@ -168,7 +166,6 @@ export function createCli(): Command {
program.addCommand(createHubCommand());
// Chat commands
program.addCommand(createChatCommand());
// Terminal commands
program.addCommand(createTerminalCommand());
@@ -176,9 +173,6 @@ export function createCli(): Command {
// Workspace script commands
program.addCommand(createScriptCommand());
// Loop commands
program.addCommand(createLoopCommand());
// Schedule commands
program.addCommand(createScheduleCommand());
program.addCommand(createHeartbeatCommand());
-31
View File
@@ -1,31 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
export interface ChatCreateOptions extends ChatCommandOptions {
purpose?: string;
}
export async function runCreateCommand(
name: string,
options: ChatCreateOptions,
_command: Command,
): Promise<SingleResult<ChatRoomRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.createChatRoom({
name,
purpose: options.purpose,
});
return {
type: "single",
data: toChatRoomRow(payload.room!),
schema: chatRoomSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_CREATE_FAILED", "create chat room", err);
} finally {
await client.close().catch(() => {});
}
}
-24
View File
@@ -1,24 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
export async function runDeleteCommand(
room: string,
options: ChatCommandOptions,
_command: Command,
): Promise<SingleResult<ChatRoomRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.deleteChatRoom({ room });
return {
type: "single",
data: toChatRoomRow(payload.room!),
schema: chatRoomSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_DELETE_FAILED", "delete chat room", err);
} finally {
await client.close().catch(() => {});
}
}
-69
View File
@@ -1,69 +0,0 @@
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { runCreateCommand } from "./create.js";
import { runLsCommand } from "./ls.js";
import { runInspectCommand } from "./inspect.js";
import { runDeleteCommand } from "./delete.js";
import { runPostCommand } from "./post.js";
import { runReadCommand } from "./read.js";
import { runWaitCommand } from "./wait.js";
export function createChatCommand(): Command {
const chat = new Command("chat").description("Manage chat rooms for agent coordination");
addJsonAndDaemonHostOptions(
chat
.command("create")
.description("Create a chat room")
.argument("<name>", "Room name (must be unique)")
.option("--purpose <text>", "Room purpose/description"),
).action(withOutput(runCreateCommand));
addJsonAndDaemonHostOptions(chat.command("ls").description("List chat rooms")).action(
withOutput(runLsCommand),
);
addJsonAndDaemonHostOptions(
chat
.command("inspect")
.description("Inspect a chat room")
.argument("<name-or-id>", "Room name or ID"),
).action(withOutput(runInspectCommand));
addJsonAndDaemonHostOptions(
chat
.command("delete")
.description("Delete a chat room")
.argument("<name-or-id>", "Room name or ID"),
).action(withOutput(runDeleteCommand));
addJsonAndDaemonHostOptions(
chat
.command("post")
.description("Post a chat message")
.argument("<name-or-id>", "Room name or ID")
.argument("<message>", "Message body")
.option("--reply-to <msg-id>", "Reply to a specific message ID"),
).action(withOutput(runPostCommand));
addJsonAndDaemonHostOptions(
chat
.command("read")
.description("Read chat messages")
.argument("<name-or-id>", "Room name or ID")
.option("--limit <n>", "Maximum number of messages to return")
.option("--since <duration-or-timestamp>", "Filter by relative duration or ISO timestamp")
.option("--agent <agent-id>", "Filter by author agent ID"),
).action(withOutput(runReadCommand));
addJsonAndDaemonHostOptions(
chat
.command("wait")
.description("Wait for new chat messages")
.argument("<name-or-id>", "Room name or ID")
.option("--timeout <duration>", "Maximum wait time"),
).action(withOutput(runWaitCommand));
return chat;
}
-24
View File
@@ -1,24 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
export async function runInspectCommand(
room: string,
options: ChatCommandOptions,
_command: Command,
): Promise<SingleResult<ChatRoomRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.inspectChatRoom({ room });
return {
type: "single",
data: toChatRoomRow(payload.room!),
schema: chatRoomSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_INSPECT_FAILED", "inspect chat room", err);
} finally {
await client.close().catch(() => {});
}
}
-23
View File
@@ -1,23 +0,0 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
export async function runLsCommand(
options: ChatCommandOptions,
_command: Command,
): Promise<ListResult<ChatRoomRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.listChatRooms();
return {
type: "list",
data: payload.rooms.map(toChatRoomRow),
schema: chatRoomSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_LIST_FAILED", "list chat rooms", err);
} finally {
await client.close().catch(() => {});
}
}
-43
View File
@@ -1,43 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import {
attachAgentNamesToMessages,
connectChatClient,
resolveChatAuthorAgentId,
toChatCommandError,
type ChatCommandOptions,
} from "./shared.js";
import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./schema.js";
export interface ChatPostOptions extends ChatCommandOptions {
replyTo?: string;
}
export async function runPostCommand(
room: string,
body: string,
options: ChatPostOptions,
_command: Command,
): Promise<SingleResult<ChatMessageRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.postChatMessage({
room,
body,
authorAgentId: resolveChatAuthorAgentId(),
replyToMessageId: options.replyTo,
});
const [message] = await attachAgentNamesToMessages(client, [
toChatMessageRow(payload.message!),
]);
return {
type: "single",
data: message,
schema: chatMessageSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_POST_FAILED", "post chat message", err);
} finally {
await client.close().catch(() => {});
}
}
-60
View File
@@ -1,60 +0,0 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import {
attachAgentNamesToMessages,
connectChatClient,
parseSinceValue,
toChatCommandError,
type ChatCommandOptions,
} from "./shared.js";
import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./schema.js";
export interface ChatReadOptions extends ChatCommandOptions {
limit?: string;
since?: string;
agent?: string;
}
function parseLimit(value?: string): number | undefined {
if (!value) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed < 0) {
throw {
code: "INVALID_LIMIT",
message: "Invalid --limit value",
details: "Use a non-negative integer.",
};
}
return parsed;
}
export async function runReadCommand(
room: string,
options: ChatReadOptions,
_command: Command,
): Promise<ListResult<ChatMessageRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.readChatMessages({
room,
limit: parseLimit(options.limit),
since: parseSinceValue(options.since),
authorAgentId: options.agent,
});
const messages = await attachAgentNamesToMessages(
client,
payload.messages.map(toChatMessageRow),
);
return {
type: "list",
data: messages,
schema: chatMessageSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_READ_FAILED", "read chat messages", err);
} finally {
await client.close().catch(() => {});
}
}
-128
View File
@@ -1,128 +0,0 @@
import type { OutputSchema } from "../../output/index.js";
import type { AnyCommandResult, OutputOptions } from "../../output/index.js";
export interface ChatRoomRow {
name: string;
id: string;
purpose: string;
messages: number;
lastMessageAt: string;
}
export interface ChatMessageRow {
id: string;
author: string;
authorName: string | null;
createdAt: string;
replyTo: string;
mentionAgentIds: string[];
mentionLabels: string[];
body: string;
}
export const chatRoomSchema: OutputSchema<ChatRoomRow> = {
idField: "id",
columns: [
{ header: "NAME", field: "name", width: 22 },
{ header: "ID", field: "id", width: 36 },
{ header: "PURPOSE", field: "purpose", width: 30 },
{ header: "MESSAGES", field: "messages", width: 10, align: "right" },
{ header: "LAST MESSAGE", field: "lastMessageAt", width: 24 },
],
};
export const chatMessageSchema: OutputSchema<ChatMessageRow> = {
idField: "id",
columns: [
{ header: "ID", field: "id", width: 36 },
{ header: "AUTHOR", field: "author", width: 16 },
{ header: "AUTHOR NAME", field: (row) => row.authorName ?? "-", width: 20 },
{ header: "CREATED", field: "createdAt", width: 24 },
{ header: "REPLY TO", field: "replyTo", width: 36 },
{ header: "MENTIONS", field: (row) => row.mentionLabels.join(", ") || "-", width: 24 },
{ header: "MESSAGE", field: "body", width: 60 },
],
renderHuman: renderChatTranscript,
};
function renderChatTranscript(
result: AnyCommandResult<ChatMessageRow>,
_options: OutputOptions,
): string {
const data = result.type === "list" ? result.data : [result.data];
if (data.length === 0) {
return "";
}
return data.map(renderChatMessageBlock).join("\n\n");
}
function renderChatMessageBlock(message: ChatMessageRow): string {
const authorLabel = message.authorName
? `${message.authorName} (${message.author})`
: message.author;
const lines = [
`┌─ ${authorLabel} ── ${formatTimestamp(message.createdAt)} ── [msg ${message.id}]`,
];
if (message.replyTo !== "-") {
lines.push(`│ reply-to: msg ${message.replyTo}`);
}
if (message.mentionLabels.length > 0) {
lines.push(`│ mentions: ${message.mentionLabels.join(", ")}`);
}
lines.push("│");
const bodyLines = message.body.split(/\r?\n/);
for (const line of bodyLines) {
lines.push(`│ ${line}`);
}
lines.push("│");
lines.push("└─");
return lines.join("\n");
}
function formatTimestamp(input: string): string {
const date = new Date(input);
if (Number.isNaN(date.getTime())) {
return input;
}
return date.toISOString().replace("T", " ").replace(".000Z", "Z");
}
export function toChatRoomRow(room: {
id: string;
name: string;
purpose: string | null;
messageCount: number;
lastMessageAt: string | null;
}): ChatRoomRow {
return {
id: room.id,
name: room.name,
purpose: room.purpose ?? "-",
messages: room.messageCount,
lastMessageAt: room.lastMessageAt ?? "-",
};
}
export function toChatMessageRow(message: {
id: string;
authorAgentId: string;
createdAt: string;
replyToMessageId: string | null;
mentionAgentIds: string[];
body: string;
}): ChatMessageRow {
return {
id: message.id,
author: message.authorAgentId,
authorName: null,
createdAt: message.createdAt,
replyTo: message.replyToMessageId ?? "-",
mentionAgentIds: message.mentionAgentIds,
mentionLabels: message.mentionAgentIds,
body: message.body,
};
}
-137
View File
@@ -1,137 +0,0 @@
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { parseDuration } from "../../utils/duration.js";
import type { CommandError, CommandOptions } from "../../output/index.js";
import type { ChatMessageRow } from "./schema.js";
export interface ChatCommandOptions extends CommandOptions {
host?: string;
}
export async function connectChatClient(host?: string) {
const daemonHost = getDaemonHost({ host });
try {
const client = await connectToDaemon({ host });
return { client, daemonHost };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${daemonHost}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
}
export async function attachAgentNamesToMessages(
client: Awaited<ReturnType<typeof connectToDaemon>>,
messages: ChatMessageRow[],
options: { timeout?: number; bestEffort?: boolean } = {},
): Promise<ChatMessageRow[]> {
const agentIds = new Set<string>();
for (const message of messages) {
agentIds.add(message.author);
for (const mentionId of message.mentionAgentIds) {
agentIds.add(mentionId);
}
}
if (agentIds.size === 0) {
return messages;
}
let payload: Awaited<ReturnType<typeof client.fetchAgents>>;
try {
payload = await client.fetchAgents({
filter: { includeArchived: true },
...(typeof options.timeout === "number" ? { timeout: options.timeout } : {}),
});
} catch (error) {
if (options.bestEffort) {
return messages;
}
throw error;
}
const agentNames = new Map<string, string>();
for (const entry of payload.entries) {
const title = entry.agent.title?.trim();
if (title) {
agentNames.set(entry.agent.id, title);
}
}
return messages.map((message) => ({
...message,
authorName: agentNames.get(message.author) ?? null,
mentionLabels: message.mentionAgentIds.map((agentId) => {
const name = agentNames.get(agentId);
return name ? `${name} (${agentId})` : agentId;
}),
}));
}
export function toChatCommandError(code: string, action: string, err: unknown): CommandError {
if (err && typeof err === "object" && "code" in err && "message" in err) {
return err as CommandError;
}
const message = err instanceof Error ? err.message : String(err);
const rpcCode =
typeof err === "object" && err !== null && "code" in err && typeof err.code === "string"
? err.code
: undefined;
return {
code: rpcCode ?? code,
message: `Failed to ${action}: ${message}`,
};
}
export function parseSinceValue(input?: string): string | undefined {
if (!input) {
return undefined;
}
try {
const durationMs = parseDuration(input);
return new Date(Date.now() - durationMs).toISOString();
} catch {
const timestamp = new Date(input);
if (Number.isNaN(timestamp.getTime())) {
const error: CommandError = {
code: "INVALID_SINCE",
message: "Invalid --since value",
details: "Use a duration like 10m or an ISO timestamp.",
};
throw error;
}
return timestamp.toISOString();
}
}
export function parseTimeoutMs(input?: string): number | undefined {
if (!input) {
return undefined;
}
try {
const timeoutMs = parseDuration(input);
if (timeoutMs <= 0) {
throw new Error("Timeout must be positive");
}
return timeoutMs;
} 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;
}
}
export function resolveChatAuthorAgentId(): string {
const agentId = process.env.PASEO_AGENT_ID?.trim();
return agentId && agentId.length > 0 ? agentId : "manual";
}
-65
View File
@@ -1,65 +0,0 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import {
attachAgentNamesToMessages,
connectChatClient,
parseTimeoutMs,
toChatCommandError,
type ChatCommandOptions,
} from "./shared.js";
import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./schema.js";
export interface ChatWaitOptions extends ChatCommandOptions {
timeout?: string;
}
const CHAT_WAIT_PREFLIGHT_TIMEOUT_MS = 2000;
export async function runWaitCommand(
room: string,
options: ChatWaitOptions,
_command: Command,
): Promise<ListResult<ChatMessageRow>> {
const timeoutMs = parseTimeoutMs(options.timeout);
const { client } = await connectChatClient(options.host);
const deadline = typeof timeoutMs === "number" ? Date.now() + timeoutMs : null;
const hasExplicitTimeout = deadline !== null;
const remainingTimeoutMs = () =>
deadline === null ? undefined : Math.max(1, deadline - Date.now());
try {
const latest = await client.readChatMessages({
room,
limit: 1,
...(hasExplicitTimeout
? {
timeout: Math.min(remainingTimeoutMs() ?? 1, CHAT_WAIT_PREFLIGHT_TIMEOUT_MS),
}
: {}),
});
const afterMessageId = latest.messages[0]?.id;
const payload = await client.waitForChatMessages({
room,
afterMessageId,
timeoutMs: remainingTimeoutMs() ?? timeoutMs,
});
const messages = await attachAgentNamesToMessages(
client,
payload.messages.map(toChatMessageRow),
hasExplicitTimeout
? {
timeout: remainingTimeoutMs(),
bestEffort: true,
}
: {},
);
return {
type: "list",
data: messages,
schema: chatMessageSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_WAIT_FAILED", "wait for chat messages", err);
} finally {
await client.close().catch(() => {});
}
}
+34 -1
View File
@@ -91,6 +91,35 @@ describe("Hub HTTP client", () => {
);
});
it("reads configuration resources in the Hub's slug vocabulary", async () => {
const requests: Array<{ url: string | undefined; body: string }> = [];
const origin = await startServer(
() => ({
status: 200,
body: {
daemons: [{ id: "a50e05af-4f20-4c8f-8dcc-58e5ea360663", slug: "macbook" }],
github: [
{
slug: "getpaseo",
accountLogin: "getpaseo",
accountType: "Organization",
repositories: ["getpaseo/paseo"],
},
],
discord: [{ slug: "paseo", guildName: "Paseo" }],
slack: [{ slug: "paseo", teamName: "Paseo" }],
},
}),
requests,
);
const resources = await new HubHttpClient().listConfigurationResources(origin, "secret");
assert.equal(resources.daemons[0]?.slug, "macbook");
assert.equal(resources.discord[0]?.slug, "paseo");
assert.equal(requests[0]?.url, "/api/v1/configuration-resources");
});
it("renders file-aware Hub validation issues without exposing credentials or response bodies", async () => {
const requests: Array<{ url: string | undefined; body: string }> = [];
const origin = await startServer(
@@ -126,10 +155,14 @@ describe("Hub HTTP client", () => {
(error: unknown) => {
assert.ok(error instanceof HubCommandError);
assert.equal(error.message.includes("operator-secret"), false);
assert.equal(error.message.includes("Correct the canonical"), false);
assert.equal(error.details?.includes("operator-secret"), false);
assert.equal(error.message.includes("sensitive bundle content"), false);
assert.equal(error.details?.includes("sensitive bundle content"), false);
assert.match(error.details ?? "", /\.paseo\/workflows\/answer\.yml\.steps\.work\.agent/u);
assert.equal(
error.details,
".paseo/workflows/answer.yml: steps.work.agent: unknown named agent [redacted]",
);
return true;
},
);
+30 -4
View File
@@ -21,6 +21,7 @@ describe("Hub commands", () => {
assert.deepEqual(names, [
"login",
"init",
"connect",
"status",
"disconnect",
@@ -344,7 +345,6 @@ describe("Hub commands", () => {
daemon.beforeDisconnect = () => {
assert.equal(credentials.active()?.credential, "human-secret");
};
const result = await runHubLogout(
{ json: true, disconnectDaemon: true, force: true },
{
@@ -359,9 +359,31 @@ describe("Hub commands", () => {
assert.equal(daemon.disconnects, 1);
assert.deepEqual(daemon.disconnectForces, [true]);
assert.equal(result.data.daemonDisconnected, true);
assert.equal(result.data.warning, undefined);
});
it("disconnect reports the current Hub and preserves it in the final result", async () => {
it("logout reports a daemon disconnect warning", async () => {
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://hub.test", credential: "human-secret" });
const daemon = new FakeDaemon("https://hub.test");
daemon.disconnectWarning = "Hub could not be reached; cleanup may remain pending.";
const result = await runHubLogout(
{ json: true, disconnectDaemon: true },
{
credentials,
daemon: new FakeDaemonConnection(daemon),
isInteractive: () => false,
confirmDisconnect: async () => false,
reporter: quietReporter,
},
);
assert.equal(result.data.daemonDisconnected, true);
assert.equal(result.data.warning, "Hub could not be reached; cleanup may remain pending.");
});
it("disconnect reports clean local status without a ghost Hub origin", async () => {
const daemon = new FakeDaemon("https://hub.test");
const progress: string[] = [];
@@ -374,7 +396,7 @@ describe("Hub commands", () => {
);
assert.deepEqual(progress, ["Disconnecting this daemon from https://hub.test"]);
assert.equal(result.data[0]?.hub, "https://hub.test");
assert.equal(result.data[0]?.hub, null);
});
it("preserves login when an accepted daemon disconnect fails", async () => {
@@ -448,6 +470,7 @@ class FakeDaemon implements HubDaemonClient {
disconnects = 0;
readonly disconnectForces: boolean[] = [];
disconnectError: Error | null = null;
disconnectWarning: string | null = null;
beforeDisconnect: (() => void) | null = null;
constructor(private readonly origin: string) {}
@@ -466,7 +489,10 @@ class FakeDaemon implements HubDaemonClient {
this.disconnectForces.push(force);
this.beforeDisconnect?.();
if (this.disconnectError !== null) throw this.disconnectError;
return { status: hubStatus("not_connected", null) };
return {
status: hubStatus("not_connected", null),
...(this.disconnectWarning ? { warning: this.disconnectWarning } : {}),
};
}
async close() {}
+14 -6
View File
@@ -8,7 +8,7 @@ import {
type HubValidationResult,
} from "./hub-client/index.js";
import { PrivateHubCredentialStore, type HubCredentialStore } from "./credentials.js";
import { discoverHubBundle } from "./deploy-bundle.js";
import { discoverHubBundle, type HubDeployBundle } from "./deploy-bundle.js";
import { processHubReporter, reportHubProgress, type HubReporter } from "./reporter.js";
import { addHubResolutionHelp } from "./help.js";
@@ -20,7 +20,7 @@ export interface HubDeployOptions {
json?: boolean;
}
interface HubDeployEnvironment {
export interface HubDeployEnvironment {
cwd: string;
env: Readonly<Record<string, string | undefined>>;
credentials?: HubCredentialStore;
@@ -76,6 +76,18 @@ export async function runHubDeploy(
credentials: new PrivateHubCredentialStore(),
hub: new HubHttpClient(),
},
): Promise<SingleResult<HubDeployResult> | SingleResult<HubDryRunResult>> {
const deployInput = await discoverHubBundle({
cwd: environment.cwd,
...(options.project === undefined ? {} : { project: options.project }),
});
return runHubDeployBundle(options, deployInput, environment);
}
export async function runHubDeployBundle(
options: HubDeployOptions,
deployInput: HubDeployBundle,
environment: HubDeployEnvironment,
): Promise<SingleResult<HubDeployResult> | SingleResult<HubDryRunResult>> {
const credentials = environment.credentials ?? new PrivateHubCredentialStore(environment.env);
const resolution = {
@@ -84,10 +96,6 @@ export async function runHubDeploy(
credentials,
};
const origin = resolveHubOrigin(resolution);
const deployInput = await discoverHubBundle({
cwd: environment.cwd,
...(options.project === undefined ? {} : { project: options.project }),
});
const action = options.dryRun === true ? "Validating" : "Deploying";
reportHubProgress(
environment.reporter ?? processHubReporter,
+2 -2
View File
@@ -31,7 +31,7 @@ export function runHubDisconnect(
);
}
const response = await client.disconnectHub(options.force ?? false);
return hubStatusResult(response.status, response.warning, current.hubOrigin);
return hubStatusResult(response.status, response.warning);
});
}
@@ -42,7 +42,7 @@ export function addHubDisconnectCommand(
addJsonAndDaemonHostOptions(
parent
.command("disconnect")
.option("--force", "Remove local authority even if the Hub is offline"),
.option("--force", "Remove local authority without notifying the Hub"),
).action(
withOutput(async (...args) => {
const options = args.at(-2) as HubDisconnectOptions;
@@ -3,6 +3,7 @@ import { HubCommandError } from "../error.js";
import {
authorizationPollSchema,
authorizationSchema,
configurationResourcesSchema,
enrollmentTokenSchema,
installResponseSchema,
projectsResponseSchema,
@@ -10,6 +11,7 @@ import {
type CliAuthorization,
type CliAuthorizationPoll,
type HubInstallResult,
type HubConfigurationResources,
type HubProject,
type HubValidationResult,
} from "./internal/contracts.js";
@@ -19,6 +21,7 @@ export type {
CliAuthorization,
CliAuthorizationPoll,
HubInstallResult,
HubConfigurationResources,
HubProject,
HubValidationResult,
} from "./internal/contracts.js";
@@ -80,6 +83,18 @@ export class HubHttpClient {
return response.projects;
}
listConfigurationResources(origin: string, apiKey: string): Promise<HubConfigurationResources> {
return requestHub({
origin,
path: "/api/v1/configuration-resources",
method: "GET",
apiKey,
successStatus: 200,
schema: configurationResourcesSchema,
failureMessage: "Hub configuration resource listing failed",
});
}
installConfiguration(input: HubConfigurationInput): Promise<HubInstallResult> {
return requestHub({
origin: input.origin,
@@ -36,6 +36,24 @@ const projectSchema = z
export const projectsResponseSchema = z.object({ projects: z.array(projectSchema) }).strict();
export const configurationResourcesSchema = z
.object({
daemons: z.array(z.object({ id: z.string().uuid(), slug: z.string().min(1) }).strict()),
github: z.array(
z
.object({
slug: z.string().min(1),
accountLogin: z.string().min(1),
accountType: z.string().min(1),
repositories: z.array(z.string().min(1)),
})
.strict(),
),
discord: z.array(z.object({ slug: z.string().min(1), guildName: z.string().min(1) }).strict()),
slack: z.array(z.object({ slug: z.string().min(1), teamName: z.string().min(1) }).strict()),
})
.strict();
export const installResponseSchema = z
.object({
projectSlug: z.string().min(1),
@@ -56,5 +74,6 @@ export const enrollmentTokenSchema = z
export type CliAuthorization = z.infer<typeof authorizationSchema>;
export type CliAuthorizationPoll = z.infer<typeof authorizationPollSchema>;
export type HubProject = z.infer<typeof projectSchema>;
export type HubConfigurationResources = z.infer<typeof configurationResourcesSchema>;
export type HubInstallResult = z.infer<typeof installResponseSchema>;
export type HubValidationResult = z.infer<typeof validationResponseSchema>;
@@ -51,9 +51,14 @@ export async function hubRequestFailure(
);
}
const title = parsed.data.title ?? `${failureMessage} with HTTP ${response.status}`;
const message = parsed.data.detail === undefined ? title : `${title}: ${parsed.data.detail}`;
const details = formatFieldIssues(parsed.data.errors, parsed.data.issues);
const code = response.status === 422 ? "HUB_VALIDATION_FAILED" : "HUB_REQUEST_FAILED";
const message =
details !== undefined || parsed.data.detail === undefined
? title
: `${title}: ${parsed.data.detail}`;
let code = "HUB_REQUEST_FAILED";
if (response.status === 422) code = "HUB_VALIDATION_FAILED";
if (response.status === 404) code = "HUB_NOT_FOUND";
return new HubCommandError(
code,
redactSecret(message, apiKey),
@@ -82,12 +87,20 @@ function formatFieldIssues(
function formatIssuePath(path: z.infer<typeof issuePathSchema> | undefined): string | undefined {
if (path === undefined || typeof path === "string") return path;
const [file, ...fieldPath] = path;
if (typeof file === "string" && file.startsWith(".paseo/") && fieldPath.length > 0) {
return `${file}: ${formatPathSegments(fieldPath)}`;
}
return formatPathSegments(path) || undefined;
}
function formatPathSegments(path: readonly (string | number)[]): string {
let formatted = "";
for (const segment of path) {
if (typeof segment === "number") formatted += `[${segment}]`;
else formatted += formatted.length === 0 ? segment : `.${segment}`;
}
return formatted || undefined;
return formatted;
}
function redactSecret(value: string, secret: string | undefined): string {
+2
View File
@@ -18,6 +18,7 @@ import { addHubProjectsCommand } from "./projects.js";
import { processHubReporter, type HubReporter } from "./reporter.js";
import { hubStatusResult } from "./status-output.js";
import { addHubResolutionHelp } from "./help.js";
import { addHubInitCommand } from "./init.js";
interface HubCommandEnvironment {
env: Readonly<Record<string, string | undefined>>;
@@ -56,6 +57,7 @@ export function createHubCommand(overrides: Partial<HubCommandEnvironment> = {})
flow: environment.login,
reporter: environment.reporter,
});
addHubInitCommand(hub, environment);
addHubConnectCommand(hub, {
env: environment.env,
credentials: environment.credentials,
@@ -0,0 +1,159 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import YAML from "yaml";
import { afterEach, describe, expect, it } from "vitest";
import { discoverHubBundle } from "./deploy-bundle.js";
import { runHubDeploy } from "./deploy.js";
import {
createHubInitScaffold,
planHubInitOpening,
resolveHubInitConnection,
resolveHubInitProjects,
type HubInitProvider,
} from "./init-plan.js";
import { githubRepositoryFromRemote } from "./init.js";
const directories: string[] = [];
const project = (slug: string) => ({
id: "7e950d84-eec0-4e18-b1f9-9c115bdb31e4",
slug,
name: slug.toUpperCase(),
});
afterEach(async () => {
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true })));
});
describe("Hub init planning", () => {
it("includes login only when there is no active login", () => {
expect(planHubInitOpening({ loggedIn: false, paseoDirectoryExists: false })).toEqual({
replaceExisting: false,
steps: ["login", "connect", "project", "scaffold"],
});
expect(planHubInitOpening({ loggedIn: true, paseoDirectoryExists: false })).toEqual({
replaceExisting: false,
steps: ["connect", "project", "scaffold"],
});
});
it("plans a confirmed replacement for an existing .paseo directory", () => {
expect(planHubInitOpening({ loggedIn: true, paseoDirectoryExists: true })).toEqual({
replaceExisting: true,
steps: ["connect", "project", "scaffold"],
});
});
it("stops, defaults, or asks according to the project count", () => {
expect(resolveHubInitProjects([])).toEqual({ kind: "none" });
expect(resolveHubInitProjects([project("one")])).toEqual({
kind: "selected",
project: project("one"),
});
expect(resolveHubInitProjects([project("one"), project("two")])).toEqual({
kind: "choose",
projects: [project("one"), project("two")],
});
});
it("reuses a connected daemon, waits for reconnect, and rejects a different Hub", () => {
const status = {
state: "connected",
daemonId: "daemon-1",
hubOrigin: "https://hub.test",
scopes: ["hub.execution.*"],
connectedAt: null,
lastError: null,
};
expect(resolveHubInitConnection(status, "https://hub.test")).toEqual({
kind: "connected",
daemonId: "daemon-1",
});
expect(resolveHubInitConnection(status, "https://other.test")).toEqual({
kind: "conflict",
origin: "https://hub.test",
});
expect(
resolveHubInitConnection({ ...status, state: "reconnecting" }, "https://hub.test"),
).toEqual({ kind: "pending", state: "reconnecting" });
expect(
resolveHubInitConnection(
{ ...status, state: "not_connected", daemonId: null, hubOrigin: null },
"https://hub.test",
),
).toEqual({ kind: "connect" });
});
});
describe("Hub init scaffold", () => {
it.each([
["github", { repo: "getpaseo/paseo", user: "boudra" }],
["slack", { workspace: "paseo", user: "boudra" }],
["discord", { guild: "paseo", user: "boudra" }],
] satisfies readonly [HubInitProvider, Record<string, string>][])(
"creates a closed %s trigger that the deploy discovery path accepts",
async (provider, providerFilters) => {
const cwd = await temporaryDirectory();
const scaffold = createHubInitScaffold({
cwd,
daemonSlug: "build-studio",
provider,
providerFilters,
});
await mkdir(path.join(cwd, ".paseo", "workflows"), { recursive: true });
await writeFile(path.join(cwd, ".paseo", "hub.yml"), scaffold.hub);
await writeFile(path.join(cwd, scaffold.workflowPath), scaffold.workflow);
const bundle = await discoverHubBundle({ cwd, project: "paseo" });
expect(bundle.workflowCount).toBe(1);
expect(bundle.files.map((file) => file.path)).toEqual([
".paseo/hub.yml",
scaffold.workflowPath,
]);
const parsed = YAML.parse(scaffold.workflow) as {
filters: { from_users: string[]; channels?: string[] };
};
expect(parsed.filters.from_users).toEqual([providerFilters.user]);
expect(parsed.filters.channels).toBeUndefined();
let validatedPaths: readonly string[] = [];
const result = await runHubDeploy(
{ project: "paseo", hub: "https://hub.test", dryRun: true },
{
cwd,
env: { PASEO_HUB_API_KEY: "test-key" },
reporter: { progress() {} },
hub: {
async validateConfiguration(input) {
validatedPaths = input.files.map((file) => file.path);
for (const file of input.files) YAML.parse(file.content);
return { projectSlug: input.projectSlug, valid: true };
},
async installConfiguration() {
throw new Error("dry-run must not install");
},
},
},
);
expect(result.data).toMatchObject({ projectSlug: "paseo", valid: true, workflows: 1 });
expect(validatedPaths).toEqual([".paseo/hub.yml", scaffold.workflowPath]);
},
);
});
describe("GitHub origin detection", () => {
it.each([
["git@github.com:getpaseo/paseo.git", "getpaseo/paseo"],
["ssh://git@github.com/getpaseo/paseo.git", "getpaseo/paseo"],
["https://github.com/getpaseo/paseo.git", "getpaseo/paseo"],
["https://gitlab.com/getpaseo/paseo.git", undefined],
])("resolves %s", (remote, expected) => {
expect(githubRepositoryFromRemote(remote)).toBe(expected);
});
});
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-hub-init-"));
directories.push(directory);
return directory;
}
+201
View File
@@ -0,0 +1,201 @@
import path from "node:path";
import YAML from "yaml";
import type { HubDeployBundle } from "./deploy-bundle.js";
import type { HubProject } from "./hub-client/index.js";
import type { HubStatus } from "./daemon-client.js";
export type HubInitProvider = "github" | "slack" | "discord";
export type HubInitProjectResolution =
| { kind: "none" }
| { kind: "selected"; project: HubProject }
| { kind: "choose"; projects: readonly HubProject[] };
export type HubInitConnectionResolution =
| { kind: "connected"; daemonId: string }
| { kind: "pending"; state: "connecting" | "reconnecting" }
| { kind: "connect" }
| { kind: "conflict"; origin: string };
export interface HubInitOpeningPlan {
replaceExisting: boolean;
steps: readonly ("login" | "connect" | "project" | "scaffold")[];
}
export interface HubInitScaffoldInput {
cwd: string;
daemonSlug: string;
provider: HubInitProvider;
providerFilters: Readonly<Record<string, string>>;
}
export interface HubInitScaffold {
hub: string;
workflowPath: string;
workflow: string;
testAction: string;
}
export function planHubInitOpening(input: {
loggedIn: boolean;
paseoDirectoryExists: boolean;
}): HubInitOpeningPlan {
return {
replaceExisting: input.paseoDirectoryExists,
steps: [...(input.loggedIn ? [] : (["login"] as const)), "connect", "project", "scaffold"],
};
}
export function createHubInitBundle(
projectSlug: string,
scaffold: HubInitScaffold,
): HubDeployBundle {
return {
projectSlug,
workflowCount: 1,
files: [
{ path: ".paseo/hub.yml", content: scaffold.hub },
{ path: scaffold.workflowPath, content: scaffold.workflow },
],
};
}
export function resolveHubInitProjects(projects: readonly HubProject[]): HubInitProjectResolution {
if (projects.length === 0) return { kind: "none" };
if (projects.length === 1) return { kind: "selected", project: projects[0]! };
return { kind: "choose", projects };
}
export function resolveHubInitConnection(
status: HubStatus,
origin: string,
): HubInitConnectionResolution {
if (status.state === "connected" && status.hubOrigin === origin && status.daemonId !== null) {
return { kind: "connected", daemonId: status.daemonId };
}
if (
(status.state === "connecting" || status.state === "reconnecting") &&
status.hubOrigin === origin
) {
return { kind: "pending", state: status.state };
}
if (status.hubOrigin !== null && status.state !== "not_connected" && status.state !== "revoked") {
return { kind: "conflict", origin: status.hubOrigin };
}
return { kind: "connect" };
}
export function createHubInitScaffold(input: HubInitScaffoldInput): HubInitScaffold {
const environmentName = input.daemonSlug;
const hub = YAML.stringify(
{
environments: {
[environmentName]: {
kind: "daemon",
daemon: input.daemonSlug,
cwd: path.resolve(input.cwd),
},
},
agents: {
codex: {
provider: "codex",
mode: "full-access",
},
},
},
{ lineWidth: 0 },
);
const provider = providerScaffold(input.provider, input.providerFilters, environmentName);
return {
hub,
workflowPath: `.paseo/workflows/${input.provider}-help.yml`,
workflow: YAML.stringify(provider.workflow, { lineWidth: 0 }),
testAction: provider.testAction,
};
}
function providerScaffold(
provider: HubInitProvider,
filters: Readonly<Record<string, string>>,
environment: string,
): { workflow: object; testAction: string } {
if (provider === "github") {
const repo = requireFilter(filters, "repo");
const user = requireFilter(filters, "user");
return {
workflow: workflow({
name: "github-help",
on: "github.issue_comment",
filters: { repo, contains: "@paseo", from_users: [user] },
environment,
}),
testAction: `Comment \`@paseo have a look\` on ${repo}.`,
};
}
const user = requireFilter(filters, "user");
if (provider === "slack") {
const workspace = requireFilter(filters, "workspace");
return {
workflow: workflow({
name: "slack-help",
on: "slack.mention",
filters: { workspace, from_users: [user] },
environment,
reply: "slack.reply",
}),
testAction: "Mention `@Paseo have a look` in Slack.",
};
}
const guild = requireFilter(filters, "guild");
return {
workflow: workflow({
name: "discord-help",
on: "discord.mention",
filters: { guild, from_users: [user] },
environment,
reply: "discord.reply",
}),
testAction: "Mention `@Paseo have a look` in Discord.",
};
}
function workflow(input: {
name: string;
on: string;
filters: object;
environment: string;
reply?: "slack.reply" | "discord.reply";
}): object {
const replyInstruction = input.reply === undefined ? "" : "Answer with hub.reply, then ";
return {
name: input.name,
on: input.on,
max_runtime: "2h",
filters: input.filters,
steps: [
{
id: "work",
environment: input.environment,
max_runtime: "90m",
idle_timeout: "10m",
agent: "codex",
prompt: [
{
text: `${replyInstruction}complete this request and call hub.finish_execution when done.\n\n<user-prompt>\n\${{ paseo.prompt }}\n</user-prompt>\n`,
},
],
...(input.reply === undefined
? {}
: { allow_outputs: [{ type: input.reply, max: 1, required: true }] }),
},
],
};
}
function requireFilter(filters: Readonly<Record<string, string>>, name: string): string {
const value = filters[name]?.trim();
if (!value) throw new Error(`${name} is required for this provider`);
return value;
}
+558
View File
@@ -0,0 +1,558 @@
import {
cancel,
confirm,
intro,
isCancel,
log,
note,
outro,
select,
spinner,
text,
} from "@clack/prompts";
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { lstat, mkdir, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import type { Command } from "commander";
import { DEFAULT_HUB_ORIGIN, resolveHubCredential } from "./authority.js";
import type { HubCredentialStore } from "./credentials.js";
import type { HubDaemonConnection } from "./daemon-client.js";
import { withHubDaemon } from "./daemon-client.js";
import { HubCommandError } from "./error.js";
import type { HubConfigurationResources, HubHttpClient, HubProject } from "./hub-client/index.js";
import {
createHubInitBundle,
createHubInitScaffold,
planHubInitOpening,
resolveHubInitConnection,
resolveHubInitProjects,
type HubInitProvider,
} from "./init-plan.js";
import type { CliLoginFlow } from "./login-flow.js";
import { runHubLogin } from "./login.js";
import { runHubConnect } from "./connect.js";
import { runHubDeployBundle } from "./deploy.js";
import { runHubProjects } from "./projects.js";
import type { HubReporter } from "./reporter.js";
import { normalizeHubOrigin } from "./origin.js";
const execFileAsync = promisify(execFile);
const DAEMON_READY_TIMEOUT_MS = 60_000;
const DAEMON_READY_POLL_MS = 250;
interface HubInitEnvironment {
env: Readonly<Record<string, string | undefined>>;
credentials: HubCredentialStore;
hub: HubHttpClient;
login: Pick<CliLoginFlow, "authorize">;
daemon: HubDaemonConnection;
reporter: HubReporter;
cwd(): string;
}
class HubInitCancelledError extends Error {}
function initErrorMessage(error: unknown): string {
if (error instanceof HubCommandError && error.details) {
return `${error.message}\n${error.details}`;
}
return error instanceof Error ? error.message : String(error);
}
export function addHubInitCommand(parent: Command, environment: HubInitEnvironment): void {
parent
.command("init")
.description("Create and optionally deploy a safe starter Hub bundle")
.action(async () => {
try {
await runHubInit(environment);
} catch (error) {
if (error instanceof HubInitCancelledError) {
cancel(error.message);
return;
}
cancel(initErrorMessage(error));
process.exitCode = 1;
}
});
}
export async function runHubInit(environment: HubInitEnvironment): Promise<void> {
requireInteractiveTerminal();
intro("Set up Paseo Hub");
const cwd = environment.cwd();
const activeLogin = environment.credentials.active();
const opening = planHubInitOpening({
loggedIn: activeLogin !== null,
paseoDirectoryExists: await pathExists(path.join(cwd, ".paseo")),
});
if (
opening.replaceExisting &&
!(await requiredConfirm("Replace the existing .paseo/ Hub bundle?", false))
) {
throw new HubInitCancelledError("Existing .paseo/ bundle left unchanged.");
}
const origin = await ensureLogin(activeLogin?.origin, environment);
const daemonId = await ensureDaemonConnection(origin, environment);
const project = await chooseProject(origin, environment);
const resources = await loadConfigurationResources(origin, environment);
const daemon = resources.daemons.find(({ id }) => id === daemonId);
if (daemon === undefined) {
throw new HubCommandError(
"HUB_DAEMON_RESOURCE_MISSING",
"The connected daemon is not available in this Hub organization. Reconnect it and try again.",
);
}
log.success(`Connected as ${daemon.slug}`);
const provider = await chooseProvider();
const providerFilters = await collectProviderFilters(provider, resources, cwd);
const scaffold = createHubInitScaffold({
cwd,
daemonSlug: daemon.slug,
provider,
providerFilters,
});
const bundle = createHubInitBundle(project.slug, scaffold);
await withSpinner("Validating bundle", async () => {
await runHubDeployBundle({ project: project.slug, hub: origin, dryRun: true }, bundle, {
cwd,
env: environment.env,
credentials: environment.credentials,
hub: environment.hub,
reporter: { progress() {} },
});
});
log.success("Dry run passed");
await writeScaffold(cwd, scaffold, opening.replaceExisting);
log.success(`Created .paseo/hub.yml and ${scaffold.workflowPath}`);
const deploy = await requiredConfirm("Deploy now?", true);
if (deploy) {
await withSpinner("Deploying bundle", async () => {
await runHubDeployBundle({ project: project.slug, hub: origin }, bundle, {
cwd,
env: environment.env,
credentials: environment.credentials,
hub: environment.hub,
reporter: { progress() {} },
});
});
log.success("Deployed");
} else {
log.message(`Skipped deployment. Run: paseo hub deploy -p ${project.slug}`);
}
const activityUrl = new URL(`/projects/${project.slug}/activity`, origin).toString();
note(`${scaffold.testAction}\nWatch it at ${activityUrl}`, "Test your workflow");
outro(deploy ? "Hub is ready" : "Hub bundle is ready");
}
async function ensureLogin(
activeOrigin: string | undefined,
environment: HubInitEnvironment,
): Promise<string> {
const endpoint = await requiredSelect({
message: "Hub endpoint",
initialValue:
activeOrigin === undefined || activeOrigin === DEFAULT_HUB_ORIGIN ? "hosted" : "custom",
options: [
{ value: "hosted", label: "hub.paseo.sh" },
{ value: "custom", label: "Custom endpoint…" },
],
});
const origin =
endpoint === "hosted"
? DEFAULT_HUB_ORIGIN
: await requiredText({
message: "Custom Hub URL",
initialValue:
activeOrigin === undefined || activeOrigin === DEFAULT_HUB_ORIGIN
? environment.env.PASEO_HUB_URL
: activeOrigin,
validate(value) {
try {
normalizeHubOrigin(value ?? "");
} catch {
return "Enter a valid Hub URL";
}
return undefined;
},
});
const normalizedOrigin = normalizeHubOrigin(origin);
if (environment.credentials.get(normalizedOrigin) !== null) {
log.success(`Logged in to ${normalizedOrigin}`);
return normalizedOrigin;
}
await runHubLogin(
normalizedOrigin,
{},
{
env: environment.env,
credentials: environment.credentials,
flow: environment.login,
reporter: environment.reporter,
},
);
log.success(`Logged in to ${normalizedOrigin}`);
return normalizedOrigin;
}
async function ensureDaemonConnection(
origin: string,
environment: HubInitEnvironment,
): Promise<string> {
const status = await withHubDaemon(environment.daemon, undefined, async (daemon) =>
daemon.getHubStatus().then((response) => response.status),
);
const connection = resolveHubInitConnection(status, origin);
if (connection.kind === "connected") {
return connection.daemonId;
}
if (connection.kind === "pending") {
return waitForDaemonReady(origin, environment.daemon);
}
if (connection.kind === "conflict") {
throw new HubCommandError(
"HUB_DAEMON_ALREADY_CONNECTED",
`This daemon is connected to ${connection.origin}. Disconnect it before running Hub init for ${origin}.`,
);
}
if (!(await requiredConfirm(`Connect this daemon to ${origin}?`, true))) {
throw new HubInitCancelledError("A connected daemon is required to create the bundle.");
}
await runHubConnect(
origin,
{},
{
env: environment.env,
credentials: environment.credentials,
hub: environment.hub,
daemon: environment.daemon,
reporter: environment.reporter,
},
);
return waitForDaemonReady(origin, environment.daemon);
}
async function waitForDaemonReady(
origin: string,
connection: HubDaemonConnection,
): Promise<string> {
return withSpinner("Waiting for the daemon to connect", async (reporter) =>
withHubDaemon(connection, undefined, async (daemon) => {
const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS;
while (true) {
const status = (await daemon.getHubStatus()).status;
const resolution = resolveHubInitConnection(status, origin);
if (resolution.kind === "connected") return resolution.daemonId;
if (resolution.kind === "conflict") {
throw new HubCommandError(
"HUB_DAEMON_ALREADY_CONNECTED",
`This daemon connected to ${resolution.origin} while Hub init was waiting for ${origin}.`,
);
}
if (resolution.kind === "connect") {
throw new HubCommandError(
"HUB_DAEMON_CONNECTION_LOST",
"The daemon lost its Hub relationship while Hub init was waiting for it.",
);
}
if (Date.now() >= deadline) {
throw new HubCommandError(
"HUB_DAEMON_CONNECTION_TIMEOUT",
"The daemon did not connect within 60 seconds. Check `paseo hub status`, then run Hub init again.",
);
}
reporter.progress(`Daemon is ${resolution.state}`);
await delay(DAEMON_READY_POLL_MS);
}
}),
);
}
async function chooseProject(origin: string, environment: HubInitEnvironment): Promise<HubProject> {
const result = await runHubProjects(
{ hub: origin },
{
env: environment.env,
credentials: environment.credentials,
hub: environment.hub,
reporter: { progress() {} },
},
);
const resolution = resolveHubInitProjects(result.data.projects);
if (resolution.kind === "none") {
throw new HubInitCancelledError(
`No Hub projects exist yet. Create one at ${new URL("/projects/new", origin).toString()}, then run paseo hub init again.`,
);
}
if (resolution.kind === "selected") {
return resolution.project;
}
const slug = await requiredSelect({
message: "Project",
options: resolution.projects.map((project) => ({
value: project.slug,
label: project.name,
hint: project.slug,
})),
});
const project = resolution.projects.find((candidate) => candidate.slug === slug);
if (project === undefined) {
throw new HubCommandError(
"HUB_PROJECT_SELECTION_INVALID",
"The selected Hub project is unavailable.",
);
}
return project;
}
async function chooseProvider(): Promise<HubInitProvider> {
return requiredSelect({
message: "Trigger provider",
options: [
{ value: "github", label: "GitHub", hint: "issue or pull request comment" },
{ value: "slack", label: "Slack", hint: "channel mention" },
{ value: "discord", label: "Discord", hint: "channel mention" },
],
});
}
async function collectProviderFilters(
provider: HubInitProvider,
resources: HubConfigurationResources,
cwd: string,
): Promise<Readonly<Record<string, string>>> {
if (provider === "github") {
const [login, originRemote] = await Promise.all([
readGhValue(["api", "user", "--jq", ".login"]),
readCommandValue("git", ["remote", "get-url", "origin"], cwd),
]);
const repo = originRemote === undefined ? undefined : githubRepositoryFromRemote(originRemote);
if (repo === undefined) {
throw new HubCommandError(
"HUB_GITHUB_REPOSITORY_UNDETECTED",
"Could not detect a GitHub repository from the origin remote.",
);
}
if (!resources.github.some(({ repositories }) => repositories.includes(repo))) {
const connected = resources.github.flatMap(({ repositories }) => repositories);
throw new HubCommandError(
"HUB_GITHUB_REPOSITORY_NOT_CONNECTED",
`${repo} is not connected to this Hub organization. Connected repositories: ${connected.join(", ") || "none"}.`,
);
}
return {
user: await requiredText({
message: "Your GitHub username (only this user can trigger the bot)",
initialValue: login,
}),
repo,
};
}
if (provider === "slack") {
return {
workspace: await chooseConnection(
"Slack workspace",
resources.slack.map(({ slug, teamName }) => ({ slug, label: teamName })),
),
user: await requiredText({
message: "Your Slack username (only this user can trigger the bot)",
}),
};
}
return {
guild: await chooseConnection(
"Discord server",
resources.discord.map(({ slug, guildName }) => ({ slug, label: guildName })),
),
user: await requiredText({
message: "Your Discord username (only this user can trigger the bot)",
}),
};
}
async function loadConfigurationResources(
origin: string,
environment: HubInitEnvironment,
): Promise<HubConfigurationResources> {
try {
return await withSpinner("Loading Hub connections", () =>
environment.hub.listConfigurationResources(
origin,
resolveHubCredential({
options: { origin },
env: environment.env,
credentials: environment.credentials,
origin,
}),
),
);
} catch (error) {
if (error instanceof HubCommandError && error.code === "HUB_NOT_FOUND") {
throw new HubCommandError(
"HUB_UPDATE_REQUIRED",
"This Hub does not support guided setup. Update the Hub and try again.",
);
}
throw error;
}
}
async function chooseConnection(
message: string,
connections: readonly { slug: string; label: string }[],
): Promise<string> {
if (connections.length === 0) {
throw new HubCommandError(
"HUB_PROVIDER_CONNECTION_REQUIRED",
`No ${message.toLowerCase()} is connected in this Hub organization. Connect one and try again.`,
);
}
if (connections.length === 1) return connections[0]!.slug;
return requiredSelect({
message,
options: connections.map(({ slug, label }) => ({ value: slug, label })),
});
}
async function writeScaffold(
cwd: string,
scaffold: ReturnType<typeof createHubInitScaffold>,
replaceExisting: boolean,
): Promise<void> {
const root = path.join(cwd, ".paseo");
const staging = path.join(cwd, `.paseo-init-${randomUUID()}`);
const backup = path.join(cwd, `.paseo-backup-${randomUUID()}`);
let movedExisting = false;
try {
await mkdir(path.join(staging, "workflows"), { recursive: true });
await writeFile(path.join(staging, "hub.yml"), scaffold.hub, { flag: "wx" });
await writeFile(
path.join(staging, "workflows", path.basename(scaffold.workflowPath)),
scaffold.workflow,
{
flag: "wx",
},
);
if (replaceExisting) {
await rename(root, backup);
movedExisting = true;
}
await rename(staging, root);
if (movedExisting) await rm(backup, { recursive: true, force: true });
} catch (error) {
await rm(staging, { recursive: true, force: true });
if (movedExisting) {
await rename(backup, root).catch(() => undefined);
}
throw error;
}
}
async function pathExists(target: string): Promise<boolean> {
try {
await lstat(target);
return true;
} catch (error) {
if (errorCode(error) === "ENOENT") return false;
throw error;
}
}
function errorCode(error: unknown): string | undefined {
return error instanceof Error && "code" in error && typeof error.code === "string"
? error.code
: undefined;
}
function delay(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
async function readGhValue(args: readonly string[]): Promise<string | undefined> {
return readCommandValue("gh", args);
}
async function readCommandValue(
command: string,
args: readonly string[],
cwd?: string,
): Promise<string | undefined> {
try {
const { stdout } = await execFileAsync(command, [...args], { encoding: "utf8", cwd });
return stdout.trim() || undefined;
} catch {
return undefined;
}
}
export function githubRepositoryFromRemote(remote: string): string | undefined {
const trimmed = remote.trim().replace(/\.git$/u, "");
const ssh = /^(?:ssh:\/\/)?git@github\.com[:/]([^/]+\/[^/]+)$/u.exec(trimmed);
if (ssh?.[1] !== undefined) return ssh[1];
try {
const url = new URL(trimmed);
if (url.hostname !== "github.com") return undefined;
const repository = url.pathname.replace(/^\//u, "");
return /^[^/]+\/[^/]+$/u.test(repository) ? repository : undefined;
} catch {
return undefined;
}
}
async function withSpinner<T>(
message: string,
action: (reporter: HubReporter) => Promise<T>,
): Promise<T> {
const progress = spinner();
progress.start(message);
try {
const result = await action({ progress: (nextMessage) => progress.message(nextMessage) });
progress.stop(message);
return result;
} catch (error) {
progress.error(message);
throw error;
}
}
async function requiredText(options: Parameters<typeof text>[0]): Promise<string> {
const answer = await text({
...options,
validate(value) {
const input = value ?? "";
const customError = options.validate?.(input);
if (customError !== undefined) return customError;
return input.trim().length === 0 ? "A value is required" : undefined;
},
});
if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled.");
return answer.trim();
}
async function requiredConfirm(message: string, initialValue: boolean): Promise<boolean> {
const answer = await confirm({ message, initialValue });
if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled.");
return answer;
}
async function requiredSelect<T extends string>(
options: Parameters<typeof select<T>>[0],
): Promise<T> {
const answer = await select<T>(options);
if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled.");
return answer;
}
function requireInteractiveTerminal(): void {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
throw new HubCommandError("HUB_INIT_INTERACTIVE_REQUIRED", "paseo hub init requires a TTY.");
}
}
+11 -4
View File
@@ -11,6 +11,7 @@ interface HubLogoutResult {
origin: string | null;
status: "logged_out" | "not_logged_in";
daemonDisconnected: boolean;
warning?: string;
}
const schema: OutputSchema<HubLogoutResult> = {
@@ -19,6 +20,7 @@ const schema: OutputSchema<HubLogoutResult> = {
{ header: "HUB", field: "origin" },
{ header: "STATUS", field: "status" },
{ header: "DAEMON DISCONNECTED", field: "daemonDisconnected" },
{ header: "WARNING", field: "warning" },
],
};
@@ -70,11 +72,16 @@ export async function runHubLogout(
options,
`Disconnecting this daemon from ${active.origin}`,
);
await withHubDaemon(dependencies.daemon, options.host, async (daemon) => {
await daemon.disconnectHub(options.force ?? false);
const disconnect = await withHubDaemon(dependencies.daemon, options.host, async (daemon) => {
return daemon.disconnectHub(options.force ?? false);
});
dependencies.credentials.logoutActive();
return logoutResult({ origin: active.origin, status: "logged_out", daemonDisconnected: true });
return logoutResult({
origin: active.origin,
status: "logged_out",
daemonDisconnected: disconnect.status.state === "not_connected",
...(disconnect.warning ? { warning: disconnect.warning } : {}),
});
}
export function addHubLogoutCommand(parent: Command, dependencies: HubLogoutDependencies): void {
@@ -83,7 +90,7 @@ export function addHubLogoutCommand(parent: Command, dependencies: HubLogoutDepe
.command("logout")
.description("Remove the active stored Hub CLI login")
.option("--disconnect-daemon", "Also disconnect a daemon related to the same Hub")
.option("--force", "Remove daemon authority even if Hub is offline"),
.option("--force", "Remove daemon authority without notifying the Hub"),
).action(
withOutput(async (...args) => {
const options = args.at(-2) as HubLogoutOptions;
-32
View File
@@ -1,32 +0,0 @@
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions, addDaemonHostOption } from "../../utils/command-options.js";
import { addLoopRunOptions, runLoopRunCommand } from "./run.js";
import { addLoopLsOptions, runLoopLsCommand } from "./ls.js";
import { addLoopInspectOptions, runLoopInspectCommand } from "./inspect.js";
import { addLoopLogsOptions, runLoopLogsCommand } from "./logs.js";
import { addLoopStopOptions, runLoopStopCommand } from "./stop.js";
export function createLoopCommand(): Command {
const loop = new Command("loop").description("Run iterative worker loops");
addJsonAndDaemonHostOptions(addLoopRunOptions(loop.command("run"))).action(
withOutput(runLoopRunCommand),
);
addJsonAndDaemonHostOptions(addLoopLsOptions(loop.command("ls"))).action(
withOutput(runLoopLsCommand),
);
addJsonAndDaemonHostOptions(addLoopInspectOptions(loop.command("inspect"))).action(
withOutput(runLoopInspectCommand),
);
addDaemonHostOption(addLoopLogsOptions(loop.command("logs"))).action(runLoopLogsCommand);
addJsonAndDaemonHostOptions(addLoopStopOptions(loop.command("stop"))).action(
withOutput(runLoopStopCommand),
);
return loop;
}
-119
View File
@@ -1,119 +0,0 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, CommandError, OutputSchema, ListResult } from "../../output/index.js";
import type { LoopDaemonClient, LoopRecord } from "./types.js";
interface InspectRow {
key: string;
value: string;
}
export interface LoopInspectOptions extends CommandOptions {}
export function addLoopInspectOptions(command: Command): Command {
return command.description("Show loop details and iteration history").argument("<id>", "Loop ID");
}
function createInspectSchema(loop: LoopRecord): OutputSchema<InspectRow> {
return {
idField: "key",
columns: [
{ header: "KEY", field: "key", width: 18 },
{ header: "VALUE", field: "value", width: 80 },
],
serialize: () => loop,
};
}
function toRows(loop: LoopRecord): InspectRow[] {
return [
{ key: "Id", value: loop.id },
{ key: "Name", value: loop.name ?? "null" },
{ key: "Status", value: loop.status },
{ key: "Cwd", value: loop.cwd },
{ key: "Provider", value: loop.provider },
{ key: "Model", value: loop.model ?? "null" },
{ key: "WorkerProvider", value: loop.workerProvider ?? "null" },
{ key: "WorkerModel", value: loop.workerModel ?? "null" },
{ key: "VerifierProvider", value: loop.verifierProvider ?? "null" },
{ key: "VerifierModel", value: loop.verifierModel ?? "null" },
{ key: "Prompt", value: loop.prompt },
{ key: "VerifyPrompt", value: loop.verifyPrompt ?? "null" },
{
key: "VerifyChecks",
value: loop.verifyChecks.length > 0 ? loop.verifyChecks.join(" | ") : "[]",
},
{ key: "Archive", value: String(loop.archive) },
{ key: "SleepMs", value: String(loop.sleepMs) },
{
key: "MaxIterations",
value: loop.maxIterations === null ? "null" : String(loop.maxIterations),
},
{ key: "MaxTimeMs", value: loop.maxTimeMs === null ? "null" : String(loop.maxTimeMs) },
{ key: "CreatedAt", value: loop.createdAt },
{ key: "UpdatedAt", value: loop.updatedAt },
{ key: "CompletedAt", value: loop.completedAt ?? "null" },
{
key: "Iterations",
value:
loop.iterations.length === 0
? "[]"
: loop.iterations
.map((iteration) => {
const summary = [
`#${iteration.index}`,
iteration.status,
iteration.workerAgentId ? `worker=${iteration.workerAgentId}` : null,
iteration.verifierAgentId ? `verifier=${iteration.verifierAgentId}` : null,
iteration.failureReason ? `reason=${iteration.failureReason}` : null,
]
.filter(Boolean)
.join(" ");
return summary;
})
.join(" | "),
},
];
}
export type LoopInspectResult = ListResult<InspectRow>;
export async function runLoopInspectCommand(
id: string,
options: LoopInspectOptions,
_command: Command,
): Promise<LoopInspectResult> {
const host = getDaemonHost({ host: options.host });
let client;
try {
client = (await connectToDaemon({
host: options.host,
})) as unknown as LoopDaemonClient;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
try {
const payload = await client.loopInspect(id);
await client.close();
if (payload.error || !payload.loop) {
throw new Error(payload.error ?? `Loop not found: ${id}`);
}
return {
type: "list",
data: toRows(payload.loop),
schema: createInspectSchema(payload.loop),
};
} catch (error) {
await client.close().catch(() => {});
throw {
code: "LOOP_INSPECT_FAILED",
message: error instanceof Error ? error.message : String(error),
} satisfies CommandError;
}
}
-83
View File
@@ -1,83 +0,0 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, CommandOptions } from "../../output/index.js";
import type { LoopDaemonClient, LoopLogEntry } from "./types.js";
export interface LoopLogsOptions extends CommandOptions {
pollInterval?: string;
}
export function addLoopLogsOptions(command: Command): Command {
return command
.description("Stream loop logs")
.argument("<id>", "Loop ID")
.option("--poll-interval <ms>", "Polling interval in milliseconds", "1000");
}
function parsePollInterval(value: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw {
code: "INVALID_POLL_INTERVAL",
message: "--poll-interval must be a positive integer",
} satisfies CommandError;
}
return parsed;
}
function renderLogEntry(entry: LoopLogEntry): string {
const prefix = [
entry.timestamp,
entry.source,
entry.iteration === null ? null : `iteration=${entry.iteration}`,
entry.level === "error" ? "ERROR" : null,
]
.filter(Boolean)
.join(" ");
return `${prefix}\n${entry.text}`;
}
export async function runLoopLogsCommand(
id: string,
options: LoopLogsOptions,
_command: Command,
): Promise<void> {
const host = getDaemonHost({ host: options.host });
const pollInterval = parsePollInterval(options.pollInterval ?? "1000");
let client: LoopDaemonClient;
try {
client = (await connectToDaemon({
host: options.host,
})) as unknown as LoopDaemonClient;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`);
console.error("Start the daemon with: paseo daemon start");
process.exit(1);
}
async function streamLogs(cursor: number): Promise<void> {
const payload = await client.loopLogs(id, cursor);
if (payload.error || !payload.loop) {
throw new Error(payload.error ?? `Loop not found: ${id}`);
}
for (const entry of payload.entries) {
console.log(renderLogEntry(entry));
}
if (payload.loop.status !== "running") {
await client.close();
return;
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
return streamLogs(payload.nextCursor);
}
try {
await streamLogs(0);
} catch (error) {
await client.close().catch(() => {});
const message = error instanceof Error ? error.message : String(error);
console.error(`Error: Failed to stream loop logs: ${message}`);
process.exit(1);
}
}
-83
View File
@@ -1,83 +0,0 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, CommandError, OutputSchema, ListResult } from "../../output/index.js";
import type { LoopDaemonClient, LoopListItem } from "./types.js";
interface LoopListRow {
id: string;
name: string | null;
status: string;
cwd: string;
updated: string;
activeIteration: string;
}
export interface LoopLsOptions extends CommandOptions {}
export const loopLsSchema: OutputSchema<LoopListRow> = {
idField: "id",
columns: [
{ header: "LOOP ID", field: "id", width: 10 },
{ header: "NAME", field: "name", width: 20 },
{ header: "STATUS", field: "status", width: 10 },
{ header: "ITER", field: "activeIteration", width: 8 },
{ header: "CWD", field: "cwd", width: 40 },
{ header: "UPDATED", field: "updated", width: 24 },
],
};
export function addLoopLsOptions(command: Command): Command {
return command.description("List loops");
}
function toRow(loop: LoopListItem): LoopListRow {
return {
id: loop.id,
name: loop.name,
status: loop.status,
cwd: loop.cwd,
updated: loop.updatedAt,
activeIteration: loop.activeIteration === null ? "-" : String(loop.activeIteration),
};
}
export type LoopLsResult = ListResult<LoopListRow>;
export async function runLoopLsCommand(
options: LoopLsOptions,
_command: Command,
): Promise<LoopLsResult> {
const host = getDaemonHost({ host: options.host });
let client;
try {
client = (await connectToDaemon({
host: options.host,
})) as unknown as LoopDaemonClient;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
try {
const payload = await client.loopList();
await client.close();
if (payload.error) {
throw new Error(payload.error);
}
return {
type: "list",
data: payload.loops.map(toRow),
schema: loopLsSchema,
};
} catch (error) {
await client.close().catch(() => {});
throw {
code: "LOOP_LIST_FAILED",
message: error instanceof Error ? error.message : String(error),
} satisfies CommandError;
}
}
@@ -1,89 +0,0 @@
import { describe, it, expect } from "vitest";
import { buildLoopRunInput } from "./run.js";
import type { LoopRunOptions } from "./types.js";
describe("buildLoopRunInput", () => {
it("should parse provider/model from provider string", () => {
const options: LoopRunOptions = {
provider: "opencode/hy3-preview-free",
model: undefined,
verifyProvider: undefined,
verifyModel: undefined,
verify: undefined,
verifyCheck: [],
archive: false,
name: undefined,
sleep: undefined,
maxIterations: undefined,
maxTime: undefined,
} as LoopRunOptions;
const result = buildLoopRunInput("test prompt", options);
expect(result.provider).toBe("opencode");
expect(result.model).toBe("hy3-preview-free");
});
it("should use provider only when no model in string", () => {
const options = {
provider: "opencode",
model: undefined,
verifyProvider: undefined,
verifyModel: undefined,
verify: undefined,
verifyCheck: [],
archive: false,
name: undefined,
sleep: undefined,
maxIterations: undefined,
maxTime: undefined,
} as LoopRunOptions;
const result = buildLoopRunInput("test prompt", options);
expect(result.provider).toBe("opencode");
expect(result.model).toBeUndefined();
});
it("should pass verifierProvider with model parsing", () => {
const options = {
provider: undefined,
model: undefined,
verifyProvider: "codex/gpt-5.4",
verifyModel: undefined,
verify: undefined,
verifyCheck: [],
archive: false,
name: undefined,
sleep: undefined,
maxIterations: undefined,
maxTime: undefined,
} as LoopRunOptions;
const result = buildLoopRunInput("test prompt", options);
expect(result.verifierProvider).toBe("codex");
expect(result.verifierModel).toBe("gpt-5.4");
});
it("should prefer explicit model over parsed model", () => {
const options = {
provider: "opencode/hy3-preview-free",
model: "other-model",
verifyProvider: undefined,
verifyModel: undefined,
verify: undefined,
verifyCheck: [],
archive: false,
name: undefined,
sleep: undefined,
maxIterations: undefined,
maxTime: undefined,
} as LoopRunOptions;
const result = buildLoopRunInput("test prompt", options);
expect(result.provider).toBe("opencode");
expect(result.model).toBe("other-model");
});
});
-208
View File
@@ -1,208 +0,0 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
CommandError,
OutputSchema,
SingleResult,
} from "../../output/index.js";
import { collectMultiple } from "../../utils/command-options.js";
import { parseDuration } from "../../utils/duration.js";
import { resolveProviderAndModel } from "../../utils/provider-model.js";
import type { LoopDaemonClient, LoopRecord, LoopRunInput } from "./types.js";
export interface LoopRunRow {
id: string;
status: string;
name: string | null;
cwd: string;
}
export interface LoopRunOptions extends CommandOptions {
provider?: string;
model?: string;
mode?: string;
verifyProvider?: string;
verifyModel?: string;
verifyMode?: string;
verify?: string;
verifyCheck?: string[];
archive?: boolean;
name?: string;
sleep?: string;
maxIterations?: string;
maxTime?: string;
}
export const loopRunSchema: OutputSchema<LoopRunRow> = {
idField: "id",
columns: [
{ header: "LOOP ID", field: "id", width: 10 },
{ header: "STATUS", field: "status", width: 10 },
{ header: "NAME", field: "name", width: 20 },
{ header: "CWD", field: "cwd", width: 40 },
],
};
export function addLoopRunOptions(command: Command): Command {
return command
.description("Start a loop")
.argument("<prompt>", "Prompt for each fresh worker iteration")
.option("--provider <provider>", "Default provider for worker and verifier agents")
.option("--model <model>", "Default model for worker and verifier agents")
.option(
"--mode <mode>",
"Provider-specific mode for the worker agent (e.g. claude bypassPermissions, opencode build)",
)
.option("--verify-provider <provider>", "Provider for the verifier agent")
.option("--verify-model <model>", "Model for the verifier agent")
.option("--verify-mode <mode>", "Provider-specific mode for the verifier agent")
.option("--verify <prompt>", "Verifier agent prompt")
.option(
"--verify-check <command>",
"Shell command that must exit 0 (repeatable)",
collectMultiple,
[],
)
.option("--archive", "Archive worker and verifier agents after each iteration")
.option("--name <name>", "Optional loop name")
.option("--sleep <duration>", "Delay between iterations (for example: 30s, 5m)")
.option("--max-iterations <n>", "Maximum number of iterations")
.option("--max-time <duration>", "Maximum total runtime (for example: 1h, 30m)");
}
function toRow(loop: LoopRecord): LoopRunRow {
return {
id: loop.id,
status: loop.status,
name: loop.name,
cwd: loop.cwd,
};
}
function parseMaxIterations(value: string | undefined): number | undefined {
if (value === undefined) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw {
code: "INVALID_MAX_ITERATIONS",
message: "--max-iterations must be a positive integer",
} satisfies CommandError;
}
return parsed;
}
// oxlint-disable complexity
export function buildLoopRunInput(prompt: string, options: LoopRunOptions): LoopRunInput {
const verifyPrompt = options.verify?.trim();
if (options.verify !== undefined && !verifyPrompt) {
throw {
code: "INVALID_VERIFY_PROMPT",
message: "--verify cannot be empty",
} satisfies CommandError;
}
const result: LoopRunInput = {
prompt,
cwd: process.cwd(),
};
// Resolve provider/model
if (options.provider) {
const { provider, model } = resolveProviderAndModel({ provider: options.provider });
if (provider) result.provider = provider;
// Explicit --model takes precedence over parsed model
if (options.model?.trim()) {
result.model = options.model.trim();
} else if (model) {
result.model = model;
}
} else if (options.model?.trim()) {
result.model = options.model.trim();
}
if (options.mode?.trim()) {
result.modeId = options.mode.trim();
}
// Resolve verifier provider/model
if (options.verifyProvider) {
const { provider, model } = resolveProviderAndModel({ provider: options.verifyProvider });
if (provider) result.verifierProvider = provider;
// Explicit --verify-model takes precedence over parsed model
if (options.verifyModel?.trim()) {
result.verifierModel = options.verifyModel.trim();
} else if (model) {
result.verifierModel = model;
}
} else if (options.verifyModel?.trim()) {
result.verifierModel = options.verifyModel.trim();
}
if (options.verifyMode?.trim()) {
result.verifierModeId = options.verifyMode.trim();
}
if (verifyPrompt) result.verifyPrompt = verifyPrompt;
if (options.verifyCheck && options.verifyCheck.length > 0) {
result.verifyChecks = options.verifyCheck;
}
if (options.archive) result.archive = true;
if (options.name?.trim()) result.name = options.name.trim();
if (options.sleep) result.sleepMs = parseDuration(options.sleep);
if (options.maxIterations) {
result.maxIterations = parseMaxIterations(options.maxIterations);
}
if (options.maxTime) result.maxTimeMs = parseDuration(options.maxTime);
return result;
}
// oxlint-enable complexity
export type LoopRunResult = SingleResult<LoopRunRow>;
export async function runLoopRunCommand(
prompt: string,
options: LoopRunOptions,
_command: Command,
): Promise<LoopRunResult> {
const host = getDaemonHost({ host: options.host });
const input = buildLoopRunInput(prompt, options);
let client;
try {
client = (await connectToDaemon({
host: options.host,
})) as unknown as LoopDaemonClient;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
try {
const payload = await client.loopRun(input);
await client.close();
if (payload.error || !payload.loop) {
throw new Error(payload.error ?? "Loop creation failed");
}
return {
type: "single",
data: toRow(payload.loop),
schema: loopRunSchema,
};
} catch (error) {
await client.close().catch(() => {});
if (error && typeof error === "object" && "code" in error) {
throw error;
}
throw {
code: "LOOP_RUN_FAILED",
message: error instanceof Error ? error.message : String(error),
} satisfies CommandError;
}
}
-80
View File
@@ -1,80 +0,0 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
CommandError,
OutputSchema,
SingleResult,
} from "../../output/index.js";
import type { LoopDaemonClient, LoopRecord } from "./types.js";
interface LoopStopRow {
id: string;
status: string;
activeIteration: string;
}
export interface LoopStopOptions extends CommandOptions {}
export const loopStopSchema: OutputSchema<LoopStopRow> = {
idField: "id",
columns: [
{ header: "LOOP ID", field: "id", width: 10 },
{ header: "STATUS", field: "status", width: 10 },
{ header: "ITER", field: "activeIteration", width: 8 },
],
};
export function addLoopStopOptions(command: Command): Command {
return command.description("Stop a running loop").argument("<id>", "Loop ID");
}
function toRow(loop: LoopRecord): LoopStopRow {
return {
id: loop.id,
status: loop.status,
activeIteration: loop.activeIteration === null ? "-" : String(loop.activeIteration),
};
}
export type LoopStopResult = SingleResult<LoopStopRow>;
export async function runLoopStopCommand(
id: string,
options: LoopStopOptions,
_command: Command,
): Promise<LoopStopResult> {
const host = getDaemonHost({ host: options.host });
let client;
try {
client = (await connectToDaemon({
host: options.host,
})) as unknown as LoopDaemonClient;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
try {
const payload = await client.loopStop(id);
await client.close();
if (payload.error || !payload.loop) {
throw new Error(payload.error ?? `Loop not found: ${id}`);
}
return {
type: "single",
data: toRow(payload.loop),
schema: loopStopSchema,
};
} catch (error) {
await client.close().catch(() => {});
throw {
code: "LOOP_STOP_FAILED",
message: error instanceof Error ? error.message : String(error),
} satisfies CommandError;
}
}
-151
View File
@@ -1,151 +0,0 @@
export type LoopStatus = "running" | "succeeded" | "failed" | "stopped";
export interface LoopLogEntry {
seq: number;
timestamp: string;
iteration: number | null;
source: "loop" | "worker" | "verifier" | "verify-check";
level: "info" | "error";
text: string;
}
export interface LoopVerifyCheckResult {
command: string;
exitCode: number;
passed: boolean;
stdout: string;
stderr: string;
startedAt: string;
completedAt: string;
}
export interface LoopVerifyPromptResult {
passed: boolean;
reason: string;
verifierAgentId: string | null;
startedAt: string;
completedAt: string;
}
export interface LoopIterationRecord {
index: number;
workerAgentId: string | null;
workerStartedAt: string;
workerCompletedAt: string | null;
verifierAgentId: string | null;
status: "running" | "succeeded" | "failed" | "stopped";
workerOutcome: "completed" | "failed" | "canceled" | null;
failureReason: string | null;
verifyChecks: LoopVerifyCheckResult[];
verifyPrompt: LoopVerifyPromptResult | null;
}
export interface LoopRecord {
id: string;
name: string | null;
prompt: string;
cwd: string;
provider: string;
model: string | null;
modeId: string | null;
workerProvider: string | null;
workerModel: string | null;
verifierProvider: string | null;
verifierModel: string | null;
verifierModeId: string | null;
verifyPrompt: string | null;
verifyChecks: string[];
archive: boolean;
sleepMs: number;
maxIterations: number | null;
maxTimeMs: number | null;
status: LoopStatus;
createdAt: string;
updatedAt: string;
startedAt: string;
completedAt: string | null;
stopRequestedAt: string | null;
iterations: LoopIterationRecord[];
logs: LoopLogEntry[];
nextLogSeq: number;
activeIteration: number | null;
activeWorkerAgentId: string | null;
activeVerifierAgentId: string | null;
}
export interface LoopListItem {
id: string;
name: string | null;
status: LoopStatus;
cwd: string;
createdAt: string;
updatedAt: string;
activeIteration: number | null;
}
export interface LoopLogsResult {
loop: LoopRecord;
entries: LoopLogEntry[];
nextCursor: number;
}
export interface LoopRunPayload {
requestId: string;
loop: LoopRecord | null;
error: string | null;
}
export interface LoopListPayload {
requestId: string;
loops: LoopListItem[];
error: string | null;
}
export interface LoopInspectPayload {
requestId: string;
loop: LoopRecord | null;
error: string | null;
}
export interface LoopLogsPayload {
requestId: string;
loop: LoopRecord | null;
entries: LoopLogEntry[];
nextCursor: number;
error: string | null;
}
export interface LoopStopPayload {
requestId: string;
loop: LoopRecord | null;
error: string | null;
}
export interface LoopRunInput {
prompt: string;
cwd: string;
provider?: string;
model?: string;
modeId?: string;
workerProvider?: string;
workerModel?: string;
verifierProvider?: string;
verifierModel?: string;
verifierModeId?: string;
verifyPrompt?: string;
verifyChecks?: string[];
archive?: boolean;
name?: string;
sleepMs?: number;
maxIterations?: number;
maxTimeMs?: number;
}
export interface LoopDaemonClient {
loopRun(input: LoopRunInput): Promise<LoopRunPayload>;
loopList(): Promise<LoopListPayload>;
loopInspect(id: string): Promise<LoopInspectPayload>;
loopLogs(id: string, afterSeq?: number): Promise<LoopLogsPayload>;
loopStop(id: string): Promise<LoopStopPayload>;
close(): Promise<void>;
}
@@ -0,0 +1,45 @@
import type { Command } from "commander";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, OutputSchema, SingleResult } from "../../output/index.js";
export interface ProviderDiagnosticResult {
provider: string;
diagnostic: string;
}
export const providerDiagnosticSchema: OutputSchema<ProviderDiagnosticResult> = {
idField: "provider",
columns: [
{ header: "PROVIDER", field: "provider" },
{ header: "DIAGNOSTIC", field: "diagnostic" },
],
renderHuman(result) {
if (result.type === "single") return result.data.diagnostic;
return result.data.map((entry) => entry.diagnostic).join("\n\n");
},
};
export interface ProviderDiagnosticOptions extends CommandOptions {
host?: string;
}
export async function runDiagnosticCommand(
provider: string,
options: ProviderDiagnosticOptions,
_command: Command,
): Promise<SingleResult<ProviderDiagnosticResult>> {
const client = await connectToDaemon({ host: options.host });
try {
const result = await client.getProviderDiagnostic(provider.trim().toLowerCase());
return {
type: "single",
data: {
provider: result.provider,
diagnostic: result.diagnostic,
},
schema: providerDiagnosticSchema,
};
} finally {
await client.close();
}
}
@@ -1,6 +1,7 @@
import { Command } from "commander";
import { runLsCommand } from "./ls.js";
import { runModelsCommand } from "./models.js";
import { runDiagnosticCommand } from "./diagnostic.js";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
@@ -19,5 +20,12 @@ export function createProviderCommand(): Command {
.option("--thinking", "Include thinking option IDs for each model"),
).action(withOutput(runModelsCommand));
addJsonAndDaemonHostOptions(
provider
.command("diagnostic")
.description("Show provider installation, environment, and availability diagnostics")
.argument("<provider>", "Provider name"),
).action(withOutput(runDiagnosticCommand));
return provider;
}
@@ -4,6 +4,7 @@ import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { runArchiveCommand } from "./archive.js";
import { runCreateCommand } from "./create.js";
import { runLsCommand } from "./ls.js";
import { runRenameCommand } from "./rename.js";
export function createWorkspaceCommand(): Command {
const workspace = new Command("workspace").description("Manage workspaces");
@@ -32,6 +33,18 @@ export function createWorkspaceCommand(): Command {
withOutput(runLsCommand),
);
addJsonAndDaemonHostOptions(
workspace
.command("rename")
.description("Set a workspace's user-visible title")
.argument("<workspace-id>", "Workspace id")
.argument("[title]", "New workspace title")
.option("--reset", "Clear the title and revert to the branch or directory name")
// Commander 12 accepts excess positionals by default, which would rename to
// the first word of an unquoted multi-word title and silently drop the rest.
.allowExcessArguments(false),
).action(withOutput(runRenameCommand));
addJsonAndDaemonHostOptions(
workspace
.command("archive")
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { createWorkspaceCommand } from "./index.js";
import { resolveWorkspaceTitle } from "./rename.js";
function catchError(run: () => unknown): unknown {
try {
run();
return null;
} catch (error) {
return error;
}
}
describe("workspace rename title", () => {
it("trims the requested title", () => {
expect(resolveWorkspaceTitle({ title: " Fix login " })).toBe("Fix login");
});
it("clears the title when resetting to the derived name", () => {
expect(resolveWorkspaceTitle({ reset: true })).toBeNull();
});
it("rejects a reset that also carries a title", () => {
expect(
catchError(() => resolveWorkspaceTitle({ title: "Fix login", reset: true })),
).toMatchObject({ code: "INVALID_OPTIONS" });
// An explicit empty title is still a title the caller passed, not a reset.
expect(catchError(() => resolveWorkspaceTitle({ title: "", reset: true }))).toMatchObject({
code: "INVALID_OPTIONS",
});
});
it("requires a non-empty title when not resetting", () => {
expect(catchError(() => resolveWorkspaceTitle({}))).toMatchObject({ code: "MISSING_TITLE" });
expect(catchError(() => resolveWorkspaceTitle({ title: " " }))).toMatchObject({
code: "MISSING_TITLE",
});
});
});
describe("workspace rename arguments", () => {
function parseRename(argv: string[]): unknown {
const workspace = createWorkspaceCommand()
.exitOverride()
.configureOutput({ writeErr: () => undefined });
workspace.commands.find((command) => command.name() === "rename")?.exitOverride();
return catchError(() => workspace.parse(argv, { from: "user" }));
}
it("rejects an unquoted multi-word title instead of dropping words", () => {
expect(parseRename(["rename", "ws-1", "Auth", "rework"])).toMatchObject({
code: "commander.excessArguments",
});
});
});
@@ -0,0 +1,80 @@
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, OutputSchema, SingleResult } from "../../output/index.js";
interface WorkspaceRenameResult {
workspaceId: string;
/** Raw title override; null once it has been reset to the derived name. */
title: string | null;
}
const workspaceRenameSchema: OutputSchema<WorkspaceRenameResult> = {
idField: "workspaceId",
columns: [
{ header: "WORKSPACE ID", field: "workspaceId", width: 20 },
{ header: "TITLE", field: "title", width: 30 },
],
};
export interface WorkspaceRenameOptions {
host?: string;
reset?: boolean;
}
/**
* Resolve the title to send to the daemon. Null clears the override and
* reverts the workspace to its branch- or directory-derived name.
*/
export function resolveWorkspaceTitle(input: { title?: string; reset?: boolean }): string | null {
const title = input.title?.trim() ?? "";
if (input.reset) {
if (input.title !== undefined) {
throw {
code: "INVALID_OPTIONS",
message: "--reset cannot be combined with a title",
details: "Pass a title to rename the workspace, or --reset to revert to the derived name",
} satisfies CommandError;
}
return null;
}
if (title.length === 0) {
throw {
code: "MISSING_TITLE",
message: "Title cannot be empty",
details: "Usage: paseo workspace rename <workspace-id> <title> | --reset",
} satisfies CommandError;
}
return title;
}
export async function runRenameCommand(
workspaceId: string,
titleArg: string | undefined,
options: WorkspaceRenameOptions,
_command: Command,
): Promise<SingleResult<WorkspaceRenameResult>> {
const title = resolveWorkspaceTitle({ title: titleArg, reset: options.reset });
const host = getDaemonHost({ host: options.host });
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
});
try {
const applied = await client.setWorkspaceTitle(workspaceId, title);
return {
type: "single",
data: { workspaceId, title: applied.title },
schema: workspaceRenameSchema,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw { code: "WORKSPACE_RENAME_FAILED", message } satisfies CommandError;
} finally {
await client.close().catch(() => undefined);
}
}