fix(cli): query daemon for status and pairing offer over RPC

Closes #1081
This commit is contained in:
Zexin Yuan
2026-05-19 11:15:52 +08:00
committed by GitHub
parent 8ff63a6d71
commit 383b380d8a
9 changed files with 334 additions and 15 deletions

View File

@@ -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.

View File

@@ -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 });

View File

@@ -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<void> {
}
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<void> {
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."));

View File

@@ -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<StatusRow> {
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<ProbeMergeState, "prob
daemonVersion: probe.daemonVersion !== undefined ? probe.daemonVersion : input.daemonVersion,
runningAgents: probe.runningAgents !== undefined ? probe.runningAgents : input.runningAgents,
idleAgents: probe.idleAgents !== undefined ? probe.idleAgents : input.idleAgents,
daemonProviders: probe.daemonProviders ?? input.daemonProviders,
note: probe.note ? appendNote(input.note, probe.note) : input.note,
};
}
@@ -338,6 +370,7 @@ export async function runStatusCommand(
let runningAgents: number | null = null;
let idleAgents: number | null = null;
let daemonVersion: string | null = null;
let daemonProviders: ProviderBinaryStatus[] | undefined;
let note: string | undefined;
if (!state.running && state.stalePidFile && state.pidInfo) {
@@ -347,17 +380,26 @@ export async function runStatusCommand(
if (host) {
const probe = await probeDaemonOverWebsocket({ host, state });
({ connectedDaemon, localDaemon, daemonNode, daemonVersion, runningAgents, idleAgents, note } =
applyProbeToStatus({
probe,
connectedDaemon,
localDaemon,
daemonNode,
daemonVersion,
runningAgents,
idleAgents,
note,
}));
({
connectedDaemon,
localDaemon,
daemonNode,
daemonVersion,
runningAgents,
idleAgents,
daemonProviders,
note,
} = applyProbeToStatus({
probe,
connectedDaemon,
localDaemon,
daemonNode,
daemonVersion,
runningAgents,
idleAgents,
daemonProviders,
note,
}));
} else {
note = appendNote(note, "Daemon is configured for unix socket listen; API probe skipped");
}
@@ -370,7 +412,7 @@ export async function runStatusCommand(
note = appendNote(note, serverIdResult.error);
}
const providers = await checkProviderBinaries();
const providers = daemonProviders ?? (await checkProviderBinaries());
const daemonStatus: DaemonStatus = {
serverId,

View File

@@ -60,6 +60,8 @@ import type {
GetProvidersSnapshotResponseMessage,
RefreshProvidersSnapshotResponseMessage,
ProviderDiagnosticResponseMessage,
DaemonGetStatusResponse,
DaemonGetPairingOfferResponse,
ListTerminalsResponse,
CreateTerminalResponse,
SubscribeTerminalResponse,
@@ -317,6 +319,8 @@ type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"];
type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"];
type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"];
type DaemonStatusPayload = DaemonGetStatusResponse["payload"];
type DaemonPairingOfferPayload = DaemonGetPairingOfferResponse["payload"];
type ReadProjectConfigPayload = Extract<
SessionOutboundMessage,
{ type: "read_project_config_response" }
@@ -3298,6 +3302,28 @@ export class DaemonClient {
});
}
async getDaemonStatus(requestId?: string): Promise<DaemonStatusPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "daemon.get_status.request",
},
responseType: "daemon.get_status.response",
timeout: 10000,
});
}
async getDaemonPairingOffer(requestId?: string): Promise<DaemonPairingOfferPayload> {
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,

View File

@@ -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) {

View File

@@ -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<string, ProviderOverride>;
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<string, ProviderOverride> | 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<SessionInboundMessage, { type: "daemon.get_status.request" }>,
): Promise<void> {
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<SessionInboundMessage, { type: "daemon.get_pairing_offer.request" }>,
): Promise<void> {
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<SessionInboundMessage, { type: "list_available_providers_request" }>,
): Promise<void> {

View File

@@ -332,6 +332,18 @@ export class VoiceAssistantWebSocketServer {
private readonly externalSessionsByKey: Map<string, SessionConnection> = 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,
},
};
}

View File

@@ -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<typeof ListAvailableProvidersResponseSchema>;
export type DaemonGetStatusResponse = z.infer<typeof DaemonGetStatusResponseSchema>;
export type DaemonGetPairingOfferResponse = z.infer<typeof DaemonGetPairingOfferResponseSchema>;
export type GetProvidersSnapshotResponseMessage = z.infer<
typeof GetProvidersSnapshotResponseMessageSchema
>;