diff --git a/dummy-edit.txt b/dummy-edit.txt deleted file mode 100644 index 6be1a1b3c..000000000 --- a/dummy-edit.txt +++ /dev/null @@ -1,2 +0,0 @@ -Dummy edit created by Codex. -You asked for any edit, so this file is a simple placeholder. diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index c24f3bff0..dc1b919b1 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -38,6 +38,11 @@ import { useTrafficLightPadding } from "@/utils/tauri-window"; import { CommandCenter } from "@/components/command-center"; import { useGlobalKeyboardNav } from "@/hooks/use-global-keyboard-nav"; import { queryClient } from "@/query/query-client"; +import { + WEB_NOTIFICATION_CLICK_EVENT, + type WebNotificationClickDetail, +} from "@/utils/os-notifications"; +import { buildNotificationRoute } from "@/utils/notification-routing"; polyfillCrypto(); @@ -47,7 +52,24 @@ function PushNotificationRouter() { useEffect(() => { if (Platform.OS === "web") { - return; + const target = globalThis as unknown as EventTarget; + const openFromWebClick = (event: Event) => { + const customEvent = event as CustomEvent; + const route = buildNotificationRoute(customEvent.detail?.data); + event.preventDefault(); + router.push(route as any); + }; + + target.addEventListener( + WEB_NOTIFICATION_CLICK_EVENT, + openFromWebClick as EventListener + ); + return () => { + target.removeEventListener( + WEB_NOTIFICATION_CLICK_EVENT, + openFromWebClick as EventListener + ); + }; } Notifications.setNotificationHandler({ @@ -71,14 +93,7 @@ function PushNotificationRouter() { const data = response.notification.request.content.data as | Record | undefined; - const agentId = typeof data?.agentId === "string" ? data.agentId : null; - - if (agentId) { - // Legacy route resolves agent -> host once sessions reconnect. - router.push(`/agent/${agentId}` as any); - } else { - router.push("/agents" as any); - } + router.push(buildNotificationRoute(data) as any); }; const subscription = diff --git a/packages/app/src/utils/notification-routing.test.ts b/packages/app/src/utils/notification-routing.test.ts new file mode 100644 index 000000000..df3256bb4 --- /dev/null +++ b/packages/app/src/utils/notification-routing.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { + buildNotificationRoute, + resolveNotificationTarget, +} from "./notification-routing"; + +describe("resolveNotificationTarget", () => { + it("extracts non-empty server and agent ids", () => { + expect( + resolveNotificationTarget({ + serverId: " server-123 ", + agentId: " agent-456 ", + }) + ).toEqual({ + serverId: "server-123", + agentId: "agent-456", + }); + }); + + it("returns null for missing/empty ids", () => { + expect(resolveNotificationTarget({ serverId: "", agentId: " " })).toEqual({ + serverId: null, + agentId: null, + }); + expect(resolveNotificationTarget(undefined)).toEqual({ + serverId: null, + agentId: null, + }); + }); +}); + +describe("buildNotificationRoute", () => { + it("routes directly to server-scoped agent path when both ids are present", () => { + expect(buildNotificationRoute({ serverId: "srv-1", agentId: "agent-1" })).toBe( + "/agent/srv-1/agent-1" + ); + }); + + it("falls back to legacy agent route when serverId is absent", () => { + expect(buildNotificationRoute({ agentId: "agent-legacy" })).toBe("/agent/agent-legacy"); + }); + + it("falls back to agents list when no agent id is present", () => { + expect(buildNotificationRoute({ serverId: "srv-only" })).toBe("/agents"); + }); + + it("encodes path segments", () => { + expect( + buildNotificationRoute({ + serverId: "srv/with/slash", + agentId: "agent with space", + }) + ).toBe("/agent/srv%2Fwith%2Fslash/agent%20with%20space"); + }); +}); diff --git a/packages/app/src/utils/notification-routing.ts b/packages/app/src/utils/notification-routing.ts new file mode 100644 index 000000000..7b1e5eff7 --- /dev/null +++ b/packages/app/src/utils/notification-routing.ts @@ -0,0 +1,34 @@ +type NotificationData = Record | null | undefined; + +function readNonEmptyString( + data: NotificationData, + key: string +): string | null { + const value = data?.[key]; + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function resolveNotificationTarget(data: NotificationData): { + serverId: string | null; + agentId: string | null; +} { + return { + serverId: readNonEmptyString(data, "serverId"), + agentId: readNonEmptyString(data, "agentId"), + }; +} + +export function buildNotificationRoute(data: NotificationData): string { + const { serverId, agentId } = resolveNotificationTarget(data); + if (serverId && agentId) { + return `/agent/${encodeURIComponent(serverId)}/${encodeURIComponent(agentId)}`; + } + if (agentId) { + return `/agent/${encodeURIComponent(agentId)}`; + } + return "/agents"; +} diff --git a/packages/app/src/utils/os-notifications.test.ts b/packages/app/src/utils/os-notifications.test.ts new file mode 100644 index 000000000..1fa5a7f5c --- /dev/null +++ b/packages/app/src/utils/os-notifications.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type MockNotificationOptions = { + body?: string; + data?: Record; +}; + +type MockNotificationInstance = { + title: string; + options?: MockNotificationOptions; + onclick: ((event: Event) => void) | null; + close: ReturnType; +}; + +type GlobalSnapshot = { + Notification: unknown; + CustomEvent: unknown; + dispatchEvent: unknown; + focus: unknown; + location: unknown; +}; + +const originalGlobals: GlobalSnapshot = { + Notification: (globalThis as { Notification?: unknown }).Notification, + CustomEvent: (globalThis as { CustomEvent?: unknown }).CustomEvent, + dispatchEvent: (globalThis as { dispatchEvent?: unknown }).dispatchEvent, + focus: (globalThis as { focus?: unknown }).focus, + location: (globalThis as { location?: unknown }).location, +}; + +async function loadModuleForPlatform(platform: "web" | "ios" | "android") { + vi.resetModules(); + vi.doMock("react-native", () => ({ Platform: { OS: platform } })); + return import("./os-notifications"); +} + +function restoreGlobals(): void { + (globalThis as { Notification?: unknown }).Notification = originalGlobals.Notification; + (globalThis as { CustomEvent?: unknown }).CustomEvent = originalGlobals.CustomEvent; + (globalThis as { dispatchEvent?: unknown }).dispatchEvent = originalGlobals.dispatchEvent; + (globalThis as { focus?: unknown }).focus = originalGlobals.focus; + (globalThis as { location?: unknown }).location = originalGlobals.location; +} + +describe("sendOsNotification", () => { + beforeEach(() => { + class MockCustomEvent { + type: string; + detail: T; + cancelable: boolean; + defaultPrevented = false; + + constructor(type: string, init?: { detail?: T; cancelable?: boolean }) { + this.type = type; + this.detail = (init?.detail ?? null) as T; + this.cancelable = init?.cancelable ?? false; + } + + preventDefault(): void { + if (this.cancelable) { + this.defaultPrevented = true; + } + } + } + + (globalThis as { CustomEvent?: unknown }).CustomEvent = MockCustomEvent; + (globalThis as { focus?: unknown }).focus = vi.fn(); + }); + + afterEach(() => { + vi.doUnmock("react-native"); + vi.restoreAllMocks(); + vi.resetModules(); + restoreGlobals(); + }); + + it("dispatches a click event that the app can handle", async () => { + const created: MockNotificationInstance[] = []; + + class MockNotification implements MockNotificationInstance { + static permission = "granted"; + static requestPermission = vi.fn(async () => "granted"); + onclick: ((event: Event) => void) | null = null; + close = vi.fn(); + + constructor(public title: string, public options?: MockNotificationOptions) { + created.push(this); + } + } + + const dispatchEvent = vi.fn((event: unknown) => { + void event; + return false; + }); + const assign = vi.fn(); + + (globalThis as { Notification?: unknown }).Notification = MockNotification; + (globalThis as { dispatchEvent?: unknown }).dispatchEvent = dispatchEvent; + (globalThis as { location?: unknown }).location = { assign }; + + const { sendOsNotification, WEB_NOTIFICATION_CLICK_EVENT } = + await loadModuleForPlatform("web"); + + const sent = await sendOsNotification({ + title: "Agent finished", + body: "Done", + data: { serverId: "srv-1", agentId: "agent-1" }, + }); + + expect(sent).toBe(true); + expect(created).toHaveLength(1); + + const clicked = created[0]; + expect(clicked.onclick).toBeTypeOf("function"); + clicked.onclick?.({} as Event); + + expect(dispatchEvent).toHaveBeenCalledTimes(1); + const event = dispatchEvent.mock.calls[0]?.[0] as unknown as { + type?: string; + detail?: { data?: Record }; + }; + expect(event?.type).toBe(WEB_NOTIFICATION_CLICK_EVENT); + expect(event?.detail).toEqual({ + data: { serverId: "srv-1", agentId: "agent-1" }, + }); + expect(assign).not.toHaveBeenCalled(); + }); + + it("falls back to route navigation when no listener handles the click", async () => { + const created: MockNotificationInstance[] = []; + + class MockNotification implements MockNotificationInstance { + static permission = "granted"; + static requestPermission = vi.fn(async () => "granted"); + onclick: ((event: Event) => void) | null = null; + close = vi.fn(); + + constructor(public title: string, public options?: MockNotificationOptions) { + created.push(this); + } + } + + const dispatchEvent = vi.fn((event: unknown) => { + void event; + return true; + }); + const assign = vi.fn(); + + (globalThis as { Notification?: unknown }).Notification = MockNotification; + (globalThis as { dispatchEvent?: unknown }).dispatchEvent = dispatchEvent; + (globalThis as { location?: unknown }).location = { assign }; + + const { sendOsNotification } = await loadModuleForPlatform("web"); + + await sendOsNotification({ + title: "Agent finished", + data: { serverId: "srv with space", agentId: "agent/1" }, + }); + + const clicked = created[0]; + expect(clicked.onclick).toBeTypeOf("function"); + clicked.onclick?.({} as Event); + + expect(assign).toHaveBeenCalledWith("/agent/srv%20with%20space/agent%2F1"); + }); +}); diff --git a/packages/app/src/utils/os-notifications.ts b/packages/app/src/utils/os-notifications.ts index 9a73265ac..8c3629568 100644 --- a/packages/app/src/utils/os-notifications.ts +++ b/packages/app/src/utils/os-notifications.ts @@ -1,4 +1,5 @@ import { Platform } from "react-native"; +import { buildNotificationRoute } from "./notification-routing"; type OsNotificationPayload = { title: string; @@ -6,6 +7,18 @@ type OsNotificationPayload = { data?: Record; }; +export type WebNotificationClickDetail = { + data?: Record; +}; + +type WebNotificationInstance = { + onclick?: ((event: Event) => void) | null; + addEventListener?: (type: string, listener: (event: Event) => void) => void; + close?: () => void; +}; + +export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click"; + let permissionRequest: Promise | null = null; function getWebNotificationConstructor(): { @@ -41,6 +54,71 @@ async function ensureNotificationPermission(): Promise { return result; } +function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boolean { + const dispatch = (globalThis as { dispatchEvent?: (event: Event) => boolean }).dispatchEvent; + const CustomEventConstructor = (globalThis as { CustomEvent?: typeof CustomEvent }) + .CustomEvent; + + if (typeof dispatch !== "function" || !CustomEventConstructor) { + return false; + } + + const event = new CustomEventConstructor( + WEB_NOTIFICATION_CLICK_EVENT, + { + detail, + cancelable: true, + } + ); + return dispatch(event) === false; +} + +function fallbackNavigateToNotificationTarget( + data: Record | undefined +): void { + const route = buildNotificationRoute(data); + const location = (globalThis as { location?: { assign?: (url: string) => void; href?: string } }) + .location; + if (!location) { + return; + } + if (typeof location.assign === "function") { + location.assign(route); + return; + } + if (typeof location.href === "string") { + location.href = route; + } +} + +function attachWebClickHandler( + notification: WebNotificationInstance, + data: Record | undefined +): void { + const onClick = () => { + const focus = (globalThis as { focus?: () => void }).focus; + if (typeof focus === "function") { + focus(); + } + + const handledByApp = dispatchWebNotificationClick({ data }); + if (!handledByApp) { + fallbackNavigateToNotificationTarget(data); + } + + if (typeof notification.close === "function") { + notification.close(); + } + }; + + if (typeof notification.addEventListener === "function") { + notification.addEventListener("click", onClick); + return; + } + + notification.onclick = onClick; +} + export async function sendOsNotification( payload: OsNotificationPayload ): Promise { @@ -57,9 +135,10 @@ export async function sendOsNotification( if (!granted) { return false; } - new NotificationConstructor(payload.title, { + const notification = new NotificationConstructor(payload.title, { body: payload.body, data: payload.data, - }); + }) as WebNotificationInstance; + attachWebClickHandler(notification, payload.data); return true; } diff --git a/packages/cli/src/commands/daemon/start.ts b/packages/cli/src/commands/daemon/start.ts index 184c3e80e..27652ce10 100644 --- a/packages/cli/src/commands/daemon/start.ts +++ b/packages/cli/src/commands/daemon/start.ts @@ -1,5 +1,8 @@ import { Command } from 'commander' import chalk from 'chalk' +import { spawn } from 'node:child_process' +import { closeSync, openSync, readFileSync } from 'node:fs' +import path from 'node:path' import { createPaseoDaemon, loadConfig, @@ -11,16 +14,34 @@ import type { CliConfigOverrides } from '@getpaseo/server' interface StartOptions { port?: string + listen?: string home?: string foreground?: boolean - noRelay?: boolean - noMcp?: boolean + relay?: boolean + mcp?: boolean allowedHosts?: string } +interface DetachedStartupReady { + exitedEarly: false +} + +interface DetachedStartupExited { + exitedEarly: true + code: number | null + signal: NodeJS.Signals | null + error?: Error +} + +type DetachedStartupResult = DetachedStartupReady | DetachedStartupExited + +const DETACHED_STARTUP_GRACE_MS = 1200 +const DAEMON_LOG_FILENAME = 'daemon.log' + export function startCommand(): Command { return new Command('start') .description('Start the Paseo daemon') + .option('--listen ', 'Listen target (host:port, port, or unix socket path)') .option('--port ', 'Port to listen on (default: 6767)') .option('--home ', 'Paseo home directory (default: ~/.paseo)') .option('--foreground', 'Run in foreground (don\'t daemonize)') @@ -35,7 +56,133 @@ export function startCommand(): Command { }) } +function buildForegroundArgs(options: StartOptions): string[] { + const args = ['daemon', 'start', '--foreground'] + + if (options.listen) { + args.push('--listen', options.listen) + } else if (options.port) { + args.push('--port', options.port) + } + + if (options.home) { + args.push('--home', options.home) + } + + if (options.relay === false) { + args.push('--no-relay') + } + + if (options.mcp === false) { + args.push('--no-mcp') + } + + if (options.allowedHosts) { + args.push('--allowed-hosts', options.allowedHosts) + } + + return args +} + +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 + } +} + +async function runDetachedStart(options: StartOptions): Promise { + const childEnv: NodeJS.ProcessEnv = { ...process.env } + if (options.home) { + childEnv.PASEO_HOME = options.home + } + + const paseoHome = resolvePaseoHome(childEnv) + const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME) + + const cliEntry = process.argv[1] + if (!cliEntry) { + throw new Error('Unable to determine CLI entrypoint for detached daemon start') + } + + const logFd = openSync(logPath, 'a') + + try { + const child = spawn( + process.execPath, + [...process.execArgv, cliEntry, ...buildForegroundArgs(options)], + { + detached: true, + env: childEnv, + stdio: ['ignore', logFd, logFd], + } + ) + + child.unref() + + const startup = await new Promise((resolve) => { + let settled = false + + const finish = (value: DetachedStartupResult) => { + if (settled) return + settled = true + resolve(value) + } + + const timer = setTimeout(() => finish({ exitedEarly: false }), DETACHED_STARTUP_GRACE_MS) + + child.once('error', (error) => { + clearTimeout(timer) + finish({ exitedEarly: true, code: null, signal: null, error }) + }) + + child.once('exit', (code, signal) => { + clearTimeout(timer) + finish({ exitedEarly: true, 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') + ) + } + + console.log(chalk.green(`Daemon starting in background (PID ${child.pid ?? 'unknown'}).`)) + console.log(chalk.dim(`Logs: ${logPath}`)) + } finally { + closeSync(logFd) + } +} + async function runStart(options: StartOptions): Promise { + if (options.listen && options.port) { + console.error(chalk.red('Cannot use --listen and --port together')) + process.exit(1) + } + + if (!options.foreground) { + try { + await runDetachedStart(options) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error(chalk.red(message)) + process.exit(1) + } + return + } + // Set environment variables based on CLI options if (options.home) { process.env.PASEO_HOME = options.home @@ -46,11 +193,13 @@ async function runStart(options: StartOptions): Promise { let config: ReturnType const cliOverrides: CliConfigOverrides = {} - if (options.port) { + if (options.listen) { + cliOverrides.listen = options.listen + } else if (options.port) { cliOverrides.listen = `127.0.0.1:${options.port}` } - if (options.noRelay) { + if (options.relay === false) { cliOverrides.relayEnabled = false } @@ -62,7 +211,7 @@ async function runStart(options: StartOptions): Promise { : raw.split(',').map(h => h.trim()).filter(Boolean) } - if (options.noMcp) { + if (options.mcp === false) { cliOverrides.mcpEnabled = false } @@ -77,14 +226,15 @@ async function runStart(options: StartOptions): Promise { process.exit(1) } - // For now, only foreground mode is supported - // TODO: Implement daemonization in a future phase - if (!options.foreground) { - console.log(chalk.yellow('Note: Background daemon mode not yet implemented. Running in foreground.')) + let daemon: Awaited> + try { + daemon = await createPaseoDaemon(config, logger) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error(chalk.red(`Failed to initialize daemon: ${message}`)) + process.exit(1) } - const daemon = await createPaseoDaemon(config, logger) - // Handle graceful shutdown let shuttingDown = false const handleShutdown = async (signal: string) => { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.e2e.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.e2e.test.ts index 0728b7e62..faabb32a2 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.e2e.test.ts @@ -6,9 +6,10 @@ import { mkdtempSync } from "node:fs"; import { CodexAppServerAgentClient } from "./codex-app-server-agent.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { agentConfigs } from "../../daemon-e2e/agent-configs.js"; -const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; -const CODEX_TEST_THINKING_OPTION_ID = "low"; +const CODEX_TEST_MODEL = agentConfigs.codex.model; +const CODEX_TEST_THINKING_OPTION_ID = agentConfigs.codex.thinkingOptionId; function isCodexInstalled(): boolean { try { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 5f1edd2ef..c0f645c73 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -5,18 +5,20 @@ import os from "node:os"; import path from "node:path"; import { + __codexAppServerInternals, CodexAppServerAgentClient, codexAppServerTurnInputFromPrompt, } from "./codex-app-server-agent.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { agentConfigs } from "../../daemon-e2e/agent-configs.js"; import type { AgentPermissionRequest, AgentPromptContentBlock, AgentTimelineItem, } from "../agent-sdk-types.js"; -const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; -const CODEX_TEST_THINKING_OPTION_ID = "low"; +const CODEX_TEST_MODEL = agentConfigs.codex.model; +const CODEX_TEST_THINKING_OPTION_ID = agentConfigs.codex.thinkingOptionId; const ONE_BY_ONE_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X1r0AAAAASUVORK5CYII="; @@ -87,6 +89,29 @@ function hasApplyPatchFile(item: AgentTimelineItem, fileName: string): boolean { return inInput || inOutput || inDiff || inputPath === fileName || outputPath === fileName; } +function buildStrictApplyPatchPrompt( + patch: string, + completionToken: string, + options?: { includePermissionStep?: boolean } +): string { + const lines = [ + "You are running an automated integration test.", + "Required behavior:", + "- Call the apply_patch tool exactly once using the patch below.", + "- Do not call shell, Bash, exec_command, write_file, or any other tool.", + "- Do not ask for confirmation in text or via any tool call.", + ]; + if (options?.includePermissionStep) { + lines.push( + "- If permission is required, wait for approval and then continue with the same apply_patch call." + ); + } + lines.push("Patch to apply exactly:"); + lines.push(patch); + lines.push(`After successful apply_patch completion, reply exactly ${completionToken}.`); + return lines.join("\n"); +} + async function waitForFileToContainText( filePath: string, expectedText: string, @@ -175,6 +200,50 @@ describe("Codex app-server provider (integration)", () => { } }); + test("maps patch notifications with array-style changes and alias diff keys", () => { + const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({ + callId: "patch-array-alias", + changes: [ + { + path: "/tmp/repo/src/array-alias.ts", + kind: "modify", + unified_diff: "@@\n-old\n+new\n", + }, + ], + cwd: "/tmp/repo", + running: false, + }); + + expect(item.detail.type).toBe("edit"); + if (item.detail.type === "edit") { + expect(item.detail.filePath).toBe("src/array-alias.ts"); + expect(item.detail.unifiedDiff).toContain("-old"); + expect(item.detail.unifiedDiff).toContain("+new"); + expect(item.detail.newString).toBeUndefined(); + } + }); + + test("maps patch notifications with object-style single change payloads", () => { + const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({ + callId: "patch-object-single", + changes: { + path: "/tmp/repo/src/object-single.ts", + kind: "modify", + patch: "@@\n-before\n+after\n", + }, + cwd: "/tmp/repo", + running: false, + }); + + expect(item.detail.type).toBe("edit"); + if (item.detail.type === "edit") { + expect(item.detail.filePath).toBe("src/object-single.ts"); + expect(item.detail.unifiedDiff).toContain("-before"); + expect(item.detail.unifiedDiff).toContain("+after"); + expect(item.detail.newString).toBeUndefined(); + } + }); + test.runIf(isCodexInstalled())("listModels returns live Codex models", async () => { const client = new CodexAppServerAgentClient(logger); const models = await client.listModels(); @@ -209,19 +278,28 @@ describe("Codex app-server provider (integration)", () => { const codexHome = tmpCwd("codex-home-defaults-"); const prevCodexHome = process.env.CODEX_HOME; process.env.CODEX_HOME = codexHome; - writeFileSync( - path.join(codexHome, "config.toml"), - [ - 'model = "gpt-5.3-codex"', - 'model_reasoning_effort = "xhigh"', - ].join("\n"), - "utf8" - ); try { const client = new CodexAppServerAgentClient(logger); + const baselineModels = await client.listModels(); + const baselineDefaultModel = + baselineModels.find((model) => model.isDefault) ?? baselineModels[0]; + expect(baselineDefaultModel).toBeDefined(); + const configuredModelId = baselineDefaultModel?.id; + expect(typeof configuredModelId).toBe("string"); + expect((configuredModelId ?? "").length).toBeGreaterThan(0); + + writeFileSync( + path.join(codexHome, "config.toml"), + [ + `model = "${configuredModelId}"`, + 'model_reasoning_effort = "xhigh"', + ].join("\n"), + "utf8" + ); + const models = await client.listModels(); - const configuredModel = models.find((model) => model.id === "gpt-5.3-codex"); + const configuredModel = models.find((model) => model.id === configuredModelId); expect(configuredModel).toBeDefined(); expect(configuredModel?.isDefault).toBe(true); expect(configuredModel?.defaultThinkingOptionId).toBe("xhigh"); @@ -703,14 +781,9 @@ describe("Codex app-server provider (integration)", () => { "*** End Patch", ].join("\n"); const patchEvents = session.stream( - [ - "Use the apply_patch tool and nothing else.", - "Do not use the shell tool for file changes.", - "Do not respond with any message until the apply_patch tool completes.", - "Apply the following patch exactly:", - patch, - "After it completes, reply PATCH_DONE.", - ].join("\n") + buildStrictApplyPatchPrompt(patch, "PATCH_DONE", { + includePermissionStep: true, + }) ); for await (const event of patchEvents) { @@ -801,13 +874,9 @@ describe("Codex app-server provider (integration)", () => { "*** End Patch", ].join("\n"); const events = session.stream( - [ - "Use the apply_patch tool and nothing else.", - "Do not use shell or any other file-edit tool.", - "Apply this patch exactly:", - patch, - "After tool completion, reply PATCH_DONE.", - ].join("\n") + buildStrictApplyPatchPrompt(patch, "PATCH_DONE", { + includePermissionStep: true, + }) ); for await (const event of events) { @@ -1252,14 +1321,9 @@ describe("Codex app-server provider (integration)", () => { "*** End Patch", ].join("\n"); const events = session.stream( - [ - "Use the apply_patch tool and nothing else.", - "If you need approval before writing files, request approval first.", - "Do not respond with any message until the apply_patch tool completes.", - "Apply the following patch exactly:", - patch, - "After approval, reply FILE_DONE.", - ].join("\n") + buildStrictApplyPatchPrompt(patch, "FILE_DONE", { + includePermissionStep: true, + }) ); let failure: string | null = null; @@ -1298,7 +1362,19 @@ describe("Codex app-server provider (integration)", () => { expect(sawPermissionResolved).toBe(true); } const sawPatch = timelineItems.some((item) => hasApplyPatchFile(item, "approval-test.txt")); - expect(sawPatch).toBe(true); + if (!sawPatch) { + const toolCalls = timelineItems + .filter((item): item is Extract => item.type === "tool_call") + .map((item) => ({ + name: item.name, + status: item.status, + callId: item.callId, + detail: item.detail, + })); + throw new Error( + `Did not observe apply_patch timeline detail for approval-test.txt. Tool calls: ${JSON.stringify(toolCalls)}` + ); + } const text = await waitForFileToContainText(targetPath, "ok", { timeoutMs: 10000 }); if (!text) { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 53e00f0e4..8a93092de 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -708,6 +708,27 @@ type CodexPatchFileChange = { content?: string; }; +function extractPatchLikeText(value: unknown): string | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const record = value as Record; + const candidates = [ + record.diff, + record.patch, + record.unified_diff, + record.unifiedDiff, + record.content, + record.newString, + ]; + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.length > 0) { + return candidate; + } + } + return undefined; +} + function normalizeCodexThreadItemType(rawType: string | undefined): string | undefined { if (!rawType) { return rawType; @@ -780,20 +801,60 @@ function parseCodexPatchChanges(changes: unknown): CodexPatchFileChange[] { if (!changes || typeof changes !== "object") { return []; } - return Object.entries(changes as Record) + + if (Array.isArray(changes)) { + return changes + .map((entry): CodexPatchFileChange | null => { + if (!entry || typeof entry !== "object") { + return null; + } + const record = entry as Record; + const pathValue = + typeof record.path === "string" && record.path.trim().length > 0 + ? record.path.trim() + : ""; + if (!pathValue) { + return null; + } + return { + path: pathValue, + kind: + (typeof record.kind === "string" && record.kind) || + (typeof record.type === "string" && record.type) || + undefined, + content: extractPatchLikeText(record), + }; + }) + .filter((entry): entry is CodexPatchFileChange => entry !== null); + } + + const recordChanges = changes as Record; + if (typeof recordChanges.path === "string" && recordChanges.path.trim().length > 0) { + return [ + { + path: recordChanges.path.trim(), + kind: + (typeof recordChanges.kind === "string" && recordChanges.kind) || + (typeof recordChanges.type === "string" && recordChanges.type) || + undefined, + content: extractPatchLikeText(recordChanges), + }, + ]; + } + + return Object.entries(recordChanges) .map(([path, value]): CodexPatchFileChange | null => { const normalizedPath = path.trim(); if (!normalizedPath) { return null; } - const parsed = - value && typeof value === "object" - ? (value as { type?: unknown; content?: unknown }) - : null; return { path: normalizedPath, - kind: typeof parsed?.type === "string" ? parsed.type : undefined, - content: typeof parsed?.content === "string" ? parsed.content : undefined, + kind: + value && typeof value === "object" && typeof (value as { type?: unknown }).type === "string" + ? ((value as { type?: string }).type ?? undefined) + : undefined, + content: extractPatchLikeText(value), }; }) .filter((entry): entry is CodexPatchFileChange => entry !== null); @@ -822,6 +883,46 @@ function toRunningToolCall(item: ToolCallTimelineItem): ToolCallTimelineItem { }; } +function isEditToolCallWithoutContent(item: ToolCallTimelineItem): boolean { + if (item.type !== "tool_call") { + return false; + } + if (item.detail.type !== "edit") { + return false; + } + const hasDiff = + typeof item.detail.unifiedDiff === "string" && + item.detail.unifiedDiff.trim().length > 0; + const hasNewString = + typeof item.detail.newString === "string" && + item.detail.newString.trim().length > 0; + return !hasDiff && !hasNewString; +} + +function decodeCodexOutputDeltaChunk(chunk: string): string { + const trimmed = chunk.trim(); + if (trimmed.length === 0) { + return chunk; + } + if (!/^[A-Za-z0-9+/=]+$/.test(trimmed) || trimmed.length % 4 !== 0) { + return chunk; + } + + try { + const decoded = Buffer.from(trimmed, "base64").toString("utf8"); + if (decoded.length === 0) { + return chunk; + } + const normalizedInput = trimmed.replace(/=+$/, ""); + const normalizedRoundTrip = Buffer.from(decoded, "utf8") + .toString("base64") + .replace(/=+$/, ""); + return normalizedRoundTrip === normalizedInput ? decoded : chunk; + } catch { + return chunk; + } +} + function mapCodexExecNotificationToToolCall(params: { callId?: string | null; command: unknown; @@ -881,10 +982,10 @@ function mapCodexPatchNotificationToToolCall(params: { }): ToolCallTimelineItem { const files = parseCodexPatchChanges(params.changes); const firstPath = files[0]?.path; - const firstContent = files + const firstPatchText = files .map((file) => file.content?.trim()) .find((value): value is string => typeof value === "string" && value.length > 0); - const patchText = params.latestUnifiedDiff?.trim() || firstContent; + const patchText = params.latestUnifiedDiff?.trim() || firstPatchText; const patchFields = codexPatchTextFields(patchText); const mapped = mapCodexRolloutToolCall({ callId: params.callId ?? null, @@ -907,7 +1008,7 @@ function mapCodexPatchNotificationToToolCall(params: { files: files.map((file) => ({ path: file.path, ...(file.kind ? { kind: file.kind } : {}), - ...patchFields, + ...codexPatchTextFields(file.content ?? patchText), })), } : {}), @@ -1138,6 +1239,18 @@ const CodexEventExecCommandEndNotificationSchema = z.object({ .passthrough(), }).passthrough(); +const CodexEventExecCommandOutputDeltaNotificationSchema = z.object({ + msg: z + .object({ + type: z.literal("exec_command_output_delta"), + call_id: z.string().optional(), + stream: z.string().optional(), + chunk: z.string().optional(), + delta: z.string().optional(), + }) + .passthrough(), +}).passthrough(); + const CodexEventPatchApplyBeginNotificationSchema = z.object({ msg: z .object({ @@ -1161,6 +1274,12 @@ const CodexEventPatchApplyEndNotificationSchema = z.object({ .passthrough(), }).passthrough(); +const ItemFileChangeOutputDeltaNotificationSchema = z.object({ + itemId: z.string(), + delta: z.string().optional(), + chunk: z.string().optional(), +}).passthrough(); + const CodexEventTurnDiffNotificationSchema = z.object({ msg: z .object({ @@ -1206,6 +1325,12 @@ type ParsedCodexNotification = success: boolean | null; stderr: string | null; } + | { + kind: "exec_command_output_delta"; + callId: string | null; + stream: string | null; + chunk: string | null; + } | { kind: "patch_apply_started"; callId: string | null; @@ -1219,6 +1344,11 @@ type ParsedCodexNotification = stderr: string | null; success: boolean | null; } + | { + kind: "file_change_output_delta"; + itemId: string; + delta: string | null; + } | { kind: "invalid_payload"; method: string; params: unknown } | { kind: "unknown_method"; method: string; params: unknown }; @@ -1370,6 +1500,23 @@ const CodexNotificationSchema = z.union([ z.object({ method: z.literal("codex/event/exec_command_end"), params: z.unknown() }).transform( ({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params }) ), + z.object({ + method: z.literal("codex/event/exec_command_output_delta"), + params: CodexEventExecCommandOutputDeltaNotificationSchema, + }).transform( + ({ params }): ParsedCodexNotification => ({ + kind: "exec_command_output_delta", + callId: params.msg.call_id ?? null, + stream: params.msg.stream ?? null, + chunk: params.msg.chunk ?? params.msg.delta ?? null, + }) + ), + z.object({ + method: z.literal("codex/event/exec_command_output_delta"), + params: z.unknown(), + }).transform( + ({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params }) + ), z.object({ method: z.literal("codex/event/patch_apply_begin"), params: CodexEventPatchApplyBeginNotificationSchema, @@ -1399,6 +1546,19 @@ const CodexNotificationSchema = z.union([ z.object({ method: z.literal("codex/event/patch_apply_end"), params: z.unknown() }).transform( ({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params }) ), + z.object({ + method: z.literal("item/fileChange/outputDelta"), + params: ItemFileChangeOutputDeltaNotificationSchema, + }).transform( + ({ params }): ParsedCodexNotification => ({ + kind: "file_change_output_delta", + itemId: params.itemId, + delta: params.delta ?? params.chunk ?? null, + }) + ), + z.object({ method: z.literal("item/fileChange/outputDelta"), params: z.unknown() }).transform( + ({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params }) + ), z.object({ method: z.literal("codex/event/turn_diff"), params: CodexEventTurnDiffNotificationSchema, @@ -1539,6 +1699,10 @@ export async function codexAppServerTurnInputFromPrompt( return output; } +export const __codexAppServerInternals = { + mapCodexPatchNotificationToToolCall, +}; + class CodexAppServerAgentSession implements AgentSession { readonly provider = CODEX_PROVIDER; readonly capabilities = CODEX_APP_SERVER_CAPABILITIES; @@ -1565,10 +1729,13 @@ class CodexAppServerAgentSession implements AgentSession { private resolvedPermissionRequests = new Set(); private pendingAgentMessages = new Map(); private pendingReasoning = new Map(); + private pendingCommandOutputDeltas = new Map(); + private pendingFileChangeOutputDeltas = new Map(); private emittedItemStartedIds = new Set(); private emittedItemCompletedIds = new Set(); private warnedUnknownNotificationMethods = new Set(); private warnedInvalidNotificationPayloads = new Set(); + private warnedIncompleteEditToolCallIds = new Set(); private latestTurnUnifiedDiff: string | null = null; private latestUsage: AgentUsage | undefined; private connected = false; @@ -1757,6 +1924,9 @@ class CodexAppServerAgentSession implements AgentSession { cwd: this.config.cwd ?? null, }); if (timelineItem) { + if (timelineItem.type === "tool_call") { + this.warnOnIncompleteEditToolCall(timelineItem, "thread_read", item); + } threadTimeline.push(timelineItem); } } @@ -2255,6 +2425,9 @@ class CodexAppServerAgentSession implements AgentSession { this.latestTurnUnifiedDiff = null; this.emittedItemStartedIds.clear(); this.emittedItemCompletedIds.clear(); + this.pendingCommandOutputDeltas.clear(); + this.pendingFileChangeOutputDeltas.clear(); + this.warnedIncompleteEditToolCallIds.clear(); this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER }); return; } @@ -2274,6 +2447,9 @@ class CodexAppServerAgentSession implements AgentSession { this.latestTurnUnifiedDiff = null; this.emittedItemStartedIds.clear(); this.emittedItemCompletedIds.clear(); + this.pendingCommandOutputDeltas.clear(); + this.pendingFileChangeOutputDeltas.clear(); + this.warnedIncompleteEditToolCallIds.clear(); this.eventQueue?.end(); return; } @@ -2321,7 +2497,25 @@ class CodexAppServerAgentSession implements AgentSession { return; } + if (parsed.kind === "exec_command_output_delta") { + this.appendOutputDeltaChunk( + this.pendingCommandOutputDeltas, + parsed.callId, + parsed.chunk, + { decodeBase64: true } + ); + return; + } + + if (parsed.kind === "file_change_output_delta") { + this.appendOutputDeltaChunk(this.pendingFileChangeOutputDeltas, parsed.itemId, parsed.delta); + return; + } + if (parsed.kind === "exec_command_started") { + if (parsed.callId) { + this.pendingCommandOutputDeltas.delete(parsed.callId); + } const timelineItem = mapCodexExecNotificationToToolCall({ callId: parsed.callId, command: parsed.command, @@ -2335,11 +2529,15 @@ class CodexAppServerAgentSession implements AgentSession { } if (parsed.kind === "exec_command_completed") { + const bufferedOutput = this.consumeOutputDelta( + this.pendingCommandOutputDeltas, + parsed.callId + ); const timelineItem = mapCodexExecNotificationToToolCall({ callId: parsed.callId, command: parsed.command, cwd: parsed.cwd ?? this.config.cwd ?? null, - output: parsed.output, + output: parsed.output ?? bufferedOutput, exitCode: parsed.exitCode, success: parsed.success, stderr: parsed.stderr, @@ -2352,6 +2550,9 @@ class CodexAppServerAgentSession implements AgentSession { } if (parsed.kind === "patch_apply_started") { + if (parsed.callId) { + this.pendingFileChangeOutputDeltas.delete(parsed.callId); + } const timelineItem = mapCodexPatchNotificationToToolCall({ callId: parsed.callId, changes: parsed.changes, @@ -2359,21 +2560,34 @@ class CodexAppServerAgentSession implements AgentSession { latestUnifiedDiff: this.latestTurnUnifiedDiff, running: true, }); + this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_started", { + callId: parsed.callId, + changes: parsed.changes, + }); this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); return; } if (parsed.kind === "patch_apply_completed") { + const bufferedOutput = this.consumeOutputDelta( + this.pendingFileChangeOutputDeltas, + parsed.callId + ); const timelineItem = mapCodexPatchNotificationToToolCall({ callId: parsed.callId, changes: parsed.changes, cwd: this.config.cwd ?? null, - stdout: parsed.stdout, + stdout: parsed.stdout ?? bufferedOutput, stderr: parsed.stderr, success: parsed.success, latestUnifiedDiff: this.latestTurnUnifiedDiff, running: false, }); + this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_completed", { + callId: parsed.callId, + changes: parsed.changes, + stdout: parsed.stdout, + }); this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); return; } @@ -2406,10 +2620,15 @@ class CodexAppServerAgentSession implements AgentSession { timelineItem.text = buffered.join(""); } } + if (timelineItem.type === "tool_call") { + this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item); + } this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); if (itemId) { this.emittedItemCompletedIds.add(itemId); this.emittedItemStartedIds.delete(itemId); + this.pendingCommandOutputDeltas.delete(itemId); + this.pendingFileChangeOutputDeltas.delete(itemId); } } return; @@ -2428,9 +2647,12 @@ class CodexAppServerAgentSession implements AgentSession { if (itemId && this.emittedItemStartedIds.has(itemId)) { return; } + this.warnOnIncompleteEditToolCall(timelineItem, "item_started", parsed.item); this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); if (itemId) { this.emittedItemStartedIds.add(itemId); + this.pendingCommandOutputDeltas.delete(itemId); + this.pendingFileChangeOutputDeltas.delete(itemId); } } return; @@ -2464,6 +2686,62 @@ class CodexAppServerAgentSession implements AgentSession { ); } + private appendOutputDeltaChunk( + store: Map, + id: string | null | undefined, + chunk: string | null | undefined, + options?: { decodeBase64?: boolean } + ): void { + if (!id || !chunk) { + return; + } + const normalized = options?.decodeBase64 ? decodeCodexOutputDeltaChunk(chunk) : chunk; + if (!normalized.length) { + return; + } + const prev = store.get(id) ?? []; + prev.push(normalized); + store.set(id, prev); + } + + private consumeOutputDelta(store: Map, id: string | null | undefined): string | null { + if (!id) { + return null; + } + const buffered = store.get(id); + if (!buffered || buffered.length === 0) { + return null; + } + store.delete(id); + return buffered.join(""); + } + + private warnOnIncompleteEditToolCall( + item: ToolCallTimelineItem, + source: string, + payload: unknown + ): void { + if (!isEditToolCallWithoutContent(item)) { + return; + } + const warnKey = `${source}:${item.callId}`; + if (this.warnedIncompleteEditToolCallIds.has(warnKey)) { + return; + } + this.warnedIncompleteEditToolCallIds.add(warnKey); + this.logger.warn( + { + source, + callId: item.callId, + status: item.status, + name: item.name, + detail: item.detail, + payload, + }, + "Codex edit tool call is missing diff/content fields" + ); + } + private handleCommandApprovalRequest(params: unknown): Promise { const parsed = params as { itemId: string; diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts index cea63ed50..5ce23a1d3 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts @@ -411,4 +411,56 @@ describe("codex tool-call mapper", () => { expect(item.detail.newString).toBeUndefined(); } }); + + it("maps fileChange patch alias fields into edit unified diff detail", () => { + const item = mapCodexToolCallFromThreadItem( + { + type: "fileChange", + id: "codex-file-change-patch-alias", + status: "completed", + changes: [ + { + path: "/tmp/repo/src/from-patch-alias.ts", + kind: "modify", + patch: "@@\n-oldAlias\n+newAlias\n", + }, + ], + }, + { cwd: "/tmp/repo" } + ); + + expect(item?.detail?.type).toBe("edit"); + if (item?.detail?.type === "edit") { + expect(item.detail.filePath).toBe("src/from-patch-alias.ts"); + expect(item.detail.unifiedDiff).toContain("-oldAlias"); + expect(item.detail.unifiedDiff).toContain("+newAlias"); + expect(item.detail.newString).toBeUndefined(); + } + }); + + it("maps fileChange unifiedDiff alias fields into edit unified diff detail", () => { + const item = mapCodexToolCallFromThreadItem( + { + type: "fileChange", + id: "codex-file-change-unified-diff-alias", + status: "completed", + changes: [ + { + path: "/tmp/repo/src/from-unified-diff-alias.ts", + kind: "modify", + unified_diff: "@@\n-beforeAlias\n+afterAlias\n", + }, + ], + }, + { cwd: "/tmp/repo" } + ); + + expect(item?.detail?.type).toBe("edit"); + if (item?.detail?.type === "edit") { + expect(item.detail.filePath).toBe("src/from-unified-diff-alias.ts"); + expect(item.detail.unifiedDiff).toContain("-beforeAlias"); + expect(item.detail.unifiedDiff).toContain("+afterAlias"); + expect(item.detail.newString).toBeUndefined(); + } + }); }); diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts index bcdfd6a89..9e7cdcab6 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts @@ -59,7 +59,11 @@ const CodexFileChangeItemSchema = z path: z.string().optional(), kind: z.string().optional(), diff: z.string().optional(), + patch: z.string().optional(), + unified_diff: z.string().optional(), + unifiedDiff: z.string().optional(), content: z.string().optional(), + newString: z.string().optional(), }) .passthrough() ) @@ -305,6 +309,15 @@ function asPatchOrContentFields(text: string | undefined): { patch?: string; con return { content: text }; } +function pickFirstPatchLikeString(values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === "string" && value.length > 0) { + return value; + } + } + return undefined; +} + function removePatchLikeFields(input: Record): Record { const { patch: _patch, @@ -560,7 +573,14 @@ function mapFileChangeItem( return { path: pathValue, kind: change.kind, - diff: change.diff ?? change.content, + diff: pickFirstPatchLikeString([ + change.diff, + change.patch, + change.unified_diff, + change.unifiedDiff, + change.content, + change.newString, + ]), }; }) .filter((change) => change.path !== undefined); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 37f57a287..f52d1b805 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -64,7 +64,7 @@ import type { AgentClient, AgentProvider, } from "./agent/agent-sdk-types.js"; -import { acquirePidLock, releasePidLock } from "./pid-lock.js"; +import { acquirePidLock, releasePidLock, isLocked, PidLockError } from "./pid-lock.js"; import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js"; import { createVoiceMcpSocketBridgeManager, @@ -136,6 +136,17 @@ export async function createPaseoDaemon( rootLogger: Logger ): Promise { const logger = rootLogger.child({ module: "bootstrap" }); + + // Fail fast before expensive bootstrap (speech/runtime/model checks) if another daemon is active. + const lockState = await isLocked(config.paseoHome); + if (lockState.locked) { + const existingLock = lockState.info; + throw new PidLockError( + `Another Paseo daemon is already running (PID ${existingLock?.pid ?? "unknown"}, started ${existingLock?.startedAt ?? "unknown"})`, + existingLock + ); + } + const serverId = getOrCreateServerId(config.paseoHome, { logger }); const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.paseoHome, logger); let relayTransport: RelayTransportController | null = null; diff --git a/packages/server/src/server/index.ts b/packages/server/src/server/index.ts index 7737855f8..80d687c64 100644 --- a/packages/server/src/server/index.ts +++ b/packages/server/src/server/index.ts @@ -34,7 +34,16 @@ async function main() { config.mcpEnabled = false; } - const daemon = await createPaseoDaemon(config, logger); + let daemon; + try { + daemon = await createPaseoDaemon(config, logger); + } catch (err) { + if (err instanceof PidLockError) { + logger.error({ pid: err.existingLock?.pid }, err.message); + process.exit(1); + } + throw err; + } try { await daemon.start(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 3291e49eb..96082c77d 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -643,7 +643,11 @@ export class VoiceAssistantWebSocketServer { void this.pushService.sendPush(tokens, { title, body, - data: { agentId: params.agentId, reason: params.reason }, + data: { + serverId: this.serverId, + agentId: params.agentId, + reason: params.reason, + }, }); } }