Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 58d1767a64 Fix mobile sidebar reset after theme switch 2026-03-28 09:59:50 +07:00
Mohamed Boudra 0ab346e5a2 fix(desktop): restore windows daemon bootstrap logging 2026-03-27 22:48:27 +07:00
Mohamed Boudra a8c04c26b9 fix: hardcode paseo://app as allowed CORS origin in daemon bootstrap
The desktop app uses the paseo:// custom protocol scheme, but the
origin was only passed via PASEO_CORS_ORIGINS when the desktop started
the daemon itself. If the daemon was started by the CLI, the origin
was missing and the Electron renderer's WebSocket connection was
rejected.

Also removes file:// and null from allowed origins — any page loaded
via file:// could connect to the daemon, which is a security gap.
2026-03-27 21:40:02 +07:00
118 changed files with 2483 additions and 10306 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-6/O7JL4VDI7bKvFwXSRMvJ7VV2yiBUtQ9zrY3cbKYb0=";
npmDepsHash = "sha256-jBl1E7cwEf5XKXFQip/ZlEa/zNQoz0Hq5t0hqjp8Qe4=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
-10
View File
@@ -25126,15 +25126,6 @@
"node": ">=10"
}
},
"node_modules/lucide-react": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz",
"integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lucide-react-native": {
"version": "0.546.0",
"resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-0.546.0.tgz",
@@ -35712,7 +35703,6 @@
"@tanstack/react-router": "^1.120.3",
"@tanstack/react-start": "^1.120.3",
"framer-motion": "^12.35.2",
"lucide-react": "^1.7.0",
"react": "^19.1.4",
"react-dom": "^19.1.4",
"react-markdown": "^10.1.0",
-14
View File
@@ -28,7 +28,6 @@ import {
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { loadSettingsFromStorage } from "@/hooks/use-settings";
import { useColorScheme } from "@/hooks/use-color-scheme";
import { SessionProvider } from "@/contexts/session-context";
import type { HostProfile } from "@/types/host-connection";
import {
@@ -69,7 +68,6 @@ import {
ensureOsNotificationPermission,
} from "@/utils/os-notifications";
import { getDesktopHost } from "@/desktop/host";
import { setDesktopTitleBarTheme } from "@/desktop/electron/window";
import { buildNotificationRoute } from "@/utils/notification-routing";
import {
buildHostRootRoute,
@@ -414,9 +412,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
const { settings, isLoading: settingsLoading } = useAppSettings();
const storeReady = useStoreReady();
const { upsertConnectionFromOfferUrl } = useHostMutations();
const systemColorScheme = useColorScheme();
const isLoading = settingsLoading || !storeReady;
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
// Apply theme setting on mount and when it changes
useEffect(() => {
@@ -429,16 +425,6 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
}
}, [isLoading, settings.theme]);
useEffect(() => {
if (isLoading || Platform.OS !== "web") {
return;
}
void setDesktopTitleBarTheme(resolvedTheme).catch((error) => {
console.warn("[DesktopWindow] Failed to update title bar theme", error);
});
}, [isLoading, resolvedTheme]);
if (isLoading) {
const isDesktopManaged =
!settingsLoading && shouldUseDesktopDaemon() && settings.manageBuiltInDaemon;
+4 -4
View File
@@ -556,7 +556,7 @@ function MobileSidebar({
>
{({ hovered }) => (
<Plus
size={theme.iconSize.md}
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -581,7 +581,7 @@ function MobileSidebar({
>
{({ hovered }) => (
<Settings
size={theme.iconSize.md}
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -736,7 +736,7 @@ function DesktopSidebar({
>
{({ hovered }) => (
<Plus
size={theme.iconSize.md}
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -761,7 +761,7 @@ function DesktopSidebar({
>
{({ hovered }) => (
<Settings
size={theme.iconSize.md}
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -565,18 +565,6 @@ export default function TerminalEmulator({
const handleInsetTop = Math.max(0, (thumbRegionHeight - scrollbarGeometry.handleSize) / 2);
const handleTravelDurationMs =
isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS;
const handleContextMenu = () => {
const showContextMenu = window.paseoDesktop?.menu?.showContextMenu;
if (typeof showContextMenu !== "function") {
return;
}
const hasSelection = Boolean(window.getSelection()?.toString());
void showContextMenu({
kind: "terminal",
hasSelection,
});
};
return (
<div
@@ -598,10 +586,6 @@ export default function TerminalEmulator({
onPointerDown={() => {
runtimeRef.current?.focus();
}}
onContextMenu={(event) => {
event.preventDefault();
handleContextMenu();
}}
>
<div
ref={hostRef}
@@ -27,12 +27,3 @@ export async function isDesktopFullscreen(): Promise<boolean> {
}
return await win.isFullscreen();
}
export async function setDesktopTitleBarTheme(theme: "light" | "dark"): Promise<void> {
const win = getDesktopWindow();
if (!win || typeof win.setTitleBarTheme !== "function") {
return;
}
await win.setTitleBarTheme(theme);
}
-9
View File
@@ -37,13 +37,6 @@ export interface DesktopOpenerBridge {
openUrl?: (url: string) => Promise<void>;
}
export interface DesktopMenuBridge {
showContextMenu?: (input?: {
kind?: "terminal";
hasSelection?: boolean;
}) => Promise<void>;
}
export interface DesktopWindowBridge {
label?: string;
startMove?: (screenX: number, screenY: number) => void;
@@ -51,7 +44,6 @@ export interface DesktopWindowBridge {
endMove?: () => void;
toggleMaximize?: () => Promise<void>;
isFullscreen?: () => Promise<boolean>;
setTitleBarTheme?: (theme: "light" | "dark") => Promise<void>;
onResized?: <TEvent = unknown>(
handler: (event: TEvent) => void,
) => Promise<() => void> | (() => void);
@@ -81,7 +73,6 @@ export interface DesktopHostBridge {
dialog?: DesktopDialogBridge;
notification?: DesktopNotificationBridge;
opener?: DesktopOpenerBridge;
menu?: DesktopMenuBridge;
}
declare global {
@@ -10,15 +10,8 @@ const CHANGELOG_URL = "https://paseo.sh/changelog";
export function UpdateBanner() {
const { theme } = useUnistyles();
const {
isDesktop,
status,
availableUpdate,
errorMessage,
checkForUpdates,
installUpdate,
isInstalling,
} = useDesktopAppUpdater();
const { isDesktop, status, availableUpdate, checkForUpdates, installUpdate, isInstalling } =
useDesktopAppUpdater();
const [dismissed, setDismissed] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -40,36 +33,24 @@ export function UpdateBanner() {
if (!isDesktop) return null;
if (dismissed) return null;
if (status !== "available" && status !== "installed" && status !== "installing" && status !== "error")
return null;
if (status !== "available" && status !== "installed") return null;
const isInstalled = status === "installed";
const isError = status === "error";
function getTitle(): string {
if (isInstalled) return "Update installed";
if (isInstalling) return "Installing update";
if (isError) return "Update failed";
return "Update available";
}
function getSubtitle(): string {
if (isInstalled) return "Restart to use the new version.";
if (isInstalling) return "Downloading and installing...";
if (isError) return errorMessage ?? "Something went wrong.";
return `${availableUpdate?.latestVersion ? `v${availableUpdate.latestVersion.replace(/^v/i, "")} is ready` : "A new version is ready"} to install.`;
}
return (
<View style={styles.container} pointerEvents="box-none">
<Pressable onPress={() => setDismissed(true)} hitSlop={8} style={styles.closeButton}>
<X size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.banner}>
<Pressable onPress={() => setDismissed(true)} hitSlop={8} style={styles.closeButton}>
<X size={14} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.textSection}>
<Text style={styles.title}>{getTitle()}</Text>
<Text style={styles.subtitle}>{getSubtitle()}</Text>
<Text style={styles.title}>{isInstalled ? "Update installed" : "Update available"}</Text>
<Text style={styles.subtitle}>
{isInstalled
? "Restart to use the new version."
: `${availableUpdate?.latestVersion ? `v${availableUpdate.latestVersion.replace(/^v/i, "")} is ready` : "A new version is ready"} to install.`}
</Text>
</View>
<View style={styles.actions}>
@@ -80,7 +61,7 @@ export function UpdateBanner() {
<Text style={styles.outlineButtonText}>What's new</Text>
</Pressable>
{!isInstalled && !isError && (
{!isInstalled && (
<Pressable
onPress={() => void installUpdate()}
disabled={isInstalling}
@@ -95,15 +76,6 @@ export function UpdateBanner() {
</Text>
</Pressable>
)}
{isError && (
<Pressable
onPress={() => void checkForUpdates()}
style={({ pressed }) => [styles.primaryButton, pressed && styles.buttonPressed]}
>
<Text style={styles.primaryButtonText}>Retry</Text>
</Pressable>
)}
</View>
</View>
</View>
@@ -137,21 +109,15 @@ const styles = StyleSheet.create((theme) => ({
},
closeButton: {
position: "absolute",
top: -8,
left: -8,
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
top: theme.spacing[2],
left: theme.spacing[2],
padding: theme.spacing[1],
zIndex: 1,
},
textSection: {
flex: 1,
gap: 2,
paddingTop: theme.spacing[1],
},
title: {
color: theme.colors.foreground,
@@ -30,16 +30,6 @@ describe("use-settings", () => {
);
});
it("defaults theme to auto when storage is empty", async () => {
asyncStorageMock.getItem.mockResolvedValue(null);
asyncStorageMock.setItem.mockResolvedValue();
const mod = await import("./use-settings");
const result = await mod.loadSettingsFromStorage();
expect(result.theme).toBe("auto");
});
it("loads persisted built-in daemon management state", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:app-settings") {
+1 -1
View File
@@ -12,7 +12,7 @@ export interface AppSettings {
}
export const DEFAULT_APP_SETTINGS: AppSettings = {
theme: "auto",
theme: "dark",
manageBuiltInDaemon: true,
};
@@ -8,7 +8,6 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal, type ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import { openExternalUrl } from "@/utils/open-external-url";
import {
type PendingTerminalModifiers,
isTerminalModifierDomKey,
@@ -169,12 +168,7 @@ export class TerminalEmulatorRuntime {
let webglAddon: WebglAddon | null = null;
terminal.loadAddon(fitAddon);
terminal.loadAddon(unicode11Addon);
terminal.loadAddon(
new WebLinksAddon((event, uri) => {
event.preventDefault();
void openExternalUrl(uri);
}),
);
terminal.loadAddon(new WebLinksAddon());
terminal.loadAddon(new SearchAddon({ highlightLimit: 20_000 }));
terminal.loadAddon(new ClipboardAddon());
try {
-12
View File
@@ -2,11 +2,8 @@ import { Command } from "commander";
import { createRequire } from "node:module";
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";
import { createSpeechCommand } from "./commands/speech/index.js";
import { createWorktreeCommand } from "./commands/worktree/index.js";
import { startCommand as daemonStartCommand } from "./commands/daemon/start.js";
@@ -140,15 +137,6 @@ export function createCli(): Command {
// Daemon commands
program.addCommand(createDaemonCommand());
// Chat commands
program.addCommand(createChatCommand());
// Loop commands
program.addCommand(createLoopCommand());
// Schedule commands
program.addCommand(createScheduleCommand());
// Permission commands
program.addCommand(createPermitCommand());
-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(() => {});
}
}
-41
View File
@@ -1,41 +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(() => {});
}
}
-126
View File
@@ -1,126 +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,
};
}
-127
View File
@@ -1,127 +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[],
): 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;
}
const payload = await client.fetchAgents({
filter: { includeArchived: true },
});
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";
}
-47
View File
@@ -1,47 +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;
}
export async function runWaitCommand(
room: string,
options: ChatWaitOptions,
_command: Command,
): Promise<ListResult<ChatMessageRow>> {
const { client } = await connectChatClient(options.host);
try {
const latest = await client.readChatMessages({
room,
limit: 1,
});
const afterMessageId = latest.messages[0]?.id;
const payload = await client.waitForChatMessages({
room,
afterMessageId,
timeoutMs: parseTimeoutMs(options.timeout),
});
const messages = await attachAgentNamesToMessages(
client,
payload.messages.map(toChatMessageRow),
);
return {
type: "list",
data: messages,
schema: chatMessageSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_WAIT_FAILED", "wait for chat messages", err);
} finally {
await client.close().catch(() => {});
}
}
@@ -1,5 +1,5 @@
import { spawn, spawnSync } from "node:child_process";
import { appendFileSync, existsSync, readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { loadConfig, resolvePaseoHome } from "@getpaseo/server";
@@ -64,7 +64,6 @@ const DETACHED_STARTUP_GRACE_MS = 1200;
const PID_POLL_INTERVAL_MS = 100;
const KILL_TIMEOUT_MS = 3000;
const DAEMON_LOG_FILENAME = "daemon.log";
const DAEMON_LAUNCH_LOG_FILENAME = "daemon-launch.log";
const DAEMON_PID_FILENAME = "paseo.pid";
export const DEFAULT_STOP_TIMEOUT_MS = 15_000;
@@ -115,24 +114,6 @@ function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv {
return childEnv;
}
function appendLaunchLog(home: string, message: string, details?: Record<string, unknown>): void {
const line = [
new Date().toISOString(),
"[CLI daemon]",
`pid=${process.pid}`,
message,
details ? JSON.stringify(details) : "",
]
.filter((part) => part.length > 0)
.join(" ");
try {
appendFileSync(path.join(home, DAEMON_LAUNCH_LOG_FILENAME), `${line}\n`, "utf-8");
} catch {
// Keep launch debugging best-effort so daemon startup behavior is unchanged.
}
}
function resolveDaemonRunnerEntry(): string {
const serverExportPath = require.resolve("@getpaseo/server");
let currentDir = path.dirname(serverExportPath);
@@ -373,14 +354,6 @@ export async function startLocalDaemonDetached(
const paseoHome = resolvePaseoHome(childEnv);
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME);
const daemonRunnerEntry = resolveDaemonRunnerEntry();
appendLaunchLog(paseoHome, "starting detached daemon", {
execPath: process.execPath,
execArgv: process.execArgv,
daemonRunnerEntry,
runnerArgs: buildRunnerArgs(options),
listen: childEnv.PASEO_LISTEN ?? null,
allowedHosts: childEnv.PASEO_ALLOWED_HOSTS ?? null,
});
const child = spawn(
process.execPath,
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
@@ -391,12 +364,6 @@ export async function startLocalDaemonDetached(
},
);
appendLaunchLog(paseoHome, "detached spawn returned", {
childPid: child.pid ?? null,
spawnfile: child.spawnfile,
spawnargs: child.spawnargs,
});
child.unref();
const startup = await new Promise<DetachedStartupResult>((resolve) => {
@@ -411,33 +378,16 @@ export async function startLocalDaemonDetached(
const timer = setTimeout(() => finish(startupReady()), DETACHED_STARTUP_GRACE_MS);
child.once("error", (error) => {
appendLaunchLog(paseoHome, "detached child emitted error during grace period", {
childPid: child.pid ?? null,
message: error.message,
});
clearTimeout(timer);
finish(startupExited({ code: null, signal: null, error }));
});
child.once("exit", (code, signal) => {
appendLaunchLog(paseoHome, "detached child emitted exit during grace period", {
childPid: child.pid ?? null,
code,
signal,
});
clearTimeout(timer);
finish(startupExited({ code, signal }));
});
});
appendLaunchLog(paseoHome, "detached startup grace period completed", {
childPid: child.pid ?? null,
exitedEarly: startup.exitedEarly,
code: startup.exitedEarly ? startup.code : null,
signal: startup.exitedEarly ? startup.signal : null,
error: startup.exitedEarly ? startup.error?.message ?? null : null,
});
if (startup.exitedEarly) {
const reason = startup.error
? startup.error.message
@@ -35,75 +35,29 @@ function resolveNodePathFromPidUnix(pid: number): NodePathFromPidResult {
return resolved ? { nodePath: resolved } : { nodePath: null, error: "ps returned an empty command path" };
}
function runProcessProbe(command: string, args: string[]): {
resolved: string | null;
error?: string;
} {
const result = spawnSync(command, args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult {
const result = spawnSync(
"wmic",
["process", "where", `ProcessId=${pid}`, "get", "ExecutablePath", "/VALUE"],
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
);
if (result.error) {
return { resolved: null, error: `${command} failed: ${normalizeError(result.error)}` };
return { nodePath: null, error: `wmic failed: ${normalizeError(result.error)}` };
}
if ((result.status ?? 1) !== 0) {
const details = result.stderr?.trim();
return {
resolved: null,
error: details ? `${command} failed: ${details}` : `${command} exited with code ${result.status ?? 1}`,
nodePath: null,
error: details ? `wmic failed: ${details}` : `wmic exited with code ${result.status ?? 1}`,
};
}
const resolved = result.stdout.trim();
return resolved ? { resolved } : { resolved: null, error: `${command} returned no executable path` };
}
function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult {
const probes: Array<{ label: string; command: string; args: string[]; parseValue?: (stdout: string) => string | null }> = [
{
label: "powershell-cim",
command: "powershell",
args: [
"-NoProfile",
"-Command",
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").ExecutablePath`,
],
},
{
label: "powershell-process",
command: "powershell",
args: ["-NoProfile", "-Command", `(Get-Process -Id ${pid}).Path`],
},
{
label: "wmic",
command: "wmic",
args: ["process", "where", `ProcessId=${pid}`, "get", "ExecutablePath", "/VALUE"],
parseValue: (stdout) => {
const match = stdout.match(/ExecutablePath=(.+)/);
return match?.[1]?.trim() ?? null;
},
},
];
const errors: string[] = [];
for (const probe of probes) {
const result = runProcessProbe(probe.command, probe.args);
if (result.resolved) {
const resolved = probe.parseValue ? probe.parseValue(result.resolved) : result.resolved;
if (resolved) {
return { nodePath: resolved };
}
errors.push(`${probe.label} returned no executable path`);
continue;
}
if (result.error) {
errors.push(`${probe.label}: ${result.error}`);
}
}
return { nodePath: null, error: errors.join("; ") || "could not resolve executable path from PID" };
// wmic output format: "ExecutablePath=C:\path\to\node.exe\r\n"
const match = result.stdout.match(/ExecutablePath=(.+)/);
const resolved = match?.[1]?.trim();
return resolved ? { nodePath: resolved } : { nodePath: null, error: "wmic returned no executable path" };
}
export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
+24 -55
View File
@@ -15,8 +15,7 @@ interface ProviderBinaryStatus {
interface DaemonStatus {
serverId: string | null;
localDaemon: "running" | "stopped" | "stale_pid" | "unresponsive";
connectedDaemon: "reachable" | "unreachable" | "not_probed";
status: "running" | "stopped" | "unresponsive";
home: string;
listen: string;
hostname: string | null;
@@ -86,16 +85,11 @@ function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
header: "VALUE",
field: "value",
color: (_, item) => {
if (item.key === "Local Daemon") {
if (item.key === "Status") {
if (item.value === "running") return "green";
if (item.value === "unresponsive") return "yellow";
return "red";
}
if (item.key === "Connected Daemon") {
if (item.value === "reachable") return "green";
if (item.value === "not_probed") return "yellow";
return "red";
}
if (item.key.startsWith(" ")) {
if (item.value === "not found") return "red";
if (item.value.endsWith("(--version failed)")) return "yellow";
@@ -112,8 +106,7 @@ function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
function toStatusRows(status: DaemonStatus): StatusRow[] {
const rows: StatusRow[] = [
{ key: "Server ID", value: status.serverId ?? "-" },
{ key: "Local Daemon", value: status.localDaemon },
{ key: "Connected Daemon", value: status.connectedDaemon },
{ key: "Status", value: status.status },
{ key: "Home", value: status.home },
{ key: "Listen", value: status.listen },
{ key: "Hostname", value: status.hostname ?? "-" },
@@ -206,7 +199,6 @@ export async function runStatusCommand(
): Promise<StatusResult> {
const home = typeof options.home === "string" ? options.home : undefined;
const state = resolveLocalDaemonState({ home });
const host = resolveTcpHostFromListen(state.listen);
const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname);
let daemonNode: string;
@@ -219,60 +211,38 @@ export async function runStatusCommand(
daemonNode = "unknown (no PID available)";
}
const cliNode = process.execPath;
let localDaemon: DaemonStatus["localDaemon"] = state.running ? "running" : "stopped";
let connectedDaemon: DaemonStatus["connectedDaemon"] = "not_probed";
let status: DaemonStatus["status"] = state.running ? "running" : "stopped";
let runningAgents: number | null = null;
let idleAgents: number | null = null;
let note: string | undefined;
if (!state.running && state.stalePidFile && state.pidInfo) {
localDaemon = "stale_pid";
note = `Stale PID file found for PID ${state.pidInfo.pid}`;
}
if (host) {
const client = await tryConnectToDaemon({ host, timeout: 1500 });
if (client) {
connectedDaemon = "reachable";
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`,
);
if (state.running) {
const host = resolveTcpHostFromListen(state.listen);
if (host) {
const client = await tryConnectToDaemon({ host, timeout: 1500 });
if (client) {
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;
} catch {
status = "unresponsive";
note = appendNote(note, `Daemon PID is running but API requests to ${host} failed`);
} finally {
await client.close().catch(() => {});
}
} 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 {
status = "unresponsive";
note = appendNote(note, `Daemon PID is running but websocket at ${host} is not reachable`);
}
} else if (state.running) {
connectedDaemon = "unreachable";
localDaemon = "unresponsive";
note = appendNote(
note,
`Local daemon PID is running but websocket at ${host} is not reachable`,
);
} else {
connectedDaemon = "unreachable";
note = appendNote(note, "Daemon is configured for unix socket listen; API probe skipped");
}
} else {
note = appendNote(note, "Daemon is configured for unix socket listen; API probe skipped");
}
const cliVersion = resolveCliVersion();
@@ -288,8 +258,7 @@ export async function runStatusCommand(
const daemonStatus: DaemonStatus = {
serverId,
localDaemon,
connectedDaemon,
status,
home: state.home,
listen: state.listen,
hostname: state.pidInfo?.hostname ?? null,
-34
View File
@@ -1,34 +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;
}
-118
View File
@@ -1,118 +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 as string | undefined });
let client;
try {
client = (await connectToDaemon({
host: options.host as string | undefined,
})) 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;
}
}
-82
View File
@@ -1,82 +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 as string | undefined });
const pollInterval = parsePollInterval(options.pollInterval ?? "1000");
let client;
try {
client = (await connectToDaemon({
host: options.host as string | undefined,
})) 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);
}
let cursor = 0;
try {
for (;;) {
const payload = await client.loopLogs(id, cursor);
if (payload.error || !payload.loop) {
throw new Error(payload.error ?? `Loop not found: ${id}`);
}
cursor = payload.nextCursor;
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));
}
} 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);
}
}
-88
View File
@@ -1,88 +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 as string | undefined });
let client;
try {
client = (await connectToDaemon({
host: options.host as string | undefined,
})) 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;
}
}
-161
View File
@@ -1,161 +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 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?: "claude" | "codex" | "opencode";
model?: string;
verifyProvider?: "claude" | "codex" | "opencode";
verifyModel?: 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("--verify-provider <provider>", "Provider for the verifier agent")
.option("--verify-model <model>", "Model 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;
}
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;
}
return {
prompt,
cwd: process.cwd(),
...(options.provider ? { provider: options.provider } : {}),
...(options.model?.trim() ? { model: options.model.trim() } : {}),
...(options.verifyProvider ? { verifierProvider: options.verifyProvider } : {}),
...(options.verifyModel?.trim() ? { verifierModel: options.verifyModel.trim() } : {}),
...(verifyPrompt ? { verifyPrompt } : {}),
...(options.verifyCheck && options.verifyCheck.length > 0
? { verifyChecks: options.verifyCheck }
: {}),
...(options.archive ? { archive: true } : {}),
...(options.name?.trim() ? { name: options.name.trim() } : {}),
...(options.sleep ? { sleepMs: parseDuration(options.sleep) } : {}),
...(options.maxIterations ? { maxIterations: parseMaxIterations(options.maxIterations) } : {}),
...(options.maxTime ? { maxTimeMs: parseDuration(options.maxTime) } : {}),
};
}
export type LoopRunResult = SingleResult<LoopRunRow>;
export async function runLoopRunCommand(
prompt: string,
options: LoopRunOptions,
_command: Command,
): Promise<LoopRunResult> {
const host = getDaemonHost({ host: options.host as string | undefined });
const input = buildLoopRunInput(prompt, options);
let client;
try {
client = (await connectToDaemon({
host: options.host as string | undefined,
})) 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 as string | undefined });
let client;
try {
client = (await connectToDaemon({
host: options.host as string | undefined,
})) 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;
}
}
-147
View File
@@ -1,147 +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: "claude" | "codex" | "opencode";
model: string | null;
workerProvider: "claude" | "codex" | "opencode" | null;
workerModel: string | null;
verifierProvider: "claude" | "codex" | "opencode" | null;
verifierModel: 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?: "claude" | "codex" | "opencode";
model?: string;
workerProvider?: "claude" | "codex" | "opencode";
workerModel?: string;
verifierProvider?: "claude" | "codex" | "opencode";
verifierModel?: 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>;
}
@@ -1,52 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { scheduleSchema } from "./schema.js";
import {
connectScheduleClient,
parseScheduleCreateInput,
toScheduleCommandError,
toScheduleRow,
type ScheduleCommandOptions,
type ScheduleRow,
} from "./shared.js";
export interface ScheduleCreateOptions extends ScheduleCommandOptions {
every?: string;
cron?: string;
name?: string;
target?: string;
maxRuns?: string;
expiresIn?: string;
}
export async function runCreateCommand(
prompt: string,
options: ScheduleCreateOptions,
_command: Command,
): Promise<SingleResult<ScheduleRow>> {
const input = parseScheduleCreateInput({
prompt,
every: options.every,
cron: options.cron,
name: options.name,
target: options.target,
maxRuns: options.maxRuns,
expiresIn: options.expiresIn,
});
const { client } = await connectScheduleClient(options.host);
try {
const payload = await client.scheduleCreate(input);
if (payload.error || !payload.schedule) {
throw new Error(payload.error ?? "Schedule creation failed");
}
return {
type: "single",
data: toScheduleRow(payload.schedule),
schema: scheduleSchema,
};
} catch (error) {
throw toScheduleCommandError("SCHEDULE_CREATE_FAILED", "create schedule", error);
} finally {
await client.close().catch(() => {});
}
}
@@ -1,43 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import type { OutputSchema } from "../../output/index.js";
import { connectScheduleClient, toScheduleCommandError, type ScheduleCommandOptions } from "./shared.js";
interface ScheduleDeleteRow {
id: string;
status: string;
}
const scheduleDeleteSchema: OutputSchema<ScheduleDeleteRow> = {
idField: "id",
columns: [
{ header: "ID", field: "id", width: 10 },
{ header: "STATUS", field: "status", width: 12 },
],
};
export async function runDeleteCommand(
id: string,
options: ScheduleCommandOptions,
_command: Command,
): Promise<SingleResult<ScheduleDeleteRow>> {
const { client } = await connectScheduleClient(options.host);
try {
const payload = await client.scheduleDelete({ id });
if (payload.error) {
throw new Error(payload.error);
}
return {
type: "single",
data: {
id: payload.scheduleId,
status: "deleted",
},
schema: scheduleDeleteSchema,
};
} catch (error) {
throw toScheduleCommandError("SCHEDULE_DELETE_FAILED", "delete schedule", error);
} finally {
await client.close().catch(() => {});
}
}
@@ -1,68 +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 { runLogsCommand } from "./logs.js";
import { runPauseCommand } from "./pause.js";
import { runResumeCommand } from "./resume.js";
import { runDeleteCommand } from "./delete.js";
export function createScheduleCommand(): Command {
const schedule = new Command("schedule").description("Manage recurring schedules");
addJsonAndDaemonHostOptions(
schedule
.command("create")
.description("Create a schedule")
.argument("<prompt>", "Prompt to run on the schedule")
.option("--every <duration>", "Fixed interval cadence (for example: 5m, 1h)")
.option("--cron <expr>", "Cron cadence expression")
.option("--name <name>", "Optional schedule name")
.option("--target <self|new-agent|agent-id>", "Run target")
.option("--max-runs <n>", "Maximum number of runs")
.option("--expires-in <duration>", "Time to live for the schedule"),
).action(withOutput(runCreateCommand));
addJsonAndDaemonHostOptions(
schedule.command("ls").description("List schedules"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
schedule
.command("inspect")
.description("Inspect a schedule")
.argument("<id>", "Schedule ID"),
).action(withOutput(runInspectCommand));
addJsonAndDaemonHostOptions(
schedule
.command("logs")
.description("Show recent schedule run logs")
.argument("<id>", "Schedule ID"),
).action(withOutput(runLogsCommand));
addJsonAndDaemonHostOptions(
schedule
.command("pause")
.description("Pause a schedule")
.argument("<id>", "Schedule ID"),
).action(withOutput(runPauseCommand));
addJsonAndDaemonHostOptions(
schedule
.command("resume")
.description("Resume a paused schedule")
.argument("<id>", "Schedule ID"),
).action(withOutput(runResumeCommand));
addJsonAndDaemonHostOptions(
schedule
.command("delete")
.description("Delete a schedule")
.argument("<id>", "Schedule ID"),
).action(withOutput(runDeleteCommand));
return schedule;
}
@@ -1,32 +0,0 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import {
createScheduleInspectRows,
createScheduleInspectSchema,
type ScheduleInspectRow,
} from "./schema.js";
import { connectScheduleClient, toScheduleCommandError, type ScheduleCommandOptions } from "./shared.js";
export async function runInspectCommand(
id: string,
options: ScheduleCommandOptions,
_command: Command,
): Promise<ListResult<ScheduleInspectRow>> {
const { client } = await connectScheduleClient(options.host);
try {
const payload = await client.scheduleInspect({ id });
if (payload.error || !payload.schedule) {
throw new Error(payload.error ?? `Schedule not found: ${id}`);
}
const rows = createScheduleInspectRows(payload.schedule);
return {
type: "list",
data: rows,
schema: createScheduleInspectSchema(payload.schedule),
};
} catch (error) {
throw toScheduleCommandError("SCHEDULE_INSPECT_FAILED", "inspect schedule", error);
} finally {
await client.close().catch(() => {});
}
}
@@ -1,27 +0,0 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import { scheduleLogSchema, toScheduleLogRow, type ScheduleLogRow } from "./schema.js";
import { connectScheduleClient, toScheduleCommandError, type ScheduleCommandOptions } from "./shared.js";
export async function runLogsCommand(
id: string,
options: ScheduleCommandOptions,
_command: Command,
): Promise<ListResult<ScheduleLogRow>> {
const { client } = await connectScheduleClient(options.host);
try {
const payload = await client.scheduleLogs({ id });
if (payload.error) {
throw new Error(payload.error);
}
return {
type: "list",
data: payload.runs.map(toScheduleLogRow),
schema: scheduleLogSchema,
};
} catch (error) {
throw toScheduleCommandError("SCHEDULE_LOGS_FAILED", "read schedule logs", error);
} finally {
await client.close().catch(() => {});
}
}
-32
View File
@@ -1,32 +0,0 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import { scheduleSchema } from "./schema.js";
import {
connectScheduleClient,
toScheduleCommandError,
toScheduleRow,
type ScheduleCommandOptions,
type ScheduleRow,
} from "./shared.js";
export async function runLsCommand(
options: ScheduleCommandOptions,
_command: Command,
): Promise<ListResult<ScheduleRow>> {
const { client } = await connectScheduleClient(options.host);
try {
const payload = await client.scheduleList();
if (payload.error) {
throw new Error(payload.error);
}
return {
type: "list",
data: payload.schedules.map(toScheduleRow),
schema: scheduleSchema,
};
} catch (error) {
throw toScheduleCommandError("SCHEDULE_LIST_FAILED", "list schedules", error);
} finally {
await client.close().catch(() => {});
}
}
@@ -1,33 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { scheduleSchema } from "./schema.js";
import {
connectScheduleClient,
toScheduleCommandError,
toScheduleRow,
type ScheduleCommandOptions,
type ScheduleRow,
} from "./shared.js";
export async function runPauseCommand(
id: string,
options: ScheduleCommandOptions,
_command: Command,
): Promise<SingleResult<ScheduleRow>> {
const { client } = await connectScheduleClient(options.host);
try {
const payload = await client.schedulePause({ id });
if (payload.error || !payload.schedule) {
throw new Error(payload.error ?? `Failed to pause schedule: ${id}`);
}
return {
type: "single",
data: toScheduleRow(payload.schedule),
schema: scheduleSchema,
};
} catch (error) {
throw toScheduleCommandError("SCHEDULE_PAUSE_FAILED", "pause schedule", error);
} finally {
await client.close().catch(() => {});
}
}
@@ -1,33 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { scheduleSchema } from "./schema.js";
import {
connectScheduleClient,
toScheduleCommandError,
toScheduleRow,
type ScheduleCommandOptions,
type ScheduleRow,
} from "./shared.js";
export async function runResumeCommand(
id: string,
options: ScheduleCommandOptions,
_command: Command,
): Promise<SingleResult<ScheduleRow>> {
const { client } = await connectScheduleClient(options.host);
try {
const payload = await client.scheduleResume({ id });
if (payload.error || !payload.schedule) {
throw new Error(payload.error ?? `Failed to resume schedule: ${id}`);
}
return {
type: "single",
data: toScheduleRow(payload.schedule),
schema: scheduleSchema,
};
} catch (error) {
throw toScheduleCommandError("SCHEDULE_RESUME_FAILED", "resume schedule", error);
} finally {
await client.close().catch(() => {});
}
}
@@ -1,88 +0,0 @@
import type { OutputSchema } from "../../output/index.js";
import { formatTarget, type ScheduleRow } from "./shared.js";
import type { ScheduleRecord, ScheduleRunRecord } from "./types.js";
export const scheduleSchema: OutputSchema<ScheduleRow> = {
idField: "id",
columns: [
{ header: "ID", field: "id", width: 10 },
{ header: "NAME", field: "name", width: 20 },
{ header: "CADENCE", field: "cadence", width: 20 },
{ header: "TARGET", field: "target", width: 20 },
{ header: "STATUS", field: "status", width: 12 },
{ header: "NEXT RUN", field: "nextRunAt", width: 24 },
],
};
export interface ScheduleInspectRow {
key: string;
value: string;
}
export function createScheduleInspectSchema(record: ScheduleRecord): OutputSchema<ScheduleInspectRow> {
return {
idField: "key",
columns: [
{ header: "KEY", field: "key", width: 18 },
{ header: "VALUE", field: "value", width: 80 },
],
serialize: () => record,
};
}
export interface ScheduleLogRow {
id: string;
status: string;
startedAt: string;
agentId: string | null;
output: string | null;
error: string | null;
}
export const scheduleLogSchema: OutputSchema<ScheduleLogRow> = {
idField: "id",
columns: [
{ header: "RUN ID", field: "id", width: 14 },
{ header: "STATUS", field: "status", width: 12 },
{ header: "STARTED", field: "startedAt", width: 24 },
{ header: "AGENT", field: "agentId", width: 12 },
{ header: "OUTPUT", field: "output", width: 40 },
{ header: "ERROR", field: "error", width: 40 },
],
};
export function toScheduleLogRow(run: ScheduleRunRecord): ScheduleLogRow {
return {
id: run.id,
status: run.status,
startedAt: run.startedAt,
agentId: run.agentId ? run.agentId.slice(0, 7) : null,
output: run.output,
error: run.error,
};
}
export function createScheduleInspectRows(schedule: ScheduleRecord): ScheduleInspectRow[] {
return [
{ key: "Id", value: schedule.id },
{ key: "Name", value: schedule.name ?? "null" },
{ key: "Prompt", value: schedule.prompt },
{
key: "Cadence",
value:
schedule.cadence.type === "cron"
? `cron:${schedule.cadence.expression}`
: `every:${schedule.cadence.everyMs}ms`,
},
{ key: "Target", value: formatTarget(schedule.target) },
{ key: "Status", value: schedule.status },
{ key: "CreatedAt", value: schedule.createdAt },
{ key: "UpdatedAt", value: schedule.updatedAt },
{ key: "NextRunAt", value: schedule.nextRunAt ?? "null" },
{ key: "LastRunAt", value: schedule.lastRunAt ?? "null" },
{ key: "PausedAt", value: schedule.pausedAt ?? "null" },
{ key: "ExpiresAt", value: schedule.expiresAt ?? "null" },
{ key: "MaxRuns", value: schedule.maxRuns == null ? "null" : `${schedule.maxRuns}` },
{ key: "RunCount", value: `${schedule.runs.length}` },
];
}
@@ -1,195 +0,0 @@
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, CommandOptions } from "../../output/index.js";
import type {
CreateScheduleInput,
ScheduleCadence,
ScheduleDaemonClient,
ScheduleListItem,
ScheduleRecord,
ScheduleTarget,
} from "./types.js";
import { parseDuration } from "../../utils/duration.js";
export interface ScheduleCommandOptions extends CommandOptions {
host?: string;
}
export async function connectScheduleClient(
host: string | undefined,
): Promise<{ client: ScheduleDaemonClient; host: string }> {
const resolvedHost = getDaemonHost({ host });
try {
const client = (await connectToDaemon({
host,
})) as unknown as ScheduleDaemonClient;
return { client, host: resolvedHost };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${resolvedHost}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
}
export function toScheduleCommandError(
code: string,
action: string,
error: unknown,
): CommandError {
if (error && typeof error === "object" && "code" in error) {
return error as CommandError;
}
const message = error instanceof Error ? error.message : String(error);
return {
code,
message: `Failed to ${action}: ${message}`,
};
}
export function formatCadence(cadence: ScheduleCadence): string {
if (cadence.type === "cron") {
return `cron:${cadence.expression}`;
}
return `every:${formatDurationMs(cadence.everyMs)}`;
}
export function formatTarget(target: ScheduleTarget | ScheduleListItem["target"]): string {
if (target.type === "self") {
return `self:${target.agentId.slice(0, 7)}`;
}
if (target.type === "agent") {
return `agent:${target.agentId.slice(0, 7)}`;
}
return `new-agent:${target.config.provider}`;
}
export function formatDurationMs(durationMs: number): string {
const parts: string[] = [];
let remainingMs = durationMs;
const hours = Math.floor(remainingMs / (60 * 60 * 1000));
if (hours > 0) {
parts.push(`${hours}h`);
remainingMs -= hours * 60 * 60 * 1000;
}
const minutes = Math.floor(remainingMs / (60 * 1000));
if (minutes > 0) {
parts.push(`${minutes}m`);
remainingMs -= minutes * 60 * 1000;
}
const seconds = Math.floor(remainingMs / 1000);
if (seconds > 0 || parts.length === 0) {
parts.push(`${seconds}s`);
}
return parts.join("");
}
export function parseScheduleCreateInput(options: {
prompt: string;
every?: string;
cron?: string;
name?: string;
target?: string;
maxRuns?: string;
expiresIn?: string;
}): CreateScheduleInput {
const prompt = options.prompt.trim();
if (!prompt) {
throw {
code: "INVALID_PROMPT",
message: "Schedule prompt cannot be empty",
} satisfies CommandError;
}
const cadenceCount = Number(options.every !== undefined) + Number(options.cron !== undefined);
if (cadenceCount !== 1) {
throw {
code: "INVALID_CADENCE",
message: "Specify exactly one of --every or --cron",
} satisfies CommandError;
}
const cadence: ScheduleCadence = options.every
? { type: "every", everyMs: parseDuration(options.every) }
: { type: "cron", expression: options.cron!.trim() };
const targetValue = options.target?.trim();
let target: ScheduleTarget;
if (!targetValue || targetValue === "self") {
const currentAgentId = process.env.PASEO_AGENT_ID?.trim();
if (currentAgentId) {
target = { type: "self", agentId: currentAgentId };
} else {
target = {
type: "new-agent",
config: {
provider: "claude",
cwd: process.cwd(),
},
};
}
} else if (targetValue === "new-agent") {
target = {
type: "new-agent",
config: {
provider: "claude",
cwd: process.cwd(),
},
};
} else {
target = {
type: "agent",
agentId: targetValue,
};
}
const maxRuns =
options.maxRuns === undefined ? undefined : parsePositiveInt(options.maxRuns, "--max-runs");
const expiresAt =
options.expiresIn === undefined
? undefined
: new Date(Date.now() + parseDuration(options.expiresIn)).toISOString();
return {
prompt,
cadence,
target,
...(options.name?.trim() ? { name: options.name.trim() } : {}),
...(maxRuns !== undefined ? { maxRuns } : {}),
...(expiresAt ? { expiresAt } : {}),
};
}
function parsePositiveInt(value: string, flag: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw {
code: "INVALID_INTEGER",
message: `${flag} must be a positive integer`,
} satisfies CommandError;
}
return parsed;
}
export interface ScheduleRow {
id: string;
name: string | null;
cadence: string;
target: string;
status: string;
nextRunAt: string | null;
lastRunAt: string | null;
}
export function toScheduleRow(schedule: ScheduleListItem | ScheduleRecord): ScheduleRow {
return {
id: schedule.id,
name: schedule.name,
cadence: formatCadence(schedule.cadence),
target: formatTarget(schedule.target),
status: schedule.status,
nextRunAt: schedule.nextRunAt,
lastRunAt: schedule.lastRunAt,
};
}
-141
View File
@@ -1,141 +0,0 @@
export type ScheduleStatus = "active" | "paused" | "completed";
export type ScheduleCadence =
| {
type: "every";
everyMs: number;
}
| {
type: "cron";
expression: string;
};
export type ScheduleTarget =
| {
type: "self";
agentId: string;
}
| {
type: "agent";
agentId: string;
}
| {
type: "new-agent";
config: {
provider: "claude" | "codex" | "opencode";
cwd: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
title?: string | null;
approvalPolicy?: string;
sandboxMode?: string;
networkAccess?: boolean;
webSearch?: boolean;
};
};
export interface ScheduleRunRecord {
id: string;
scheduledFor: string;
startedAt: string;
endedAt: string | null;
status: "running" | "succeeded" | "failed";
agentId: string | null;
output: string | null;
error: string | null;
}
export interface ScheduleRecord {
id: string;
name: string | null;
prompt: string;
cadence: ScheduleCadence;
target: Exclude<ScheduleTarget, { type: "self" }>;
status: ScheduleStatus;
createdAt: string;
updatedAt: string;
nextRunAt: string | null;
lastRunAt: string | null;
pausedAt: string | null;
expiresAt: string | null;
maxRuns: number | null;
runs: ScheduleRunRecord[];
}
export interface ScheduleListItem {
id: string;
name: string | null;
cadence: ScheduleCadence;
target: Exclude<ScheduleTarget, { type: "self" }>;
status: ScheduleStatus;
nextRunAt: string | null;
lastRunAt: string | null;
createdAt: string;
updatedAt: string;
pausedAt: string | null;
expiresAt: string | null;
maxRuns: number | null;
}
export interface CreateScheduleInput {
prompt: string;
name?: string;
cadence: ScheduleCadence;
target: ScheduleTarget;
maxRuns?: number;
expiresAt?: string;
}
export interface ScheduleCreatePayload {
requestId: string;
schedule: ScheduleListItem | null;
error: string | null;
}
export interface ScheduleListPayload {
requestId: string;
schedules: ScheduleListItem[];
error: string | null;
}
export interface ScheduleInspectPayload {
requestId: string;
schedule: ScheduleRecord | null;
error: string | null;
}
export interface ScheduleLogsPayload {
requestId: string;
runs: ScheduleRunRecord[];
error: string | null;
}
export interface SchedulePausePayload {
requestId: string;
schedule: ScheduleListItem | null;
error: string | null;
}
export interface ScheduleResumePayload {
requestId: string;
schedule: ScheduleListItem | null;
error: string | null;
}
export interface ScheduleDeletePayload {
requestId: string;
scheduleId: string;
error: string | null;
}
export interface ScheduleDaemonClient {
scheduleCreate(input: CreateScheduleInput): Promise<ScheduleCreatePayload>;
scheduleList(): Promise<ScheduleListPayload>;
scheduleInspect(input: { id: string }): Promise<ScheduleInspectPayload>;
scheduleLogs(input: { id: string }): Promise<ScheduleLogsPayload>;
schedulePause(input: { id: string }): Promise<SchedulePausePayload>;
scheduleResume(input: { id: string }): Promise<ScheduleResumePayload>;
scheduleDelete(input: { id: string }): Promise<ScheduleDeletePayload>;
close(): Promise<void>;
}
-3
View File
@@ -40,9 +40,6 @@ export function render<T>(
return renderYaml(result, opts);
case "table":
default:
if (result.schema.renderHuman) {
return result.schema.renderHuman(result, opts);
}
return renderTable(result, opts);
}
}
-2
View File
@@ -40,8 +40,6 @@ export interface OutputSchema<T> {
idField: keyof T | ((item: T) => string);
/** Column definitions for table output */
columns: ColumnDef<T>[];
/** Optional: custom renderer for human/table output */
renderHuman?: (result: AnyCommandResult<T>, options: OutputOptions) => string;
/** Optional: transform data before JSON/YAML output */
serialize?: (data: T) => unknown;
}
-41
View File
@@ -29,17 +29,6 @@ interface Agent {
provider: string;
}
interface ChatMessage {
id: string;
author: string;
authorName: string | null;
createdAt: string;
replyTo: string;
mentionAgentIds: string[];
mentionLabels: string[];
body: string;
}
// Schema for agents
const agentSchema: OutputSchema<Agent> = {
idField: "id",
@@ -222,36 +211,6 @@ test("render uses table format by default", () => {
assert.ok(output.includes("ID"), "Should use table format with headers");
});
test("render uses custom human renderer when provided", () => {
const chatSchema: OutputSchema<ChatMessage> = {
idField: "id",
columns: [{ header: "ID", field: "id" }],
renderHuman: (result) => {
const data = result.type === "list" ? result.data : [result.data];
return data.map((message) => `msg ${message.id}: ${message.body}`).join("\n");
},
};
const chatResult: ListResult<ChatMessage> = {
type: "list",
data: [
{
id: "m1",
author: "agent-1",
authorName: "Planner",
createdAt: "2026-03-29T10:00:00Z",
replyTo: "-",
mentionAgentIds: [],
mentionLabels: [],
body: "hello",
},
],
schema: chatSchema,
};
const output = render(chatResult, { noColor: true });
assert.strictEqual(output, "msg m1: hello", "Should use custom human renderer");
});
test("render uses json format when specified", () => {
const output = render(listResult, { format: "json" });
const parsed = JSON.parse(output);
+11 -9
View File
@@ -31,6 +31,11 @@ type ProviderModel = {
};
const EXPECTED_CLAUDE_MODELS = [
{
id: "claude-sonnet-4-5-20250929",
model: "Sonnet 4.5",
descriptionFragment: "Best for everyday tasks",
},
{
id: "claude-sonnet-4-6",
model: "Sonnet 4.6",
@@ -42,7 +47,7 @@ const EXPECTED_CLAUDE_MODELS = [
descriptionFragment: "Most capable",
},
{
id: "claude-haiku-4-5",
id: "claude-haiku-4-5-20251001",
model: "Haiku 4.5",
descriptionFragment: "Fastest",
},
@@ -202,13 +207,10 @@ try {
ids.includes("opencode/gpt-5-nano"),
"opencode output should include opencode/gpt-5-nano",
);
assert(ids.includes("openai/o3-mini"), "opencode output should include openai/o3-mini");
assert(
ids.some((id) => id.startsWith("openrouter/openai/")),
"opencode output should include OpenRouter OpenAI models",
);
assert(
ids.includes("openrouter/openai/gpt-5.3-codex"),
"opencode output should include openrouter/openai/gpt-5.3-codex",
ids.includes("openai/gpt-5.3-codex-spark"),
"opencode output should include openai/gpt-5.3-codex-spark",
);
console.log("✓ provider models opencode returns namespaced model IDs\n");
}
@@ -267,8 +269,8 @@ try {
"--quiet should print the current Claude catalog IDs",
);
assert(
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-4-6"),
"captured --json output should include the current Claude everyday model id",
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-4-5-20250929"),
"captured --json output should still include the Claude default model id",
);
console.log("✓ provider models --quiet outputs model IDs only\n");
}
-85
View File
@@ -1,85 +0,0 @@
#!/usr/bin/env npx tsx
import assert from "node:assert";
import { rm } from "node:fs/promises";
import { createE2ETestContext } from "./helpers/test-daemon.ts";
console.log("=== Chat Command Tests ===\n");
const ctx = await createE2ETestContext({ timeout: 30000 });
try {
{
console.log("Test 1: chat create/ls/inspect work");
const created = await ctx.paseo(["chat", "create", "coord-room", "--purpose", "Coordination"]);
assert.strictEqual(created.exitCode, 0, created.stderr);
assert(created.stdout.includes("coord-room"), created.stdout);
const listed = await ctx.paseo(["chat", "ls"]);
assert.strictEqual(listed.exitCode, 0, listed.stderr);
assert(listed.stdout.includes("coord-room"), listed.stdout);
const inspected = await ctx.paseo(["chat", "inspect", "coord-room"]);
assert.strictEqual(inspected.exitCode, 0, inspected.stderr);
assert(inspected.stdout.includes("Coordination"), inspected.stdout);
console.log("chat create/ls/inspect work\n");
}
{
console.log("Test 2: chat post/read/wait work");
const posted = await ctx.paseo([
"chat",
"post",
"coord-room",
"first message for @agent-1",
], {
env: { PASEO_AGENT_ID: "00000000-0000-4000-8000-000000000111" },
});
assert.strictEqual(posted.exitCode, 0, posted.stderr);
assert(posted.stdout.includes("first message"), posted.stdout);
assert(posted.stdout.includes("00000000-0000-4000-8000-000000000111"), posted.stdout);
const read = await ctx.paseo(["chat", "read", "coord-room", "--limit", "10"]);
assert.strictEqual(read.exitCode, 0, read.stderr);
assert(read.stdout.includes("first message"), read.stdout);
assert(read.stdout.includes("00000000-0000-4000-8000-000000000111"), read.stdout);
const readJson = await ctx.paseo(["chat", "read", "coord-room", "--limit", "10", "--json"]);
assert.strictEqual(readJson.exitCode, 0, readJson.stderr);
const readPayload = JSON.parse(readJson.stdout);
assert.strictEqual(readPayload[0]?.author, "00000000-0000-4000-8000-000000000111");
const waitPromise = ctx.paseo(["chat", "wait", "coord-room", "--timeout", "5s"]);
await new Promise((resolve) => setTimeout(resolve, 250));
const secondPost = await ctx.paseo(["chat", "post", "coord-room", "second message"]);
assert.strictEqual(secondPost.exitCode, 0, secondPost.stderr);
const waited = await waitPromise;
assert.strictEqual(waited.exitCode, 0, waited.stderr);
assert(waited.stdout.includes("second message"), waited.stdout);
console.log("chat post/read/wait work\n");
}
{
console.log("Test 3: duplicate room create fails");
const duplicate = await ctx.paseo(["chat", "create", "coord-room"]);
assert.notStrictEqual(duplicate.exitCode, 0, "duplicate create should fail");
const combined = `${duplicate.stdout}\n${duplicate.stderr}`;
assert(combined.toLowerCase().includes("already exists"), combined);
console.log("duplicate room create fails\n");
}
{
console.log("Test 4: chat delete works");
const deleted = await ctx.paseo(["chat", "delete", "coord-room"]);
assert.strictEqual(deleted.exitCode, 0, deleted.stderr);
assert(deleted.stdout.includes("coord-room"), deleted.stdout);
console.log("chat delete works\n");
}
} finally {
await ctx.stop();
await rm(ctx.paseoHome, { recursive: true, force: true });
await rm(ctx.workDir, { recursive: true, force: true });
}
console.log("=== Chat Command Tests Passed ===");
@@ -1,99 +0,0 @@
#!/usr/bin/env npx tsx
import assert from "node:assert";
import { rm } from "node:fs/promises";
import { createE2ETestContext } from "./helpers/test-daemon.ts";
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
console.log("=== Loop And Schedule Command Tests ===\n");
const ctx = await createE2ETestContext({ timeout: 30000 });
try {
{
console.log("Test 1: schedule create/ls/inspect/pause/resume/delete work");
const created = await ctx.paseo(
["schedule", "create", "Review new PRs", "--every", "5m", "--name", "review-prs", "--json"],
{ timeout: 30000 },
);
assert.strictEqual(created.exitCode, 0, created.stderr);
const createdJson = JSON.parse(created.stdout);
assert.strictEqual(createdJson.name, "review-prs");
assert.strictEqual(createdJson.cadence, "every:5m");
const listed = await ctx.paseo(["schedule", "ls", "--json"]);
assert.strictEqual(listed.exitCode, 0, listed.stderr);
const listedJson = JSON.parse(listed.stdout);
assert(Array.isArray(listedJson), listed.stdout);
assert(listedJson.some((item: { id: string }) => item.id === createdJson.id), listed.stdout);
const inspected = await ctx.paseo(["schedule", "inspect", createdJson.id, "--json"]);
assert.strictEqual(inspected.exitCode, 0, inspected.stderr);
const inspectedJson = JSON.parse(inspected.stdout);
assert.strictEqual(inspectedJson.status, "active");
assert.strictEqual(inspectedJson.prompt, "Review new PRs");
const paused = await ctx.paseo(["schedule", "pause", createdJson.id, "--json"]);
assert.strictEqual(paused.exitCode, 0, paused.stderr);
assert.strictEqual(JSON.parse(paused.stdout).status, "paused");
const resumed = await ctx.paseo(["schedule", "resume", createdJson.id, "--json"]);
assert.strictEqual(resumed.exitCode, 0, resumed.stderr);
assert.strictEqual(JSON.parse(resumed.stdout).status, "active");
const deleted = await ctx.paseo(["schedule", "delete", createdJson.id, "--json"]);
assert.strictEqual(deleted.exitCode, 0, deleted.stderr);
assert.strictEqual(JSON.parse(deleted.stdout).id, createdJson.id);
console.log("schedule commands work\n");
}
{
console.log("Test 2: loop run/ls/inspect/logs/stop work");
const run = await ctx.paseo(
["loop", "run", "Return any response", "--name", "smoke-loop", "--verify-check", "true", "--json"],
{ timeout: 30000 },
);
assert.strictEqual(run.exitCode, 0, run.stderr);
const runJson = JSON.parse(run.stdout);
assert.strictEqual(runJson.name, "smoke-loop");
const listed = await ctx.paseo(["loop", "ls", "--json"]);
assert.strictEqual(listed.exitCode, 0, listed.stderr);
const listedJson = JSON.parse(listed.stdout);
assert(Array.isArray(listedJson), listed.stdout);
assert(listedJson.some((item: { id: string }) => item.id === runJson.id), listed.stdout);
let status = "running";
for (let attempt = 0; attempt < 40; attempt += 1) {
const inspect = await ctx.paseo(["loop", "inspect", runJson.id, "--json"]);
assert.strictEqual(inspect.exitCode, 0, inspect.stderr);
const inspectJson = JSON.parse(inspect.stdout);
status = inspectJson.status;
if (status !== "running") {
assert.strictEqual(status, "succeeded", inspect.stdout);
break;
}
await sleep(250);
}
assert.strictEqual(status, "succeeded");
const logs = await ctx.paseo(["loop", "logs", runJson.id], { timeout: 15000 });
assert.strictEqual(logs.exitCode, 0, logs.stderr);
assert(logs.stdout.includes("verify-check"), logs.stdout);
const stopped = await ctx.paseo(["loop", "stop", runJson.id, "--json"]);
assert.strictEqual(stopped.exitCode, 0, stopped.stderr);
const stoppedJson = JSON.parse(stopped.stdout);
assert(["succeeded", "stopped"].includes(stoppedJson.status), stopped.stdout);
console.log("loop commands work\n");
}
} finally {
await ctx.stop();
await rm(ctx.paseoHome, { recursive: true, force: true });
await rm(ctx.workDir, { recursive: true, force: true });
}
console.log("=== Loop And Schedule Command Tests Passed ===");
+2 -4
View File
@@ -333,7 +333,6 @@ export async function runPaseoCli(
options?: {
timeout?: number;
cwd?: string;
env?: NodeJS.ProcessEnv;
},
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
const timeout = options?.timeout ?? 60000;
@@ -349,7 +348,6 @@ export async function runPaseoCli(
...TEST_DAEMON_ENV_DEFAULTS,
PASEO_HOST: `${TEST_DAEMON_HOST}:${ctx.port}`,
PASEO_HOME: ctx.paseoHome,
...options?.env,
},
cwd,
stdio: ["ignore", "pipe", "pipe"],
@@ -401,7 +399,7 @@ export async function createE2ETestContext(options?: { timeout?: number }): Prom
/** Run a paseo CLI command against this daemon */
paseo: (
args: string[],
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv },
opts?: { timeout?: number; cwd?: string },
) => Promise<{
exitCode: number;
stdout: string;
@@ -411,7 +409,7 @@ export async function createE2ETestContext(options?: { timeout?: number }): Prom
> {
const ctx = await startTestDaemon({ timeout: options?.timeout });
const paseo = (args: string[], opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv }) =>
const paseo = (args: string[], opts?: { timeout?: number; cwd?: string }) =>
runPaseoCli(ctx, args, opts);
return {
+1 -60
View File
@@ -1,5 +1,5 @@
import { spawn, type ChildProcess } from "node:child_process";
import { appendFileSync, existsSync, readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { app, ipcMain } from "electron";
import { loadConfig, resolvePaseoHome, getOrCreateServerId } from "@getpaseo/server";
@@ -27,7 +27,6 @@ const STOP_TIMEOUT_MS = 15_000;
const KILL_TIMEOUT_MS = 3_000;
const DETACHED_STARTUP_GRACE_MS = 1200;
const DEFAULT_ELECTRON_DEV_SERVER_URL = "http://localhost:8081";
const DAEMON_LAUNCH_LOG_FILENAME = "daemon-launch.log";
type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
@@ -76,10 +75,6 @@ function logFilePath(): string {
return path.join(getPaseoHome(), DAEMON_LOG_FILENAME);
}
function launchLogFilePath(): string {
return path.join(getPaseoHome(), DAEMON_LAUNCH_LOG_FILENAME);
}
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
@@ -145,24 +140,6 @@ function tailFile(filePath: string, lines = 50): string {
}
}
function appendLaunchLog(message: string, details?: Record<string, unknown>): void {
const line = [
new Date().toISOString(),
"[desktop daemon]",
`pid=${process.pid}`,
message,
details ? JSON.stringify(details) : "",
]
.filter((part) => part.length > 0)
.join(" ");
try {
appendFileSync(launchLogFilePath(), `${line}\n`, "utf-8");
} catch {
// Keep launch debugging best-effort so desktop startup behavior is unchanged.
}
}
function buildDesktopDaemonCorsOriginsEnv(): string | undefined {
const origins = new Set(
(process.env.PASEO_CORS_ORIGINS ?? "")
@@ -326,16 +303,6 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
},
});
appendLaunchLog("starting detached daemon", {
appIsPackaged: app.isPackaged,
daemonRunnerEntry: daemonRunner.entryPath,
daemonRunnerExecArgv: daemonRunner.execArgv,
command: invocation.command,
args: invocation.args,
listen: process.env.PASEO_LISTEN ?? null,
corsOrigins: corsOrigins ?? null,
});
const child: ChildProcess = spawn(
invocation.command,
invocation.args,
@@ -346,12 +313,6 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
},
);
appendLaunchLog("detached spawn returned", {
childPid: child.pid ?? null,
spawnfile: child.spawnfile,
spawnargs: child.spawnargs,
});
child.unref();
// Wait for process to survive the grace period
@@ -366,26 +327,15 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
const timer = setTimeout(() => finish(false), DETACHED_STARTUP_GRACE_MS);
child.once("error", () => {
appendLaunchLog("detached child emitted error during grace period", {
childPid: child.pid ?? null,
});
clearTimeout(timer);
finish(true);
});
child.once("exit", () => {
appendLaunchLog("detached child emitted exit during grace period", {
childPid: child.pid ?? null,
});
clearTimeout(timer);
finish(true);
});
});
appendLaunchLog("detached startup grace period completed", {
childPid: child.pid ?? null,
exitedEarly,
});
if (exitedEarly) {
const logs = tailFile(logFilePath(), 15);
throw new Error(`Daemon failed to start.${logs ? `\n\nRecent logs:\n${logs}` : ""}`);
@@ -394,15 +344,6 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
// Poll for PID file with server ID
for (let attempt = 0; attempt < STARTUP_POLL_MAX_ATTEMPTS; attempt++) {
const status = resolveStatus();
if (attempt === 0 || attempt === STARTUP_POLL_MAX_ATTEMPTS - 1 || attempt % 10 === 9) {
appendLaunchLog("polling daemon status after detached start", {
attempt: attempt + 1,
status: status.status,
pid: status.pid,
listen: status.listen,
serverId: status.serverId || null,
});
}
if (status.status === "running" && status.serverId) return status;
await sleep(STARTUP_POLL_INTERVAL_MS);
}
+1 -38
View File
@@ -1,9 +1,4 @@
import { app, Menu, BrowserWindow, ipcMain } from "electron";
type ShowContextMenuInput = {
kind?: "terminal";
hasSelection?: boolean;
};
import { app, Menu, BrowserWindow } from "electron";
function withBrowserWindow(
callback: (win: BrowserWindow) => void,
@@ -94,36 +89,4 @@ export function setupApplicationMenu(): void {
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
ipcMain.handle("paseo:menu:showContextMenu", (event, input?: ShowContextMenuInput) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) {
return;
}
if (input?.kind !== "terminal") {
return;
}
const menu = Menu.buildFromTemplate([
{
label: "Copy",
role: "copy",
enabled: input.hasSelection === true,
},
{
label: "Paste",
role: "paste",
},
{
type: "separator",
},
{
label: "Select All",
role: "selectAll",
},
]);
menu.popup({ window: win });
});
}
+6 -3
View File
@@ -13,8 +13,6 @@ import {
import { closeAllTransportSessions } from "./daemon/local-transport.js";
import {
registerWindowManager,
getTitleBarOverlayOptions,
resolveSystemWindowTheme,
setupWindowResizeEvents,
setupDragDropPrevention,
} from "./window/window-manager.js";
@@ -98,7 +96,11 @@ async function createMainWindow(): Promise<void> {
...(isMac
? { trafficLightPosition: { x: 16, y: 14 } }
: {
titleBarOverlay: getTitleBarOverlayOptions(resolveSystemWindowTheme()),
titleBarOverlay: {
color: "#18181c",
symbolColor: "#e4e4e7",
height: 48,
},
autoHideMenuBar: true,
}),
webPreferences: {
@@ -110,6 +112,7 @@ async function createMainWindow(): Promise<void> {
setupWindowResizeEvents(mainWindow);
setupDragDropPrevention(mainWindow);
mainWindow.once("ready-to-show", () => {
mainWindow.show();
});
+1 -6
View File
@@ -1,3 +1,4 @@
import "electron-log/preload";
import { contextBridge, ipcRenderer } from "electron";
type EventHandler = (payload: unknown) => void;
@@ -26,8 +27,6 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
endMove: () => ipcRenderer.send("paseo:window:endMove"),
toggleMaximize: () => ipcRenderer.invoke("paseo:window:toggleMaximize"),
isFullscreen: () => ipcRenderer.invoke("paseo:window:isFullscreen"),
setTitleBarTheme: (theme: "light" | "dark") =>
ipcRenderer.invoke("paseo:window:setTitleBarTheme", theme),
onResized: (handler: EventHandler): (() => void) => {
const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => {
handler(payload);
@@ -53,8 +52,4 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
opener: {
openUrl: (url: string) => ipcRenderer.invoke("paseo:opener:openUrl", url),
},
menu: {
showContextMenu: (input?: Record<string, unknown>) =>
ipcRenderer.invoke("paseo:menu:showContextMenu", input),
},
});
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { getTitleBarOverlayOptions, readBadgeCount, readWindowTheme } from "./window-manager";
import { readBadgeCount } from "./window-manager";
describe("window-manager", () => {
describe("readBadgeCount", () => {
@@ -20,35 +20,4 @@ describe("window-manager", () => {
expect(readBadgeCount({ count: 2 })).toBe(0);
});
});
describe("readWindowTheme", () => {
it("accepts supported title bar themes", () => {
expect(readWindowTheme("light")).toBe("light");
expect(readWindowTheme("dark")).toBe("dark");
});
it("rejects invalid title bar themes", () => {
expect(readWindowTheme(undefined)).toBeNull();
expect(readWindowTheme("auto")).toBeNull();
expect(readWindowTheme("system")).toBeNull();
});
});
describe("getTitleBarOverlayOptions", () => {
it("returns light title bar overlay colors", () => {
expect(getTitleBarOverlayOptions("light")).toEqual({
color: "#ffffff",
symbolColor: "#09090b",
height: 48,
});
});
it("returns dark title bar overlay colors", () => {
expect(getTitleBarOverlayOptions("dark")).toEqual({
color: "#18181c",
symbolColor: "#e4e4e7",
height: 48,
});
});
});
});
+1 -37
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow, ipcMain, nativeTheme } from "electron";
import { app, BrowserWindow, ipcMain } from "electron";
import {
createWindowMoveState,
readWindowMovePayload,
@@ -14,28 +14,6 @@ export function readBadgeCount(input: unknown): number {
return input;
}
export type WindowTheme = "light" | "dark";
export function readWindowTheme(input: unknown): WindowTheme | null {
if (input === "light" || input === "dark") {
return input;
}
return null;
}
export function resolveSystemWindowTheme(): WindowTheme {
return nativeTheme.shouldUseDarkColors ? "dark" : "light";
}
export function getTitleBarOverlayOptions(theme: WindowTheme): Electron.TitleBarOverlayOptions {
if (theme === "dark") {
return { color: "#18181c", symbolColor: "#e4e4e7", height: 48 };
}
return { color: "#ffffff", symbolColor: "#09090b", height: 48 };
}
export function registerWindowManager(): void {
// ---------------------------------------------------------------------------
// Manual window dragging (replaces flaky CSS -webkit-app-region: drag).
@@ -123,20 +101,6 @@ export function registerWindowManager(): void {
}
}
});
ipcMain.handle("paseo:window:setTitleBarTheme", (event, theme?: unknown) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win || process.platform === "darwin") {
return;
}
const nextTheme = readWindowTheme(theme);
if (!nextTheme) {
return;
}
win.setTitleBarOverlay(getTitleBarOverlayOptions(nextTheme));
});
}
export function setupWindowResizeEvents(win: BrowserWindow): void {
+2 -81
View File
@@ -1,6 +1,5 @@
import { fileURLToPath } from "url";
import { appendFileSync, existsSync } from "node:fs";
import path from "node:path";
import { existsSync } from "node:fs";
import { loadConfig } from "../src/server/config.js";
import { acquirePidLock, PidLockError, releasePidLock } from "../src/server/pid-lock.js";
import { resolvePaseoHome } from "../src/server/paseo-home.js";
@@ -56,84 +55,25 @@ function resolveWorkerExecArgv(workerEntry: string): string[] {
return workerEntry.endsWith(".ts") ? ["--import", "tsx"] : [];
}
function resolvePackagedNodeEntrypointRunnerPath(currentScriptPath: string): string | null {
const packageMarker = `${path.sep}node_modules${path.sep}@getpaseo${path.sep}server${path.sep}`;
const markerIndex = currentScriptPath.lastIndexOf(packageMarker);
if (markerIndex === -1) {
return null;
}
const appRoot = currentScriptPath.slice(0, markerIndex);
const runnerPath = path.join(appRoot, "dist", "daemon", "node-entrypoint-runner.js");
return existsSync(runnerPath) ? runnerPath : null;
}
function appendLaunchLog(
launchLogPath: string,
message: string,
details?: Record<string, unknown>,
): void {
const line = [
new Date().toISOString(),
"[DaemonRunner]",
`pid=${process.pid}`,
message,
details ? JSON.stringify(details) : "",
]
.filter((part) => part.length > 0)
.join(" ");
try {
appendFileSync(launchLogPath, `${line}\n`, "utf-8");
} catch {
// Keep launch debugging best-effort so daemon startup behavior is unchanged.
}
}
async function main(): Promise<void> {
const config = parseConfig(process.argv.slice(2));
const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry();
const workerExecArgv = resolveWorkerExecArgv(workerEntry);
const workerEnv: NodeJS.ProcessEnv = {
...process.env,
PASEO_PID_LOCK_MODE: "external",
};
const packagedNodeEntrypointRunner =
process.env.ELECTRON_RUN_AS_NODE === "1"
? resolvePackagedNodeEntrypointRunnerPath(fileURLToPath(import.meta.url))
: null;
applySherpaLoaderEnv(workerEnv);
const paseoHome = resolvePaseoHome(workerEnv);
const launchLogPath = path.join(paseoHome, "daemon-launch.log");
const daemonConfig = loadConfig(paseoHome, { env: workerEnv });
appendLaunchLog(launchLogPath, "daemon runner starting", {
argv: process.argv.slice(2),
execPath: process.execPath,
execArgv: process.execArgv,
workerEntry,
workerExecArgv,
devMode: config.devMode,
listen: daemonConfig.listen,
packagedNodeEntrypointRunner,
});
try {
await acquirePidLock(paseoHome, daemonConfig.listen, {
ownerPid: process.pid,
});
appendLaunchLog(launchLogPath, "pid lock acquired", {
ownerPid: process.pid,
listen: daemonConfig.listen,
});
} catch (error) {
if (error instanceof PidLockError) {
appendLaunchLog(launchLogPath, "pid lock acquisition failed", {
message: error.message,
existingLock: error.existingLock,
});
process.stderr.write(`${error.message}\n`);
process.exit(1);
return;
@@ -147,9 +87,6 @@ async function main(): Promise<void> {
return;
}
lockReleased = true;
appendLaunchLog(launchLogPath, "releasing pid lock", {
ownerPid: process.pid,
});
await releasePidLock(paseoHome, {
ownerPid: process.pid,
});
@@ -157,29 +94,13 @@ async function main(): Promise<void> {
runSupervisor({
name: "DaemonRunner",
launchLogPath,
startupMessage: config.devMode
? "Starting daemon worker (dev mode, crash restarts enabled)"
: "Starting daemon worker (IPC restart enabled)",
resolveWorkerEntry: () => workerEntry,
workerArgs: config.workerArgs,
workerEnv,
workerExecArgv,
resolveWorkerSpawnSpec: packagedNodeEntrypointRunner
? (resolvedWorkerEntry) => ({
command: process.execPath,
args: [
packagedNodeEntrypointRunner,
"node-script",
resolvedWorkerEntry,
...config.workerArgs,
],
env: {
...workerEnv,
ELECTRON_RUN_AS_NODE: "1",
},
})
: undefined,
workerExecArgv: resolveWorkerExecArgv(workerEntry),
restartOnCrash: config.devMode,
onSupervisorExit: releaseLock,
});
+5 -108
View File
@@ -1,5 +1,4 @@
import { fork, spawn, type ChildProcess } from "child_process";
import { appendFileSync } from "node:fs";
import { fork, type ChildProcess } from "child_process";
type WorkerLifecycleMessage =
| {
@@ -13,16 +12,10 @@ type WorkerLifecycleMessage =
type SupervisorOptions = {
name: string;
startupMessage: string;
launchLogPath?: string;
resolveWorkerEntry: () => string;
workerArgs?: string[];
workerEnv?: NodeJS.ProcessEnv;
workerExecArgv?: string[];
resolveWorkerSpawnSpec?: (workerEntry: string) => {
command: string;
args: string[];
env?: NodeJS.ProcessEnv;
} | null;
restartOnCrash?: boolean;
onSupervisorExit?: () => Promise<void> | void;
};
@@ -54,7 +47,6 @@ export function runSupervisor(options: SupervisorOptions): void {
const workerArgs = options.workerArgs ?? process.argv.slice(2);
const workerEnv = options.workerEnv ?? process.env;
const workerExecArgv = options.workerExecArgv ?? ["--import", "tsx"];
const resolveWorkerSpawnSpec = options.resolveWorkerSpawnSpec;
let child: ChildProcess | null = null;
let restarting = false;
@@ -65,28 +57,6 @@ export function runSupervisor(options: SupervisorOptions): void {
process.stderr.write(`[${options.name}] ${message}\n`);
};
const logLaunch = (message: string, details?: Record<string, unknown>): void => {
if (!options.launchLogPath) {
return;
}
const line = [
new Date().toISOString(),
`[${options.name}]`,
`pid=${process.pid}`,
message,
details ? JSON.stringify(details) : "",
]
.filter((part) => part.length > 0)
.join(" ");
try {
appendFileSync(options.launchLogPath, `${line}\n`, "utf-8");
} catch {
// Keep launch debugging best-effort so daemon startup behavior is unchanged.
}
};
const exitSupervisor = (code: number): void => {
if (exiting) {
return;
@@ -114,60 +84,14 @@ export function runSupervisor(options: SupervisorOptions): void {
return;
}
const spawnSpec = resolveWorkerSpawnSpec?.(workerEntry) ?? null;
if (spawnSpec) {
logLaunch("spawning worker via explicit spawn spec", {
workerEntry,
command: spawnSpec.command,
args: spawnSpec.args,
workerArgs,
});
child = spawn(spawnSpec.command, spawnSpec.args, {
stdio: ["inherit", "inherit", "inherit", "ipc"],
env: spawnSpec.env ?? workerEnv,
});
} else {
logLaunch("spawning worker via fork", {
workerEntry,
workerArgs,
workerExecArgv,
});
child = fork(workerEntry, workerArgs, {
stdio: "inherit",
env: workerEnv,
execArgv: workerExecArgv,
});
}
logLaunch("worker spawn returned", {
childPid: child.pid ?? null,
connected: child.connected,
spawnfile: child.spawnfile,
spawnargs: child.spawnargs,
});
child.on("spawn", () => {
logLaunch("worker emitted spawn", {
childPid: child?.pid ?? null,
});
});
child.on("error", (error) => {
logLaunch("worker emitted error", {
childPid: child?.pid ?? null,
message: error.message,
});
child = fork(workerEntry, workerArgs, {
stdio: "inherit",
env: workerEnv,
execArgv: workerExecArgv,
});
child.on("message", (msg: unknown) => {
const lifecycleMessage = parseLifecycleMessage(msg);
logLaunch("worker emitted ipc message", {
childPid: child?.pid ?? null,
rawType:
typeof msg === "object" && msg !== null && "type" in msg
? (msg as { type?: unknown }).type
: typeof msg,
});
if (!lifecycleMessage) {
return;
}
@@ -181,13 +105,6 @@ export function runSupervisor(options: SupervisorOptions): void {
});
child.on("exit", (code, signal) => {
logLaunch("worker emitted exit", {
childPid: child?.pid ?? null,
code,
signal,
restarting,
shuttingDown,
});
const exitDescriptor = describeExit(code, signal);
if (shuttingDown) {
@@ -206,20 +123,6 @@ export function runSupervisor(options: SupervisorOptions): void {
log(`Worker exited (${exitDescriptor}). Supervisor exiting.`);
exitSupervisor(typeof code === "number" ? code : 0);
});
child.on("close", (code, signal) => {
logLaunch("worker emitted close", {
childPid: child?.pid ?? null,
code,
signal,
});
});
child.on("disconnect", () => {
logLaunch("worker emitted disconnect", {
childPid: child?.pid ?? null,
});
});
};
const requestRestart = (reason: string) => {
@@ -252,12 +155,6 @@ export function runSupervisor(options: SupervisorOptions): void {
process.on("SIGINT", () => forwardSignal("SIGINT"));
process.on("SIGTERM", () => forwardSignal("SIGTERM"));
logLaunch("supervisor starting", {
startupMessage: options.startupMessage,
restartOnCrash,
workerArgs,
workerExecArgv,
});
process.stdout.write(`[${options.name}] ${options.startupMessage}\n`);
spawnWorker();
}
-435
View File
@@ -241,82 +241,6 @@ type ListTerminalsPayload = ListTerminalsResponse["payload"];
type CreateTerminalPayload = CreateTerminalResponse["payload"];
type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"];
type KillTerminalPayload = KillTerminalResponse["payload"];
type ChatCreatePayload = Extract<
SessionOutboundMessage,
{ type: "chat/create/response" }
>["payload"];
type ChatListPayload = Extract<
SessionOutboundMessage,
{ type: "chat/list/response" }
>["payload"];
type ChatInspectPayload = Extract<
SessionOutboundMessage,
{ type: "chat/inspect/response" }
>["payload"];
type ChatDeletePayload = Extract<
SessionOutboundMessage,
{ type: "chat/delete/response" }
>["payload"];
type ChatPostPayload = Extract<
SessionOutboundMessage,
{ type: "chat/post/response" }
>["payload"];
type ChatReadPayload = Extract<
SessionOutboundMessage,
{ type: "chat/read/response" }
>["payload"];
type ChatWaitPayload = Extract<
SessionOutboundMessage,
{ type: "chat/wait/response" }
>["payload"];
type LoopRunPayload = Extract<
SessionOutboundMessage,
{ type: "loop/run/response" }
>["payload"];
type LoopListPayload = Extract<
SessionOutboundMessage,
{ type: "loop/list/response" }
>["payload"];
type LoopInspectPayload = Extract<
SessionOutboundMessage,
{ type: "loop/inspect/response" }
>["payload"];
type LoopLogsPayload = Extract<
SessionOutboundMessage,
{ type: "loop/logs/response" }
>["payload"];
type LoopStopPayload = Extract<
SessionOutboundMessage,
{ type: "loop/stop/response" }
>["payload"];
type ScheduleCreatePayload = Extract<
SessionOutboundMessage,
{ type: "schedule/create/response" }
>["payload"];
type ScheduleListPayload = Extract<
SessionOutboundMessage,
{ type: "schedule/list/response" }
>["payload"];
type ScheduleInspectPayload = Extract<
SessionOutboundMessage,
{ type: "schedule/inspect/response" }
>["payload"];
type ScheduleLogsPayload = Extract<
SessionOutboundMessage,
{ type: "schedule/logs/response" }
>["payload"];
type SchedulePausePayload = Extract<
SessionOutboundMessage,
{ type: "schedule/pause/response" }
>["payload"];
type ScheduleResumePayload = Extract<
SessionOutboundMessage,
{ type: "schedule/resume/response" }
>["payload"];
type ScheduleDeletePayload = Extract<
SessionOutboundMessage,
{ type: "schedule/delete/response" }
>["payload"];
export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"];
export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"];
@@ -353,110 +277,6 @@ export type FetchWorkspacesOptions = Omit<FetchWorkspacesRequest, "type" | "requ
};
export type FetchWorkspacesEntry = FetchWorkspacesPayload["entries"][number];
export type FetchWorkspacesPageInfo = FetchWorkspacesPayload["pageInfo"];
export type CreateChatRoomOptions = {
name: string;
purpose?: string | null;
requestId?: string;
};
export type InspectChatRoomOptions = {
room: string;
requestId?: string;
};
export type DeleteChatRoomOptions = {
room: string;
requestId?: string;
};
export type PostChatMessageOptions = {
room: string;
body: string;
authorAgentId?: string;
replyToMessageId?: string | null;
requestId?: string;
};
export type ReadChatMessagesOptions = {
room: string;
limit?: number;
since?: string;
authorAgentId?: string;
requestId?: string;
};
export type WaitForChatMessagesOptions = {
room: string;
afterMessageId?: string | null;
timeoutMs?: number;
requestId?: string;
};
export type RunLoopOptions = {
prompt: string;
cwd: string;
verifyPrompt?: string | null;
verifyChecks?: string[];
name?: string | null;
sleepMs?: number;
maxIterations?: number;
maxTimeMs?: number;
requestId?: string;
};
export type InspectLoopOptions = {
id: string;
requestId?: string;
};
export type LoopLogsOptions = {
id: string;
afterSeq?: number;
requestId?: string;
};
export type StopLoopOptions = {
id: string;
requestId?: string;
};
export type CreateScheduleOptions = {
prompt: string;
name?: string | null;
cadence:
| {
type: "every";
everyMs: number;
}
| {
type: "cron";
expression: string;
};
target:
| {
type: "self";
agentId: string;
}
| {
type: "agent";
agentId: string;
}
| {
type: "new-agent";
config: {
provider: AgentProvider;
cwd: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
title?: string | null;
approvalPolicy?: string;
sandboxMode?: string;
networkAccess?: boolean;
webSearch?: boolean;
extra?: AgentSessionConfig["extra"];
systemPrompt?: string;
mcpServers?: AgentSessionConfig["mcpServers"];
};
};
maxRuns?: number;
expiresAt?: string;
requestId?: string;
};
export type InspectScheduleOptions = {
id: string;
requestId?: string;
};
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"];
@@ -2857,261 +2677,6 @@ export class DaemonClient {
});
}
async createChatRoom(options: CreateChatRoomOptions): Promise<ChatCreatePayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "chat/create",
name: options.name,
...(options.purpose ? { purpose: options.purpose } : {}),
},
responseType: "chat/create/response",
timeout: 10000,
});
}
async listChatRooms(requestId?: string): Promise<ChatListPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "chat/list",
},
responseType: "chat/list/response",
timeout: 10000,
});
}
async inspectChatRoom(options: InspectChatRoomOptions): Promise<ChatInspectPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "chat/inspect",
room: options.room,
},
responseType: "chat/inspect/response",
timeout: 10000,
});
}
async deleteChatRoom(options: DeleteChatRoomOptions): Promise<ChatDeletePayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "chat/delete",
room: options.room,
},
responseType: "chat/delete/response",
timeout: 10000,
});
}
async postChatMessage(options: PostChatMessageOptions): Promise<ChatPostPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "chat/post",
room: options.room,
body: options.body,
...(options.authorAgentId ? { authorAgentId: options.authorAgentId } : {}),
...(options.replyToMessageId ? { replyToMessageId: options.replyToMessageId } : {}),
},
responseType: "chat/post/response",
timeout: 10000,
});
}
async readChatMessages(options: ReadChatMessagesOptions): Promise<ChatReadPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "chat/read",
room: options.room,
...(typeof options.limit === "number" ? { limit: options.limit } : {}),
...(options.since ? { since: options.since } : {}),
...(options.authorAgentId ? { authorAgentId: options.authorAgentId } : {}),
},
responseType: "chat/read/response",
timeout: 10000,
});
}
async waitForChatMessages(options: WaitForChatMessagesOptions): Promise<ChatWaitPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "chat/wait",
room: options.room,
...(options.afterMessageId ? { afterMessageId: options.afterMessageId } : {}),
...(typeof options.timeoutMs === "number" ? { timeoutMs: options.timeoutMs } : {}),
},
responseType: "chat/wait/response",
timeout: (options.timeoutMs ?? 0) + 10000,
});
}
async scheduleCreate(options: CreateScheduleOptions): Promise<ScheduleCreatePayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "schedule/create",
prompt: options.prompt,
cadence: options.cadence,
target: options.target,
...(options.name ? { name: options.name } : {}),
...(typeof options.maxRuns === "number" ? { maxRuns: options.maxRuns } : {}),
...(options.expiresAt ? { expiresAt: options.expiresAt } : {}),
},
responseType: "schedule/create/response",
timeout: 10000,
});
}
async scheduleList(requestId?: string): Promise<ScheduleListPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "schedule/list",
},
responseType: "schedule/list/response",
timeout: 10000,
});
}
async scheduleInspect(options: InspectScheduleOptions): Promise<ScheduleInspectPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "schedule/inspect",
scheduleId: options.id,
},
responseType: "schedule/inspect/response",
timeout: 10000,
});
}
async scheduleLogs(options: InspectScheduleOptions): Promise<ScheduleLogsPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "schedule/logs",
scheduleId: options.id,
},
responseType: "schedule/logs/response",
timeout: 10000,
});
}
async schedulePause(options: InspectScheduleOptions): Promise<SchedulePausePayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "schedule/pause",
scheduleId: options.id,
},
responseType: "schedule/pause/response",
timeout: 10000,
});
}
async scheduleResume(options: InspectScheduleOptions): Promise<ScheduleResumePayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "schedule/resume",
scheduleId: options.id,
},
responseType: "schedule/resume/response",
timeout: 10000,
});
}
async scheduleDelete(options: InspectScheduleOptions): Promise<ScheduleDeletePayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "schedule/delete",
scheduleId: options.id,
},
responseType: "schedule/delete/response",
timeout: 10000,
});
}
async loopRun(options: RunLoopOptions): Promise<LoopRunPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "loop/run",
prompt: options.prompt,
cwd: options.cwd,
...(options.verifyPrompt ? { verifyPrompt: options.verifyPrompt } : {}),
...(options.verifyChecks && options.verifyChecks.length > 0
? { verifyChecks: options.verifyChecks }
: {}),
...(options.name ? { name: options.name } : {}),
...(typeof options.sleepMs === "number" ? { sleepMs: options.sleepMs } : {}),
...(typeof options.maxIterations === "number"
? { maxIterations: options.maxIterations }
: {}),
...(typeof options.maxTimeMs === "number" ? { maxTimeMs: options.maxTimeMs } : {}),
},
responseType: "loop/run/response",
timeout: 15000,
});
}
async loopList(requestId?: string): Promise<LoopListPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "loop/list",
},
responseType: "loop/list/response",
timeout: 10000,
});
}
async loopInspect(options: string | InspectLoopOptions): Promise<LoopInspectPayload> {
const normalized = typeof options === "string" ? { id: options } : options;
return this.sendCorrelatedSessionRequest({
requestId: normalized.requestId,
message: {
type: "loop/inspect",
id: normalized.id,
},
responseType: "loop/inspect/response",
timeout: 10000,
});
}
async loopLogs(options: string | LoopLogsOptions, afterSeq?: number): Promise<LoopLogsPayload> {
const normalized =
typeof options === "string" ? { id: options, afterSeq } : options;
return this.sendCorrelatedSessionRequest({
requestId: normalized.requestId,
message: {
type: "loop/logs",
id: normalized.id,
...(typeof normalized.afterSeq === "number" ? { afterSeq: normalized.afterSeq } : {}),
},
responseType: "loop/logs/response",
timeout: 10000,
});
}
async loopStop(options: string | StopLoopOptions): Promise<LoopStopPayload> {
const normalized = typeof options === "string" ? { id: options } : options;
return this.sendCorrelatedSessionRequest({
requestId: normalized.requestId,
message: {
type: "loop/stop",
id: normalized.id,
},
responseType: "loop/stop/response",
timeout: 10000,
});
}
onTerminalStreamEvent(handler: (event: TerminalStreamEvent) => void): () => void {
this.terminalStreamListeners.add(handler);
return () => {
@@ -1,5 +1,3 @@
import type { AgentAttentionReason } from "../shared/agent-attention-notification.js";
export type ClientAttentionState = {
deviceType: "web" | "mobile" | null;
focusedAgentId: string | null;
@@ -7,6 +5,8 @@ export type ClientAttentionState = {
appVisible: boolean;
};
export type AgentAttentionReason = "finished" | "error" | "permission";
type ComputeClientNotificationInput = {
clientState: ClientAttentionState;
allClientStates: ClientAttentionState[];
@@ -860,39 +860,6 @@ export class AgentManager {
this.logger.trace({ agentId }, "closeAgent: completed");
}
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
const agent = this.requireAgent(agentId);
if (!this.registry) {
throw new Error("Agent storage is not configured");
}
await this.registry.applySnapshot(agent, {
internal: agent.internal,
});
const stored = await this.registry.get(agentId);
if (!stored) {
throw new Error(`Agent ${agentId} not found in storage after snapshot`);
}
const archivedAt = new Date().toISOString();
const normalizedStatus =
stored.lastStatus === "running" || stored.lastStatus === "initializing"
? "idle"
: stored.lastStatus;
await this.registry.upsert({
...stored,
archivedAt,
lastStatus: normalizedStatus,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
});
await this.closeAgent(agentId);
return { archivedAt };
}
async setAgentMode(agentId: string, modeId: string): Promise<void> {
const agent = this.requireAgent(agentId);
await agent.session.setMode(modeId);
@@ -134,40 +134,6 @@ describe("applyProviderEnv", () => {
});
describe("findExecutable", () => {
test("on Windows, resolves executables using current machine and user PATH entries", () => {
findExecutableDependencies.platform = vi.fn(() => "win32");
process.env.Path = "C:\\Windows\\System32";
findExecutableDependencies.execFileSync.mockImplementation(
((command: string, args?: string[]) => {
if (command === "powershell") {
return "C:\\Windows\\System32\r\nC:\\Users\\boudr\\.local\\bin\r\n";
}
if (command === "where.exe") {
return "C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n";
}
throw new Error(`unexpected command ${command}`);
}) as any,
);
expect(findExecutable("claude", findExecutableDependencies)).toBe(
"C:\\Users\\boudr\\.local\\bin\\claude.exe",
);
const powershellCall = findExecutableDependencies.execFileSync.mock.calls[0];
expect(powershellCall?.[0]).toBe("powershell");
expect(powershellCall?.[1]).toContain("-NoProfile");
expect(powershellCall?.[1]).toContain("-NonInteractive");
expect(powershellCall?.[1]).toContain(
'$machine = [Environment]::GetEnvironmentVariable("Path", "Machine"); $user = [Environment]::GetEnvironmentVariable("Path", "User"); if ($machine) { Write-Output $machine }; if ($user) { Write-Output $user }',
);
const whereCall = findExecutableDependencies.execFileSync.mock.calls[1];
expect(whereCall?.[0]).toBe("where.exe");
expect(whereCall?.[1]).toEqual(["claude"]);
expect(whereCall?.[2]?.encoding).toBe("utf8");
const env = whereCall?.[2]?.env as Record<string, string | undefined>;
expect(env.PATH).toContain("C:\\Users\\boudr\\.local\\bin");
expect(env.Path).toContain("C:\\Users\\boudr\\.local\\bin");
});
test("uses the last line from login-shell which output", () => {
findExecutableDependencies.shell = "/bin/zsh";
findExecutableDependencies.execSync.mockReturnValue(
@@ -64,33 +64,6 @@ interface FindExecutableDependencies {
shell: string | undefined;
}
function resolveWindowsPathEntries(deps: FindExecutableDependencies): string[] {
try {
const output = deps.execFileSync(
"powershell",
[
"-NoProfile",
"-NonInteractive",
"-Command",
[
'$machine = [Environment]::GetEnvironmentVariable("Path", "Machine")',
'$user = [Environment]::GetEnvironmentVariable("Path", "User")',
"if ($machine) { Write-Output $machine }",
"if ($user) { Write-Output $user }",
].join("; "),
],
{ encoding: "utf8" },
);
return output
.split(/\r?\n/)
.flatMap((line) => line.split(";"))
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
} catch {
return [];
}
}
function resolveExecutableFromWhichOutput(
name: string,
output: string,
@@ -213,21 +186,7 @@ export function findExecutable(
if (deps.platform() === "win32") {
try {
const inheritedPath = process.env["Path"] ?? process.env["PATH"] ?? "";
const resolvedPath = [
...inheritedPath.split(";"),
...resolveWindowsPathEntries(deps),
]
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
.filter((entry, index, entries) => entries.indexOf(entry) === index)
.join(";");
const env = {
...process.env,
PATH: resolvedPath,
Path: resolvedPath,
};
const out = deps.execFileSync("where.exe", [trimmed], { encoding: "utf8", env }).trim();
const out = deps.execSync(`where.exe ${trimmed}`, { encoding: "utf8" }).trim();
const firstLine = out.split(/\r?\n/)[0]?.trim();
return firstLine || null;
} catch {
@@ -258,7 +258,6 @@ type ClaudeOptionsLogSummary = {
hasCanUseTool: boolean;
hasSpawnOverride: boolean;
hasStderrHandler: boolean;
pathToClaudeCodeExecutable: string | null;
};
const MAX_RECENT_STDERR_CHARS = 4000;
@@ -302,10 +301,6 @@ function summarizeClaudeOptionsForLog(options: ClaudeOptions): ClaudeOptionsLogS
hasCanUseTool: typeof options.canUseTool === "function",
hasSpawnOverride: typeof options.spawnClaudeCodeProcess === "function",
hasStderrHandler: typeof options.stderr === "function",
pathToClaudeCodeExecutable:
typeof options.pathToClaudeCodeExecutable === "string"
? options.pathToClaudeCodeExecutable
: null,
};
}
@@ -1868,22 +1863,6 @@ class ClaudeAgentSession implements AgentSession {
.join("\n\n");
const claudeBinary = findExecutable("claude");
this.logger.debug(
{
claudeBinary,
pathEnvKey:
process.env["Path"] !== undefined
? "Path"
: process.env["PATH"] !== undefined
? "PATH"
: null,
pathIncludesClaudeLocalBin:
(process.env["Path"] ?? process.env["PATH"] ?? "")
.toLowerCase()
.includes("\\.local\\bin"),
},
"Resolved Claude executable",
);
const base: ClaudeOptions = {
cwd: this.config.cwd,
includePartialMessages: true,
-30
View File
@@ -99,9 +99,6 @@ import { createAgentMcpServer } from "./agent/mcp-server.js";
import { createAllClients, shutdownProviders } from "./agent/provider-registry.js";
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
import { FileBackedChatService } from "./chat/chat-service.js";
import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js";
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
@@ -376,10 +373,6 @@ export async function createPaseoDaemon(
path.join(config.paseoHome, "projects", "workspaces.json"),
logger,
);
const chatService = new FileBackedChatService({
paseoHome: config.paseoHome,
logger,
});
const agentManager = new AgentManager({
clients: {
...createAllClients(logger, {
@@ -408,24 +401,6 @@ export async function createPaseoDaemon(
logger,
});
logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped");
await chatService.initialize();
logger.info({ elapsed: elapsed() }, "Chat service initialized");
const loopService = new LoopService({
paseoHome: config.paseoHome,
logger,
agentManager,
});
await loopService.initialize();
logger.info({ elapsed: elapsed() }, "Loop service initialized");
const scheduleService = new ScheduleService({
paseoHome: config.paseoHome,
logger,
agentManager,
agentStorage,
});
await scheduleService.start();
logger.info({ elapsed: elapsed() }, "Schedule service initialized");
logger.info({ elapsed: elapsed() }, "Loading persisted agent registry");
const persistedRecords = await agentStorage.list();
logger.info(
{ elapsed: elapsed() },
@@ -434,7 +409,6 @@ export async function createPaseoDaemon(
logger.info(
"Voice mode configured for agent-scoped resume flow (no dedicated voice assistant provider)",
);
logger.info({ elapsed: elapsed() }, "Preparing voice and MCP runtime");
let wsServer: VoiceAssistantWebSocketServer | null = null;
let voiceMcpBridgeManager: VoiceMcpSocketBridgeManager | null = null;
let unsubscribeSpeechReadiness: (() => void) | null = null;
@@ -659,9 +633,6 @@ export async function createPaseoDaemon(
},
projectRegistry,
workspaceRegistry,
chatService,
loopService,
scheduleService,
);
unsubscribeSpeechReadiness = subscribeSpeechReadiness((snapshot) => {
wsServer?.publishSpeechReadiness(snapshot);
@@ -755,7 +726,6 @@ export async function createPaseoDaemon(
unsubscribeSpeechReadiness?.();
unsubscribeSpeechReadiness = null;
cleanupSpeechRuntime();
await scheduleService.stop().catch(() => undefined);
await relayTransport?.stop().catch(() => undefined);
if (wsServer) {
await wsServer.close();
@@ -1,83 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import {
buildChatMentionNotification,
notifyChatMentions,
resolveChatMentionTargetAgentIds,
} from "./chat-mentions.js";
describe("chat mentions", () => {
test("@everyone expands to active non-archived agents", () => {
const targets = resolveChatMentionTargetAgentIds({
authorAgentId: "author-agent",
mentionAgentIds: ["everyone"],
storedAgents: [
{ id: "agent-a", internal: false, archivedAt: null } as any,
{ id: "agent-b", internal: false, archivedAt: "2026-03-28T00:00:00.000Z" } as any,
{ id: "author-agent", internal: false, archivedAt: null } as any,
{ id: "internal-agent", internal: true, archivedAt: null } as any,
],
liveAgents: [
{ id: "agent-c", internal: false } as any,
{ id: "internal-live", internal: true } as any,
],
});
expect(targets.sort()).toEqual(["agent-a", "agent-c"]);
});
test("@everyone deduplicates with explicit mentions and keeps explicit non-everyone mentions", () => {
const targets = resolveChatMentionTargetAgentIds({
authorAgentId: "author-agent",
mentionAgentIds: ["everyone", "agent-a", "custom-title"],
storedAgents: [{ id: "agent-a", internal: false, archivedAt: null } as any],
liveAgents: [{ id: "agent-b", internal: false } as any],
});
expect(targets.sort()).toEqual(["agent-a", "agent-b", "custom-title"]);
});
test("notification body strips inline mentions but keeps the room context", () => {
expect(
buildChatMentionNotification({
room: "coord-room",
authorAgentId: "author-agent",
body: "@agent-a @everyone Check the latest status.",
mentionAgentIds: ["agent-a", "everyone"],
}),
).toContain("Check the latest status.");
});
test("notifyChatMentions delegates sends for resolved targets", async () => {
const resolveAgentIdentifier = vi.fn(async (identifier: string) => ({
ok: true as const,
agentId: identifier,
}));
const sendAgentMessage = vi.fn(async () => {});
const logger = {
warn: vi.fn(),
} as any;
await notifyChatMentions({
room: "coord-room",
authorAgentId: "author-agent",
body: "@everyone Check status",
mentionAgentIds: ["everyone"],
logger,
listStoredAgents: async () => [{ id: "agent-a", internal: false, archivedAt: null } as any],
listLiveAgents: () => [{ id: "agent-b", internal: false } as any],
resolveAgentIdentifier,
sendAgentMessage,
});
expect(resolveAgentIdentifier).toHaveBeenCalledTimes(2);
expect(sendAgentMessage).toHaveBeenCalledTimes(2);
expect(sendAgentMessage).toHaveBeenCalledWith(
"agent-a",
expect.stringContaining('room "coord-room"'),
);
expect(sendAgentMessage).toHaveBeenCalledWith(
"agent-b",
expect.stringContaining("Check status"),
);
});
});
@@ -1,114 +0,0 @@
import type pino from "pino";
import type { StoredAgentRecord } from "../agent/agent-storage.js";
import type { ManagedAgent } from "../agent/agent-manager.js";
export interface ChatMentionNotificationInput {
room: string;
authorAgentId: string;
body: string;
mentionAgentIds: string[];
}
export interface NotifyChatMentionsInput extends ChatMentionNotificationInput {
logger: pino.Logger;
listStoredAgents: () => Promise<StoredAgentRecord[]>;
listLiveAgents: () => ManagedAgent[];
resolveAgentIdentifier: (
identifier: string,
) => Promise<{ ok: true; agentId: string } | { ok: false; error: string }>;
sendAgentMessage: (agentId: string, text: string) => Promise<void>;
}
export async function notifyChatMentions(input: NotifyChatMentionsInput): Promise<void> {
const mentionAgentIds = await resolveChatMentionTargetAgentIds({
authorAgentId: input.authorAgentId,
mentionAgentIds: input.mentionAgentIds,
storedAgents: await input.listStoredAgents(),
liveAgents: input.listLiveAgents(),
});
if (mentionAgentIds.length === 0) {
return;
}
const notification = buildChatMentionNotification({
room: input.room,
authorAgentId: input.authorAgentId,
body: input.body,
mentionAgentIds,
});
await Promise.all(
mentionAgentIds.map(async (mentionedAgentId) => {
const resolved = await input.resolveAgentIdentifier(mentionedAgentId);
if (!resolved.ok) {
input.logger.warn(
{ mentionedAgentId, room: input.room, error: resolved.error },
"Failed to resolve chat mention target",
);
return;
}
try {
await input.sendAgentMessage(resolved.agentId, notification);
} catch (error) {
input.logger.warn(
{ err: error, mentionedAgentId: resolved.agentId, room: input.room },
"Failed to notify mentioned agent about chat message",
);
}
}),
);
}
export function resolveChatMentionTargetAgentIds(input: {
authorAgentId: string;
mentionAgentIds: string[];
storedAgents: StoredAgentRecord[];
liveAgents: ManagedAgent[];
}): string[] {
const targets = new Set<string>();
const mentionsEveryone = input.mentionAgentIds.includes("everyone");
for (const mentionAgentId of input.mentionAgentIds) {
if (mentionAgentId === "everyone") {
continue;
}
if (mentionAgentId !== input.authorAgentId) {
targets.add(mentionAgentId);
}
}
if (!mentionsEveryone) {
return Array.from(targets);
}
for (const record of input.storedAgents) {
if (record.internal || record.archivedAt || record.id === input.authorAgentId) {
continue;
}
targets.add(record.id);
}
for (const agent of input.liveAgents) {
if (agent.internal || agent.id === input.authorAgentId) {
continue;
}
targets.add(agent.id);
}
return Array.from(targets);
}
export function buildChatMentionNotification(input: ChatMentionNotificationInput): string {
const mentioned = input.mentionAgentIds.map((agentId) => `@${agentId}`).join(", ");
const bodyWithoutMentions = input.body.replace(/(^|\s)@[A-Za-z0-9][A-Za-z0-9._-]*/g, "$1").trim();
const body = bodyWithoutMentions.length > 0 ? bodyWithoutMentions : input.body;
return [
`Chat mention from ${input.authorAgentId} in room "${input.room}".`,
`Mentioned agents: ${mentioned}.`,
"Message:",
body,
`Read the room with: paseo chat read ${input.room} --limit 20`,
].join("\n");
}
@@ -1,116 +0,0 @@
import { z } from "zod";
import { ChatMessageSchema, ChatRoomDetailSchema } from "./chat-types.js";
export const ChatCreateRequestSchema = z.object({
type: z.literal("chat/create"),
requestId: z.string(),
name: z.string(),
purpose: z.string().optional(),
});
export const ChatListRequestSchema = z.object({
type: z.literal("chat/list"),
requestId: z.string(),
});
export const ChatInspectRequestSchema = z.object({
type: z.literal("chat/inspect"),
requestId: z.string(),
room: z.string(),
});
export const ChatDeleteRequestSchema = z.object({
type: z.literal("chat/delete"),
requestId: z.string(),
room: z.string(),
});
export const ChatPostRequestSchema = z.object({
type: z.literal("chat/post"),
requestId: z.string(),
room: z.string(),
body: z.string(),
authorAgentId: z.string().optional(),
replyToMessageId: z.string().optional(),
});
export const ChatReadRequestSchema = z.object({
type: z.literal("chat/read"),
requestId: z.string(),
room: z.string(),
limit: z.number().int().nonnegative().optional(),
since: z.string().optional(),
authorAgentId: z.string().optional(),
});
export const ChatWaitRequestSchema = z.object({
type: z.literal("chat/wait"),
requestId: z.string(),
room: z.string(),
afterMessageId: z.string().optional(),
timeoutMs: z.number().int().nonnegative().optional(),
});
export const ChatCreateResponseSchema = z.object({
type: z.literal("chat/create/response"),
payload: z.object({
requestId: z.string(),
room: ChatRoomDetailSchema.nullable(),
error: z.string().nullable(),
}),
});
export const ChatListResponseSchema = z.object({
type: z.literal("chat/list/response"),
payload: z.object({
requestId: z.string(),
rooms: z.array(ChatRoomDetailSchema),
error: z.string().nullable(),
}),
});
export const ChatInspectResponseSchema = z.object({
type: z.literal("chat/inspect/response"),
payload: z.object({
requestId: z.string(),
room: ChatRoomDetailSchema.nullable(),
error: z.string().nullable(),
}),
});
export const ChatDeleteResponseSchema = z.object({
type: z.literal("chat/delete/response"),
payload: z.object({
requestId: z.string(),
room: ChatRoomDetailSchema.nullable(),
error: z.string().nullable(),
}),
});
export const ChatPostResponseSchema = z.object({
type: z.literal("chat/post/response"),
payload: z.object({
requestId: z.string(),
message: ChatMessageSchema.nullable(),
error: z.string().nullable(),
}),
});
export const ChatReadResponseSchema = z.object({
type: z.literal("chat/read/response"),
payload: z.object({
requestId: z.string(),
messages: z.array(ChatMessageSchema),
error: z.string().nullable(),
}),
});
export const ChatWaitResponseSchema = z.object({
type: z.literal("chat/wait/response"),
payload: z.object({
requestId: z.string(),
messages: z.array(ChatMessageSchema),
timedOut: z.boolean(),
error: z.string().nullable(),
}),
});
@@ -1,152 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { ChatServiceError, FileBackedChatService, parseMentionAgentIds } from "./chat-service.js";
describe("FileBackedChatService", () => {
let paseoHome: string;
let service: FileBackedChatService;
beforeEach(async () => {
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-chat-service-"));
service = new FileBackedChatService({
paseoHome,
logger: pino({ level: "silent" }),
});
await service.initialize();
});
afterEach(async () => {
await rm(paseoHome, { recursive: true, force: true });
});
test("creates rooms, enforces unique names, and persists to disk", async () => {
const created = await service.createRoom({
name: "cli-features-epic",
purpose: "Coordination room",
});
await expect(
service.createRoom({
name: "CLI-FEATURES-EPIC",
}),
).rejects.toMatchObject<Partial<ChatServiceError>>({
code: "chat_room_name_taken",
});
const raw = await readFile(path.join(paseoHome, "chat", "rooms.json"), "utf8");
expect(raw).toContain("cli-features-epic");
expect(created.name).toBe("cli-features-epic");
expect(created.purpose).toBe("Coordination room");
expect(created.messageCount).toBe(0);
});
test("resolves rooms by name or ID, validates replies, and reads filtered messages", async () => {
const room = await service.createRoom({ name: "auth-refactor" });
const first = await service.postMessage({
room: room.name,
authorAgentId: "agent-a",
body: "first message for @agent-b and @agent-c and again @agent-b",
});
await service.postMessage({
room: room.id,
authorAgentId: "agent-b",
body: "reply",
replyToMessageId: first.id,
});
await expect(
service.postMessage({
room: room.name,
authorAgentId: "agent-b",
body: "bad reply",
replyToMessageId: "missing",
}),
).rejects.toMatchObject<Partial<ChatServiceError>>({
code: "chat_message_not_found",
});
const all = await service.readMessages({ room: room.name, limit: 10 });
expect(all).toHaveLength(2);
expect(all[0]?.mentionAgentIds).toEqual(["agent-b", "agent-c"]);
const byAuthor = await service.readMessages({
room: room.id,
authorAgentId: "agent-b",
limit: 10,
});
expect(byAuthor).toHaveLength(1);
expect(byAuthor[0]?.body).toBe("reply");
const detail = await service.inspectRoom({ room: room.name });
expect(detail.room.messageCount).toBe(2);
expect(detail.room.lastMessageAt).toBeTruthy();
});
test("waits for new messages after a cursor and times out with an empty result", async () => {
const room = await service.createRoom({ name: "loop-status" });
const first = await service.postMessage({
room: room.name,
authorAgentId: "agent-a",
body: "ready",
});
const waitPromise = service.waitForMessages({
room: room.name,
afterMessageId: first.id,
timeoutMs: 1000,
});
await service.postMessage({
room: room.name,
authorAgentId: "agent-b",
body: "new work",
});
const waited = await waitPromise;
expect(waited).toHaveLength(1);
expect(waited[0]?.body).toBe("new work");
const timedOut = await service.waitForMessages({
room: room.name,
afterMessageId: waited[0]?.id,
timeoutMs: 10,
});
expect(timedOut).toEqual([]);
});
test("deletes rooms, removes messages, and rejects pending waiters", async () => {
const room = await service.createRoom({ name: "schedule-jobs" });
await service.postMessage({
room: room.name,
authorAgentId: "agent-a",
body: "hello",
});
const waitPromise = service.waitForMessages({
room: room.name,
timeoutMs: 1000,
});
const deleted = await service.deleteRoom({ room: room.id });
expect(deleted.room.messageCount).toBe(1);
await expect(waitPromise).rejects.toMatchObject<Partial<ChatServiceError>>({
code: "chat_room_deleted",
});
await expect(service.inspectRoom({ room: room.name })).rejects.toMatchObject<
Partial<ChatServiceError>
>({
code: "chat_room_not_found",
});
});
test("extracts inline mentions from chat bodies", () => {
expect(
parseMentionAgentIds(
"Checking with @agent-a, (@agent_b), @everyone, and duplicate @agent-a again.",
),
).toEqual(["agent-a", "agent_b", "everyone"]);
expect(parseMentionAgentIds("email@example.com is not a mention")).toEqual([]);
});
});
@@ -1,449 +0,0 @@
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import type pino from "pino";
import { z } from "zod";
import {
ChatMessageSchema,
ChatRoomDetailSchema,
ChatRoomSchema,
type ChatMessage,
type ChatRoom,
type ChatRoomDetail,
} from "./chat-types.js";
const ChatStorePayloadSchema = z.object({
rooms: z.array(ChatRoomSchema),
messages: z.array(ChatMessageSchema),
});
type ChatStorePayload = z.infer<typeof ChatStorePayloadSchema>;
function normalizeRoomName(name: string): string {
return name.trim().toLocaleLowerCase();
}
function trimToNull(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
const CHAT_MENTION_PATTERN = /(?:^|[\s(])@([A-Za-z0-9][A-Za-z0-9._-]*)/g;
export function parseMentionAgentIds(body: string): string[] {
const mentionAgentIds = new Set<string>();
for (const match of body.matchAll(CHAT_MENTION_PATTERN)) {
const agentId = match[1]?.trim();
if (agentId) {
mentionAgentIds.add(agentId);
}
}
return Array.from(mentionAgentIds).sort();
}
export class ChatServiceError extends Error {
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.name = "ChatServiceError";
this.code = code;
}
}
type Waiter = {
roomId: string;
afterMessageId: string | null;
resolve: (messages: ChatMessage[]) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout> | null;
};
export interface CreateChatRoomInput {
name: string;
purpose?: string | null;
}
export interface InspectChatRoomInput {
room: string;
}
export interface DeleteChatRoomInput {
room: string;
}
export interface PostChatMessageInput {
room: string;
authorAgentId: string;
body: string;
replyToMessageId?: string | null;
}
export interface ReadChatMessagesInput {
room: string;
limit?: number;
since?: string;
authorAgentId?: string;
}
export interface WaitForChatMessagesInput {
room: string;
afterMessageId?: string | null;
timeoutMs?: number;
}
export interface DeleteChatRoomResult {
room: ChatRoomDetail;
}
export interface InspectChatRoomResult {
room: ChatRoomDetail;
}
export class FileBackedChatService {
private readonly filePath: string;
private readonly logger: pino.Logger;
private loaded = false;
private readonly rooms = new Map<string, ChatRoom>();
private readonly messagesByRoomId = new Map<string, ChatMessage[]>();
private persistQueue: Promise<void> = Promise.resolve();
private readonly waitersByRoomId = new Map<string, Set<Waiter>>();
constructor(options: { paseoHome: string; logger: pino.Logger }) {
this.filePath = path.join(options.paseoHome, "chat", "rooms.json");
this.logger = options.logger.child({ component: "chat-service" });
}
async initialize(): Promise<void> {
await this.load();
}
async createRoom(input: CreateChatRoomInput): Promise<ChatRoomDetail> {
await this.load();
const name = input.name.trim();
if (name.length === 0) {
throw new ChatServiceError("invalid_chat_room_name", "Chat room name is required");
}
if (this.findRoomByName(name)) {
throw new ChatServiceError(
"chat_room_name_taken",
`Chat room already exists with name: ${name}`,
);
}
const now = new Date().toISOString();
const room = ChatRoomSchema.parse({
id: randomUUID(),
name,
purpose: trimToNull(input.purpose),
createdAt: now,
updatedAt: now,
});
this.rooms.set(room.id, room);
await this.enqueuePersist();
return this.toRoomDetail(room);
}
async listRooms(): Promise<ChatRoomDetail[]> {
await this.load();
return Array.from(this.rooms.values())
.map((room) => this.toRoomDetail(room))
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}
async inspectRoom(input: InspectChatRoomInput): Promise<InspectChatRoomResult> {
await this.load();
const room = this.resolveRoom(input.room);
return {
room: this.toRoomDetail(room),
};
}
async deleteRoom(input: DeleteChatRoomInput): Promise<DeleteChatRoomResult> {
await this.load();
const room = this.resolveRoom(input.room);
const detail = this.toRoomDetail(room);
this.rooms.delete(room.id);
this.messagesByRoomId.delete(room.id);
await this.enqueuePersist();
this.rejectWaiters(
room.id,
new ChatServiceError("chat_room_deleted", `Chat room deleted: ${room.name}`),
);
return { room: detail };
}
async postMessage(input: PostChatMessageInput): Promise<ChatMessage> {
await this.load();
const room = this.resolveRoom(input.room);
const body = input.body.trim();
if (body.length === 0) {
throw new ChatServiceError("invalid_chat_message", "Chat message body is required");
}
const authorAgentId = input.authorAgentId.trim();
if (authorAgentId.length === 0) {
throw new ChatServiceError("invalid_chat_author", "Chat message author is required");
}
const messages = this.getRoomMessages(room.id);
const replyToMessageId = trimToNull(input.replyToMessageId);
if (replyToMessageId) {
const replyTarget = messages.find((message) => message.id === replyToMessageId);
if (!replyTarget) {
throw new ChatServiceError(
"chat_message_not_found",
`Reply target not found: ${replyToMessageId}`,
);
}
}
const createdAt = new Date().toISOString();
const message = ChatMessageSchema.parse({
id: randomUUID(),
roomId: room.id,
authorAgentId,
body,
replyToMessageId,
mentionAgentIds: parseMentionAgentIds(body),
createdAt,
});
messages.push(message);
this.messagesByRoomId.set(room.id, messages);
this.rooms.set(
room.id,
ChatRoomSchema.parse({
...room,
updatedAt: createdAt,
}),
);
await this.enqueuePersist();
this.notifyWaiters(room.id);
return message;
}
async readMessages(input: ReadChatMessagesInput): Promise<ChatMessage[]> {
await this.load();
const room = this.resolveRoom(input.room);
const messages = [...this.getRoomMessages(room.id)];
const since = trimToNull(input.since);
const authorAgentId = trimToNull(input.authorAgentId);
const limit = this.normalizeLimit(input.limit);
const filtered = messages.filter((message) => {
if (since && message.createdAt < since) {
return false;
}
if (authorAgentId && message.authorAgentId !== authorAgentId) {
return false;
}
return true;
});
if (limit === 0 || filtered.length <= limit) {
return filtered;
}
return filtered.slice(filtered.length - limit);
}
async waitForMessages(input: WaitForChatMessagesInput): Promise<ChatMessage[]> {
await this.load();
const room = this.resolveRoom(input.room);
const timeoutMs = Math.max(0, Math.floor(input.timeoutMs ?? 0));
const afterMessageId = trimToNull(input.afterMessageId);
if (afterMessageId) {
const existing = this.selectMessagesAfter(room.id, afterMessageId);
if (existing.length > 0) {
return existing;
}
const knownMessage = this.getRoomMessages(room.id).some((message) => message.id === afterMessageId);
if (!knownMessage) {
throw new ChatServiceError(
"chat_message_not_found",
`Wait cursor not found: ${afterMessageId}`,
);
}
}
return new Promise<ChatMessage[]>((resolve, reject) => {
const waiter: Waiter = {
roomId: room.id,
afterMessageId,
resolve: (messages) => {
if (waiter.timeout) {
clearTimeout(waiter.timeout);
waiter.timeout = null;
}
this.removeWaiter(waiter);
resolve(messages);
},
reject: (error) => {
if (waiter.timeout) {
clearTimeout(waiter.timeout);
waiter.timeout = null;
}
this.removeWaiter(waiter);
reject(error);
},
timeout: null,
};
if (timeoutMs > 0) {
waiter.timeout = setTimeout(() => {
waiter.resolve([]);
}, timeoutMs);
}
const roomWaiters = this.waitersByRoomId.get(room.id) ?? new Set<Waiter>();
roomWaiters.add(waiter);
this.waitersByRoomId.set(room.id, roomWaiters);
});
}
private async load(): Promise<void> {
if (this.loaded) {
return;
}
this.rooms.clear();
this.messagesByRoomId.clear();
try {
const raw = await fs.readFile(this.filePath, "utf8");
const parsed = ChatStorePayloadSchema.parse(JSON.parse(raw));
for (const room of parsed.rooms) {
this.rooms.set(room.id, room);
}
for (const message of parsed.messages) {
const messages = this.messagesByRoomId.get(message.roomId) ?? [];
messages.push(message);
this.messagesByRoomId.set(message.roomId, messages);
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
this.logger.error({ err: error, filePath: this.filePath }, "Failed to load chat store");
}
}
this.loaded = true;
}
private async enqueuePersist(): Promise<void> {
const nextPersist = this.persistQueue.then(() => this.persist());
this.persistQueue = nextPersist.catch(() => {});
await nextPersist;
}
private async persist(): Promise<void> {
const payload: ChatStorePayload = {
rooms: Array.from(this.rooms.values()).sort((left, right) => left.createdAt.localeCompare(right.createdAt)),
messages: Array.from(this.messagesByRoomId.values())
.flat()
.sort((left, right) => left.createdAt.localeCompare(right.createdAt)),
};
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
await fs.writeFile(tempPath, JSON.stringify(payload, null, 2), "utf8");
await fs.rename(tempPath, this.filePath);
}
private findRoomByName(name: string): ChatRoom | null {
const normalizedName = normalizeRoomName(name);
for (const room of this.rooms.values()) {
if (normalizeRoomName(room.name) === normalizedName) {
return room;
}
}
return null;
}
private resolveRoom(roomSelector: string): ChatRoom {
const selector = roomSelector.trim();
if (selector.length === 0) {
throw new ChatServiceError("invalid_chat_room", "Chat room name or ID is required");
}
const byId = this.rooms.get(selector);
if (byId) {
return byId;
}
const byName = this.findRoomByName(selector);
if (byName) {
return byName;
}
throw new ChatServiceError("chat_room_not_found", `Chat room not found: ${selector}`);
}
private getRoomMessages(roomId: string): ChatMessage[] {
return this.messagesByRoomId.get(roomId) ?? [];
}
private toRoomDetail(room: ChatRoom): ChatRoomDetail {
const messages = this.getRoomMessages(room.id);
return ChatRoomDetailSchema.parse({
...room,
messageCount: messages.length,
lastMessageAt: messages[messages.length - 1]?.createdAt ?? null,
});
}
private normalizeLimit(limit: number | undefined): number {
if (limit === undefined) {
return 20;
}
const normalized = Math.max(0, Math.floor(limit));
return normalized;
}
private selectMessagesAfter(roomId: string, afterMessageId: string): ChatMessage[] {
const messages = this.getRoomMessages(roomId);
const index = messages.findIndex((message) => message.id === afterMessageId);
if (index === -1) {
return [];
}
return messages.slice(index + 1);
}
private notifyWaiters(roomId: string): void {
const waiters = this.waitersByRoomId.get(roomId);
if (!waiters || waiters.size === 0) {
return;
}
for (const waiter of [...waiters]) {
const messages =
waiter.afterMessageId === null
? this.getRoomMessages(roomId).slice(-1)
: this.selectMessagesAfter(roomId, waiter.afterMessageId);
if (messages.length === 0) {
continue;
}
waiter.resolve(messages);
}
}
private removeWaiter(waiter: Waiter): void {
const waiters = this.waitersByRoomId.get(waiter.roomId);
if (!waiters) {
return;
}
waiters.delete(waiter);
if (waiters.size === 0) {
this.waitersByRoomId.delete(waiter.roomId);
}
}
private rejectWaiters(roomId: string, error: Error): void {
const waiters = this.waitersByRoomId.get(roomId);
if (!waiters) {
return;
}
for (const waiter of [...waiters]) {
waiter.reject(error);
}
}
}
@@ -1,30 +0,0 @@
import { z } from "zod";
export const ChatRoomSchema = z.object({
id: z.string(),
name: z.string(),
purpose: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
});
export type ChatRoom = z.infer<typeof ChatRoomSchema>;
export const ChatMessageSchema = z.object({
id: z.string(),
roomId: z.string(),
authorAgentId: z.string(),
body: z.string(),
replyToMessageId: z.string().nullable(),
mentionAgentIds: z.array(z.string()),
createdAt: z.string(),
});
export type ChatMessage = z.infer<typeof ChatMessageSchema>;
export const ChatRoomDetailSchema = ChatRoomSchema.extend({
messageCount: z.number().int().nonnegative(),
lastMessageAt: z.string().nullable(),
});
export type ChatRoomDetail = z.infer<typeof ChatRoomDetailSchema>;
@@ -1,480 +0,0 @@
import os from "node:os";
import path from "node:path";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { beforeEach, afterEach, describe, expect, test } from "vitest";
import type {
AgentCapabilityFlags,
AgentClient,
AgentLaunchContext,
AgentMode,
AgentModelDefinition,
AgentPersistenceHandle,
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentSession,
AgentSessionConfig,
AgentStreamEvent,
AgentSlashCommand,
AgentRuntimeInfo,
ListModelsOptions,
AgentProvider,
} from "./agent/agent-sdk-types.js";
import { AgentStorage } from "./agent/agent-storage.js";
import { AgentManager } from "./agent/agent-manager.js";
import { LoopService } from "./loop-service.js";
import { createTestLogger } from "../test-utils/test-logger.js";
const TEST_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
};
interface ScriptedAgentBehavior {
onRun(input: { config: AgentSessionConfig; prompt: string; turnId: string }): Promise<string>;
}
class ScriptedAgentClient implements AgentClient {
readonly provider: AgentProvider;
readonly capabilities = TEST_CAPABILITIES;
constructor(
provider: AgentProvider,
private readonly behavior: ScriptedAgentBehavior,
) {
this.provider = provider;
}
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(
config: AgentSessionConfig,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
return new ScriptedAgentSession(config, this.provider, this.behavior);
}
async resumeSession(
_handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
return new ScriptedAgentSession(
{
provider: this.provider,
cwd: overrides?.cwd ?? process.cwd(),
...overrides,
},
this.provider,
this.behavior,
);
}
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
return [];
}
}
class ScriptedAgentSession implements AgentSession {
readonly capabilities = TEST_CAPABILITIES;
readonly id = randomUUID();
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private turnCount = 0;
private interrupted = false;
constructor(
private readonly config: AgentSessionConfig,
readonly provider: AgentProvider,
private readonly behavior: ScriptedAgentBehavior,
) {}
async run(): Promise<AgentRunResult> {
return {
sessionId: this.id,
finalText: "",
timeline: [],
};
}
async startTurn(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<{ turnId: string }> {
const promptText = typeof prompt === "string" ? prompt : JSON.stringify(prompt);
const turnId = `turn-${++this.turnCount}`;
this.interrupted = false;
setTimeout(() => {
void this.runScript(promptText, turnId);
}, 0);
return { turnId };
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
return {
provider: this.provider,
sessionId: this.id,
model: this.config.model ?? null,
modeId: this.config.modeId ?? null,
};
}
async getAvailableModes(): Promise<AgentMode[]> {
return [];
}
async getCurrentMode(): Promise<string | null> {
return this.config.modeId ?? null;
}
async setMode(): Promise<void> {}
getPendingPermissions() {
return [];
}
async respondToPermission(): Promise<void> {}
describePersistence(): AgentPersistenceHandle {
return {
provider: this.provider,
sessionId: this.id,
};
}
async interrupt(): Promise<void> {
this.interrupted = true;
}
async close(): Promise<void> {}
async listCommands(): Promise<AgentSlashCommand[]> {
return [];
}
private emit(event: AgentStreamEvent): void {
for (const subscriber of this.subscribers) {
subscriber(event);
}
}
private async runScript(prompt: string, turnId: string): Promise<void> {
this.emit({ type: "turn_started", provider: this.provider, turnId });
if (this.interrupted) {
this.emit({ type: "turn_canceled", provider: this.provider, reason: "interrupted", turnId });
return;
}
try {
const responseText = await this.behavior.onRun({
config: this.config,
prompt,
turnId,
});
if (this.interrupted) {
this.emit({ type: "turn_canceled", provider: this.provider, reason: "interrupted", turnId });
return;
}
this.emit({
type: "timeline",
provider: this.provider,
turnId,
item: { type: "assistant_message", text: responseText },
});
this.emit({ type: "turn_completed", provider: this.provider, turnId });
} catch (error) {
this.emit({
type: "turn_failed",
provider: this.provider,
turnId,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
describe("LoopService", () => {
const logger = createTestLogger();
let tmpDir: string;
let paseoHome: string;
let workspaceDir: string;
let storage: AgentStorage;
beforeEach(() => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "loop-service-"));
paseoHome = path.join(tmpDir, "paseo-home");
workspaceDir = path.join(tmpDir, "workspace");
storage = new AgentStorage(path.join(tmpDir, "agents"), logger);
mkdirSync(workspaceDir, { recursive: true });
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
test("runs fresh worker agents until verify-check passes", async () => {
const state = { workerRuns: 0 };
const manager = new AgentManager({
clients: {
claude: new ScriptedAgentClient("claude", {
async onRun({ config }) {
state.workerRuns += 1;
if (config.title?.includes("worker") && state.workerRuns >= 2) {
writeFileSync(path.join(workspaceDir, "done.txt"), "ok");
}
if (config.title?.includes("worker")) {
return `worker run ${state.workerRuns}`;
}
return "{\"passed\":true,\"reason\":\"not used\"}";
},
}),
},
registry: storage,
logger,
});
const service = new LoopService({ paseoHome, agentManager: manager, logger });
await service.initialize();
const loop = await service.runLoop({
prompt: "Create done.txt when the task is actually fixed.",
cwd: workspaceDir,
verifyChecks: ["test -f done.txt"],
sleepMs: 1,
maxIterations: 3,
});
await waitForLoopCompletion(service, loop.id);
const finalLoop = await service.inspectLoop(loop.id);
expect(finalLoop.status).toBe("succeeded");
expect(finalLoop.iterations).toHaveLength(2);
expect(finalLoop.iterations[0]?.workerAgentId).not.toBe(finalLoop.iterations[1]?.workerAgentId);
expect(finalLoop.iterations[0]?.status).toBe("failed");
expect(finalLoop.iterations[1]?.status).toBe("succeeded");
expect(finalLoop.iterations[0]?.verifyChecks[0]?.passed).toBe(false);
expect(finalLoop.iterations[1]?.verifyChecks[0]?.passed).toBe(true);
expect(readFileSync(path.join(paseoHome, "loops", "loops.json"), "utf8")).toContain(loop.id);
});
test("uses worker and verifier provider-model settings when provided", async () => {
const workerConfigs: AgentSessionConfig[] = [];
const verifierConfigs: AgentSessionConfig[] = [];
const manager = new AgentManager({
clients: {
codex: new ScriptedAgentClient("codex", {
async onRun({ config }) {
workerConfigs.push(config);
writeFileSync(path.join(workspaceDir, "done.txt"), "ok");
return "done";
},
}),
claude: new ScriptedAgentClient("claude", {
async onRun({ config }) {
verifierConfigs.push(config);
return "{\"passed\":true,\"reason\":\"verified\"}";
},
}),
},
registry: storage,
logger,
});
const service = new LoopService({ paseoHome, agentManager: manager, logger });
await service.initialize();
const loop = await service.runLoop({
prompt: "Create done.txt",
cwd: workspaceDir,
provider: "codex",
model: "fallback-model",
workerModel: "gpt-5.4",
verifyPrompt: "Confirm that done.txt exists in the workspace.",
verifierProvider: "claude",
verifierModel: "sonnet",
maxIterations: 1,
});
await waitForLoopCompletion(service, loop.id);
const finalLoop = await service.inspectLoop(loop.id);
expect(finalLoop.status).toBe("succeeded");
expect(finalLoop.provider).toBe("codex");
expect(finalLoop.model).toBe("fallback-model");
expect(finalLoop.workerProvider).toBeNull();
expect(finalLoop.workerModel).toBe("gpt-5.4");
expect(finalLoop.verifierProvider).toBe("claude");
expect(finalLoop.verifierModel).toBe("sonnet");
expect(workerConfigs).toHaveLength(1);
expect(workerConfigs[0]).toMatchObject({
provider: "codex",
model: "gpt-5.4",
internal: true,
});
expect(verifierConfigs).toHaveLength(1);
expect(verifierConfigs[0]).toMatchObject({
provider: "claude",
model: "sonnet",
internal: true,
});
});
test("archives worker and verifier agents after each iteration when requested", async () => {
const archivedAgentIds: string[] = [];
const manager = new AgentManager({
clients: {
claude: new ScriptedAgentClient("claude", {
async onRun({ config }) {
if (config.title?.includes("worker")) {
writeFileSync(path.join(workspaceDir, "done.txt"), "ok");
return "created done.txt";
}
return "{\"passed\":true,\"reason\":\"done.txt exists\"}";
},
}),
},
registry: storage,
logger,
});
const archiveAgent = manager.archiveAgent.bind(manager);
manager.archiveAgent = async (agentId) => {
archivedAgentIds.push(agentId);
await archiveAgent(agentId);
};
const service = new LoopService({ paseoHome, agentManager: manager, logger });
await service.initialize();
const loop = await service.runLoop({
prompt: "Create done.txt",
cwd: workspaceDir,
verifyPrompt: "Confirm that done.txt exists in the workspace.",
archive: true,
maxIterations: 1,
});
await waitForLoopCompletion(service, loop.id);
const finalLoop = await service.inspectLoop(loop.id);
const iteration = finalLoop.iterations[0];
expect(finalLoop.archive).toBe(true);
expect(iteration?.workerAgentId).toBeTruthy();
expect(iteration?.verifierAgentId).toBeTruthy();
expect(archivedAgentIds).toEqual([iteration!.workerAgentId!, iteration!.verifierAgentId!]);
await storage.flush();
await expect(storage.get(iteration!.workerAgentId!)).resolves.toMatchObject({
id: iteration!.workerAgentId!,
archivedAt: expect.any(String),
internal: true,
});
await expect(storage.get(iteration!.verifierAgentId!)).resolves.toMatchObject({
id: iteration!.verifierAgentId!,
archivedAt: expect.any(String),
internal: true,
});
});
test("uses verifier prompt when provided", async () => {
const manager = new AgentManager({
clients: {
claude: new ScriptedAgentClient("claude", {
async onRun({ config }) {
if (config.title?.includes("worker")) {
await fsMkdir(workspaceDir);
writeFileSync(path.join(workspaceDir, "done.txt"), "ok");
return "created done.txt";
}
const exists = pathExists(path.join(workspaceDir, "done.txt"));
return exists
? "{\"passed\":true,\"reason\":\"done.txt exists\"}"
: "{\"passed\":false,\"reason\":\"done.txt missing\"}";
},
}),
},
registry: storage,
logger,
});
const service = new LoopService({ paseoHome, agentManager: manager, logger });
await service.initialize();
const loop = await service.runLoop({
prompt: "Create done.txt",
cwd: workspaceDir,
verifyPrompt: "Confirm that done.txt exists in the workspace.",
maxIterations: 1,
});
await waitForLoopCompletion(service, loop.id);
const finalLoop = await service.inspectLoop(loop.id);
expect(finalLoop.status).toBe("succeeded");
expect(finalLoop.iterations[0]?.verifyPrompt).toMatchObject({
passed: true,
reason: "done.txt exists",
});
const logs = await service.getLoopLogs(loop.id);
expect(logs.entries.some((entry) => entry.text.includes("Verifier result"))).toBe(true);
});
test("stops a running loop and cancels the active worker", async () => {
let release: (() => void) | null = null;
const blocker = new Promise<void>((resolve) => {
release = resolve;
});
const manager = new AgentManager({
clients: {
claude: new ScriptedAgentClient("claude", {
async onRun({ config }) {
if (config.title?.includes("worker")) {
await blocker;
return "finished";
}
return "{\"passed\":true,\"reason\":\"ok\"}";
},
}),
},
registry: storage,
logger,
});
const service = new LoopService({ paseoHome, agentManager: manager, logger });
await service.initialize();
const loop = await service.runLoop({
prompt: "Wait forever",
cwd: workspaceDir,
verifyChecks: ["test -f never.txt"],
});
await new Promise((resolve) => setTimeout(resolve, 25));
const stopped = await service.stopLoop(loop.id);
release?.();
expect(stopped.status).toBe("stopped");
const finalLoop = await service.inspectLoop(loop.id);
expect(finalLoop.status).toBe("stopped");
expect(finalLoop.iterations[0]?.status).toBe("stopped");
expect(finalLoop.logs.some((entry) => entry.text.includes("Stop requested"))).toBe(true);
});
});
async function fsMkdir(target: string): Promise<void> {
await import("node:fs/promises").then(({ mkdir }) => mkdir(target, { recursive: true }));
}
function pathExists(target: string): boolean {
return existsSync(target);
}
async function waitForLoopCompletion(service: LoopService, loopId: string): Promise<void> {
while ((await service.inspectLoop(loopId)).status === "running") {
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
-876
View File
@@ -1,876 +0,0 @@
import { randomUUID } from "node:crypto";
import { execFile } from "node:child_process";
import { promises as fs } from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import { z } from "zod";
import type { Logger } from "pino";
import { curateAgentActivity } from "./agent/activity-curator.js";
import type { AgentManager } from "./agent/agent-manager.js";
import { getStructuredAgentResponse } from "./agent/agent-response-loop.js";
import type {
AgentPromptInput,
AgentSessionConfig,
AgentStreamEvent,
AgentTimelineItem,
AgentProvider,
} from "./agent/agent-sdk-types.js";
const execFileAsync = promisify(execFile);
const LOOP_ID_LENGTH = 8;
const DEFAULT_LOOP_PROVIDER: AgentProvider = "claude";
const MAX_VERIFY_OUTPUT_BYTES = 64 * 1024;
const LoopVerifyPromptSchema = z.object({
passed: z.boolean(),
reason: z.string().min(1),
});
const LoopLogEntrySchema = z.object({
seq: z.number().int().positive(),
timestamp: z.string(),
iteration: z.number().int().positive().nullable(),
source: z.enum(["loop", "worker", "verifier", "verify-check"]),
level: z.enum(["info", "error"]),
text: z.string(),
});
const LoopVerifyCheckResultSchema = z.object({
command: z.string(),
exitCode: z.number().int(),
passed: z.boolean(),
stdout: z.string(),
stderr: z.string(),
startedAt: z.string(),
completedAt: z.string(),
});
const LoopVerifyPromptResultSchema = z.object({
passed: z.boolean(),
reason: z.string(),
verifierAgentId: z.string().nullable(),
startedAt: z.string(),
completedAt: z.string(),
});
const LoopIterationRecordSchema = z.object({
index: z.number().int().positive(),
workerAgentId: z.string().nullable(),
workerStartedAt: z.string(),
workerCompletedAt: z.string().nullable(),
verifierAgentId: z.string().nullable(),
status: z.enum(["running", "succeeded", "failed", "stopped"]),
workerOutcome: z.enum(["completed", "failed", "canceled"]).nullable(),
failureReason: z.string().nullable(),
verifyChecks: z.array(LoopVerifyCheckResultSchema),
verifyPrompt: LoopVerifyPromptResultSchema.nullable(),
});
const LoopRecordSchema = z.object({
id: z.string(),
name: z.string().nullable(),
prompt: z.string(),
cwd: z.string(),
provider: z.enum(["claude", "codex", "opencode"]),
model: z.string().nullable(),
workerProvider: z.enum(["claude", "codex", "opencode"]).nullable(),
workerModel: z.string().nullable(),
verifierProvider: z.enum(["claude", "codex", "opencode"]).nullable(),
verifierModel: z.string().nullable(),
verifyPrompt: z.string().nullable(),
verifyChecks: z.array(z.string()),
archive: z.boolean(),
sleepMs: z.number().int().nonnegative(),
maxIterations: z.number().int().positive().nullable(),
maxTimeMs: z.number().int().positive().nullable(),
status: z.enum(["running", "succeeded", "failed", "stopped"]),
createdAt: z.string(),
updatedAt: z.string(),
startedAt: z.string(),
completedAt: z.string().nullable(),
stopRequestedAt: z.string().nullable(),
iterations: z.array(LoopIterationRecordSchema),
logs: z.array(LoopLogEntrySchema),
nextLogSeq: z.number().int().positive(),
activeIteration: z.number().int().positive().nullable(),
activeWorkerAgentId: z.string().nullable(),
activeVerifierAgentId: z.string().nullable(),
});
const StoredLoopsSchema = z.array(LoopRecordSchema);
export type LoopStatus = z.infer<typeof LoopRecordSchema>["status"];
export type LoopLogEntry = z.infer<typeof LoopLogEntrySchema>;
export type LoopVerifyCheckResult = z.infer<typeof LoopVerifyCheckResultSchema>;
export type LoopVerifyPromptResult = z.infer<typeof LoopVerifyPromptResultSchema>;
export type LoopIterationRecord = z.infer<typeof LoopIterationRecordSchema>;
export type LoopRecord = z.infer<typeof LoopRecordSchema>;
export interface LoopRunOptions {
prompt: string;
cwd: string;
provider?: AgentProvider;
model?: string;
workerProvider?: AgentProvider;
workerModel?: string;
verifierProvider?: AgentProvider;
verifierModel?: string;
verifyPrompt?: string;
verifyChecks?: string[];
archive?: boolean;
name?: string;
sleepMs?: number;
maxIterations?: number;
maxTimeMs?: number;
}
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;
}
interface RunningLoopState {
abortController: AbortController;
promise: Promise<void>;
}
function nowIso(): string {
return new Date().toISOString();
}
function cloneLoop(record: LoopRecord): LoopRecord {
return LoopRecordSchema.parse(record);
}
function createLoopId(): string {
return randomUUID().replace(/-/g, "").slice(0, LOOP_ID_LENGTH);
}
function normalizeName(name: string | undefined): string | null {
const trimmed = name?.trim();
return trimmed ? trimmed : null;
}
function normalizePrompt(value: string | undefined, field: string): string | null {
if (value === undefined) {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
throw new Error(`${field} cannot be empty`);
}
return trimmed;
}
function ensurePositiveInteger(value: number | undefined, field: string): number | null {
if (value === undefined) {
return null;
}
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${field} must be a positive integer`);
}
return value;
}
function ensureNonNegativeInteger(value: number | undefined, field: string): number {
if (value === undefined) {
return 0;
}
if (!Number.isInteger(value) || value < 0) {
throw new Error(`${field} must be a non-negative integer`);
}
return value;
}
function buildWorkerTitle(loop: LoopRecord, iterationIndex: number): string {
const prefix = loop.name ?? loop.id;
return `${prefix} [loop ${iterationIndex} worker]`;
}
function buildVerifierTitle(loop: LoopRecord, iterationIndex: number): string {
const prefix = loop.name ?? loop.id;
return `${prefix} [loop ${iterationIndex} verifier]`;
}
function formatStreamLog(event: AgentStreamEvent): string | null {
switch (event.type) {
case "timeline": {
const rendered = curateAgentActivity([event.item]);
return rendered === "No activity to display." ? null : rendered;
}
case "turn_failed":
return `[Turn Failed] ${event.error}`;
case "turn_canceled":
return `[Turn Canceled] ${event.reason}`;
case "permission_requested":
return `[Permission Requested] ${event.request.name}`;
case "permission_resolved":
return `[Permission Resolved] ${event.requestId}`;
default:
return null;
}
}
async function sleepWithAbort(ms: number, signal: AbortSignal): Promise<void> {
if (ms <= 0) {
return;
}
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
resolve();
}, ms);
const onAbort = () => {
cleanup();
reject(signal.reason instanceof Error ? signal.reason : new Error("Loop aborted"));
};
const cleanup = () => {
clearTimeout(timer);
signal.removeEventListener("abort", onAbort);
};
signal.addEventListener("abort", onAbort, { once: true });
});
}
function isAbortError(error: unknown): boolean {
if (error instanceof Error && error.message === "Loop aborted") {
return true;
}
return false;
}
async function runVerifyCheck(options: {
cwd: string;
command: string;
}): Promise<LoopVerifyCheckResult> {
const startedAt = nowIso();
try {
const result = await execFileAsync("/bin/zsh", ["-lc", options.command], {
cwd: options.cwd,
maxBuffer: MAX_VERIFY_OUTPUT_BYTES,
});
return {
command: options.command,
exitCode: 0,
passed: true,
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
startedAt,
completedAt: nowIso(),
};
} catch (error) {
const childError = error as Error & {
code?: number | string;
stdout?: string;
stderr?: string;
};
return {
command: options.command,
exitCode: typeof childError.code === "number" ? childError.code : 1,
passed: false,
stdout: childError.stdout ?? "",
stderr: childError.stderr ?? "",
startedAt,
completedAt: nowIso(),
};
}
}
export class LoopService {
private readonly storePath: string;
private readonly logger: Logger;
private loaded = false;
private readonly loops = new Map<string, LoopRecord>();
private persistQueue: Promise<void> = Promise.resolve();
private readonly running = new Map<string, RunningLoopState>();
constructor(
private readonly options: {
paseoHome: string;
agentManager: AgentManager;
logger: Logger;
},
) {
this.storePath = path.join(options.paseoHome, "loops", "loops.json");
this.logger = options.logger.child({ module: "loop-service" });
}
async initialize(): Promise<void> {
if (this.loaded) {
return;
}
this.loops.clear();
try {
const raw = await fs.readFile(this.storePath, "utf8");
const parsed = StoredLoopsSchema.parse(JSON.parse(raw));
for (const record of parsed) {
if (record.status === "running") {
const recovered = cloneLoop(record);
recovered.status = "stopped";
recovered.updatedAt = nowIso();
recovered.completedAt = recovered.updatedAt;
recovered.stopRequestedAt = recovered.updatedAt;
recovered.activeIteration = null;
recovered.activeWorkerAgentId = null;
recovered.activeVerifierAgentId = null;
this.appendLog(recovered, {
iteration: null,
source: "loop",
level: "error",
text: "Loop was interrupted by daemon restart.",
});
const lastIteration = recovered.iterations.at(-1);
if (lastIteration && lastIteration.status === "running") {
lastIteration.status = "stopped";
lastIteration.failureReason = "Daemon restarted";
lastIteration.workerCompletedAt = recovered.updatedAt;
}
this.loops.set(recovered.id, recovered);
continue;
}
this.loops.set(record.id, record);
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
this.logger.error({ err: error, storePath: this.storePath }, "Failed to load loops");
}
}
this.loaded = true;
await this.persist();
}
async runLoop(input: LoopRunOptions): Promise<LoopRecord> {
await this.initialize();
const prompt = normalizePrompt(input.prompt, "prompt");
if (!prompt) {
throw new Error("prompt cannot be empty");
}
const verifyPrompt = normalizePrompt(input.verifyPrompt, "verifyPrompt");
const verifyChecks = (input.verifyChecks ?? []).map((command) => {
const trimmed = command.trim();
if (!trimmed) {
throw new Error("verifyChecks cannot contain empty commands");
}
return trimmed;
});
if (!verifyPrompt && verifyChecks.length === 0) {
throw new Error("Loop requires --verify or at least one --verify-check");
}
const createdAt = nowIso();
const record = LoopRecordSchema.parse({
id: createLoopId(),
name: normalizeName(input.name),
prompt,
cwd: path.resolve(input.cwd),
provider: input.provider ?? DEFAULT_LOOP_PROVIDER,
model: normalizePrompt(input.model, "model"),
workerProvider: input.workerProvider ?? null,
workerModel: normalizePrompt(input.workerModel, "workerModel"),
verifierProvider: input.verifierProvider ?? null,
verifierModel: normalizePrompt(input.verifierModel, "verifierModel"),
verifyPrompt,
verifyChecks,
archive: input.archive ?? false,
sleepMs: ensureNonNegativeInteger(input.sleepMs, "sleepMs"),
maxIterations: ensurePositiveInteger(input.maxIterations, "maxIterations"),
maxTimeMs: ensurePositiveInteger(input.maxTimeMs, "maxTimeMs"),
status: "running",
createdAt,
updatedAt: createdAt,
startedAt: createdAt,
completedAt: null,
stopRequestedAt: null,
iterations: [],
logs: [],
nextLogSeq: 1,
activeIteration: null,
activeWorkerAgentId: null,
activeVerifierAgentId: null,
});
this.loops.set(record.id, record);
this.appendLog(record, {
iteration: null,
source: "loop",
level: "info",
text: `Loop created in ${record.cwd}`,
});
await this.persist();
const abortController = new AbortController();
const promise = this.executeLoop(record.id, abortController.signal).finally(() => {
this.running.delete(record.id);
});
this.running.set(record.id, { abortController, promise });
return cloneLoop(record);
}
async listLoops(): Promise<LoopListItem[]> {
await this.initialize();
return Array.from(this.loops.values())
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
.map((record) => ({
id: record.id,
name: record.name,
status: record.status,
cwd: record.cwd,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
activeIteration: record.activeIteration,
}));
}
async inspectLoop(idOrPrefix: string): Promise<LoopRecord> {
await this.initialize();
const loop = this.requireLoop(idOrPrefix);
return cloneLoop(loop);
}
async getLoopLogs(idOrPrefix: string, afterSeq = 0): Promise<LoopLogsResult> {
await this.initialize();
const loop = this.requireLoop(idOrPrefix);
const entries = loop.logs.filter((entry) => entry.seq > afterSeq);
return {
loop: cloneLoop(loop),
entries: entries.map((entry) => LoopLogEntrySchema.parse(entry)),
nextCursor: loop.nextLogSeq - 1,
};
}
async stopLoop(idOrPrefix: string): Promise<LoopRecord> {
await this.initialize();
const loop = this.requireLoop(idOrPrefix);
if (loop.status !== "running") {
return cloneLoop(loop);
}
const running = this.running.get(loop.id);
loop.stopRequestedAt = loop.stopRequestedAt ?? nowIso();
loop.updatedAt = loop.stopRequestedAt;
this.appendLog(loop, {
iteration: loop.activeIteration,
source: "loop",
level: "info",
text: "Stop requested.",
});
await this.persist();
if (running) {
running.abortController.abort(new Error("Loop aborted"));
if (loop.activeWorkerAgentId) {
await this.options.agentManager.cancelAgentRun(loop.activeWorkerAgentId).catch(() => {});
}
if (loop.activeVerifierAgentId) {
await this.options.agentManager.cancelAgentRun(loop.activeVerifierAgentId).catch(() => {});
}
await running.promise.catch(() => {});
} else {
loop.status = "stopped";
loop.completedAt = nowIso();
loop.updatedAt = loop.completedAt;
await this.persist();
}
return cloneLoop(loop);
}
private async executeLoop(loopId: string, signal: AbortSignal): Promise<void> {
const loop = this.requireLoop(loopId);
const deadline = loop.maxTimeMs ? Date.now() + loop.maxTimeMs : null;
try {
for (let index = 1; ; index += 1) {
if (signal.aborted) {
throw new Error("Loop aborted");
}
if (loop.maxIterations && index > loop.maxIterations) {
this.finishLoop(loop, "failed", `Reached max iterations (${loop.maxIterations}).`);
return;
}
if (deadline !== null && Date.now() > deadline) {
this.finishLoop(loop, "failed", `Reached max time (${loop.maxTimeMs}ms).`);
return;
}
const iteration = LoopIterationRecordSchema.parse({
index,
workerAgentId: null,
workerStartedAt: nowIso(),
workerCompletedAt: null,
verifierAgentId: null,
status: "running",
workerOutcome: null,
failureReason: null,
verifyChecks: [],
verifyPrompt: null,
});
loop.iterations.push(iteration);
loop.activeIteration = index;
loop.updatedAt = nowIso();
this.appendLog(loop, {
iteration: index,
source: "loop",
level: "info",
text: `Starting iteration ${index}.`,
});
await this.persist();
const workerPassed = await this.runWorkerIteration(loop, iteration, signal);
if (signal.aborted) {
throw new Error("Loop aborted");
}
if (!workerPassed) {
iteration.status = iteration.status === "stopped" ? "stopped" : "failed";
} else {
const verificationPassed = await this.runVerification(loop, iteration, signal);
if (verificationPassed) {
iteration.status = "succeeded";
this.finishLoop(loop, "succeeded", `Iteration ${index} passed verification.`);
return;
}
if (iteration.status === "running") {
iteration.status = "failed";
}
}
loop.activeIteration = null;
loop.activeWorkerAgentId = null;
loop.activeVerifierAgentId = null;
loop.updatedAt = nowIso();
await this.persist();
if (loop.sleepMs > 0) {
this.appendLog(loop, {
iteration: index,
source: "loop",
level: "info",
text: `Sleeping ${loop.sleepMs}ms before next iteration.`,
});
await this.persist();
await sleepWithAbort(loop.sleepMs, signal);
}
}
} catch (error) {
if (isAbortError(error)) {
this.finishLoop(loop, "stopped", "Loop stopped.");
const iteration = loop.activeIteration
? loop.iterations.find((candidate) => candidate.index === loop.activeIteration)
: null;
if (iteration && iteration.status === "running") {
iteration.status = "stopped";
iteration.failureReason = "Loop stopped";
iteration.workerCompletedAt = nowIso();
}
await this.persist();
return;
}
const message = error instanceof Error ? error.message : String(error);
this.logger.error({ err: error, loopId }, "Loop execution failed");
this.finishLoop(loop, "failed", message);
const iteration = loop.activeIteration
? loop.iterations.find((candidate) => candidate.index === loop.activeIteration)
: null;
if (iteration && iteration.status === "running") {
iteration.status = "failed";
iteration.failureReason = message;
iteration.workerCompletedAt = nowIso();
}
await this.persist();
}
}
private async runWorkerIteration(
loop: LoopRecord,
iteration: LoopIterationRecord,
signal: AbortSignal,
): Promise<boolean> {
const agent = await this.options.agentManager.createAgent(this.buildWorkerConfig(loop, iteration));
iteration.workerAgentId = agent.id;
loop.activeWorkerAgentId = agent.id;
loop.updatedAt = nowIso();
await this.persist();
const unsubscribe = this.options.agentManager.subscribe(
(event) => {
if (event.type !== "agent_stream") {
return;
}
const text = formatStreamLog(event.event);
if (!text) {
return;
}
this.appendLog(loop, {
iteration: iteration.index,
source: "worker",
level: event.event.type === "turn_failed" ? "error" : "info",
text,
});
void this.persist();
},
{ agentId: agent.id, replayState: false },
);
try {
const prompt = this.toPrompt(loop.prompt);
const result = await this.options.agentManager.runAgent(agent.id, prompt);
iteration.workerCompletedAt = nowIso();
iteration.workerOutcome = result.canceled ? "canceled" : "completed";
if (result.canceled) {
iteration.failureReason = "Worker run was canceled.";
iteration.status = "stopped";
return false;
}
return true;
} catch (error) {
iteration.workerCompletedAt = nowIso();
iteration.workerOutcome = "failed";
iteration.failureReason = error instanceof Error ? error.message : String(error);
this.appendLog(loop, {
iteration: iteration.index,
source: "loop",
level: "error",
text: `Worker failed: ${iteration.failureReason}`,
});
return false;
} finally {
unsubscribe();
loop.activeWorkerAgentId = null;
loop.updatedAt = nowIso();
await this.persist();
try {
if (loop.archive) {
await this.options.agentManager.archiveAgent(agent.id);
} else {
await this.options.agentManager.closeAgent(agent.id);
}
} catch {
// Ignore cleanup errors for internal loop workers.
}
if (signal.aborted && iteration.status === "running") {
iteration.status = "stopped";
}
}
}
private async runVerification(
loop: LoopRecord,
iteration: LoopIterationRecord,
signal: AbortSignal,
): Promise<boolean> {
for (const command of loop.verifyChecks) {
if (signal.aborted) {
throw new Error("Loop aborted");
}
this.appendLog(loop, {
iteration: iteration.index,
source: "verify-check",
level: "info",
text: `$ ${command}`,
});
const result = await runVerifyCheck({ cwd: loop.cwd, command });
iteration.verifyChecks.push(result);
const output = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join("\n");
this.appendLog(loop, {
iteration: iteration.index,
source: "verify-check",
level: result.passed ? "info" : "error",
text: output
? `exit ${result.exitCode}\n${output}`
: `exit ${result.exitCode}`,
});
loop.updatedAt = nowIso();
await this.persist();
if (!result.passed) {
iteration.failureReason = `Verify check failed: ${command}`;
return false;
}
}
if (!loop.verifyPrompt) {
return true;
}
const startedAt = nowIso();
const verifierAgent = await this.options.agentManager.createAgent(
this.buildVerifierConfig(loop, iteration),
);
iteration.verifierAgentId = verifierAgent.id;
loop.activeVerifierAgentId = verifierAgent.id;
loop.updatedAt = nowIso();
await this.persist();
const unsubscribe = this.options.agentManager.subscribe(
(event) => {
if (event.type !== "agent_stream") {
return;
}
const text = formatStreamLog(event.event);
if (!text) {
return;
}
this.appendLog(loop, {
iteration: iteration.index,
source: "verifier",
level: event.event.type === "turn_failed" ? "error" : "info",
text,
});
void this.persist();
},
{ agentId: verifierAgent.id, replayState: false },
);
try {
const result = await getStructuredAgentResponse({
caller: async (nextPrompt) => {
const run = await this.options.agentManager.runAgent(verifierAgent.id, this.toPrompt(nextPrompt));
return this.resolveFinalText(run.timeline, run.finalText);
},
prompt: loop.verifyPrompt,
schema: LoopVerifyPromptSchema,
maxRetries: 2,
schemaName: "LoopVerifierResult",
});
iteration.verifyPrompt = {
passed: result.passed,
reason: result.reason,
verifierAgentId: verifierAgent.id,
startedAt,
completedAt: nowIso(),
};
this.appendLog(loop, {
iteration: iteration.index,
source: "loop",
level: result.passed ? "info" : "error",
text: `Verifier result: ${result.reason}`,
});
if (!result.passed) {
iteration.failureReason = result.reason;
}
return result.passed;
} finally {
unsubscribe();
loop.activeVerifierAgentId = null;
loop.updatedAt = nowIso();
await this.persist();
try {
if (loop.archive) {
await this.options.agentManager.archiveAgent(verifierAgent.id);
} else {
await this.options.agentManager.closeAgent(verifierAgent.id);
}
} catch {
// Ignore cleanup errors for internal loop verifiers.
}
}
}
private buildWorkerConfig(loop: LoopRecord, iteration: LoopIterationRecord): AgentSessionConfig {
return {
provider: loop.workerProvider ?? loop.provider,
cwd: loop.cwd,
model: loop.workerModel ?? loop.model ?? undefined,
title: buildWorkerTitle(loop, iteration.index),
internal: true,
};
}
private buildVerifierConfig(loop: LoopRecord, iteration: LoopIterationRecord): AgentSessionConfig {
return {
provider: loop.verifierProvider ?? loop.provider,
cwd: loop.cwd,
model: loop.verifierModel ?? loop.model ?? undefined,
title: buildVerifierTitle(loop, iteration.index),
internal: true,
};
}
private resolveFinalText(timeline: AgentTimelineItem[], finalText: string): string {
if (finalText.trim()) {
return finalText;
}
for (let index = timeline.length - 1; index >= 0; index -= 1) {
const item = timeline[index];
if (item?.type === "assistant_message" && item.text.trim()) {
return item.text;
}
}
return "";
}
private toPrompt(text: string): AgentPromptInput {
return text;
}
private finishLoop(loop: LoopRecord, status: Exclude<LoopStatus, "running">, message: string): void {
loop.status = status;
loop.completedAt = nowIso();
loop.updatedAt = loop.completedAt;
loop.activeIteration = null;
loop.activeWorkerAgentId = null;
loop.activeVerifierAgentId = null;
this.appendLog(loop, {
iteration: null,
source: "loop",
level: status === "succeeded" ? "info" : "error",
text: message,
});
}
private appendLog(
loop: LoopRecord,
entry: Omit<LoopLogEntry, "seq" | "timestamp">,
): void {
loop.logs.push({
seq: loop.nextLogSeq,
timestamp: nowIso(),
...entry,
});
loop.nextLogSeq += 1;
loop.updatedAt = nowIso();
}
private requireLoop(idOrPrefix: string): LoopRecord {
const trimmed = idOrPrefix.trim();
if (!trimmed) {
throw new Error("Loop id is required");
}
const exact = this.loops.get(trimmed);
if (exact) {
return exact;
}
const matches = Array.from(this.loops.values()).filter((record) => record.id.startsWith(trimmed));
if (matches.length === 1) {
return matches[0]!;
}
if (matches.length > 1) {
throw new Error(`Loop id prefix is ambiguous: ${trimmed}`);
}
throw new Error(`Loop not found: ${trimmed}`);
}
private async persist(): Promise<void> {
const nextPersist = this.persistQueue.then(async () => {
await fs.mkdir(path.dirname(this.storePath), { recursive: true });
const records = Array.from(this.loops.values()).sort((left, right) =>
left.createdAt.localeCompare(right.createdAt),
);
await fs.writeFile(this.storePath, JSON.stringify(records, null, 2), "utf8");
});
this.persistQueue = nextPersist.catch(() => {});
await nextPersist;
}
}
@@ -1,191 +0,0 @@
import { z } from "zod";
import { AgentProviderSchema } from "../agent/provider-manifest.js";
export const LoopLogEntrySchema = z.object({
seq: z.number().int().positive(),
timestamp: z.string(),
iteration: z.number().int().positive().nullable(),
source: z.enum(["loop", "worker", "verifier", "verify-check"]),
level: z.enum(["info", "error"]),
text: z.string(),
});
export const LoopVerifyCheckResultSchema = z.object({
command: z.string(),
exitCode: z.number().int(),
passed: z.boolean(),
stdout: z.string(),
stderr: z.string(),
startedAt: z.string(),
completedAt: z.string(),
});
export const LoopVerifyPromptResultSchema = z.object({
passed: z.boolean(),
reason: z.string(),
verifierAgentId: z.string().nullable(),
startedAt: z.string(),
completedAt: z.string(),
});
export const LoopIterationRecordSchema = z.object({
index: z.number().int().positive(),
workerAgentId: z.string().nullable(),
workerStartedAt: z.string(),
workerCompletedAt: z.string().nullable(),
verifierAgentId: z.string().nullable(),
status: z.enum(["running", "succeeded", "failed", "stopped"]),
workerOutcome: z.enum(["completed", "failed", "canceled"]).nullable(),
failureReason: z.string().nullable(),
verifyChecks: z.array(LoopVerifyCheckResultSchema),
verifyPrompt: LoopVerifyPromptResultSchema.nullable(),
});
export const LoopRecordSchema = z.object({
id: z.string(),
name: z.string().nullable(),
prompt: z.string(),
cwd: z.string(),
provider: AgentProviderSchema,
model: z.string().nullable(),
workerProvider: AgentProviderSchema.nullable(),
workerModel: z.string().nullable(),
verifierProvider: AgentProviderSchema.nullable(),
verifierModel: z.string().nullable(),
verifyPrompt: z.string().nullable(),
verifyChecks: z.array(z.string()),
archive: z.boolean(),
sleepMs: z.number().int().nonnegative(),
maxIterations: z.number().int().positive().nullable(),
maxTimeMs: z.number().int().positive().nullable(),
status: z.enum(["running", "succeeded", "failed", "stopped"]),
createdAt: z.string(),
updatedAt: z.string(),
startedAt: z.string(),
completedAt: z.string().nullable(),
stopRequestedAt: z.string().nullable(),
iterations: z.array(LoopIterationRecordSchema),
logs: z.array(LoopLogEntrySchema),
nextLogSeq: z.number().int().positive(),
activeIteration: z.number().int().positive().nullable(),
activeWorkerAgentId: z.string().nullable(),
activeVerifierAgentId: z.string().nullable(),
});
export const LoopListItemSchema = z.object({
id: z.string(),
name: z.string().nullable(),
status: z.enum(["running", "succeeded", "failed", "stopped"]),
cwd: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
activeIteration: z.number().int().positive().nullable(),
});
export const LoopRunRequestSchema = z.object({
type: z.literal("loop/run"),
requestId: z.string(),
prompt: z.string().trim().min(1),
cwd: z.string(),
provider: AgentProviderSchema.optional(),
model: z.string().trim().min(1).optional(),
workerProvider: AgentProviderSchema.optional(),
workerModel: z.string().trim().min(1).optional(),
verifierProvider: AgentProviderSchema.optional(),
verifierModel: z.string().trim().min(1).optional(),
verifyPrompt: z.string().trim().min(1).optional(),
verifyChecks: z.array(z.string().trim().min(1)).optional(),
archive: z.boolean().optional(),
name: z.string().trim().min(1).optional(),
sleepMs: z.number().int().nonnegative().optional(),
maxIterations: z.number().int().positive().optional(),
maxTimeMs: z.number().int().positive().optional(),
});
export const LoopListRequestSchema = z.object({
type: z.literal("loop/list"),
requestId: z.string(),
});
export const LoopInspectRequestSchema = z.object({
type: z.literal("loop/inspect"),
requestId: z.string(),
id: z.string().trim().min(1),
});
export const LoopLogsRequestSchema = z.object({
type: z.literal("loop/logs"),
requestId: z.string(),
id: z.string().trim().min(1),
afterSeq: z.number().int().nonnegative().optional(),
});
export const LoopStopRequestSchema = z.object({
type: z.literal("loop/stop"),
requestId: z.string(),
id: z.string().trim().min(1),
});
export const LoopRunResponseSchema = z.object({
type: z.literal("loop/run/response"),
payload: z.object({
requestId: z.string(),
loop: LoopRecordSchema.nullable(),
error: z.string().nullable(),
}),
});
export const LoopListResponseSchema = z.object({
type: z.literal("loop/list/response"),
payload: z.object({
requestId: z.string(),
loops: z.array(LoopListItemSchema),
error: z.string().nullable(),
}),
});
export const LoopInspectResponseSchema = z.object({
type: z.literal("loop/inspect/response"),
payload: z.object({
requestId: z.string(),
loop: LoopRecordSchema.nullable(),
error: z.string().nullable(),
}),
});
export const LoopLogsResponseSchema = z.object({
type: z.literal("loop/logs/response"),
payload: z.object({
requestId: z.string(),
loop: LoopRecordSchema.nullable(),
entries: z.array(LoopLogEntrySchema),
nextCursor: z.number().int().nonnegative(),
error: z.string().nullable(),
}),
});
export const LoopStopResponseSchema = z.object({
type: z.literal("loop/stop/response"),
payload: z.object({
requestId: z.string(),
loop: LoopRecordSchema.nullable(),
error: z.string().nullable(),
}),
});
export type LoopLogEntry = z.infer<typeof LoopLogEntrySchema>;
export type LoopVerifyCheckResult = z.infer<typeof LoopVerifyCheckResultSchema>;
export type LoopVerifyPromptResult = z.infer<typeof LoopVerifyPromptResultSchema>;
export type LoopIterationRecord = z.infer<typeof LoopIterationRecordSchema>;
export type LoopRecord = z.infer<typeof LoopRecordSchema>;
export type LoopListItem = z.infer<typeof LoopListItemSchema>;
export type LoopRunRequest = z.infer<typeof LoopRunRequestSchema>;
export type LoopListRequest = z.infer<typeof LoopListRequestSchema>;
export type LoopInspectRequest = z.infer<typeof LoopInspectRequestSchema>;
export type LoopLogsRequest = z.infer<typeof LoopLogsRequestSchema>;
export type LoopStopRequest = z.infer<typeof LoopStopRequestSchema>;
export type LoopRunResponse = z.infer<typeof LoopRunResponseSchema>;
export type LoopListResponse = z.infer<typeof LoopListResponseSchema>;
export type LoopInspectResponse = z.infer<typeof LoopInspectResponseSchema>;
export type LoopLogsResponse = z.infer<typeof LoopLogsResponseSchema>;
export type LoopStopResponse = z.infer<typeof LoopStopResponseSchema>;
@@ -1,28 +0,0 @@
import { describe, expect, test } from "vitest";
import { computeNextRunAt, validateScheduleCadence } from "./cron.js";
describe("schedule cron cadence", () => {
test("computes the next every cadence from the provided timestamp", () => {
const next = computeNextRunAt(
{ type: "every", everyMs: 5 * 60_000 },
new Date("2026-01-01T00:00:00.000Z"),
);
expect(next.toISOString()).toBe("2026-01-01T00:05:00.000Z");
});
test("computes the next cron minute match in UTC", () => {
const next = computeNextRunAt(
{ type: "cron", expression: "15 9 * * 1-5" },
new Date("2026-01-05T09:14:30.000Z"),
);
expect(next.toISOString()).toBe("2026-01-05T09:15:00.000Z");
});
test("rejects invalid cron expressions", () => {
expect(() =>
validateScheduleCadence({ type: "cron", expression: "not-a-valid-cron" }),
).toThrow("Cron expressions must have 5 fields");
});
});
-150
View File
@@ -1,150 +0,0 @@
import type { ScheduleCadence } from "./types.js";
interface CronFieldMatcher {
matches(value: number): boolean;
}
function buildValueSet(values: Iterable<number>): Set<number> {
return new Set(values);
}
function createRange(start: number, end: number, step: number): number[] {
const values: number[] = [];
for (let value = start; value <= end; value += step) {
values.push(value);
}
return values;
}
function parseField(
source: string,
bounds: { min: number; max: number; name: string },
): CronFieldMatcher {
const trimmed = source.trim();
if (!trimmed) {
throw new Error(`Invalid cron ${bounds.name} field`);
}
const values = new Set<number>();
for (const rawPart of trimmed.split(",")) {
const part = rawPart.trim();
if (!part) {
throw new Error(`Invalid cron ${bounds.name} field`);
}
const [base, stepSource] = part.split("/");
const step =
stepSource === undefined ? 1 : Number.parseInt(stepSource, 10);
if (!Number.isInteger(step) || step <= 0) {
throw new Error(`Invalid cron ${bounds.name} step`);
}
if (base === "*") {
for (const value of createRange(bounds.min, bounds.max, step)) {
values.add(value);
}
continue;
}
const rangeMatch = base.match(/^(\d+)-(\d+)$/);
if (rangeMatch) {
const start = Number.parseInt(rangeMatch[1], 10);
const end = Number.parseInt(rangeMatch[2], 10);
if (start > end || start < bounds.min || end > bounds.max) {
throw new Error(`Invalid cron ${bounds.name} range`);
}
for (const value of createRange(start, end, step)) {
values.add(value);
}
continue;
}
const value = Number.parseInt(base, 10);
if (!Number.isInteger(value) || value < bounds.min || value > bounds.max) {
throw new Error(`Invalid cron ${bounds.name} value`);
}
values.add(value);
}
const allowed = buildValueSet(values);
return {
matches(value: number): boolean {
return allowed.has(value);
},
};
}
interface ParsedCronExpression {
minute: CronFieldMatcher;
hour: CronFieldMatcher;
dayOfMonth: CronFieldMatcher;
month: CronFieldMatcher;
dayOfWeek: CronFieldMatcher;
}
function parseCronExpression(expression: string): ParsedCronExpression {
const parts = expression.trim().split(/\s+/);
if (parts.length !== 5) {
throw new Error("Cron expressions must have 5 fields");
}
return {
minute: parseField(parts[0], { min: 0, max: 59, name: "minute" }),
hour: parseField(parts[1], { min: 0, max: 23, name: "hour" }),
dayOfMonth: parseField(parts[2], { min: 1, max: 31, name: "day-of-month" }),
month: parseField(parts[3], { min: 1, max: 12, name: "month" }),
dayOfWeek: parseField(parts[4], { min: 0, max: 6, name: "day-of-week" }),
};
}
function startOfNextMinute(date: Date): Date {
return new Date(
Date.UTC(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate(),
date.getUTCHours(),
date.getUTCMinutes() + 1,
0,
0,
),
);
}
export function validateScheduleCadence(cadence: ScheduleCadence): void {
if (cadence.type === "cron") {
parseCronExpression(cadence.expression);
}
}
export function computeNextRunAt(cadence: ScheduleCadence, after: Date): Date {
if (cadence.type === "every") {
return new Date(after.getTime() + cadence.everyMs);
}
const cron = parseCronExpression(cadence.expression);
const limit = 366 * 24 * 60;
let cursor = startOfNextMinute(after);
for (let index = 0; index < limit; index += 1) {
const minute = cursor.getUTCMinutes();
const hour = cursor.getUTCHours();
const dayOfMonth = cursor.getUTCDate();
const month = cursor.getUTCMonth() + 1;
const dayOfWeek = cursor.getUTCDay();
if (
cron.minute.matches(minute) &&
cron.hour.matches(hour) &&
cron.dayOfMonth.matches(dayOfMonth) &&
cron.month.matches(month) &&
cron.dayOfWeek.matches(dayOfWeek)
) {
return cursor;
}
cursor = new Date(cursor.getTime() + 60_000);
}
throw new Error(`Unable to compute next run time for cron expression: ${cadence.expression}`);
}
@@ -1,132 +0,0 @@
import { z } from "zod";
import {
ScheduleCadenceSchema,
ScheduleRunSchema,
ScheduleSummarySchema,
StoredScheduleSchema,
ScheduleTargetSchema,
} from "./types.js";
const ScheduleCreateTargetSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("self"),
agentId: z.string().uuid(),
}),
z.object({
type: z.literal("agent"),
agentId: z.string().uuid(),
}),
z.object({
type: z.literal("new-agent"),
config: ScheduleTargetSchema.options[1].shape.config,
}),
]);
export const ScheduleCreateRequestSchema = z.object({
type: z.literal("schedule/create"),
requestId: z.string(),
prompt: z.string().min(1),
name: z.string().optional(),
cadence: ScheduleCadenceSchema,
target: ScheduleCreateTargetSchema,
maxRuns: z.number().int().positive().optional(),
expiresAt: z.string().optional(),
});
export const ScheduleListRequestSchema = z.object({
type: z.literal("schedule/list"),
requestId: z.string(),
});
export const ScheduleInspectRequestSchema = z.object({
type: z.literal("schedule/inspect"),
requestId: z.string(),
scheduleId: z.string(),
});
export const ScheduleLogsRequestSchema = z.object({
type: z.literal("schedule/logs"),
requestId: z.string(),
scheduleId: z.string(),
});
export const SchedulePauseRequestSchema = z.object({
type: z.literal("schedule/pause"),
requestId: z.string(),
scheduleId: z.string(),
});
export const ScheduleResumeRequestSchema = z.object({
type: z.literal("schedule/resume"),
requestId: z.string(),
scheduleId: z.string(),
});
export const ScheduleDeleteRequestSchema = z.object({
type: z.literal("schedule/delete"),
requestId: z.string(),
scheduleId: z.string(),
});
export const ScheduleCreateResponseSchema = z.object({
type: z.literal("schedule/create/response"),
payload: z.object({
requestId: z.string(),
schedule: ScheduleSummarySchema.nullable(),
error: z.string().nullable(),
}),
});
export const ScheduleListResponseSchema = z.object({
type: z.literal("schedule/list/response"),
payload: z.object({
requestId: z.string(),
schedules: z.array(ScheduleSummarySchema),
error: z.string().nullable(),
}),
});
export const ScheduleInspectResponseSchema = z.object({
type: z.literal("schedule/inspect/response"),
payload: z.object({
requestId: z.string(),
schedule: StoredScheduleSchema.nullable(),
error: z.string().nullable(),
}),
});
export const ScheduleLogsResponseSchema = z.object({
type: z.literal("schedule/logs/response"),
payload: z.object({
requestId: z.string(),
runs: z.array(ScheduleRunSchema),
error: z.string().nullable(),
}),
});
export const SchedulePauseResponseSchema = z.object({
type: z.literal("schedule/pause/response"),
payload: z.object({
requestId: z.string(),
schedule: ScheduleSummarySchema.nullable(),
error: z.string().nullable(),
}),
});
export const ScheduleResumeResponseSchema = z.object({
type: z.literal("schedule/resume/response"),
payload: z.object({
requestId: z.string(),
schedule: ScheduleSummarySchema.nullable(),
error: z.string().nullable(),
}),
});
export const ScheduleDeleteResponseSchema = z.object({
type: z.literal("schedule/delete/response"),
payload: z.object({
requestId: z.string(),
scheduleId: z.string(),
error: z.string().nullable(),
}),
});
@@ -1,229 +0,0 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { AgentManager } from "../agent/agent-manager.js";
import { AgentStorage } from "../agent/agent-storage.js";
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { ScheduleService } from "./service.js";
describe("ScheduleService", () => {
let tempDir: string;
let agentStorage: AgentStorage;
let now: Date;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "schedule-service-test-"));
await mkdir(join(tempDir, "agents"), { recursive: true });
agentStorage = new AgentStorage(join(tempDir, "agents"), createTestLogger());
await agentStorage.initialize();
now = new Date("2026-01-01T00:00:00.000Z");
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
test("ticks due schedules and records run history on disk", async () => {
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
now: () => now,
runner: async (schedule) => ({
agentId: "00000000-0000-0000-0000-000000000001",
output: `ran:${schedule.prompt}`,
}),
});
const created = await service.create({
prompt: "Review new PRs",
cadence: { type: "every", everyMs: 60_000 },
target: {
type: "new-agent",
config: {
provider: "claude",
cwd: tempDir,
},
},
});
now = new Date("2026-01-01T00:01:00.000Z");
await service.tick();
const inspected = await service.inspect(created.id);
expect(inspected.runs).toHaveLength(1);
expect(inspected.runs[0]).toMatchObject({
status: "succeeded",
agentId: "00000000-0000-0000-0000-000000000001",
output: "ran:Review new PRs",
});
expect(inspected.nextRunAt).toBe("2026-01-01T00:02:00.000Z");
});
test("pause and resume update persisted schedule state", async () => {
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
now: () => now,
runner: async () => ({
agentId: null,
output: "ok",
}),
});
const created = await service.create({
prompt: "Check status",
cadence: { type: "every", everyMs: 60_000 },
target: {
type: "new-agent",
config: {
provider: "claude",
cwd: tempDir,
},
},
});
const paused = await service.pause(created.id);
expect(paused.status).toBe("paused");
expect(paused.nextRunAt).toBeNull();
now = new Date("2026-01-01T00:03:00.000Z");
const resumed = await service.resume(created.id);
expect(resumed.status).toBe("active");
expect(resumed.nextRunAt).toBe("2026-01-01T00:04:00.000Z");
});
test("completes schedules when max runs is reached", async () => {
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
now: () => now,
runner: async () => ({
agentId: null,
output: "done",
}),
});
const created = await service.create({
prompt: "One shot",
cadence: { type: "every", everyMs: 60_000 },
target: {
type: "new-agent",
config: {
provider: "claude",
cwd: tempDir,
},
},
maxRuns: 1,
});
now = new Date("2026-01-01T00:01:00.000Z");
await service.tick();
const inspected = await service.inspect(created.id);
expect(inspected.status).toBe("completed");
expect(inspected.nextRunAt).toBeNull();
});
test("executes new-agent schedules through AgentManager with real fake clients", async () => {
const manager = new AgentManager({
logger: createTestLogger(),
clients: createTestAgentClients(),
registry: agentStorage,
});
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: manager,
agentStorage,
now: () => now,
});
const created = await service.create({
prompt: "Respond with exactly hello",
cadence: { type: "every", everyMs: 60_000 },
target: {
type: "new-agent",
config: {
provider: "claude",
cwd: tempDir,
approvalPolicy: "never",
},
},
maxRuns: 1,
});
now = new Date("2026-01-01T00:01:00.000Z");
await service.tick();
const inspected = await service.inspect(created.id);
expect(inspected.runs).toHaveLength(1);
expect(inspected.runs[0]?.status).toBe("succeeded");
expect(inspected.runs[0]?.agentId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
});
test("keeps schedules paused when an in-flight run finishes after pause", async () => {
let releaseRun: (() => void) | null = null;
const runStarted = new Promise<void>((resolve) => {
releaseRun = resolve;
});
let finishRun: (() => void) | null = null;
const runBlocked = new Promise<void>((resolve) => {
finishRun = resolve;
});
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
now: () => now,
runner: async () => {
releaseRun?.();
await runBlocked;
return {
agentId: null,
output: "finished",
};
},
});
const created = await service.create({
prompt: "Check status",
cadence: { type: "every", everyMs: 60_000 },
target: {
type: "new-agent",
config: {
provider: "claude",
cwd: tempDir,
},
},
});
now = new Date("2026-01-01T00:01:00.000Z");
const tickPromise = service.tick();
await runStarted;
const paused = await service.pause(created.id);
expect(paused.status).toBe("paused");
expect(paused.nextRunAt).toBeNull();
finishRun?.();
await tickPromise;
const inspected = await service.inspect(created.id);
expect(inspected.status).toBe("paused");
expect(inspected.nextRunAt).toBeNull();
expect(inspected.runs).toHaveLength(1);
expect(inspected.runs[0]?.status).toBe("succeeded");
});
});
@@ -1,472 +0,0 @@
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import type { Logger } from "pino";
import { AgentManager } from "../agent/agent-manager.js";
import type { ManagedAgent } from "../agent/agent-manager.js";
import { AgentStorage } from "../agent/agent-storage.js";
import type {
AgentPromptInput,
AgentSessionConfig,
} from "../agent/agent-sdk-types.js";
import { curateAgentActivity } from "../agent/activity-curator.js";
import { buildConfigOverrides, buildSessionConfig, extractTimestamps } from "../persistence-hooks.js";
import { ScheduleStore } from "./store.js";
import { computeNextRunAt, validateScheduleCadence } from "./cron.js";
import type {
CreateScheduleInput,
ScheduleExecutionResult,
ScheduleRun,
StoredSchedule,
} from "./types.js";
const SCHEDULE_TICK_INTERVAL_MS = 1000;
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
function trimOptionalName(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizePrompt(prompt: string): string {
const trimmed = prompt.trim();
if (!trimmed) {
throw new Error("Schedule prompt is required");
}
return trimmed;
}
function normalizeMaxRuns(value: number | null | undefined): number | null {
if (value == null) {
return null;
}
if (!Number.isInteger(value) || value <= 0) {
throw new Error("maxRuns must be a positive integer");
}
return value;
}
function countCompletedRuns(schedule: StoredSchedule): number {
return schedule.runs.filter((run) => run.status !== "running").length;
}
function shouldCompleteSchedule(schedule: StoredSchedule, now: Date): boolean {
if (schedule.expiresAt && new Date(schedule.expiresAt).getTime() <= now.getTime()) {
return true;
}
if (schedule.maxRuns == null) {
return false;
}
return countCompletedRuns(schedule) >= schedule.maxRuns;
}
function completeSchedule(schedule: StoredSchedule, now: Date): StoredSchedule {
return {
...schedule,
status: "completed",
nextRunAt: null,
pausedAt: null,
updatedAt: now.toISOString(),
};
}
function buildRunOutput(params: {
output: string | null;
timelineText: string;
finalText: string;
}): string | null {
if (params.output && params.output.trim().length > 0) {
return params.output;
}
if (params.finalText.trim().length > 0) {
return params.finalText.trim();
}
if (params.timelineText.trim().length > 0) {
return params.timelineText.trim();
}
return null;
}
function buildAgentPrompt(text: string): AgentPromptInput {
return text;
}
export interface ScheduleServiceOptions {
paseoHome: string;
logger: Logger;
agentManager: AgentManager;
agentStorage: AgentStorage;
now?: () => Date;
runner?: (schedule: StoredSchedule) => Promise<ScheduleExecutionResult>;
}
export class ScheduleService {
private readonly store: ScheduleStore;
private readonly logger: Logger;
private readonly agentManager: AgentManager;
private readonly agentStorage: AgentStorage;
private readonly now: () => Date;
private readonly runner: (schedule: StoredSchedule) => Promise<ScheduleExecutionResult>;
private readonly runningScheduleIds = new Set<string>();
private tickTimer: ReturnType<typeof setInterval> | null = null;
constructor(options: ScheduleServiceOptions) {
this.store = new ScheduleStore(join(options.paseoHome, "schedules"));
this.logger = options.logger.child({ module: "schedule-service" });
this.agentManager = options.agentManager;
this.agentStorage = options.agentStorage;
this.now = options.now ?? (() => new Date());
this.runner = options.runner ?? ((schedule) => this.executeSchedule(schedule));
}
async start(): Promise<void> {
await this.recoverInterruptedRuns();
if (this.tickTimer) {
return;
}
const timer = setInterval(() => {
void this.tick().catch((error) => {
this.logger.error({ err: error }, "Failed to process schedule tick");
});
}, SCHEDULE_TICK_INTERVAL_MS);
(timer as unknown as { unref?: () => void }).unref?.();
this.tickTimer = timer;
}
async stop(): Promise<void> {
if (this.tickTimer) {
clearInterval(this.tickTimer);
this.tickTimer = null;
}
}
async create(input: CreateScheduleInput): Promise<StoredSchedule> {
const now = this.now();
const prompt = normalizePrompt(input.prompt);
validateScheduleCadence(input.cadence);
const schedule = await this.store.create({
name: trimOptionalName(input.name),
prompt,
cadence: input.cadence,
target: input.target,
status: "active",
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
nextRunAt: computeNextRunAt(input.cadence, now).toISOString(),
lastRunAt: null,
pausedAt: null,
expiresAt: input.expiresAt ?? null,
maxRuns: normalizeMaxRuns(input.maxRuns),
runs: [],
});
return schedule;
}
async list(): Promise<StoredSchedule[]> {
return this.store.list();
}
async inspect(id: string): Promise<StoredSchedule> {
const schedule = await this.store.get(id);
if (!schedule) {
throw new Error(`Schedule not found: ${id}`);
}
return schedule;
}
async logs(id: string): Promise<ScheduleRun[]> {
const schedule = await this.inspect(id);
return [...schedule.runs].sort((left, right) => left.startedAt.localeCompare(right.startedAt));
}
async pause(id: string): Promise<StoredSchedule> {
const schedule = await this.inspect(id);
if (schedule.status === "completed") {
throw new Error(`Schedule ${id} is already completed`);
}
if (schedule.status === "paused") {
return schedule;
}
const now = this.now();
const paused = {
...schedule,
status: "paused" as const,
nextRunAt: null,
pausedAt: now.toISOString(),
updatedAt: now.toISOString(),
};
await this.store.put(paused);
return paused;
}
async resume(id: string): Promise<StoredSchedule> {
const schedule = await this.inspect(id);
if (schedule.status === "completed") {
throw new Error(`Schedule ${id} is already completed`);
}
if (schedule.status === "active") {
return schedule;
}
const now = this.now();
const resumed = {
...schedule,
status: "active" as const,
pausedAt: null,
nextRunAt: computeNextRunAt(schedule.cadence, now).toISOString(),
updatedAt: now.toISOString(),
};
await this.store.put(resumed);
return resumed;
}
async delete(id: string): Promise<void> {
await this.store.delete(id);
}
async tick(): Promise<void> {
const now = this.now();
const schedules = await this.store.list();
for (const schedule of schedules) {
if (schedule.status !== "active" || !schedule.nextRunAt) {
continue;
}
if (this.runningScheduleIds.has(schedule.id)) {
continue;
}
if (shouldCompleteSchedule(schedule, now)) {
await this.store.put(completeSchedule(schedule, now));
continue;
}
if (new Date(schedule.nextRunAt).getTime() > now.getTime()) {
continue;
}
await this.runSchedule(schedule, now);
}
}
private async recoverInterruptedRuns(): Promise<void> {
const schedules = await this.store.list();
const now = this.now();
for (const schedule of schedules) {
const runningIndex = schedule.runs.findIndex((run) => run.status === "running");
if (runningIndex === -1) {
continue;
}
const runs = [...schedule.runs];
runs[runningIndex] = {
...runs[runningIndex],
status: "failed",
endedAt: now.toISOString(),
error: "Daemon restarted before the scheduled run completed",
};
const nextSchedule = {
...schedule,
runs,
updatedAt: now.toISOString(),
};
await this.store.put(nextSchedule);
}
}
private async runSchedule(schedule: StoredSchedule, now: Date): Promise<void> {
this.runningScheduleIds.add(schedule.id);
const runId = randomUUID();
const runningRun: ScheduleRun = {
id: runId,
scheduledFor: schedule.nextRunAt ?? now.toISOString(),
startedAt: now.toISOString(),
endedAt: null,
status: "running",
agentId: null,
output: null,
error: null,
};
const scheduleWithRun = {
...schedule,
updatedAt: now.toISOString(),
runs: [...schedule.runs, runningRun],
};
await this.store.put(scheduleWithRun);
try {
const result = await this.runner(scheduleWithRun);
await this.finishRun({
scheduleId: schedule.id,
runId,
status: "succeeded",
agentId: result.agentId,
output: result.output,
error: null,
});
} catch (error) {
await this.finishRun({
scheduleId: schedule.id,
runId,
status: "failed",
agentId: null,
output: null,
error: error instanceof Error ? error.message : String(error),
});
} finally {
this.runningScheduleIds.delete(schedule.id);
}
}
private async finishRun(params: {
scheduleId: string;
runId: string;
status: "succeeded" | "failed";
agentId: string | null;
output: string | null;
error: string | null;
}): Promise<void> {
const schedule = await this.inspect(params.scheduleId);
const now = this.now();
const completedRuns = schedule.runs.map((run) =>
run.id === params.runId
? {
...run,
status: params.status,
endedAt: now.toISOString(),
agentId: params.agentId,
output: params.output,
error: params.error,
}
: run,
);
let updated: StoredSchedule = {
...schedule,
runs: completedRuns,
lastRunAt: now.toISOString(),
updatedAt: now.toISOString(),
};
if (shouldCompleteSchedule(updated, now)) {
updated = completeSchedule(updated, now);
} else if (updated.status === "paused") {
updated = {
...updated,
nextRunAt: null,
};
} else {
const after = new Date(schedule.nextRunAt ?? now.toISOString());
let nextRunAt = computeNextRunAt(updated.cadence, after);
while (nextRunAt.getTime() <= now.getTime()) {
nextRunAt = computeNextRunAt(updated.cadence, nextRunAt);
}
updated = {
...updated,
nextRunAt: nextRunAt.toISOString(),
};
}
await this.store.put(updated);
}
private async executeSchedule(schedule: StoredSchedule): Promise<ScheduleExecutionResult> {
if (schedule.target.type === "agent") {
const agent = await this.ensureAgentLoaded(schedule.target.agentId);
if (this.agentManager.hasInFlightRun(agent.id)) {
throw new Error(`Agent ${agent.id} already has an active run`);
}
this.agentManager.recordUserMessage(agent.id, schedule.prompt, { emitState: false });
const result = await this.agentManager.runAgent(agent.id, buildAgentPrompt(schedule.prompt));
const timelineText = curateAgentActivity(result.timeline);
return {
agentId: agent.id,
output: buildRunOutput({
output: null,
timelineText,
finalText: result.finalText,
}),
};
}
const config: AgentSessionConfig = {
provider: schedule.target.config.provider,
cwd: schedule.target.config.cwd,
modeId: schedule.target.config.modeId,
model: schedule.target.config.model,
thinkingOptionId: schedule.target.config.thinkingOptionId,
title: schedule.target.config.title,
approvalPolicy: schedule.target.config.approvalPolicy,
sandboxMode: schedule.target.config.sandboxMode,
networkAccess: schedule.target.config.networkAccess,
webSearch: schedule.target.config.webSearch,
extra: schedule.target.config.extra,
systemPrompt: schedule.target.config.systemPrompt,
mcpServers: schedule.target.config.mcpServers as AgentSessionConfig["mcpServers"],
};
const labels = {
"paseo.schedule-id": schedule.id,
"paseo.schedule-run": randomUUID(),
};
const agent = await this.agentManager.createAgent(config, undefined, { labels });
this.agentManager.recordUserMessage(agent.id, schedule.prompt, { emitState: false });
const result = await this.agentManager.runAgent(agent.id, buildAgentPrompt(schedule.prompt));
const timelineText = curateAgentActivity(result.timeline);
return {
agentId: agent.id,
output: buildRunOutput({
output: null,
timelineText,
finalText: result.finalText,
}),
};
}
private async ensureAgentLoaded(agentId: string): Promise<ManagedAgent> {
const existing = this.agentManager.getAgent(agentId);
if (existing) {
return existing;
}
const inflight = pendingAgentInitializations.get(agentId);
if (inflight) {
return inflight;
}
const initPromise = (async () => {
const record = await this.agentStorage.get(agentId);
if (!record) {
throw new Error(`Agent not found: ${agentId}`);
}
if (record.archivedAt) {
throw new Error(`Agent ${agentId} is archived`);
}
let snapshot: ManagedAgent;
if (record.persistence?.provider && record.persistence?.sessionId) {
snapshot = await this.agentManager.resumeAgentFromPersistence(
{
provider: record.persistence.provider as AgentSessionConfig["provider"],
sessionId: record.persistence.sessionId,
nativeHandle: record.persistence.nativeHandle,
metadata: record.persistence.metadata,
},
buildConfigOverrides(record),
agentId,
extractTimestamps(record),
);
} else {
snapshot = await this.agentManager.createAgent(buildSessionConfig(record), agentId, {
labels: record.labels,
});
}
await this.agentManager.hydrateTimelineFromProvider(agentId);
return this.agentManager.getAgent(agentId) ?? snapshot;
})();
pendingAgentInitializations.set(agentId, initPromise);
try {
return await initPromise;
} finally {
if (pendingAgentInitializations.get(agentId) === initPromise) {
pendingAgentInitializations.delete(agentId);
}
}
}
}
@@ -1,78 +0,0 @@
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { ScheduleStore } from "./store.js";
describe("ScheduleStore", () => {
let tempDir: string;
let store: ScheduleStore;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "schedule-store-test-"));
store = new ScheduleStore(tempDir);
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
test("creates and reloads schedules from disk", async () => {
const created = await store.create({
name: "Morning summary",
prompt: "Summarize new commits",
cadence: { type: "every", everyMs: 60_000 },
target: {
type: "new-agent",
config: {
provider: "claude",
cwd: tempDir,
},
},
status: "active",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
nextRunAt: "2026-01-01T00:01:00.000Z",
lastRunAt: null,
pausedAt: null,
expiresAt: null,
maxRuns: null,
runs: [],
});
const reloaded = new ScheduleStore(tempDir);
const listed = await reloaded.list();
expect(created.id).toHaveLength(8);
expect(listed).toEqual([created]);
});
test("deletes schedules from disk", async () => {
const created = await store.create({
name: null,
prompt: "Check status",
cadence: { type: "every", everyMs: 30_000 },
target: {
type: "new-agent",
config: {
provider: "claude",
cwd: tempDir,
},
},
status: "active",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
nextRunAt: "2026-01-01T00:00:30.000Z",
lastRunAt: null,
pausedAt: null,
expiresAt: null,
maxRuns: null,
runs: [],
});
await store.delete(created.id);
expect(await store.get(created.id)).toBeNull();
expect(await store.list()).toEqual([]);
});
});
@@ -1,63 +0,0 @@
import { randomBytes } from "node:crypto";
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { StoredScheduleSchema, type StoredSchedule } from "./types.js";
function generateScheduleId(): string {
return randomBytes(4).toString("hex");
}
export class ScheduleStore {
constructor(private readonly dir: string) {}
private filePath(id: string): string {
return join(this.dir, `${id}.json`);
}
private async ensureDir(): Promise<void> {
await mkdir(this.dir, { recursive: true });
}
async list(): Promise<StoredSchedule[]> {
await this.ensureDir();
const entries = await readdir(this.dir, { withFileTypes: true });
const schedules = await Promise.all(
entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
.map(async (entry) => {
const content = await readFile(join(this.dir, entry.name), "utf-8");
return StoredScheduleSchema.parse(JSON.parse(content));
}),
);
return schedules.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
}
async get(id: string): Promise<StoredSchedule | null> {
await this.ensureDir();
try {
const content = await readFile(this.filePath(id), "utf-8");
return StoredScheduleSchema.parse(JSON.parse(content));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw error;
}
}
async create(schedule: Omit<StoredSchedule, "id">): Promise<StoredSchedule> {
const created = { ...schedule, id: generateScheduleId() };
await this.put(created);
return created;
}
async put(schedule: StoredSchedule): Promise<void> {
await this.ensureDir();
await writeFile(this.filePath(schedule.id), JSON.stringify(schedule, null, 2), "utf-8");
}
async delete(id: string): Promise<void> {
await this.ensureDir();
await rm(this.filePath(id), { force: true });
}
}
@@ -1,98 +0,0 @@
import { z } from "zod";
import { AgentProviderSchema } from "../agent/provider-manifest.js";
export const ScheduleStatusSchema = z.enum(["active", "paused", "completed"]);
export type ScheduleStatus = z.infer<typeof ScheduleStatusSchema>;
export const ScheduleCadenceSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("every"),
everyMs: z.number().int().positive(),
}),
z.object({
type: z.literal("cron"),
expression: z.string().trim().min(1),
}),
]);
export type ScheduleCadence = z.infer<typeof ScheduleCadenceSchema>;
export const ScheduleTargetSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("agent"),
agentId: z.string().uuid(),
}),
z.object({
type: z.literal("new-agent"),
config: z.object({
provider: AgentProviderSchema,
cwd: z.string().trim().min(1),
modeId: z.string().trim().min(1).optional(),
model: z.string().trim().min(1).optional(),
thinkingOptionId: z.string().trim().min(1).optional(),
title: z.string().trim().min(1).nullable().optional(),
approvalPolicy: z.string().trim().min(1).optional(),
sandboxMode: z.string().trim().min(1).optional(),
networkAccess: z.boolean().optional(),
webSearch: z.boolean().optional(),
extra: z
.object({
codex: z.record(z.unknown()).optional(),
claude: z.record(z.unknown()).optional(),
})
.partial()
.optional(),
systemPrompt: z.string().optional(),
mcpServers: z.record(z.unknown()).optional(),
}),
}),
]);
export type ScheduleTarget = z.infer<typeof ScheduleTargetSchema>;
export const ScheduleRunSchema = z.object({
id: z.string(),
scheduledFor: z.string(),
startedAt: z.string(),
endedAt: z.string().nullable(),
status: z.enum(["running", "succeeded", "failed"]),
agentId: z.string().uuid().nullable(),
output: z.string().nullable(),
error: z.string().nullable(),
});
export type ScheduleRun = z.infer<typeof ScheduleRunSchema>;
export const StoredScheduleSchema = z.object({
id: z.string(),
name: z.string().nullable(),
prompt: z.string().min(1),
cadence: ScheduleCadenceSchema,
target: ScheduleTargetSchema,
status: ScheduleStatusSchema,
createdAt: z.string(),
updatedAt: z.string(),
nextRunAt: z.string().nullable(),
lastRunAt: z.string().nullable(),
pausedAt: z.string().nullable(),
expiresAt: z.string().nullable(),
maxRuns: z.number().int().positive().nullable(),
runs: z.array(ScheduleRunSchema),
});
export type StoredSchedule = z.infer<typeof StoredScheduleSchema>;
export const ScheduleSummarySchema = StoredScheduleSchema.omit({
runs: true,
});
export type ScheduleSummary = z.infer<typeof ScheduleSummarySchema>;
export interface CreateScheduleInput {
name?: string | null;
prompt: string;
cadence: ScheduleCadence;
target: ScheduleTarget;
maxRuns?: number | null;
expiresAt?: string | null;
}
export interface ScheduleExecutionResult {
agentId: string | null;
output: string | null;
}
-574
View File
@@ -177,13 +177,6 @@ import { toResolver, type Resolvable } from "./speech/provider-resolver.js";
import type { SpeechReadinessSnapshot, SpeechReadinessState } from "./speech/speech-runtime.js";
import type pino from "pino";
import { resolveClientMessageId } from "./client-message-id.js";
import {
ChatServiceError,
FileBackedChatService,
} from "./chat/chat-service.js";
import { notifyChatMentions } from "./chat/chat-mentions.js";
import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js";
const execAsync = promisify(exec);
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
@@ -414,9 +407,6 @@ export type SessionOptions = {
agentStorage: AgentStorage;
projectRegistry: ProjectRegistry;
workspaceRegistry: WorkspaceRegistry;
chatService: FileBackedChatService;
scheduleService: ScheduleService;
loopService: LoopService;
createAgentMcpTransport: AgentMcpTransportFactory;
stt: Resolvable<SpeechToTextProvider | null>;
tts: Resolvable<TextToSpeechProvider | null>;
@@ -600,9 +590,6 @@ export class Session {
private readonly agentStorage: AgentStorage;
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
private readonly chatService: FileBackedChatService;
private readonly scheduleService: ScheduleService;
private readonly loopService: LoopService;
private readonly createAgentMcpTransport: AgentMcpTransportFactory;
private readonly downloadTokenStore: DownloadTokenStore;
private readonly pushTokenStore: PushTokenStore;
@@ -665,9 +652,6 @@ export class Session {
agentStorage,
projectRegistry,
workspaceRegistry,
chatService,
scheduleService,
loopService,
createAgentMcpTransport,
stt,
tts,
@@ -690,9 +674,6 @@ export class Session {
this.agentStorage = agentStorage;
this.projectRegistry = projectRegistry;
this.workspaceRegistry = workspaceRegistry;
this.chatService = chatService;
this.scheduleService = scheduleService;
this.loopService = loopService;
this.createAgentMcpTransport = createAgentMcpTransport;
this.terminalManager = terminalManager;
if (this.terminalManager) {
@@ -1777,82 +1758,6 @@ export class Session {
case "kill_terminal_request":
await this.handleKillTerminalRequest(msg);
break;
case "chat/create":
await this.handleChatCreateRequest(msg);
break;
case "chat/list":
await this.handleChatListRequest(msg);
break;
case "chat/inspect":
await this.handleChatInspectRequest(msg);
break;
case "chat/delete":
await this.handleChatDeleteRequest(msg);
break;
case "chat/post":
await this.handleChatPostRequest(msg);
break;
case "chat/read":
await this.handleChatReadRequest(msg);
break;
case "chat/wait":
await this.handleChatWaitRequest(msg);
break;
case "schedule/create":
await this.handleScheduleCreateRequest(msg);
break;
case "schedule/list":
await this.handleScheduleListRequest(msg);
break;
case "schedule/inspect":
await this.handleScheduleInspectRequest(msg);
break;
case "schedule/logs":
await this.handleScheduleLogsRequest(msg);
break;
case "schedule/pause":
await this.handleSchedulePauseRequest(msg);
break;
case "schedule/resume":
await this.handleScheduleResumeRequest(msg);
break;
case "schedule/delete":
await this.handleScheduleDeleteRequest(msg);
break;
case "loop/run":
await this.handleLoopRunRequest(msg);
break;
case "loop/list":
await this.handleLoopListRequest(msg);
break;
case "loop/inspect":
await this.handleLoopInspectRequest(msg);
break;
case "loop/logs":
await this.handleLoopLogsRequest(msg);
break;
case "loop/stop":
await this.handleLoopStopRequest(msg);
break;
}
} catch (error: any) {
const err = error instanceof Error ? error : new Error(String(error));
@@ -7627,485 +7532,6 @@ export class Session {
this.detachTerminalStream(terminalId, { emitExit: true });
}
private emitChatRpcError(request: { requestId: string; type: string }, error: unknown): void {
const message =
error instanceof Error ? error.message : "Chat request failed";
const code = error instanceof ChatServiceError ? error.code : "chat_request_failed";
this.sessionLogger.error({ err: error, requestType: request.type }, "Chat request failed");
this.emit({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: message,
code,
},
});
}
private async handleChatCreateRequest(
request: Extract<SessionInboundMessage, { type: "chat/create" }>,
): Promise<void> {
try {
const room = await this.chatService.createRoom({
name: request.name,
purpose: request.purpose,
});
this.emit({
type: "chat/create/response",
payload: {
requestId: request.requestId,
room,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
private async handleChatListRequest(
request: Extract<SessionInboundMessage, { type: "chat/list" }>,
): Promise<void> {
try {
const rooms = await this.chatService.listRooms();
this.emit({
type: "chat/list/response",
payload: {
requestId: request.requestId,
rooms,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
private async handleChatInspectRequest(
request: Extract<SessionInboundMessage, { type: "chat/inspect" }>,
): Promise<void> {
try {
const result = await this.chatService.inspectRoom({
room: request.room,
});
this.emit({
type: "chat/inspect/response",
payload: {
requestId: request.requestId,
room: result.room,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
private async handleChatDeleteRequest(
request: Extract<SessionInboundMessage, { type: "chat/delete" }>,
): Promise<void> {
try {
const result = await this.chatService.deleteRoom({
room: request.room,
});
this.emit({
type: "chat/delete/response",
payload: {
requestId: request.requestId,
room: result.room,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
private async handleChatPostRequest(
request: Extract<SessionInboundMessage, { type: "chat/post" }>,
): Promise<void> {
try {
const authorAgentId = request.authorAgentId?.trim() || this.clientId;
const message = await this.chatService.postMessage({
room: request.room,
authorAgentId,
body: request.body,
replyToMessageId: request.replyToMessageId,
});
this.emit({
type: "chat/post/response",
payload: {
requestId: request.requestId,
message,
error: null,
},
});
void notifyChatMentions({
room: request.room,
authorAgentId,
body: request.body,
mentionAgentIds: message.mentionAgentIds,
logger: this.sessionLogger,
listStoredAgents: () => this.agentStorage.list(),
listLiveAgents: () => this.agentManager.listAgents(),
resolveAgentIdentifier: (identifier) => this.resolveAgentIdentifier(identifier),
sendAgentMessage: (agentId, text) => this.handleSendAgentMessage(agentId, text),
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
private async handleChatReadRequest(
request: Extract<SessionInboundMessage, { type: "chat/read" }>,
): Promise<void> {
try {
const messages = await this.chatService.readMessages({
room: request.room,
limit: request.limit,
since: request.since,
authorAgentId: request.authorAgentId,
});
this.emit({
type: "chat/read/response",
payload: {
requestId: request.requestId,
messages,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
private async handleChatWaitRequest(
request: Extract<SessionInboundMessage, { type: "chat/wait" }>,
): Promise<void> {
try {
const messages = await this.chatService.waitForMessages({
room: request.room,
afterMessageId: request.afterMessageId,
timeoutMs: request.timeoutMs,
});
this.emit({
type: "chat/wait/response",
payload: {
requestId: request.requestId,
messages,
timedOut: messages.length === 0,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
private toScheduleSummary(
schedule: Awaited<ReturnType<ScheduleService["inspect"]>>,
): Extract<SessionOutboundMessage, { type: "schedule/list/response" }>["payload"]["schedules"][number] {
const { runs: _runs, ...summary } = schedule;
return summary;
}
private emitScheduleRpcError(
request: Extract<
SessionInboundMessage,
{
type:
| "schedule/create"
| "schedule/list"
| "schedule/inspect"
| "schedule/logs"
| "schedule/pause"
| "schedule/resume"
| "schedule/delete";
}
>,
error: unknown,
): void {
const message = error instanceof Error ? error.message : String(error);
this.sessionLogger.error({ err: error, requestType: request.type }, "Schedule request failed");
this.emit({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: message,
code: "schedule_request_failed",
},
});
}
private async handleScheduleCreateRequest(
request: Extract<SessionInboundMessage, { type: "schedule/create" }>,
): Promise<void> {
try {
const target =
request.target.type === "self"
? { type: "agent" as const, agentId: request.target.agentId }
: request.target;
const schedule = await this.scheduleService.create({
prompt: request.prompt,
name: request.name,
cadence: request.cadence,
target,
maxRuns: request.maxRuns,
expiresAt: request.expiresAt,
});
this.emit({
type: "schedule/create/response",
payload: {
requestId: request.requestId,
schedule: this.toScheduleSummary(schedule),
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
private async handleScheduleListRequest(
request: Extract<SessionInboundMessage, { type: "schedule/list" }>,
): Promise<void> {
try {
const schedules = await this.scheduleService.list();
this.emit({
type: "schedule/list/response",
payload: {
requestId: request.requestId,
schedules: schedules.map((schedule) => this.toScheduleSummary(schedule)),
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
private async handleScheduleInspectRequest(
request: Extract<SessionInboundMessage, { type: "schedule/inspect" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.inspect(request.scheduleId);
this.emit({
type: "schedule/inspect/response",
payload: {
requestId: request.requestId,
schedule,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
private async handleScheduleLogsRequest(
request: Extract<SessionInboundMessage, { type: "schedule/logs" }>,
): Promise<void> {
try {
const runs = await this.scheduleService.logs(request.scheduleId);
this.emit({
type: "schedule/logs/response",
payload: {
requestId: request.requestId,
runs,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
private async handleSchedulePauseRequest(
request: Extract<SessionInboundMessage, { type: "schedule/pause" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.pause(request.scheduleId);
this.emit({
type: "schedule/pause/response",
payload: {
requestId: request.requestId,
schedule: this.toScheduleSummary(schedule),
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
private async handleScheduleResumeRequest(
request: Extract<SessionInboundMessage, { type: "schedule/resume" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.resume(request.scheduleId);
this.emit({
type: "schedule/resume/response",
payload: {
requestId: request.requestId,
schedule: this.toScheduleSummary(schedule),
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
private async handleScheduleDeleteRequest(
request: Extract<SessionInboundMessage, { type: "schedule/delete" }>,
): Promise<void> {
try {
await this.scheduleService.delete(request.scheduleId);
this.emit({
type: "schedule/delete/response",
payload: {
requestId: request.requestId,
scheduleId: request.scheduleId,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
private emitLoopRpcError(
request: Extract<
SessionInboundMessage,
{
type: "loop/run" | "loop/list" | "loop/inspect" | "loop/logs" | "loop/stop";
}
>,
error: unknown,
): void {
const message = error instanceof Error ? error.message : String(error);
this.sessionLogger.error({ err: error, requestType: request.type }, "Loop request failed");
this.emit({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: message,
code: "loop_request_failed",
},
});
}
private async handleLoopRunRequest(
request: Extract<SessionInboundMessage, { type: "loop/run" }>,
): Promise<void> {
try {
const loop = await this.loopService.runLoop({
prompt: request.prompt,
cwd: request.cwd,
provider: request.provider,
model: request.model,
workerProvider: request.workerProvider,
workerModel: request.workerModel,
verifierProvider: request.verifierProvider,
verifierModel: request.verifierModel,
verifyPrompt: request.verifyPrompt,
verifyChecks: request.verifyChecks,
archive: request.archive,
name: request.name,
sleepMs: request.sleepMs,
maxIterations: request.maxIterations,
maxTimeMs: request.maxTimeMs,
});
this.emit({
type: "loop/run/response",
payload: {
requestId: request.requestId,
loop,
error: null,
},
});
} catch (error) {
this.emitLoopRpcError(request, error);
}
}
private async handleLoopListRequest(
request: Extract<SessionInboundMessage, { type: "loop/list" }>,
): Promise<void> {
try {
const loops = await this.loopService.listLoops();
this.emit({
type: "loop/list/response",
payload: {
requestId: request.requestId,
loops,
error: null,
},
});
} catch (error) {
this.emitLoopRpcError(request, error);
}
}
private async handleLoopInspectRequest(
request: Extract<SessionInboundMessage, { type: "loop/inspect" }>,
): Promise<void> {
try {
const loop = await this.loopService.inspectLoop(request.id);
this.emit({
type: "loop/inspect/response",
payload: {
requestId: request.requestId,
loop,
error: null,
},
});
} catch (error) {
this.emitLoopRpcError(request, error);
}
}
private async handleLoopLogsRequest(
request: Extract<SessionInboundMessage, { type: "loop/logs" }>,
): Promise<void> {
try {
const result = await this.loopService.getLoopLogs(request.id, request.afterSeq ?? 0);
this.emit({
type: "loop/logs/response",
payload: {
requestId: request.requestId,
loop: result.loop,
entries: result.entries,
nextCursor: result.nextCursor,
error: null,
},
});
} catch (error) {
this.emitLoopRpcError(request, error);
}
}
private async handleLoopStopRequest(
request: Extract<SessionInboundMessage, { type: "loop/stop" }>,
): Promise<void> {
try {
const loop = await this.loopService.stopLoop(request.id);
this.emit({
type: "loop/stop/response",
payload: {
requestId: request.requestId,
loop,
error: null,
},
});
} catch (error) {
this.emitLoopRpcError(request, error);
}
}
private emitTerminalsChangedSnapshot(input: {
cwd: string;
terminals: Array<{ id: string; name: string }>;
@@ -82,12 +82,6 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
undefined,
undefined,
"1.2.3-test",
undefined,
undefined,
undefined,
{} as any,
{} as any,
{} as any,
);
return { server, agentManager };
@@ -36,7 +36,6 @@ const sessionMock = vi.hoisted(() => {
handleMessage = vi.fn(async () => {});
handleBinaryFrame = vi.fn((_frame: unknown) => {});
getClientActivity = vi.fn(() => null);
resetPeakInflight = vi.fn(() => {});
getRuntimeMetrics = vi.fn(() => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
@@ -44,8 +43,6 @@ const sessionMock = vi.hoisted(() => {
checkoutDiffFallbackRefreshTargetCount: 0,
terminalDirectorySubscriptionCount: 0,
terminalSubscriptionCount: 0,
inflightRequests: 0,
peakInflightRequests: 0,
}));
readonly args: Record<string, unknown>;
@@ -154,13 +151,6 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu
{
setAgentAttentionCallback: vi.fn(),
getAgent: vi.fn(() => null),
getMetricsSnapshot: vi.fn(() => ({
totalAgents: 0,
idleAgents: 0,
runningAgents: 0,
pendingPermissionAgents: 0,
erroredAgents: 0,
})),
} as any,
{} as any,
{} as any,
@@ -177,12 +167,6 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu
: undefined,
undefined,
TEST_DAEMON_VERSION,
undefined,
undefined,
undefined,
{} as any,
{} as any,
{} as any,
);
}
@@ -9,9 +9,6 @@ import type { DownloadTokenStore } from "./file-download/token-store.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type pino from "pino";
import type { ProjectRegistry, WorkspaceRegistry } from "./workspace-registry.js";
import type { FileBackedChatService } from "./chat/chat-service.js";
import type { LoopService } from "./loop-service.js";
import type { ScheduleService } from "./schedule/service.js";
import {
type ServerInfoStatusPayload,
type WSHelloMessage,
@@ -231,9 +228,6 @@ export class VoiceAssistantWebSocketServer {
private readonly agentStorage: AgentStorage;
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
private readonly chatService: FileBackedChatService;
private readonly loopService: LoopService;
private readonly scheduleService: ScheduleService;
private readonly downloadTokenStore: DownloadTokenStore;
private readonly paseoHome: string;
private readonly pushTokenStore: PushTokenStore;
@@ -320,9 +314,6 @@ export class VoiceAssistantWebSocketServer {
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void,
projectRegistry?: ProjectRegistry,
workspaceRegistry?: WorkspaceRegistry,
chatService?: FileBackedChatService,
loopService?: LoopService,
scheduleService?: ScheduleService,
) {
this.logger = logger.child({ module: "websocket-server" });
this.serverId = serverId;
@@ -334,18 +325,6 @@ export class VoiceAssistantWebSocketServer {
this.agentStorage = agentStorage;
this.projectRegistry = projectRegistry ?? createNoopProjectRegistry();
this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry();
if (!chatService) {
throw new Error("VoiceAssistantWebSocketServer requires a chat service.");
}
this.chatService = chatService;
if (!loopService) {
throw new Error("VoiceAssistantWebSocketServer requires a loop service.");
}
this.loopService = loopService;
if (!scheduleService) {
throw new Error("VoiceAssistantWebSocketServer requires a schedule service.");
}
this.scheduleService = scheduleService;
this.downloadTokenStore = downloadTokenStore;
this.paseoHome = paseoHome;
this.createAgentMcpTransport = createAgentMcpTransport;
@@ -643,9 +622,6 @@ export class VoiceAssistantWebSocketServer {
agentStorage: this.agentStorage,
projectRegistry: this.projectRegistry,
workspaceRegistry: this.workspaceRegistry,
chatService: this.chatService,
loopService: this.loopService,
scheduleService: this.scheduleService,
createAgentMcpTransport: this.createAgentMcpTransport,
stt: this.stt,
tts: this.tts,
-120
View File
@@ -3,50 +3,6 @@ import { AGENT_LIFECYCLE_STATUSES } from "./agent-lifecycle.js";
import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "../server/agent/agent-title-limits.js";
import { AgentProviderSchema } from "../server/agent/provider-manifest.js";
import { TOOL_CALL_ICON_NAMES } from "../server/agent/agent-sdk-types.js";
import {
ChatCreateRequestSchema,
ChatListRequestSchema,
ChatInspectRequestSchema,
ChatDeleteRequestSchema,
ChatPostRequestSchema,
ChatReadRequestSchema,
ChatWaitRequestSchema,
ChatCreateResponseSchema,
ChatListResponseSchema,
ChatInspectResponseSchema,
ChatDeleteResponseSchema,
ChatPostResponseSchema,
ChatReadResponseSchema,
ChatWaitResponseSchema,
} from "../server/chat/chat-rpc-schemas.js";
import {
ScheduleCreateRequestSchema,
ScheduleListRequestSchema,
ScheduleInspectRequestSchema,
ScheduleLogsRequestSchema,
SchedulePauseRequestSchema,
ScheduleResumeRequestSchema,
ScheduleDeleteRequestSchema,
ScheduleCreateResponseSchema,
ScheduleListResponseSchema,
ScheduleInspectResponseSchema,
ScheduleLogsResponseSchema,
SchedulePauseResponseSchema,
ScheduleResumeResponseSchema,
ScheduleDeleteResponseSchema,
} from "../server/schedule/rpc-schemas.js";
import {
LoopRunRequestSchema,
LoopListRequestSchema,
LoopInspectRequestSchema,
LoopLogsRequestSchema,
LoopStopRequestSchema,
LoopRunResponseSchema,
LoopListResponseSchema,
LoopInspectResponseSchema,
LoopLogsResponseSchema,
LoopStopResponseSchema,
} from "../server/loop/rpc-schemas.js";
import type {
AgentCapabilityFlags,
AgentModelDefinition,
@@ -1248,25 +1204,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
UnsubscribeTerminalRequestSchema,
TerminalInputSchema,
KillTerminalRequestSchema,
ChatCreateRequestSchema,
ChatListRequestSchema,
ChatInspectRequestSchema,
ChatDeleteRequestSchema,
ChatPostRequestSchema,
ChatReadRequestSchema,
ChatWaitRequestSchema,
ScheduleCreateRequestSchema,
ScheduleListRequestSchema,
ScheduleInspectRequestSchema,
ScheduleLogsRequestSchema,
SchedulePauseRequestSchema,
ScheduleResumeRequestSchema,
ScheduleDeleteRequestSchema,
LoopRunRequestSchema,
LoopListRequestSchema,
LoopInspectRequestSchema,
LoopLogsRequestSchema,
LoopStopRequestSchema,
]);
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
@@ -2320,25 +2257,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
SubscribeTerminalResponseSchema,
KillTerminalResponseSchema,
TerminalStreamExitSchema,
ChatCreateResponseSchema,
ChatListResponseSchema,
ChatInspectResponseSchema,
ChatDeleteResponseSchema,
ChatPostResponseSchema,
ChatReadResponseSchema,
ChatWaitResponseSchema,
ScheduleCreateResponseSchema,
ScheduleListResponseSchema,
ScheduleInspectResponseSchema,
ScheduleLogsResponseSchema,
SchedulePauseResponseSchema,
ScheduleResumeResponseSchema,
ScheduleDeleteResponseSchema,
LoopRunResponseSchema,
LoopListResponseSchema,
LoopInspectResponseSchema,
LoopLogsResponseSchema,
LoopStopResponseSchema,
]);
export type SessionOutboundMessage = z.infer<typeof SessionOutboundMessageSchema>;
@@ -2383,25 +2301,6 @@ export type ListProviderModelsResponseMessage = z.infer<
export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>;
export type SpeechModelsListResponse = z.infer<typeof SpeechModelsListResponseSchema>;
export type SpeechModelsDownloadResponse = z.infer<typeof SpeechModelsDownloadResponseSchema>;
export type ChatCreateResponse = z.infer<typeof ChatCreateResponseSchema>;
export type ChatListResponse = z.infer<typeof ChatListResponseSchema>;
export type ChatInspectResponse = z.infer<typeof ChatInspectResponseSchema>;
export type ChatDeleteResponse = z.infer<typeof ChatDeleteResponseSchema>;
export type ChatPostResponse = z.infer<typeof ChatPostResponseSchema>;
export type ChatReadResponse = z.infer<typeof ChatReadResponseSchema>;
export type ChatWaitResponse = z.infer<typeof ChatWaitResponseSchema>;
export type ScheduleCreateResponse = z.infer<typeof ScheduleCreateResponseSchema>;
export type ScheduleListResponse = z.infer<typeof ScheduleListResponseSchema>;
export type ScheduleInspectResponse = z.infer<typeof ScheduleInspectResponseSchema>;
export type ScheduleLogsResponse = z.infer<typeof ScheduleLogsResponseSchema>;
export type SchedulePauseResponse = z.infer<typeof SchedulePauseResponseSchema>;
export type ScheduleResumeResponse = z.infer<typeof ScheduleResumeResponseSchema>;
export type ScheduleDeleteResponse = z.infer<typeof ScheduleDeleteResponseSchema>;
export type LoopRunResponse = z.infer<typeof LoopRunResponseSchema>;
export type LoopListResponse = z.infer<typeof LoopListResponseSchema>;
export type LoopInspectResponse = z.infer<typeof LoopInspectResponseSchema>;
export type LoopLogsResponse = z.infer<typeof LoopLogsResponseSchema>;
export type LoopStopResponse = z.infer<typeof LoopStopResponseSchema>;
// Type exports for payload types
export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>;
@@ -2424,25 +2323,6 @@ export type ListProviderModelsRequestMessage = z.infer<
export type ListAvailableProvidersRequestMessage = z.infer<
typeof ListAvailableProvidersRequestMessageSchema
>;
export type ChatCreateRequest = z.infer<typeof ChatCreateRequestSchema>;
export type ChatListRequest = z.infer<typeof ChatListRequestSchema>;
export type ChatInspectRequest = z.infer<typeof ChatInspectRequestSchema>;
export type ChatDeleteRequest = z.infer<typeof ChatDeleteRequestSchema>;
export type ChatPostRequest = z.infer<typeof ChatPostRequestSchema>;
export type ChatReadRequest = z.infer<typeof ChatReadRequestSchema>;
export type ChatWaitRequest = z.infer<typeof ChatWaitRequestSchema>;
export type ScheduleCreateRequest = z.infer<typeof ScheduleCreateRequestSchema>;
export type ScheduleListRequest = z.infer<typeof ScheduleListRequestSchema>;
export type ScheduleInspectRequest = z.infer<typeof ScheduleInspectRequestSchema>;
export type ScheduleLogsRequest = z.infer<typeof ScheduleLogsRequestSchema>;
export type SchedulePauseRequest = z.infer<typeof SchedulePauseRequestSchema>;
export type ScheduleResumeRequest = z.infer<typeof ScheduleResumeRequestSchema>;
export type ScheduleDeleteRequest = z.infer<typeof ScheduleDeleteRequestSchema>;
export type LoopRunRequest = z.infer<typeof LoopRunRequestSchema>;
export type LoopListRequest = z.infer<typeof LoopListRequestSchema>;
export type LoopInspectRequest = z.infer<typeof LoopInspectRequestSchema>;
export type LoopLogsRequest = z.infer<typeof LoopLogsRequestSchema>;
export type LoopStopRequest = z.infer<typeof LoopStopRequestSchema>;
export type SpeechModelsListRequestMessage = z.infer<typeof SpeechModelsListRequestSchema>;
export type SpeechModelsDownloadRequestMessage = z.infer<typeof SpeechModelsDownloadRequestSchema>;
export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessageSchema>;
-1
View File
@@ -16,7 +16,6 @@
"@tanstack/react-router": "^1.120.3",
"@tanstack/react-start": "^1.120.3",
"framer-motion": "^12.35.2",
"lucide-react": "^1.7.0",
"react": "^19.1.4",
"react-dom": "^19.1.4",
"react-markdown": "^10.1.0",
@@ -1,7 +0,0 @@
---
title: "Draft Example"
description: "A draft post for testing the draft system."
date: "2026-03-29"
---
This is a draft post. It only appears when `?drafts` is in the URL.
-7
View File
@@ -1,7 +0,0 @@
---
title: "Hello World"
description: "The first post on the Paseo blog."
date: "2026-03-29"
---
Welcome to the Paseo blog.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

@@ -1,737 +0,0 @@
import * as React from "react";
import { motion } from "framer-motion";
import {
FolderGit2,
Paperclip,
ArrowUp,
X,
MessagesSquare,
Plus,
Settings,
Monitor,
GitCommitHorizontal,
ChevronDown,
ChevronRight,
ListChevronsUpDown,
Bot,
Check,
Loader,
SquareTerminal,
PanelLeft,
Ellipsis,
Mic,
GitPullRequest,
SquarePen,
Columns2,
Rows2,
} from "lucide-react";
import { ClaudeIcon, CodexIcon, SourceControlIcon } from "~/components/mockup";
// ── Stagger timing ──────────────────────────────────────
const D = {
shell: 0,
sidebar: 0.1,
titleBar: 0.15,
tabs: 0.25,
chat: 0.35,
diffPanel: 0.55,
};
function fade(delay: number) {
return {
initial: { opacity: 0, y: 8 } as const,
animate: { opacity: 1, y: 0 } as const,
transition: { duration: 0.5, delay, ease: "easeOut" as const },
};
}
// ── Data ────────────────────────────────────────────────
type ChatItem =
| { type: "user"; text: string }
| { type: "text"; text: string; bold?: boolean }
| { type: "tool"; label: string; summary?: string; status: "running" | "done" };
const CHAT: ChatItem[] = [
{ type: "user", text: "Build me an internal dashboard for tracking customer returns" },
{ type: "text", text: "I'll break this down into planning and implementation.", bold: true },
{ type: "tool", label: "Run plan-technical", summary: "codex · plan-technical", status: "done" },
{ type: "tool", label: "Run plan-design", summary: "claude · plan-design", status: "done" },
{ type: "tool", label: "Wait for agents", summary: "plan-technical plan-design", status: "done" },
{ type: "text", text: "Got the plans. Spinning up Codex for implementation.", bold: true },
{ type: "tool", label: "Run implement", summary: "codex · 12 files changed", status: "done" },
{ type: "text", text: "Implementation done. Requesting review from Claude." },
{ type: "tool", label: "Run review", summary: "claude · no issues found", status: "running" },
{ type: "text", text: "All tasks complete. Dashboard is ready.", bold: true },
];
// ── Sidebar data ────────────────────────────────────────
type SidebarWorkspace = {
name: string;
kind: "checkout" | "worktree";
status: "running" | "done" | "idle" | "syncing";
selected?: boolean;
diffStat?: { additions: number; deletions: number };
pr?: { number: number; state: "open" | "merged" | "closed" };
};
type SidebarProject = {
initial: string;
name: string;
workspaces: SidebarWorkspace[];
};
const SIDEBAR_PROJECTS: SidebarProject[] = [
{
initial: "A",
name: "acme/returns-app",
workspaces: [
{ name: "main", kind: "checkout", status: "syncing", selected: true, diffStat: { additions: 247, deletions: 15 } },
{ name: "feat/dashboard", kind: "worktree", status: "done", pr: { number: 142, state: "open" } },
],
},
{
initial: "P",
name: "acme/payments",
workspaces: [
{ name: "main", kind: "checkout", status: "idle" },
{ name: "fix/stripe-webhook", kind: "worktree", status: "done", diffStat: { additions: 38, deletions: 4 }, pr: { number: 89, state: "merged" } },
],
},
{
initial: "I",
name: "acme/infra",
workspaces: [
{ name: "main", kind: "checkout", status: "idle" },
{ name: "feat/k8s-autoscale", kind: "worktree", status: "syncing", diffStat: { additions: 91, deletions: 3 } },
],
},
{
initial: "D",
name: "acme/design-system",
workspaces: [
{ name: "main", kind: "checkout", status: "idle" },
],
},
];
// ── Tab data ─────────────────────────────────────────────
type TabDef = {
name: string;
provider: "claude" | "codex" | "terminal";
done: boolean;
active?: boolean;
};
const TABS: TabDef[] = [
{ name: "Orchestrator", provider: "claude", done: false, active: true },
{ name: "Implement", provider: "codex", done: true },
{ name: "Review", provider: "claude", done: true },
];
// ── Diff data ──────────────────────────────────────────
type DiffLineType = "add" | "remove" | "context" | "header";
type DiffLine = {
type: DiffLineType;
ln: string | null;
content?: string;
tokens?: Array<{ text: string; cls: string }>;
};
const DIFF_LINES: DiffLine[] = [
{ type: "add", ln: "1", tokens: [{ text: "import", cls: "text-syn-keyword" }, { text: " { ", cls: "text-syn-punctuation" }, { text: "useState", cls: "text-syn-variable" }, { text: " } ", cls: "text-syn-punctuation" }, { text: "from", cls: "text-syn-keyword" }, { text: ' "react"', cls: "text-syn-string" }] },
{ type: "add", ln: "2", tokens: [{ text: "import", cls: "text-syn-keyword" }, { text: " { ", cls: "text-syn-punctuation" }, { text: "ReturnTable", cls: "text-syn-variable" }, { text: " } ", cls: "text-syn-punctuation" }, { text: "from", cls: "text-syn-keyword" }, { text: ' "./components"', cls: "text-syn-string" }] },
{ type: "add", ln: "3", tokens: [] },
{ type: "add", ln: "4", tokens: [{ text: "export", cls: "text-syn-keyword" }, { text: " function", cls: "text-syn-keyword" }, { text: " Dashboard", cls: "text-syn-function" }, { text: "() {", cls: "text-syn-punctuation" }] },
{ type: "add", ln: "5", tokens: [{ text: " const", cls: "text-syn-keyword" }, { text: " [returns, setReturns]", cls: "text-syn-variable" }, { text: " = ", cls: "text-syn-operator" }, { text: "useState", cls: "text-syn-function" }, { text: "([])", cls: "text-syn-punctuation" }] },
{ type: "add", ln: "6", tokens: [{ text: " const", cls: "text-syn-keyword" }, { text: " [filter, setFilter]", cls: "text-syn-variable" }, { text: " = ", cls: "text-syn-operator" }, { text: "useState", cls: "text-syn-function" }, { text: '("all")', cls: "text-syn-string" }] },
{ type: "add", ln: "7", tokens: [] },
{ type: "add", ln: "8", tokens: [{ text: " ", cls: "text-syn-punctuation" }, { text: "return", cls: "text-syn-keyword" }, { text: " (", cls: "text-syn-punctuation" }] },
{ type: "add", ln: "9", tokens: [{ text: " <", cls: "text-syn-punctuation" }, { text: "main", cls: "text-syn-tag" }, { text: " className", cls: "text-syn-property" }, { text: '="', cls: "text-syn-punctuation" }, { text: "min-h-screen p-8", cls: "text-syn-string" }, { text: '">', cls: "text-syn-punctuation" }] },
{ type: "add", ln: "10", tokens: [{ text: " <", cls: "text-syn-punctuation" }, { text: "h1", cls: "text-syn-tag" }, { text: ">Customer Returns</", cls: "text-syn-variable" }, { text: "h1", cls: "text-syn-tag" }, { text: ">", cls: "text-syn-punctuation" }] },
{ type: "add", ln: "11", tokens: [{ text: " <", cls: "text-syn-punctuation" }, { text: "FilterBar", cls: "text-syn-tag" }, { text: " value", cls: "text-syn-property" }, { text: "={", cls: "text-syn-punctuation" }, { text: "filter", cls: "text-syn-variable" }, { text: "}", cls: "text-syn-punctuation" }, { text: " onChange", cls: "text-syn-property" }, { text: "={", cls: "text-syn-punctuation" }, { text: "setFilter", cls: "text-syn-variable" }, { text: "} />", cls: "text-syn-punctuation" }] },
{ type: "add", ln: "12", tokens: [{ text: " <", cls: "text-syn-punctuation" }, { text: "ReturnTable", cls: "text-syn-tag" }, { text: " data", cls: "text-syn-property" }, { text: "={", cls: "text-syn-punctuation" }, { text: "returns", cls: "text-syn-variable" }, { text: "} />", cls: "text-syn-punctuation" }] },
{ type: "add", ln: "13", tokens: [{ text: " <", cls: "text-syn-punctuation" }, { text: "StatusChart", cls: "text-syn-tag" }, { text: " data", cls: "text-syn-property" }, { text: "={", cls: "text-syn-punctuation" }, { text: "returns", cls: "text-syn-variable" }, { text: "} />", cls: "text-syn-punctuation" }] },
{ type: "add", ln: "14", tokens: [{ text: " </", cls: "text-syn-punctuation" }, { text: "main", cls: "text-syn-tag" }, { text: ">", cls: "text-syn-punctuation" }] },
{ type: "add", ln: "15", tokens: [{ text: " )", cls: "text-syn-punctuation" }] },
{ type: "add", ln: "16", tokens: [{ text: "}", cls: "text-syn-punctuation" }] },
];
// ── Implementation agent chat data ──────────────────────
const IMPLEMENT_CHAT: ChatItem[] = [
{ type: "text", text: "Starting implementation of the returns dashboard.", bold: true },
{ type: "tool", label: "Edit file", summary: "src/pages/dashboard.tsx", status: "done" },
{ type: "tool", label: "Edit file", summary: "src/components/return-table.tsx", status: "done" },
{ type: "tool", label: "Edit file", summary: "src/components/filter-bar.tsx", status: "done" },
{ type: "tool", label: "Edit file", summary: "src/components/status-chart.tsx", status: "done" },
{ type: "tool", label: "Run command", summary: "npm run typecheck", status: "done" },
{ type: "text", text: "Typecheck passes. Adding API route and data fetching." },
{ type: "tool", label: "Edit file", summary: "src/api/returns.ts", status: "done" },
{ type: "tool", label: "Edit file", summary: "src/hooks/use-returns.ts", status: "done" },
{ type: "tool", label: "Run command", summary: "npm run typecheck", status: "running" },
];
// ── Terminal log lines ─────────────────────────────────
const TERMINAL_LINES = [
{ text: "$ npm run dev", cls: "text-mock-fg" },
{ text: "", cls: "" },
{ text: "> acme-returns@0.1.0 dev", cls: "text-mock-fg-muted" },
{ text: "> next dev --turbopack", cls: "text-mock-fg-muted" },
{ text: "", cls: "" },
{ text: " ▲ Next.js 15.3.1 (Turbopack)", cls: "text-mock-fg" },
{ text: " - Local: http://localhost:3000", cls: "text-mock-fg-muted" },
{ text: "", cls: "" },
{ text: " ✓ Starting...", cls: "text-mock-green" },
{ text: " ✓ Ready in 1.2s", cls: "text-mock-green" },
{ text: " ○ Compiling /dashboard ...", cls: "text-mock-fg-muted" },
{ text: " ✓ Compiled /dashboard in 340ms", cls: "text-mock-green" },
{ text: " ○ Compiling /api/returns ...", cls: "text-mock-fg-muted" },
{ text: " ✓ Compiled /api/returns in 120ms", cls: "text-mock-green" },
];
// ── Sub-components ──────────────────────────────────────
function TrafficLights() {
return (
<div className="flex items-center gap-[6px]">
<div className="w-[11px] h-[11px] rounded-full bg-[#ff5f57]" />
<div className="w-[11px] h-[11px] rounded-full bg-[#febc2e]" />
<div className="w-[11px] h-[11px] rounded-full bg-[#28c840]" />
</div>
);
}
function ProviderIcon({ provider, muted = false }: { provider: "claude" | "codex" | "terminal"; muted?: boolean }) {
const cls = muted ? "text-mock-fg-muted" : "text-mock-fg";
if (provider === "terminal") return <SquareTerminal size={13} className={cls} />;
return provider === "claude"
? <ClaudeIcon size={13} className={cls} />
: <CodexIcon size={13} className={cls} />;
}
function TabBarAction({ icon: Icon }: { icon: React.ComponentType<{ size?: number; className?: string; strokeWidth?: number }> }) {
return (
<div className="w-4 h-5 flex items-center justify-center flex-shrink-0">
<Icon size={12} strokeWidth={1.5} className="text-mock-fg-muted" />
</div>
);
}
function PaneTabBar({ tabs, focused = false }: { tabs: TabDef[]; focused?: boolean }) {
return (
<div className="flex items-stretch h-7 bg-mock-surface0 border-b border-mock-border flex-shrink-0">
{tabs.map((tab) => (
<div key={tab.name} className="flex items-center gap-1.5 px-2 border-r border-mock-border relative min-w-0">
{tab.active && (
<div className={`absolute top-0 left-0 right-0 h-0.5 ${focused ? "bg-mock-accent" : "bg-mock-border-accent"}`} />
)}
<div className="relative flex-shrink-0">
<ProviderIcon provider={tab.provider} muted={!tab.active} />
</div>
<span className={`text-[11px] truncate ${tab.active ? "text-mock-fg" : "text-mock-fg-muted"}`}>
{tab.name}
</span>
<X size={11} className="text-mock-zinc500 flex-shrink-0 ml-auto" />
</div>
))}
<div className="ml-auto flex items-center px-1.5">
<TabBarAction icon={SquarePen} />
<TabBarAction icon={SquareTerminal} />
<TabBarAction icon={Columns2} />
<TabBarAction icon={Rows2} />
</div>
</div>
);
}
function Composer({ provider, model, focused = false }: { provider: "claude" | "codex"; model: string; focused?: boolean }) {
const Icon = provider === "claude" ? ClaudeIcon : CodexIcon;
return (
<div className="px-3 pb-3 flex-shrink-0">
<div className="bg-mock-surface1 border border-mock-border-accent rounded-2xl px-3 py-2 flex flex-col gap-[10px]">
<div className="flex items-center">
{focused && (
<span className="inline-block w-[1px] h-[13px] bg-mock-fg animate-pulse mr-[1px] flex-shrink-0" />
)}
<span className="text-[11px] text-mock-zinc500 leading-[1.4] select-none whitespace-nowrap overflow-hidden text-ellipsis">
Message the agent, tag @files, or use /commands and /skills
</span>
</div>
<div className="flex items-end justify-between">
<div className="flex items-center gap-0.5">
<div className="w-[22px] h-[22px] rounded-full flex items-center justify-center flex-shrink-0">
<Paperclip size={12} className="text-mock-fg" />
</div>
<div className="flex items-center gap-[4px] h-[22px] px-[6px] rounded-full">
<Icon size={12} className="text-mock-fg-muted" />
<span className="text-[10px] text-mock-fg-muted leading-none">{model}</span>
<ChevronDown size={10} className="text-mock-fg-muted" />
</div>
</div>
<div className="flex items-center gap-[6px]">
<div className="w-[22px] h-[22px] rounded-full flex items-center justify-center flex-shrink-0">
<Mic size={12} className="text-mock-fg" />
</div>
<div className="w-[22px] h-[22px] rounded-full bg-mock-accent flex items-center justify-center flex-shrink-0">
<ArrowUp size={12} className="text-white" />
</div>
</div>
</div>
</div>
</div>
);
}
function ChatMessages({ items }: { items: ChatItem[] }) {
return (
<div className="flex-1 overflow-hidden px-2 py-2">
{items.map((item, i) => {
if (item.type === "user") {
return (
<div key={i} className="flex justify-end py-1">
<div
className="bg-mock-surface2 px-2.5 py-1.5 max-w-[85%] leading-none"
style={{ borderRadius: "16px 2px 16px 16px" }}
>
<span className="text-[11px] text-mock-fg leading-none">{item.text}</span>
</div>
</div>
);
}
if (item.type === "tool") {
const isDone = item.status === "done";
return (
<div
key={i}
className="flex items-center rounded-lg px-2 py-[1px] -mx-[6px] border border-transparent"
>
<div className="w-[22px] h-[22px] rounded-full flex items-center justify-center flex-shrink-0 mr-1">
<Bot size={12} className="text-mock-fg-muted" />
</div>
<span className="text-[11px] text-mock-fg-muted truncate flex-shrink-0">
{item.label}
</span>
{item.summary && (
<span className="text-[11px] text-mock-zinc500 truncate flex-1 ml-2">
{item.summary}
</span>
)}
<div className="ml-auto flex-shrink-0 pl-2">
{isDone ? (
<Check size={11} className="text-mock-zinc500" />
) : (
<Loader size={11} className="text-mock-fg-muted animate-spin" />
)}
</div>
</div>
);
}
return (
<p
key={i}
className={`text-[11px] px-2 py-[1px] text-mock-fg leading-[1.5] ${item.bold ? "font-medium" : ""}`}
>
{item.text}
</p>
);
})}
</div>
);
}
function ChatArea() {
return (
<div className="flex flex-col flex-1 min-w-0 bg-mock-surface0">
<ChatMessages items={CHAT} />
<Composer provider="claude" model="Opus 4.6" focused />
</div>
);
}
function ImplementPane() {
return (
<div className="flex flex-col flex-1 min-w-0 min-h-0 bg-mock-surface0">
<ChatMessages items={IMPLEMENT_CHAT} />
<Composer provider="codex" model="gpt-5.4" />
</div>
);
}
function TerminalPane() {
return (
<div className="flex flex-col flex-1 min-w-0 min-h-0 bg-mock-surface0">
<div className="flex-1 overflow-hidden px-3 py-2">
{TERMINAL_LINES.map((line, i) => (
<div key={i} className="leading-[1.3]">
<code className={`text-[10px] font-mono ${line.cls} whitespace-pre`}>{line.text}</code>
</div>
))}
</div>
</div>
);
}
function ExplorerSidebar() {
return (
<div className="flex flex-col bg-mock-sidebar border-l border-mock-border w-[28%] flex-shrink-0 overflow-hidden">
{/* Explorer header — h-12, Changes/Files tabs */}
<div className="flex items-center h-10 px-2 border-b border-mock-border flex-shrink-0">
<div className="flex items-center gap-1">
<div className="flex items-center px-3 py-1.5 rounded-md bg-mock-surface1">
<span className="text-[11px] text-mock-fg font-medium">Changes</span>
</div>
<div className="flex items-center px-3 py-1.5 rounded-md">
<span className="text-[11px] text-mock-fg-muted">Files</span>
</div>
</div>
</div>
{/* Secondary header — h-9, Uncommitted dropdown + filter icons */}
<div className="flex items-center justify-between h-9 border-b border-mock-border flex-shrink-0">
<div className="flex items-center gap-1 ml-2 px-1 h-6 rounded">
<span className="text-[11px] text-mock-fg-muted">Uncommitted</span>
<ChevronDown size={11} className="text-mock-fg-muted" />
</div>
<div className="flex items-center pr-2">
<div className="flex items-center justify-center w-6 h-6 rounded">
<ListChevronsUpDown size={14} className="text-mock-fg-muted" />
</div>
</div>
</div>
{/* File header — expanded */}
<div className="flex items-center justify-between pl-2 pr-2 py-1.5 border-b border-mock-border flex-shrink-0">
<div className="flex items-center gap-1 flex-1 min-w-0 overflow-hidden">
<ChevronDown size={10} className="text-mock-fg-muted flex-shrink-0" />
<span className="text-[11px] text-mock-fg flex-shrink-0">dashboard.tsx</span>
<span className="text-[11px] text-mock-fg-muted truncate min-w-0"> src/pages</span>
<span
className="text-[10px] text-mock-green-400 px-2 py-[2px] rounded-md flex-shrink-0"
style={{ backgroundColor: "rgba(46, 160, 67, 0.2)" }}
>New</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0 ml-1">
<span className="text-[11px] text-mock-green-400">+16</span>
<span className="text-[11px] text-mock-red">-0</span>
</div>
</div>
{/* Diff lines */}
<div className="overflow-hidden bg-mock-surface1">
{DIFF_LINES.map((line, i) => {
const isAdd = line.type === "add";
const isRemove = line.type === "remove";
const lineBg = isAdd ? "bg-mock-diff-add" : isRemove ? "bg-mock-diff-remove" : "bg-mock-surface1";
const lineNumCls = isAdd ? "text-mock-green-400" : isRemove ? "text-mock-red" : "text-mock-fg-muted";
return (
<div key={i} className={`flex items-stretch ${lineBg}`}>
<div className="w-8 border-r border-mock-border flex-shrink-0 flex items-center justify-end">
<code className={`text-[10px] font-mono ${lineNumCls} select-none pr-2 py-[1px]`}>{line.ln ?? ""}</code>
</div>
<code className="text-[10px] font-mono text-mock-fg pl-3 pr-3 py-[1px] whitespace-pre flex-1 min-w-0">
{line.tokens?.map((tok, j) => (
<span key={j} className={tok.cls}>{tok.text}</span>
))}
</code>
</div>
);
})}
</div>
{/* Collapsed file rows */}
{[
{ name: "return-table.tsx", dir: "src/components", added: 42, removed: 8 },
{ name: "filter-bar.tsx", dir: "src/components", added: 28, removed: 3 },
{ name: "status-chart.tsx", dir: "src/components", added: 19, removed: 0, isNew: true },
{ name: "returns.ts", dir: "src/api", added: 12, removed: 5 },
{ name: "index.tsx", dir: "src/pages", added: 6, removed: 2 },
].map((file) => (
<div key={file.name} className="flex items-center justify-between pl-2 pr-2 py-1.5 border-b border-mock-border">
<div className="flex items-center gap-1 flex-1 min-w-0 overflow-hidden">
<ChevronRight size={10} className="text-mock-fg-muted flex-shrink-0" />
<span className="text-[11px] text-mock-fg flex-shrink-0">{file.name}</span>
<span className="text-[11px] text-mock-fg-muted truncate min-w-0"> {file.dir}</span>
{file.isNew && (
<span
className="text-[10px] text-mock-green-400 px-2 py-[2px] rounded-md flex-shrink-0"
style={{ backgroundColor: "rgba(46, 160, 67, 0.2)" }}
>New</span>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0 ml-1">
<span className="text-[11px] text-mock-green-400">+{file.added}</span>
<span className="text-[11px] text-mock-red">-{file.removed}</span>
</div>
</div>
))}
<div className="flex-1" />
</div>
);
}
// Snake traversal order matching the real app: [0, 1, 3, 5, 4, 2] (2-col × 3-row grid)
// DOT_SEQUENCE[sequenceIndex] = dotIndex, so sequenceIndex = indexOf(dotIndex)
const DOT_SEQUENCE = [0, 1, 3, 5, 4, 2] as const;
const SYNCED_LOADER_DURATION_MS = 950;
const DOT_COUNT = 6;
const STEP_MS = SYNCED_LOADER_DURATION_MS / DOT_COUNT; // 158.333…ms per step
function SyncedLoader({ size = 11 }: { size?: number }) {
const gap = Math.max(1, Math.round(size * 0.12));
const dotSize = Math.max(2, Math.floor((size - gap * 2) / 3));
const gridW = dotSize * 2 + gap;
const gridH = dotSize * 3 + gap * 2;
return (
<div style={{ width: size, height: size, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
<div style={{ position: "relative", width: gridW, height: gridH }}>
{Array.from({ length: DOT_COUNT }).map((_, dotIndex) => {
const col = dotIndex % 2;
const row = Math.floor(dotIndex / 2);
// The snake sequence index for this dot: when is it the "head"?
const sequenceIndex = DOT_SEQUENCE.indexOf(dotIndex as (typeof DOT_SEQUENCE)[number]);
// Negative delay syncs the animation so this dot is the head at t=0 when sequenceIndex=0.
// At t=0, headIndex should be 0, meaning sequenceIndex=0 dot is the head.
// Each dot is shifted by sequenceIndex steps back in time.
const delayMs = -(sequenceIndex * STEP_MS);
return (
<div
key={dotIndex}
style={{
position: "absolute",
left: col * (dotSize + gap),
top: row * (dotSize + gap),
width: dotSize,
height: dotSize,
borderRadius: "50%",
backgroundColor: "#f59e0b",
animationName: "synced-snake-dot",
animationDuration: `${SYNCED_LOADER_DURATION_MS}ms`,
animationTimingFunction: "linear",
animationIterationCount: "infinite",
animationDelay: `${delayMs}ms`,
}}
/>
);
})}
</div>
</div>
);
}
function WorkspaceStatusDot({ status }: { status: SidebarWorkspace["status"] }) {
if (status === "running") return <div className="w-[5px] h-[5px] rounded-full bg-mock-amber" />;
if (status === "done") return <div className="w-[5px] h-[5px] rounded-full bg-mock-green" />;
return null;
}
function Sidebar() {
// Shared left edge: pl-3 (12px) for traffic lights, icons, footer dot
// Workspace indent: pl-7 (28px)
// PR badge aligns with workspace text: pl-[42px]
return (
<div className="flex flex-col bg-mock-sidebar border-r border-mock-border w-[200px] flex-shrink-0">
{/* Traffic lights */}
<div className="flex items-center pl-3 pr-2 pt-3 pb-2">
<TrafficLights />
</div>
{/* Sessions header */}
<div className="flex items-center gap-2 pl-3 pr-2 py-2 border-b border-mock-border">
<MessagesSquare size={16} className="text-mock-fg-muted flex-shrink-0" />
<span className="text-sm text-mock-fg-muted">Sessions</span>
</div>
{/* Project list */}
<div className="flex-1 overflow-hidden pt-1 pb-4 min-h-0">
{SIDEBAR_PROJECTS.map((project) => (
<div key={project.name} className="mb-2">
{/* Project row — icon aligns with traffic lights / sessions */}
<div className="flex items-center gap-2 min-h-[32px] py-1.5 pl-3 pr-2">
<div className="w-4 h-4 rounded-sm border border-mock-border flex items-center justify-center flex-shrink-0">
<span className="text-[9px] text-mock-fg-muted leading-none">{project.initial}</span>
</div>
<span className="text-[13px] text-mock-fg font-normal truncate flex-1 min-w-0 leading-5">
{project.name}
</span>
</div>
{/* Workspace rows — indented one level */}
{project.workspaces.map((workspace) => (
<div key={workspace.name} className={`mb-1 mx-1.5 rounded-lg ${workspace.selected ? "bg-mock-surface1" : ""}`}>
<div className="flex items-center gap-2 min-h-[28px] py-1 pl-[22px] pr-1">
<div className="relative w-[14px] h-4 flex-shrink-0 flex items-center justify-center">
{workspace.status === "syncing" ? (
<SyncedLoader size={11} />
) : (
<>
{workspace.kind === "worktree"
? <FolderGit2 size={14} className="text-mock-fg-muted" />
: <Monitor size={14} className="text-mock-fg-muted" />
}
{workspace.status !== "idle" && (
<div className="absolute bottom-0 right-0">
<WorkspaceStatusDot status={workspace.status} />
</div>
)}
</>
)}
</div>
<span className="text-[12px] text-mock-fg/[0.76] font-normal truncate flex-1 min-w-0 leading-[1.4]">
{workspace.name}
</span>
{workspace.diffStat && (
<div className="flex items-center gap-1 flex-shrink-0">
<span className="text-[10px] text-mock-green-400 font-normal leading-none">+{workspace.diffStat.additions}</span>
<span className="text-[10px] text-mock-red font-normal leading-none">-{workspace.diffStat.deletions}</span>
</div>
)}
</div>
{workspace.pr && (
<div className="flex items-center gap-1 pl-[42px] pr-2 pb-0.5">
<GitPullRequest size={11} className="text-mock-fg-muted" />
<span className="text-[10px] text-mock-fg-muted leading-none truncate">
#{workspace.pr.number} · {workspace.pr.state === "open" ? "Open" : workspace.pr.state === "merged" ? "Merged" : "Closed"}
</span>
</div>
)}
</div>
))}
</div>
))}
</div>
{/* Footer — dot aligns with traffic lights / icons */}
<div className="flex items-center justify-between pl-3 pr-2 py-3 border-t border-mock-border">
<div className="flex items-center gap-2 min-w-0 flex-shrink">
<div className="w-2 h-2 rounded-full bg-mock-green flex-shrink-0" />
<span className="text-[13px] text-mock-fg-muted truncate">MacBook Pro</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<div className="w-6 h-6 flex items-center justify-center">
<Plus size={18} className="text-mock-fg-muted" />
</div>
<div className="w-6 h-6 flex items-center justify-center">
<Settings size={18} className="text-mock-fg-muted" />
</div>
</div>
</div>
</div>
);
}
// ── Desktop Mockup ──────────────────────────────────────
function DesktopMockup() {
const containerRef = React.useRef<HTMLDivElement>(null);
const [scale, setScale] = React.useState(1);
React.useEffect(() => {
function updateScale() {
if (!containerRef.current) return;
const parentWidth = containerRef.current.parentElement?.clientWidth ?? 1200;
const designWidth = 1200;
setScale(Math.min(1, parentWidth / designWidth));
}
updateScale();
window.addEventListener("resize", updateScale);
return () => window.removeEventListener("resize", updateScale);
}, []);
return (
<div ref={containerRef} className="w-full overflow-hidden" style={{ height: `${1200 * (9 / 16) * scale}px` }}>
<div
className="mx-auto rounded-xl overflow-hidden border border-mock-border bg-mock-surface0 shadow-[6px_6px_0_rgba(0,0,0,0.4)] origin-top-left"
style={{ width: 1200, transform: `scale(${scale})` }}
>
{/* Top-level: left sidebar | center column | explorer sidebar — all full height */}
<div className="flex aspect-video">
{/* Left sidebar — full height */}
<motion.div {...fade(D.sidebar)} className="contents">
<Sidebar />
</motion.div>
{/* Center column: title bar + split panes */}
<div className="flex flex-col flex-1 min-w-0 min-h-0">
{/* Title bar — belongs to center column only */}
<motion.div {...fade(D.titleBar)} className="flex items-center h-10 px-2 bg-mock-surface0 border-b border-mock-border flex-shrink-0">
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="px-2 py-1 rounded-lg flex items-center justify-center flex-shrink-0">
<PanelLeft size={14} className="text-mock-fg-muted" />
</div>
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className="text-[13px] font-light text-mock-fg truncate flex-shrink-0">main</span>
<span className="text-[13px] text-mock-fg-muted truncate flex-shrink min-w-0">acme/returns-app</span>
<div className="px-2 py-1 rounded-lg flex items-center justify-center flex-shrink-0">
<Ellipsis size={14} className="text-mock-fg-muted" />
</div>
</div>
</div>
<div className="flex items-center gap-2 ml-auto flex-shrink-0">
<div className="flex items-stretch rounded-md border border-mock-border-accent overflow-hidden">
<div className="flex items-center gap-2 px-3 py-1">
<GitCommitHorizontal size={12} className="text-mock-fg-muted flex-shrink-0" />
<span className="text-[11px] text-mock-fg font-normal">Commit</span>
</div>
<div className="flex items-center justify-center w-7 border-l border-mock-border-accent">
<ChevronDown size={12} className="text-mock-fg-muted" />
</div>
</div>
<div className="flex items-center gap-2 px-3 py-1 rounded-md">
<SourceControlIcon size={14} className="text-mock-fg-muted" />
<span className="text-[11px] font-normal text-mock-green-400">+247</span>
<span className="text-[11px] font-normal text-mock-red">-15</span>
</div>
</div>
</motion.div>
{/* Split panes */}
<div className="flex flex-1 min-h-0">
{/* Left pane: all agent tabs + chat */}
<div className="flex flex-col flex-1 min-w-0 min-h-0">
<motion.div {...fade(D.tabs)}>
<PaneTabBar tabs={TABS} focused />
</motion.div>
<motion.div {...fade(D.chat)} className="flex-1 flex min-h-0">
<ChatArea />
</motion.div>
</div>
{/* Resize handle */}
<div className="w-px bg-mock-border flex-shrink-0" />
{/* Right pane: terminal only */}
<motion.div {...fade(D.chat)} className="flex flex-col flex-1 min-w-0 min-h-0">
<PaneTabBar tabs={[{ name: "npm run dev", provider: "terminal", done: false, active: true }]} />
<TerminalPane />
</motion.div>
</div>
</div>
{/* Explorer sidebar — full height, pushes center column */}
<motion.div {...fade(D.diffPanel)} className="contents">
<ExplorerSidebar />
</motion.div>
</div>
</div>
</div>
);
}
// ── Export ───────────────────────────────────────────────
export function HeroMockup() {
return <DesktopMockup />;
}
File diff suppressed because it is too large Load Diff
@@ -1,28 +0,0 @@
// Exact SVG paths from packages/app/src/components/icons/
export function ClaudeIcon({ size = 13, className }: { size?: number; className?: string }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" fillRule="evenodd" className={className}>
<path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" />
</svg>
);
}
export function CodexIcon({ size = 13, className }: { size?: number; className?: string }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" fillRule="evenodd" className={className}>
<path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z" />
</svg>
);
}
export function SourceControlIcon({ size = 14, className }: { size?: number; className?: string }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className}>
<rect x={3} y={3} width={18} height={18} rx={2} />
<line x1={9} y1={9.5} x2={15} y2={9.5} />
<line x1={12} y1={6.5} x2={12} y2={12.5} />
<line x1={9} y1={16} x2={15} y2={16} />
</svg>
);
}
@@ -1 +0,0 @@
export { ClaudeIcon, CodexIcon, SourceControlIcon } from "./icons";
@@ -1,73 +0,0 @@
import "~/styles.css";
export function SiteHeader() {
return (
<header className="flex items-center justify-between gap-4">
<a href="/" className="flex items-center gap-3">
<img src="/logo.svg" alt="Paseo" className="w-6 h-6" />
<span className="text-lg font-medium">Paseo</span>
</a>
<div className="flex items-center gap-4">
<a
href="/blog"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Blog
</a>
<a
href="/docs"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Docs
</a>
<a
href="/changelog"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Changelog
</a>
<a
href="/download"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Download
</a>
<a
href="https://discord.gg/jz8T2uahpH"
target="_blank"
rel="noopener noreferrer"
aria-label="Discord"
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center"
>
<svg
role="img"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
</a>
<a
href="https://github.com/getpaseo/paseo"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M12 0C5.37 0 0 5.484 0 12.252c0 5.418 3.438 10.013 8.205 11.637.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.738-4.042-1.61-4.042-1.61-.546-1.403-1.333-1.776-1.333-1.776-1.089-.756.084-.741.084-.741 1.205.087 1.838 1.262 1.838 1.262 1.07 1.87 2.809 1.33 3.495 1.017.108-.79.417-1.33.76-1.636-2.665-.31-5.467-1.35-5.467-6.005 0-1.327.465-2.413 1.235-3.262-.124-.31-.535-1.556.117-3.243 0 0 1.008-.33 3.3 1.248a11.2 11.2 0 0 1 3.003-.404c1.02.005 2.045.138 3.003.404 2.29-1.578 3.297-1.248 3.297-1.248.653 1.687.242 2.933.118 3.243.77.85 1.233 1.935 1.233 3.262 0 4.667-2.807 5.692-5.48 5.995.43.38.823 1.133.823 2.285 0 1.65-.015 2.98-.015 3.386 0 .315.218.694.825.576C20.565 22.26 24 17.667 24 12.252 24 5.484 18.627 0 12 0z" />
</svg>
</a>
</div>
</header>
);
}
-82
View File
@@ -1,82 +0,0 @@
interface PostFrontmatter {
title: string;
description: string;
date: string;
draft: boolean;
}
export interface Post {
slug: string;
frontmatter: PostFrontmatter;
content: string;
}
function parseFrontmatter(raw: string): { data: Record<string, string>; content: string } {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!match) return { data: {}, content: raw };
const data: Record<string, string> = {};
for (const line of match[1].split("\n")) {
const colonIdx = line.indexOf(":");
if (colonIdx === -1) continue;
const key = line.slice(0, colonIdx).trim();
const value = line
.slice(colonIdx + 1)
.trim()
.replace(/^["']|["']$/g, "");
data[key] = value;
}
return { data, content: match[2] };
}
const postModules = import.meta.glob("../posts/**/*.md", {
eager: true,
query: "?raw",
import: "default",
}) as Record<string, string>;
function loadPosts(includeDrafts: boolean): Post[] {
const posts: Post[] = [];
for (const [path, raw] of Object.entries(postModules)) {
const isDraft = path.includes("/drafts/");
if (isDraft && !includeDrafts) continue;
const { data, content } = parseFrontmatter(raw);
const fileName = path.split("/").pop()!.replace(".md", "");
const slug = isDraft ? `drafts/${fileName}` : fileName;
posts.push({
slug,
frontmatter: {
title: data.title ?? "",
description: data.description ?? "",
date: data.date ?? "",
draft: isDraft,
},
content,
});
}
posts.sort(
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(),
);
return posts;
}
export function getPosts(includeDrafts = false): Post[] {
return loadPosts(includeDrafts);
}
export function getPost(slug: string): Post | undefined {
return loadPosts(true).find((p) => p.slug === slug);
}
export function formatDate(date: Date): string {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
return `${year}-${month}-${day}`;
}

Some files were not shown because too many files have changed in this diff Show More