diff --git a/docs/rpc-namespacing.md b/docs/rpc-namespacing.md index cf1b9e1d9..cd949ffdb 100644 --- a/docs/rpc-namespacing.md +++ b/docs/rpc-namespacing.md @@ -11,7 +11,7 @@ The namespace reads left to right: - Domain: `checkout` - Provider or subsystem: `github` -- Operation: `set_auto_merge` +- Operation: `set_auto_merge`; this segment is a verb, not a noun. If you would name an RPC `noun.request`, name it `get_noun.request` instead. - Direction: `request` or `response` Use dots, not slashes. Dots are protocol namespaces; slashes imply paths or transport routing. diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index 943752e4e..f153df13f 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -392,9 +392,15 @@ export function resolveLocalDaemonState(options: { home?: string } = {}): LocalD 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 }); diff --git a/packages/cli/src/commands/daemon/pair.ts b/packages/cli/src/commands/daemon/pair.ts index 14aced0cc..cd238fce7 100644 --- a/packages/cli/src/commands/daemon/pair.ts +++ b/packages/cli/src/commands/daemon/pair.ts @@ -1,6 +1,8 @@ import { Command } from "commander"; import chalk from "chalk"; import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from "@getpaseo/server"; +import { tryConnectToDaemon } from "../../utils/client.js"; +import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js"; import { addJsonOption } from "../../utils/command-options.js"; interface PairOptions { @@ -22,6 +24,35 @@ export async function runPairCommand(options: PairOptions): Promise { } const paseoHome = resolvePaseoHome(); + const state = resolveLocalDaemonState({ home: paseoHome }); + const host = resolveTcpHostFromListen(state.listen); + + // Try to get the pairing offer from the running daemon first. + if (host) { + const client = await tryConnectToDaemon({ host, timeout: 1500 }); + if (client) { + const supportsDaemonStatusRpc = + client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true; + if (supportsDaemonStatusRpc) { + try { + const offer = await client.getDaemonPairingOffer(); + await client.close().catch(() => {}); + outputPairingResult( + { relayEnabled: offer.relayEnabled, url: offer.url, qr: offer.qr ?? null }, + options, + ); + return; + } catch { + // COMPAT(daemon-rpc-rollout): fall back to CLI-side pairing generation while + // old daemons lack daemonStatusRpc. Remove once the daemon floor is past + // v0.1.76; pairing should come from daemon.get_pairing_offer. + } + } + await client.close().catch(() => {}); + } + } + + // Fall back to local pairing offer generation. const config = loadConfig(paseoHome); const pairing = await generateLocalPairingOffer({ paseoHome, @@ -34,6 +65,13 @@ export async function runPairCommand(options: PairOptions): Promise { includeQr: true, }); + outputPairingResult(pairing, options); +} + +function outputPairingResult( + pairing: { relayEnabled: boolean; url: string | null; qr: string | null }, + options: PairOptions, +): void { if (!pairing.relayEnabled || !pairing.url) { console.error(chalk.red("Relay pairing is disabled for this daemon config.")); console.error(chalk.yellow("Enable relay and run this command again.")); diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index dbc41c423..6752099be 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -10,6 +10,7 @@ interface ProviderBinaryStatus { label: string; path: string | null; version: string | null; + source?: "daemon" | "local"; } interface DaemonStatus { @@ -99,7 +100,7 @@ function createStatusSchema(status: DaemonStatus): OutputSchema { return "red"; } if (item.key.startsWith(" ")) { - if (item.value === "not found") return "red"; + if (item.value === "not found" || item.value === "not found (daemon)") return "red"; if (item.value.endsWith("(--version failed)")) return "yellow"; return "green"; } @@ -149,7 +150,13 @@ function toStatusRows(status: DaemonStatus): StatusRow[] { rows.push({ key: "", value: "" }); rows.push({ key: "Providers", value: "" }); for (const provider of status.providers) { - if (!provider.path) { + 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)` }); @@ -210,6 +217,7 @@ interface DaemonProbeResult { runningAgents?: number; idleAgents?: number; daemonNodeOverride?: string; + daemonProviders?: ProviderBinaryStatus[]; note?: string; } @@ -231,12 +239,32 @@ async function probeDaemonOverWebsocket(args: { } const daemonVersion = client.getLastServerInfoMessage()?.version ?? null; + const supportsDaemonStatusRpc = + client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true; try { const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } }); const agents = agentsPayload.entries.map((entry) => entry.agent); const runningAgents = agents.filter((a) => a.status === "running").length; const idleAgents = agents.filter((a) => a.status === "idle").length; + let daemonProviders: ProviderBinaryStatus[] | undefined; + if (supportsDaemonStatusRpc) { + try { + const statusPayload = await client.getDaemonStatus(); + const labelMap = new Map(PROVIDER_BINARIES.map((p) => [p.binary, p.label])); + 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, + })); + } catch { + // COMPAT(daemon-rpc-rollout): fall back to CLI-side provider resolution while + // old daemons lack daemonStatusRpc. Remove once the daemon floor is past + // v0.1.76; status should come from daemon.get_status. + } + } + if (!state.running) { return { connectedDaemon: "reachable", @@ -244,6 +272,7 @@ async function probeDaemonOverWebsocket(args: { runningAgents, idleAgents, daemonNodeOverride: "unknown (API reachable, PID unresolved)", + daemonProviders, 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`, @@ -255,6 +284,7 @@ async function probeDaemonOverWebsocket(args: { daemonVersion, runningAgents, idleAgents, + daemonProviders, }; } catch { return { @@ -278,6 +308,7 @@ interface ProbeMergeState { daemonVersion: string | null; runningAgents: number | null; idleAgents: number | null; + daemonProviders: ProviderBinaryStatus[] | undefined; note: string | undefined; } @@ -290,6 +321,7 @@ function applyProbeToStatus(input: ProbeMergeState): Omit { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "daemon.get_status.request", + }, + responseType: "daemon.get_status.response", + timeout: 10000, + }); + } + + async getDaemonPairingOffer(requestId?: string): Promise { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "daemon.get_pairing_offer.request", + }, + responseType: "daemon.get_pairing_offer.response", + timeout: 10000, + }); + } + async patchDaemonConfig( config: MutableDaemonConfigPatch, requestId?: string, diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index a4554bb5e..4f509e40a 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -936,6 +936,16 @@ export async function createPaseoDaemon( workspaceGitService, github, config.pushNotificationSender, + { + listen: formatListenTarget(boundListenTarget ?? listenTarget), + relay: { + enabled: relayEnabled, + endpoint: relayEndpoint, + publicEndpoint: relayPublicEndpoint, + useTls: relayUseTls, + publicUseTls: relayPublicUseTls, + }, + }, ); if (relayEnabled) { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2e6a383d6..04ee2264b 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -47,6 +47,8 @@ import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js" import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js"; import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js"; import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js"; +import { getPidLockInfo } from "./pid-lock.js"; +import { generateLocalPairingOffer } from "./pairing-offer.js"; import { DictationStreamManager, type DictationStreamOutboundMessage, @@ -595,6 +597,18 @@ export interface SessionOptions { agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap; providerOverrides?: Record; isDev?: boolean; + serverId?: string; + daemonVersion?: string; + daemonRuntimeConfig?: { + listen: string | null; + relay: { + enabled: boolean; + endpoint: string; + publicEndpoint: string; + useTls: boolean; + publicUseTls: boolean; + } | null; + }; } export type SessionLifecycleIntent = @@ -805,6 +819,9 @@ export class Session { private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; private readonly providerOverrides: Record | undefined; private readonly isDev: boolean; + private readonly serverId: string | undefined; + private readonly daemonVersion: string | undefined; + private readonly daemonRuntimeConfig: SessionOptions["daemonRuntimeConfig"]; private voiceModeAgentId: string | null = null; private voiceModeBaseConfig: VoiceModeBaseConfig | null = null; @@ -850,6 +867,9 @@ export class Session { agentProviderRuntimeSettings, providerOverrides, isDev, + serverId, + daemonVersion, + daemonRuntimeConfig, } = options; this.clientId = clientId; this.appVersion = appVersion ?? null; @@ -901,6 +921,9 @@ export class Session { this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; this.providerOverrides = providerOverrides; this.isDev = isDev === true; + this.serverId = serverId; + this.daemonVersion = daemonVersion; + this.daemonRuntimeConfig = daemonRuntimeConfig; this.abortController = new AbortController(); this.workspaceDirectory = new WorkspaceDirectory({ logger: this.sessionLogger, @@ -1864,6 +1887,10 @@ export class Session { payload: { requestId: msg.requestId, config: this.daemonConfigStore.get() }, }); return undefined; + case "daemon.get_status.request": + return this.handleDaemonGetStatusRequest(msg); + case "daemon.get_pairing_offer.request": + return this.handleDaemonGetPairingOfferRequest(msg); case "set_daemon_config_request": this.emit({ type: "set_daemon_config_response", @@ -3813,6 +3840,86 @@ export class Session { } } + private async handleDaemonGetStatusRequest( + msg: Extract, + ): Promise { + try { + const pidInfo = await getPidLockInfo(this.paseoHome); + const providers = (await this.agentManager.listProviderAvailability()).map((p) => ({ + provider: p.provider, + available: p.available, + error: p.error ?? null, + })); + this.emit({ + type: "daemon.get_status.response", + payload: { + requestId: msg.requestId, + serverId: this.serverId ?? "", + version: this.daemonVersion ?? null, + pid: process.pid, + nodePath: process.execPath, + startedAt: pidInfo?.startedAt ?? null, + listen: this.daemonRuntimeConfig?.listen ?? null, + relay: this.daemonRuntimeConfig?.relay ?? null, + providers, + }, + }); + } catch (error) { + this.sessionLogger.error({ err: error }, "Failed to handle daemon status request"); + this.emit({ + type: "daemon.get_status.response", + payload: { + requestId: msg.requestId, + serverId: this.serverId ?? "", + version: this.daemonVersion ?? null, + pid: process.pid, + nodePath: process.execPath, + startedAt: null, + listen: null, + relay: null, + providers: [], + }, + }); + } + } + + private async handleDaemonGetPairingOfferRequest( + msg: Extract, + ): Promise { + try { + const relay = this.daemonRuntimeConfig?.relay; + const pairing = await generateLocalPairingOffer({ + paseoHome: this.paseoHome, + relayEnabled: relay?.enabled ?? true, + relayEndpoint: relay?.endpoint, + relayPublicEndpoint: relay?.publicEndpoint, + relayUseTls: relay?.useTls, + relayPublicUseTls: relay?.publicUseTls, + includeQr: true, + logger: this.sessionLogger, + }); + this.emit({ + type: "daemon.get_pairing_offer.response", + payload: { + requestId: msg.requestId, + url: pairing.url ?? "", + qr: pairing.qr ?? null, + relayEnabled: pairing.relayEnabled, + }, + }); + } catch (error) { + this.sessionLogger.error({ err: error }, "Failed to handle daemon pairing offer request"); + this.emit({ + type: "rpc_error", + payload: { + requestId: msg.requestId, + requestType: "daemon.get_pairing_offer.request", + error: error instanceof Error ? error.message : String(error), + }, + }); + } + } + private async handleListAvailableProvidersRequest( msg: Extract, ): Promise { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index f81438e9f..93088f5e6 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -332,6 +332,18 @@ export class VoiceAssistantWebSocketServer { private readonly externalSessionsByKey: Map = new Map(); private readonly serverId: string; private readonly daemonVersion: string; + private readonly daemonRuntimeConfig: + | { + listen: string | null; + relay: { + enabled: boolean; + endpoint: string; + publicEndpoint: string; + useTls: boolean; + publicUseTls: boolean; + }; + } + | undefined; private readonly agentManager: AgentManager; private readonly agentStorage: AgentStorage; private readonly projectRegistry: ProjectRegistry; @@ -416,6 +428,16 @@ export class VoiceAssistantWebSocketServer { workspaceGitService?: WorkspaceGitService, github?: GitHubService, pushNotificationSender?: PushNotificationSender, + daemonRuntimeConfig?: { + listen: string | null; + relay: { + enabled: boolean; + endpoint: string; + publicEndpoint: string; + useTls: boolean; + publicUseTls: boolean; + }; + }, ) { this.logger = logger.child({ module: "websocket-server" }); this.serverId = serverId; @@ -423,6 +445,7 @@ export class VoiceAssistantWebSocketServer { throw new MissingDaemonVersionError(); } this.daemonVersion = daemonVersion.trim(); + this.daemonRuntimeConfig = daemonRuntimeConfig; this.agentManager = agentManager; this.agentStorage = agentStorage; this.projectRegistry = projectRegistry ?? createNoopProjectRegistry(); @@ -921,6 +944,9 @@ export class VoiceAssistantWebSocketServer { agentProviderRuntimeSettings: this.agentProviderRuntimeSettings, providerOverrides: this.providerOverrides, isDev: this.isDev, + serverId: this.serverId, + daemonVersion: this.daemonVersion, + daemonRuntimeConfig: this.daemonRuntimeConfig, }); connection = { @@ -1053,6 +1079,8 @@ export class VoiceAssistantWebSocketServer { providersSnapshot: true, // COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13. checkoutGithubSetAutoMerge: true, + // COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18. + daemonStatusRpc: true, }, }; } diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index fc4eeafaf..8c18fe2ef 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -979,6 +979,16 @@ export const WaitForFinishRequestSchema = z.object({ timeoutMs: z.number().int().positive().optional(), }); +export const DaemonGetStatusRequestSchema = z.object({ + type: z.literal("daemon.get_status.request"), + requestId: z.string(), +}); + +export const DaemonGetPairingOfferRequestSchema = z.object({ + type: z.literal("daemon.get_pairing_offer.request"), + requestId: z.string(), +}); + export const GetDaemonConfigRequestMessageSchema = z.object({ type: z.literal("get_daemon_config_request"), requestId: z.string(), @@ -1764,6 +1774,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ SetVoiceModeMessageSchema, SendAgentMessageRequestSchema, WaitForFinishRequestSchema, + DaemonGetStatusRequestSchema, + DaemonGetPairingOfferRequestSchema, GetDaemonConfigRequestMessageSchema, SetDaemonConfigRequestMessageSchema, ReadProjectConfigRequestMessageSchema, @@ -2026,6 +2038,8 @@ export const ServerInfoStatusPayloadSchema = z .object({ providersSnapshot: z.boolean().optional(), checkoutGithubSetAutoMerge: z.boolean().optional(), + // COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18. + daemonStatusRpc: z.boolean().optional(), }) .optional(), }) @@ -2586,6 +2600,50 @@ export const GetDaemonConfigResponseMessageSchema = z.object({ .passthrough(), }); +export const DaemonGetStatusResponseSchema = z.object({ + type: z.literal("daemon.get_status.response"), + payload: z + .object({ + requestId: z.string(), + serverId: z.string(), + version: z.string().nullable().optional(), + pid: z.number(), + nodePath: z.string(), + startedAt: z.string().nullable().optional(), + listen: z.string().nullable(), + relay: z + .object({ + enabled: z.boolean(), + endpoint: z.string(), + publicEndpoint: z.string(), + useTls: z.boolean(), + publicUseTls: z.boolean(), + }) + .nullable() + .optional(), + providers: z.array( + z.object({ + provider: z.string(), + available: z.boolean(), + error: z.string().nullable().optional(), + }), + ), + }) + .passthrough(), +}); + +export const DaemonGetPairingOfferResponseSchema = z.object({ + type: z.literal("daemon.get_pairing_offer.response"), + payload: z + .object({ + requestId: z.string(), + url: z.string(), + qr: z.string().nullable().optional(), + relayEnabled: z.boolean(), + }) + .passthrough(), +}); + export const SetDaemonConfigResponseMessageSchema = z.object({ type: z.literal("set_daemon_config_response"), payload: z @@ -3481,6 +3539,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ClearAgentAttentionResponseMessageSchema, SendAgentMessageResponseMessageSchema, SetVoiceModeResponseMessageSchema, + DaemonGetStatusResponseSchema, + DaemonGetPairingOfferResponseSchema, GetDaemonConfigResponseMessageSchema, SetDaemonConfigResponseMessageSchema, ReadProjectConfigResponseMessageSchema, @@ -3643,6 +3703,8 @@ export type ListProviderFeaturesResponseMessage = z.infer< typeof ListProviderFeaturesResponseMessageSchema >; export type ListAvailableProvidersResponse = z.infer; +export type DaemonGetStatusResponse = z.infer; +export type DaemonGetPairingOfferResponse = z.infer; export type GetProvidersSnapshotResponseMessage = z.infer< typeof GetProvidersSnapshotResponseMessageSchema >;