mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 21:52:54 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58d1767a64 | ||
|
|
0ab346e5a2 | ||
|
|
a8c04c26b9 |
+1
-1
@@ -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).
|
||||
|
||||
Generated
-10
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 ===");
|
||||
@@ -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,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,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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,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,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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user