fix(daemon): keep lifecycle operations on the selected instance

Restart reconstructed launch inputs and address fallback could redirect a home-selected shutdown to another daemon. Let supervisors publish bound endpoints, retain their worker restart contract, and share captured local lifecycle ownership between CLI and Desktop.
This commit is contained in:
Mohamed Boudra
2026-09-09 14:09:53 +02:00
parent fdf3b4b47f
commit 264276880e
166 changed files with 3824 additions and 4467 deletions
@@ -0,0 +1,27 @@
import { randomUUID } from "node:crypto";
import { expect, test } from "../support/fixtures";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import {
openDaemonOverview,
restartDaemonInSettings,
expectDaemonRestartComplete,
readWorkerPid,
} from "../support/helpers/daemon-lifecycle";
test("settings restart waits for a replacement worker while retaining the supervisor", async ({
page,
}, testInfo) => {
const daemon = await startIsolatedHostDaemon(randomUUID());
try {
const supervisor = daemon.getPid();
const previousWorker = await readWorkerPid(daemon);
await openDaemonOverview(page, daemon);
await restartDaemonInSettings(page);
await expectDaemonRestartComplete(page);
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
expect(daemon.getPid()).toBe(supervisor);
await page.screenshot({ path: testInfo.outputPath("restart-confirmed.png"), fullPage: true });
} finally {
await daemon.close();
}
});
+2 -1
View File
@@ -16,7 +16,8 @@ export interface WaitForServerOptions {
type ServerProbe = (host: string, port: number) => Promise<void>;
const RESERVED_LOCAL_PORTS = new Set([
6767, // Developer daemon.
6767, // Installed daemon.
6768, // Developer daemon.
61680, // OpenCode's default local server.
]);
@@ -0,0 +1,42 @@
import { expect, type Page } from "@playwright/test";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectDaemonClient } from "./daemon-client-loader";
import { openSettings } from "./app";
import { openHostSection, openSettingsHost, seedSavedSettingsHosts } from "./settings";
import type { IsolatedHostDaemon } from "./isolated-host-daemon";
export async function openDaemonOverview(page: Page, daemon: IsolatedHostDaemon) {
await seedSavedSettingsHosts(page, [
{ serverId: daemon.serverId, label: "Lifecycle host", endpoint: `127.0.0.1:${daemon.port}` },
]);
await page.reload();
await openSettings(page);
await openSettingsHost(page, daemon.serverId);
await openHostSection(page, daemon.serverId, "host");
}
export async function restartDaemonInSettings(page: Page) {
const button = page.getByRole("button", { name: "Restart", exact: true });
await expect(button).toBeEnabled();
page.once("dialog", (dialog) => dialog.accept());
await button.click();
await expect(page.getByRole("button", { name: "Restarting...", exact: true })).toBeDisabled();
}
export async function expectDaemonRestartComplete(page: Page) {
await expect(page.getByRole("button", { name: "Restart", exact: true })).toBeEnabled({
timeout: 30_000,
});
}
export async function readWorkerPid(daemon: IsolatedHostDaemon): Promise<number> {
const client = await connectDaemonClient<DaemonClient>({
port: daemon.port,
clientIdPrefix: "lifecycle-observer",
});
try {
return (await client.getDaemonStatus()).pid;
} finally {
await client.close();
}
}
@@ -83,7 +83,7 @@ export async function startIsolatedHostDaemon(
): Promise<IsolatedHostDaemon> {
const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0);
let port = await getAvailablePort();
while (port === 6767 || port === primaryPort) port = await getAvailablePort();
while (port === 6767 || port === 6768 || port === primaryPort) port = await getAvailablePort();
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) throw new Error("E2E_METRO_PORT is required to start an isolated host daemon");
@@ -1,5 +1,5 @@
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -59,23 +59,37 @@ export async function startPackagedWebDaemon(input: {
const serverId = `relay-deployment-${Date.now().toString(36)}`;
const paseo = path.resolve(__dirname, "../../../../../node_modules/.bin/paseo");
const env: NodeJS.ProcessEnv = {
...process.env,
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))),
HOME: home,
USERPROFILE: home,
CI: "true",
NODE_ENV: "development",
PASEO_NODE_ENV: "development",
PASEO_SERVER_ID: serverId,
PASEO_RELAY_ENDPOINT: input.relayEndpoint,
PASEO_RELAY_PUBLIC_ENDPOINT: input.relayEndpoint,
PASEO_RELAY_USE_TLS: "false",
PASEO_RELAY_PUBLIC_USE_TLS: "false",
};
try {
await execFileAsync(
paseo,
["daemon", "start", "--home", home, "--port", String(port), "--relay", "--web-ui"],
{ env },
await writeFile(
path.join(home, "config.json"),
JSON.stringify({
daemon: {
listen: `127.0.0.1:${port}`,
relay: {
enabled: true,
endpoint: input.relayEndpoint,
publicEndpoint: input.relayEndpoint,
useTls: false,
publicUseTls: false,
},
},
features: {
webUi: { enabled: true },
dictation: { enabled: false },
voiceMode: { enabled: false },
},
}),
);
await execFileAsync(paseo, ["daemon", "start", "--home", home], { env });
const origin = `http://127.0.0.1:${port}`;
await waitForWebUi(origin, home);
@@ -12,7 +12,11 @@ import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { openExternalUrl } from "@/utils/open-external-url";
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import { getCliDaemonStatus, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import {
getCliDaemonStatus,
shouldUseDesktopDaemon,
confirmAndStopDesktopDaemon,
} from "@/desktop/daemon/desktop-daemon";
import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-management";
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
import { useDesktopSettings, type DesktopSettings } from "@/desktop/settings/desktop-settings";
@@ -330,6 +334,17 @@ export function LocalDaemonSection() {
const daemonStatus = data?.status ?? null;
const daemonLogs = data?.logs ?? null;
const daemonVersion = daemonStatus?.version ?? null;
const [isStopping, setIsStopping] = useState(false);
const handleStop = useCallback(() => {
setIsStopping(true);
void confirmAndStopDesktopDaemon()
.then((status) => {
if (status) setStatus(status);
return undefined;
})
.catch((error) => Alert.alert(t("desktop.daemon.lifecycle.stopFailed"), String(error)))
.finally(() => setIsStopping(false));
}, [setStatus, t]);
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
const daemonStatusStateText =
@@ -445,6 +460,26 @@ export function LocalDaemonSection() {
isLoadingCliStatus={isLoadingCliStatus}
/>
{daemonStatus?.pid ? (
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>
{daemonStatus.ownedByDesktop
? t("desktop.daemon.lifecycle.owned")
: t("desktop.daemon.lifecycle.attached")}
</Text>
<Text style={settingsStyles.rowHint}>{daemonStatus.home}</Text>
</View>
<Button variant="outline" size="sm" onPress={handleStop} disabled={isStopping}>
{isStopping
? t("desktop.daemon.lifecycle.stopping")
: t("desktop.daemon.lifecycle.stop")}
</Button>
</View>
</View>
) : null}
{daemonVersionMismatch ? (
<View style={styles.warningCard}>
<Text style={styles.warningText}>{t("desktop.daemon.versionMismatch")}</Text>
@@ -11,10 +11,16 @@ const runningManagedStatus: DesktopDaemonStatus = {
home: "/tmp/paseo",
version: "1.0.0",
desktopManaged: true,
ownedByDesktop: true,
startedAt: "2026-01-01T00:00:00.000Z",
error: null,
};
const stoppedStatus: DesktopDaemonStatus = { ...runningManagedStatus, status: "stopped" };
const stoppedStatus: DesktopDaemonStatus = {
...runningManagedStatus,
status: "stopped",
ownedByDesktop: false,
};
function makeDeps(overrides?: {
confirm?: () => Promise<boolean>;
@@ -128,9 +134,9 @@ describe("executeDaemonManagementToggle", () => {
expect(result).toEqual({ kind: "disabled", newStatus: stoppedStatus });
});
it("skips stop when daemon is running but not desktop-managed", async () => {
it("skips stop for an attached daemon even with a legacy desktop-managed flag", async () => {
const { deps, calls } = makeDeps();
const manuallyManagedStatus = { ...runningManagedStatus, desktopManaged: false };
const manuallyManagedStatus = { ...runningManagedStatus, ownedByDesktop: false };
const result = await executeDaemonManagementToggle(true, manuallyManagedStatus, deps);
@@ -14,7 +14,7 @@ export type DaemonManagementToggleResult =
export async function executeDaemonManagementToggle(
currentlyManaging: boolean,
daemonStatus: Pick<DesktopDaemonStatus, "status" | "desktopManaged"> | null,
daemonStatus: Pick<DesktopDaemonStatus, "status" | "ownedByDesktop"> | null,
deps: DaemonManagementToggleDeps,
): Promise<DaemonManagementToggleResult> {
if (!currentlyManaging) {
@@ -32,7 +32,7 @@ export async function executeDaemonManagementToggle(
// state reflects what was actually applied if the stop fails.
await deps.persistSettings({ manageBuiltInDaemon: false });
if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) {
if (daemonStatus?.ownedByDesktop && ["running", "starting"].includes(daemonStatus.status)) {
const newStatus = await deps.stopDaemon();
return { kind: "disabled", newStatus };
}
@@ -1,3 +1,5 @@
import { i18n } from "@/i18n/i18next";
import { confirmDialog } from "@/utils/confirm-dialog";
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
import type { AgentSkillSelection } from "@getpaseo/protocol/messages";
@@ -21,6 +23,8 @@ export interface DesktopDaemonStatus {
home: string;
version: string | null;
desktopManaged: boolean;
ownedByDesktop: boolean;
startedAt: string | null;
error: string | null;
}
@@ -128,6 +132,8 @@ function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus {
home: toStringOrNull(raw.home) ?? "",
version: toStringOrNull(raw.version),
desktopManaged: raw.desktopManaged === true,
ownedByDesktop: raw.ownedByDesktop === true,
startedAt: typeof raw.startedAt === "string" ? raw.startedAt : null,
error: toStringOrNull(raw.error),
};
}
@@ -190,6 +196,33 @@ export async function stopDesktopDaemon(
return parseDesktopDaemonStatus(await invokeDesktopCommand("stop_desktop_daemon", { reason }));
}
export async function confirmAndStopDesktopDaemon(): Promise<DesktopDaemonStatus | null> {
const captured = await getDesktopDaemonStatus();
if (!captured.pid || !captured.startedAt) return captured;
const ownership = captured.ownedByDesktop
? i18n.t("desktop.daemon.lifecycle.ownedMessage")
: i18n.t("desktop.daemon.lifecycle.attachedMessage");
const confirmed = await confirmDialog({
title: i18n.t("desktop.daemon.lifecycle.stopTitle"),
message: i18n.t("desktop.daemon.lifecycle.stopMessage", {
ownership,
home: captured.home,
pid: captured.pid,
}),
confirmLabel: i18n.t("desktop.daemon.lifecycle.stop"),
cancelLabel: i18n.t("common.actions.cancel"),
destructive: true,
});
if (!confirmed) return null;
return parseDesktopDaemonStatus(
await invokeDesktopCommand("stop_desktop_daemon", {
reason: "manual_ipc",
pid: captured.pid,
startedAt: captured.startedAt,
}),
);
}
export async function restartDesktopDaemon(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand("restart_desktop_daemon"));
}
@@ -58,8 +58,16 @@ export function useBuiltInDaemonManagement(
confirm: () =>
confirmDialog({
title: t("desktop.daemon.management.pauseTitle"),
message: t("desktop.daemon.management.pauseMessage"),
confirmLabel: t("desktop.daemon.management.pauseAndStop"),
message: t(
daemonStatus?.ownedByDesktop
? "desktop.daemon.management.pauseMessage"
: "desktop.daemon.lifecycle.pauseAttached",
),
confirmLabel: t(
daemonStatus?.ownedByDesktop
? "desktop.daemon.management.pauseAndStop"
: "desktop.daemon.lifecycle.pause",
),
cancelLabel: t("common.actions.cancel"),
destructive: true,
}),
@@ -15,6 +15,8 @@ function makeSources(): DesktopDiagnosticSources {
home: "/paseo/home",
version: "1.2.3",
desktopManaged: true,
ownedByDesktop: true,
startedAt: "2026-01-01T00:00:00.000Z",
error: null,
}),
getDaemonLogs: async () => ({
+1
View File
@@ -1263,6 +1263,7 @@ export const ar: TranslationResources = {
detail: "إيقاف البرنامج الخفي المحلي.",
},
daemon: {
lifecycle: en.desktop.daemon.lifecycle,
title: "Daemon",
status: {
title: "حالة",
+17
View File
@@ -1273,6 +1273,23 @@ export const en = {
},
daemon: {
title: "Daemon",
lifecycle: {
owned: "Launched by this Desktop session",
attached: "Attached to an existing daemon",
ownedMessage: "This daemon was launched by this Desktop session.",
attachedMessage: "This daemon was not launched by this Desktop session.",
stopTitle: "Stop local daemon?",
stopMessage:
"{{ownership}}\nHome: {{home}}\nSupervisor PID: {{pid}}\nRunning agent work will be interrupted.",
stop: "Stop daemon",
stopping: "Stopping…",
stopFailed: "Unable to stop daemon",
pauseAttached: "Pause automatic daemon management? The attached daemon will keep running.",
pause: "Pause management",
workerUpdated: "Worker updated to {{version}}",
supervisorRefresh:
"The running supervisor retains its original code. Its launcher must stop and start it to refresh the supervisor.",
},
status: {
title: "Status",
builtInOnly: "Only the built-in desktop daemon is shown here",
+1
View File
@@ -1297,6 +1297,7 @@ export const es: TranslationResources = {
detail: "Deteniendo el demonio local.",
},
daemon: {
lifecycle: en.desktop.daemon.lifecycle,
title: "Daemon",
status: {
title: "Estado",
+1
View File
@@ -1297,6 +1297,7 @@ export const fr: TranslationResources = {
detail: "Arrêt du démon local.",
},
daemon: {
lifecycle: en.desktop.daemon.lifecycle,
title: "Daemon",
status: {
title: "Statut",
+1
View File
@@ -1275,6 +1275,7 @@ export const ja: TranslationResources = {
detail: "ローカルデーモンを停止中。",
},
daemon: {
lifecycle: en.desktop.daemon.lifecycle,
title: "デーモン",
status: {
title: "ステータス",
+1
View File
@@ -1270,6 +1270,7 @@ export const ko: TranslationResources = {
detail: "로컬 데몬을 중지하는 중입니다.",
},
daemon: {
lifecycle: en.desktop.daemon.lifecycle,
title: "데몬",
status: {
title: "상태",
+1
View File
@@ -1288,6 +1288,7 @@ export const ptBR: TranslationResources = {
detail: "Parando o daemon local.",
},
daemon: {
lifecycle: en.desktop.daemon.lifecycle,
title: "Daemon",
status: {
title: "Status",
+1
View File
@@ -1280,6 +1280,7 @@ export const ru: TranslationResources = {
detail: "Остановка локального демона.",
},
daemon: {
lifecycle: en.desktop.daemon.lifecycle,
title: "Демон",
status: {
title: "Статус",
+1
View File
@@ -1252,6 +1252,7 @@ export const zhCN: TranslationResources = {
detail: "正在停止本地 daemon。",
},
daemon: {
lifecycle: en.desktop.daemon.lifecycle,
title: "Daemon",
status: {
title: "状态",
@@ -36,6 +36,8 @@ function makeStatus(overrides: Partial<DesktopDaemonStatus> = {}): DesktopDaemon
home: "/home",
version: "0.0.0",
desktopManaged: true,
ownedByDesktop: true,
startedAt: "2026-01-01T00:00:00.000Z",
error: null,
...overrides,
};
@@ -0,0 +1,98 @@
import { expect, test } from "vitest";
import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle";
test("settings restart completes when a replacement worker is observed without a sampled disconnect", async () => {
let pid = 10;
await restartDaemonFromSettings("daemon", "settings", {
getStatus: async () => ({ pid, version: "1.0.0", serverId: "daemon" }),
restartServer: async () => {
pid = 11;
},
});
expect(pid).toBe(11);
});
test("a status permission failure prevents the restart request", async () => {
let restarted = false;
await expect(
restartDaemonFromSettings("daemon", "settings", {
getStatus: async () => {
throw new Error("Permission denied");
},
restartServer: async () => {
restarted = true;
},
}),
).rejects.toThrow("Permission denied");
expect(restarted).toBe(false);
});
test("a different responder fails confirmation immediately", async () => {
let serverId = "daemon";
await expect(
restartDaemonFromSettings("daemon", "settings", {
getStatus: async () => ({ pid: 10, version: "1.0.0", serverId }),
restartServer: async () => {
serverId = "other";
},
}),
).rejects.toThrow("identity changed");
});
test("a lost restart acknowledgment can still confirm the replacement", async () => {
let pid = 10;
await restartDaemonFromSettings("daemon", "settings", {
getStatus: async () => ({ pid, version: "1.0.0", serverId: "daemon" }),
restartServer: async () => {
pid = 11;
throw Object.assign(new Error("Connection lost"), { code: "DAEMON_CONNECTION_LOST" });
},
});
expect(pid).toBe(11);
});
test("installation and worker version confirmation are separate outcomes", async () => {
let pid = 10;
await expect(
updateDaemonFromSettings("daemon", {
getStatus: async () => ({ pid, version: "1.0.0", serverId: "daemon" }),
updateDaemon: async () => {
pid = 11;
return { success: true, error: null, newVersion: "2.0.0" };
},
}),
).rejects.toThrow("Package installed; replacement worker version was not confirmed");
});
test("an installed version is confirmed only in its replacement worker", async () => {
let pid = 10,
version = "1.0.0";
await expect(
updateDaemonFromSettings("daemon", {
getStatus: async () => ({ pid, version, serverId: "daemon" }),
updateDaemon: async () => {
pid = 11;
version = "2.0.0";
return { success: true, error: null, newVersion: version };
},
}),
).resolves.toEqual({ workerVersion: "2.0.0" });
});
test("RPC errors mentioning transport are not retried", async () => {
let requested = false;
await expect(
restartDaemonFromSettings("daemon", "settings", {
getStatus: async () => {
if (requested)
throw Object.assign(new Error("Connection policy denied by plugin"), {
code: "permission_denied",
});
return { pid: 10, version: "1.0.0", serverId: "daemon" };
},
restartServer: async () => {
requested = true;
},
}),
).rejects.toThrow("Connection policy denied by plugin");
});
@@ -0,0 +1,94 @@
interface WorkerStatus {
pid: number;
serverId: string;
version: string | null;
}
interface StatusReader {
getStatus: () => Promise<WorkerStatus>;
}
export interface SettingsDaemonRestartDeps extends StatusReader {
restartServer: (reason: string) => Promise<unknown>;
}
/** Acknowledgment starts the wait; only a different ready worker completes it. */
export async function restartDaemonFromSettings(
hostServerId: string,
reason: string,
deps: SettingsDaemonRestartDeps,
): Promise<void> {
const previous = await readSelectedWorker(hostServerId, deps);
let acknowledged = false;
try {
await deps.restartServer(reason);
acknowledged = true;
} catch (error) {
if (!isReconnectFailure(error)) throw error;
}
try {
await observeReplacement(previous, deps);
} catch (error) {
throw new Error(`Restart acknowledged: ${acknowledged}. ${String(error)}`, { cause: error });
}
}
export async function updateDaemonFromSettings(
hostServerId: string,
deps: StatusReader & {
updateDaemon: () => Promise<{
success: boolean;
error: string | null;
newVersion: string | null;
}>;
},
): Promise<{ workerVersion: string }> {
const previous = await readSelectedWorker(hostServerId, deps);
const installed = await deps.updateDaemon();
if (!installed.success) throw new Error(installed.error ?? "Package installation failed");
try {
const worker = await observeReplacement(previous, deps);
if (!worker.version || !installed.newVersion || worker.version !== installed.newVersion) {
throw new Error(
`Expected installed version ${installed.newVersion ?? "unknown"}; observed worker ${worker.version}.`,
);
}
return { workerVersion: worker.version };
} catch (error) {
throw new Error(
`Package installed; replacement worker version was not confirmed. ${String(error)}`,
{ cause: error },
);
}
}
async function readSelectedWorker(serverId: string, deps: StatusReader): Promise<WorkerStatus> {
const worker = await deps.getStatus();
if (worker.serverId !== serverId) throw new Error("Daemon identity changed");
return worker;
}
async function observeReplacement(
previous: WorkerStatus,
deps: StatusReader,
): Promise<WorkerStatus> {
const deadline = Date.now() + 600_000;
while (Date.now() < deadline) {
try {
const current = await readSelectedWorker(previous.serverId, deps);
if (current.pid !== previous.pid) return current;
} catch (error) {
if (!isReconnectFailure(error)) throw error;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error("Replacement worker could not be confirmed. Check daemon status and logs.");
}
function isReconnectFailure(error: unknown): boolean {
return Boolean(
error &&
typeof error === "object" &&
"code" in error &&
["DAEMON_CONNECTION_LOST", "DAEMON_REQUEST_TIMEOUT"].includes(String(error.code)),
);
}
@@ -1,131 +0,0 @@
import { describe, expect, it } from "vitest";
import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
import { restartDaemonFromSettings, type SettingsDaemonRestartDeps } from "./daemon-restart";
const runningDesktopDaemonStatus: DesktopDaemonStatus = {
serverId: "local-desktop",
status: "running",
listen: null,
hostname: null,
pid: 123,
home: "/tmp/paseo",
version: "1.0.0",
desktopManaged: true,
error: null,
};
const desktopSettings = {
daemon: {
manageBuiltInDaemon: true,
},
};
function makeDeps(overrides?: {
isElectron?: boolean;
desktopDaemonStatus?: DesktopDaemonStatus;
desktopSettings?: typeof desktopSettings;
desktopSettingsError?: Error;
restartDesktopDaemon?: () => Promise<DesktopDaemonStatus>;
restartServer?: (reason: string) => Promise<void>;
}) {
const calls: string[] = [];
const deps: SettingsDaemonRestartDeps = {
getIsElectron: () => overrides?.isElectron ?? true,
getDesktopDaemonStatus: async () => {
calls.push("desktop-status");
return overrides?.desktopDaemonStatus ?? runningDesktopDaemonStatus;
},
getDesktopSettings: async () => {
calls.push("desktop-settings");
if (overrides?.desktopSettingsError) {
throw overrides.desktopSettingsError;
}
return overrides?.desktopSettings ?? desktopSettings;
},
restartDesktopDaemon:
overrides?.restartDesktopDaemon ??
(async () => {
calls.push("desktop-restart");
return runningDesktopDaemonStatus;
}),
restartServer:
overrides?.restartServer ??
(async (reason) => {
calls.push(`rpc-restart:${reason}`);
}),
};
return { calls, deps };
}
describe("restartDaemonFromSettings", () => {
it("restarts the local desktop-managed daemon through the desktop bridge", async () => {
const { calls, deps } = makeDeps();
await restartDaemonFromSettings(" local-desktop ", "settings_daemon_restart_local", deps);
expect(calls).toEqual(["desktop-status", "desktop-settings", "desktop-restart"]);
});
it("restarts remote hosts over the daemon RPC without reading desktop settings", async () => {
const { calls, deps } = makeDeps({
desktopSettingsError: new Error("Unreadable desktop settings."),
});
await restartDaemonFromSettings("remote-host", "settings_daemon_restart_remote", deps);
expect(calls).toEqual(["desktop-status", "rpc-restart:settings_daemon_restart_remote"]);
});
it("keeps manually managed local daemons on the RPC path without reading desktop settings", async () => {
const { calls, deps } = makeDeps({
desktopDaemonStatus: { ...runningDesktopDaemonStatus, desktopManaged: false },
desktopSettingsError: new Error("Unreadable desktop settings."),
});
await restartDaemonFromSettings("local-desktop", "settings_daemon_restart_local", deps);
expect(calls).toEqual(["desktop-status", "rpc-restart:settings_daemon_restart_local"]);
});
it("keeps the RPC path when built-in daemon management is disabled", async () => {
const { calls, deps } = makeDeps({
desktopSettings: {
...desktopSettings,
daemon: { ...desktopSettings.daemon, manageBuiltInDaemon: false },
},
});
await restartDaemonFromSettings("local-desktop", "settings_daemon_restart_local", deps);
expect(calls).toEqual([
"desktop-status",
"desktop-settings",
"rpc-restart:settings_daemon_restart_local",
]);
});
it("uses the RPC path outside Electron without reading desktop daemon status or settings", async () => {
const { calls, deps } = makeDeps({
isElectron: false,
});
await restartDaemonFromSettings("local-desktop", "settings_daemon_restart_local", deps);
expect(calls).toEqual(["rpc-restart:settings_daemon_restart_local"]);
});
it("surfaces desktop restart failures without falling back to worker recycle", async () => {
const { calls, deps } = makeDeps({
restartDesktopDaemon: async () => {
calls.push("desktop-restart");
throw new Error("Desktop restart failed.");
},
});
await expect(
restartDaemonFromSettings("local-desktop", "settings_daemon_restart_local", deps),
).rejects.toThrow("Desktop restart failed.");
expect(calls).toEqual(["desktop-status", "desktop-settings", "desktop-restart"]);
});
});
@@ -1,57 +0,0 @@
import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
interface DesktopDaemonRestartStatus {
desktopManaged: boolean;
serverId: string;
}
interface DesktopDaemonRestartSettings {
daemon: {
manageBuiltInDaemon: boolean;
};
}
export interface SettingsDaemonRestartDeps {
getIsElectron: () => boolean;
getDesktopDaemonStatus: () => Promise<DesktopDaemonRestartStatus>;
getDesktopSettings: () => Promise<DesktopDaemonRestartSettings>;
restartDesktopDaemon: () => Promise<DesktopDaemonStatus>;
restartServer: (reason: string) => Promise<unknown>;
}
async function isLocalDesktopManagedDaemon(
hostServerId: string,
deps: SettingsDaemonRestartDeps,
): Promise<boolean> {
if (!deps.getIsElectron()) {
return false;
}
const desktopDaemonStatus = await deps.getDesktopDaemonStatus();
if (!desktopDaemonStatus.desktopManaged) {
return false;
}
const normalizedHostServerId = hostServerId.trim();
const normalizedDesktopServerId = desktopDaemonStatus.serverId.trim();
if (normalizedHostServerId.length === 0 || normalizedHostServerId !== normalizedDesktopServerId) {
return false;
}
const desktopSettings = await deps.getDesktopSettings();
return desktopSettings.daemon.manageBuiltInDaemon;
}
export async function restartDaemonFromSettings(
hostServerId: string,
reason: string,
deps: SettingsDaemonRestartDeps,
): Promise<void> {
if (await isLocalDesktopManagedDaemon(hostServerId, deps)) {
await deps.restartDesktopDaemon();
return;
}
await deps.restartServer(reason);
}
+53 -166
View File
@@ -33,16 +33,10 @@ import {
ProfileDraft,
TerminalProfileEditModal,
} from "@/screens/settings/terminal-profile-edit-modal";
import { getIsElectron } from "@/constants/platform";
import {
getDesktopDaemonStatus,
restartDesktopDaemon,
startDesktopDaemon,
stopDesktopDaemon,
} from "@/desktop/daemon/desktop-daemon";
import { startDesktopDaemon, stopDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
import { loadDesktopSettings, useDesktopSettings } from "@/desktop/settings/desktop-settings";
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
@@ -72,8 +66,16 @@ import { ICON_SIZE } from "@/styles/theme";
import type { Theme } from "@/styles/theme";
import { getProviderIcon } from "@/components/provider-icons";
import { BrowserToolsOptInCard } from "./browser-tools-card";
import { hasDaemonReconnectedAfter, type DaemonConnectionMarker } from "./daemon-reconnect";
import { restartDaemonFromSettings } from "./daemon-restart";
import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle";
const ThemedRestart = withUnistyles(RotateCw);
const ThemedUpdate = withUnistyles(ArrowUpToLine);
const lifecycleIconProps = (theme: Theme) => ({
size: theme.iconSize.sm,
color: theme.colors.foreground,
});
const restartIcon = <ThemedRestart uniProps={lifecycleIconProps} />;
const updateIcon = <ThemedUpdate uniProps={lifecycleIconProps} />;
const ThemedArrowUp = withUnistyles(ArrowUp);
const ThemedArrowDown = withUnistyles(ArrowDown);
@@ -553,14 +555,8 @@ function ConnectionRow({
);
}
const delay = (ms: number) =>
new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
function RestartDaemonCard({ host }: { host: HostProfile }) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const daemonClient = useHostRuntimeClient(host.serverId);
const isConnected = useHostRuntimeIsConnected(host.serverId);
const runtime = getHostRuntimeStore();
@@ -578,68 +574,20 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
[host.serverId, runtime],
);
const waitForCondition = useCallback(
async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isMountedRef.current) return false;
if (predicate()) return true;
await delay(intervalMs);
}
return predicate();
},
[],
);
const waitForDaemonRestart = useCallback(
async (restartRequest: Promise<void>) => {
const disconnectTimeoutMs = 30000;
const reconnectTimeoutMs = 30000;
const requestFailureDisconnectGraceMs = 2000;
const disconnectedPromise = isHostConnected()
? waitForCondition(() => !isHostConnected(), disconnectTimeoutMs)
: Promise.resolve(true);
const restartResult = await restartRequest.then(
() => ({ status: "accepted" as const }),
async (error) => ({
status: "rejected" as const,
error,
disconnectedAfterFailure: await waitForCondition(
() => !isHostConnected(),
requestFailureDisconnectGraceMs,
100,
),
}),
);
if (!isMountedRef.current) return;
if (restartResult.status === "rejected" && !restartResult.disconnectedAfterFailure) {
console.error(`[HostPage] Failed to restart daemon ${host.label}`, restartResult.error);
setIsRestarting(false);
async (request: Promise<void>) => {
try {
await request;
} catch (error) {
Alert.alert(
t("settings.host.daemon.restart.requestFailedTitle"),
t("settings.host.daemon.restart.requestFailedMessage"),
error instanceof Error ? error.message : String(error),
);
return;
}
const disconnected =
restartResult.status === "rejected"
? restartResult.disconnectedAfterFailure
: await disconnectedPromise;
const reconnected =
disconnected && (await waitForCondition(() => isHostConnected(), reconnectTimeoutMs));
if (isMountedRef.current) {
setIsRestarting(false);
if (!reconnected) {
Alert.alert(
t("settings.host.daemon.restart.unableToReconnectTitle"),
t("settings.host.daemon.restart.unableToReconnectMessage", { name: host.label }),
);
}
} finally {
if (isMountedRef.current) setIsRestarting(false);
}
},
[host.label, isHostConnected, t, waitForCondition],
[t],
);
const handleRestart = useCallback(() => {
@@ -672,11 +620,12 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
host.serverId,
`settings_daemon_restart_${host.serverId}`,
{
getIsElectron,
getDesktopDaemonStatus,
getDesktopSettings: loadDesktopSettings,
restartDesktopDaemon,
restartServer: (reason) => daemonClient.restartServer(reason),
getStatus: async () => ({
...(await daemonClient.getDaemonStatus({ timeout: 1500 })),
serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "",
version: daemonClient.getLastServerInfoMessage()?.version ?? null,
}),
},
);
void waitForDaemonRestart(restartRequest);
@@ -691,11 +640,6 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
});
}, [daemonClient, host.label, host.serverId, isHostConnected, t, waitForDaemonRestart]);
const restartIcon = useMemo(
() => <RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />,
[theme.iconSize.sm, theme.colors.foreground],
);
return (
<View style={settingsStyles.card} testID="host-page-restart-card">
<View style={settingsStyles.row}>
@@ -722,12 +666,12 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
type DaemonUpdateState =
| { status: "idle" }
| { status: "complete"; workerVersion: string }
| { status: "updating"; phase: string }
| { status: "failed"; title: string; message: string };
function UpdateDaemonCard({ host }: { host: HostProfile }) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const daemonClient = useHostRuntimeClient(host.serverId);
const isConnected = useHostRuntimeIsConnected(host.serverId);
const runtime = getHostRuntimeStore();
@@ -759,55 +703,6 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
() => isHostRuntimeConnected(runtime.getSnapshot(host.serverId)),
[host.serverId, runtime],
);
const hasReconnectedAfter = useCallback(
(startMarker: DaemonConnectionMarker | null) =>
hasDaemonReconnectedAfter(runtime.getSnapshot(host.serverId), startMarker),
[host.serverId, runtime],
);
const waitForCondition = useCallback(
async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isMountedRef.current) return false;
if (predicate()) return true;
await delay(intervalMs);
}
return predicate();
},
[],
);
const waitForDaemonRestart = useCallback(
async (startMarker: DaemonConnectionMarker | null) => {
const disconnectTimeoutMs = 15000;
const reconnectTimeoutMs = 120000; // 2 minutes — npm update + restart can take a while
if (!hasReconnectedAfter(startMarker) && isHostConnected()) {
await waitForCondition(
() => !isHostConnected() || hasReconnectedAfter(startMarker),
disconnectTimeoutMs,
);
}
const reconnected =
hasReconnectedAfter(startMarker) ||
(await waitForCondition(() => hasReconnectedAfter(startMarker), reconnectTimeoutMs));
if (isMountedRef.current) {
if (!reconnected) {
setUpdateState({
status: "failed",
title: t("settings.host.daemon.update.unableToReconnectTitle"),
message: t("settings.host.daemon.update.unableToReconnectMessage", {
name: host.label,
}),
});
return;
}
setUpdateState({ status: "idle" });
}
},
[hasReconnectedAfter, host.label, isHostConnected, t, waitForCondition],
);
const handleUpdate = useCallback(() => {
if (!daemonClient) {
setUpdateState({
@@ -835,13 +730,6 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
})
.then((confirmed) => {
if (!confirmed || !isMountedRef.current) return;
const startSnapshot = runtime.getSnapshot(host.serverId);
const startMarker = startSnapshot
? {
clientGeneration: startSnapshot.clientGeneration,
lastOnlineAt: startSnapshot.lastOnlineAt,
}
: null;
setUpdateState({
status: "updating",
phase: t("settings.host.daemon.update.phaseStarting"),
@@ -875,24 +763,18 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
});
unsubscribeRef.current = unsubscribe;
void daemonClient
.updateDaemon(requestId)
.then((response) => {
void updateDaemonFromSettings(host.serverId, {
updateDaemon: () => daemonClient.updateDaemon(requestId),
getStatus: async () => ({
...(await daemonClient.getDaemonStatus({ timeout: 1500 })),
serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "",
version: daemonClient.getLastServerInfoMessage()?.version ?? null,
}),
})
.then(({ workerVersion }) => {
unsubscribeRef.current = null;
unsubscribe();
if (!response.success) {
if (!isMountedRef.current) return undefined;
setUpdateState({
status: "failed",
title: t("settings.host.daemon.update.requestFailedTitle"),
message: t("settings.host.daemon.update.requestFailedMessage", {
error: response.error ?? "Unknown error",
}),
});
return undefined;
}
// Update succeeded — wait for daemon to restart and reconnect
void waitForDaemonRestart(startMarker);
if (isMountedRef.current) setUpdateState({ status: "complete", workerVersion });
return undefined;
})
.catch((error) => {
@@ -919,15 +801,10 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
message: t("settings.host.daemon.update.dialogFailedMessage"),
});
});
}, [daemonClient, host.label, host.serverId, isHostConnected, runtime, t, waitForDaemonRestart]);
const updateIcon = useMemo(
() => <ArrowUpToLine size={theme.iconSize.sm} color={theme.colors.foreground} />,
[theme.iconSize.sm, theme.colors.foreground],
);
}, [daemonClient, host.label, host.serverId, isHostConnected, t]);
const shouldShowUpdate = hasVersionMismatch && (supportsSelfUpdate || desktopManaged);
if (!shouldShowUpdate) {
if (!shouldShowUpdate && updateState.status !== "complete") {
return null;
}
@@ -956,6 +833,15 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
{buttonLabel}
</Button>
</View>
{updateState.status === "complete" ? (
<InlineAlert
variant="success"
title={t("desktop.daemon.lifecycle.workerUpdated", {
version: updateState.workerVersion,
})}
description={t("desktop.daemon.lifecycle.supervisorRefresh")}
/>
) : null}
{updateState.status === "failed" ? (
<View style={styles.updateFailure}>
<InlineAlert
@@ -1253,13 +1139,14 @@ function RemoveHostSection({
const [isConfirming, setIsConfirming] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
const daemonStatus = daemonStatusData?.status ?? null;
const stopsOwnedDaemon = isLocalDaemon && daemonStatus?.ownedByDesktop === true;
const removeHostHeader = useMemo<SheetHeader>(
() => ({
title: isLocalDaemon
title: stopsOwnedDaemon
? t("settings.host.daemon.remove.localConfirmTitle")
: t("settings.host.daemon.remove.title"),
}),
[isLocalDaemon, t],
[stopsOwnedDaemon, t],
);
const destructiveTextStyle = useMemo(
@@ -1292,7 +1179,7 @@ function RemoveHostSection({
try {
await updateSettings({ daemon: { manageBuiltInDaemon: false } });
didDisableDaemonManagement = true;
if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) {
if (daemonStatus?.status === "running" && daemonStatus.ownedByDesktop) {
setStatus(await stopDesktopDaemon("host_remove"));
didStopDaemon = true;
}
@@ -1360,7 +1247,7 @@ function RemoveHostSection({
: t("settings.host.daemon.remove.title")}
</Text>
<Text style={settingsStyles.rowHint}>
{isLocalDaemon
{stopsOwnedDaemon
? t("settings.host.daemon.remove.localHint")
: t("settings.host.daemon.remove.hint")}
</Text>
@@ -1386,7 +1273,7 @@ function RemoveHostSection({
testID="remove-host-confirm-modal"
>
<Text style={styles.confirmText}>
{isLocalDaemon
{stopsOwnedDaemon
? t("settings.host.daemon.remove.localConfirmMessage")
: t("settings.host.daemon.remove.confirmMessage", { name: host.label })}
</Text>
+23 -55
View File
@@ -1,3 +1,4 @@
import { pairCommand } from "./commands/daemon/pair.js";
import { Command, Option } from "commander";
import { createAgentCommand } from "./commands/agent/index.js";
import { createDaemonCommand } from "./commands/daemon/index.js";
@@ -15,9 +16,9 @@ import { createHeartbeatCommand } from "./commands/heartbeat/index.js";
import { createHubCommand } from "./commands/hub/index.js";
import { createHooksCommand } from "./commands/hooks.js";
import { startCommand as daemonStartCommand } from "./commands/daemon/start.js";
import { runStatusCommand as runDaemonStatusCommand } from "./commands/daemon/status.js";
import { runRestartCommand as runDaemonRestartCommand } from "./commands/daemon/restart.js";
import { runDaemonReloadCommand } from "./commands/daemon/reload.js";
import { daemonStatusCommand } from "./commands/daemon/status.js";
import { daemonRestartCommand } from "./commands/daemon/restart.js";
import { daemonReloadCommand } from "./commands/daemon/reload.js";
import { addLsOptions, runLsCommand } from "./commands/agent/ls.js";
import { addRunOptions, runRunCommand } from "./commands/agent/run.js";
import { addLogsOptions, runLogsCommand } from "./commands/agent/logs.js";
@@ -35,19 +36,12 @@ import { onboardCommand } from "./commands/onboard.js";
import {
addDaemonHostOption,
addJsonAndDaemonHostOptions,
addJsonOption,
withGlobalOptions,
} from "./utils/command-options.js";
import { resolveCliVersion } from "./version.js";
const VERSION = resolveCliVersion();
function resolveHostnamesOption(hostnames: unknown, allowedHosts: unknown): string | undefined {
if (typeof hostnames === "string") return hostnames;
if (typeof allowedHosts === "string") return allowedHosts;
return undefined;
}
export function createCli(): Command {
const program = new Command();
@@ -125,51 +119,10 @@ export function createCli(): Command {
program.addCommand(daemonStartCommand());
program.addCommand(createHooksCommand());
addJsonOption(
program
.command("status")
.description('Show local daemon status (alias for "paseo daemon status")'),
)
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.action(withOutput(runDaemonStatusCommand));
addJsonAndDaemonHostOptions(
program.command("reload").description('Reload daemon config (alias for "paseo daemon reload")'),
).action(withOutput(runDaemonReloadCommand));
addJsonOption(
program
.command("restart")
.description('Restart local daemon (alias for "paseo daemon restart")'),
)
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--timeout <seconds>", "Wait timeout before force step (default: 15)")
.option("--force", "Send SIGKILL if graceful stop times out")
.option(
"--listen <listen>",
"Listen target for restarted daemon (host:port, port, or unix socket)",
)
.option("--port <port>", "Port for restarted daemon listen target")
.option("--relay", "Enable relay on restarted daemon")
.option("--no-relay", "Disable relay on restarted daemon")
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
.option(
"--hostnames <hosts>",
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
)
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
.action(
withOutput((...args) => {
const [options, command] = args.slice(-2) as [(typeof args)[number], Command];
return runDaemonRestartCommand(
{
...options,
hostnames: resolveHostnamesOption(options.hostnames, options.allowedHosts),
},
command,
);
}),
);
program.addCommand(daemonStatusCommand());
program.addCommand(daemonRestartCommand());
program.addCommand(daemonReloadCommand());
program.addCommand(pairCommand());
// Advanced agent commands (less common operations)
program.addCommand(createAgentCommand());
@@ -207,5 +160,20 @@ export function createCli(): Command {
// Added in v0.2.0; remove after 2027-01-17.
program.addCommand(createWorktreeCommand(), { hidden: true });
const enforceSelectorDuplicates = (command: Command) => {
for (const option of command.options) {
if (option.long !== "--home" && option.long !== "--host") continue;
option.argParser((value: string, previous: string | undefined) => {
if (previous !== undefined && previous !== value)
throw {
code: "TARGET_AMBIGUOUS",
message: `Conflicting duplicate ${option.long} selectors.`,
};
return value;
});
}
command.commands.forEach(enforceSelectorDuplicates);
};
enforceSelectorDuplicates(program);
return program;
}
+2 -16
View File
@@ -1,6 +1,5 @@
import { Command } from "commander";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
import { connectToDaemon, resolveAgentId } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
@@ -44,8 +43,6 @@ export async function runArchiveCommand(
options: AgentArchiveOptions,
_command: Command,
): Promise<AgentArchiveCommandResult> {
const host = getDaemonHost({ host: options.host });
// Validate arguments
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
@@ -56,18 +53,7 @@ export async function runArchiveCommand(
throw error;
}
let client: DaemonClient;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
+3 -13
View File
@@ -5,17 +5,17 @@ export function addAttachOptions(cmd: Command): Command {
.description("Attach to a running agent's output stream")
.argument("<id>", "Agent ID (or prefix)");
}
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import {
fetchProjectedTimelineItems,
LIVE_HISTORY_FETCH_TIMEOUT_MS,
} from "../../utils/timeline.js";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
export interface AgentAttachOptions {
host?: string;
daemonTarget: import("../../utils/daemon-target.js").DaemonTarget;
[key: string]: unknown;
}
@@ -105,23 +105,13 @@ export async function runAttachCommand(
options: AgentAttachOptions,
_command: Command,
): Promise<void> {
const host = getDaemonHost({ host: options.host });
if (!id) {
console.error("Error: Agent ID required");
console.error("Usage: paseo attach <id>");
process.exit(1);
}
let client: DaemonClient;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`);
console.error("Start the daemon with: paseo daemon start");
process.exit(1);
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const fetchResult = await client.fetchAgent({ agentId: id });
+3 -16
View File
@@ -1,6 +1,5 @@
import type { Command } from "commander";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
export function addDeleteOptions(cmd: Command): Command {
@@ -39,8 +38,6 @@ export async function runDeleteCommand(
options: AgentDeleteOptions,
_command: Command,
): Promise<AgentDeleteResult> {
const host = getDaemonHost({ host: options.host });
if (!id && !options.all && !options.cwd) {
const error: CommandError = {
code: "MISSING_ARGUMENT",
@@ -50,18 +47,7 @@ export async function runDeleteCommand(
throw error;
}
let client: DaemonClient;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } });
@@ -97,6 +83,7 @@ export async function runDeleteCommand(
await client.deleteAgent(agent.id);
return { ok: true as const, id: agent.id };
} catch (err) {
if (err && typeof err === "object" && "code" in err) throw err;
const message = err instanceof Error ? err.message : String(err);
return { ok: false as const, id: agent.id, message };
}
+2 -13
View File
@@ -1,6 +1,5 @@
import type { Command } from "commander";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
import { connectToDaemon, resolveAgentId } from "../../utils/client.js";
import type {
CommandError,
CommandOptions,
@@ -26,17 +25,7 @@ export async function runDetachCommand(
options: CommandOptions,
_command: Command,
): Promise<SingleResult<AgentDetachResult>> {
const host = getDaemonHost({ host: options.host });
let client: DaemonClient;
try {
client = await connectToDaemon({ host: options.host });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
} satisfies CommandError;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const payload = await client.fetchAgents({ filter: { includeArchived: true } });
+2 -19
View File
@@ -1,5 +1,5 @@
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import { collectMultiple } from "../../utils/command-options.js";
import type { CommandError, CommandOptions, SingleResult } from "../../output/index.js";
import { agentRunSchema, type AgentRunResult } from "./run.js";
@@ -94,28 +94,11 @@ export function resolveImportCwd(explicitCwd: string | undefined, defaultCwd: st
return cwd;
}
async function connectToDaemonOrThrow(
hostOption: string | undefined,
host: string,
): Promise<Awaited<ReturnType<typeof connectToDaemon>>> {
try {
return await connectToDaemon({ host: hostOption });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
}
export async function runImportCommand(
sessionIdArg: string,
options: AgentImportOptions,
_command: Command,
): Promise<AgentImportCommandResult> {
const host = getDaemonHost({ host: options.host });
const sessionId = sessionIdArg.trim();
if (!sessionId) {
throw {
@@ -129,7 +112,7 @@ export async function runImportCommand(
const cwd = resolveImportCwd(options.cwd, process.cwd());
const labels = parseImportLabels(options.label);
const client = await connectToDaemonOrThrow(options.host, host);
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const agent = await client.importAgent({
+2 -15
View File
@@ -1,7 +1,7 @@
import type { Command } from "commander";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
export function addInspectOptions(cmd: Command): Command {
@@ -217,8 +217,6 @@ export async function runInspectCommand(
options: AgentInspectOptions,
_command: Command,
): Promise<AgentInspectResult> {
const host = getDaemonHost({ host: options.host });
// Validate arguments
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
@@ -229,18 +227,7 @@ export async function runInspectCommand(
throw error;
}
let client;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const fetchResult = await client.fetchAgent({ agentId: agentIdArg });
+3 -12
View File
@@ -1,5 +1,5 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions } from "../../output/index.js";
import {
fetchProjectedTimelineItems,
@@ -91,23 +91,13 @@ export async function runLogsCommand(
options: AgentLogsOptions,
_command: Command,
): Promise<AgentLogsResult> {
const host = getDaemonHost({ host: options.host });
if (!id) {
console.error("Error: Agent ID required");
console.error("Usage: paseo agent logs <id>");
process.exit(1);
}
let client: DaemonClient;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`);
console.error("Start the daemon with: paseo daemon start");
process.exit(1);
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const fetchResult = await client.fetchAgent({ agentId: id });
@@ -157,6 +147,7 @@ export async function runLogsCommand(
const transcript = formatAgentActivityTranscript(timelineItems, tailCount);
console.log(transcript);
} catch (err) {
if (err && typeof err === "object" && "code" in err) throw err;
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: Failed to get logs: ${message}`);
await client.close().catch(() => {});
+2 -24
View File
@@ -1,6 +1,6 @@
import type { Command } from "commander";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
import { collectMultiple } from "../../utils/command-options.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
@@ -104,21 +104,6 @@ function toListItem(agent: AgentSnapshotPayload): AgentListItem {
export type AgentLsResult = ListResult<AgentListItem>;
function daemonConnectionFailure(host: string, cause: unknown): CommandError {
const reason = cause instanceof Error ? cause.message : String(cause);
const isSsh = host.trim().startsWith("ssh://");
return {
code: "DAEMON_NOT_RUNNING",
message: `Cannot reach the daemon at ${host}: ${reason}`,
details: isSsh
? "Start the Paseo daemon on the SSH host; SSH transport does not install or start it."
: [
"Start a local daemon with: paseo daemon start",
"To use another daemon, pass --host <host:port> or set PASEO_HOST.",
].join("\n"),
};
}
export interface AgentLsOptions extends CommandOptions {
/** -a: Include archived agents */
all?: boolean;
@@ -185,14 +170,7 @@ export async function runLsCommand(
options: AgentLsOptions,
_command: Command,
): Promise<AgentLsResult> {
const host = getDaemonHost({ host: options.host });
let client;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
throw daemonConnectionFailure(host, err);
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const normalizedThinkingOptionId = options.thinking?.trim();
+2 -2
View File
@@ -55,7 +55,7 @@ export async function runModeCommand(
_command: Command,
): Promise<AgentModeResult> {
const normalizedMode = mode?.trim();
const host = getDaemonHost({ host: options.host });
const host = getDaemonHost({ target: options.daemonTarget });
// Validate arguments
if (!options.list && !normalizedMode) {
@@ -64,7 +64,7 @@ export async function runModeCommand(
let client: Awaited<ReturnType<typeof connectToDaemon>> | undefined;
try {
client = await connectToDaemon({ host: options.host });
client = await connectToDaemon({ target: options.daemonTarget });
const fetchResult = await client.fetchAgent({ agentId: id });
if (!fetchResult) {
const error: CommandError = {
+2 -7
View File
@@ -1,5 +1,5 @@
import type { Command } from "commander";
import { buildDaemonConnectionCommandError, connectToDaemon } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import { openDesktopWithAgent } from "../open.js";
import type {
CommandError,
@@ -36,12 +36,7 @@ async function resolveServerId(options: CommandOptions): Promise<string> {
return explicitServerId;
}
let client;
try {
client = await connectToDaemon({ host: options.host });
} catch (error) {
throw buildDaemonConnectionCommandError({ host: options.host, error });
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const serverId = client.getLastServerInfoMessage()?.serverId.trim();
if (!serverId) {
+2 -16
View File
@@ -1,6 +1,5 @@
import { Command } from "commander";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
import { connectToDaemon, resolveAgentId } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
@@ -40,8 +39,6 @@ export async function runReloadCommand(
options: AgentReloadOptions,
_command: Command,
): Promise<AgentReloadCommandResult> {
const host = getDaemonHost({ host: options.host });
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
code: "MISSING_AGENT_ID",
@@ -51,18 +48,7 @@ export async function runReloadCommand(
throw error;
}
let client: DaemonClient;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
+7 -19
View File
@@ -1,7 +1,7 @@
import { Command, Option } from "commander";
import { getStructuredAgentResponse, StructuredAgentResponseError } from "@getpaseo/server";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
@@ -140,6 +140,7 @@ function resolveNewWorkspaceKind(options: AgentRunOptions): string | undefined {
function buildRunWorkspaceSource(options: AgentRunOptions, cwd: string) {
const newWorkspace = resolveNewWorkspaceKind(options) ?? "local";
return buildWorkspaceSource({
daemonTarget: options.daemonTarget,
isolation: newWorkspace,
path: cwd,
mode: options.worktreeMode,
@@ -181,6 +182,7 @@ function loadOutputSchema(value: string): Record<string, unknown> {
try {
source = readFileSync(resolve(trimmed), "utf8");
} catch (err) {
if (err && typeof err === "object" && "code" in err) throw err;
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "INVALID_OUTPUT_SCHEMA",
@@ -195,6 +197,7 @@ function loadOutputSchema(value: string): Record<string, unknown> {
try {
parsed = JSON.parse(source);
} catch (err) {
if (err && typeof err === "object" && "code" in err) throw err;
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "INVALID_OUTPUT_SCHEMA",
@@ -406,6 +409,7 @@ function parseWaitTimeoutOption(waitTimeout: string | undefined): number {
}
return ms;
} catch (err) {
if (err && typeof err === "object" && "code" in err) throw err;
const message = err instanceof Error ? err.message : String(err);
throw {
code: "INVALID_TIMEOUT",
@@ -432,6 +436,7 @@ function loadRunImages(
mimeType,
};
} catch (err) {
if (err && typeof err === "object" && "code" in err) throw err;
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to read image ${imagePath}: ${message}`, { cause: err });
}
@@ -482,22 +487,6 @@ function parseKeyValueFlags(
return labels;
}
async function connectToDaemonOrThrow(
hostOption: string | undefined,
host: string,
): Promise<ConnectedDaemonClient> {
try {
return await connectToDaemon({ host: hostOption });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
}
// A workspace is the explicit home of a run: it owns the directory the agent
// runs in. The CLI resolves one before creating any agent, so no run leans on
// createAgent's legacy cwd->workspace fallback.
@@ -587,7 +576,6 @@ export async function runRunCommand(
options: AgentRunOptions,
_command: Command,
): Promise<SingleResult<AgentRunResult>> {
const host = getDaemonHost({ host: options.host });
const outputSchema = options.outputSchema ? loadOutputSchema(options.outputSchema) : undefined;
validateRunOptions(prompt, options, outputSchema);
@@ -596,7 +584,7 @@ export async function runRunCommand(
const resolvedProviderModel = resolveProviderAndModel(options);
const resolvedTitle = options.title ?? options.name;
const client = await connectToDaemonOrThrow(options.host, host);
const client = await connectToDaemon({ target: options.daemonTarget });
try {
// Resolve working directory
+4 -15
View File
@@ -1,6 +1,6 @@
import { type Command } from "commander";
import { collectMultiple } from "../../utils/command-options.js";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
@@ -78,6 +78,7 @@ async function readImageFiles(
return { data: buffer.toString("base64"), mimeType };
} catch (err) {
if (err && typeof err === "object" && "code" in err) throw err;
const message = err instanceof Error ? err.message : String(err);
throw {
code: "IMAGE_READ_ERROR",
@@ -128,6 +129,7 @@ async function resolvePromptInput(options: {
try {
return await readFile(resolve(promptFilePath), "utf8");
} catch (err) {
if (err && typeof err === "object" && "code" in err) throw err;
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "PROMPT_FILE_READ_ERROR",
@@ -166,8 +168,6 @@ export async function runSendCommand(
options: AgentSendOptions,
_command: Command,
): Promise<SingleResult<AgentSendResult>> {
const host = getDaemonHost({ host: options.host });
// Validate arguments
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
@@ -184,18 +184,7 @@ export async function runSendCommand(
promptFile: options.promptFile,
});
let client;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
// Read image files if provided
+2 -16
View File
@@ -1,6 +1,5 @@
import { Command } from "commander";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
import type {
CommandOptions,
@@ -42,8 +41,6 @@ export async function runStopCommand(
options: AgentStopOptions,
_command: Command,
): Promise<AgentStopResult> {
const host = getDaemonHost({ host: options.host });
// Validate arguments - need either an id, --all, or --cwd
if (!id && !options.all && !options.cwd) {
const error: CommandError = {
@@ -54,18 +51,7 @@ export async function runStopCommand(
throw error;
}
let client: DaemonClient;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } });
+2 -15
View File
@@ -1,7 +1,7 @@
import type { Command } from "commander";
import type { AgentProviderNotice } from "@getpaseo/protocol/agent-types";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
@@ -203,8 +203,6 @@ export async function runUpdateCommand(
options: AgentUpdateOptions,
_command: Command,
): Promise<AgentUpdateCommandResult> {
const host = getDaemonHost({ host: options.host });
// Validate arguments
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
@@ -217,18 +215,7 @@ export async function runUpdateCommand(
const changes = parseAgentChanges(options);
let client;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const fetchResult = await client.fetchAgent({ agentId: agentIdArg });
+3 -15
View File
@@ -1,5 +1,5 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
@@ -72,6 +72,7 @@ function parseWaitTimeout(timeout: string | undefined): {
timeoutLabel: `${timeoutSeconds} second${timeoutSeconds === 1 ? "" : "s"}`,
};
} catch (err) {
if (err && typeof err === "object" && "code" in err) throw err;
const message = err instanceof Error ? err.message : String(err);
throw {
code: "INVALID_TIMEOUT",
@@ -142,8 +143,6 @@ export async function runWaitCommand(
options: AgentWaitOptions,
_command: Command,
): Promise<SingleResult<AgentWaitResult>> {
const host = getDaemonHost({ host: options.host });
if (!agentIdArg || agentIdArg.trim().length === 0) {
throw {
code: "MISSING_AGENT_ID",
@@ -154,18 +153,7 @@ export async function runWaitCommand(
const { timeoutMs, timeoutLabel } = parseWaitTimeout(options.timeout);
let client;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
try {
+2 -8
View File
@@ -1,7 +1,6 @@
import type { Command } from "commander";
import { isCompleteGitRemote } from "@getpaseo/protocol/git-remote";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { buildDaemonConnectionCommandError, connectToDaemon } from "../utils/client.js";
import { connectToDaemon } from "../utils/client.js";
import type { CommandError, OutputSchema, SingleResult } from "../output/index.js";
import type { CommandOptions } from "../output/with-output.js";
@@ -45,12 +44,7 @@ export async function runCloneCommand(
throw cmdError("INVALID_ARGUMENT", "--protocol is required for owner/repo repository names");
}
let client: DaemonClient;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
throw buildDaemonConnectionCommandError({ host: options.host, error: err });
}
const client = await connectToDaemon({ target: options.daemonTarget });
if (client.getLastServerInfoMessage()?.features?.projectGithubClone !== true) {
await client.close().catch(() => {});
+116
View File
@@ -0,0 +1,116 @@
import { addLocalDaemonOptions } from "../../utils/command-options.js";
import { Command } from "commander";
import {
readDaemonInstance,
readPersistedConfig,
getPersistedConfigValue,
editPersistedConfig,
} from "@getpaseo/server";
import { connectToDaemon } from "../../utils/client.js";
import { withOutput, type CommandOptions } from "../../output/index.js";
function redact(value: unknown): unknown {
if (Array.isArray(value)) return value.map(redact);
if (!value || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
/password|(?:api|private|access)[_-]?key|token|secret|authorization/i.test(key)
? "[redacted]"
: redact(item),
]),
);
}
function result(data: Record<string, unknown>) {
return {
type: "single" as const,
data,
schema: {
idField: () => "configuration",
columns: [],
renderHuman: () => JSON.stringify(data, null, 2),
},
};
}
function homeOf(options: CommandOptions) {
if (options.daemonTarget.kind !== "instance")
throw new Error("Configuration edits require --home");
return options.daemonTarget.home;
}
async function applySaved(home: string, options: CommandOptions) {
const nextCommand = `paseo daemon start --home ${JSON.stringify(home)}`;
const instance = await readDaemonInstance(home);
if (!instance?.listen)
return result({
action: "saved",
applied: false,
message: "Saved; not applied to a running daemon",
nextCommand,
});
let client;
try {
client = await connectToDaemon({ target: options.daemonTarget, timeout: 1_500 });
} catch {
return result({
action: "saved",
applied: false,
message: "Saved; not applied to a running daemon",
nextCommand: `paseo daemon reload --home ${JSON.stringify(home)}`,
});
}
try {
const reload = await client.reloadDaemonConfig();
return result({ action: "saved", ...reload });
} catch (error) {
throw { code: "CONFIG_SAVED_RELOAD_FAILED", message: `Saved; reload failed: ${String(error)}` };
} finally {
await client.close();
}
}
export function daemonConfigCommand(): Command {
const config = new Command("config").description(
"Read or edit persistent local configuration (local operation)",
);
addLocalDaemonOptions(config.command("get [path]")).action(
withOutput(async (field: string | undefined, options: CommandOptions, _command: Command) => {
const persisted = readPersistedConfig(homeOf(options));
const value = field ? getPersistedConfigValue(persisted, field) : persisted;
return result({
source: "configured",
path: field ?? null,
set: value !== undefined,
value:
field && /password|(?:api|private|access)[_-]?key|token|secret|authorization/i.test(field)
? "[redacted]"
: redact(value),
});
}),
);
addLocalDaemonOptions(config.command("set <path> <value>"))
.option("--string", "Interpret value literally as a string")
.action(
withOutput(async (field: string, raw: string, options: CommandOptions, _command: Command) => {
let value: unknown = raw;
if (!options.string) {
try {
value = JSON.parse(raw);
} catch {
/* Unquoted input is a string. */
}
}
editPersistedConfig(homeOf(options), field, { value });
return applySaved(homeOf(options), options);
}),
);
addLocalDaemonOptions(config.command("unset <path>")).action(
withOutput(async (field: string, options: CommandOptions, _command: Command) => {
editPersistedConfig(homeOf(options), field, { unset: true });
return applySaved(homeOf(options), options);
}),
);
return config;
}
+23 -72
View File
@@ -1,81 +1,32 @@
import { Command, Option } from "commander";
import { startCommand } from "./start.js";
import { runStatusCommand } from "./status.js";
import { runStopCommand } from "./stop.js";
import { runRestartCommand } from "./restart.js";
import { Command } from "commander";
import { startCommand, daemonRunCommand } from "./start.js";
import { daemonStatusCommand } from "./status.js";
import { daemonStopCommand } from "./stop.js";
import { daemonRestartCommand } from "./restart.js";
import { runSetPasswordCommand } from "./set-password.js";
import { pairCommand } from "./pair.js";
import { runDaemonReloadCommand } from "./reload.js";
import { daemonReloadCommand } from "./reload.js";
import { daemonConfigCommand } from "./config.js";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions, addJsonOption } from "../../utils/command-options.js";
function resolveHostnamesOption(hostnames: unknown, allowedHosts: unknown): string | undefined {
if (typeof hostnames === "string") return hostnames;
if (typeof allowedHosts === "string") return allowedHosts;
return undefined;
}
import { addJsonOption, addLocalDaemonOptions } from "../../utils/command-options.js";
export function createDaemonCommand(): Command {
const daemon = new Command("daemon").description("Manage the Paseo daemon");
daemon.addCommand(startCommand());
daemon.addCommand(pairCommand());
addJsonAndDaemonHostOptions(
daemon.command("reload").description("Reload config.json without restarting the daemon"),
).action(withOutput(runDaemonReloadCommand));
addJsonOption(daemon.command("status").description("Show local daemon status"))
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.action(withOutput(runStatusCommand));
addJsonOption(daemon.command("stop").description("Stop the local daemon"))
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--timeout <seconds>", "Wait timeout before failing (default: 15)")
.option("--force", "Send SIGKILL if graceful stop times out")
.option("--kill-timeout <seconds>", "Wait after SIGKILL before failing (default: 3)")
.action(withOutput(runStopCommand));
addJsonOption(daemon.command("restart").description("Restart the local daemon"))
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--timeout <seconds>", "Wait timeout before force step (default: 15)")
.option("--force", "Send SIGKILL if graceful stop times out")
.option(
"--listen <listen>",
"Listen target for restarted daemon (host:port, port, or unix socket)",
)
.option("--port <port>", "Port for restarted daemon listen target")
.option("--relay", "Enable relay on restarted daemon")
.option("--no-relay", "Disable relay on restarted daemon")
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
.option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
.option("--web-ui", "Enable the bundled daemon web UI on restarted daemon")
.option("--no-web-ui", "Disable the bundled daemon web UI on restarted daemon")
.option(
"--hostnames <hosts>",
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
)
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
.action(
withOutput((...args) => {
const [options, command] = args.slice(-2) as [(typeof args)[number], Command];
return runRestartCommand(
{
...options,
hostnames: resolveHostnamesOption(options.hostnames, options.allowedHosts),
},
command,
);
}),
);
for (const command of [
startCommand(),
daemonRunCommand(),
daemonStatusCommand(),
daemonStopCommand(),
daemonRestartCommand(),
daemonReloadCommand(),
pairCommand(),
daemonConfigCommand(),
])
daemon.addCommand(command);
addJsonOption(
daemon
.command("set-password")
.description("Prompt for and save a hashed daemon password to config.json"),
)
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.action(withOutput(runSetPasswordCommand));
addLocalDaemonOptions(
daemon.command("set-password").description("Save a hashed daemon password (local operation)"),
),
).action(withOutput(runSetPasswordCommand));
return daemon;
}
@@ -0,0 +1,609 @@
import { spawn } from "node:child_process";
import { mkdtemp, mkdir, readFile, rm, writeFile, stat } from "node:fs/promises";
import { existsSync } from "node:fs";
import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { startDaemonInstance, readDaemonInstance } from "@getpaseo/server";
import { expect, test } from "vitest";
import { connectToDaemon } from "../../utils/client.js";
const repo = fileURLToPath(new URL("../../../../..", import.meta.url));
const cli = path.join(repo, "packages/cli/dist/index.js");
async function port() {
const server = net.createServer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const value = (server.address() as net.AddressInfo).port;
await new Promise<void>((resolve) => server.close(() => resolve()));
if (value === 6767 || value === 6768) throw new Error("Unsafe test port");
return value;
}
async function fixture() {
const root = await mkdtemp(path.join(tmpdir(), "paseo-lifecycle-"));
const env = {
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))),
HOME: root,
USERPROFILE: root,
};
const homes = [path.join(root, "a"), path.join(root, "b")];
const owned = new Map<string, { pid: number; startedAt: string }>();
async function run(args: string[], overrides: NodeJS.ProcessEnv = {}) {
const child = spawn(process.execPath, [cli, ...args], {
cwd: root,
env: { ...env, ...overrides },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "",
stderr = "";
child.stdout.on("data", (data) => {
stdout += data;
});
child.stderr.on("data", (data) => {
stderr += data;
});
const code = await new Promise<number | null>((resolve, reject) => {
const timer = setTimeout(() => {
child.kill("SIGTERM");
reject(new Error(`CLI timed out: ${args.join(" ")}\n${stderr}`));
}, 45_000);
child.once("error", reject);
child.once("close", (exitCode) => {
clearTimeout(timer);
resolve(exitCode);
});
});
for (const home of homes) {
try {
const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8"));
owned.set(home, lock);
} catch {
/* No acquired launch. */
}
}
return { code, stdout, stderr, json: () => JSON.parse(stdout) };
}
async function ok(args: string[], overrides?: NodeJS.ProcessEnv) {
const result = await run(["--json", ...args], overrides);
expect(result.code, `${args.join(" ")}\n${result.stdout}\n${result.stderr}`).toBe(0);
return result.json();
}
async function liveStatus(home: string, overrides?: NodeJS.ProcessEnv) {
let status;
await expect
.poll(
async () => {
status = await ok(["status", "--home", home], overrides);
return status.connectedDaemon;
},
{ timeout: 30_000 },
)
.toBe("reachable");
return status;
}
async function configure(home: string, listen: string) {
for (const [field, value] of [
["daemon.listen", listen],
["daemon.relay.enabled", "false"],
["features.webUi.enabled", "false"],
["features.dictation.enabled", "false"],
["features.voiceMode.enabled", "false"],
]) {
await ok(["daemon", "config", "set", field!, value!, "--home", home]);
}
}
async function close() {
for (const [home, captured] of owned) {
try {
const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8"));
if (lock.pid === captured.pid && lock.startedAt === captured.startedAt) {
await run(["daemon", "stop", "--home", home, "--force", "--timeout", "2"]);
}
} catch {
/* Already exited. */
}
}
await rm(root, { recursive: true, force: true });
}
return { root, homes, env, run, ok, liveStatus, configure, close };
}
test("managed two-home restart retains its supervisor and never routes ordinary commands or stop to the other home", async () => {
const f = await fixture();
const [a, b] = f.homes as [string, string];
const portA = await port(),
portB = await port(),
movedPort = await port();
try {
await f.configure(a, `127.0.0.1:${portA}`);
await f.configure(b, `127.0.0.1:${portB}`);
const launchA = await f.ok(["start", "--home", a, "--timeout", "30"]);
const poisoned = {
PASEO_HOME: a,
PASEO_HOST: `127.0.0.1:${portA}`,
PASEO_LISTEN: `127.0.0.1:${portA}`,
PORT: String(portA),
PASEO_WEB_UI_ENABLED: "true",
PASEO_RELAY_ENABLED: "true",
};
const launchB = await f.ok(["daemon", "start", "--home", b, "--timeout", "30"], poisoned);
expect(launchA.listen).toBe(`127.0.0.1:${portA}`);
expect(launchB.listen).toBe(`127.0.0.1:${portB}`);
const beforeA = await f.liveStatus(a);
const beforeB = await f.ok(["--home", b, "daemon", "status"], poisoned);
if (process.platform !== "win32") {
for (const home of [a, b, path.join(f.root, ".paseo")])
expect((await stat(home)).mode & 0o777).toBe(0o700);
}
const repoB = path.join(f.root, "project-b");
await mkdir(repoB);
await f.ok(["project", "create", repoB, "--home", b], poisoned);
expect(await f.ok(["project", "ls", "--home", a])).toEqual([]);
expect((await f.ok(["project", "ls", "--home", b], poisoned)).length).toBe(1);
const stream = await f.run(["logs", "missing-agent", "--follow", "--home", b], poisoned);
expect(stream.stderr).toContain("Agent not found: missing-agent");
const restart = await f.ok(["restart", "--home", b, "--timeout", "30"], poisoned);
expect(restart.supervisorPid).toBe(launchB.pid);
expect(restart.workerPid).not.toBe(beforeB.workerPid);
const saved = await f.ok(
["daemon", "config", "set", "daemon.listen", `127.0.0.1:${movedPort}`, "--home", b],
poisoned,
);
expect(saved.restartRequiredPaths).toContain("daemon.listen");
await f.ok(["daemon", "restart", "--home", b, "--timeout", "30"], poisoned);
const moved = await f.liveStatus(b);
expect(moved.listen).toBe(`127.0.0.1:${movedPort}`);
expect(moved.pid).toBe(launchB.pid);
await f.ok(["daemon", "stop", "--home", b], poisoned);
const stoppedQuery = await f.run(["--json", "project", "ls", "--home", b], poisoned);
expect(stoppedQuery.code).toBe(1);
expect(stoppedQuery.stderr).toContain("DAEMON_NOT_RUNNING");
const afterA = await f.liveStatus(a);
expect(afterA.workerPid).toBe(beforeA.workerPid);
expect(afterA.pid).toBe(beforeA.pid);
expect(await f.ok(["project", "ls", "--home", a])).toEqual([]);
} finally {
await f.close();
}
}, 120_000);
test("removed flags and ambiguous targets fail before side effects; observation does not create a home", async () => {
const f = await fixture();
const home = f.homes[0]!;
try {
await f.ok(["status", "--home", home]);
await f.ok(["daemon", "config", "get", "--home", home]);
expect(existsSync(home)).toBe(false);
for (const args of [
["start", "--port", "12345"],
["start", "--foreground"],
["daemon", "restart", "--no-relay"],
]) {
const result = await f.run(["--json", ...args, "--home", home]);
expect(result.code).toBe(1);
expect(result.stderr).toContain("REMOVED_LAUNCH_OPTION");
expect(existsSync(home)).toBe(false);
}
const ambiguous = await f.run(["--home", home, "project", "ls", "--host", "unused:12345"]);
expect(ambiguous.stderr).toContain("Choose either --home or --host");
expect(existsSync(home)).toBe(false);
} finally {
await f.close();
}
}, 30_000);
test("an occupied initial or replacement address fails without false readiness or killing its owner", async () => {
const f = await fixture();
const [a, b] = f.homes as [string, string];
const portA = await port(),
portB = await port();
try {
await f.configure(a, `127.0.0.1:${portA}`);
await f.configure(b, `127.0.0.1:${portA}`);
await f.ok(["start", "--home", a, "--timeout", "30"]);
const before = await f.liveStatus(a);
const failed = await f.run(["--json", "start", "--home", b, "--timeout", "30"]);
expect(failed.code).toBe(1);
expect(failed.stderr).toContain("DAEMON_START_FAILED");
await f.ok(["daemon", "config", "set", "daemon.listen", `127.0.0.1:${portB}`, "--home", b]);
await f.ok(["start", "--home", b, "--timeout", "30"]);
await f.ok(["daemon", "config", "set", "daemon.listen", `127.0.0.1:${portA}`, "--home", b]);
const restart = await f.run(["--json", "restart", "--home", b, "--timeout", "30"]);
expect(restart.code).toBe(1);
expect(restart.stderr).toContain("RESTART_NOT_CONFIRMED");
await f.ok(["daemon", "stop", "--home", b]);
expect((await f.liveStatus(a)).workerPid).toBe(before.workerPid);
} finally {
await f.close();
}
}, 120_000);
test("a slow worker outlives the caller's readiness deadline and remains stoppable", async () => {
const f = await fixture();
const home = f.homes[0]!;
try {
await f.configure(home, `127.0.0.1:${await port()}`);
const preload = path.join(f.root, "slow-worker.mjs");
await writeFile(
preload,
'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 5000));',
);
const launch = await f.run(["--json", "start", "--home", home, "--timeout", "1"], {
NODE_OPTIONS: `--import=${pathToFileURL(preload).href}`,
});
expect(launch.code).toBe(1);
expect(launch.stderr).toContain("DAEMON_NOT_READY");
expect(launch.stderr).toContain("remains running");
expect(launch.stderr).toContain("Logs:");
expect(launch.stderr).toContain("Status:");
expect(launch.stderr).toContain("Stop:");
const pending = await f.ok(["status", "--home", home]);
expect(pending.localDaemon).toBe("not_ready");
expect(pending.listen).toBeNull();
process.kill(pending.pid, 0);
const already = await f.ok(["start", "--home", home]);
expect(already.action).toBe("already_running");
expect(already.pid).toBe(pending.pid);
await f.ok(["project", "ls", "--home", home]);
expect((await f.ok(["status", "--home", home])).pid).toBe(pending.pid);
await f.ok(["daemon", "stop", "--home", home]);
} finally {
await f.close();
}
}, 60_000);
test("worker restart preserves an already-running legacy supervisor's launch flags", async () => {
const f = await fixture();
const home = f.homes[0]!;
const filePort = await port(),
launchPort = await port();
try {
await f.configure(home, `127.0.0.1:${filePort}`);
const launch = await startDaemonInstance({
home,
command: process.execPath,
args: [
path.join(repo, "packages/server/dist/scripts/supervisor-entrypoint.js"),
"--no-relay",
"--no-web-ui",
],
// The legacy CLI translated --port into PASEO_LISTEN before spawning.
env: { ...f.env, PASEO_LISTEN: `127.0.0.1:${launchPort}` },
mode: "deployment",
timeoutMs: 30_000,
});
const before = await f.liveStatus(home);
expect(before.listen).toBe(`127.0.0.1:${launchPort}`);
const restarted = await f.ok(["restart", "--home", home, "--timeout", "30"], {
PASEO_LISTEN: `127.0.0.1:${filePort}`,
});
expect(restarted.supervisorPid).toBe(launch.instance.pid);
expect(restarted.workerPid).not.toBe(before.workerPid);
expect((await f.ok(["status", "--home", home])).listen).toBe(before.listen);
} finally {
await f.close();
}
}, 60_000);
test.skipIf(process.platform === "win32")(
"POSIX stop ignores a wrong endpoint even with copied identities; supported live identity overrides still restart",
async () => {
const f = await fixture();
const [a, b] = f.homes as [string, string];
try {
await f.configure(a, `127.0.0.1:${await port()}`);
await f.configure(b, `127.0.0.1:${await port()}`);
await writeFile(path.join(b, "server-id"), "saved-b");
await f.ok(["start", "--home", a, "--timeout", "30"]);
await f.ok(["start", "--home", b, "--timeout", "30"], { PASEO_SERVER_ID: "live-b" });
const beforeA = await f.liveStatus(a);
expect((await f.liveStatus(b)).serverId).toBe("live-b");
await f.ok(["restart", "--home", b, "--timeout", "30"]);
const bLock = JSON.parse(await readFile(path.join(b, "paseo.pid"), "utf8"));
await writeFile(
path.join(b, "paseo.pid"),
JSON.stringify({ ...bLock, listen: beforeA.listen }),
);
await writeFile(path.join(b, "server-id"), beforeA.serverId);
await f.ok(["daemon", "stop", "--home", b]);
expect((await f.liveStatus(a)).workerPid).toBe(beforeA.workerPid);
} finally {
await f.close();
}
},
60_000,
);
test.skipIf(process.platform === "win32").each([["start"], ["daemon", "run"]])(
"explicit cancellation before readiness cleans up only this launch: %j",
async (...launchArgs) => {
const f = await fixture();
const home = f.homes[0]!;
let child: ReturnType<typeof spawn> | undefined;
try {
await f.configure(home, `127.0.0.1:${await port()}`);
const preload = path.join(f.root, "slow-worker.mjs");
await writeFile(
preload,
'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 30000));',
);
child = spawn(process.execPath, [cli, ...launchArgs, "--home", home], {
cwd: repo,
env: { ...f.env, NODE_OPTIONS: `--import=${pathToFileURL(preload).href}` },
stdio: "ignore",
});
const exited = new Promise((resolve) => child!.once("exit", resolve));
await expect
.poll(async () => existsSync(path.join(home, "paseo.pid")), { timeout: 10_000 })
.toBe(true);
const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8"));
await f.ok(["status", "--home", home]);
child.kill("SIGINT");
await exited;
expect(() => process.kill(lock.pid, 0)).toThrow();
expect((await f.ok(["status", "--home", home])).localDaemon).toBe("stopped");
} finally {
child?.kill("SIGTERM");
await f.close();
}
},
60_000,
);
test.runIf(process.platform === "win32")(
"Windows unbound stop requires explicit force",
async () => {
const f = await fixture();
const home = f.homes[0]!;
try {
await f.configure(home, `127.0.0.1:${await port()}`);
const preload = path.join(f.root, "slow-worker.mjs");
await writeFile(
preload,
'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 30000));',
);
const pending = await f.run(["--json", "start", "--home", home, "--timeout", "1"], {
NODE_OPTIONS: `--import=${pathToFileURL(preload).href}`,
});
expect(pending.stderr).toContain("DAEMON_NOT_READY");
const refused = await f.run(["--json", "daemon", "stop", "--home", home]);
expect(refused.code).toBe(1);
expect(refused.stderr).toContain("STOP_NO_GRACEFUL_CHANNEL");
const stopped = await f.ok(["daemon", "stop", "--home", home, "--force"]);
expect(stopped.forced).toBe(true);
expect(stopped.usedLifecycleRpc).toBe(false);
} finally {
await f.close();
}
},
60_000,
);
test("empty explicit selectors never select the ambient daemon or create local state", async () => {
const f = await fixture();
const home = f.homes[0]!;
try {
await f.configure(home, `127.0.0.1:${await port()}`);
await f.ok(["start", "--home", home]);
const before = await f.liveStatus(home);
for (const args of [
["daemon", "stop", "--home", ""],
["daemon", "stop", "--host", ""],
["start", "--home", ""],
["daemon", "config", "set", "daemon.relay.enabled", "true", "--home", ""],
]) {
const refused = await f.run(["--json", ...args], { PASEO_HOME: home });
expect(refused.code).toBe(1);
expect(refused.stderr).toContain("TARGET_INVALID");
expect((await f.liveStatus(home)).workerPid).toBe(before.workerPid);
expect(existsSync(path.join(f.root, "paseo.pid"))).toBe(false);
expect(existsSync(path.join(f.root, "config.json"))).toBe(false);
}
} finally {
await f.close();
}
}, 60_000);
test("IPv6 publication supports home-selected query and worker restart", async () => {
const f = await fixture();
const home = f.homes[0]!;
const endpoint = `[::1]:${await port()}`;
try {
await f.configure(home, endpoint);
const launch = await f.ok(["start", "--home", home, "--timeout", "30"]);
expect(launch.listen).toBe(endpoint);
expect(await f.ok(["project", "ls", "--home", home])).toEqual([]);
const restarted = await f.ok(["restart", "--home", home, "--timeout", "30"]);
expect(restarted.supervisorPid).toBe(launch.pid);
expect(restarted.workerPid).not.toBe(restarted.previousWorkerPid);
} finally {
await f.close();
}
}, 60_000);
test("raw log following subscribes to a stored agent that exists only in B", async () => {
const f = await fixture();
const [a, b] = f.homes as [string, string];
let follower: ReturnType<typeof spawn> | undefined;
try {
await f.configure(a, `127.0.0.1:${await port()}`);
await f.configure(b, `127.0.0.1:${await port()}`);
const agentId = "4de8b157-7614-4c38-b189-e50e57fb9498";
const now = new Date().toISOString();
await mkdir(path.join(b, "agents"));
await writeFile(
path.join(b, "agents", `${agentId}.json`),
JSON.stringify({
id: agentId,
provider: "codex",
cwd: f.root,
title: "B-only stored agent",
createdAt: now,
updatedAt: now,
lastStatus: "closed",
persistence: null,
}),
);
const launchA = await f.ok(["start", "--home", a]);
await f.ok(["start", "--home", b]);
const absent = await f.run(["logs", agentId, "--home", a]);
expect(absent.stderr).toContain("Agent not found");
follower = spawn(
process.execPath,
[cli, "logs", agentId, "--follow", "--tail", "0", "--home", b],
{
cwd: f.root,
env: { ...f.env, PASEO_HOME: a, PASEO_HOST: launchA.listen },
stdio: ["ignore", "pipe", "pipe"],
},
);
let output = "",
errors = "";
follower.stdout!.on("data", (data) => {
output += data;
});
follower.stderr!.on("data", (data) => {
errors += data;
});
await expect.poll(() => output, { timeout: 15_000 }).toContain("Following logs (no history;");
expect(errors).not.toContain("Agent not found");
} finally {
if (follower && follower.exitCode === null) {
const exited = new Promise((resolve) => follower!.once("exit", resolve));
follower.kill("SIGINT");
await exited;
}
await f.close();
}
}, 60_000);
test("a connection pinned to an earlier supervisor refuses its replacement", async () => {
const f = await fixture();
const home = f.homes[0]!;
try {
await f.configure(home, `127.0.0.1:${await port()}`);
await f.ok(["start", "--home", home]);
const captured = (await readDaemonInstance(home))!;
await f.ok(["daemon", "stop", "--home", home]);
await f.ok(["start", "--home", home]);
const before = await f.liveStatus(home);
await expect(
connectToDaemon({ target: { kind: "instance", home }, instance: captured, timeout: 1000 }),
).rejects.toMatchObject({ code: "DAEMON_REPLACED" });
expect((await f.liveStatus(home)).workerPid).toBe(before.workerPid);
} finally {
await f.close();
}
}, 60_000);
test("foreground deployment retains environment until its owner ends the launch; endpoint stop reports acknowledgment", async () => {
const f = await fixture();
const home = f.homes[0]!;
const fileEndpoint = `127.0.0.1:${await port()}`,
deploymentEndpoint = `127.0.0.1:${await port()}`,
changedEndpoint = `127.0.0.1:${await port()}`;
let deployment: ReturnType<typeof spawn> | undefined;
try {
await f.configure(home, fileEndpoint);
deployment = spawn(process.execPath, [cli, "daemon", "run", "--home", home], {
cwd: f.root,
env: { ...f.env, PASEO_LISTEN: deploymentEndpoint, PASEO_RELAY_ENABLED: "false" },
stdio: "ignore",
});
await expect
.poll(async () => (await readDaemonInstance(home))?.listen, { timeout: 30_000 })
.toBe(deploymentEndpoint);
const before = await f.liveStatus(home);
expect(before.configuredListen).toBe(fileEndpoint);
expect(await f.ok(["daemon", "run", "--home", home])).toMatchObject({
action: "already_running",
pid: before.pid,
});
expect((await f.liveStatus(home)).workerPid).toBe(before.workerPid);
const changed = await f.ok([
"daemon",
"config",
"set",
"daemon.listen",
changedEndpoint,
"--home",
home,
]);
expect(changed.overrideControlledPaths).toContain("daemon.listen");
const restarted = await f.ok(["restart", "--home", home], { PASEO_LISTEN: fileEndpoint });
expect(restarted.supervisorPid).toBe(before.pid);
expect((await f.ok(["status", "--home", home])).listen).toBe(deploymentEndpoint);
const exited = new Promise((resolve) => deployment!.once("exit", resolve));
const remote = await f.ok(["daemon", "stop", "--host", deploymentEndpoint]);
expect(remote.action).toBe("shutdown_requested");
await exited;
const managed = await f.ok(["start", "--home", home]);
expect(managed.listen).toBe(changedEndpoint);
expect(managed.pid).not.toBe(before.pid);
} finally {
if (deployment?.exitCode === null) deployment.kill("SIGTERM");
await f.close();
}
}, 90_000);
test("malformed configuration cannot turn a readiness timeout into launch cancellation", async () => {
const f = await fixture();
const home = f.homes[0]!;
let starter: ReturnType<typeof spawn> | undefined;
try {
await f.configure(home, `127.0.0.1:${await port()}`);
const configPath = path.join(home, "config.json");
const saved = await readFile(configPath, "utf8");
const preload = path.join(f.root, "slow-worker.mjs");
await writeFile(
preload,
'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 30000));',
);
starter = spawn(process.execPath, [cli, "--json", "start", "--home", home, "--timeout", "2"], {
cwd: f.root,
env: { ...f.env, NODE_OPTIONS: `--import=${pathToFileURL(preload).href}` },
stdio: ["ignore", "pipe", "pipe"],
});
let errors = "";
starter.stderr!.on("data", (data) => {
errors += data;
});
const exited = new Promise((resolve) => starter!.once("exit", resolve));
await expect
.poll(async () => (await readDaemonInstance(home))?.pid, { timeout: 10_000 })
.toBeTruthy();
const captured = (await readDaemonInstance(home))!;
await f.ok(["status", "--home", home]);
await writeFile(configPath, "{");
await exited;
expect(errors).toContain("DAEMON_NOT_READY");
expect(errors).toContain(path.join(home, "daemon.log"));
process.kill(captured.pid, 0);
await writeFile(configPath, saved);
await f.ok(["status", "--home", home]);
} finally {
if (starter?.exitCode === null) starter.kill("SIGTERM");
await f.close();
}
}, 60_000);
test("onboarding preserves an existing MCP choice unless explicitly supplied", async () => {
const f = await fixture();
const home = f.homes[0]!;
try {
await f.configure(home, `127.0.0.1:${await port()}`);
await f.ok(["daemon", "config", "set", "daemon.mcp.enabled", "false", "--home", home]);
for (const flags of [[], ["--no-mcp"]]) {
const onboard = await f.run(["onboard", "--home", home, "--voice", "disable", ...flags]);
expect(onboard.code, onboard.stderr).toBe(0);
expect(
(await f.ok(["daemon", "config", "get", "daemon.mcp.enabled", "--home", home])).value,
).toBe(false);
}
} finally {
await f.close();
}
}, 60_000);
@@ -1,214 +0,0 @@
import { EventEmitter } from "node:events";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
type DaemonLaunchRuntime,
type DetachedDaemonProcess,
resolveLocalDaemonState,
startLocalDaemonDetached,
startLocalDaemonForeground,
} from "./local-daemon.js";
type RecordedDaemonLaunch =
| {
mode: "detached";
command: string;
args: string[];
options: Parameters<DaemonLaunchRuntime["spawnDetached"]>[2];
}
| {
mode: "foreground";
command: string;
args: string[];
options: Parameters<DaemonLaunchRuntime["spawnForeground"]>[2];
};
class FakeDaemonProcess extends EventEmitter implements DetachedDaemonProcess {
pid = 4242;
wasUnreferenced = false;
unref(): void {
this.wasUnreferenced = true;
}
}
class FakeDaemonRuntime implements DaemonLaunchRuntime {
readonly recordedLaunches: RecordedDaemonLaunch[] = [];
readonly daemonProcess = new FakeDaemonProcess();
foregroundStatus = 0;
runnerEntry = "/repo/packages/server/scripts/supervisor-entrypoint.ts";
resolveRunnerEntry(): string {
return this.runnerEntry;
}
resolveHome(env: NodeJS.ProcessEnv): string {
return env.PASEO_HOME ?? "/tmp/paseo";
}
spawnDetached(
command: string,
args: string[],
options: Parameters<DaemonLaunchRuntime["spawnDetached"]>[2],
): DetachedDaemonProcess {
this.recordedLaunches.push({ mode: "detached", command, args, options });
return this.daemonProcess;
}
spawnForeground(
command: string,
args: string[],
options: Parameters<DaemonLaunchRuntime["spawnForeground"]>[2],
) {
this.recordedLaunches.push({ mode: "foreground", command, args, options });
return { status: this.foregroundStatus, error: undefined };
}
}
const tempRoots: string[] = [];
async function createPaseoHome(config: unknown): Promise<string> {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-local-daemon-"));
tempRoots.push(root);
const paseoHome = path.join(root, ".paseo");
await mkdir(paseoHome, { recursive: true });
await writeFile(path.join(paseoHome, "config.json"), JSON.stringify(config, null, 2));
return paseoHome;
}
function expectSupervisorLaunch(argv: string[]): void {
const joined = argv.join(" ");
expect(joined).toContain("supervisor-entrypoint");
expect(joined).not.toContain("src/server/index.ts");
expect(joined).not.toContain("dist/server/server/index.js");
expect(joined).not.toContain("src/server/daemon-worker.ts");
expect(joined).not.toContain("dist/server/server/daemon-worker.js");
}
describe("local daemon launch supervision", () => {
beforeEach(() => {
vi.useRealTimers();
});
afterEach(async () => {
await Promise.all(
tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
vi.restoreAllMocks();
});
test("foreground start spawns supervisor-entrypoint instead of server/index", async () => {
const runtime = new FakeDaemonRuntime();
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false }, runtime);
expect(status).toBe(0);
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
const launch = runtime.recordedLaunches[0];
expect(launch?.mode).toBe("foreground");
expect(launch?.command).toBe(process.execPath);
expectSupervisorLaunch(launch?.args ?? []);
expect(launch?.args).toContain("--no-relay");
});
test("detached start spawns supervisor-entrypoint instead of server/index", async () => {
vi.useFakeTimers();
const runtime = new FakeDaemonRuntime();
const resultPromise = startLocalDaemonDetached(
{ home: "/tmp/paseo-test", mcp: false },
runtime,
);
await vi.advanceTimersByTimeAsync(1200);
const result = await resultPromise;
expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" });
expect(runtime.daemonProcess.wasUnreferenced).toBe(true);
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["detached"]);
const launch = runtime.recordedLaunches[0];
expect(launch?.mode).toBe("detached");
expect(launch?.command).toBe(process.execPath);
expectSupervisorLaunch(launch?.args ?? []);
expect(launch?.args).toContain("--no-mcp");
});
test("relay TLS flag is passed to the supervised daemon", async () => {
const runtime = new FakeDaemonRuntime();
const status = startLocalDaemonForeground(
{
home: "/tmp/paseo-test",
relayUseTls: true,
},
runtime,
);
expect(status).toBe(0);
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
const launch = runtime.recordedLaunches[0];
expect(launch?.mode).toBe("foreground");
expect(launch?.args).toContain("--relay-use-tls");
expect(launch?.options?.env?.PASEO_RELAY_USE_TLS).toBe("true");
});
test("web UI flag is passed to the supervised daemon", async () => {
const runtime = new FakeDaemonRuntime();
const status = startLocalDaemonForeground(
{
home: "/tmp/paseo-test",
webUi: true,
},
runtime,
);
expect(status).toBe(0);
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
const launch = runtime.recordedLaunches[0];
expect(launch?.mode).toBe("foreground");
expect(launch?.args).toContain("--web-ui");
expect(launch?.options?.env?.PASEO_WEB_UI_ENABLED).toBe("true");
});
test("no-web UI flag is passed to the supervised daemon", async () => {
const runtime = new FakeDaemonRuntime();
const status = startLocalDaemonForeground(
{
home: "/tmp/paseo-test",
webUi: false,
},
runtime,
);
expect(status).toBe(0);
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
const launch = runtime.recordedLaunches[0];
expect(launch?.mode).toBe("foreground");
expect(launch?.args).toContain("--no-web-ui");
expect(launch?.options?.env?.PASEO_WEB_UI_ENABLED).toBe("false");
});
test("local daemon state keeps public relay TLS separate from daemon relay TLS", async () => {
const home = await createPaseoHome({
version: 1,
daemon: {
relay: {
endpoint: "10.0.0.5:51185",
publicEndpoint: "paseo.example.com",
useTls: false,
publicUseTls: true,
},
},
});
const state = resolveLocalDaemonState({ home });
expect(state.relayEndpoint).toBe("paseo.example.com");
expect(state.relayUseTls).toBe(false);
expect(state.relayPublicUseTls).toBe(true);
});
});
+62 -715
View File
@@ -1,184 +1,9 @@
import { spawnSync, type ChildProcess } from "node:child_process";
import { existsSync, readFileSync, unlinkSync } from "node:fs";
import { Command, Option } from "commander";
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { loadConfig, resolvePaseoHome, spawnProcess } from "@getpaseo/server";
import treeKill from "tree-kill";
import { tryConnectToDaemon } from "../../utils/client.js";
export interface DaemonStartOptions {
port?: string;
listen?: string;
home?: string;
foreground?: boolean;
relay?: boolean;
relayUseTls?: boolean;
mcp?: boolean;
injectMcp?: boolean;
webUi?: boolean;
hostnames?: string;
}
export interface LocalDaemonPidInfo {
pid: number;
startedAt?: string;
hostname?: string;
uid?: number;
listen?: string;
desktopManaged?: boolean;
}
export interface LocalDaemonState {
home: string;
listen: string;
relayEnabled: boolean;
relayEndpoint: string;
relayUseTls: boolean;
relayPublicUseTls: boolean;
logPath: string;
pidPath: string;
pidInfo: LocalDaemonPidInfo | null;
running: boolean;
stalePidFile: boolean;
}
export interface DetachedStartResult {
pid: number | null;
logPath: string;
}
export interface StopLocalDaemonOptions {
home?: string;
timeoutMs?: number;
killTimeoutMs?: number;
force?: boolean;
}
export interface StopLocalDaemonResult {
action: "stopped" | "not_running";
home: string;
pid: number | null;
forced: boolean;
usedLifecycleRpc: boolean;
reason: "not_running" | "lifecycle_shutdown_rpc" | "owner_pid_signal" | "owner_pid_sigkill";
message: string;
}
interface ProcessExitDetails {
code: number | null;
signal: NodeJS.Signals | null;
error?: Error;
}
type DetachedStartupResult = { exitedEarly: false } | ({ exitedEarly: true } & ProcessExitDetails);
export interface DetachedDaemonProcess extends Pick<ChildProcess, "once" | "pid" | "unref"> {}
export interface ForegroundDaemonProcessResult {
status: number | null;
error?: Error;
}
export interface DaemonLaunchRuntime {
resolveRunnerEntry(): string;
resolveHome(env: NodeJS.ProcessEnv): string;
spawnDetached(
command: string,
args: string[],
options: Parameters<typeof spawnProcess>[2],
): DetachedDaemonProcess;
spawnForeground(
command: string,
args: string[],
options: Parameters<typeof spawnSync>[2],
): ForegroundDaemonProcessResult;
}
const DETACHED_STARTUP_GRACE_MS = 1200;
const PID_POLL_INTERVAL_MS = 100;
const DAEMON_LOG_FILENAME = "daemon.log";
const DAEMON_PID_FILENAME = "paseo.pid";
export const DEFAULT_STOP_TIMEOUT_MS = 15_000;
export const DEFAULT_KILL_TIMEOUT_MS = 3_000;
import { startDaemonInstance, resolvePaseoHome } from "@getpaseo/server";
const require = createRequire(import.meta.url);
const defaultDaemonLaunchRuntime: DaemonLaunchRuntime = {
resolveRunnerEntry: resolveDaemonRunnerEntry,
resolveHome: resolvePaseoHome,
spawnDetached: spawnProcess,
spawnForeground: spawnSync,
};
const startupReady = (): DetachedStartupResult => ({ exitedEarly: false });
const startupExited = (details: ProcessExitDetails): DetachedStartupResult => ({
exitedEarly: true,
...details,
});
function envWithHome(home?: string): NodeJS.ProcessEnv {
if (!home) {
return process.env;
}
return { ...process.env, PASEO_HOME: home };
}
function buildRunnerArgs(options: DaemonStartOptions): string[] {
const args: string[] = [];
if (options.relay === true) {
args.push("--relay");
}
if (options.relay === false) {
args.push("--no-relay");
}
if (options.relayUseTls === true) {
args.push("--relay-use-tls");
}
if (options.mcp === false) {
args.push("--no-mcp");
}
if (options.injectMcp === false) {
args.push("--no-inject-mcp");
}
if (options.webUi === true) {
args.push("--web-ui");
}
if (options.webUi === false) {
args.push("--no-web-ui");
}
return args;
}
function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv {
const childEnv: NodeJS.ProcessEnv = { ...process.env };
if (options.home) {
childEnv.PASEO_HOME = options.home;
}
if (options.listen) {
childEnv.PASEO_LISTEN = options.listen;
} else if (options.port) {
childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`;
}
if (options.hostnames) {
childEnv.PASEO_HOSTNAMES = options.hostnames;
}
if (options.relayUseTls === true) {
childEnv.PASEO_RELAY_USE_TLS = "true";
}
if (options.webUi === true) {
childEnv.PASEO_WEB_UI_ENABLED = "true";
}
if (options.webUi === false) {
childEnv.PASEO_WEB_UI_ENABLED = "false";
}
return childEnv;
}
function resolveServerRunnerFromDir(currentDir: string): string | null {
const packageJsonPath = path.join(currentDir, "package.json");
if (!existsSync(packageJsonPath)) return null;
@@ -215,550 +40,72 @@ function resolveDaemonRunnerEntry(): string {
throw new Error("Unable to resolve @getpaseo/server package root for daemon runner");
}
function pidFilePath(paseoHome: string): string {
return path.join(paseoHome, DAEMON_PID_FILENAME);
}
function resolveListenField(listen: unknown, sockPath: unknown): string | undefined {
if (typeof listen === "string") return listen;
if (typeof sockPath === "string") return sockPath;
return undefined;
}
function resolveStopMessage(
forced: boolean,
lifecycleRequested: boolean,
fallbackMessage: string | null | undefined,
): string {
if (forced) return "Daemon owner process was force-stopped";
if (lifecycleRequested) return "Daemon stopped gracefully";
return fallbackMessage ?? "Daemon stopped via owner PID signal";
}
function resolveStopReason(
forced: boolean,
lifecycleRequested: boolean,
): StopLocalDaemonResult["reason"] {
if (forced) return "owner_pid_sigkill";
if (lifecycleRequested) return "lifecycle_shutdown_rpc";
return "owner_pid_signal";
}
function readPidFile(pidPath: string): LocalDaemonPidInfo | null {
export async function launchLocalDaemon(options: {
home: string;
timeoutMs?: number;
foreground?: boolean;
}) {
const abort = new AbortController();
const cancel = () => abort.abort();
process.once("SIGINT", cancel);
process.once("SIGTERM", cancel);
try {
const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as Record<string, unknown>;
const pidValue = parsed.pid;
if (typeof pidValue !== "number" || !Number.isInteger(pidValue) || pidValue <= 0) {
return null;
}
return {
pid: pidValue,
startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : undefined,
hostname: typeof parsed.hostname === "string" ? parsed.hostname : undefined,
uid: typeof parsed.uid === "number" ? parsed.uid : undefined,
listen: resolveListenField(parsed.listen, parsed.sockPath),
desktopManaged: parsed.desktopManaged === true ? true : undefined,
};
} catch {
return null;
}
}
function tailFile(filePath: string, lines = 30): string | null {
try {
const content = readFileSync(filePath, "utf-8");
return content.split("\n").filter(Boolean).slice(-lines).join("\n");
} catch {
return null;
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function readNodeErrnoCode(error: unknown): string | undefined {
if (typeof error !== "object" || error === null || !("code" in error)) {
return undefined;
}
return typeof error.code === "string" ? error.code : undefined;
}
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (err) {
const code = readNodeErrnoCode(err);
if (code === "EPERM") {
return true;
}
return false;
}
}
function signalProcess(pid: number, signal: NodeJS.Signals): boolean {
try {
process.kill(pid, signal);
return true;
} catch (err) {
const code = readNodeErrnoCode(err);
if (code === "ESRCH") {
return false;
}
throw err;
}
}
function signalProcessSafely(pid: number, signal: NodeJS.Signals): boolean {
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) {
return false;
}
try {
return signalProcess(pid, signal);
} catch (err) {
const code = readNodeErrnoCode(err);
if (code === "EPERM") {
return true;
}
throw err;
}
}
async function signalProcessTreeSafely(pid: number, signal: NodeJS.Signals): Promise<boolean> {
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) {
return false;
}
return new Promise((resolve, reject) => {
treeKill(pid, signal, (err) => {
if (!err) {
resolve(true);
return;
}
const code = readNodeErrnoCode(err);
if (code === "ESRCH") {
resolve(false);
return;
}
if (code === "EPERM") {
resolve(true);
return;
}
reject(err);
const entry = resolveDaemonRunnerEntry();
return await startDaemonInstance({
home: resolvePaseoHome({ PASEO_HOME: options.home }),
command: process.execPath,
args: [...(entry.endsWith(".ts") ? ["--import", "tsx"] : []), entry],
env: process.env,
mode: options.foreground ? "deployment" : "managed",
foreground: options.foreground,
timeoutMs: options.timeoutMs,
signal: abort.signal,
onReady: options.foreground
? (instance) =>
process.stdout.write(`Listening on ${instance.listen} (PID ${instance.pid})\n`)
: undefined,
});
});
}
async function signalProcessTreeOrOwnerSafely(
pid: number,
signal: NodeJS.Signals,
): Promise<boolean> {
try {
return await signalProcessTreeSafely(pid, signal);
} catch {
return signalProcessSafely(pid, signal);
}
}
async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
async function poll(): Promise<boolean> {
if (!isProcessRunning(pid)) return true;
if (Date.now() >= deadline) return !isProcessRunning(pid);
await sleep(PID_POLL_INTERVAL_MS);
return poll();
}
return poll();
}
async function waitForDaemonUnreachable(
state: LocalDaemonState,
timeoutMs: number,
): Promise<boolean> {
const host = resolveTcpHostFromListen(state.listen);
if (!host) {
return true;
}
const reachableHost = host;
const deadline = Date.now() + timeoutMs;
async function poll(): Promise<boolean> {
const client = await tryConnectToDaemon({ host: reachableHost, timeout: 500 });
if (!client) {
return true;
}
await client.close().catch(() => undefined);
if (Date.now() >= deadline) {
const finalClient = await tryConnectToDaemon({
host: reachableHost,
timeout: PID_POLL_INTERVAL_MS,
});
if (!finalClient) {
return true;
}
await finalClient.close().catch(() => undefined);
return false;
}
await sleep(PID_POLL_INTERVAL_MS);
return poll();
}
return poll();
}
function removeStalePidFile(state: LocalDaemonState): void {
if (!state.stalePidFile) {
return;
}
try {
unlinkSync(state.pidPath);
} catch {
// Best-effort cleanup only. The successful lifecycle stop is authoritative.
}
}
function createNotRunningStopResult(
state: LocalDaemonState,
pid: number | null,
message: string,
): StopLocalDaemonResult {
return {
action: "not_running",
home: state.home,
pid,
forced: false,
usedLifecycleRpc: false,
reason: "not_running",
message,
};
}
function createStopTimeoutError(
state: LocalDaemonState,
pid: number | null,
timeoutMs: number,
): Error {
if (!state.running) {
const host = resolveTcpHostFromListen(state.listen);
return new Error(
`Timed out waiting for daemon${host ? ` at ${host}` : ""} to stop after ${Math.ceil(
timeoutMs / 1000,
)}s`,
);
}
return new Error(
`Timed out waiting for daemon PID ${pid} to stop after ${Math.ceil(timeoutMs / 1000)}s`,
);
}
async function signalDaemonOwnerForStop(
state: LocalDaemonState,
pid: number | null,
): Promise<StopLocalDaemonResult | null> {
if (pid === null) {
return createNotRunningStopResult(state, null, "Daemon is not running");
}
const signaled = await signalProcessTreeOrOwnerSafely(pid, "SIGTERM");
if (signaled) {
return null;
}
return createNotRunningStopResult(state, pid, "Daemon process was already stopped");
}
async function waitForStopAfterRequest(args: {
state: LocalDaemonState;
pid: number | null;
timeoutMs: number;
killTimeoutMs: number;
force?: boolean;
}): Promise<{ stopped: boolean; forced: boolean }> {
const { state, pid, timeoutMs, killTimeoutMs, force } = args;
let stopped =
state.running && pid !== null
? await waitForPidExit(pid, timeoutMs)
: await waitForDaemonUnreachable(state, timeoutMs);
if (!stopped && force && state.running && pid !== null) {
await signalProcessTreeOrOwnerSafely(pid, "SIGKILL");
stopped = await waitForPidExit(pid, killTimeoutMs);
return { stopped, forced: true };
}
return { stopped, forced: false };
}
type LifecycleShutdownAttempt = { requested: true } | { requested: false; reason: string };
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function resolveLocalPaseoHome(home?: string): string {
return resolvePaseoHome(envWithHome(home));
}
export function resolveTcpHostFromListen(listen: string): string | null {
const normalized = listen.trim();
if (!normalized) {
return null;
}
if (
normalized.startsWith("/") ||
normalized.startsWith("unix://") ||
normalized.startsWith("pipe://") ||
normalized.startsWith("\\\\.\\pipe\\") ||
/^[A-Za-z]:[/\\]/.test(normalized)
) {
return null;
}
if (/^\d+$/.test(normalized)) {
return `127.0.0.1:${normalized}`;
}
if (normalized.includes(":")) {
return normalized;
}
return null;
}
export function resolveLocalDaemonState(options: { home?: string } = {}): LocalDaemonState {
const env: NodeJS.ProcessEnv = {
...envWithHome(options.home),
// Status should reflect local persisted config + pid file, not inherited daemon env overrides.
// This is CLI-side defensive scrubbing; the daemon RPC is authoritative when available.
PASEO_LISTEN: undefined,
PASEO_HOSTNAMES: undefined,
PASEO_ALLOWED_HOSTS: undefined,
PASEO_RELAY_ENABLED: undefined,
PASEO_RELAY_ENDPOINT: undefined,
PASEO_RELAY_PUBLIC_ENDPOINT: undefined,
PASEO_RELAY_USE_TLS: undefined,
PASEO_RELAY_PUBLIC_USE_TLS: undefined,
};
const home = resolvePaseoHome(env);
const config = loadConfig(home, { env });
const pidPath = pidFilePath(home);
const logPath = path.join(home, DAEMON_LOG_FILENAME);
const pidInfo = existsSync(pidPath) ? readPidFile(pidPath) : null;
const running = pidInfo ? isProcessRunning(pidInfo.pid) : false;
const listen = pidInfo?.listen ?? config.listen;
return {
home,
listen,
relayEnabled: config.relayEnabled ?? true,
relayEndpoint: config.relayPublicEndpoint ?? config.relayEndpoint ?? "relay.paseo.sh:443",
relayUseTls: config.relayUseTls ?? false,
relayPublicUseTls: config.relayPublicUseTls ?? config.relayUseTls ?? false,
logPath,
pidPath,
pidInfo,
running,
stalePidFile: Boolean(pidInfo) && !running,
};
}
export function tailDaemonLog(home?: string, lines = 30): string | null {
const logPath = path.join(resolveLocalPaseoHome(home), DAEMON_LOG_FILENAME);
return tailFile(logPath, lines);
}
export async function startLocalDaemonDetached(
options: DaemonStartOptions,
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
): Promise<DetachedStartResult> {
if (options.listen && options.port) {
throw new Error("Cannot use --listen and --port together");
}
const daemonRunnerEntry = runtime.resolveRunnerEntry();
const childEnv = buildChildEnv(options);
const paseoHome = runtime.resolveHome(childEnv);
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME);
const child = runtime.spawnDetached(
process.execPath,
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
{
detached: true,
envMode: "internal",
env: childEnv,
stdio: ["ignore", "ignore", "ignore"],
},
);
child.unref();
const startup = await new Promise<DetachedStartupResult>((resolve) => {
let settled = false;
const finish = (value: DetachedStartupResult) => {
if (settled) return;
settled = true;
resolve(value);
};
const timer = setTimeout(() => finish(startupReady()), DETACHED_STARTUP_GRACE_MS);
child.once("error", (error) => {
clearTimeout(timer);
finish(startupExited({ code: null, signal: null, error }));
});
child.once("exit", (code, signal) => {
clearTimeout(timer);
finish(startupExited({ code, signal }));
});
});
if (startup.exitedEarly) {
const reason = startup.error
? startup.error.message
: `exit code ${startup.code ?? "unknown"}${startup.signal ? ` (${startup.signal})` : ""}`;
const recentLogs = tailFile(logPath);
throw new Error(
[
`Daemon failed to start in background (${reason}).`,
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
]
.filter(Boolean)
.join("\n\n"),
);
}
return {
pid: child.pid ?? null,
logPath,
};
}
export function startLocalDaemonForeground(
options: DaemonStartOptions,
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
): number {
if (options.listen && options.port) {
throw new Error("Cannot use --listen and --port together");
}
const daemonRunnerEntry = runtime.resolveRunnerEntry();
const childEnv = buildChildEnv(options);
const result = runtime.spawnForeground(
process.execPath,
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
{
env: childEnv,
stdio: "inherit",
},
);
if (result.error) {
throw result.error;
}
return result.status ?? 1;
}
async function requestLifecycleShutdown(
state: LocalDaemonState,
timeoutMs: number,
): Promise<LifecycleShutdownAttempt> {
const host = resolveTcpHostFromListen(state.listen);
if (!host) {
return {
requested: false,
reason: "daemon listen target is not TCP, falling back to owner PID signal",
};
}
const deadline = Date.now() + timeoutMs;
const remainingTimeoutMs = () => Math.max(1, deadline - Date.now());
const client = await tryConnectToDaemon({ host, timeout: Math.min(remainingTimeoutMs(), 5000) });
if (!client) {
return {
requested: false,
reason: `daemon websocket at ${host} is not reachable, falling back to owner PID signal`,
};
}
try {
await client.shutdownServer({ timeout: Math.min(remainingTimeoutMs(), 5000) });
return { requested: true };
} catch (error) {
return {
requested: false,
reason: `daemon lifecycle shutdown request failed (${getErrorMessage(
error,
)}), falling back to owner PID signal`,
};
} finally {
await client.close().catch(() => undefined);
process.removeListener("SIGINT", cancel);
process.removeListener("SIGTERM", cancel);
}
}
export async function stopLocalDaemon(
options: StopLocalDaemonOptions = {},
): Promise<StopLocalDaemonResult> {
const timeoutMs = options.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS;
const killTimeoutMs = options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
const state = resolveLocalDaemonState({ home: options.home });
const deadline = Date.now() + timeoutMs;
const remainingTimeoutMs = () => Math.max(1, deadline - Date.now());
export function parseTimeoutMs(raw: unknown, fallback = 600_000): number {
if (raw === undefined) return fallback;
const seconds = Number(raw);
if (!Number.isFinite(seconds) || seconds <= 0)
throw { code: "INVALID_TIMEOUT", message: "Timeout must be a positive number of seconds." };
return Math.ceil(seconds * 1000);
}
const shutdownAttempt = await requestLifecycleShutdown(state, remainingTimeoutMs());
const lifecycleRequested = shutdownAttempt.requested;
const REMOVED_LAUNCH_FLAGS: Record<string, string> = {
"--port <port>": "daemon.listen",
"--listen <listen>": "daemon.listen",
"--relay": "daemon.relay.enabled",
"--no-relay": "daemon.relay.enabled",
"--relay-use-tls": "daemon.relay.useTls",
"--no-mcp": "daemon.mcp.enabled",
"--no-inject-mcp": "daemon.mcp.injectIntoAgents",
"--web-ui": "features.webUi.enabled",
"--no-web-ui": "features.webUi.enabled",
"--hostnames <hosts>": "daemon.hostnames",
"--allowed-hosts <hosts>": "daemon.hostnames",
"--foreground": "",
};
if (!state.pidInfo || (!state.running && !lifecycleRequested)) {
const staleSuffix =
state.stalePidFile && state.pidInfo ? ` (stale PID file for ${state.pidInfo.pid})` : "";
return createNotRunningStopResult(
state,
state.pidInfo?.pid ?? null,
`Daemon is not running${staleSuffix}`,
);
}
const pid = state.pidInfo?.pid ?? null;
const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason;
if (!lifecycleRequested) {
const notRunningResult = await signalDaemonOwnerForStop(state, pid);
if (notRunningResult) return notRunningResult;
}
const { stopped, forced } = await waitForStopAfterRequest({
state,
pid,
timeoutMs: remainingTimeoutMs(),
killTimeoutMs,
force: options.force,
export function rejectRemovedLaunchFlags(command: Command): Command {
for (const flag of Object.keys(REMOVED_LAUNCH_FLAGS))
command.addOption(new Option(flag).hideHelp());
command.hook("preAction", () => {
for (const [flag, configPath] of Object.entries(REMOVED_LAUNCH_FLAGS)) {
const name = new Option(flag).attributeName();
if (command.getOptionValueSource(name) !== "cli") continue;
throw {
code: "REMOVED_LAUNCH_OPTION",
message: `${flag.split(" ")[0]} was removed. ${configPath ? `Use paseo daemon config set ${configPath} <value> --home <path>, then start or restart.` : "Use paseo daemon run --home <path> for foreground deployment."} Deployment environment overrides belong to paseo daemon run.`,
};
}
});
if (!stopped) {
throw createStopTimeoutError(state, pid, timeoutMs);
}
if (lifecycleRequested) {
removeStalePidFile(state);
}
return {
action: "stopped",
home: state.home,
pid,
forced,
usedLifecycleRpc: lifecycleRequested,
reason: resolveStopReason(forced, lifecycleRequested),
message: resolveStopMessage(forced, lifecycleRequested, fallbackMessage),
};
return command;
}
+26 -137
View File
@@ -1,139 +1,28 @@
import { describe, expect, test, vi } from "vitest";
import { mkdtemp, rm, readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect } from "vitest";
import { resolveLocalPairingOffer } from "./pair.js";
import { runPairCommand, type PairCommandOutput, type PairingOffer } from "./pair.js";
const disabledOffer: PairingOffer = { relayEnabled: false, url: null, qr: null };
const enabledOffer: PairingOffer = {
relayEnabled: true,
url: "https://app.paseo.sh/#offer=test",
qr: null,
};
interface RecordedPairCommandOutput extends PairCommandOutput {
stdout: string[];
stderr: string[];
successes: string[];
exitCode: number | undefined;
}
function createRecordedOutput(): RecordedPairCommandOutput {
return {
columns: 80,
stdout: [],
stderr: [],
successes: [],
exitCode: undefined,
writeStdout(message) {
this.stdout.push(message);
},
writeStderr(message) {
this.stderr.push(message);
},
setExitCode(code) {
this.exitCode = code;
},
success(message) {
this.successes.push(message);
},
};
}
describe("daemon pair workflow", () => {
test("interactive decline prints direct guidance and creates no pairing output", async () => {
const resolveOffer = vi.fn(async () => disabledOffer);
const confirmRelay = vi.fn(async () => false);
const printDirectGuidance = vi.fn();
const output = createRecordedOutput();
await runPairCommand(
{},
{ resolveOffer, confirmRelay, printDirectGuidance, isInteractive: () => true, output },
);
expect(confirmRelay).toHaveBeenCalledOnce();
expect(printDirectGuidance).toHaveBeenCalledOnce();
expect(output.stderr.join("")).toContain("No pairing QR was created");
expect(output.exitCode).toBe(1);
});
test("interactive consent enables relay and prints the refreshed offer", async () => {
const resolveOffer = vi
.fn<(options: { paseoHome: string; enableRelay?: boolean }) => Promise<PairingOffer>>()
.mockResolvedValueOnce(disabledOffer)
.mockResolvedValueOnce(enabledOffer);
const output = createRecordedOutput();
await runPairCommand(
{},
{
resolveOffer,
confirmRelay: async () => true,
printDirectGuidance: vi.fn(),
isInteractive: () => true,
output,
},
);
expect(resolveOffer).toHaveBeenNthCalledWith(2, expect.objectContaining({ enableRelay: true }));
expect(output.stdout.join("")).toContain(enabledOffer.url ?? "");
expect(output.successes).toEqual(["Relay enabled"]);
});
test("JSON mode never prompts and returns a structured relay-disabled error", async () => {
const confirmRelay = vi.fn(async () => true);
const output = createRecordedOutput();
await runPairCommand(
{ json: true },
{
resolveOffer: async () => disabledOffer,
confirmRelay,
printDirectGuidance: vi.fn(),
isInteractive: () => true,
output,
},
);
expect(confirmRelay).not.toHaveBeenCalled();
expect(output.stderr.join("")).toContain('"code":"RELAY_DISABLED"');
expect(output.exitCode).toBe(1);
});
test("explicit relay opts in without prompting", async () => {
const resolveOffer = vi.fn(async () => enabledOffer);
const confirmRelay = vi.fn(async () => false);
const output = createRecordedOutput();
await runPairCommand(
{ relay: true, json: true },
{
resolveOffer,
confirmRelay,
printDirectGuidance: vi.fn(),
isInteractive: () => false,
output,
},
);
expect(resolveOffer).toHaveBeenCalledWith(expect.objectContaining({ enableRelay: true }));
expect(confirmRelay).not.toHaveBeenCalled();
expect(output.exitCode).toBeUndefined();
});
test("surfaces launch-override rejection", async () => {
await expect(
runPairCommand(
{ relay: true },
{
resolveOffer: async () => {
throw new Error("Relay is controlled by a daemon launch override");
},
confirmRelay: vi.fn(),
printDirectGuidance: vi.fn(),
isInteractive: () => false,
output: createRecordedOutput(),
},
),
).rejects.toThrow("launch override");
});
test("offline pairing requires relay consent and saves it in the selected home", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-offline-pair-"));
const home = path.join(root, "home");
try {
expect(await resolveLocalPairingOffer({ paseoHome: home })).toMatchObject({
relayEnabled: false,
url: null,
});
expect(existsSync(home)).toBe(false);
const offer = await resolveLocalPairingOffer({ paseoHome: home, enableRelay: true });
expect(offer.relayEnabled).toBe(true);
expect(offer.url).toContain("offer=");
expect(
JSON.parse(await readFile(path.join(home, "config.json"), "utf8")).daemon.relay.enabled,
).toBe(true);
expect(existsSync(path.join(home, "server-id"))).toBe(true);
expect(existsSync(path.join(home, "daemon-keypair.json"))).toBe(true);
} finally {
await rm(root, { recursive: true, force: true });
}
});
+52 -79
View File
@@ -3,29 +3,23 @@ import { Command } from "commander";
import chalk from "chalk";
import {
generateLocalPairingOffer,
getOrCreateServerId,
loadConfig,
resolvePaseoHome,
readDaemonInstance,
readPersistedConfig,
editPersistedConfig,
resolveConfigFromPersisted,
} from "@getpaseo/server";
import { tryConnectToDaemon } from "../../utils/client.js";
import { resolveLocalDaemonState } from "./local-daemon.js";
import { addJsonOption } from "../../utils/command-options.js";
import { connectToDaemon } from "../../utils/client.js";
import type { DaemonTarget } from "../../utils/daemon-target.js";
import { addJsonAndDaemonHostOptions, withGlobalOptions } from "../../utils/command-options.js";
import { formatPairingInstructions } from "../../output/pairing.js";
interface PairOptions {
daemonTarget: DaemonTarget;
home?: string;
json?: boolean;
relay?: boolean;
}
export interface PairCommandDependencies {
resolveOffer: typeof resolveLocalPairingOffer;
confirmRelay: typeof confirmRelayPairing;
printDirectGuidance: typeof printDirectConnectionGuidance;
isInteractive: () => boolean;
output: PairCommandOutput;
}
export interface PairCommandOutput {
columns: number | undefined;
writeStdout(message: string): void;
@@ -62,33 +56,32 @@ function createProcessOutput(): PairCommandOutput {
}
export function pairCommand(): Command {
return addJsonOption(new Command("pair").description("Print the daemon pairing QR code and link"))
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
return addJsonAndDaemonHostOptions(
new Command("pair").description("Print the daemon pairing QR code and link"),
)
.option("--relay", "Enable relay without prompting")
.action(async (_options: PairOptions, command: Command) => {
await runPairCommand(command.optsWithGlobals());
});
.action(
withGlobalOptions((options: PairOptions, _command: Command) => runPairCommand(options)),
);
}
export async function resolveLocalPairingOffer(options: {
paseoHome: string;
enableRelay?: boolean;
}): Promise<PairingOffer> {
const state = resolveLocalDaemonState({ home: options.paseoHome });
const serverId = getOrCreateServerId(state.home);
const daemonOffer = await resolveDaemonPairingOffer(state.listen, serverId, options.enableRelay);
if (daemonOffer) return daemonOffer;
if (state.running) {
throw new Error(
"The running daemon did not provide a pairing offer. Check daemon connectivity or update the daemon.",
const instance = await readDaemonInstance(options.paseoHome);
if (instance)
return resolveDaemonPairingOffer(
{ kind: "instance", home: options.paseoHome },
options.enableRelay,
);
}
const config = loadConfig(options.paseoHome);
if (options.enableRelay && !config.relayEnabled) {
throw new Error("Start the daemon before enabling relay for pairing.");
}
if (options.enableRelay)
editPersistedConfig(options.paseoHome, "daemon.relay.enabled", { value: true });
const config = resolveConfigFromPersisted(
options.paseoHome,
readPersistedConfig(options.paseoHome, { defaultsIfMissing: true }),
{ env: {} },
);
return generateLocalPairingOffer({
paseoHome: options.paseoHome,
@@ -103,23 +96,16 @@ export async function resolveLocalPairingOffer(options: {
}
async function resolveDaemonPairingOffer(
listen: string,
expectedServerId: string,
target: DaemonTarget,
enableRelay: boolean | undefined,
): Promise<PairingOffer | null> {
const client = await tryConnectToDaemon({
host: listen,
): Promise<PairingOffer> {
const client = await connectToDaemon({
target,
timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS,
});
if (!client) return null;
try {
const serverInfo = client.getLastServerInfoMessage();
if (serverInfo?.serverId.trim() !== expectedServerId) {
throw new Error(
"The reachable daemon belongs to a different Paseo home. Check --home or the daemon listen configuration.",
);
}
if (serverInfo?.features?.daemonStatusRpc !== true) {
throw new Error("Update the Paseo daemon before pairing from this command.");
}
@@ -132,9 +118,14 @@ async function resolveDaemonPairingOffer(
throw new Error("Update the Paseo daemon before enabling relay from this command.");
}
await client.patchDaemonConfig({ relay: { enabled: true } });
offer = await client.getDaemonPairingOffer({
timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS,
});
try {
offer = await client.getDaemonPairingOffer({ timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS });
} catch (error) {
throw new Error(
`Relay configuration was saved, but fetching the pairing offer failed: ${String(error)}`,
{ cause: error },
);
}
}
return {
relayEnabled: offer.relayEnabled,
@@ -164,40 +155,22 @@ export function printDirectConnectionGuidance(): void {
console.log(`Learn more: ${RELAY_DOCS_URL}#direct-connections`);
}
export async function runPairCommand(
options: PairOptions,
dependencyOverrides: Partial<PairCommandDependencies> = {},
): Promise<void> {
if (options.home) process.env.PASEO_HOME = options.home;
const dependencies: PairCommandDependencies = {
resolveOffer: resolveLocalPairingOffer,
confirmRelay: confirmRelayPairing,
printDirectGuidance: printDirectConnectionGuidance,
isInteractive: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
output: createProcessOutput(),
...dependencyOverrides,
};
export async function runPairCommand(options: PairOptions): Promise<void> {
const output = createProcessOutput();
const target = options.daemonTarget;
const resolveOffer = (enableRelay: boolean) =>
target.kind === "instance"
? resolveLocalPairingOffer({ paseoHome: target.home, enableRelay })
: resolveDaemonPairingOffer(target, enableRelay);
const offline = target.kind === "instance" && !(await readDaemonInstance(target.home));
const pairing = await resolveOffer(options.relay === true);
const paseoHome = resolvePaseoHome();
let pairing = await dependencies.resolveOffer({
paseoHome,
enableRelay: options.relay === true,
});
if (offline)
output.writeStderr(
`Offline pairing offer. Start with: paseo daemon start --home ${JSON.stringify(target.kind === "instance" ? target.home : "")}\n`,
);
const canPrompt = dependencies.isInteractive() && options.json !== true;
if (!pairing.relayEnabled && canPrompt) {
const shouldEnable = await dependencies.confirmRelay();
if (!shouldEnable) {
dependencies.printDirectGuidance();
dependencies.output.writeStderr(`${chalk.yellow("No pairing QR was created.")}\n`);
dependencies.output.setExitCode(1);
return;
}
pairing = await dependencies.resolveOffer({ paseoHome, enableRelay: true });
dependencies.output.success("Relay enabled");
}
outputPairingResult(pairing, options, dependencies.output);
outputPairingResult(pairing, options, output);
}
function outputPairingResult(
+14 -3
View File
@@ -1,8 +1,12 @@
import type { Command } from "commander";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { withOutput } from "../../output/index.js";
import { Command } from "commander";
import { describeDaemonTarget } from "../../utils/daemon-target.js";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, OutputSchema, SingleResult } from "../../output/index.js";
export interface DaemonReloadResult {
restartCommand: string;
appliedPaths: string[];
restartRequiredPaths: string[];
overrideControlledPaths: string[];
@@ -20,7 +24,7 @@ export const daemonReloadSchema: OutputSchema<DaemonReloadResult> = {
"Warning: These changes require a daemon restart:",
...result.data.restartRequiredPaths.map((path) => ` ${path}`),
"",
"Run: paseo daemon restart",
`Run: ${result.data.restartCommand}`,
);
}
if (result.data.overrideControlledPaths.length > 0) {
@@ -38,12 +42,13 @@ export async function runDaemonReloadCommand(
options: CommandOptions,
_command: Command,
): Promise<SingleResult<DaemonReloadResult>> {
const client = await connectToDaemon({ host: options.host });
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const payload = await client.reloadDaemonConfig();
return {
type: "single",
data: {
restartCommand: `paseo daemon restart ${options.daemonTarget.kind === "instance" ? `--home ${JSON.stringify(options.daemonTarget.home)}` : `--host ${JSON.stringify(describeDaemonTarget(options.daemonTarget))}`}`,
appliedPaths: payload.appliedPaths,
restartRequiredPaths: payload.restartRequiredPaths,
overrideControlledPaths: payload.overrideControlledPaths,
@@ -54,3 +59,9 @@ export async function runDaemonReloadCommand(
await client.close();
}
}
export function daemonReloadCommand(): Command {
return addJsonAndDaemonHostOptions(
new Command("reload").description("Reload config.json without restarting"),
).action(withOutput(runDaemonReloadCommand));
}
+113 -122
View File
@@ -1,131 +1,122 @@
import type { Command } from "commander";
import {
startLocalDaemonDetached,
stopLocalDaemon,
DEFAULT_STOP_TIMEOUT_MS,
type DaemonStartOptions,
} from "./local-daemon.js";
import type {
CommandOptions,
SingleResult,
OutputSchema,
CommandError,
} from "../../output/index.js";
import { Command } from "commander";
import { readDaemonInstance, isSameDaemonInstance, DaemonInstanceError } from "@getpaseo/server";
import { setTimeout as delay } from "node:timers/promises";
import { connectToDaemon } from "../../utils/client.js";
import { withOutput, type CommandOptions } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { describeDaemonTarget } from "../../utils/daemon-target.js";
import { parseTimeoutMs, rejectRemovedLaunchFlags } from "./local-daemon.js";
interface RestartResult {
action: "restarted";
home: string;
pid: string;
message: string;
export function daemonRestartCommand(): Command {
return rejectRemovedLaunchFlags(
addJsonAndDaemonHostOptions(
new Command("restart").description(
"Restart the selected daemon worker, retaining its supervisor launch",
),
),
)
.option("--timeout <seconds>", "Replacement readiness deadline (default: 600)")
.action(withOutput(runRestartCommand));
}
const restartResultSchema: OutputSchema<RestartResult> = {
idField: "action",
columns: [
{
header: "STATUS",
field: "action",
color: () => "green",
},
{ header: "HOME", field: "home" },
{ header: "PID", field: "pid" },
{ header: "MESSAGE", field: "message" },
],
};
export type RestartCommandResult = SingleResult<RestartResult>;
function parseTimeoutMs(raw: unknown): number {
if (typeof raw !== "string" || raw.trim().length === 0) {
return DEFAULT_STOP_TIMEOUT_MS;
export async function runRestartCommand(options: CommandOptions, _command: Command) {
const target = options.daemonTarget;
const deadline = Date.now() + parseTimeoutMs(options.timeout);
const remaining = () => Math.max(1, deadline - Date.now());
const instance = target.kind === "instance" ? await readDaemonInstance(target.home) : null;
async function checkSupervisor() {
if (target.kind !== "instance") return;
const current = await readDaemonInstance(target.home);
if (!current || !instance || !isSameDaemonInstance(instance, current))
throw new DaemonInstanceError(
"DAEMON_REPLACED",
`Supervisor exited or was replaced for ${target.home}.`,
);
}
const seconds = Number(raw);
if (!Number.isFinite(seconds) || seconds <= 0) {
const error: CommandError = {
code: "INVALID_TIMEOUT",
message: `Invalid timeout value: ${raw}`,
details: "Timeout must be a positive number of seconds",
};
throw error;
}
return Math.ceil(seconds * 1000);
}
function toStartOptions(options: CommandOptions): DaemonStartOptions {
const startOptions: DaemonStartOptions = {
home: typeof options.home === "string" ? options.home : undefined,
listen: typeof options.listen === "string" ? options.listen : undefined,
port: typeof options.port === "string" ? options.port : undefined,
relay: typeof options.relay === "boolean" ? options.relay : undefined,
mcp: typeof options.mcp === "boolean" ? options.mcp : undefined,
injectMcp: typeof options.injectMcp === "boolean" ? options.injectMcp : undefined,
webUi: typeof options.webUi === "boolean" ? options.webUi : undefined,
hostnames: typeof options.hostnames === "string" ? options.hostnames : undefined,
};
if (startOptions.listen && startOptions.port) {
const error: CommandError = {
code: "INVALID_OPTIONS",
message: "Cannot use --listen and --port together",
};
throw error;
}
return startOptions;
}
export async function runRestartCommand(
options: CommandOptions,
_command: Command,
): Promise<RestartCommandResult> {
const timeoutMs = parseTimeoutMs(options.timeout);
const force = options.force === true;
const startOptions = toStartOptions(options);
if (target.kind === "instance" && !instance)
throw new DaemonInstanceError(
"DAEMON_NOT_RUNNING",
`Daemon is not running for ${target.home}.`,
);
const client = await connectToDaemon({
target,
instance: instance ?? undefined,
timeout: remaining(),
});
let workerPid: number;
const serverId = client.getLastServerInfoMessage()?.serverId;
let acknowledged = false;
try {
let stopResult: Awaited<ReturnType<typeof stopLocalDaemon>>;
workerPid = (await client.getDaemonStatus({ timeout: remaining() })).pid;
await checkSupervisor();
try {
stopResult = await stopLocalDaemon({
home: startOptions.home,
timeoutMs,
force,
});
} catch (err) {
const isTimeout =
err instanceof Error && err.message.includes("Timed out waiting for daemon PID");
if (!force && isTimeout) {
stopResult = await stopLocalDaemon({
home: startOptions.home,
timeoutMs,
force: true,
});
} else {
throw err;
}
await client.restartServer("cli_restart", undefined, { timeout: remaining() });
acknowledged = true;
} catch (error) {
if (!isReconnectFailure(error)) throw error;
}
const startup = await startLocalDaemonDetached(startOptions);
const before = stopResult.pid === null ? "not running" : `PID ${stopResult.pid}`;
const after = startup.pid === null ? "unknown PID" : `PID ${startup.pid}`;
return {
type: "single",
data: {
action: "restarted",
home: stopResult.home,
pid: startup.pid === null ? "-" : String(startup.pid),
message: `Local daemon restarted (${before} -> ${after})`,
},
schema: restartResultSchema,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "RESTART_FAILED",
message: `Failed to restart local daemon: ${message}`,
};
throw error;
} finally {
await client.close();
}
let lastError: unknown = "No replacement worker observed";
while (Date.now() < deadline) {
try {
const replacement = await connectToDaemon({
target,
instance: instance ?? undefined,
timeout: Math.min(1_000, remaining()),
});
try {
if (replacement.getLastServerInfoMessage()?.serverId !== serverId)
throw new Error("Connected peer identity changed");
const status = await replacement.getDaemonStatus({ timeout: Math.min(1_000, remaining()) });
if (status.pid !== workerPid) {
await checkSupervisor();
return {
type: "single" as const,
data: {
action: "restarted",
target: describeDaemonTarget(target),
supervisorPid: instance?.pid ?? null,
previousWorkerPid: workerPid,
workerPid: status.pid,
acknowledged,
},
schema: {
idField: "action" as const,
columns: [],
renderHuman: () =>
`Restarted worker ${workerPid} → ${status.pid} at ${describeDaemonTarget(target)}. Supervisor launch retained.`,
},
};
}
} finally {
await replacement.close();
}
} catch (error) {
lastError = error;
const code = (error as { code?: string } | null)?.code;
if (code === "DAEMON_REPLACED" || code === "DAEMON_NOT_RUNNING") break;
if (!isReconnectFailure(error)) throw error;
}
await delay(Math.min(100, remaining()));
}
throw {
code: "RESTART_NOT_CONFIRMED",
message: `Replacement was not confirmed for ${describeDaemonTarget(target)}. Restart acknowledged: ${acknowledged}. Last observation: ${String(lastError)}`,
};
}
function isReconnectFailure(error: unknown): boolean {
return Boolean(
error &&
typeof error === "object" &&
"code" in error &&
[
"DAEMON_CONNECTION_LOST",
"DAEMON_REQUEST_TIMEOUT",
"DAEMON_UNREACHABLE",
"DAEMON_NOT_READY",
].includes(String(error.code)),
);
}
@@ -3,7 +3,8 @@ import type { Command } from "commander";
import { isCancel, password as passwordPrompt } from "@clack/prompts";
import {
hashDaemonPassword,
loadPersistedConfig,
readPersistedConfig,
resolvePaseoHome,
savePersistedConfig,
type PersistedConfig,
} from "@getpaseo/server";
@@ -14,7 +15,6 @@ import type {
OutputSchema,
SingleResult,
} from "../../output/index.js";
import { resolveLocalPaseoHome } from "./local-daemon.js";
const CONFIG_FILENAME = "config.json";
@@ -81,9 +81,9 @@ export async function setDaemonPasswordInConfig(
newPassword: string,
options: SetPasswordOptions = {},
): Promise<SetPasswordResult> {
const paseoHome = resolveLocalPaseoHome(options.home);
const paseoHome = resolvePaseoHome({ PASEO_HOME: options.home });
const configPath = path.join(paseoHome, CONFIG_FILENAME);
const persisted = loadPersistedConfig(paseoHome);
const persisted = readPersistedConfig(paseoHome);
const nextConfig: PersistedConfig = {
...persisted,
daemon: {
@@ -100,8 +100,8 @@ export async function setDaemonPasswordInConfig(
return {
action: "password_set",
configPath,
restartCommand: "paseo daemon restart",
message: `Password written to ${configPath}\nRestart the daemon for the change to take effect.\nRun: paseo daemon restart`,
restartCommand: `paseo daemon restart --home ${JSON.stringify(paseoHome)}`,
message: `Password written to ${configPath}\nRestart the daemon for the change to take effect.\nRun: paseo daemon restart --home ${JSON.stringify(paseoHome)}`,
};
}
@@ -115,7 +115,7 @@ export async function runSetPasswordCommand(
: (message: string) => passwordPrompt({ message });
const newPassword = await promptForPassword(promptPassword);
const result = await setDaemonPasswordInConfig(newPassword, {
home: typeof options.home === "string" ? options.home : undefined,
home: options.daemonTarget.kind === "instance" ? options.daemonTarget.home : undefined,
});
return {
+48 -64
View File
@@ -1,71 +1,55 @@
import { Command, Option } from "commander";
import chalk from "chalk";
import {
startLocalDaemonForeground,
startLocalDaemonDetached,
type DaemonStartOptions as StartOptions,
} from "./local-daemon.js";
import { getErrorMessage } from "../../utils/errors.js";
export type { DaemonStartOptions as StartOptions } from "./local-daemon.js";
type RawStartCommandOptions = StartOptions & {
allowedHosts?: string;
};
import { addLocalDaemonOptions } from "../../utils/command-options.js";
import { Command } from "commander";
import { daemonLogPath } from "@getpaseo/server";
import { launchLocalDaemon, parseTimeoutMs, rejectRemovedLaunchFlags } from "./local-daemon.js";
import { withOutput, type CommandOptions } from "../../output/index.js";
export function startCommand(): Command {
return new Command("start")
.description("Start the local Paseo daemon")
.option("--listen <listen>", "Listen target (host:port, port, or unix socket path)")
.option("--port <port>", "Port to listen on (default: 6767)")
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--foreground", "Run in foreground (don't daemonize)")
.option("--relay", "Enable relay connection")
.option("--no-relay", "Disable relay connection")
.option("--relay-use-tls", "Use wss:// for the relay connection and pairing offers")
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
.option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
.option("--web-ui", "Enable the bundled daemon web UI")
.option("--no-web-ui", "Disable the bundled daemon web UI")
.option(
"--hostnames <hosts>",
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
)
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
.action(async (options: RawStartCommandOptions) => {
await runStart({
...options,
hostnames: options.hostnames ?? options.allowedHosts,
});
});
return rejectRemovedLaunchFlags(addLocalDaemonOptions(new Command("start")))
.description("Start the local daemon from persistent configuration (local operation)")
.option("--timeout <seconds>", "Readiness deadline (default: 600)")
.action(withOutput(runStart));
}
export async function runStart(options: StartOptions): Promise<void> {
if (options.listen && options.port) {
console.error(chalk.red("Cannot use --listen and --port together"));
process.exit(1);
}
if (!options.foreground) {
try {
const startup = await startLocalDaemonDetached(options);
console.log(chalk.green(`Daemon starting in background (PID ${startup.pid ?? "unknown"}).`));
console.log(chalk.dim(`Logs: ${startup.logPath}`));
} catch (err) {
exitWithError(getErrorMessage(err));
}
return;
}
try {
const status = startLocalDaemonForeground(options);
process.exit(status);
} catch (err) {
const message = getErrorMessage(err);
exitWithError(`Failed to start daemon: ${message}`);
}
export async function runStart(options: CommandOptions, _command: Command) {
if (options.daemonTarget.kind !== "instance") throw new Error("Start requires a local home");
const home = options.daemonTarget.home;
const result = await launchLocalDaemon({ home, timeoutMs: parseTimeoutMs(options.timeout) });
const data = {
action: result.spawned ? "started" : "already_running",
home,
pid: result.instance.pid,
listen: result.instance.listen,
logPath: daemonLogPath(home),
};
return {
type: "single" as const,
data,
schema: {
idField: "pid" as const,
columns: [],
renderHuman: () =>
`${result.spawned ? "Started" : "Already running"}: PID ${data.pid}${data.listen ? `, listening on ${data.listen}` : ", not ready"}\nLogs: ${data.logPath}`,
},
};
}
function exitWithError(message: string): never {
console.error(chalk.red(message));
process.exit(1);
export function daemonRunCommand(): Command {
return rejectRemovedLaunchFlags(addLocalDaemonOptions(new Command("run")))
.description("Run a local daemon in the foreground with deployment environment overrides")
.action(
withOutput(async (options: CommandOptions, _command: Command) => {
if (options.daemonTarget.kind !== "instance") throw new Error("Run requires a local home");
const result = await launchLocalDaemon({
home: options.daemonTarget.home,
foreground: true,
});
process.exitCode = result.exitCode ?? 0;
return {
type: "single" as const,
data: { pid: result.instance.pid, action: result.spawned ? "exited" : "already_running" },
schema: { idField: "pid" as const, columns: [] },
};
}),
);
}
@@ -1,31 +0,0 @@
import { describe, expect, test } from "vitest";
import { selectRelayStatus } from "./status.js";
describe("selectRelayStatus", () => {
const persisted = {
enabled: false,
endpoint: "persisted.internal:443",
publicEndpoint: "persisted.example.com:443",
useTls: true,
publicUseTls: true,
};
test("uses the running daemon relay state over persisted config", () => {
expect(
selectRelayStatus({
persisted,
live: {
enabled: true,
endpoint: "live.internal:443",
publicEndpoint: "live.example.com:443",
useTls: true,
publicUseTls: true,
},
}),
).toBe("wss://live.example.com:443");
});
test("falls back to persisted config when the daemon cannot report live state", () => {
expect(selectRelayStatus({ persisted })).toBe("disabled");
});
});
+97 -456
View File
@@ -1,464 +1,105 @@
import type { Command } from "commander";
import { createRequire } from "node:module";
import { getOrCreateServerId, findExecutable, execCommand } from "@getpaseo/server";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { resolveLocalDaemonState } from "./local-daemon.js";
import { resolveNodePathFromPid } from "./runtime-toolchain.js";
import { Command } from "commander";
import {
readDaemonInstance,
readPersistedConfig,
resolveConfigFromPersisted,
daemonLogPath,
isSameDaemonInstance,
DaemonInstanceError,
} from "@getpaseo/server";
import { connectToDaemon, buildDaemonConnectionCommandError } from "../../utils/client.js";
import { withOutput, type CommandOptions } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { describeDaemonTarget } from "../../utils/daemon-target.js";
const DAEMON_STATUS_PROBE_TIMEOUT_MS = 1500;
interface ProviderBinaryStatus {
label: string;
path: string | null;
version: string | null;
source?: "daemon" | "local";
export function daemonStatusCommand(): Command {
return addJsonAndDaemonHostOptions(
new Command("status").description("Observe the selected daemon and its published endpoint"),
).action(withOutput(runStatusCommand));
}
interface DaemonStatus {
serverId: string | null;
localDaemon: "running" | "stopped" | "stale_pid" | "unresponsive";
connectedDaemon: "reachable" | "unreachable" | "auth_required" | "auth_failed" | "not_probed";
home: string;
listen: string;
relay: string;
hostname: string | null;
pid: number | null;
startedAt: string | null;
owner: string | null;
logPath: string;
daemonNode: string;
cliNode: string;
cliVersion: string;
daemonVersion: string | null;
desktopManaged: boolean;
providers: ProviderBinaryStatus[];
note?: string;
}
interface StatusRow {
key: string;
value: string;
}
interface CliPackageJson {
version?: unknown;
}
const require = createRequire(import.meta.url);
function normalizeError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
function shortenMessage(message: string, max = 120): string {
const normalized = message.replace(/\s+/g, " ").trim();
if (normalized.length <= max) {
return normalized;
}
return `${normalized.slice(0, max - 3)}...`;
}
function appendNote(current: string | undefined, next: string | undefined): string | undefined {
if (!next) return current;
if (!current) return next;
return `${current}; ${next}`;
}
function resolveCliVersion(): string {
try {
const packageJson = require("../../../package.json") as CliPackageJson;
if (typeof packageJson.version === "string" && packageJson.version.trim().length > 0) {
return packageJson.version.trim();
}
} catch {
// Fall through.
}
return "unknown";
}
function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
return {
idField: "key",
columns: [
{ header: "KEY", field: "key" },
{
header: "VALUE",
field: "value",
color: (_, item) => {
if (item.key === "Local Daemon") {
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" || item.value === "auth_required") return "yellow";
return "red";
}
if (item.key.startsWith(" ")) {
if (item.value === "not found" || item.value === "not found (daemon)") return "red";
if (item.value.endsWith("(--version failed)")) return "yellow";
return "green";
}
return undefined;
},
},
],
serialize: () => status,
};
}
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: "Home", value: status.home },
{ key: "Listen", value: status.listen },
{ key: "Relay", value: status.relay },
{ key: "Hostname", value: status.hostname ?? "-" },
{ key: "PID", value: status.pid === null ? "-" : String(status.pid) },
{ key: "Started", value: status.startedAt ?? "-" },
{ key: "Owner", value: status.owner ?? "-" },
{ key: "Logs", value: status.logPath },
{ key: "Daemon Node", value: status.daemonNode },
{ key: "CLI Node", value: status.cliNode },
{ key: "CLI", value: status.cliVersion },
{ key: "Daemon Version", value: status.daemonVersion ?? "-" },
];
if (status.note) {
rows.push({ key: "Note", value: status.note });
}
rows.push({ key: "", value: "" });
rows.push({ key: "Providers", value: "" });
for (const provider of status.providers) {
if (provider.source === "daemon") {
if (!provider.path) {
rows.push({ key: ` ${provider.label}`, value: "not found (daemon)" });
} else {
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (daemon)` });
}
} else if (!provider.path) {
rows.push({ key: ` ${provider.label}`, value: "not found" });
} else if (!provider.version) {
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (--version failed)` });
} else {
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (${provider.version})` });
}
}
return rows;
}
const PROVIDER_BINARIES: { label: string; binary: string }[] = [
{ label: "Claude", binary: "claude" },
{ label: "Codex", binary: "codex" },
{ label: "OpenCode", binary: "opencode" },
];
async function checkProviderBinary(
binary: string,
): Promise<{ path: string | null; version: string | null }> {
const binaryPath = await findExecutable(binary);
if (!binaryPath) {
return { path: null, version: null };
}
try {
const { stdout } = await execCommand(binaryPath, ["--version"], {
timeout: 5000,
});
return { path: binaryPath, version: stdout.trim() || null };
} catch {
return { path: binaryPath, version: null };
}
}
async function checkProviderBinaries(): Promise<ProviderBinaryStatus[]> {
const results = await Promise.all(
PROVIDER_BINARIES.map(async ({ label, binary }) => {
const result = await checkProviderBinary(binary);
return Object.assign({ label }, result);
}),
);
return results;
}
function resolveOwnerLabel(uid: number | undefined, hostname: string | undefined): string | null {
if (uid === undefined && !hostname) {
return null;
}
const uidPart = uid === undefined ? "?" : String(uid);
const hostPart = hostname ?? "unknown-host";
return `${uidPart}@${hostPart}`;
}
interface DaemonProbeResult {
connectedDaemon: DaemonStatus["connectedDaemon"];
localDaemonOverride?: DaemonStatus["localDaemon"];
daemonVersion?: string | null;
daemonNodeOverride?: string;
daemonProviders?: ProviderBinaryStatus[];
relayStatus?: string;
note?: string;
}
type DaemonAuthProbeFailure = "auth_required" | "auth_failed";
function classifyDaemonAuthProbeFailure(error: unknown): DaemonAuthProbeFailure | null {
if (!(error instanceof Error)) return null;
if (error.message === "Password required") return "auth_required";
if (error.message === "Incorrect password") return "auth_failed";
return null;
}
function describeDaemonAuthProbeFailure(host: string, failure: DaemonAuthProbeFailure): string {
if (failure === "auth_required") {
return `Daemon is reachable at ${host} but requires a password. Set PASEO_PASSWORD and retry.`;
}
return `Daemon is reachable at ${host} but the supplied password was rejected. Check PASEO_PASSWORD and retry.`;
}
async function probeDaemonOverWebsocket(args: {
host: string;
state: ReturnType<typeof resolveLocalDaemonState>;
}): Promise<DaemonProbeResult> {
const { host, state } = args;
let client: Awaited<ReturnType<typeof connectToDaemon>>;
try {
client = await connectToDaemon({ host, timeout: 1500 });
} catch (error) {
const authFailure = classifyDaemonAuthProbeFailure(error);
if (authFailure) {
return {
connectedDaemon: authFailure,
note: describeDaemonAuthProbeFailure(host, authFailure),
};
}
if (state.running) {
return {
connectedDaemon: "unreachable",
localDaemonOverride: "unresponsive",
note: `Local daemon PID is running but websocket at ${host} is not reachable`,
};
}
return { connectedDaemon: "unreachable" };
}
const daemonVersion = client.getLastServerInfoMessage()?.version ?? null;
try {
const statusPayload = await client.getDaemonStatus({
timeout: DAEMON_STATUS_PROBE_TIMEOUT_MS,
});
const labelMap = new Map(PROVIDER_BINARIES.map((p) => [p.binary, p.label]));
const daemonProviders = statusPayload.providers.map((p) => ({
label: labelMap.get(p.provider) ?? p.provider,
path: p.available ? "available" : null,
version: p.available ? null : (p.error ?? null),
source: "daemon" as const,
}));
const relayStatus =
statusPayload.relay == null
? undefined
: selectRelayStatus({
persisted: relayConfigFromLocalState(state),
live: statusPayload.relay,
});
if (!state.running) {
return {
connectedDaemon: "reachable",
daemonVersion: statusPayload.version ?? daemonVersion,
daemonNodeOverride: statusPayload.nodePath,
daemonProviders,
relayStatus,
note: state.pidInfo
? `Connected daemon is reachable at ${host} even though local daemon PID ${state.pidInfo.pid} is stale`
: `Connected daemon is reachable at ${host} but no local daemon PID file was found`,
};
}
return {
connectedDaemon: "reachable",
daemonVersion: statusPayload.version ?? daemonVersion,
daemonNodeOverride: statusPayload.nodePath,
daemonProviders,
relayStatus,
};
} catch {
return {
connectedDaemon: "reachable",
daemonVersion,
note: state.running
? `Local daemon PID is running but daemon detail request to ${host} failed`
: `Connected daemon websocket is reachable at ${host} but daemon status request failed`,
};
} finally {
await client.close().catch(() => {});
}
}
interface ProbeMergeState {
probe: DaemonProbeResult;
connectedDaemon: DaemonStatus["connectedDaemon"];
localDaemon: DaemonStatus["localDaemon"];
daemonNode: string;
daemonVersion: string | null;
daemonProviders: ProviderBinaryStatus[] | undefined;
relayStatus: string;
note: string | undefined;
}
function applyProbeToStatus(input: ProbeMergeState): Omit<ProbeMergeState, "probe"> {
const { probe } = input;
return {
connectedDaemon: probe.connectedDaemon,
localDaemon: probe.localDaemonOverride ?? input.localDaemon,
daemonNode: probe.daemonNodeOverride ?? input.daemonNode,
daemonVersion: probe.daemonVersion !== undefined ? probe.daemonVersion : input.daemonVersion,
daemonProviders: probe.daemonProviders ?? input.daemonProviders,
relayStatus: probe.relayStatus ?? input.relayStatus,
note: probe.note ? appendNote(input.note, probe.note) : input.note,
};
}
function resolveServerIdSafely(home: string): { serverId: string | null; error: string | null } {
try {
return { serverId: getOrCreateServerId(home), error: null };
} catch (error) {
return {
serverId: null,
error: `serverId unavailable: ${shortenMessage(normalizeError(error))}`,
};
}
}
async function resolveDaemonNodeLabel(
state: ReturnType<typeof resolveLocalDaemonState>,
): Promise<string> {
if (!state.running) return "-";
if (!state.pidInfo?.pid) return "unknown (no PID available)";
const fromPid = await resolveNodePathFromPid(state.pidInfo.pid);
return fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`;
}
interface RelayStatusConfig {
enabled: boolean;
endpoint: string;
publicEndpoint: string;
useTls: boolean;
publicUseTls: boolean;
}
function relayConfigFromLocalState(
state: ReturnType<typeof resolveLocalDaemonState>,
): RelayStatusConfig {
return {
enabled: state.relayEnabled,
endpoint: state.relayEndpoint,
publicEndpoint: state.relayEndpoint,
useTls: state.relayUseTls,
publicUseTls: state.relayPublicUseTls,
};
}
export function selectRelayStatus(input: {
persisted: RelayStatusConfig;
live?: RelayStatusConfig;
}): string {
const relay = input.live ?? input.persisted;
if (!relay.enabled) return "disabled";
const scheme = relay.publicUseTls ? "wss" : "ws";
return `${scheme}://${relay.publicEndpoint}`;
}
export type StatusResult = ListResult<StatusRow>;
export async function runStatusCommand(
options: CommandOptions,
_command: Command,
): Promise<StatusResult> {
const home = typeof options.home === "string" ? options.home : undefined;
const state = resolveLocalDaemonState({ home });
const daemonTarget = state.listen.trim();
const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname);
let daemonNode = await resolveDaemonNodeLabel(state);
const cliNode = process.execPath;
let localDaemon: DaemonStatus["localDaemon"] = state.running ? "running" : "stopped";
let connectedDaemon: DaemonStatus["connectedDaemon"] = "not_probed";
let daemonVersion: string | null = null;
let daemonProviders: ProviderBinaryStatus[] | undefined;
let relayStatus = selectRelayStatus({ persisted: relayConfigFromLocalState(state) });
export async function runStatusCommand(options: CommandOptions, _command: Command) {
const target = options.daemonTarget;
const instance = target.kind === "instance" ? await readDaemonInstance(target.home) : null;
const local =
target.kind === "instance"
? localStatus(target.home, instance)
: { host: describeDaemonTarget(target) };
let connectedDaemon = "not_probed";
let note: string | undefined;
if (!state.running && state.stalePidFile && state.pidInfo) {
localDaemon = "stale_pid";
note = `Stale PID file found for PID ${state.pidInfo.pid}`;
let live: Record<string, unknown> = {};
if (target.kind === "endpoint" || instance?.listen) {
try {
const client = await connectToDaemon({
target,
instance: instance ?? undefined,
timeout: 1_500,
});
try {
const status = await client.getDaemonStatus({ timeout: 1_500 });
const current = target.kind === "instance" ? await readDaemonInstance(target.home) : null;
if (instance && (!current || !isSameDaemonInstance(instance, current)))
throw new DaemonInstanceError(
"DAEMON_REPLACED",
"Supervisor exited or was replaced during status observation.",
);
live = {
serverId: client.getLastServerInfoMessage()?.serverId ?? null,
daemonVersion: status.version,
workerPid: status.pid,
daemonNode: status.nodePath,
providers: status.providers,
relay: status.relay,
};
connectedDaemon = "reachable";
} finally {
await client.close();
}
} catch (error) {
const failure = buildDaemonConnectionCommandError({ target, error });
if (target.kind === "endpoint") throw failure;
connectedDaemon = "unreachable";
if (failure.code === "AUTH_REQUIRED") connectedDaemon = "auth_required";
if (failure.code === "AUTH_FAILED") connectedDaemon = "auth_failed";
note = failure.message;
}
}
if (daemonTarget) {
const probe = await probeDaemonOverWebsocket({ host: daemonTarget, state });
({
connectedDaemon,
localDaemon,
daemonNode,
daemonVersion,
daemonProviders,
relayStatus,
note,
} = applyProbeToStatus({
probe,
connectedDaemon,
localDaemon,
daemonNode,
daemonVersion,
daemonProviders,
relayStatus,
note,
}));
}
const cliVersion = resolveCliVersion();
const serverIdResult = resolveServerIdSafely(state.home);
const serverId = serverIdResult.serverId;
if (serverIdResult.error) {
note = appendNote(note, serverIdResult.error);
}
const providers = daemonProviders ?? (await checkProviderBinaries());
const daemonStatus: DaemonStatus = {
serverId,
localDaemon,
connectedDaemon,
home: state.home,
listen: state.listen,
relay: relayStatus,
hostname: state.pidInfo?.hostname ?? null,
pid: state.pidInfo?.pid ?? null,
startedAt: state.pidInfo?.startedAt ?? null,
owner,
logPath: state.logPath,
daemonNode,
cliNode,
cliVersion,
daemonVersion,
desktopManaged: state.pidInfo?.desktopManaged === true,
providers,
note,
};
const data: Record<string, unknown> = { ...local, ...live, connectedDaemon, note };
return {
type: "list",
data: toStatusRows(daemonStatus),
schema: createStatusSchema(daemonStatus),
type: "single" as const,
data,
schema: {
idField: () => "daemon",
columns: [],
renderHuman: () =>
Object.entries(data)
.filter(([, value]) => value !== undefined)
.map(
([key, value]) =>
`${key}: ${typeof value === "object" ? JSON.stringify(value) : value}`,
)
.join("\n"),
},
};
}
function localStatus(home: string, instance: Awaited<ReturnType<typeof readDaemonInstance>>) {
const config = resolveConfigFromPersisted(
home,
readPersistedConfig(home, { defaultsIfMissing: true }),
{ env: {} },
);
let localDaemon = "stopped";
if (instance) localDaemon = instance.listen ? "running" : "not_ready";
return {
home,
pid: instance?.pid ?? null,
startedAt: instance?.startedAt ?? null,
listen: instance?.listen ?? null,
hostname: instance?.hostname ?? null,
configuredListen: config.listen,
localDaemon,
desktopManaged: instance?.desktopManaged === true,
logPath: daemonLogPath(home),
};
}
+62 -89
View File
@@ -1,94 +1,67 @@
import type { Command } from "commander";
import {
stopLocalDaemon,
DEFAULT_STOP_TIMEOUT_MS,
DEFAULT_KILL_TIMEOUT_MS,
} from "./local-daemon.js";
import type {
CommandOptions,
SingleResult,
OutputSchema,
CommandError,
} from "../../output/index.js";
import { Command } from "commander";
import { stopDaemonInstance, type DaemonInstance } from "@getpaseo/server";
import { connectToDaemon } from "../../utils/client.js";
import { withOutput, type CommandOptions } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { describeDaemonTarget } from "../../utils/daemon-target.js";
import { parseTimeoutMs } from "./local-daemon.js";
interface StopResult {
action: "stopped" | "not_running";
home: string;
pid: string;
forced: boolean;
usedLifecycleRpc: boolean;
reason: "not_running" | "lifecycle_shutdown_rpc" | "owner_pid_signal" | "owner_pid_sigkill";
message: string;
export function daemonStopCommand(): Command {
return addJsonAndDaemonHostOptions(new Command("stop").description("Stop the selected daemon"))
.option("--timeout <seconds>", "Graceful exit deadline (default: 15)")
.option("--force", "Permit forced local process cleanup")
.option("--kill-timeout <seconds>", "Forced exit deadline (default: 3)")
.action(withOutput(runStopCommand));
}
const stopResultSchema: OutputSchema<StopResult> = {
idField: "action",
columns: [
{
header: "STATUS",
field: "action",
color: (value) => (value === "stopped" ? "green" : "yellow"),
export async function runStopCommand(options: CommandOptions, _command: Command) {
const target = options.daemonTarget;
const timeoutMs = parseTimeoutMs(options.timeout, 15_000);
const deadline = Date.now() + timeoutMs;
const remaining = () => Math.max(1, deadline - Date.now());
const requestShutdown = async (instance?: DaemonInstance) => {
const client = await connectToDaemon({ target, timeout: remaining(), instance });
try {
await client.shutdownServer({ timeout: remaining() });
} finally {
await client.close();
}
};
if (target.kind === "endpoint" && options.force)
throw {
code: "INVALID_OPTIONS",
message: "--force requires a local --home; an endpoint gives no remote process authority.",
};
const result: {
action: string;
home?: string;
host?: string;
pid?: number | null;
forced?: boolean;
usedLifecycleRpc?: boolean;
} =
target.kind === "instance"
? {
...(await stopDaemonInstance(target.home, {
force: options.force === true,
timeoutMs,
killTimeoutMs: parseTimeoutMs(options.killTimeout, 3_000),
requestShutdown,
})),
home: target.home,
}
: (await requestShutdown(),
{ action: "shutdown_requested", host: describeDaemonTarget(target) });
return {
type: "single" as const,
data: result,
schema: {
idField: "action" as const,
columns: [],
renderHuman: () =>
target.kind === "endpoint"
? "Shutdown requested; remote process exit was not verified."
: `${result.action.replaceAll("_", " ")}: ${target.home}`,
},
{ header: "HOME", field: "home" },
{ header: "PID", field: "pid" },
{ header: "MESSAGE", field: "message" },
],
};
export type StopCommandResult = SingleResult<StopResult>;
function parseSecondsOption(raw: unknown, fallbackMs: number, label: string): number {
if (typeof raw !== "string" || raw.trim().length === 0) {
return fallbackMs;
}
const seconds = Number(raw);
if (!Number.isFinite(seconds) || seconds <= 0) {
const error: CommandError = {
code: "INVALID_TIMEOUT",
message: `Invalid ${label} value: ${raw}`,
details: `${label} must be a positive number of seconds`,
};
throw error;
}
return Math.ceil(seconds * 1000);
}
export async function runStopCommand(
options: CommandOptions,
_command: Command,
): Promise<StopCommandResult> {
const home = typeof options.home === "string" ? options.home : undefined;
const force = options.force === true;
const timeoutMs = parseSecondsOption(options.timeout, DEFAULT_STOP_TIMEOUT_MS, "timeout");
const killTimeoutMs = parseSecondsOption(
options.killTimeout,
DEFAULT_KILL_TIMEOUT_MS,
"kill-timeout",
);
try {
const result = await stopLocalDaemon({ home, force, timeoutMs, killTimeoutMs });
return {
type: "single",
data: {
action: result.action,
home: result.home,
pid: result.pid === null ? "-" : String(result.pid),
forced: result.forced,
usedLifecycleRpc: result.usedLifecycleRpc,
reason: result.reason,
message: result.message,
},
schema: stopResultSchema,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "STOP_FAILED",
message: `Failed to stop local daemon: ${message}`,
};
throw error;
}
};
}
+3 -3
View File
@@ -64,7 +64,7 @@ async function runCreateHeartbeat(
if (!cron) {
throw new Error("--cron is required");
}
const { client } = await connectScheduleClient(options.host);
const { client } = await connectScheduleClient(options.daemonTarget);
try {
const maxRuns = options.maxRuns ? Number.parseInt(options.maxRuns, 10) : undefined;
if (maxRuns !== undefined && (!Number.isSafeInteger(maxRuns) || maxRuns <= 0)) {
@@ -105,7 +105,7 @@ async function runUpdateHeartbeat(
if (!cron) {
throw new Error("--cron is required");
}
const { client } = await connectScheduleClient(options.host);
const { client } = await connectScheduleClient(options.daemonTarget);
try {
await requireOwnedHeartbeat(client, id, agentId);
const payload = await client.scheduleUpdate({
@@ -133,7 +133,7 @@ async function runDeleteHeartbeat(
_command: Command,
): Promise<SingleResult<HeartbeatDeleteRow>> {
const agentId = requireCallerAgentId();
const { client } = await connectScheduleClient(options.host);
const { client } = await connectScheduleClient(options.daemonTarget);
try {
await requireOwnedHeartbeat(client, id, agentId);
const payload = await client.scheduleDelete({ id });
+2 -1
View File
@@ -13,6 +13,7 @@ import { addHubResolutionHelp } from "./help.js";
interface HubConnectOptions {
apiKey?: string;
host?: string;
daemonTarget: import("../../utils/daemon-target.js").DaemonTarget;
json?: boolean;
permission?: readonly string[];
permissions?: readonly string[];
@@ -41,7 +42,7 @@ export async function runHubConnect(
const credential = resolveHubCredential({ ...resolution, origin });
const token = await dependencies.hub.issueEnrollmentToken(origin, credential);
const permissions = options.permissions ?? options.permission ?? [];
return withHubDaemon(dependencies.daemon, options.host, async (daemon) => {
return withHubDaemon(dependencies.daemon, options.daemonTarget, async (daemon) => {
const response = await daemon.connectHub(origin, token, permissions);
if (
response.status.hubOrigin !== null &&
@@ -1,3 +1,4 @@
import type { DaemonTarget } from "../../utils/daemon-target.js";
import { connectToDaemon } from "../../utils/client.js";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
@@ -33,19 +34,19 @@ export interface HubDaemonClient {
}
export interface HubDaemonConnection {
connect(host: string | undefined): Promise<HubDaemonClient>;
connect(target: DaemonTarget): Promise<HubDaemonClient>;
}
export const productionHubDaemonConnection: HubDaemonConnection = {
connect: (host) => connectToDaemon({ host }),
connect: (target) => connectToDaemon({ target }),
};
export async function withHubDaemon<T>(
connection: HubDaemonConnection,
host: string | undefined,
target: DaemonTarget,
action: (client: HubDaemonClient) => Promise<T>,
): Promise<T> {
const client = await connection.connect(host);
const client = await connection.connect(target);
try {
return await action(client);
} finally {
+2 -1
View File
@@ -9,6 +9,7 @@ import { hubStatusResult } from "./status-output.js";
interface HubDisconnectOptions {
force?: boolean;
host?: string;
daemonTarget: import("../../utils/daemon-target.js").DaemonTarget;
json?: boolean;
}
@@ -21,7 +22,7 @@ export function runHubDisconnect(
options: HubDisconnectOptions,
dependencies: HubDisconnectDependencies,
) {
return withHubDaemon(dependencies.daemon, options.host, async (client) => {
return withHubDaemon(dependencies.daemon, options.daemonTarget, async (client) => {
const current = (await client.getHubStatus()).status;
if (current.hubOrigin !== null) {
reportHubProgress(
+7 -3
View File
@@ -59,7 +59,8 @@ export function createHubCommand(overrides: Partial<HubCommandEnvironment> = {})
flow: environment.login,
reporter: environment.reporter,
isInteractive: environment.isInteractive,
continueGuidedSetup: (origin) => continueHubGuidedSetup(origin, environment),
continueGuidedSetup: (origin, daemonTarget) =>
continueHubGuidedSetup(origin, { ...environment, daemonTarget }),
});
addHubInitCommand(hub, environment);
addHubConnectCommand(hub, {
@@ -71,8 +72,11 @@ export function createHubCommand(overrides: Partial<HubCommandEnvironment> = {})
});
addJsonAndDaemonHostOptions(hub.command("status")).action(
withOutput(async (...args) => {
const options = args.at(-2) as { host?: string };
return withHubDaemon(environment.daemon, options.host, async (client) =>
const options = args.at(-2) as {
host?: string;
daemonTarget: import("../../utils/daemon-target.js").DaemonTarget;
};
return withHubDaemon(environment.daemon, options.daemonTarget, async (client) =>
hubStatusResult((await client.getHubStatus()).status),
);
}),
+36 -23
View File
@@ -1,3 +1,6 @@
import type { DaemonTarget } from "../../utils/daemon-target.js";
import { withGlobalOptions } from "../../utils/command-options.js";
import type { CommandOptions } from "../../output/index.js";
import {
cancel,
confirm,
@@ -49,6 +52,7 @@ const DAEMON_READY_POLL_MS = 250;
const PROVIDER_READY_TIMEOUT_MS = 60_000;
export interface HubGuidedSetupEnvironment {
daemonTarget: DaemonTarget;
env: Readonly<Record<string, string | undefined>>;
credentials: HubCredentialStore;
hub: HubHttpClient;
@@ -80,22 +84,27 @@ function initErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function addHubInitCommand(parent: Command, environment: HubGuidedSetupEnvironment): void {
export function addHubInitCommand(
parent: Command,
environment: Omit<HubGuidedSetupEnvironment, "daemonTarget">,
): void {
parent
.command("init")
.description("Create and optionally deploy a safe starter Hub trigger")
.action(async () => {
try {
await runHubInit(environment);
} catch (error) {
if (error instanceof HubInitCancelledError) {
cancel(error.message);
return;
.action(
withGlobalOptions(async (options: CommandOptions) => {
try {
await runHubInit({ ...environment, daemonTarget: options.daemonTarget });
} catch (error) {
if (error instanceof HubInitCancelledError) {
cancel(error.message);
return;
}
cancel(initErrorMessage(error));
process.exitCode = 1;
}
cancel(initErrorMessage(error));
process.exitCode = 1;
}
});
}),
);
}
export async function runHubInit(environment: HubGuidedSetupEnvironment): Promise<void> {
@@ -175,8 +184,10 @@ export async function continueHubGuidedSetup(
origin: string,
environment: HubGuidedSetupEnvironment,
): Promise<void> {
const currentStatus = await withHubDaemon(environment.daemon, undefined, async (daemon) =>
daemon.getHubStatus().then((response) => response.status),
const currentStatus = await withHubDaemon(
environment.daemon,
environment.daemonTarget,
async (daemon) => daemon.getHubStatus().then((response) => response.status),
);
const current = resolveHubInitConnection(currentStatus, origin);
if (current.kind === "connected") {
@@ -187,7 +198,7 @@ export async function continueHubGuidedSetup(
}.`,
);
} else if (current.kind === "pending") {
await waitForDaemonReady(origin, environment.daemon);
await waitForDaemonReady(origin, environment);
} else if (current.kind === "conflict") {
reportMessage(
environment,
@@ -273,7 +284,7 @@ async function ensureDaemonConnection(
confirmed = false,
permissions: readonly string[] = ["hub.execute"],
): Promise<string> {
const status = await withHubDaemon(environment.daemon, undefined, async (daemon) =>
const status = await withHubDaemon(environment.daemon, environment.daemonTarget, async (daemon) =>
daemon.getHubStatus().then((response) => response.status),
);
const connection = resolveHubInitConnection(status, origin);
@@ -287,7 +298,7 @@ async function ensureDaemonConnection(
return connection.daemonId;
}
if (connection.kind === "pending") {
return waitForDaemonReady(origin, environment.daemon);
return waitForDaemonReady(origin, environment);
}
if (connection.kind === "conflict") {
throw new HubCommandError(
@@ -315,7 +326,7 @@ async function connectDaemon(
): Promise<string> {
await runHubConnect(
origin,
{ permissions },
{ permissions, daemonTarget: environment.daemonTarget },
{
env: environment.env,
credentials: environment.credentials,
@@ -324,15 +335,15 @@ async function connectDaemon(
reporter: environment.reporter,
},
);
return waitForDaemonReady(origin, environment.daemon);
return waitForDaemonReady(origin, environment);
}
async function waitForDaemonReady(
origin: string,
connection: HubDaemonConnection,
environment: HubGuidedSetupEnvironment,
): Promise<string> {
return withSpinner("Waiting for the daemon to connect", async (reporter) =>
withHubDaemon(connection, undefined, async (daemon) => {
withHubDaemon(environment.daemon, environment.daemonTarget, async (daemon) => {
const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS;
while (true) {
const status = (await daemon.getHubStatus()).status;
@@ -436,7 +447,7 @@ async function waitForStarterAgentProviders(
cwd: string,
): Promise<readonly HubStarterAgentProvider[]> {
return withSpinner("Discovering agent runtimes", async (reporter) =>
withHubDaemon(environment.daemon, undefined, async (daemon) => {
withHubDaemon(environment.daemon, environment.daemonTarget, async (daemon) => {
const deadline = Date.now() + PROVIDER_READY_TIMEOUT_MS;
while (true) {
const snapshot = await daemon.getProvidersSnapshot({ cwd });
@@ -750,7 +761,9 @@ async function requiredSelect<T extends string>(
return answer;
}
function requireInteractiveTerminal(environment: HubGuidedSetupEnvironment): void {
function requireInteractiveTerminal(
environment: Omit<HubGuidedSetupEnvironment, "daemonTarget">,
): void {
if (!(environment.isInteractive?.() ?? (process.stdin.isTTY && process.stdout.isTTY))) {
throw new HubCommandError("HUB_INIT_INTERACTIVE_REQUIRED", "paseo hub init requires a TTY.");
}
+6 -4
View File
@@ -1,3 +1,5 @@
import type { CommandOptions } from "../../output/index.js";
import type { DaemonTarget } from "../../utils/daemon-target.js";
import type { Command } from "commander";
import { withOutput, type OutputSchema, type SingleResult } from "../../output/index.js";
import { addJsonOption } from "../../utils/command-options.js";
@@ -26,12 +28,12 @@ interface HubLoginDependencies {
flow: Pick<CliLoginFlow, "authorize">;
reporter: HubReporter;
isInteractive?(): boolean;
continueGuidedSetup?(origin: string): Promise<void>;
continueGuidedSetup?(origin: string, daemonTarget: DaemonTarget): Promise<void>;
}
export async function runHubLogin(
originInput: string | undefined,
options: { json?: boolean },
options: Pick<CommandOptions, "json" | "daemonTarget">,
dependencies: HubLoginDependencies,
): Promise<SingleResult<HubLoginResult>> {
const origin = resolveHubOrigin({
@@ -48,7 +50,7 @@ export async function runHubLogin(
dependencies.isInteractive?.() &&
dependencies.continueGuidedSetup !== undefined
) {
await dependencies.continueGuidedSetup(origin);
await dependencies.continueGuidedSetup(origin, options.daemonTarget);
}
return { type: "single", data: { origin, status: "logged_in" }, schema };
}
@@ -64,7 +66,7 @@ export function addHubLoginCommand(parent: Command, dependencies: HubLoginDepend
).action(
withOutput(async (...args) => {
const origin = args[0] as string | undefined;
const options = args.at(-2) as { json?: boolean };
const options = args.at(-2) as CommandOptions;
return runHubLogin(origin, options, dependencies);
}),
);
+9 -4
View File
@@ -28,6 +28,7 @@ interface HubLogoutOptions {
disconnectDaemon?: boolean;
force?: boolean;
host?: string;
daemonTarget: import("../../utils/daemon-target.js").DaemonTarget;
json?: boolean;
}
@@ -53,7 +54,7 @@ export async function runHubLogout(
let daemonStatus: HubStatus | null = null;
const shouldInspectDaemon = options.disconnectDaemon === true || mayPrompt;
if (shouldInspectDaemon) {
daemonStatus = await withHubDaemon(dependencies.daemon, options.host, async (daemon) =>
daemonStatus = await withHubDaemon(dependencies.daemon, options.daemonTarget, async (daemon) =>
daemon.getHubStatus().then((response) => response.status),
);
}
@@ -72,9 +73,13 @@ export async function runHubLogout(
options,
`Disconnecting this daemon from ${active.origin}`,
);
const disconnect = await withHubDaemon(dependencies.daemon, options.host, async (daemon) => {
return daemon.disconnectHub(options.force ?? false);
});
const disconnect = await withHubDaemon(
dependencies.daemon,
options.daemonTarget,
async (daemon) => {
return daemon.disconnectHub(options.force ?? false);
},
);
dependencies.credentials.logoutActive();
return logoutResult({
origin: active.origin,
+3 -2
View File
@@ -8,6 +8,7 @@ import { hubStatusResult } from "./status-output.js";
interface HubPermissionsOptions {
host?: string;
daemonTarget: import("../../utils/daemon-target.js").DaemonTarget;
json?: boolean;
}
@@ -33,7 +34,7 @@ export function runHubPermissionsList(
options: HubPermissionsOptions,
dependencies: HubPermissionsDependencies,
): Promise<ListResult<PermissionRow>> {
return withHubDaemon(dependencies.daemon, options.host, async (client) => {
return withHubDaemon(dependencies.daemon, options.daemonTarget, async (client) => {
const status = (await client.getHubStatus()).status;
requireConnectedHub(status);
return {
@@ -53,7 +54,7 @@ export function runHubPermissionChange(
options: HubPermissionsOptions,
dependencies: HubPermissionsDependencies,
) {
return withHubDaemon(dependencies.daemon, options.host, async (client) => {
return withHubDaemon(dependencies.daemon, options.daemonTarget, async (client) => {
const current = (await client.getHubStatus()).status;
requireConnectedHub(current);
const response = await client.updateHubPermissions(
+73 -259
View File
@@ -1,17 +1,18 @@
import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from "@clack/prompts";
import { addLocalDaemonOptions } from "../utils/command-options.js";
import { cancel, confirm, intro, isCancel, log, note, outro } from "@clack/prompts";
import { Command, Option } from "commander";
import { writeFileSync } from "node:fs";
import path from "node:path";
import { loadPersistedConfig, type PersistedConfig } from "@getpaseo/server";
import {
resolveLocalPaseoHome,
resolveLocalDaemonState,
resolveTcpHostFromListen,
startLocalDaemonDetached,
tailDaemonLog,
type DaemonStartOptions,
} from "./daemon/local-daemon.js";
import { tryConnectToDaemon } from "../utils/client.js";
readPersistedConfig as loadPersistedConfig,
savePersistedConfig,
readDaemonInstance,
waitForDaemonReady,
type PersistedConfig,
} from "@getpaseo/server";
import { withGlobalOptions } from "../utils/command-options.js";
import type { CommandOptions } from "../output/index.js";
import { launchLocalDaemon, parseTimeoutMs } from "./daemon/local-daemon.js";
import { connectToDaemon } from "../utils/client.js";
import { formatPairingInstructions } from "../output/pairing.js";
import {
confirmRelayPairing,
@@ -19,7 +20,12 @@ import {
resolveLocalPairingOffer,
} from "./daemon/pair.js";
interface OnboardOptions extends DaemonStartOptions {
interface OnboardOptions extends CommandOptions {
port?: string;
listen?: string;
relay?: boolean;
mcp?: boolean;
hostnames?: string;
timeout?: string;
voice?: "ask" | "enable" | "disable";
}
@@ -39,41 +45,12 @@ type OnboardPersistedConfig = PersistedConfig & {
};
};
const DEFAULT_READY_TIMEOUT_MS = 10 * 60 * 1000;
const READY_PROBE_TIMEOUT_MS = 1200;
class OnboardCancelledError extends Error {}
const plainNoteFormat = (line: string): string => line;
function renderNote(message: string, title: string): void {
note(message, title, { format: plainNoteFormat });
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function parseTimeoutMs(raw: string | undefined): number {
if (!raw || raw.trim().length === 0) {
return DEFAULT_READY_TIMEOUT_MS;
}
const seconds = Number(raw);
if (!Number.isFinite(seconds) || seconds <= 0) {
throw new Error(`Invalid timeout value: ${raw}`);
}
return Math.ceil(seconds * 1000);
}
function savePersistedConfig(paseoHome: string, config: OnboardPersistedConfig): void {
const configPath = path.join(paseoHome, "config.json");
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
}
function applyVoiceSelection(
config: OnboardPersistedConfig,
enabled: boolean,
@@ -135,134 +112,6 @@ async function resolveVoiceSelection(mode: OnboardOptions["voice"]): Promise<boo
return answer;
}
interface DownloadProgress {
modelId: string | null;
pct: number | null;
}
function parseDownloadProgress(logTail: string): DownloadProgress | null {
const lines = logTail.split("\n").filter(Boolean);
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index];
if (!line || !line.includes("Downloading model artifact")) {
continue;
}
const pctMatch = line.match(/"pct"\s*:\s*(\d{1,3})|\bpct[=:]\s*(\d{1,3})/);
const modelMatch = line.match(/"modelId"\s*:\s*"([^"]+)"|\bmodelId[=:]\s*"?([^\s",}]+)/);
return {
modelId: modelMatch?.[1] ?? modelMatch?.[2] ?? null,
pct: pctMatch ? Number(pctMatch[1] ?? pctMatch[2]) : null,
};
}
return null;
}
function renderProgressLine(progress: DownloadProgress): string {
const modelSuffix = progress.modelId ? ` (${progress.modelId})` : "";
if (progress.pct === null) {
return `Downloading speech model${modelSuffix}...`;
}
return `Downloading speech model${modelSuffix}: ${progress.pct}%`;
}
type ProbeResult = { kind: "ready"; listen: string; host: string | null } | { kind: "pending" };
async function probeDaemonReady(home: string, timeoutMs: number): Promise<ProbeResult> {
const state = resolveLocalDaemonState({ home });
const host = resolveTcpHostFromListen(state.listen);
const deadline = Date.now() + timeoutMs;
const remainingTimeoutMs = () => Math.max(1, deadline - Date.now());
if (state.running && host) {
const client = await tryConnectToDaemon({
host,
timeout: Math.min(remainingTimeoutMs(), READY_PROBE_TIMEOUT_MS),
});
if (client) {
try {
await client.fetchAgents({
timeout: Math.min(remainingTimeoutMs(), READY_PROBE_TIMEOUT_MS),
});
return { kind: "ready", listen: state.listen, host };
} catch {
// Daemon process is alive but not API-ready yet.
} finally {
await client.close().catch(() => {});
}
}
} else if (state.running && !host) {
return { kind: "ready", listen: state.listen, host: null };
}
return { kind: "pending" };
}
interface ProgressState {
lastStatus: string;
lastPrintedAt: number;
}
function announceProgress(
home: string,
state: ProgressState,
onStatus: ((message: string) => void) | undefined,
): ProgressState {
const progress = parseDownloadProgress(tailDaemonLog(home, 120) ?? "");
const progressLine = progress ? renderProgressLine(progress) : null;
const statusMessage = progressLine ?? "Waiting for daemon to become ready...";
if (statusMessage !== state.lastStatus) {
onStatus?.(statusMessage);
return { lastStatus: statusMessage, lastPrintedAt: Date.now() };
}
if (!onStatus && Date.now() - state.lastPrintedAt >= 3000) {
console.log(statusMessage);
return { lastStatus: state.lastStatus, lastPrintedAt: Date.now() };
}
return state;
}
async function waitForDaemonReady(args: {
home: string;
timeoutMs: number;
onStatus?: (message: string) => void;
}): Promise<{ listen: string; host: string | null }> {
const deadline = Date.now() + args.timeoutMs;
const createTimeoutError = () => {
const recentLogs = tailDaemonLog(args.home, 60);
return new Error(
[
`Timed out after ${Math.ceil(args.timeoutMs / 1000)}s waiting for daemon readiness.`,
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
]
.filter(Boolean)
.join("\n\n"),
);
};
async function poll(state: ProgressState): Promise<{ listen: string; host: string | null }> {
if (Date.now() >= deadline) {
throw createTimeoutError();
}
const probe = await probeDaemonReady(args.home, Math.max(1, deadline - Date.now()));
if (probe.kind === "ready") {
return { listen: probe.listen, host: probe.host };
}
const nextState = announceProgress(args.home, state, args.onStatus);
if (Date.now() >= deadline) {
throw createTimeoutError();
}
await sleep(200);
return poll(nextState);
}
return poll({ lastStatus: "", lastPrintedAt: 0 });
}
function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: boolean): void {
const daemonLogPath = path.join(paseoHome, "daemon.log");
const nextStepsLines = [
@@ -272,13 +121,13 @@ function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: bo
"2. Web app: https://app.paseo.sh",
"3. Desktop app: https://github.com/getpaseo/paseo/releases/latest",
"4. Docs: https://paseo.sh/docs",
'5. Example: paseo run --output-schema schema.json "extract fields"',
`5. Example: paseo run --home ${JSON.stringify(paseoHome)} --output-schema schema.json "extract fields"`,
];
const quickReferenceLines = [
"1. paseo --help",
"2. paseo ls",
'3. paseo run "your prompt"',
"4. paseo status",
`2. paseo ls --home ${JSON.stringify(paseoHome)}`,
`3. paseo run --home ${JSON.stringify(paseoHome)} "your prompt"`,
`4. paseo status --home ${JSON.stringify(paseoHome)}`,
`5. Daemon logs: ${daemonLogPath}`,
];
@@ -301,11 +150,10 @@ function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: bo
}
export function onboardCommand(): Command {
return new Command("onboard")
return addLocalDaemonOptions(new Command("onboard"))
.description("Run first-time setup, start daemon, and print pairing instructions")
.option("--listen <listen>", "Listen target (host:port, port, or unix socket path)")
.option("--port <port>", "Port to listen on (default: 6767)")
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--relay", "Enable relay connection without prompting")
.option("--no-relay", "Disable relay connection")
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
@@ -316,12 +164,15 @@ export function onboardCommand(): Command {
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
.option("--timeout <seconds>", "Max time to wait for daemon readiness (default: 600)")
.option("--voice <mode>", "Voice setup mode: ask, enable, disable", "ask")
.action(async (options: RawOnboardOptions) => {
await runOnboard({
...options,
hostnames: options.hostnames ?? options.allowedHosts,
});
});
.action(
withGlobalOptions(async (options: RawOnboardOptions, command: Command) => {
await runOnboard({
...options,
mcp: command.getOptionValueSource("mcp") === "cli" ? options.mcp : undefined,
hostnames: options.hostnames ?? options.allowedHosts,
});
}),
);
}
async function resolveAndPersistVoice(
@@ -354,70 +205,30 @@ async function resolveAndPersistVoice(
return voiceEnabled;
}
async function ensureDaemonStarted(options: OnboardOptions, richUi: boolean): Promise<void> {
const stateBeforeStart = resolveLocalDaemonState({ home: options.home });
if (stateBeforeStart.running) {
log.message(`Daemon already running (PID ${stateBeforeStart.pidInfo?.pid ?? "unknown"}).`);
return;
}
const startSpinner = richUi ? spinner() : null;
try {
if (startSpinner) {
startSpinner.start("Starting daemon...");
} else {
log.message("Starting daemon...");
}
const startup = await startLocalDaemonDetached(options);
if (startSpinner) {
startSpinner.stop(`Daemon started (PID ${startup.pid ?? "unknown"})`);
} else {
log.message(`Daemon started (PID ${startup.pid ?? "unknown"})`);
}
log.message(`Logs: ${startup.logPath}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (startSpinner) {
startSpinner.error(message);
} else {
log.error(message);
}
process.exit(1);
}
}
async function waitForDaemonReadyWithUi(args: {
home: string;
timeoutMs: number;
richUi: boolean;
}): Promise<{ listen: string; host: string | null }> {
const readySpinner = args.richUi ? spinner() : null;
try {
if (readySpinner) {
readySpinner.start("Waiting for daemon to become ready...");
} else {
log.message("Waiting for daemon to become ready...");
}
const readyState = await waitForDaemonReady({
home: args.home,
timeoutMs: args.timeoutMs,
onStatus: readySpinner ? (message) => readySpinner.message(message) : undefined,
});
if (readySpinner) {
readySpinner.stop(`Daemon ready on ${readyState.listen}`);
} else {
log.message(`Daemon ready on ${readyState.listen}`);
}
return readyState;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (readySpinner) {
readySpinner.error(message);
} else {
log.error(message);
}
return process.exit(1);
function persistSetupChoices(paseoHome: string, options: OnboardOptions): void {
const persisted = loadPersistedConfig(paseoHome, { defaultsIfMissing: true });
if (options.listen || options.port) {
persisted.daemon = {
...persisted.daemon,
listen: options.listen ?? `127.0.0.1:${options.port}`,
};
}
if (options.relay !== undefined)
persisted.daemon = {
...persisted.daemon,
relay: { ...persisted.daemon?.relay, enabled: options.relay },
};
if (options.mcp !== undefined)
persisted.daemon = {
...persisted.daemon,
mcp: { ...persisted.daemon?.mcp, enabled: options.mcp },
};
if (options.hostnames)
persisted.daemon = {
...persisted.daemon,
hostnames: options.hostnames === "true" ? true : options.hostnames.split(","),
};
savePersistedConfig(paseoHome, persisted);
}
export async function runOnboard(options: OnboardOptions): Promise<void> {
@@ -431,16 +242,12 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
process.exit(1);
}
let timeoutMs = DEFAULT_READY_TIMEOUT_MS;
try {
timeoutMs = parseTimeoutMs(options.timeout);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
cancel(message);
process.exit(1);
}
const timeoutMs = parseTimeoutMs(options.timeout);
const paseoHome = resolveLocalPaseoHome(options.home);
if (options.daemonTarget.kind !== "instance") throw new Error("Onboarding requires a local home");
const paseoHome = options.daemonTarget.home;
const alreadyRunning = await readDaemonInstance(paseoHome);
persistSetupChoices(paseoHome, options);
if (richUi) {
renderNote(paseoHome, "Paseo home");
}
@@ -452,12 +259,19 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
: "Voice features disabled. Local speech models will not be downloaded.",
);
await ensureDaemonStarted(options, richUi);
await waitForDaemonReadyWithUi({
home: options.home ?? paseoHome,
timeoutMs,
richUi,
});
if (alreadyRunning) {
log.message(`Daemon already running (PID ${alreadyRunning.pid}); retaining its supervisor.`);
const client = await connectToDaemon({ target: options.daemonTarget, timeout: timeoutMs });
try {
log.message(JSON.stringify(await client.reloadDaemonConfig()));
} finally {
await client.close();
}
} else {
await launchLocalDaemon({ home: paseoHome, timeoutMs });
}
const ready = await waitForDaemonReady(paseoHome, { timeoutMs });
log.message(`Daemon ready on ${ready.listen}`);
if (options.relay === false) {
log.message("Relay pairing skipped because --no-relay was provided.");
+1
View File
@@ -95,6 +95,7 @@ export async function openDesktopWithProject(projectPath: string): Promise<void>
try {
launchDesktop([projectPath]);
} catch (error) {
if (error && typeof error === "object" && "code" in error) throw error;
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
+2 -15
View File
@@ -1,6 +1,6 @@
import type { Command } from "commander";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
/** Permission response item for display */
@@ -46,8 +46,6 @@ export async function runAllowCommand(
options: PermitAllowOptions,
_command: Command,
): Promise<PermitAllowResult> {
const host = getDaemonHost({ host: options.host });
// No validation needed - if no reqId provided, allow all by default
// Parse input JSON if provided
@@ -65,18 +63,7 @@ export async function runAllowCommand(
}
}
let client;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
+2 -15
View File
@@ -1,6 +1,6 @@
import type { Command } from "commander";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, CommandError } from "../../output/index.js";
import { permitResponseSchema, type PermissionResponseItem } from "./allow.js";
@@ -19,8 +19,6 @@ export async function runDenyCommand(
options: PermitDenyOptions,
_command: Command,
): Promise<PermitDenyResult> {
const host = getDaemonHost({ host: options.host });
// Validate arguments
if (!options.all && !reqId) {
const error: CommandError = {
@@ -31,18 +29,7 @@ export async function runDenyCommand(
throw error;
}
let client;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
+2 -15
View File
@@ -1,7 +1,7 @@
import type { Command } from "commander";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
/** Permission list item for display */
@@ -48,20 +48,7 @@ export async function runLsCommand(
options: PermitLsOptions,
_command: Command,
): Promise<PermitLsResult> {
const host = getDaemonHost({ host: options.host });
let client;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
+11 -7
View File
@@ -90,7 +90,9 @@ export async function runPluginListCommand(
options: PluginOptions,
_command: Command,
): Promise<ListResult<PluginListItem>> {
const plugins = await withPluginManagementClient(options.host, (client) => client.listPlugins());
const plugins = await withPluginManagementClient(options.daemonTarget, (client) =>
client.listPlugins(),
);
const data = pluginId ? plugins.filter((plugin) => plugin.id === pluginId) : plugins;
if (pluginId && data.length === 0) throw new Error(`Plugin is not configured: ${pluginId}`);
return { type: "list", data, schema: pluginSchema };
@@ -101,7 +103,9 @@ export async function runPluginLogsCommand(
options: PluginOptions,
_command: Command,
): Promise<ListResult<PluginLogEntry>> {
const data = await withPluginLogsClient(options.host, (client) => client.getPluginLogs(pluginId));
const data = await withPluginLogsClient(options.daemonTarget, (client) =>
client.getPluginLogs(pluginId),
);
return { type: "list", data, schema: pluginLogsSchema };
}
@@ -126,10 +130,10 @@ async function install(
isExplicitPath && !hasPluginPathSuffix && !options.ref && !options.path;
const sourceReference = formatPluginSourceReference(source, options.path);
const data = canUseLegacyDirectoryInstall
? await withPluginManagementClient(options.host, (client) =>
? await withPluginManagementClient(options.daemonTarget, (client) =>
client.installDirectoryPlugin(source, options.id),
)
: await withPluginSourceClient(options.host, (client) =>
: await withPluginSourceClient(options.daemonTarget, (client) =>
client.installPluginSource({
source: sourceReference,
...(options.id ? { id: options.id } : {}),
@@ -147,7 +151,7 @@ async function update(
if ((pluginId === undefined) === (options.all !== true)) {
throw new Error("Choose one plugin ID or pass --all");
}
const data = await withPluginSourceClient(options.host, (client) =>
const data = await withPluginSourceClient(options.daemonTarget, (client) =>
client.updatePluginSources(pluginId),
);
return { type: "list", data, schema: pluginUpdateSchema };
@@ -158,7 +162,7 @@ async function act(
pluginId: string,
options: PluginOptions,
): Promise<SingleResult<PluginListItem>> {
const data = await withPluginManagementClient(options.host, (client) =>
const data = await withPluginManagementClient(options.daemonTarget, (client) =>
client[`${action}Plugin`](pluginId),
);
return { type: "single", data, schema: pluginSchema };
@@ -169,7 +173,7 @@ async function remove(
options: PluginOptions,
_command: Command,
): Promise<SingleResult<PluginListItem>> {
const data = await withPluginManagementClient(options.host, async (client) => {
const data = await withPluginManagementClient(options.daemonTarget, async (client) => {
const current = (await client.listPlugins()).find((plugin) => plugin.id === pluginId);
if (!current) throw new Error(`Plugin is not configured: ${pluginId}`);
await client.removePlugin(pluginId);
+9 -8
View File
@@ -1,3 +1,4 @@
import type { DaemonTarget } from "../../utils/daemon-target.js";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { CommandError } from "../../output/index.js";
import { connectToDaemon } from "../../utils/client.js";
@@ -5,12 +6,12 @@ import { connectToDaemon } from "../../utils/client.js";
type PluginFeature = "pluginManagement" | "pluginLogs" | "pluginGitManagement";
async function withPluginClient<T>(
host: string | undefined,
target: DaemonTarget,
feature: PluginFeature,
updateMessage: string,
run: (client: DaemonClient) => Promise<T>,
): Promise<T> {
const client = await connectToDaemon({ host });
const client = await connectToDaemon({ target });
if (client.getLastServerInfoMessage()?.features?.[feature] !== true) {
await client.close().catch(() => undefined);
throw {
@@ -26,12 +27,12 @@ async function withPluginClient<T>(
}
export async function withPluginManagementClient<T>(
host: string | undefined,
target: DaemonTarget,
run: (client: DaemonClient) => Promise<T>,
): Promise<T> {
// COMPAT(pluginManagement): added in v0.3.1, remove gate after 2027-08-14.
return withPluginClient(
host,
target,
"pluginManagement",
"Update the host to use plugin management.",
run,
@@ -39,20 +40,20 @@ export async function withPluginManagementClient<T>(
}
export async function withPluginLogsClient<T>(
host: string | undefined,
target: DaemonTarget,
run: (client: DaemonClient) => Promise<T>,
): Promise<T> {
// COMPAT(pluginLogs): added in v0.4.0, remove gate after 2027-08-16.
return withPluginClient(host, "pluginLogs", "Update the host to view plugin logs.", run);
return withPluginClient(target, "pluginLogs", "Update the host to view plugin logs.", run);
}
export async function withPluginSourceClient<T>(
host: string | undefined,
target: DaemonTarget,
run: (client: DaemonClient) => Promise<T>,
): Promise<T> {
// COMPAT(pluginGitManagement): added in v0.7.0, remove gate after 2027-08-26.
return withPluginClient(
host,
target,
"pluginGitManagement",
"Update the host to install and update Git plugins.",
run,
+4 -3
View File
@@ -31,10 +31,10 @@ export async function runCreateCommand(
const projectPath = resolveProjectPath({
pathArg,
cwd: process.cwd(),
daemonTarget: options.host ?? process.env.PASEO_HOST,
daemonTarget: options.daemonTarget.kind === "endpoint" ? options.daemonTarget.host : undefined,
});
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
throw buildDaemonConnectionCommandError({ host: options.host, error });
const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => {
throw buildDaemonConnectionCommandError({ target: options.daemonTarget, error });
});
try {
@@ -44,6 +44,7 @@ export async function runCreateCommand(
}
return { type: "single", data: toProjectRow(payload.project), schema: projectSchema };
} catch (error) {
if (error && typeof error === "object" && "code" in error) throw error;
const message = error instanceof Error ? error.message : String(error);
throw { code: "PROJECT_CREATE_FAILED", message } satisfies CommandError;
} finally {
+3 -2
View File
@@ -24,8 +24,8 @@ export async function runDeleteCommand(
options: CommandOptions,
_command: Command,
): Promise<SingleResult<ProjectDeleteResult>> {
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
throw buildDaemonConnectionCommandError({ host: options.host, error });
const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => {
throw buildDaemonConnectionCommandError({ target: options.daemonTarget, error });
});
try {
@@ -36,6 +36,7 @@ export async function runDeleteCommand(
schema: projectDeleteSchema,
};
} catch (error) {
if (error && typeof error === "object" && "code" in error) throw error;
const message = error instanceof Error ? error.message : String(error);
throw { code: "PROJECT_DELETE_FAILED", message } satisfies CommandError;
} finally {
+2 -2
View File
@@ -7,8 +7,8 @@ export async function runLsCommand(
options: CommandOptions,
_command: Command,
): Promise<ListResult<ProjectRow>> {
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
throw buildDaemonConnectionCommandError({ host: options.host, error });
const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => {
throw buildDaemonConnectionCommandError({ target: options.daemonTarget, error });
});
try {
@@ -47,7 +47,11 @@ describe("project commands", () => {
});
it("creates a project from an absolute directory path", async () => {
const result = await runCreateCommand("relative/project", {}, {} as never);
const result = await runCreateCommand(
"relative/project",
{ daemonTarget: { kind: "instance", home: "/tmp/project-test" } },
{} as never,
);
expect(addProject).toHaveBeenCalledWith(path.resolve("relative/project"));
expect(result.data).toEqual({
@@ -77,7 +81,10 @@ describe("project commands", () => {
});
it("lists projects", async () => {
const result = await runLsCommand({}, {} as never);
const result = await runLsCommand(
{ daemonTarget: { kind: "instance", home: "/tmp/project-test" } },
{} as never,
);
expect(listProjects).toHaveBeenCalled();
expect(result.data).toHaveLength(1);
@@ -88,7 +95,12 @@ describe("project commands", () => {
expect(resolveProjectName({ name: " New name " })).toBe("New name");
expect(resolveProjectName({ reset: true })).toBeNull();
const result = await runRenameCommand("project-1", " New name ", {}, {} as never);
const result = await runRenameCommand(
"project-1",
" New name ",
{ daemonTarget: { kind: "instance", home: "/tmp/project-test" } },
{} as never,
);
expect(renameProject).toHaveBeenCalledWith("project-1", "New name");
expect(result.data).toEqual({ projectId: "project-1", name: "New name" });
});
@@ -98,7 +110,11 @@ describe("project commands", () => {
});
it("deletes a project", async () => {
const result = await runDeleteCommand("project-1", {}, {} as never);
const result = await runDeleteCommand(
"project-1",
{ daemonTarget: { kind: "instance", home: "/tmp/project-test" } },
{} as never,
);
expect(removeProject).toHaveBeenCalledWith("project-1");
expect(result.data).toEqual({
+3 -2
View File
@@ -44,8 +44,8 @@ export async function runRenameCommand(
_command: Command,
): Promise<SingleResult<ProjectRenameResult>> {
const name = resolveProjectName({ name: nameArg, reset: options.reset });
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
throw buildDaemonConnectionCommandError({ host: options.host, error });
const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => {
throw buildDaemonConnectionCommandError({ target: options.daemonTarget, error });
});
try {
@@ -56,6 +56,7 @@ export async function runRenameCommand(
schema: projectRenameSchema,
};
} catch (error) {
if (error && typeof error === "object" && "code" in error) throw error;
const message = error instanceof Error ? error.message : String(error);
throw { code: "PROJECT_RENAME_FAILED", message } satisfies CommandError;
} finally {
@@ -28,7 +28,7 @@ export async function runDiagnosticCommand(
options: ProviderDiagnosticOptions,
_command: Command,
): Promise<SingleResult<ProviderDiagnosticResult>> {
const client = await connectToDaemon({ host: options.host });
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const result = await client.getProviderDiagnostic(provider.trim().toLowerCase());
return {
+2 -31
View File
@@ -1,8 +1,7 @@
import type { Command } from "commander";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/protocol/provider-manifest";
import { tryConnectToDaemon } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
export interface ProviderListItem {
provider: ProviderSnapshotEntry["provider"];
@@ -13,20 +12,6 @@ export interface ProviderListItem {
modes: string;
}
/** Derive provider list from the manifest — single source of truth */
const PROVIDERS: ProviderListItem[] = AGENT_PROVIDER_DEFINITIONS.map((def) => ({
provider: def.id,
label: def.label,
status: "available",
enabled: def.enabledByDefault === false ? "Disabled" : "Enabled",
defaultMode: def.defaultModeId ?? "-",
modes: def.modes.length > 0 ? def.modes.map((m) => m.label).join(", ") : "-",
}));
function getStaticProviders(): ProviderListItem[] {
return PROVIDERS;
}
/** Schema for provider ls output */
export const providerLsSchema: OutputSchema<ProviderListItem> = {
idField: "provider",
@@ -59,15 +44,7 @@ export async function runLsCommand(
options: ProviderLsOptions,
_command: Command,
): Promise<ProviderLsResult> {
const client = await tryConnectToDaemon({ host: options.host });
if (!client) {
return {
type: "list",
data: getStaticProviders(),
schema: providerLsSchema,
};
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const snapshot = await client.getProvidersSnapshot();
@@ -83,12 +60,6 @@ export async function runLsCommand(
})),
schema: providerLsSchema,
};
} catch {
return {
type: "list",
data: getStaticProviders(),
schema: providerLsSchema,
};
} finally {
await client.close().catch(() => {});
}
+1 -1
View File
@@ -62,7 +62,7 @@ export async function runModelsCommand(
): Promise<ProviderModelsResult> {
const normalizedProvider = provider.toLowerCase();
const client = await connectToDaemon({ host: options.host });
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const result = await client.listProviderModels(normalizedProvider);
+2 -2
View File
@@ -43,12 +43,12 @@ export async function runCreateCommand(
mode: options.mode,
thinking: options.thinking,
cwd: options.cwd,
host: options.host,
daemonTarget: options.daemonTarget,
maxRuns: options.maxRuns,
expiresIn: options.expiresIn,
runNow,
});
const { client } = await connectScheduleClient(options.host);
const { client } = await connectScheduleClient(options.daemonTarget);
try {
const payload = await client.scheduleCreate(input);
if (payload.error || !payload.schedule) {
+1 -1
View File
@@ -25,7 +25,7 @@ export async function runDeleteCommand(
options: ScheduleCommandOptions,
_command: Command,
): Promise<SingleResult<ScheduleDeleteRow>> {
const { client } = await connectScheduleClient(options.host);
const { client } = await connectScheduleClient(options.daemonTarget);
try {
const payload = await client.scheduleDelete({ id });
if (payload.error) {
@@ -16,7 +16,7 @@ export async function runInspectCommand(
options: ScheduleCommandOptions,
_command: Command,
): Promise<ListResult<ScheduleInspectRow>> {
const { client } = await connectScheduleClient(options.host);
const { client } = await connectScheduleClient(options.daemonTarget);
try {
const payload = await client.scheduleInspect({ id });
if (payload.error || !payload.schedule) {
+1 -1
View File
@@ -13,7 +13,7 @@ export async function runLogsCommand(
options: ScheduleCommandOptions,
_command: Command,
): Promise<ListResult<ScheduleLogRow>> {
const { client } = await connectScheduleClient(options.host);
const { client } = await connectScheduleClient(options.daemonTarget);
try {
await requireNewAgentSchedule(client, id);
const payload = await client.scheduleLogs({ id });
+1 -1
View File
@@ -13,7 +13,7 @@ export async function runLsCommand(
options: ScheduleCommandOptions,
_command: Command,
): Promise<ListResult<ScheduleRow>> {
const { client } = await connectScheduleClient(options.host);
const { client } = await connectScheduleClient(options.daemonTarget);
try {
const payload = await client.scheduleList();
if (payload.error) {
+1 -1
View File
@@ -15,7 +15,7 @@ export async function runPauseCommand(
options: ScheduleCommandOptions,
_command: Command,
): Promise<SingleResult<ScheduleRow>> {
const { client } = await connectScheduleClient(options.host);
const { client } = await connectScheduleClient(options.daemonTarget);
try {
await requireNewAgentSchedule(client, id);
const payload = await client.schedulePause({ id });
+1 -1
View File
@@ -15,7 +15,7 @@ export async function runResumeCommand(
options: ScheduleCommandOptions,
_command: Command,
): Promise<SingleResult<ScheduleRow>> {
const { client } = await connectScheduleClient(options.host);
const { client } = await connectScheduleClient(options.daemonTarget);
try {
await requireNewAgentSchedule(client, id);
const payload = await client.scheduleResume({ id });
@@ -15,7 +15,7 @@ export async function runRunOnceCommand(
options: ScheduleCommandOptions,
_command: Command,
): Promise<SingleResult<ScheduleRow>> {
const { client } = await connectScheduleClient(options.host);
const { client } = await connectScheduleClient(options.daemonTarget);
try {
await requireNewAgentSchedule(client, id);
const payload = await client.scheduleRunOnce({ id });
+8 -5
View File
@@ -1,4 +1,5 @@
import { connectToDaemon, getDaemonHost, getExplicitDaemonHost } from "../../utils/client.js";
import type { DaemonTarget } from "../../utils/daemon-target.js";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, CommandOptions } from "../../output/index.js";
import type {
CreateScheduleInput,
@@ -19,15 +20,16 @@ export interface ScheduleCommandOptions extends CommandOptions {
}
export async function connectScheduleClient(
host: string | undefined,
target: DaemonTarget,
): Promise<{ client: ScheduleDaemonClient; host: string }> {
const resolvedHost = getDaemonHost({ host });
const resolvedHost = getDaemonHost({ target });
try {
const client = (await connectToDaemon({
host,
target,
})) as unknown as ScheduleDaemonClient;
return { client, host: resolvedHost };
} catch (error) {
if (error && typeof error === "object" && "code" in error) throw error;
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
@@ -147,6 +149,7 @@ export function parseScheduleCreateInput(options: {
thinking?: string;
cwd?: string;
host?: string;
daemonTarget: import("../../utils/daemon-target.js").DaemonTarget;
maxRuns?: string;
expiresIn?: string;
runNow?: boolean;
@@ -168,7 +171,7 @@ export function parseScheduleCreateInput(options: {
}
const cwdInput = options.cwd?.trim();
if (getExplicitDaemonHost(options.host) !== undefined && !cwdInput) {
if (options.daemonTarget.kind === "endpoint" && !cwdInput) {
throw {
code: "MISSING_CWD",
message:
+1 -1
View File
@@ -50,7 +50,7 @@ export async function runUpdateCommand(
clearMaxRuns: options.noMaxRuns,
clearExpires: options.noExpiresIn,
});
const { client } = await connectScheduleClient(options.host);
const { client } = await connectScheduleClient(options.daemonTarget);
try {
await requireNewAgentSchedule(client, id);
const payload = await client.scheduleUpdate(input);
+1 -1
View File
@@ -12,7 +12,7 @@ export async function runLsCommand(
options: WorkspaceScriptCommandOptions,
_command: Command,
): Promise<ListResult<WorkspaceScriptRow>> {
const client = await connectWorkspaceScriptClient(options.host);
const client = await connectWorkspaceScriptClient(options.daemonTarget);
try {
const workspaceId = await resolveWorkspaceScriptWorkspaceId(client, options);
const payload = await client.listWorkspaceScripts(workspaceId);
+4 -3
View File
@@ -1,3 +1,4 @@
import type { DaemonTarget } from "../../utils/daemon-target.js";
import { resolve } from "node:path";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
@@ -9,10 +10,10 @@ export interface WorkspaceScriptCommandOptions extends CommandOptions {
workspace?: string;
}
export async function connectWorkspaceScriptClient(host?: string): Promise<DaemonClient> {
const daemonHost = getDaemonHost({ host });
export async function connectWorkspaceScriptClient(target: DaemonTarget): Promise<DaemonClient> {
const daemonHost = getDaemonHost({ target });
try {
const client = await connectToDaemon({ host });
const client = await connectToDaemon({ target });
// COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10.
if (!client.getLastServerInfoMessage()?.features?.workspaceScriptManagement) {
await client.close().catch(() => {});
+1 -1
View File
@@ -13,7 +13,7 @@ export async function runStartCommand(
options: WorkspaceScriptCommandOptions,
_command: Command,
): Promise<SingleResult<WorkspaceScriptRow>> {
const client = await connectWorkspaceScriptClient(options.host);
const client = await connectWorkspaceScriptClient(options.daemonTarget);
try {
const workspaceId = await resolveWorkspaceScriptWorkspaceId(client, options);
const payload = await client.startWorkspaceScriptWithStatus(workspaceId, scriptName);
+1 -1
View File
@@ -13,7 +13,7 @@ export async function runStopCommand(
options: WorkspaceScriptCommandOptions,
_command: Command,
): Promise<SingleResult<WorkspaceScriptRow>> {
const client = await connectWorkspaceScriptClient(options.host);
const client = await connectWorkspaceScriptClient(options.daemonTarget);
try {
const workspaceId = await resolveWorkspaceScriptWorkspaceId(client, options);
const payload = await client.stopWorkspaceScript(workspaceId, scriptName);
@@ -16,11 +16,9 @@ export interface TerminalCaptureOptions extends TerminalCommandOptions {
export async function runCaptureCommand(
terminalId: string,
_options: TerminalCaptureOptions,
command: Command,
options: TerminalCaptureOptions,
_command: Command,
): Promise<void> {
const options = command.optsWithGlobals() as TerminalCaptureOptions;
try {
const payload = await executeCaptureCommand(terminalId, options);
if (options.json) {
@@ -55,7 +53,7 @@ async function executeCaptureCommand(
terminalId: string,
options: TerminalCaptureOptions,
): Promise<{ terminalId: string; lines: string[]; totalLines: number }> {
const { client, close } = await connectTerminalClient(options.host);
const { client, close } = await connectTerminalClient(options.daemonTarget);
try {
const resolvedId = await resolveTerminalId(client, terminalId);
+1 -1
View File
@@ -17,7 +17,7 @@ export async function runCreateCommand(
options: TerminalCreateOptions,
_command: Command,
): Promise<SingleResult<TerminalRow>> {
const { client, close } = await connectTerminalClient(options.host);
const { client, close } = await connectTerminalClient(options.daemonTarget);
try {
const cwd = options.cwd ?? (options.workspace ? undefined : process.cwd());
const workspaceId =
+1 -1
View File
@@ -13,7 +13,7 @@ export async function runKillCommand(
options: TerminalCommandOptions,
_command: Command,
): Promise<SingleResult<TerminalKillRow>> {
const { client, close } = await connectTerminalClient(options.host);
const { client, close } = await connectTerminalClient(options.daemonTarget);
try {
const resolvedId = await requireTerminalId(client, terminalId);
+1 -1
View File
@@ -17,7 +17,7 @@ export async function runLsCommand(
options: TerminalLsOptions,
_command: Command,
): Promise<ListResult<TerminalRow>> {
const { client, close } = await connectTerminalClient(options.host);
const { client, close } = await connectTerminalClient(options.daemonTarget);
const cwd = options.all || options.workspace ? undefined : (options.cwd ?? process.cwd());
try {

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