diff --git a/apps/desktop/electron/main/ipc/agent-ipc.ts b/apps/desktop/electron/main/ipc/agent-ipc.ts index e889b6914..c0d29952e 100644 --- a/apps/desktop/electron/main/ipc/agent-ipc.ts +++ b/apps/desktop/electron/main/ipc/agent-ipc.ts @@ -13,6 +13,7 @@ import type { PersistenceOutbox } from "../persistence-outbox"; import type { ComposerCommandService } from "./composer-ipc"; import type { IpcRegistrar } from "./types"; import { withPromptEnhancementTimeout } from "../prompt-enhancement-timeout"; +import { pendingAsksRegistry } from "../pending-asks"; export type AgentIpcDependencies = { registrar: IpcRegistrar; @@ -703,6 +704,7 @@ export function registerAgentIpc({ agentExtensions.cancelPrompts(req.sessionId); cancelSessionTools(req.sessionId, "Session turn was aborted"); result = await sidecar.call("agent.abort", req); + pendingAsksRegistry.clearSession(req.sessionId); } finally { // A turn that already stopped owning the session is refused inside the // finalizer, so the identity captured above is the only one used here. @@ -794,16 +796,27 @@ export function registerAgentIpc({ return resolved; }); + handle( + IPC.invoke.askToolPending, + async (input: { sessionId?: unknown } = {}) => { + const sessionId = + typeof input.sessionId === "string" ? input.sessionId.trim() : ""; + return pendingAsksRegistry.list(sessionId || undefined); + }, + ); + handle(IPC.invoke.askToolResolve, async (resolution: AskToolResolution) => { if (!sidecar) throw new Error("sidecar unavailable"); const sessionId = String(resolution?.sessionId ?? "").trim(); const requestId = String(resolution?.requestId ?? "").trim(); if (!sessionId || !requestId) throw new Error("asktool resolution identity required"); - return sidecar.call("asktool.resolve", { + const result = await sidecar.call("asktool.resolve", { ...resolution, sessionId, requestId, }); + pendingAsksRegistry.settle(sessionId, requestId); + return result; }); handle(IPC.invoke.plansPending, async (input: { sessionId?: string } = {}) => { diff --git a/apps/desktop/electron/main/ipc/session-ipc.ts b/apps/desktop/electron/main/ipc/session-ipc.ts index 65333f3d1..1a9647e14 100644 --- a/apps/desktop/electron/main/ipc/session-ipc.ts +++ b/apps/desktop/electron/main/ipc/session-ipc.ts @@ -28,6 +28,7 @@ import type { PluginRuntime } from "../plugin-runtime"; import { readSessionCollaboration } from "../services/session-collaboration"; import { searchSessionsAcrossSources } from "../services/session-search"; import type { IpcRegistrar } from "./types"; +import { pendingAsksRegistry } from "../pending-asks"; type RuntimeSession = { id?: string; @@ -324,6 +325,7 @@ export function registerSessionIpc({ } if (!host) throw new Error("host unavailable"); const res = await host.call("session.delete", { id }); + pendingAsksRegistry.clearSession(id); await persistenceOutbox.dropSession(id); // Drop the session's pi-agent so a later session with the same id (or a // stale runtime) can't answer with this session's context. diff --git a/apps/desktop/electron/main/mcp-control.ts b/apps/desktop/electron/main/mcp-control.ts index 804b49246..0f855f365 100644 --- a/apps/desktop/electron/main/mcp-control.ts +++ b/apps/desktop/electron/main/mcp-control.ts @@ -244,6 +244,7 @@ const CONTROL_OPERATION_SPECS: OperationSpec[] = [ spec("pullsList", "pulls/list", "List pull requests for the active workspace.", "read", []), spec("scheduledList", "scheduled/list", "List scheduled tasks.", "read", []), spec("toolResolvePermission", "tool/resolvePermission", "Resolve a pending tool permission request.", "dangerous", ["resolution"]), + spec("askToolPending", "agent/askTool/pending", "List pending Agent questions.", "read", ["input"]), spec("askToolResolve", "agent/askTool/resolve", "Answer an Agent question.", "dangerous", ["resolution"]), spec("plansPending", "plans/pending", "List pending Plan or Goal approvals.", "read", ["input"]), spec("plansResolve", "plans/resolve", "Approve or reject a Plan or Goal checkpoint.", "dangerous", ["resolution"]), @@ -405,6 +406,13 @@ const CORE_TOOL_SPECS = [ "agent/compact", (input) => [input], ), + coreTool( + "pi_asktool_pending", + "List pending Agent questions.", + objectSchema({ sessionId: stringSchema("Optional session id filter.") }), + "agent/askTool/pending", + (input) => [input], + ), coreTool( "pi_plans_pending", "List pending Plan or Goal approvals.", diff --git a/apps/desktop/electron/main/pending-asks.ts b/apps/desktop/electron/main/pending-asks.ts new file mode 100644 index 000000000..a801c487c --- /dev/null +++ b/apps/desktop/electron/main/pending-asks.ts @@ -0,0 +1,227 @@ +/** + * Process-memory registry of pending Agent asktool questions. + * + * The desktop renderer receives every `asktool_request` as an in-memory + * `AgentEventEnvelope`; the local MCP control plane can answer such a + * question (`agent/askTool/resolve`) but could not read it back: the request + * id lives only in the renderer's zustand store. This registry ingests the + * same envelopes in Electron main so a phone client can discover the + * requestId and the questions it needs for the resolve operation. + * + * Security bounds: + * - Process memory only. Never persisted to disk, log, notification, or + * transcript. State dies with the process, matching the runtime's own + * pending-request lifetime. + * - Bounded per session: when a session accumulates more than + * {@link MAX_PENDING_ASKS_PER_SESSION} unresolved requests, the oldest + * entry is pruned first. No unbounded growth. + * - `list()` returns structural clones so callers cannot mutate the + * registry's references. + * + * Lifecycle: + * - `asktool_request` adds (or dedupes by requestId). + * - `tool_end` for the same session removes the entry whose `toolCallId` + * matches (the asktool call has completed). + * - `agent_end` clears the session: the turn is over and every unresolved + * request is dead for that session. + */ + +import type { + AgentEventEnvelope, + AskToolQuestion, + AskToolRequest, +} from "@pi-desktop/shared"; + +/** Hard per-session bound; pruned oldest-first. */ +export const MAX_PENDING_ASKS_PER_SESSION = 20; + +/** Hard global bound on session buckets; prevents stale remote hosts from growing memory forever. */ +export const MAX_PENDING_ASK_SESSIONS = 1024; + +/** One pending ask as exposed over the read operation. */ +export type PendingAsk = { + requestId: string; + sessionId: string; + toolCallId: string; + questions: AskToolQuestion[]; + receivedAt: number; +}; + +export type PendingAsksResult = { + kind: "pending" | "none"; + asks: PendingAsk[]; +}; + +export type PendingAsksRegistry = { + ingest: (envelope: AgentEventEnvelope) => void; + settle: (sessionId: string, requestId: string) => void; + clearSession: (sessionId: string) => void; + /** + * Drop every bucket whose session id starts with `prefix`. Used when a remote + * host goes away: its asks can never be answered afterwards, and the ids are + * namespaced (`remote::`), so the prefix is exact. + * Returns the number of buckets removed. + */ + clearSessionsWithPrefix: (prefix: string) => number; + list: (sessionId?: string) => PendingAsksResult; +}; + +const isNonEmptyString = (value: unknown): value is string => + typeof value === "string" && value.trim().length > 0; + +/** + * Create the main-process pending-asks registry. Pure in-memory state with + * no timers, no persistence, and no external dependencies beyond the + * shared `AgentEventEnvelope` contract. + */ +export function createPendingAsksRegistry(): PendingAsksRegistry { + /** sessionId -> Array, ordered by receipt. */ + const bySession = new Map(); + + /** + * Keep the bucket count bounded. A remote host that disconnects without + * resolving its asks (or a long-lived desktop accumulating dead sessions) + * must not grow process memory without limit; the per-session cap alone does + * not bound the number of sessions. The oldest bucket (Map insertion order) + * goes first, matching the per-session oldest-first policy. + */ + const evictOldestSessions = (): void => { + while (bySession.size > MAX_PENDING_ASK_SESSIONS) { + const oldest = bySession.keys().next(); + if (oldest.done) return; + bySession.delete(oldest.value); + } + }; + + const ingest = (envelope: AgentEventEnvelope): void => { + if (!envelope || typeof envelope !== "object") return; + const sessionId = String(envelope.sessionId ?? "").trim(); + const event = envelope.event; + if (!sessionId || !event) return; + if (event.type === "asktool_request") { + const request: AskToolRequest | undefined = event.request; + if ( + !request || + !isNonEmptyString(request.requestId) || + !isNonEmptyString(request.toolCallId) || + request.sessionId !== sessionId || + !Array.isArray(request.questions) + ) { + return; + } + const questions: AskToolQuestion[] = []; + for (const raw of request.questions as unknown[]) { + if (!raw || typeof raw !== "object") return; + const question = raw as Partial; + if ( + !isNonEmptyString(question.question) || + !Array.isArray(question.options) || + !question.options.every((option) => typeof option === "string") + ) { + return; + } + questions.push({ + question: question.question, + options: [...question.options], + ...(question.multiSelect === true ? { multiSelect: true } : {}), + }); + } + if (questions.length === 0) return; + const existing = bySession.get(sessionId) ?? []; + if (existing.some((ask) => ask.requestId === request.requestId)) return; + const entry: PendingAsk = { + requestId: request.requestId, + sessionId, + toolCallId: request.toolCallId, + questions, + receivedAt: Number.isFinite(envelope.ts) ? envelope.ts : Date.now(), + }; + const next = [...existing, entry]; + while (next.length > MAX_PENDING_ASKS_PER_SESSION) { + next.shift(); + } + // Re-insert so this session becomes the newest bucket: Map iteration order + // is insertion order, which is what `evictOldestSessions` relies on. + bySession.delete(sessionId); + bySession.set(sessionId, next); + evictOldestSessions(); + return; + } + if (event.type === "tool_end") { + const toolCallId = event.toolCallId; + if (!isNonEmptyString(toolCallId)) return; + const existing = bySession.get(sessionId); + if (!existing?.length) return; + const remaining = existing.filter((ask) => ask.toolCallId !== toolCallId); + if (remaining.length === existing.length) return; + if (remaining.length === 0) bySession.delete(sessionId); + else bySession.set(sessionId, remaining); + return; + } + if (event.type === "agent_end") { + bySession.delete(sessionId); + } + }; + + const settle = (sessionId: string, requestId: string): void => { + const normalizedSessionId = String(sessionId ?? "").trim(); + const normalizedRequestId = String(requestId ?? "").trim(); + if (!normalizedSessionId || !normalizedRequestId) return; + const existing = bySession.get(normalizedSessionId); + if (!existing?.length) return; + const remaining = existing.filter( + (ask) => ask.requestId !== normalizedRequestId, + ); + if (remaining.length === existing.length) return; + if (remaining.length === 0) bySession.delete(normalizedSessionId); + else bySession.set(normalizedSessionId, remaining); + }; + + const clearSession = (sessionId: string): void => { + const normalized = String(sessionId ?? "").trim(); + if (normalized) bySession.delete(normalized); + }; + + const clearSessionsWithPrefix = (prefix: string): number => { + const normalized = String(prefix ?? ""); + if (!normalized) return 0; + let removed = 0; + for (const sessionId of [...bySession.keys()]) { + if (!sessionId.startsWith(normalized)) continue; + bySession.delete(sessionId); + removed += 1; + } + return removed; + }; + + const list = (sessionId?: string): PendingAsksResult => { + const sources: PendingAsk[][] = []; + if (isNonEmptyString(sessionId)) { + const existing = bySession.get(sessionId.trim()); + if (existing?.length) sources.push(existing); + } else { + for (const bucket of bySession.values()) { + if (bucket.length) sources.push(bucket); + } + } + const asks = sources.flat().sort((left, right) => { + if (left.receivedAt !== right.receivedAt) return left.receivedAt - right.receivedAt; + return left.requestId < right.requestId ? -1 : left.requestId > right.requestId ? 1 : 0; + }); + return { + kind: asks.length > 0 ? "pending" : "none", + asks: asks.map((ask) => ({ + ...ask, + questions: ask.questions.map((question) => ({ + ...question, + options: [...question.options], + })), + })), + }; + }; + + return { ingest, settle, clearSession, clearSessionsWithPrefix, list }; +} + +/** Shared process-memory registry used by local, native and remote event paths. */ +export const pendingAsksRegistry = createPendingAsksRegistry(); diff --git a/apps/desktop/electron/main/remote/backend-router.ts b/apps/desktop/electron/main/remote/backend-router.ts index 9e38a4309..84502a3d5 100644 --- a/apps/desktop/electron/main/remote/backend-router.ts +++ b/apps/desktop/electron/main/remote/backend-router.ts @@ -21,6 +21,13 @@ export const ROUTE_LOCAL = Symbol("pi-desktop.route-local"); /** Namespaced-id prefix for sessions owned by a remote host. */ const REMOTE_PREFIX = "remote:"; +/** + * The same prefix, exported for callers that must reason about a whole host's + * sessions at once (e.g. clearing that host's pending-ask buckets when it goes + * away) instead of one namespaced id at a time. + */ +export const REMOTE_SESSION_PREFIX = REMOTE_PREFIX; + /** * Delimiter that embeds the renderer-visible remote session id inside a tool * permission `requestId`. `toolResolvePermission` carries only `{requestId, diff --git a/apps/desktop/electron/main/remote/remote-backend.ts b/apps/desktop/electron/main/remote/remote-backend.ts index b3237ab78..bd6ec8f05 100644 --- a/apps/desktop/electron/main/remote/remote-backend.ts +++ b/apps/desktop/electron/main/remote/remote-backend.ts @@ -39,6 +39,7 @@ import { sessionIdForCall, } from "./backend-router.js"; import { racpSessionToSummary, snapshotToSessionDetail } from "./remote-transcript.js"; +import { pendingAsksRegistry } from "../pending-asks"; /** The subset of `RacpClient` this backend needs; kept minimal for testing. */ export type RemoteRacpClient = { @@ -306,6 +307,7 @@ export function createRemoteBackend(options: RemoteBackendOptions): RemoteBacken answers: resolution.answers, context: context(), }); + pendingAsksRegistry.settle(resolution.sessionId, resolution.requestId); return { ok: true }; } case IPC.invoke.plansResolve: { diff --git a/apps/desktop/electron/main/remote/remote-event-bridge.ts b/apps/desktop/electron/main/remote/remote-event-bridge.ts index fcac20a20..9830174d1 100644 --- a/apps/desktop/electron/main/remote/remote-event-bridge.ts +++ b/apps/desktop/electron/main/remote/remote-event-bridge.ts @@ -21,6 +21,7 @@ import type { ToolPermissionRequest, } from "@pi-desktop/shared"; import { makeRemoteApprovalRequestId, makeRemoteSessionId } from "./backend-router.js"; +import { pendingAsksRegistry } from "../pending-asks"; /** A minimal shape of the session field carried by host-scope session events. * Both the RACP `RacpSession` and the host's smaller `SessionSummary` extend @@ -120,10 +121,10 @@ function toAskToolRequest( return { requestId: input.id, sessionId: remoteSessionId, - // Ask-tool needs a toolCallId to attach the answer to; the RACP schema - // supplies it as `parentToolCallId` when the input came from a subagent, - // and leaves it undefined for the top-level agent. - toolCallId: input.parentToolCallId ?? "", + // The RACP response is keyed by `input.id`, not this field. Preserve the + // parent tool call when present; for a top-level ask use the input id as a + // stable non-empty lifecycle key for the main-process registry. + toolCallId: input.parentToolCallId ?? input.id, questions: input.questions.map((question) => ({ question: question.question, options: question.options, @@ -156,6 +157,7 @@ export function createRemoteEventBridge(options: RemoteEventBridgeOptions): Remo ...(envelope.parentToolCallId ? { parentToolCallId: envelope.parentToolCallId } : {}), ...(envelope.agentName ? { agentName: envelope.agentName } : {}), }; + pendingAsksRegistry.ingest(local); emit(IPC.event.agentMessage, local); }; @@ -181,6 +183,7 @@ export function createRemoteEventBridge(options: RemoteEventBridgeOptions): Remo } // "session.archived": pass through to the lifecycle handler for router // cleanup, then refresh the renderer's session list. + pendingAsksRegistry.clearSession(remoteSessionId); onLifecycle?.({ kind: "session.archived", hostSessionId: session.id, remoteSessionId, session }); emit(IPC.event.sessionsChanged, { reason: "remote.session.archived" }); }; @@ -244,8 +247,14 @@ export function createRemoteEventBridge(options: RemoteEventBridgeOptions): Remo }); return; } + case "input.resolved": { + const payload = envelope.payload; + if (isRecord(payload) && typeof payload.inputId === "string") { + pendingAsksRegistry.settle(remoteSessionId, payload.inputId); + } + return; + } case "approval.resolved": - case "input.resolved": case "terminal.changed": case "terminal.output": case "resync.required": diff --git a/apps/desktop/electron/main/remote/remote-host-connection.ts b/apps/desktop/electron/main/remote/remote-host-connection.ts index 174832f12..929cf5e4a 100644 --- a/apps/desktop/electron/main/remote/remote-host-connection.ts +++ b/apps/desktop/electron/main/remote/remote-host-connection.ts @@ -21,6 +21,8 @@ import { type RemoteEventBridge, type RemoteLifecycleEvent, } from "./remote-event-bridge.js"; +import { pendingAsksRegistry } from "../pending-asks"; +import { REMOTE_SESSION_PREFIX } from "./backend-router.js"; /** * The transport surface this connection needs. {@link RemoteRacpClient} covers @@ -147,6 +149,12 @@ export function createRemoteHostConnection( bridge = null; for (const remoteSessionId of registered) router.unregisterBackend(remoteSessionId); registered.clear(); + // A host that went away can never resolve its asks, and no later event + // will arrive to settle or clear them: `input.resolved` and + // `session.archived` both come from the same dead stream. Without this the + // dead entries stay visible in unfiltered listings and their buckets are + // never reclaimed. The namespaced id makes the prefix exact for this host. + pendingAsksRegistry.clearSessionsWithPrefix(`${REMOTE_SESSION_PREFIX}${hostKey}:`); }, }; } diff --git a/apps/desktop/electron/main/runtime/sidecar.ts b/apps/desktop/electron/main/runtime/sidecar.ts index 679362cbf..45e90b91c 100644 --- a/apps/desktop/electron/main/runtime/sidecar.ts +++ b/apps/desktop/electron/main/runtime/sidecar.ts @@ -24,6 +24,7 @@ import type { PluginRuntime } from "../plugin-runtime"; import type { RuntimeState } from "./context"; import type { FinishTurn } from "./plans"; import { formatSkillToolContent, type LoadedSkillDocument } from "../skill-document"; +import { pendingAsksRegistry } from "../pending-asks"; export type SidecarRuntimeDependencies = { runtimeState: RuntimeState; @@ -101,6 +102,7 @@ export function createSidecarRuntime({ // the current turn's state in Agent Host or the renderer. Persistence is a // separate call, so dropping it here still archives it as history. if (isStaleTerminalEvent(envelope)) return; + pendingAsksRegistry.ingest(envelope); runtimeState.agentHostBridge?.ingest(envelope); sendToRenderer(IPC.event.agentMessage, envelope); }; @@ -183,7 +185,9 @@ export function createSidecarRuntime({ if (method === "native.agent.event") { // Native AgentSession already persisted the event to its canonical Pi // JSONL. It owns neither the Desktop outbox nor Host queue/turn state. - sendToRenderer(IPC.event.agentMessage, params as AgentEventEnvelope); + const envelope = params as AgentEventEnvelope; + pendingAsksRegistry.ingest(envelope); + sendToRenderer(IPC.event.agentMessage, envelope); return; } if (method === "agent.event") { @@ -255,6 +259,13 @@ export function createSidecarRuntime({ activeToolCalls.clear(); runtimeState.sidecar = null; steeringReplies.clear(); + const crashedSessions = new Set([ + ...activeTurns.keys(), + ...interruptedToolCalls.map((tool) => tool.sessionId), + ]); + for (const sessionId of crashedSessions) { + pendingAsksRegistry.clearSession(sessionId); + } if (intentional || isQuitting()) return; for (const tool of interruptedToolCalls) { logger.app("tool", "error", "tool execution interrupted", { diff --git a/apps/desktop/test/mcp-control.test.mjs b/apps/desktop/test/mcp-control.test.mjs index 1a3c751e2..0cf538156 100644 --- a/apps/desktop/test/mcp-control.test.mjs +++ b/apps/desktop/test/mcp-control.test.mjs @@ -74,6 +74,7 @@ const fixtureChannels = { sessionDelete: "pi-desktop/session/delete", sessionConfigure: "pi-desktop/session/configure", plansResolve: "pi-desktop/plans/resolve", + askToolPending: "pi-desktop/agent/askTool/pending", agentPrompt: "pi-desktop/agent/prompt", }; @@ -173,8 +174,29 @@ test("local MCP control server authenticates, discovers, and invokes desktop ope assert.ok(toolNames.includes("pi_control_describe")); assert.ok(toolNames.includes("pi_project_open")); assert.ok(toolNames.includes("pi_plans_resolve")); + assert.ok(toolNames.includes("pi_asktool_pending")); assert.ok(toolNames.includes("pi_session_configure")); + const pendingAsks = await post( + info.url, + info.token, + { + jsonrpc: "2.0", + id: 21, + method: "tools/call", + params: { + name: "pi_asktool_pending", + arguments: { sessionId: "session-1" }, + }, + }, + { "Mcp-Session-Id": sessionId }, + ); + assert.equal(pendingAsks.body.result.structuredContent.ok, true); + assert.deepEqual(calls.at(-1), { + channel: "pi-desktop/agent/askTool/pending", + args: [{ sessionId: "session-1" }], + }); + const opened = await post( info.url, info.token, diff --git a/apps/desktop/test/native-pi-sessions.test.mjs b/apps/desktop/test/native-pi-sessions.test.mjs index f2d7babe7..aa4ae2d9e 100644 --- a/apps/desktop/test/native-pi-sessions.test.mjs +++ b/apps/desktop/test/native-pi-sessions.test.mjs @@ -222,6 +222,9 @@ function forkHarness({ host, sidecar, activeTurns = new Map() }) { "../importers": { convertSession() {}, scanAllSources() {}, scanModelConfigs() {} }, "../services/session-collaboration": { readSessionCollaboration() {} }, "../services/session-search": { searchSessionsAcrossSources }, + // The session IPC also clears the session's pending asktool entries on + // delete; a no-op registry is enough for the fork contract under test. + "../pending-asks": { pendingAsksRegistry: { clearSession() {} } }, }); registerSessionIpc({ registrar: { handle: (channel, handler) => handlers.set(channel, handler) }, diff --git a/apps/desktop/test/pending-asks.test.mjs b/apps/desktop/test/pending-asks.test.mjs new file mode 100644 index 000000000..c19d2dd5b --- /dev/null +++ b/apps/desktop/test/pending-asks.test.mjs @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { join, dirname } from "node:path"; +import { register } from "node:module"; +import { pathToFileURL, fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +register(pathToFileURL(join(here, "helpers/ts-import-hooks.mjs"))); +const { + MAX_PENDING_ASKS_PER_SESSION, + MAX_PENDING_ASK_SESSIONS, + createPendingAsksRegistry, +} = await import("../electron/main/pending-asks.ts"); + +function ask(sessionId, requestId, toolCallId, ts, question = requestId) { + return { + sessionId, + ts, + event: { + type: "asktool_request", + request: { + requestId, + sessionId, + toolCallId, + questions: [{ question, options: ["a", "b"] }], + }, + }, + }; +} + +function toolEnd(sessionId, toolCallId, ts = 999) { + return { sessionId, ts, event: { type: "tool_end", toolCallId, result: {} } }; +} + +function agentEnd(sessionId, ts = 1000) { + return { sessionId, ts, event: { type: "agent_end", messageIds: [] } }; +} + +test("pending ask registry dedupes, filters, clones and orders stably", () => { + const registry = createPendingAsksRegistry(); + registry.ingest(ask("s2", "r2", "t2", 20)); + const trimmed = ask(" s1 ", "r1", "t1", 10); + trimmed.event.request.sessionId = "s1"; + registry.ingest(trimmed); + registry.ingest(ask("s1", "r1", "t1", 99, "duplicate must not replace")); + + assert.deepEqual(registry.list().asks.map((entry) => entry.requestId), ["r1", "r2"]); + assert.deepEqual(registry.list("s1").asks.map((entry) => entry.requestId), ["r1"]); + assert.equal(registry.list("missing").kind, "none"); + + const listed = registry.list("s1"); + listed.asks[0].questions[0].question = "mutated"; + listed.asks[0].questions[0].options.push("mutated"); + assert.equal(registry.list("s1").asks[0].questions[0].question, "r1"); + assert.deepEqual(registry.list("s1").asks[0].questions[0].options, ["a", "b"]); + assert.equal(registry.list("s1").asks[0].receivedAt, 10); +}); + +test("malformed question shapes are ignored without throwing", () => { + const registry = createPendingAsksRegistry(); + for (const questions of [ + [null], + [{ question: "broken" }], + [{ question: "broken", options: "not-an-array" }], + [{ question: "broken", options: ["ok", 2] }], + ]) { + const envelope = ask("s1", `bad-${JSON.stringify(questions)}`, "t", 1); + envelope.event.request.questions = questions; + assert.doesNotThrow(() => registry.ingest(envelope)); + } + assert.equal(registry.list().kind, "none"); +}); + +test("tool_end removes one request and agent_end clears the session", () => { + const registry = createPendingAsksRegistry(); + registry.ingest(ask("s1", "r1", "t1", 1)); + registry.ingest(ask("s1", "r2", "t2", 2)); + registry.ingest(toolEnd("s1", "t1")); + assert.deepEqual(registry.list("s1").asks.map((entry) => entry.requestId), ["r2"]); + registry.ingest(agentEnd("s1")); + assert.deepEqual(registry.list("s1"), { kind: "none", asks: [] }); +}); + +test("identity mismatch is rejected and settlement stays session-scoped", () => { + const registry = createPendingAsksRegistry(); + const mismatched = ask("envelope-session", "bad", "t0", 0); + mismatched.event.request.sessionId = "request-session"; + registry.ingest(mismatched); + assert.equal(registry.list().kind, "none"); + + registry.ingest(ask("s1", "same", "t1", 1)); + registry.ingest(ask("s2", "same", "t2", 2)); + registry.settle("s1", "same"); + assert.equal(registry.list("s1").kind, "none"); + assert.equal(registry.list("s2").asks[0].requestId, "same"); + registry.clearSession("s2"); + assert.equal(registry.list().kind, "none"); +}); + +test("per-session queue is bounded and prunes oldest first", () => { + const registry = createPendingAsksRegistry(); + for (let index = 0; index < MAX_PENDING_ASKS_PER_SESSION + 3; index += 1) { + registry.ingest(ask("s1", `r${index}`, `t${index}`, index)); + } + const asks = registry.list("s1").asks; + assert.equal(asks.length, MAX_PENDING_ASKS_PER_SESSION); + assert.equal(asks[0].requestId, "r3"); + assert.equal(asks.at(-1).requestId, `r${MAX_PENDING_ASKS_PER_SESSION + 2}`); +}); + +test("the session-bucket count is bounded and evicts the oldest bucket first", () => { + const registry = createPendingAsksRegistry(); + const total = MAX_PENDING_ASK_SESSIONS + 5; + for (let index = 0; index < total; index += 1) { + registry.ingest(ask(`s${index}`, `r${index}`, `t${index}`, index)); + } + + // The newest bucket survives; the oldest ones are gone. + assert.equal(registry.list(`s${total - 1}`).kind, "pending"); + assert.equal(registry.list("s0").kind, "none"); + assert.equal(registry.list("s4").kind, "none"); + assert.equal(registry.list("s5").kind, "pending"); + assert.equal( + registry.list().asks.length, + MAX_PENDING_ASK_SESSIONS, + "an unfiltered listing must stay bounded too", + ); +}); + +test("clearSessionsWithPrefix drops exactly one host's buckets", () => { + const registry = createPendingAsksRegistry(); + registry.ingest(ask("remote:hostA:s1", "r1", "t1", 1)); + registry.ingest(ask("remote:hostA:s2", "r2", "t2", 2)); + registry.ingest(ask("remote:hostB:s1", "r3", "t3", 3)); + // A session whose id merely shares a prefix with another host's namespace must + // not be touched by that host's cleanup. + registry.ingest(ask("local:s1", "r4", "t4", 4)); + + assert.equal(registry.clearSessionsWithPrefix("remote:hostA:"), 2); + assert.equal(registry.list("remote:hostA:s1").kind, "none"); + assert.equal(registry.list("remote:hostA:s2").kind, "none"); + assert.equal(registry.list("remote:hostB:s1").kind, "pending"); + assert.equal(registry.list("local:s1").kind, "pending"); + assert.equal(registry.clearSessionsWithPrefix(""), 0, "an empty prefix clears nothing"); +}); diff --git a/apps/desktop/test/permission-inline.test.mjs b/apps/desktop/test/permission-inline.test.mjs index 04ecd9e4c..4140fb987 100644 --- a/apps/desktop/test/permission-inline.test.mjs +++ b/apps/desktop/test/permission-inline.test.mjs @@ -157,6 +157,34 @@ test("asktool requests queue independently and never expire", () => { ); assert.deepEqual(Object.keys(clearSessionAsks(next, "session-a")), []); }); +test("asktool tool-row matching keys on the ask's own tool call, not an empty id", () => { + // A top-level remote ask used to arrive with `toolCallId: ""`, while the + // runtime's own asks always carry a non-empty id (the local runtime passes + // the asktool call's own id). Queue removal is an exact match on that id, and + // a real `tool_end` always carries a non-empty `toolCallId`, so an empty id + // could never be cleared by the event that actually ends the ask: the entry + // stayed queued until the session did. Pin the non-empty contract here so a + // future fallback cannot silently reintroduce the leak. + const topLevel = { + sessionId: "session-a", + requestId: "ask-remote", + toolCallId: "top-level-input", + questions: [{ question: "Color?", options: ["Blue"] }], + }; + const legacyEmpty = { ...topLevel, requestId: "ask-legacy", toolCallId: "" }; + const queues = enqueueAsk(enqueueAsk({}, topLevel), legacyEmpty); + + const afterOtherTool = removeAskForToolCall(queues, "session-a", "some-other-tool"); + assert.equal(afterOtherTool, queues, "an unrelated tool end removes nothing"); + + const afterOwnTool = removeAskForToolCall(queues, "session-a", "top-level-input"); + assert.equal(headAsk(afterOwnTool, "session-a"), legacyEmpty); + assert.equal( + afterOwnTool["session-a"].length, + 1, + "the non-empty ask is cleared by its own tool end", + ); +}); test("asktool card is a stepwise, non-expiring composer question surface", () => { assert.match(chatSurfaceSource, /pendingAsk/); diff --git a/apps/desktop/test/remote-event-bridge.test.mjs b/apps/desktop/test/remote-event-bridge.test.mjs index 9780472d5..46049358a 100644 --- a/apps/desktop/test/remote-event-bridge.test.mjs +++ b/apps/desktop/test/remote-event-bridge.test.mjs @@ -14,6 +14,9 @@ const { makeRemoteApprovalRequestId, makeRemoteSessionId } = await import( const { createRemoteEventBridge } = await import( "../electron/main/remote/remote-event-bridge.ts" ); +const { pendingAsksRegistry } = await import( + "../electron/main/pending-asks.ts" +); const HOST_KEY = "hostA"; const HOST_SESSION_ID = "sess-1"; @@ -219,6 +222,32 @@ test("input.requested synthesizes an asktool_request keyed by the RACP input id" assert.equal(request.toolCallId, "tc-parent"); assert.equal(request.questions.length, 1); assert.equal(request.questions[0].multiSelect, false); + const pending = pendingAsksRegistry.list(REMOTE_SESSION_ID); + assert.equal(pending.kind, "pending"); + assert.equal(pending.asks[0].requestId, "input-42"); + assert.equal(pending.asks[0].sessionId, REMOTE_SESSION_ID); +}); + +test("top-level input uses its id as lifecycle key and input.resolved clears it", () => { + const { bridge } = collect(); + bridge.handle(makeEnvelope({ + kind: "input.requested", + payload: { + id: "top-level-input", + sessionId: HOST_SESSION_ID, + questions: [{ id: "q", question: "continue?", options: ["yes", "no"] }], + }, + })); + let pending = pendingAsksRegistry.list(REMOTE_SESSION_ID); + assert.equal(pending.asks.at(-1).requestId, "top-level-input"); + assert.equal(pending.asks.at(-1).toolCallId, "top-level-input"); + + bridge.handle(makeEnvelope({ + kind: "input.resolved", + payload: { inputId: "top-level-input" }, + })); + pending = pendingAsksRegistry.list(REMOTE_SESSION_ID); + assert.equal(pending.asks.some((ask) => ask.requestId === "top-level-input"), false); }); test("terminal and resync kinds are silently dropped in Stage 2 — later stages own them", () => { diff --git a/apps/desktop/test/remote-host-connection.test.mjs b/apps/desktop/test/remote-host-connection.test.mjs index f73254f99..b869bad64 100644 --- a/apps/desktop/test/remote-host-connection.test.mjs +++ b/apps/desktop/test/remote-host-connection.test.mjs @@ -14,6 +14,7 @@ const { createBackendRouter, makeRemoteSessionId } = await import( const { createRemoteHostConnection } = await import( "../electron/main/remote/remote-host-connection.ts" ); +const { pendingAsksRegistry } = await import("../electron/main/pending-asks.ts"); const HOST_KEY = "hostA"; @@ -186,3 +187,32 @@ test("session/list failure leaves the connection registered for nothing but does ); assert.equal(client.hasListener(), true); }); + +test("close clears the disconnected host's pending asks", async () => { + // A host that goes away can never resolve its asks, and no later event will + // arrive to settle them: `input.resolved` / `session.archived` ride the same + // dead stream. Leaving them behind made a dead host's questions show up in + // unfiltered listings forever. + const remoteId = makeRemoteSessionId(HOST_KEY, "s1"); + const { conn, client } = setup({ sessions: [makeSession("s1")] }); + await conn.open(); + client.push( + makeEnvelope({ + sessionId: "s1", + kind: "input.requested", + payload: { + id: "ask-1", + questions: [{ question: "which one?", options: ["a", "b"] }], + }, + }), + ); + assert.equal(pendingAsksRegistry.list(remoteId).kind, "pending"); + + await conn.close(); + + assert.equal( + pendingAsksRegistry.list(remoteId).kind, + "none", + "a closed host must not leave pending asks behind", + ); +}); diff --git a/apps/desktop/test/session-collaboration-ipc.test.mjs b/apps/desktop/test/session-collaboration-ipc.test.mjs index 9308aa8d9..30622ba4a 100644 --- a/apps/desktop/test/session-collaboration-ipc.test.mjs +++ b/apps/desktop/test/session-collaboration-ipc.test.mjs @@ -35,6 +35,7 @@ const { registerSessionIpc } = load("../electron/main/ipc/session-ipc.ts", { "../importers": {}, "../services/session-collaboration": collaboration, "../services/session-search": { searchSessionsAcrossSources: async () => ({ hits: [], nextOffset: null }) }, + "../pending-asks": { pendingAsksRegistry: { clearSession() {} } }, }); const summary = { sessionId: "worker-session-id", diff --git a/docs/spec/03-runtime/01-ipc-protocol.md b/docs/spec/03-runtime/01-ipc-protocol.md index eaf3b4aa3..6898fc694 100644 --- a/docs/spec/03-runtime/01-ipc-protocol.md +++ b/docs/spec/03-runtime/01-ipc-protocol.md @@ -50,6 +50,7 @@ Examples: - `pi-desktop/agent/stop` - `pi-desktop/agent/abort` - `pi-desktop/agent/event/message` +- `pi-desktop/agent/askTool/pending` - `pi-desktop/agent/askTool/resolve` - `pi-desktop/session/list` - `pi-desktop/session/summarizeTitle` @@ -2208,9 +2209,22 @@ The named tools cover the common Agent workflow: `pi_session_configure` - `pi_agent_prompt`, `pi_agent_status`, `pi_agent_stop`, `pi_agent_abort`, `pi_agent_compact` +- `pi_asktool_pending` - `pi_plans_pending`, `pi_plans_resolve` - `pi_workspace_diff`, `pi_fs_list`, `pi_fs_read` +`pi_asktool_pending` is the read side of `agent/askTool/resolve`. It returns the +Agent questions that are still waiting for an answer, with the `requestId` the +resolve operation needs. `sessionId` is an optional filter: without it the +operation lists asks from every session, including asks that belong to a paired +remote host (`remote::`), because a client that does not +yet know the session must still be able to discover what an Agent is waiting +for. This is the same cross-session shape `plans/pending` already has. The +registry is process memory only: it is never persisted, logged, or written into +the transcript, and an entry disappears when the question is resolved, the turn +ends, the session is deleted or archived, the sidecar crashes (clearing the +sessions it was running), or a remote host connection closes. + `pi_control_describe` returns the reviewed operation catalog. `pi_desktop_invoke` accepts an operation id and positional IPC arguments: diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 78b0f169f..cfeb28a25 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -8680,7 +8680,7 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. | M6+ (Chat file references) | E2E-PLUGIN-file-view-collapse-persists | | M6+ (project folder roots) | E2E-PLUGIN-file-view-switches-folder-per-project | | Post-MVP | E2E-022A, E2E-022B, E2E-022C, E2E-024I, E2E-024J, E2E-024K, E2E-024L, E2E-024M (plugin roadmap R2/R3/R6) | -| Post-baseline local automation | E2E-220 | +| Post-baseline local automation | E2E-220, E2E-MCP-pending-asktool-questions-are-readable | | Post-MVP remote control | E2E-221, E2E-222, E2E-223, E2E-224, E2E-225, E2E-226, E2E-227, E2E-228, E2E-229, E2E-230, E2E-231, E2E-232 | | Trusted extensions (R7 v1) | E2E-DIALOG-long-text-boundaries, E2E-241, E2E-242, E2E-HOOKS-cancel-and-dispose, E2E-TRUSTED-EXTENSION-custom-agent-stream-and-binding, E2E-243, E2E-244, E2E-245, E2E-PLUGIN-imported-pi-package-skills, E2E-PLUGIN-import-extension-installs-dependencies, E2E-PLUGIN-import-extension-reports-missing-dependency, E2E-PLUGIN-declared-provider-appears-in-the-native-provider-list | | Trusted extensions (R7 v1 npm recovery) | E2E-PLUGIN-import-extension-recovers-missing-npm | @@ -12595,7 +12595,48 @@ are withdrawn with ADR 0165. - **Milestone**: M6+ - **Status**: MCP protocol/unit-covered by `apps/desktop/test/mcp-control.test.mjs`; full Electron journey documented and remains deferred by the no-local-E2E - policy + policy. The pending-question read side is covered by the scenario below. + +#### E2E-MCP-pending-asktool-questions-are-readable: A remote client discovers the pending question + +- **Preconditions**: Start PI-Desktop with `PI_DESKTOP_MCP_CONTROL=1` and a + clean profile, a configured model, and one Agent session. A prompt that makes + the Agent open an asktool question is available. +- **Steps**: 1) Prompt the Agent so it opens an asktool question and stays + waiting. 2) Read `mcp-control.json` and call `pi_asktool_pending` with no + `sessionId`. 3) Repeat with the waiting session's `sessionId`, then with an + unknown session id. 4) Call `pi_desktop_invoke` for the generic + `agent/askTool/pending` operation. 5) Answer the question through + `agent/askTool/resolve` and read again; repeat by stopping the turn instead. + 6) Archive the session, then read again. 7) Delete the session, then read + again. +- **Expected**: Step 1 leaves the Agent waiting on a question the desktop card + shows. Step 2 returns `kind: "pending"` with the question text, its options, + and the `requestId` the resolve operation needs; the listing spans every + session bucket, including a paired remote host's + (`remote::`), so a client that does not yet know the + session still discovers the request. Step 3 filters to exactly the named + session, and an unknown session id returns `kind: "none"` with an empty list + rather than an error. Step 4 returns the same payload through the reviewed + read catalog. Step 5 removes the entry for both an answered question and a + stopped turn, because neither can be answered afterwards. Steps 6 and 7 leave + no entry for that session. No listing returns an ask whose `sessionId` is not + the requested one, and `pi_control_describe` still omits secret-write, + native-picker, and `plugin/loadDev` channels. +- **Specs linked**: `03-runtime/01-ipc-protocol.md` §13d, + `03-runtime/17-asktool-questions.md`, `05-security/01-security.md`, ADR 0203, + D370 +- **Acceptance**: A (app control), C (conversation & stream), Security, Quality +- **Milestone**: M6+ +- **Status**: NOT RUN for the Electron journey above — it needs a running + desktop driving a live Agent turn, which the no-local-E2E policy defers. + Alternative validation: the registry lifecycle, the reviewed catalog entry, + and the remote ingestion path are unit-covered by + `apps/desktop/test/pending-asks.test.mjs`, + `apps/desktop/test/mcp-control.test.mjs`, and + `apps/desktop/test/remote-event-bridge.test.mjs`. Remaining risk is the HTTP + round trip and the live asktool prompt path, which the unit coverage does not + exercise. #### E2E-234: Workspace security denylist and ignore layers diff --git a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md index 8aa9e0f16..029ce5779 100644 --- a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md +++ b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md @@ -52,6 +52,7 @@ event: pi-desktop//event/ - `pi-desktop/agent/steer` - `pi-desktop/agent/abort` - `pi-desktop/agent/event/message` +- `pi-desktop/agent/askTool/pending` - `pi-desktop/agent/askTool/resolve` - `pi-desktop/session/list` - `pi-desktop/project/open` @@ -1777,9 +1778,18 @@ Electron 等待主机关闭之前会停止服务,并将清单标记为非活 `pi_session_configure` - `pi_agent_prompt`、`pi_agent_status`、`pi_agent_stop`、`pi_agent_abort`、 `pi_agent_compact` +- `pi_asktool_pending` - `pi_plans_pending`、`pi_plans_resolve` - `pi_workspace_diff`、`pi_fs_list`、`pi_fs_read` +`pi_asktool_pending` 是 `agent/askTool/resolve` 的读取侧。它返回仍在等待回答的 +Agent 问题,并带上 resolve 操作所需的 `requestId`。`sessionId` 是可选过滤器: +不带它时,该操作会列出所有会话的问题,包括属于已配对远端主机的问题 +(`remote::`),因为尚未知道会话 id 的客户端仍须能够 +发现 Agent 正在等待什么。这与 `plans/pending` 已有的跨会话语义一致。注册表仅存在于 +进程内存中:绝不持久化、不写日志、不写入对话记录;问题被回答、回合结束、会话被删除或 +归档、sidecar 崩溃(清掉其正在运行的会话条目),或远端主机连接关闭时,对应条目即消失。 + `pi_control_describe` 返回经过审查的操作目录。`pi_desktop_invoke` 接受操作 id 和位置参数形式的 IPC 参数: diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 5eb4f4533..772bad08b 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -5479,7 +5479,7 @@ eleven-tool-round desktop paths are verified by | M6+(聊天文件引用) | E2E-PLUGIN-file-view-collapse-persists | | M6+(项目文件夹根) | E2E-PLUGIN-file-view-switches-folder-per-project | | 后MVP | E2E-022A、E2E-022B、E2E-022C、E2E-024I、E2E-024J、E2E-024K、E2E-024L、E2E-024M(插件路线图 R2/R3/R6) | -| 基线后本地自动化 | E2E-220 | +| 基线后本地自动化 | E2E-220、E2E-MCP-pending-asktool-questions-are-readable | | MVP 后远程控制 | E2E-221、E2E-222、E2E-223、E2E-224、E2E-225、E2E-226、E2E-227、E2E-228、E2E-229、E2E-230、E2E-231、E2E-232 | | 受信任扩展(R7 v1) | E2E-DIALOG-long-text-boundaries、E2E-241、E2E-242、E2E-HOOKS-cancel-and-dispose、E2E-243、E2E-244、E2E-245、E2E-PLUGIN-imported-pi-package-skills、E2E-PLUGIN-import-extension-installs-dependencies、E2E-PLUGIN-import-extension-reports-missing-dependency、E2E-PLUGIN-declared-provider-appears-in-the-native-provider-list | | 受信任扩展(R7 v1 npm 恢复) | E2E-PLUGIN-import-extension-recovers-missing-npm | @@ -7446,7 +7446,34 @@ eleven-tool-round desktop paths are verified by - **验收**:A(应用控制)、C(会话)、安全、质量 - **里程碑**:M6+ - **状态**:由 `apps/desktop/test/mcp-control.test.mjs` 覆盖 MCP 协议/单元;完整 Electron - 旅程已记录,仍按策略延后 + 旅程已记录,仍按策略延后。待处理问题的读取侧由下方场景覆盖。 + +#### E2E-MCP-pending-asktool-questions-are-readable:远端客户端发现待处理问题 + +- **前提条件**:使用 `PI_DESKTOP_MCP_CONTROL=1` 和干净配置启动 PI-Desktop,已配置模型, + 并存在一个 Agent 会话。可用能让 Agent 打开 asktool 问题的提示词。 +- **步骤**:1)发送提示词,使 Agent 打开一个 asktool 问题并保持等待。2)读取 + `mcp-control.json`,不带 `sessionId` 调用 `pi_asktool_pending`。3)改用等待中会话的 + `sessionId` 重复,再用一个未知会话 id 重复。4)通过 `pi_desktop_invoke` 调用通用 + `agent/askTool/pending` 操作。5)通过 `agent/askTool/resolve` 回答问题后再读一次; + 改为停止回合再重复一次。6)归档该会话后再读一次。7)删除该会话后再读一次。 +- **预期**:步骤 1 后 Agent 停留在桌面卡片可见的问题上。步骤 2 返回 `kind: "pending"`, + 含问题文本、选项,以及 resolve 操作所需的 `requestId`;列表覆盖所有会话桶,包括已配对 + 远端主机的(`remote::`),因此尚不知道会话的客户端仍能发现该 + 请求。步骤 3 只过滤出指定会话;未知会话 id 返回 `kind: "none"` 和空列表,而不是报错。 + 步骤 4 通过已审查的只读目录返回同样的载荷。步骤 5 对已回答的问题和被停止的回合都会移除 + 该条目,因为二者之后都无法再回答。步骤 6 和步骤 7 之后该会话不再有条目。任何列表都不会 + 返回 `sessionId` 与请求不一致的问题,且 `pi_control_describe` 仍不包含密钥写入、 + 原生选择器和 `plugin/loadDev` 通道。 +- **链接规格**:`03-runtime/01-ipc-protocol.md` §13d、 + `03-runtime/17-asktool-questions.md`、`05-security/01-security.md`、ADR 0203、D370 +- **验收**:A(应用控制)、C(对话与流)、安全、质量 +- **里程碑**:M6+ +- **状态**:上述 Electron 旅程为 NOT RUN——它需要运行中的桌面驱动真实 Agent 回合,而 + no-local-E2E 策略将其延后。替代验证:注册表生命周期、已审查的目录条目和远端接入路径由 + `apps/desktop/test/pending-asks.test.mjs`、`apps/desktop/test/mcp-control.test.mjs` + 和 `apps/desktop/test/remote-event-bridge.test.mjs` 覆盖。剩余风险是 HTTP 往返和真实 + asktool 提示路径,单元覆盖未涉及这两者。 ## 受信任扩展场景(R7 v1) diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 7313e9f1b..86254c95c 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -175,6 +175,8 @@ export const IPC = { scheduledExecute: "pi-desktop/scheduled/execute", scheduledListRuns: "pi-desktop/scheduled/listRuns", toolResolvePermission: "pi-desktop/tool/resolvePermission", + /** Process-memory registry of pending Agent asktool questions. */ + askToolPending: "pi-desktop/agent/askTool/pending", askToolResolve: "pi-desktop/agent/askTool/resolve", plansPending: "pi-desktop/plans/pending", plansResolve: "pi-desktop/plans/resolve",